Merge branch 'develop' into feature/batch-edit

This commit is contained in:
Michael Mayer
2025-09-26 11:02:08 +02:00
13 changed files with 312 additions and 104 deletions

View File

@@ -1,6 +1,6 @@
# PhotoPrism® Repository Guidelines
**Last Updated:** September 25, 2025
**Last Updated:** September 26, 2025
## Purpose
@@ -197,7 +197,7 @@ Note: Across our public documentation, official images, and in production, the c
- Explicit “replace” actions or admin tools where the user confirmed overwrite.
- Not for import/index flows; Originals must not be clobbered.
- ### Archive Extraction — Security Checklist
### Archive Extraction — Security Checklist
- Always validate ZIP entry names with a safe join; reject:
- absolute paths (e.g., `/etc/passwd`).
@@ -231,11 +231,8 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
## Agent Quick Tips (Do This)
### NextSession Priorities
- If we add Postgres provisioning support, extend `BuildDSN` and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API/CLI.
- Consider surfacing a short “uuid → db/user” mapping helper in the CLI (e.g., `nodes show --creds`) if operators request it.
### Testing & Fixtures
- Go tests live next to their sources (`path/to/pkg/<file>_test.go`); group related cases as `t.Run(...)` sub-tests to keep table-driven coverage readable.
- Prefer focused `go test` runs for speed (`go test ./internal/<pkg> -run <Name> -count=1`, `go test ./internal/commands -run <Name> -count=1`) and avoid `./...` unless you need the entire suite.
- Heavy packages such as `internal/entity` and `internal/photoprism` run migrations and fixtures; expect 30120s on first run and narrow with `-run` to keep iterations low.
@@ -244,8 +241,10 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
- Generate identifiers with `rnd.GenerateUID(entity.ClientUID)` for OAuth client IDs and `rnd.UUIDv7()` for node UUIDs; treat `node.uuid` as required in responses.
- Shared fixtures live under `storage/testdata`; `NewTestConfig("<pkg>")` already calls `InitializeTestData()`, but call `c.InitializeTestData()` (and optionally `c.AssertTestData(t)`) when you construct custom configs so originals/import/cache/temp exist. `InitializeTestData()` clears old data, downloads fixtures if needed, then calls `CreateDirectories()`.
- For slimmer tests that only need config objects, prefer the new helpers in `internal/config/test.go`: `NewMinimalTestConfig(t.TempDir())` when no database is needed, or `NewMinimalTestConfigWithDb("<pkg>", t.TempDir())` to spin up an isolated SQLite schema without seeding all fixtures.
- When you need illustrative credentials (join tokens, client IDs/secrets, etc.), reuse the shared `Example*` constants (see `internal/service/cluster/examples.go`) so tests, docs, and examples stay consistent.
### Roles & ACL
- Map roles via the shared tables: users through `acl.ParseRole(s)` / `acl.UserRoles[...]`, clients through `acl.ClientRoles[...]`.
- Treat `RoleAliasNone` ("none") and an empty string as `RoleNone`; no caller-specific overrides.
- Default unknown client roles to `RoleClient`; `acl.ParseRole` already handles `0/false/nil` as none for users.
@@ -258,6 +257,7 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
- Mixed roots: when testing related files, keep `ExamplesPath()/ImportPath()/OriginalsPath()` consistent so `RelatedFiles` and `AllowExt` behave as expected.
### CLI Usage & Assertions
- Wrap CLI tests in `RunWithTestContext(cmd, args)` so `urfave/cli` cannot exit the process; assert quoted `show` output with `assert.Contains`/regex for the trailing ", or <last>" rule.
- Prefer `--json` responses for automation. `photoprism show commands --json [--nested]` exposes the tree view (add `--all` for hidden entries).
- Use `internal/commands/catalog` to inspect commands/flags without running the binary; when validating large JSON docs, marshal DTOs via `catalog.BuildFlat/BuildNode` instead of parsing CLI stdout.
@@ -355,4 +355,5 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
- Registration flow: send `rotate=true` only for MySQL/MariaDB nodes without credentials, treat 401/403/404 as terminal, include `clientId` + `clientSecret` when renaming an existing node, and persist only newly generated secrets or DB settings.
- Registry & DTOs: use the client-backed registry (`NewClientRegistryWithConfig`)—the file-backed version is legacy—and treat migration as complete only after swapping callsites, building, and running focused API/CLI tests. Nodes are keyed by UUID v7 (`/api/v1/cluster/nodes/{uuid}`), the registry interface stays UUID-first (`Get`, `FindByNodeUUID`, `FindByClientID`, `RotateSecret`, `DeleteAllByUUID`), CLI lookups resolve `uuid → clientId → name`, and DTOs normalize `database.{name,user,driver,rotatedAt}` while exposing `clientSecret` only during creation/rotation. `nodes rm --all-ids` cleans duplicate client rows, admin responses may include `advertiseUrl`/`database`, client/user sessions stay redacted, registry files live under `conf.PortalConfigPath()/nodes/` (mode 0600), and `ClientData` no longer stores `NodeUUID`.
- Provisioner & DSN: database/user names use UUID-based HMACs (`photoprism_d<hmac11>`, `photoprism_u<hmac11>`); `BuildDSN` accepts a `driver` but falls back to MySQL format with a warning when unsupported.
- If we add Postgres provisioning support, extend `BuildDSN` and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API/CLI.
- Testing: exercise Portal endpoints with `httptest`, guard extraction paths with `pkg/fs.Unzip` size caps, and expect admin-only fields to disappear when authenticated as a client/user session.

