diff --git a/agent/client.go b/agent/client.go index 19933c1..87449cf 100644 --- a/agent/client.go +++ b/agent/client.go @@ -85,7 +85,7 @@ func getToken() (string, error) { if err != nil { return "", err } - return string(tokenBytes), nil + return strings.TrimSpace(string(tokenBytes)), nil } // getOptions returns the WebSocket client options, creating them if necessary. diff --git a/agent/client_test.go b/agent/client_test.go index 2edc203..5741884 100644 --- a/agent/client_test.go +++ b/agent/client_test.go @@ -537,4 +537,25 @@ func TestGetToken(t *testing.T) { assert.NoError(t, err) 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") + }) } diff --git a/i18n.yml b/i18n.yml index c5882ca..ec7b8cb 100644 --- a/i18n.yml +++ b/i18n.yml @@ -1,3 +1,3 @@ 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 diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 89fb4a9..890abae 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -175,35 +175,31 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error { // custom middlewares 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 if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" { se.Router.BindFunc(func(e *core.RequestEvent) error { - if e.Auth != nil { - 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() + return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader)) }) } } diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index 47b5300..2b8762e 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -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) { var hubs []*beszelTests.TestHub diff --git a/internal/site/biome.json b/internal/site/biome.json index 330e804..14bd3e8 100644 --- a/internal/site/biome.json +++ b/internal/site/biome.json @@ -17,7 +17,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "correctness": { + "useUniqueElementIds": "off" + } } }, "javascript": { @@ -35,4 +38,4 @@ } } } -} \ No newline at end of file +} diff --git a/internal/site/src/components/add-system.tsx b/internal/site/src/components/add-system.tsx index 9613830..9f1e3dc 100644 --- a/internal/site/src/components/add-system.tsx +++ b/internal/site/src/components/add-system.tsx @@ -22,7 +22,7 @@ import { memo, useEffect, useRef, useState } from "react" import { $router, basePath, Link, navigate } from "./router" import { SystemRecord } from "@/types" 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 { getPagePath } from "@nanostores/router" import { @@ -253,6 +253,12 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) => copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token), 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`, url: "https://beszel.dev/guide/agent-installation#binary", diff --git a/internal/site/src/components/routes/settings/tokens-fingerprints.tsx b/internal/site/src/components/routes/settings/tokens-fingerprints.tsx index 502525b..7a98894 100644 --- a/internal/site/src/components/routes/settings/tokens-fingerprints.tsx +++ b/internal/site/src/components/routes/settings/tokens-fingerprints.tsx @@ -9,6 +9,7 @@ import { RotateCwIcon, ServerIcon, Trash2Icon, + ExternalLinkIcon, } from "lucide-react" import { memo, useEffect, useMemo, useState } from "react" import { @@ -28,7 +29,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } 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 { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" @@ -150,6 +151,7 @@ const SectionUniversalToken = memo(() => { setIsLoading(false) } + // biome-ignore lint/correctness/useExhaustiveDependencies: only on mount useEffect(() => { updateToken() }, []) @@ -221,6 +223,16 @@ const ActionsButtonUniversalToken = memo(({ token, checked }: { token: string; c onClick: () => copyWindowsCommand(port, publicKey, token), 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 (