mirror of
https://github.com/fankes/beszel.git
synced 2025-10-18 17:29:28 +08:00
Merge branch 'henrygd:main' into main
This commit is contained in:
@@ -85,7 +85,7 @@ func getToken() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return string(tokenBytes), nil
|
return strings.TrimSpace(string(tokenBytes)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||||
|
@@ -537,4 +537,25 @@ func TestGetToken(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "", token, "Empty file should return empty string")
|
assert.Equal(t, "", token, "Empty file should return empty string")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
tokenWithWhitespace := " test-token-with-whitespace \n\t"
|
||||||
|
expectedToken := "test-token-with-whitespace"
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
|
||||||
|
_, err = tokenFile.WriteString(tokenWithWhitespace)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
2
i18n.yml
2
i18n.yml
@@ -1,3 +1,3 @@
|
|||||||
files:
|
files:
|
||||||
- source: /internal/site/src/locales/en/en.po
|
- source: /internal/site/src/locales/en/
|
||||||
translation: /internal/site/src/locales/%two_letters_code%/%two_letters_code%.po
|
translation: /internal/site/src/locales/%two_letters_code%/%two_letters_code%.po
|
||||||
|
@@ -175,35 +175,31 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
|||||||
|
|
||||||
// custom middlewares
|
// custom middlewares
|
||||||
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
||||||
|
// authorizes request with user matching the provided email
|
||||||
|
authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) {
|
||||||
|
if e.Auth != nil || email == "" {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
|
||||||
|
e.Auth, err = e.App.FindFirstRecordByData("users", "email", email)
|
||||||
|
if err != nil || !isAuthRefresh {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
// auth refresh endpoint, make sure token is set in header
|
||||||
|
token, _ := e.Auth.NewAuthToken()
|
||||||
|
e.Request.Header.Set("Authorization", token)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
// authenticate with trusted header
|
||||||
|
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
|
||||||
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
|
return authorizeRequestWithEmail(e, autoLogin)
|
||||||
|
})
|
||||||
|
}
|
||||||
// authenticate with trusted header
|
// authenticate with trusted header
|
||||||
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
||||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
if e.Auth != nil {
|
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
trustedEmail := e.Request.Header.Get(trustedHeader)
|
|
||||||
if trustedEmail == "" {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
|
|
||||||
if !isAuthRefresh {
|
|
||||||
authRecord, err := e.App.FindAuthRecordByEmail("users", trustedEmail)
|
|
||||||
if err == nil {
|
|
||||||
e.Auth = authRecord
|
|
||||||
}
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
// if auth refresh endpoint, find user record directly and generate token
|
|
||||||
user, err := e.App.FindFirstRecordByData("users", "email", trustedEmail)
|
|
||||||
if err != nil {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
e.Auth = user
|
|
||||||
// need to set the authorization header for the client sdk to pick up the token
|
|
||||||
if token, err := user.NewAuthToken(); err == nil {
|
|
||||||
e.Request.Header.Set("Authorization", token)
|
|
||||||
}
|
|
||||||
return e.Next()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -712,6 +712,60 @@ func TestCreateUserEndpointAvailability(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAutoLoginMiddleware(t *testing.T) {
|
||||||
|
var hubs []*beszelTests.TestHub
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
defer os.Unsetenv("AUTO_LOGIN")
|
||||||
|
for _, hub := range hubs {
|
||||||
|
hub.Cleanup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("AUTO_LOGIN", "user@test.com")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
hubs = append(hubs, hub)
|
||||||
|
hub.StartHub()
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - without auto login should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - with auto login should fail if no matching user",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - with auto login should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"key\":", "\"v\":"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.CreateUser(app, "user@test.com", "password123")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTrustedHeaderMiddleware(t *testing.T) {
|
func TestTrustedHeaderMiddleware(t *testing.T) {
|
||||||
var hubs []*beszelTests.TestHub
|
var hubs []*beszelTests.TestHub
|
||||||
|
|
||||||
|
@@ -17,7 +17,10 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": true,
|
||||||
|
"correctness": {
|
||||||
|
"useUniqueElementIds": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
@@ -35,4 +38,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -22,7 +22,7 @@ import { memo, useEffect, useRef, useState } from "react"
|
|||||||
import { $router, basePath, Link, navigate } from "./router"
|
import { $router, basePath, Link, navigate } from "./router"
|
||||||
import { SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
import { SystemStatus } from "@/lib/enums"
|
import { SystemStatus } from "@/lib/enums"
|
||||||
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
||||||
import { InputCopy } from "./ui/input-copy"
|
import { InputCopy } from "./ui/input-copy"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import {
|
import {
|
||||||
@@ -253,6 +253,12 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
|
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
|
||||||
icons: [WindowsIcon],
|
icons: [WindowsIcon],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: t({ message: "FreeBSD command", context: "Button to copy install command" }),
|
||||||
|
onClick: async () =>
|
||||||
|
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
|
||||||
|
icons: [FreeBsdIcon],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: t`Manual setup instructions`,
|
text: t`Manual setup instructions`,
|
||||||
url: "https://beszel.dev/guide/agent-installation#binary",
|
url: "https://beszel.dev/guide/agent-installation#binary",
|
||||||
|
@@ -9,6 +9,7 @@ import {
|
|||||||
RotateCwIcon,
|
RotateCwIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
|
ExternalLinkIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useEffect, useMemo, useState } from "react"
|
import { memo, useEffect, useMemo, useState } from "react"
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +29,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
|
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -150,6 +151,7 @@ const SectionUniversalToken = memo(() => {
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: only on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateToken()
|
updateToken()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -221,6 +223,16 @@ const ActionsButtonUniversalToken = memo(({ token, checked }: { token: string; c
|
|||||||
onClick: () => copyWindowsCommand(port, publicKey, token),
|
onClick: () => copyWindowsCommand(port, publicKey, token),
|
||||||
icons: [WindowsIcon],
|
icons: [WindowsIcon],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: t({ message: "FreeBSD command", context: "Button to copy install command" }),
|
||||||
|
onClick: () => copyLinuxCommand(port, publicKey, token),
|
||||||
|
icons: [FreeBsdIcon],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t`Manual setup instructions`,
|
||||||
|
url: "https://beszel.dev/guide/agent-installation#binary",
|
||||||
|
icons: [ExternalLinkIcon],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -291,8 +303,8 @@ const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRec
|
|||||||
</tr>
|
</tr>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="whitespace-pre">
|
<TableBody className="whitespace-pre">
|
||||||
{fingerprints.map((fingerprint, i) => (
|
{fingerprints.map((fingerprint) => (
|
||||||
<TableRow key={i}>
|
<TableRow key={fingerprint.id}>
|
||||||
<TableCell className="font-medium ps-5 py-2 max-w-60 truncate">
|
<TableCell className="font-medium ps-5 py-2 max-w-60 truncate">
|
||||||
{fingerprint.expand.system.name}
|
{fingerprint.expand.system.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -317,10 +329,10 @@ async function updateFingerprint(fingerprint: FingerprintRecord, rotateToken = f
|
|||||||
fingerprint: "",
|
fingerprint: "",
|
||||||
token: rotateToken ? generateToken() : fingerprint.token,
|
token: rotateToken ? generateToken() : fingerprint.token,
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: t`Error`,
|
||||||
description: error.message,
|
description: (error as Error).message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
/** biome-ignore-all lint/suspicious/noAssignInExpressions: it's fine :) */
|
||||||
import type { PreinitializedMapStore } from "nanostores"
|
import type { PreinitializedMapStore } from "nanostores"
|
||||||
import { pb, verifyAuth } from "@/lib/api"
|
import { pb, verifyAuth } from "@/lib/api"
|
||||||
import {
|
import {
|
||||||
@@ -16,9 +17,10 @@ const COLLECTION = pb.collection<SystemRecord>("systems")
|
|||||||
const FIELDS_DEFAULT = "id,name,host,port,info,status"
|
const FIELDS_DEFAULT = "id,name,host,port,info,status"
|
||||||
|
|
||||||
/** Maximum system name length for display purposes */
|
/** Maximum system name length for display purposes */
|
||||||
const MAX_SYSTEM_NAME_LENGTH = 20
|
const MAX_SYSTEM_NAME_LENGTH = 22
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
// biome-ignore lint/suspicious/noConfusingVoidType: typescript rocks
|
||||||
let unsub: (() => void) | undefined | void
|
let unsub: (() => void) | undefined | void
|
||||||
|
|
||||||
/** Initialize the systems manager and set up listeners */
|
/** Initialize the systems manager and set up listeners */
|
||||||
@@ -104,20 +106,37 @@ async function fetchSystems(): Promise<SystemRecord[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Makes sure the system has valid info object and throws if not */
|
||||||
|
function validateSystemInfo(system: SystemRecord) {
|
||||||
|
if (!("cpu" in system.info)) {
|
||||||
|
throw new Error(`${system.name} has no CPU info`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Add system to both name and ID stores */
|
/** Add system to both name and ID stores */
|
||||||
export function add(system: SystemRecord) {
|
export function add(system: SystemRecord) {
|
||||||
$allSystemsByName.setKey(system.name, system)
|
try {
|
||||||
$allSystemsById.setKey(system.id, system)
|
validateSystemInfo(system)
|
||||||
|
$allSystemsByName.setKey(system.name, system)
|
||||||
|
$allSystemsById.setKey(system.id, system)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update system in stores */
|
/** Update system in stores */
|
||||||
export function update(system: SystemRecord) {
|
export function update(system: SystemRecord) {
|
||||||
// if name changed, make sure old name is removed from the name store
|
try {
|
||||||
const oldName = $allSystemsById.get()[system.id]?.name
|
validateSystemInfo(system)
|
||||||
if (oldName !== system.name) {
|
// if name changed, make sure old name is removed from the name store
|
||||||
$allSystemsByName.setKey(oldName, undefined as any)
|
const oldName = $allSystemsById.get()[system.id]?.name
|
||||||
|
if (oldName !== system.name) {
|
||||||
|
$allSystemsByName.setKey(oldName, undefined as unknown as SystemRecord)
|
||||||
|
}
|
||||||
|
add(system)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
}
|
}
|
||||||
add(system)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove system from stores */
|
/** Remove system from stores */
|
||||||
@@ -132,7 +151,7 @@ export function remove(system: SystemRecord) {
|
|||||||
/** Remove system from specific store */
|
/** Remove system from specific store */
|
||||||
function removeFromStore(system: SystemRecord, store: PreinitializedMapStore<Record<string, SystemRecord>>) {
|
function removeFromStore(system: SystemRecord, store: PreinitializedMapStore<Record<string, SystemRecord>>) {
|
||||||
const key = store === $allSystemsByName ? system.name : system.id
|
const key = store === $allSystemsByName ? system.name : system.id
|
||||||
store.setKey(key, undefined as any)
|
store.setKey(key, undefined as unknown as SystemRecord)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Action functions for subscription */
|
/** Action functions for subscription */
|
||||||
|
@@ -1,5 +1,13 @@
|
|||||||
## 0.12.8
|
## 0.12.8
|
||||||
|
|
||||||
|
- Add setting for time format (12h / 24h). (#424)
|
||||||
|
|
||||||
|
- Add experimental one-time password (OTP) support.
|
||||||
|
|
||||||
|
- Add `TRUSTED_AUTH_HEADER` environment variable for authentication forwarding. (#399)
|
||||||
|
|
||||||
|
- Add `AUTO_LOGIN` environment variable for automatic login. (#399)
|
||||||
|
|
||||||
- Add FreeBSD support for agent install script and update command.
|
- Add FreeBSD support for agent install script and update command.
|
||||||
|
|
||||||
## 0.12.7
|
## 0.12.7
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "beszel.fullname" . }}-web
|
name: {{ include "beszel.fullname" . }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "beszel.labels" . | nindent 4 }}
|
{{- include "beszel.labels" . | nindent 4 }}
|
||||||
{{- if .Values.service.annotations }}
|
{{- if .Values.service.annotations }}
|
||||||
|
@@ -30,14 +30,10 @@ securityContext: {}
|
|||||||
|
|
||||||
service:
|
service:
|
||||||
enabled: true
|
enabled: true
|
||||||
type: LoadBalancer
|
annotations: {}
|
||||||
loadBalancerIP: "10.0.10.251"
|
type: ClusterIP
|
||||||
|
loadBalancerIP: ""
|
||||||
port: 8090
|
port: 8090
|
||||||
# -- Annotations for the DHCP service
|
|
||||||
annotations:
|
|
||||||
metallb.universe.tf/address-pool: pool
|
|
||||||
metallb.universe.tf/allow-shared-ip: beszel-hub-web
|
|
||||||
# -- Labels for the DHCP service
|
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -96,7 +92,7 @@ persistentVolumeClaim:
|
|||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
|
|
||||||
storageClass: "retain-local-path"
|
storageClass: ""
|
||||||
|
|
||||||
# -- volume claim size
|
# -- volume claim size
|
||||||
size: "500Mi"
|
size: "500Mi"
|
||||||
|
Reference in New Issue
Block a user