diff --git a/internal/hub/agent_connect.go b/internal/hub/agent_connect.go
index 736e2d8d..bf690c6b 100644
--- a/internal/hub/agent_connect.go
+++ b/internal/hub/agent_connect.go
@@ -66,6 +66,15 @@ func (acr *agentConnectRequest) agentConnect() (err error) {
// Check if token is an active universal token
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
+ if !acr.isUniversalToken {
+ // Fallback: check for a permanent universal token stored in the DB
+ if rec, err := acr.hub.FindFirstRecordByFilter("universal_tokens", "token = {:token}", dbx.Params{"token": acr.token}); err == nil {
+ if userID := rec.GetString("user"); userID != "" {
+ acr.userId = userID
+ acr.isUniversalToken = true
+ }
+ }
+ }
// Find matching fingerprint records for this token
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
diff --git a/internal/hub/agent_connect_test.go b/internal/hub/agent_connect_test.go
index c114e4c9..40f66224 100644
--- a/internal/hub/agent_connect_test.go
+++ b/internal/hub/agent_connect_test.go
@@ -1169,6 +1169,106 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
}
}
+// TestPermanentUniversalTokenFromDB verifies that a universal token persisted in the DB
+// (universal_tokens collection) is accepted for agent self-registration even if it is not
+// present in the in-memory universalTokenMap.
+func TestPermanentUniversalTokenFromDB(t *testing.T) {
+ // Create hub and test app
+ hub, testApp, err := createTestHub(t)
+ require.NoError(t, err)
+ defer testApp.Cleanup()
+
+ // Get the hub's SSH key
+ hubSigner, err := hub.GetSSHKey("")
+ require.NoError(t, err)
+ goodPubKey := hubSigner.PublicKey()
+
+ // Create test user
+ userRecord, err := createTestUser(testApp)
+ require.NoError(t, err)
+
+ // Create a permanent universal token record in the DB (do NOT add it to universalTokenMap)
+ universalToken := "db-universal-token-123"
+ _, err = createTestRecord(testApp, "universal_tokens", map[string]any{
+ "user": userRecord.Id,
+ "token": universalToken,
+ })
+ require.NoError(t, err)
+
+ // Create HTTP server with the actual API route
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/api/beszel/agent-connect" {
+ acr := &agentConnectRequest{
+ hub: hub,
+ req: r,
+ res: w,
+ }
+ acr.agentConnect()
+ } else {
+ http.NotFound(w, r)
+ }
+ }))
+ defer ts.Close()
+
+ // Create and configure agent
+ agentDataDir := t.TempDir()
+ err = os.WriteFile(filepath.Join(agentDataDir, "fingerprint"), []byte("db-token-system-fingerprint"), 0644)
+ require.NoError(t, err)
+
+ testAgent, err := agent.NewAgent(agentDataDir)
+ require.NoError(t, err)
+
+ // Set up environment variables for the agent
+ os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
+ os.Setenv("BESZEL_AGENT_TOKEN", universalToken)
+ defer func() {
+ os.Unsetenv("BESZEL_AGENT_HUB_URL")
+ os.Unsetenv("BESZEL_AGENT_TOKEN")
+ }()
+
+ // Start agent in background
+ done := make(chan error, 1)
+ go func() {
+ serverOptions := agent.ServerOptions{
+ Network: "tcp",
+ Addr: "127.0.0.1:46050",
+ Keys: []ssh.PublicKey{goodPubKey},
+ }
+ done <- testAgent.Start(serverOptions)
+ }()
+
+ // Wait for connection result
+ maxWait := 2 * time.Second
+ time.Sleep(20 * time.Millisecond)
+ checkInterval := 20 * time.Millisecond
+ timeout := time.After(maxWait)
+ ticker := time.Tick(checkInterval)
+
+ connectionManager := testAgent.GetConnectionManager()
+ for {
+ select {
+ case <-timeout:
+ t.Fatalf("Expected connection to succeed but timed out - agent state: %d", connectionManager.State)
+ case <-ticker:
+ if connectionManager.State == agent.WebSocketConnected {
+ // Success
+ goto verify
+ }
+ case err := <-done:
+ // If Start returns early, treat it as failure
+ if err != nil {
+ t.Fatalf("Agent failed to start/connect: %v", err)
+ }
+ }
+ }
+
+verify:
+ // Verify that a system was created for the user (self-registration path)
+ systemsAfter, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id})
+ require.NoError(t, err)
+ require.NotEmpty(t, systemsAfter, "Expected a system to be created for DB-backed universal token")
+}
+
// TestFindOrCreateSystemForToken tests the findOrCreateSystemForToken function
func TestFindOrCreateSystemForToken(t *testing.T) {
hub, testApp, err := createTestHub(t)
diff --git a/internal/hub/hub.go b/internal/hub/hub.go
index 28bd07fd..c24688d0 100644
--- a/internal/hub/hub.go
+++ b/internal/hub/hub.go
@@ -20,6 +20,7 @@ import (
"github.com/henrygd/beszel/internal/users"
"github.com/google/uuid"
+ "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -288,24 +289,90 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
userID := e.Auth.Id
query := e.Request.URL.Query()
token := query.Get("token")
+ enable := query.Get("enable")
+ permanent := query.Get("permanent")
+ // helper for deleting any existing permanent token record for this user
+ deletePermanent := func() error {
+ rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
+ if err != nil {
+ return nil // no record
+ }
+ return h.Delete(rec)
+ }
+
+ // helper for upserting a permanent token record for this user
+ upsertPermanent := func(token string) error {
+ rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
+ if err == nil {
+ rec.Set("token", token)
+ return h.Save(rec)
+ }
+
+ col, err := h.FindCachedCollectionByNameOrId("universal_tokens")
+ if err != nil {
+ return err
+ }
+ newRec := core.NewRecord(col)
+ newRec.Set("user", userID)
+ newRec.Set("token", token)
+ return h.Save(newRec)
+ }
+
+ // Disable universal tokens (both ephemeral and permanent)
+ if enable == "0" {
+ tokenMap.RemovebyValue(userID)
+ _ = deletePermanent()
+ return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
+ }
+
+ // Enable universal token (ephemeral or permanent)
+ if enable == "1" {
+ if token == "" {
+ token = uuid.New().String()
+ }
+
+ if permanent == "1" {
+ // make token permanent (persist across restarts)
+ tokenMap.RemovebyValue(userID)
+ if err := upsertPermanent(token); err != nil {
+ return err
+ }
+ return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true})
+ }
+
+ // default: ephemeral mode (1 hour)
+ _ = deletePermanent()
+ tokenMap.Set(token, userID, time.Hour)
+ return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
+ }
+
+ // Read current state
+ // Prefer permanent token if it exists.
+ if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil {
+ dbToken := rec.GetString("token")
+ // If no token was provided, or the caller is asking about their permanent token, return it.
+ if token == "" || token == dbToken {
+ return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true})
+ }
+ // Token doesn't match their permanent token (avoid leaking other info)
+ return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
+ }
+
+ // No permanent token; fall back to ephemeral token map.
if token == "" {
// return existing token if it exists
if token, _, ok := tokenMap.GetByValue(userID); ok {
- return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
+ return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// if no token is provided, generate a new one
token = uuid.New().String()
}
- response := map[string]any{"token": token}
- switch query.Get("enable") {
- case "1":
- tokenMap.Set(token, userID, time.Hour)
- case "0":
- tokenMap.RemovebyValue(userID)
- }
- _, response["active"] = tokenMap.GetOk(token)
+ // Token is considered active only if it belongs to the current user.
+ activeUser, ok := tokenMap.GetOk(token)
+ active := ok && activeUser == userID
+ response := map[string]any{"token": token, "active": active, "permanent": false}
return e.JSON(http.StatusOK, response)
}
diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go
index 2aa67f4c..e4654dad 100644
--- a/internal/hub/hub_test.go
+++ b/internal/hub/hub_test.go
@@ -378,7 +378,18 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken,
},
ExpectedStatus: 200,
- ExpectedContent: []string{"active", "token"},
+ ExpectedContent: []string{"active", "token", "permanent"},
+ TestAppFactory: testAppFactory,
+ },
+ {
+ Name: "GET /universal-token - enable permanent should succeed",
+ Method: http.MethodGet,
+ URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123",
+ Headers: map[string]string{
+ "Authorization": userToken,
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
TestAppFactory: testAppFactory,
},
{
diff --git a/internal/migrations/0_collections_snapshot_0_18_0_dev_1.go b/internal/migrations/0_collections_snapshot_0_18_0_dev_2.go
similarity index 95%
rename from internal/migrations/0_collections_snapshot_0_18_0_dev_1.go
rename to internal/migrations/0_collections_snapshot_0_18_0_dev_2.go
index 320b4eed..79800649 100644
--- a/internal/migrations/0_collections_snapshot_0_18_0_dev_1.go
+++ b/internal/migrations/0_collections_snapshot_0_18_0_dev_2.go
@@ -1617,6 +1617,74 @@ func init() {
"type": "base",
"updateRule": "",
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
+ },
+ {
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{10}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 10,
+ "min": 10,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "cascadeDelete": true,
+ "collectionId": "_pb_users_auth_",
+ "hidden": false,
+ "id": "relation2375276105",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "user",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text1597481275",
+ "max": 0,
+ "min": 0,
+ "name": "token",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_3383022248",
+ "indexes": [
+ "CREATE INDEX ` + "`" + `idx_iaD9Y2Lgbl` + "`" + ` ON ` + "`" + `universal_tokens` + "`" + ` (` + "`" + `token` + "`" + `)",
+ "CREATE UNIQUE INDEX ` + "`" + `idx_wdR0A4PbRG` + "`" + ` ON ` + "`" + `universal_tokens` + "`" + ` (` + "`" + `user` + "`" + `)"
+ ],
+ "listRule": null,
+ "name": "universal_tokens",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
}
]`
diff --git a/internal/site/src/components/routes/settings/tokens-fingerprints.tsx b/internal/site/src/components/routes/settings/tokens-fingerprints.tsx
index 0c23c35e..89fa17d5 100644
--- a/internal/site/src/components/routes/settings/tokens-fingerprints.tsx
+++ b/internal/site/src/components/routes/settings/tokens-fingerprints.tsx
@@ -32,6 +32,7 @@ import {
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/use-toast"
import { isReadOnlyUser, pb } from "@/lib/api"
@@ -137,21 +138,23 @@ const SectionUniversalToken = memo(() => {
const [token, setToken] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [checked, setChecked] = useState(false)
+ const [isPermanent, setIsPermanent] = useState(false)
- async function updateToken(enable: number = -1) {
+ async function updateToken(enable: number = -1, permanent: number = -1) {
// enable: 0 for disable, 1 for enable, -1 (unset) for get current state
const data = await pb.send(`/api/beszel/universal-token`, {
query: {
token,
enable,
+ permanent,
},
})
setToken(data.token)
setChecked(data.active)
+ setIsPermanent(!!data.permanent)
setIsLoading(false)
}
- // biome-ignore lint/correctness/useExhaustiveDependencies: only on mount
useEffect(() => {
updateToken()
}, [])
@@ -162,30 +165,64 @@ const SectionUniversalToken = memo(() => {
-
+
+