mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
452 lines
15 KiB
Go
452 lines
15 KiB
Go
package commands
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/urfave/cli/v2"
|
|
|
|
"github.com/photoprism/photoprism/internal/config"
|
|
"github.com/photoprism/photoprism/internal/service/cluster"
|
|
clusternode "github.com/photoprism/photoprism/internal/service/cluster/node"
|
|
"github.com/photoprism/photoprism/internal/service/cluster/theme"
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
"github.com/photoprism/photoprism/pkg/fs"
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
|
"github.com/photoprism/photoprism/pkg/service/http/header"
|
|
"github.com/photoprism/photoprism/pkg/txt/report"
|
|
)
|
|
|
|
// Supported cluster node register flags.
|
|
var (
|
|
regNameFlag = &cli.StringFlag{Name: "name", Usage: "node `NAME` (lowercase letters, digits, hyphens)"}
|
|
regRoleFlag = &cli.StringFlag{Name: "role", Usage: "node `ROLE` (instance, service)", Value: "instance"}
|
|
regIntUrlFlag = &cli.StringFlag{Name: "advertise-url", Usage: "internal service `URL`"}
|
|
regSiteUrlFlag = &cli.StringFlag{Name: "site-url", Usage: "public site `URL` (https://...)"}
|
|
regAppNameFlag = &cli.StringFlag{Name: "app-name", Usage: "override application `NAME` reported to the portal"}
|
|
regAppVersionFlag = &cli.StringFlag{Name: "app-version", Usage: "override application `VERSION` reported to the portal"}
|
|
regLabelFlag = &cli.StringSliceFlag{Name: "label", Usage: "`k=v` label (repeatable)"}
|
|
regRotateDatabase = &cli.BoolFlag{Name: "rotate", Usage: "rotates the node's database password"}
|
|
regRotateSec = &cli.BoolFlag{Name: "rotate-secret", Usage: "rotates the node's secret used for JWT"}
|
|
regPortalURL = &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to config)"}
|
|
regPortalTok = &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to config)"}
|
|
regWriteConf = &cli.BoolFlag{Name: "write-config", Usage: "persists returned secrets and DB settings to local config"}
|
|
regForceFlag = &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "confirm actions that may overwrite/replace local data (e.g., --write-config)"}
|
|
regDryRun = DryRunFlag("print derived values and payload without performing registration")
|
|
)
|
|
|
|
// ClusterRegisterCommand registers a node with the Portal via HTTP.
|
|
// ClusterRegisterCommand wires the `cluster register` CLI entrypoint.
|
|
var ClusterRegisterCommand = &cli.Command{
|
|
Name: "register",
|
|
Usage: "Registers a node or updates its credentials within a cluster",
|
|
Flags: append(append([]cli.Flag{
|
|
regDryRun,
|
|
regNameFlag,
|
|
regRoleFlag,
|
|
regPortalURL,
|
|
regPortalTok,
|
|
regIntUrlFlag,
|
|
regSiteUrlFlag,
|
|
regAppNameFlag,
|
|
regAppVersionFlag,
|
|
regLabelFlag,
|
|
regRotateDatabase,
|
|
regRotateSec,
|
|
regWriteConf,
|
|
regForceFlag,
|
|
}, report.CliFlags...)),
|
|
Action: clusterRegisterAction,
|
|
}
|
|
|
|
// clusterRegisterAction resolves CLI flags, builds the registration payload,
|
|
// and calls the Portal's register endpoint with retry/backoff handling.
|
|
func clusterRegisterAction(ctx *cli.Context) error {
|
|
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
|
// Resolve inputs
|
|
name := clean.DNSLabel(ctx.String("name"))
|
|
derivedName := false
|
|
|
|
if name == "" { // default from config if set
|
|
name = clean.DNSLabel(conf.NodeName())
|
|
if name != "" {
|
|
derivedName = true
|
|
}
|
|
}
|
|
|
|
if name == "" {
|
|
return cli.Exit(fmt.Errorf("node name is required (use --name or set node-name)"), 2)
|
|
}
|
|
|
|
nodeRole := clean.TypeLowerDash(ctx.String("role"))
|
|
switch nodeRole {
|
|
case "instance", "service":
|
|
default:
|
|
return cli.Exit(fmt.Errorf("invalid --role (must be instance or service)"), 2)
|
|
}
|
|
|
|
portalURL := ctx.String("portal-url")
|
|
derivedPortal := false
|
|
if portalURL == "" {
|
|
portalURL = conf.PortalUrl()
|
|
if portalURL != "" {
|
|
derivedPortal = true
|
|
}
|
|
}
|
|
|
|
// Derive advertise/site URLs when omitted.
|
|
advertise := ctx.String("advertise-url")
|
|
if advertise == "" {
|
|
advertise = conf.AdvertiseUrl()
|
|
}
|
|
site := strings.TrimSpace(ctx.String("site-url"))
|
|
if site == "" {
|
|
site = conf.SiteUrl()
|
|
}
|
|
|
|
overrideAppName := clean.TypeUnicode(ctx.String("app-name"))
|
|
overrideAppVersion := clean.TypeUnicode(ctx.String("app-version"))
|
|
|
|
defaultAppName := clean.TypeUnicode(conf.AppName())
|
|
defaultAppVersion := clean.TypeUnicode(conf.Version())
|
|
|
|
if overrideAppName == "" {
|
|
overrideAppName = defaultAppName
|
|
}
|
|
if overrideAppVersion == "" {
|
|
overrideAppVersion = defaultAppVersion
|
|
}
|
|
|
|
payload := cluster.RegisterRequest{
|
|
NodeName: name,
|
|
NodeRole: nodeRole,
|
|
Labels: parseLabelSlice(ctx.StringSlice("label")),
|
|
AdvertiseUrl: advertise,
|
|
RotateDatabase: ctx.Bool("rotate"),
|
|
RotateSecret: ctx.Bool("rotate-secret"),
|
|
AppName: overrideAppName,
|
|
AppVersion: overrideAppVersion,
|
|
}
|
|
|
|
// If auto detection is allowed, rotate database only when the current node lacks configured credentials.
|
|
if !payload.RotateDatabase && conf.ShouldAutoRotateDatabase() {
|
|
payload.RotateDatabase = true
|
|
}
|
|
|
|
// If we already have client credentials for this node (e.g., re-registering the
|
|
// same instance), include them so the portal can verify UUID/name changes. Avoid
|
|
// sending the portal's own credentials when registering a different node.
|
|
if id, secret := strings.TrimSpace(conf.NodeClientID()), strings.TrimSpace(conf.NodeClientSecret()); id != "" && secret != "" && strings.EqualFold(conf.NodeName(), name) {
|
|
payload.ClientID = id
|
|
payload.ClientSecret = secret
|
|
}
|
|
|
|
if site != "" {
|
|
payload.SiteUrl = site
|
|
}
|
|
if themeVersion, err := theme.DetectVersion(conf.ThemePath()); err == nil && themeVersion != "" {
|
|
payload.Theme = themeVersion
|
|
}
|
|
b, _ := json.Marshal(payload)
|
|
|
|
// In dry-run, we allow empty portalURL (will print derived/empty values).
|
|
if ctx.Bool("dry-run") {
|
|
if ctx.Bool("json") {
|
|
out := struct {
|
|
PortalURL string `json:"PortalUrl"`
|
|
Payload cluster.RegisterRequest `json:"Payload"`
|
|
}{PortalURL: portalURL, Payload: payload}
|
|
jb, _ := json.Marshal(out)
|
|
fmt.Println(string(jb))
|
|
} else {
|
|
fmt.Printf("Portal URL: %s\n", portalURL)
|
|
fmt.Printf("Node Name: %s\n", name)
|
|
if derivedPortal || derivedName || advertise == conf.AdvertiseUrl() {
|
|
fmt.Println("(derived defaults were used where flags were omitted)")
|
|
}
|
|
fmt.Printf("Advertise: %s\n", advertise)
|
|
if payload.SiteUrl != "" {
|
|
fmt.Printf("Site URL: %s\n", payload.SiteUrl)
|
|
}
|
|
if overrideAppName != "" {
|
|
fmt.Printf("App Name: %s\n", overrideAppName)
|
|
}
|
|
if overrideAppVersion != "" {
|
|
fmt.Printf("App Version:%s\n", overrideAppVersion)
|
|
}
|
|
// Warn if non-HTTPS on public host; server will enforce too.
|
|
if warnInsecurePublicURL(advertise) {
|
|
fmt.Println("Warning: advertise-url is http for a public host; server may reject it (HTTPS required).")
|
|
}
|
|
if payload.SiteUrl != "" && warnInsecurePublicURL(payload.SiteUrl) {
|
|
fmt.Println("Warning: site-url is http for a public host; server may reject it (HTTPS required).")
|
|
}
|
|
// Single-line summary for quick operator scan
|
|
if payload.SiteUrl != "" {
|
|
fmt.Printf("Derived: portal=%s advertise=%s site=%s\n", portalURL, advertise, payload.SiteUrl)
|
|
} else {
|
|
fmt.Printf("Derived: portal=%s advertise=%s\n", portalURL, advertise)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// For actual registration, require portal URL and token.
|
|
if portalURL == "" {
|
|
return cli.Exit(fmt.Errorf("portal URL is required (use --portal-url or set portal-url)"), 2)
|
|
}
|
|
|
|
token := ctx.String("join-token")
|
|
|
|
if token == "" {
|
|
token = conf.JoinToken()
|
|
}
|
|
|
|
if token == "" {
|
|
return cli.Exit(fmt.Errorf("portal token is required (use --join-token or set join-token)"), 2)
|
|
}
|
|
|
|
// POST with bounded backoff on 429
|
|
endpointUrl := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
|
|
|
|
var resp cluster.RegisterResponse
|
|
if err := postWithBackoff(endpointUrl, token, b, &resp); err != nil {
|
|
var httpErr *httpError
|
|
if errors.As(err, &httpErr) && httpErr.Status == http.StatusTooManyRequests {
|
|
return cli.Exit(fmt.Errorf("portal rate-limited registration attempts"), 6)
|
|
}
|
|
// Map common errors
|
|
if errors.As(err, &httpErr) {
|
|
switch httpErr.Status {
|
|
case http.StatusUnauthorized, http.StatusForbidden:
|
|
return cli.Exit(fmt.Errorf("%s", httpErr.Error()), 4)
|
|
case http.StatusConflict:
|
|
return cli.Exit(fmt.Errorf("%s", httpErr.Error()), 5)
|
|
case http.StatusBadRequest:
|
|
return cli.Exit(fmt.Errorf("%s", httpErr.Error()), 2)
|
|
case http.StatusNotFound:
|
|
return cli.Exit(fmt.Errorf("%s", httpErr.Error()), 3)
|
|
}
|
|
}
|
|
return cli.Exit(err, 1)
|
|
}
|
|
|
|
// Output
|
|
if ctx.Bool("json") {
|
|
jb, _ := json.Marshal(resp)
|
|
fmt.Println(string(jb))
|
|
} else {
|
|
// Human-readable: node row and credentials if present (UUID first as primary identifier)
|
|
cols := []string{"UUID", "ClientID", "Name", "Role"}
|
|
row := []string{resp.Node.UUID, resp.Node.ClientID, resp.Node.Name, resp.Node.Role}
|
|
|
|
if resp.Database.Driver != "" {
|
|
cols = append(cols, "DB Driver")
|
|
row = append(row, resp.Database.Driver)
|
|
}
|
|
if resp.Database.Name != "" {
|
|
cols = append(cols, "DB Name")
|
|
row = append(row, resp.Database.Name)
|
|
}
|
|
if resp.Database.User != "" {
|
|
cols = append(cols, "DB User")
|
|
row = append(row, resp.Database.User)
|
|
}
|
|
if resp.Database.Host != "" {
|
|
cols = append(cols, "Host")
|
|
row = append(row, resp.Database.Host)
|
|
}
|
|
if resp.Database.Port > 0 {
|
|
cols = append(cols, "Port")
|
|
row = append(row, strconv.Itoa(resp.Database.Port))
|
|
}
|
|
|
|
rows := [][]string{row}
|
|
out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
|
fmt.Printf("\n%s\n", out)
|
|
|
|
// Secrets/credentials block if any
|
|
// Show secrets in up to two tables, then print DSN if present
|
|
if (resp.Secrets != nil && resp.Secrets.ClientSecret != "") || resp.Database.Password != "" {
|
|
fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:")
|
|
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
|
|
secretTable, _ := report.RenderFormat([][]string{{resp.Secrets.ClientSecret}}, []string{"Node Client Secret"}, report.CliFormat(ctx))
|
|
fmt.Printf("\n%s\n", secretTable)
|
|
}
|
|
if resp.Database.Password != "" {
|
|
dbTable, _ := report.RenderFormat([][]string{{resp.Database.User, resp.Database.Password}}, []string{"DB User", "DB Password"}, report.CliFormat(ctx))
|
|
fmt.Printf("\n%s\n", dbTable)
|
|
}
|
|
if resp.Database.DSN != "" {
|
|
fmt.Printf("DSN: %s\n", resp.Database.DSN)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Optional persistence
|
|
if ctx.Bool("write-config") {
|
|
if err := persistRegisterResponse(conf, &resp); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// HTTP helpers and backoff
|
|
|
|
type httpError struct {
|
|
Status int
|
|
Body string
|
|
}
|
|
|
|
func (e *httpError) Error() string { return fmt.Sprintf("http %d: %s", e.Status, e.Body) }
|
|
|
|
// postWithBackoff executes the register HTTP POST with bounded retries for 429 responses.
|
|
func postWithBackoff(url, token string, payload []byte, out any) error {
|
|
// backoff: 500ms -> max ~8s, 6 attempts with jitter
|
|
delay := 500 * time.Millisecond
|
|
for attempt := 0; attempt < 6; attempt++ {
|
|
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
|
|
header.SetAuthorization(req, token)
|
|
req.Header.Set(header.ContentType, "application/json")
|
|
|
|
client := &http.Client{Timeout: cluster.BootstrapRegisterTimeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
// backoff and retry
|
|
time.Sleep(jitter(delay, 0.25))
|
|
if delay < 8*time.Second {
|
|
delay *= 2
|
|
}
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return &httpError{Status: resp.StatusCode, Body: string(b)}
|
|
}
|
|
dec := json.NewDecoder(resp.Body)
|
|
return dec.Decode(out)
|
|
}
|
|
return &httpError{Status: http.StatusTooManyRequests, Body: "rate limited"}
|
|
}
|
|
|
|
// jitter applies +/- jitter to a duration to avoid retry stampedes.
|
|
func jitter(d time.Duration, frac float64) time.Duration {
|
|
// simple +/- jitter
|
|
n := time.Duration(float64(d) * (1 + (randFloat()*2-1)*frac))
|
|
if n <= 0 {
|
|
return d
|
|
}
|
|
return n
|
|
}
|
|
|
|
// randFloat returns a simple pseudo-random float in [0,1) without touching math/rand global state.
|
|
func randFloat() float64 { return float64(time.Now().UnixNano()%1000) / 1000.0 }
|
|
|
|
// stringsTrimRightSlash removes trailing slashes to build consistent endpoints.
|
|
func stringsTrimRightSlash(s string) string {
|
|
for len(s) > 0 && s[len(s)-1] == '/' {
|
|
s = s[:len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// warnInsecurePublicURL returns true if the URL is HTTP on a non-local host.
|
|
func warnInsecurePublicURL(u string) bool {
|
|
parsed, err := url.Parse(u)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return false
|
|
}
|
|
if parsed.Scheme != "http" {
|
|
return false
|
|
}
|
|
h := parsed.Hostname()
|
|
if h == "localhost" || h == "127.0.0.1" || h == "::1" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// parseLabelSlice converts repeated k=v CLI inputs into a map.
|
|
func parseLabelSlice(labels []string) map[string]string {
|
|
if len(labels) == 0 {
|
|
return nil
|
|
}
|
|
m := make(map[string]string)
|
|
for _, kv := range labels {
|
|
if i := bytes.IndexByte([]byte(kv), '='); i > 0 && i < len(kv)-1 {
|
|
k := kv[:i]
|
|
v := kv[i+1:]
|
|
m[k] = v
|
|
}
|
|
}
|
|
if len(m) == 0 {
|
|
return nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
// persistRegisterResponse writes returned secrets/DB details into local config when requested.
|
|
func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error {
|
|
updates := cluster.OptionsUpdate{}
|
|
|
|
if rnd.IsUUID(resp.UUID) {
|
|
updates.SetClusterUUID(resp.UUID)
|
|
}
|
|
|
|
if cidr := strings.TrimSpace(resp.ClusterCIDR); cidr != "" {
|
|
updates.SetClusterCIDR(cidr)
|
|
}
|
|
|
|
// Node client secret file
|
|
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
|
|
// Prefer PHOTOPRISM_NODE_CLIENT_SECRET_FILE; otherwise config cluster path
|
|
fileName := os.Getenv(config.FlagFileVar("NODE_CLIENT_SECRET"))
|
|
if fileName == "" {
|
|
fileName = filepath.Join(conf.PortalConfigPath(), "node-secret")
|
|
}
|
|
if err := fs.MkdirAll(filepath.Dir(fileName)); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(fileName, []byte(resp.Secrets.ClientSecret), 0o600); err != nil {
|
|
return err
|
|
}
|
|
log.Infof("wrote node client secret to %s", clean.Log(fileName))
|
|
}
|
|
|
|
// DB settings (MySQL/MariaDB only)
|
|
if resp.Database.Name != "" && resp.Database.User != "" {
|
|
updates.SetDatabaseDriver(config.MySQL)
|
|
updates.SetDatabaseName(resp.Database.Name)
|
|
updates.SetDatabaseServer(fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port))
|
|
updates.SetDatabaseUser(resp.Database.User)
|
|
updates.SetDatabasePassword(resp.Database.Password)
|
|
}
|
|
|
|
if !updates.IsZero() {
|
|
if _, err := clusternode.ApplyOptionsUpdate(conf, updates); err != nil {
|
|
return err
|
|
}
|
|
log.Infof("updated options.yml with cluster registration settings for node %s", clean.LogQuote(resp.Node.Name))
|
|
}
|
|
return nil
|
|
}
|