CLI: Improve "photoprism cluster" sub-commands #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-19 06:52:45 +02:00
parent 2fe48605a2
commit 29ca2c1331
7 changed files with 348 additions and 18 deletions

View File

@@ -9455,8 +9455,6 @@
"type": "integer",
"format": "int64",
"enum": [
-9223372036854775808,
9223372036854775807,
1,
1000,
1000000,
@@ -9465,8 +9463,6 @@
3600000000000
],
"x-enum-varnames": [
"minDuration",
"maxDuration",
"Nanosecond",
"Microsecond",
"Millisecond",

View File

@@ -0,0 +1,55 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// obtainClientCredentialsViaRegister calls the portal register endpoint using a join token
// to (re)register the node, rotating the secret when necessary, and returns client id/secret.
func obtainClientCredentialsViaRegister(portalURL, joinToken, nodeName string) (id, secret string, err error) {
u, err := url.Parse(strings.TrimRight(portalURL, "/"))
if err != nil || u.Scheme == "" || u.Host == "" {
return "", "", fmt.Errorf("invalid portal-url: %s", portalURL)
}
endpoint := *u
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/api/v1/cluster/nodes/register"
reqBody := map[string]any{
"nodeName": nodeName,
"nodeRole": cluster.RoleInstance,
"rotateSecret": true,
}
b, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
header.SetAuthorization(req, joinToken)
resp, err := (&http.Client{}).Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
return "", "", fmt.Errorf("%s", resp.Status)
}
var regResp cluster.RegisterResponse
if err := json.NewDecoder(resp.Body).Decode(&regResp); err != nil {
return "", "", err
}
id = regResp.Node.ID
if regResp.Secrets != nil {
secret = regResp.Secrets.NodeSecret
}
if id == "" || secret == "" {
return "", "", fmt.Errorf("missing client credentials in response")
}
return id, secret, nil
}

View File