View File

@@ -25,7 +25,7 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("ExistingClientRequiresSecret", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Pre-create a node via registry and rotate to get a plaintext secret for tests
@@ -39,17 +39,17 @@ func TestClusterNodesRegister(t *testing.T) {
// Missing secret → 401
body := `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `"}`
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusUnauthorized, r.Code)
// Wrong secret → 401
body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"WRONG"}`
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusUnauthorized, r.Code)
// Correct secret → 200 (existing-node path)
body = `{"nodeName":"pp-auth","clientId":"` + nr.ClientID + `","clientSecret":"` + secret + `"}`
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("MissingToken", func(t *testing.T) {
@@ -63,12 +63,12 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("CreateNode_SucceedsWithProvisioner", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Provisioner is independent of the main DB; with MariaDB admin DSN configured
// it should successfully provision and return 201.
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
body := r.Body.String()
assert.Contains(t, body, "\"database\"")
@@ -79,7 +79,7 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("UUIDChangeRequiresSecret", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
regy, err := reg.NewClientRegistryWithConfig(conf)
@@ -91,56 +91,56 @@ func TestClusterNodesRegister(t *testing.T) {
// Attempt to change UUID via name without client credentials → 409
newUUID := rnd.UUIDv7()
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-lock","nodeUUID":"`+newUUID+`"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-lock","nodeUUID":"`+newUUID+`"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusConflict, r.Code)
})
t.Run("BadAdvertiseUrlRejected", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// http scheme for public host must be rejected (require https unless localhost).
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-03","advertiseUrl":"http://example.com"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-03","advertiseUrl":"http://example.com"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("GoodAdvertiseUrlAccepted", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// https is allowed for public host
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04","advertiseUrl":"https://example.com"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04","advertiseUrl":"https://example.com"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
// http is allowed for localhost
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04b","advertiseUrl":"http://localhost:2342"}`, "t0k3n")
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-04b","advertiseUrl":"http://localhost:2342"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
})
t.Run("SiteUrlValidation", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Reject http siteUrl for public host
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-05","siteUrl":"http://example.com"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-05","siteUrl":"http://example.com"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusBadRequest, r.Code)
// Accept https siteUrl
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-06","siteUrl":"https://photos.example.com"}`, "t0k3n")
r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-06","siteUrl":"https://photos.example.com"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
})
t.Run("NormalizeName", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Mixed separators and case should normalize to DNS label
body := `{"nodeName":"My.Node/Name:Prod"}`
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
regy, err := reg.NewClientRegistryWithConfig(conf)
@@ -154,17 +154,17 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("BadName", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Empty nodeName → 400
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":""}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Pre-create node in registry so handler goes through existing-node path
@@ -176,7 +176,7 @@ func TestClusterNodesRegister(t *testing.T) {
n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance"}}
assert.NoError(t, regy.Put(n))
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r.Code)
// Secret should have rotated and been persisted even though DB ensure failed.
@@ -190,7 +190,7 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("ExistingNodeSiteUrlPersistsAndRespondsOK", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Pre-create node in registry so handler goes through existing-node path.
@@ -200,7 +200,7 @@ func TestClusterNodesRegister(t *testing.T) {
assert.NoError(t, regy.Put(n))
// Provisioner is independent; endpoint should respond 200 and persist metadata.
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-02","siteUrl":"https://Photos.Example.COM"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-02","siteUrl":"https://Photos.Example.COM"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r.Code)
// Ensure normalized/persisted siteUrl.
@@ -211,11 +211,11 @@ func TestClusterNodesRegister(t *testing.T) {
t.Run("AssignNodeUUIDWhenMissing", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = "t0k3n"
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
// Register without nodeUUID; server should assign one (UUID v7 preferred).
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-uuid"}`, "t0k3n")
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-uuid"}`, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
// Response must include node.uuid

View File

@@ -5,11 +5,13 @@ import (
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/service/cluster"
)
func TestExitCodes_Register_ValidationAndUnauthorized(t *testing.T) {
t.Run("MissingURL", func(t *testing.T) {
ctx := NewTestContext([]string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", "token"})
ctx := NewTestContext([]string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", cluster.ExampleJoinToken})
err := ClusterRegisterCommand.Action(ctx)
assert.Error(t, err)
if ec, ok := err.(cli.ExitCoder); ok {

View File

@@ -14,6 +14,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
)
func TestClusterRegister_HTTPHappyPath(t *testing.T) {
@@ -23,7 +24,7 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -32,7 +33,7 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n1", "name": "pp-node-02", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd", "dsn": "user:pwd@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": "secret", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": cluster.ExampleClientSecret, "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": false,
"alreadyProvisioned": false,
})
@@ -40,12 +41,12 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-02", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
"register", "--name", "pp-node-02", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--json",
})
assert.NoError(t, err)
// Parse JSON
assert.Equal(t, "pp-node-02", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret", gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, cluster.ExampleClientSecret, gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, "pwd", gjson.Get(out, "database.password").String())
dsn := gjson.Get(out, "database.dsn").String()
parsed := cfg.NewDSN(dsn)
@@ -58,12 +59,13 @@ func TestClusterRegister_HTTPHappyPath(t *testing.T) {
func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
// Fake Portal register endpoint for rotation
secret := cluster.ExampleClientSecret
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -72,7 +74,7 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n1", "name": "pp-node-03", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd2", "dsn": "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": "secret2", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -80,13 +82,13 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
defer os.Unsetenv("PHOTOPRISM_CLI")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--secret", "--yes", "pp-node-03",
"rotate", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--secret", "--yes", "pp-node-03",
})
assert.NoError(t, err)
assert.Contains(t, out, "pp-node-03")
@@ -96,12 +98,13 @@ func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
func TestClusterNodesRotate_HTTPJson(t *testing.T) {
// Fake Portal register endpoint for rotation in JSON mode
secret := cluster.ExampleClientSecret
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -110,7 +113,7 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n2", "name": "pp-node-04", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd3", "dsn": "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": "secret3", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -118,7 +121,7 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
@@ -128,7 +131,7 @@ func TestClusterNodesRotate_HTTPJson(t *testing.T) {
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-04", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret3", gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, "pwd3", gjson.Get(out, "database.password").String())
dsn := gjson.Get(out, "database.dsn").String()
parsed := cfg.NewDSN(dsn)
@@ -145,7 +148,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -171,7 +174,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
_ = os.Setenv("PHOTOPRISM_YES", "true")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
@@ -193,12 +196,13 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
}
func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
secret := cluster.ExampleClientSecret
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -215,7 +219,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n4", "name": "pp-node-06", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": "secret4", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -223,7 +227,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
@@ -231,7 +235,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-06", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret4", gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, "", gjson.Get(out, "database.password").String())
}
@@ -258,7 +262,7 @@ func TestClusterRegister_HTTPConflict(t *testing.T) {
defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-conflict", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
"register", "--name", "pp-node-conflict", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--json",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 5, ec.ExitCode())
@@ -302,7 +306,7 @@ func TestClusterRegister_HTTPBadRequest(t *testing.T) {
defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp node invalid", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--json",
"register", "--name", "pp node invalid", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--json",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 2, ec.ExitCode())
@@ -331,7 +335,7 @@ func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-rl", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--rotate", "--json",
"register", "--name", "pp-node-rl", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-rl", gjson.Get(out, "node.name").String())
@@ -360,7 +364,7 @@ func TestClusterNodesRotate_HTTPConflict_JSON(t *testing.T) {
defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp-node-x",
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp-node-x",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 5, ec.ExitCode())
@@ -376,7 +380,7 @@ func TestClusterNodesRotate_HTTPBadRequest_JSON(t *testing.T) {
defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp node invalid",
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp node invalid",
})
if ec, ok := err.(cli.ExitCoder); ok {
assert.Equal(t, 2, ec.ExitCode())
@@ -405,7 +409,7 @@ func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) {
defer ts.Close()
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=test-token", "--db", "--yes", "pp-node-rl2",
"rotate", "--json", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken, "--db", "--yes", "pp-node-rl2",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-rl2", gjson.Get(out, "node.name").String())
@@ -417,7 +421,7 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -438,7 +442,7 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-07", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--rotate", "--json",
"register", "--name", "pp-node-07", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-07", gjson.Get(out, "node.name").String())
@@ -453,12 +457,13 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
}
func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
secret := cluster.ExampleClientSecret
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -472,7 +477,7 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n6", "name": "pp-node-08", "role": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"database": map[string]any{"host": "database", "port": 3306, "name": "pp_db", "user": "pp_user", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": "pwd8secret", "rotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"clientSecret": secret, "rotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
@@ -480,10 +485,10 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-08", "--role", "instance", "--portal-url", ts.URL, "--join-token", "test-token", "--rotate-secret", "--json",
"register", "--name", "pp-node-08", "--role", "instance", "--portal-url", ts.URL, "--join-token", cluster.ExampleJoinToken, "--rotate-secret", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-08", gjson.Get(out, "node.name").String())
assert.Equal(t, "pwd8secret", gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, secret, gjson.Get(out, "secrets.clientSecret").String())
assert.Equal(t, "", gjson.Get(out, "database.password").String())
}

View File

@@ -64,7 +64,7 @@ func TestClusterThemePullCommand(t *testing.T) {
func TestClusterRegisterCommand(t *testing.T) {
t.Run("ValidationMissingURL", func(t *testing.T) {
out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", "token"})
out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--role", "instance", "--join-token", cluster.ExampleJoinToken})
assert.Error(t, err)
_ = out
})
@@ -124,7 +124,7 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -140,11 +140,11 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_JOIN_TOKEN", cluster.ExampleJoinToken)
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_JOIN_TOKEN")
out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--join-token=test-token"})
out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--join-token=" + cluster.ExampleJoinToken})
assert.NoError(t, err)
// Expect extracted file
assert.FileExists(t, filepath.Join(destDir, "test.txt"))

View File

@@ -81,7 +81,7 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
switch r.URL.Path {
case "/api/v1/cluster/nodes/register":
// Must have Bearer join token
if r.Header.Get("Authorization") != "Bearer jt" {
if r.Header.Get("Authorization") != "Bearer "+cluster.ExampleJoinToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -97,12 +97,12 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
UUID: rnd.UUID(),
ClusterCIDR: "203.0.113.0/24",
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
Node: cluster.Node{ClientID: cluster.ExampleClientID, Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: cluster.ExampleClientSecret},
})
case "/api/v1/oauth/token":
// Expect Basic for the returned creds
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("cs5gfen1bgxz7s9i:s3cr3t")) {
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte(cluster.ExampleClientID+":"+cluster.ExampleClientSecret)) {
w.WriteHeader(http.StatusUnauthorized)
return
}
@@ -125,7 +125,7 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{
"pull", "--dest", dest, "-f",
"--portal-url=" + ts.URL,
"--join-token=jt",
"--join-token=" + cluster.ExampleJoinToken,
})
_ = out
assert.NoError(t, err)

View File

@@ -105,18 +105,54 @@ func (c *Config) PortalThemePath() string {
return c.ThemePath()
}
// JoinToken returns the token required to access the portal API endpoints.
// JoinToken returns the token required to use the node register API endpoint.
// Example: k9sEFe6-A7gt6zqm-gY9gFh0
func (c *Config) JoinToken() string {
if c.options.JoinToken != "" {
return c.options.JoinToken
} else if fileName := FlagFilePath("JOIN_TOKEN"); fileName == "" {
return ""
} else if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: failed to read portal token from %s (%s)", fileName, err)
return ""
} else {
return string(b)
if s := strings.TrimSpace(c.options.JoinToken); rnd.IsJoinToken(s, false) {
c.options.JoinToken = s
return s
}
if fileName := FlagFilePath("JOIN_TOKEN"); fileName != "" && fs.FileExistsNotEmpty(fileName) {
if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: could not read portal token from %s (%s)", fileName, err)
} else if s := strings.TrimSpace(string(b)); rnd.IsJoinToken(s, false) {
return s
} else {
log.Warnf("config: portal join token from %s is shorter than %d characters", fileName, rnd.JoinTokenLength)
}
}
if !c.IsPortal() {
return ""
}
fileName := filepath.Join(c.PortalConfigPath(), "secrets", "join_token")
if fs.FileExistsNotEmpty(fileName) {
if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: could not read portal token from %s (%s)", fileName, err)
} else if s := strings.TrimSpace(string(b)); rnd.IsJoinToken(s, false) {
c.options.JoinToken = s
return s
} else {
log.Warnf("config: portal join token stored in %s is shorter than %d characters; generating a new one", fileName, rnd.JoinTokenLength)
}
}
token := rnd.JoinToken()
if !rnd.IsJoinToken(token, true) {
return ""
}
if err := fs.WriteFile(fileName, []byte(token), fs.ModeSecretFile); err != nil {
log.Errorf("config: could not write portal join token (%s)", err)
return ""
}
c.options.JoinToken = token
return token
}
// deriveNodeNameAndDomainFromHttpHost attempts to derive cluster host and domain name from the site URL.

View File

@@ -15,6 +15,8 @@ import (
"github.com/photoprism/photoprism/pkg/rnd"
)
const shortTestJoinToken = "short-token"
func TestConfig_PortalUrl(t *testing.T) {
t.Run("Unset", func(t *testing.T) {
c := NewConfig(CliTestContext())
@@ -23,6 +25,32 @@ func TestConfig_PortalUrl(t *testing.T) {
assert.Equal(t, "", c.PortalUrl())
c.options.PortalUrl = DefaultPortalUrl
})
t.Run("JoinTokenTooShort", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.JoinToken = shortTestJoinToken
assert.Equal(t, "", c.JoinToken())
})
t.Run("PortalAutoGeneratesJoinToken", func(t *testing.T) {
tempCfg := t.TempDir()
ctx := CliTestContext()
assert.NoError(t, ctx.Set("config-path", tempCfg))
c := NewConfig(ctx)
c.options.NodeRole = cluster.RolePortal
c.options.JoinToken = ""
token := c.JoinToken()
assert.NotEmpty(t, token)
assert.GreaterOrEqual(t, len(token), rnd.JoinTokenLength)
assert.True(t, rnd.IsJoinToken(token, false))
assert.True(t, rnd.IsJoinToken(token, true))
secretFile := filepath.Join(c.PortalConfigPath(), "secrets", "join_token")
assert.FileExists(t, secretFile)
info, err := os.Stat(secretFile)
assert.NoError(t, err)
assert.Equal(t, fs.ModeSecretFile, info.Mode().Perm())
assert.Equal(t, token, c.JoinToken())
})
t.Run("Default", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.PortalUrl = DefaultPortalUrl
@@ -209,11 +237,11 @@ func TestConfig_Cluster(t *testing.T) {
// Set and read back values
c.options.PortalUrl = "https://portal.example.test"
c.options.JoinToken = "join-token"
c.options.JoinToken = cluster.ExampleJoinToken
c.options.NodeClientSecret = "node-secret"
assert.Equal(t, "https://portal.example.test", c.PortalUrl())
assert.Equal(t, "join-token", c.JoinToken())
assert.Equal(t, cluster.ExampleJoinToken, c.JoinToken())
assert.Equal(t, "node-secret", c.NodeClientSecret())
})
t.Run("AbsolutePaths", func(t *testing.T) {
@@ -298,8 +326,8 @@ func TestConfig_Cluster(t *testing.T) {
dir := t.TempDir()
nsFile := filepath.Join(dir, "node_client_secret")
tkFile := filepath.Join(dir, "portal_token")
assert.NoError(t, os.WriteFile(nsFile, []byte("s3cr3t"), 0o600))
assert.NoError(t, os.WriteFile(tkFile, []byte("t0k3n"), 0o600))
assert.NoError(t, os.WriteFile(nsFile, []byte(cluster.ExampleClientSecret), fs.ModeSecretFile))
assert.NoError(t, os.WriteFile(tkFile, []byte(cluster.ExampleJoinTokenAlt), fs.ModeSecretFile))
// Clear inline values so file-based lookup is used.
c.options.NodeClientSecret = ""
@@ -308,8 +336,8 @@ func TestConfig_Cluster(t *testing.T) {
// Point env vars at the files and verify.
t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", nsFile)
t.Setenv("PHOTOPRISM_JOIN_TOKEN_FILE", tkFile)
assert.Equal(t, "s3cr3t", c.NodeClientSecret())
assert.Equal(t, "t0k3n", c.JoinToken())
assert.Equal(t, cluster.ExampleClientSecret, c.NodeClientSecret())
assert.Equal(t, cluster.ExampleJoinTokenAlt, c.JoinToken())
// Empty / missing should yield empty strings.
t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", filepath.Join(dir, "missing"))

View File

@@ -689,7 +689,7 @@ var Flags = CliFlags{
}}, {
Flag: &cli.StringFlag{
Name: "join-token",
Usage: "secret `TOKEN` required to join the cluster",
Usage: "secret `TOKEN` required to join a cluster; min 24 chars",
EnvVars: EnvVars("JOIN_TOKEN"),
}}, {
Flag: &cli.StringFlag{

View File

@@ -0,0 +1,13 @@
package cluster
// Example values used by documentation and tests to illustrate cluster tokens.
const (
// ExampleJoinToken represents a valid portal join token.
ExampleJoinToken = "pGVplw8-eISgkdQN-Mep62nQ"
// ExampleJoinTokenAlt provides an alternative join token for negative/rotation tests.
ExampleJoinTokenAlt = "k9sEFe6-A7gt6zqm-gY9gFh0"
// ExampleClientID is a sample node client identifier issued by the portal.
ExampleClientID = "cs5gfen1bgxz7s9i"
// ExampleClientSecret is a sample node client secret matching the format generated by rnd.ClientSecret().
ExampleClientSecret = "A1B2C3D4E5F6G7H8J9K0L1M2N3P4Q5R6"
)

View File

@@ -49,7 +49,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
Node: cluster.Node{Name: "pp-node-01"},
UUID: rnd.UUID(),
ClusterCIDR: "192.0.2.0/24",
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
Secrets: &cluster.RegisterSecrets{ClientSecret: cluster.ExampleClientSecret},
JWKSUrl: jwksURL,
Database: cluster.RegisterDatabase{
Driver: config.MySQL,
@@ -77,7 +77,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
// Configure Portal.
c.Options().PortalUrl = srv.URL
c.Options().JoinToken = "t0k3n"
c.Options().JoinToken = cluster.ExampleJoinToken
// Gate rotate=true: driver mysql and no DSN/fields.
c.Options().DatabaseDriver = config.MySQL
c.Options().DatabaseDSN = ""
@@ -89,7 +89,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
assert.NoError(t, InitConfig(c))
// Options should be reloaded; check values.
assert.Equal(t, "SECRET", c.NodeClientSecret())
assert.Equal(t, cluster.ExampleClientSecret, c.NodeClientSecret())
// DSN branch should be preferred and persisted.
assert.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db")
assert.Equal(t, config.MySQL, c.Options().DatabaseDriver)
@@ -106,13 +106,14 @@ func TestThemeInstall_Missing(t *testing.T) {
_ = zw.Close()
// Fake Portal server (register -> oauth token -> theme)
clientSecret := cluster.ExampleClientSecret
var jwksURL2 string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/cluster/nodes/register":
w.Header().Set("Content-Type", "application/json")
// Return NodeClientID + NodeClientSecret so bootstrap can request OAuth token
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), ClusterCIDR: "198.51.100.0/24", Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}, JWKSUrl: jwksURL2})
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), ClusterCIDR: "198.51.100.0/24", Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: clientSecret}, JWKSUrl: jwksURL2})
case "/api/v1/oauth/token":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"})
@@ -132,7 +133,7 @@ func TestThemeInstall_Missing(t *testing.T) {
// Point Portal.
c.Options().PortalUrl = srv.URL
c.Options().JoinToken = "t0k3n"
c.Options().JoinToken = cluster.ExampleJoinToken
// Ensure theme dir is empty and unique.
tempTheme, err := os.MkdirTemp("", "pp-theme-*")
@@ -160,7 +161,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
w.WriteHeader(http.StatusCreated)
resp := cluster.RegisterResponse{
Node: cluster.Node{Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
Secrets: &cluster.RegisterSecrets{ClientSecret: cluster.ExampleClientSecret},
ClusterCIDR: "203.0.113.0/24",
JWKSUrl: jwksURL3,
Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
@@ -178,7 +179,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
// SQLite driver by default; set Portal.
c.Options().PortalUrl = srv.URL
c.Options().JoinToken = "t0k3n"
c.Options().JoinToken = cluster.ExampleJoinToken
// Remember original DSN so we can ensure it is not changed.
origDSN := c.Options().DatabaseDSN
t.Cleanup(func() { _ = os.Remove(origDSN) })
@@ -187,7 +188,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
assert.NoError(t, InitConfig(c))
// NodeClientSecret should persist, but DB should remain SQLite (no DSN update).
assert.Equal(t, "SECRET", c.NodeClientSecret())
assert.Equal(t, cluster.ExampleClientSecret, c.NodeClientSecret())
assert.Equal(t, config.SQLite3, c.DatabaseDriver())
assert.Equal(t, origDSN, c.Options().DatabaseDSN)
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
@@ -210,7 +211,7 @@ func TestRegister_404_NoRetry(t *testing.T) {
defer c.CloseDb()
c.Options().PortalUrl = srv.URL
c.Options().JoinToken = "t0k3n"
c.Options().JoinToken = cluster.ExampleJoinToken
// Run bootstrap; registration should attempt once and stop on 404.
_ = InitConfig(c)

View File

@@ -10,12 +10,16 @@ import (
)
const (
SessionIdLength = 64
AuthTokenLength = 48
AppPasswordLength = 27
AppPasswordSeparator = '-'
SessionIdLength = 64
AuthTokenLength = 48
JoinTokenLength = 24
AppPasswordLength = 27
Separator = '-'
)
// joinTokenSeparators determines where token separators (hyphens) appear.
var joinTokenSeparators = [...]int{7, 16}
// AuthToken generates a random hexadecimal character token for authenticating client applications.
//
// Examples: 9fa8e562564dac91b96881040e98f6719212a1a364e0bb25
@@ -49,7 +53,7 @@ func AppPassword() string {
for i := 0; i < AppPasswordLength; i++ {
if (i+1)%7 == 0 {
b = append(b, AppPasswordSeparator)
b = append(b, Separator)
} else if i == AppPasswordLength-1 {
b = append(b, checksum.Char(b))
return string(b)
@@ -71,7 +75,7 @@ func IsAppPassword(s string, verifyChecksum bool) bool {
// Check characters.
sep := 0
for _, r := range s {
if r == AppPasswordSeparator {
if r == Separator {
sep++
} else if (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
return false
@@ -89,6 +93,78 @@ func IsAppPassword(s string, verifyChecksum bool) bool {
return s[AppPasswordLength-1] == checksum.Char([]byte(s[:AppPasswordLength-1]))
}
// JoinToken generates a random, human-friendly cluster join token.
// The token has a length of 24 characters and is separated by 2 dashes.
//
// Example: pGVplw8-eISgkdQN-Mep62nQ
func JoinToken() string {
m := big.NewInt(int64(len(CharsetBase62)))
token := make([]byte, 0, JoinTokenLength)
for i := 0; i < JoinTokenLength-1; i++ {
if isJoinTokenSeparatorIndex(i) {
token = append(token, Separator)
continue
}
ch := CharsetBase62[0]
if r, err := rand.Int(rand.Reader, m); err == nil {
ch = CharsetBase62[r.Int64()]
}
token = append(token, ch)
}
token = append(token, checksum.Char(token))
return string(token)
}
func isJoinTokenSeparatorIndex(i int) bool {
for _, pos := range joinTokenSeparators {
if i == pos {
return true
}
}
return false
}
// IsJoinToken checks if the string represents a join token.
func IsJoinToken(s string, strict bool) bool {
// Non-strict mode: only enforce minimum length so legacy tokens that were
// longer than the auto-generated format continue to work.
if !strict {
return len(s) >= JoinTokenLength
}
// Strict validation enforces canonical formatting and checksum.
if len(s) != JoinTokenLength {
return false
}
sep := 0
for idx, r := range s {
if r == Separator {
if !isJoinTokenSeparatorIndex(idx) {
return false
}
sep++
continue
}
if (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
return false
}
}
if sep != len(joinTokenSeparators) {
return false
}
return s[JoinTokenLength-1] == checksum.Char([]byte(s[:JoinTokenLength-1]))
}
// IsAuthAny checks if the string represents a valid auth token or app password.
func IsAuthAny(s string) bool {
// Check if string might be a regular auth token.

View File

@@ -104,6 +104,52 @@ func BenchmarkAppPasswordIgnoreChecksum(b *testing.B) {
}
}
func TestJoinToken(t *testing.T) {
for n := 0; n < 10; n++ {
s := JoinToken()
t.Logf("JoinToken %d: %s", n, s)
assert.Equal(t, JoinTokenLength, len(s))
assert.True(t, IsJoinToken(s, true))
}
}
func TestIsJoinToken(t *testing.T) {
t.Run("VerifyChecksum", func(t *testing.T) {
assert.True(t, IsJoinToken("pGVplw8-eISgkdQN-Mep62nQ", true))
assert.True(t, IsJoinToken("k9sEFe6-A7gt6zqm-gY9gFh0", true))
assert.False(t, IsJoinToken("M2hMhlx-f4bD1zCQ-VklHch1", true))
assert.False(t, IsJoinToken("oWIEZ6e-FnKWzaGz-vkTBf84", true))
assert.True(t, IsJoinToken(JoinToken(), true))
assert.True(t, IsJoinToken(JoinToken(), true))
assert.False(t, IsJoinToken(AppPassword(), true))
assert.False(t, IsJoinToken(AppPassword(), true))
assert.False(t, IsJoinToken(AuthToken(), true))
assert.False(t, IsJoinToken(AuthToken(), true))
assert.False(t, IsJoinToken(SessionID(AuthToken()), true))
assert.False(t, IsJoinToken(SessionID(AuthToken()), true))
assert.False(t, IsJoinToken("pGVplw8e-ISgkdQN-Mep62nQ", true))
assert.False(t, IsJoinToken("55785BAC-9H4B-4747-B090-EE123FFEE437", true))
assert.False(t, IsJoinToken("4B1FEF2D1CF4A5BE38B263E0637EDEAD", true))
assert.False(t, IsJoinToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", true))
assert.False(t, IsJoinToken("", true))
})
t.Run("MinLengthOnly", func(t *testing.T) {
assert.True(t, IsJoinToken("pGVplw8-eISgkdQN-Mep62nQ", false))
assert.True(t, IsJoinToken("M2hMhlx-f4bD1zCQ-VklHchp", false))
assert.True(t, IsJoinToken(AppPassword(), false))
assert.True(t, IsJoinToken("55785BAC-9H4B-4747-B090-EE123FFEE437", false))
assert.True(t, IsJoinToken("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2", false))
assert.False(t, IsJoinToken("abcdefghijklmnopqrstuvw", false))
assert.False(t, IsJoinToken("", false))
})
}
func BenchmarkJoinToken(b *testing.B) {
for n := 0; n < b.N; n++ {
JoinToken()
}
}
func TestIsAuthAny(t *testing.T) {
assert.True(t, IsAuthAny("MPkOqm-RtKGOi-ctIvXm-Qv3XhN"))
assert.True(t, IsAuthAny("9q2JHc-P0LzNE-xzvY9j-vMoefj"))