mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
CLI: Improve "photoprism cluster" sub-commands #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -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",
|
||||
|
55
internal/commands/cluster_helpers.go
Normal file
55
internal/commands/cluster_helpers.go
Normal 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(®Resp); 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
|
||||
}
|
@@ -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 {
|
||||
|
130
internal/commands/cluster_theme_pull_oauth_test.go
Normal file
130
internal/commands/cluster_theme_pull_oauth_test.go
Normal 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)
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
}
|
||||
|
Reference in New Issue
Block a user