@@ -2,9 +2,12 @@ package commands
import (
"archive/zip"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -25,12 +28,14 @@ var ClusterThemePullCommand = &cli.Command{
Subcommands: []*cli.Command{
{
Name: "pull",
Usage: "Downloads the theme from a portal and installs it in config/theme or the dest path",
Usage: "Downloads the theme from a portal and installs it in config/theme or the dest path. If only a join token is provided, this command first registers the node to obtain client credentials, then downloads the theme (no extra command needed).",
Flags: []cli.Flag{
&cli.PathFlag{Name: "dest", Usage: "extract destination `PATH` (defaults to config/theme)", Value: ""},
&cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "replace existing files at destination"},
&cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to global config)"},
&cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to global config)"},
&cli.StringFlag{Name: "client-id", Usage: "Node client `ID` (defaults to NodeID from config)"},
&cli.StringFlag{Name: "client-secret", Usage: "Node client `SECRET` (defaults to NodeSecret from config)"},
JsonFlag,
},
Action: clusterThemePullAction,
@@ -50,15 +55,44 @@ func clusterThemePullAction(ctx *cli.Context) error {
if portalURL == "" {
return fmt.Errorf("portal-url not configured; set --portal-url or PHOTOPRISM_PORTAL_URL")
}
token := ctx.String("join-token")
if token == "" {
token = conf.JoinToken()
// Credentials: prefer OAuth client credentials (client-id/secret), fallback to join-token for compatibility.
clientID := ctx.String("client-id")
if clientID == "" {
clientID = conf.NodeID()
}
clientSecret := ctx.String("client-secret")
if clientSecret == "" {
clientSecret = conf.NodeSecret()
}
token := ""
if clientID != "" && clientSecret != "" {
// OAuth client_credentials
t, err := obtainOAuthToken(portalURL, clientID, clientSecret)
if err != nil {
log.Warnf("cluster: oauth token failed, falling back to join token (%s)", clean.Error(err))
} else {
token = t
}
}
if token == "" {
token = os.Getenv(config.EnvVar("join-token"))
}
if token == "" {
return fmt.Errorf("join-token not configured; set --join-token or PHOTOPRISM_JOIN_TOKEN")
// Try join-token assisted path. If NodeID/NodeSecret not available, attempt register to obtain them, then OAuth.
jt := ctx.String("join-token")
if jt == "" {
jt = conf.JoinToken()
}
if jt == "" {
jt = os.Getenv(config.EnvVar("join-token"))
}
if jt != "" && (clientID == "" || clientSecret == "") {
if id, sec, err := obtainClientCredentialsViaRegister(portalURL, jt, conf.NodeName()); err == nil {
if t, err := obtainOAuthToken(portalURL, id, sec); err == nil {
token = t
}
}
}
if token == "" {
return fmt.Errorf("authentication required: provide --client-id/--client-secret or a join token to obtain credentials")
}
}
dest := ctx.Path("dest")
@@ -151,6 +185,46 @@ func clusterThemePullAction(ctx *cli.Context) error {
})
}
// obtainOAuthToken requests an access token via client_credentials using Basic auth.
func obtainOAuthToken(portalURL, clientID, clientSecret string) (string, error) {
u, err := url.Parse(strings.TrimRight(portalURL, "/"))
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("invalid portal-url: %s", portalURL)
}
tokenURL := *u
tokenURL.Path = strings.TrimRight(tokenURL.Path, "/") + "/api/v1/oauth/token"
form := url.Values{}
form.Set("grant_type", "client_credentials")
req, _ := http.NewRequest(http.MethodPost, tokenURL.String(), strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
basic := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
req.Header.Set("Authorization", "Basic "+basic)
client := &http.Client{Timeout: cluster.BootstrapRegisterTimeout}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%s", resp.Status)
}
var tok struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
}
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return "", err
}
if tok.AccessToken == "" {
return "", fmt.Errorf("empty access_token")
}
return tok.AccessToken, nil
}
func dirNonEmpty(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {

View File

@@ -0,0 +1,130 @@
package commands
import (
"archive/zip"
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/service/cluster"
)
// Verifies OAuth path in cluster theme pull using client_id/client_secret.
func TestClusterThemePull_OAuth(t *testing.T) {
// Build an in-memory zip with one file
var zipBuf bytes.Buffer
zw := zip.NewWriter(&zipBuf)
f, _ := zw.Create("ok.txt")
_, _ = f.Write([]byte("ok\n"))
_ = zw.Close()
// Fake portal server
var gotBasic string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/oauth/token":
// Expect Basic auth for nodeid:secret
gotBasic = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer", "scope": "cluster vision"})
case "/api/v1/cluster/theme":
if r.Header.Get("Authorization") != "Bearer tok" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/zip")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(zipBuf.Bytes())
default:
http.NotFound(w, r)
}
}))
defer ts.Close()
// Prepare destination
dest := t.TempDir()
// Run CLI with OAuth creds
out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{
"pull", "--dest", dest, "-f",
"--portal-url=" + ts.URL,
"--client-id=nodeid",
"--client-secret=secret",
})
_ = out
assert.NoError(t, err)
// Verify file extracted
assert.FileExists(t, filepath.Join(dest, "ok.txt"))
// Verify Basic header format
expect := "Basic " + base64.StdEncoding.EncodeToString([]byte("nodeid:secret"))
assert.Equal(t, expect, gotBasic)
}
// Verifies that when only a join token is provided, the command obtains
// client credentials via the register endpoint, then uses OAuth to pull the theme.
func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
// Zip fixture
var zipBuf bytes.Buffer
zw := zip.NewWriter(&zipBuf)
_, _ = zw.Create("ok2.txt")
_ = zw.Close()
// Fake portal server responds with register then token then theme
var sawRotateSecret bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/cluster/nodes/register":
// Must have Bearer join token
if r.Header.Get("Authorization") != "Bearer jt" {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Read body to check rotateSecret flag
var req struct {
RotateSecret bool `json:"rotateSecret"`
NodeName string `json:"nodeName"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
sawRotateSecret = req.RotateSecret
w.Header().Set("Content-Type", "application/json")
// Return NodeID and a fresh secret
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
Node: cluster.Node{ID: "cid123", Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{NodeSecret: "s3cr3t"},
})
case "/api/v1/oauth/token":
// Expect Basic for the returned creds
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("cid123:s3cr3t")) {
w.WriteHeader(http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok2", "token_type": "Bearer"})
case "/api/v1/cluster/theme":
if r.Header.Get("Authorization") != "Bearer tok2" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/zip")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(zipBuf.Bytes())
default:
http.NotFound(w, r)
}
}))
defer ts.Close()
dest := t.TempDir()
out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{
"pull", "--dest", dest, "-f",
"--portal-url=" + ts.URL,
"--join-token=jt",
})
_ = out
assert.NoError(t, err)
assert.True(t, sawRotateSecret)
}

View File

@@ -1,8 +1,10 @@
package instance
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
@@ -199,6 +201,11 @@ func isTemporary(err error) bool {
func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRotateDatabase bool) error {
updates := map[string]interface{}{}
// Always persist NodeID (client UID) from response for future OAuth token requests.
if r.Node.ID != "" {
updates["NodeID"] = r.Node.ID
}
// Persist node secret only if missing locally and provided by server.
if r.Secrets != nil && r.Secrets.NodeSecret != "" && c.NodeSecret() == "" {
updates["NodeSecret"] = r.Secrets.NodeSecret
@@ -297,8 +304,23 @@ func installThemeIfMissing(c *config.Config, portal *url.URL, token string) erro
endpoint := *portal
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/api/v1/cluster/theme"
// Prefer OAuth client-credentials using NodeID/NodeSecret if available; fallback to join token.
bearer := ""
if id, secret := strings.TrimSpace(c.NodeID()), strings.TrimSpace(c.NodeSecret()); id != "" && secret != "" {
if t, err := oauthAccessToken(c, portal, id, secret); err != nil {
log.Debugf("cluster: oauth token request failed (%s)", clean.Error(err))
} else {
bearer = t
}
}
// If we do not have a bearer token, skip theme install for this run (no insecure fallback).
if bearer == "" {
log.Debugf("cluster: theme install skipped (missing OAuth credentials)")
return nil
}
req, _ := http.NewRequest(http.MethodGet, endpoint.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Authorization", "Bearer "+bearer)
req.Header.Set("Accept", "application/zip")
resp, err := newHTTPClient(cluster.BootstrapRegisterTimeout).Do(req)
@@ -339,3 +361,44 @@ func installThemeIfMissing(c *config.Config, portal *url.URL, token string) erro
return errors.New(resp.Status)
}
}
// oauthAccessToken requests an OAuth access token via client_credentials using Basic auth.
func oauthAccessToken(c *config.Config, portal *url.URL, clientID, clientSecret string) (string, error) {
if portal == nil {
return "", fmt.Errorf("invalid portal url")
}
tokenURL := *portal
tokenURL.Path = strings.TrimRight(tokenURL.Path, "/") + "/api/v1/oauth/token"
form := url.Values{}
form.Set("grant_type", "client_credentials")
req, _ := http.NewRequest(http.MethodPost, tokenURL.String(), strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
// Basic auth for client credentials
basic := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
req.Header.Set("Authorization", "Basic "+basic)
resp, err := newHTTPClient(cluster.BootstrapRegisterTimeout).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%s", resp.Status)
}
var tok struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
}
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&tok); err != nil {
return "", err
}
if tok.AccessToken == "" {
return "", fmt.Errorf("empty access_token")
}
return tok.AccessToken, nil
}

View File

@@ -78,12 +78,16 @@ func TestThemeInstall_Missing(t *testing.T) {
_, _ = f.Write([]byte("body{}\n"))
_ = zw.Close()
// Fake Portal server.
// Fake Portal server (register -> oauth token -> theme)
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")
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{Node: cluster.Node{Name: "pp-node-01"}})
// Return NodeID + NodeSecret so bootstrap can request OAuth token
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{Node: cluster.Node{ID: "cid123", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{NodeSecret: "s3cr3t"}})
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"})
case "/api/v1/cluster/theme":
w.Header().Set("Content-Type", "application/zip")
w.WriteHeader(http.StatusOK)

View File

@@ -11,13 +11,13 @@ import (
)
// ClientRegistry implements Registry using auth_clients + passwords.
type ClientRegistry struct{}
type ClientRegistry struct{ conf *config.Config }
func NewClientRegistry() *ClientRegistry { return &ClientRegistry{} }
// NewClientRegistryWithConfig returns a client-backed registry; the config is accepted for parity with file-backed init.
func NewClientRegistryWithConfig(_ *config.Config) (*ClientRegistry, error) {
return &ClientRegistry{}, nil
func NewClientRegistryWithConfig(c *config.Config) (*ClientRegistry, error) {
return &ClientRegistry{conf: c}, nil
}
// toNode maps an auth client to the registry.Node DTO used by response builders.
@@ -84,6 +84,14 @@ func (r *ClientRegistry) Put(n *Node) error {
if n.Role != "" {
m.SetRole(n.Role)
}
// Ensure a default scope for node clients (instance/service) if none is set.
// Always include "vision"; this only permits access to Vision endpoints WHEN the Portal enables them.
if m.Scope() == "" {
role := m.AclRole().String()
if role == "instance" || role == "service" {
m.SetScope("cluster vision")
}
}
if n.AdvertiseUrl != "" {
m.ClientURL = n.AdvertiseUrl
}