Files
photoprism/internal/config/config_cluster.go
2025-11-22 20:00:53 +01:00

598 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"errors"
"fmt"
urlpkg "net/url"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v2"
"github.com/photoprism/photoprism/internal/service/cluster"
"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/http/dns"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd"
)
// DefaultPortalUrl specifies the default portal URL with variable cluster domain.
var DefaultPortalUrl = "https://portal.${PHOTOPRISM_CLUSTER_DOMAIN}"
// DefaultNodeRole is the default node role assigned when none is configured.
var DefaultNodeRole = cluster.RoleApp
// DefaultJWTAllowedScopes lists default OAuth scopes for cluster-issued JWTs.
var DefaultJWTAllowedScopes = "config cluster vision metrics"
// ClusterDomain returns the cluster DOMAIN (lowercase DNS name; 163 chars).
func (c *Config) ClusterDomain() string {
if c.options.ClusterDomain != "" {
return strings.ToLower(c.options.ClusterDomain)
}
if _, d, found := c.deriveNodeNameAndDomainFromHttpHost(); found && d != "" {
return d
}
// Attempt to derive from system configuration when not explicitly set.
if d := dns.GetSystemDomain(); d != "" {
return d
}
return ""
}
// ClusterCIDR returns the configured cluster CIDR used for IP-based allowances.
func (c *Config) ClusterCIDR() string {
return strings.TrimSpace(c.options.ClusterCIDR)
}
// ClusterUUID returns a stable UUIDv4 that uniquely identifies the Portal.
// Precedence: env PHOTOPRISM_CLUSTER_UUID -> options.yml (ClusterUUID) -> auto-generate and persist.
func (c *Config) ClusterUUID() string {
// Return if the configured cluster UUID is not in the expected format.
if !rnd.IsUUID(c.options.ClusterUUID) {
return ""
}
// Respect explicit CLI value if provided.
if c.cliCtx != nil && c.cliCtx.IsSet("cluster-uuid") {
return c.options.ClusterUUID
}
return c.options.ClusterUUID
}
// PortalUrl returns the URL of the cluster management portal server, if configured.
func (c *Config) PortalUrl() string {
if c.options.PortalUrl == "" {
return ""
}
d := c.ClusterDomain()
// Return empty string if default and there's no cluster domain configured.
if d == "" && c.options.PortalUrl == DefaultPortalUrl {
return ""
}
// Replace variables with the configured cluster domain.
c.options.PortalUrl = ExpandVars(c.options.PortalUrl, map[string]string{
"cluster-domain": d,
"CLUSTER_DOMAIN": d,
"PHOTOPRISM_CLUSTER_DOMAIN": d,
})
return c.options.PortalUrl
}
// Portal returns true if the configured node type is "portal".
func (c *Config) Portal() bool {
return c.NodeRole() == cluster.RolePortal
}
// PortalConfigPath returns the path to the default configuration for cluster portals.
func (c *Config) PortalConfigPath() string {
return filepath.Join(c.ConfigPath(), fs.PortalDir)
}
// PortalThemePath returns the path to the theme files for cluster portals to use.
func (c *Config) PortalThemePath() string {
themeDir := filepath.Join(c.PortalConfigPath(), fs.ThemeDir)
if fs.PathExists(themeDir) && fs.FileExists(filepath.Join(themeDir, fs.AppJsFile)) {
return themeDir
}
// Fallback to the default theme directory in the main config path.
return c.ThemePath()
}
// NodeConfigPath returns the path to the default configuration for cluster nodes.
func (c *Config) NodeConfigPath() string {
return filepath.Join(c.ConfigPath(), fs.NodeDir)
}
// NodeThemePath returns the path to the theme files for cluster nodes to use.
func (c *Config) NodeThemePath() string {
return filepath.Join(c.NodeConfigPath(), fs.ThemeDir)
}
// NodeThemeVersion returns the version to the theme files of the cluster node.
func (c *Config) NodeThemeVersion() string {
if version, err := theme.DetectVersion(c.NodeThemePath()); err == nil {
return version
}
return ""
}
// JoinToken returns the portal join token used when registering nodes. It
// lazily loads the token from disk (or generates a new one) and caches it in
// memory. Example format: k9sEFe6-A7gt6zqm-gY9gFh0.
func (c *Config) JoinToken() string {
// Read token from config options (memory).
if rnd.IsJoinToken(c.options.JoinToken, false) {
return c.options.JoinToken
}
// Read token from file if possible. Uses a cache to reduce I/O.
if fileName := c.JoinTokenFile(); fileName != "" {
if c.cache == nil {
// Skip cache lookup.
} else if s, hit := c.cache.Get(fileName); hit && s != nil {
return s.(string)
}
if fs.FileExistsNotEmpty(fileName) {
if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 { //nolint:gosec // path derived from config directory
log.Warnf("config: could not read cluster join token from %s (%s)", fileName, err)
} else if s := strings.TrimSpace(string(b)); rnd.IsJoinToken(s, false) {
if c.cache != nil {
c.cache.SetDefault(fileName, s)
}
return s
} else {
log.Warnf("config: cluster join token from %s is shorter than %d characters", fileName, rnd.JoinTokenLength)
}
}
}
// Do not proceed with generating a token on nodes.
if !c.Portal() {
return ""
} else if token, _, err := c.SaveJoinToken(""); err != nil {
log.Errorf("config: %v", err)
return ""
} else {
return token
}
}
// SaveJoinToken writes a fresh portal join token to disk and updates the
// in-memory value. When customToken is provided it must already be valid.
func (c *Config) SaveJoinToken(customToken string) (token string, fileName string, err error) {
fileName = c.JoinTokenFile()
if fileName == "" {
return "", "", fmt.Errorf("invalid cluster join token path")
}
dir := filepath.Dir(fileName)
if dir == "" {
return "", "", fmt.Errorf("invalid cluster secrets directory")
}
if customToken != "" {
if !rnd.IsJoinToken(customToken, false) {
return "", "", fmt.Errorf("insecure custom cluster join token specified")
}
token = customToken
} else {
token = rnd.JoinToken()
if !rnd.IsJoinToken(token, true) {
return "", "", fmt.Errorf("invalid cluster join token generated")
}
}
// Create secret directory.
if err = fs.MkdirAll(dir); err != nil {
// Use memory to store join token if directory is not writable.
c.options.JoinToken = token
return "", "", fmt.Errorf("could not create cluster secrets path (%w)", err)
}
// Write secret to file.
if err = fs.WriteFile(fileName, []byte(token), fs.ModeSecretFile); err != nil {
// Use memory to store join token if file is not writable.
c.options.JoinToken = token
return "", "", fmt.Errorf("could not write cluster join token (%w)", err)
}
// Use an in-memory cache with a
// short TTL to cache the token.
if c.cache != nil {
c.cache.SetDefault(fileName, token)
c.options.JoinToken = ""
} else {
// Store token in Options
// if cache is unavailable.
c.options.JoinToken = token
}
return token, fileName, nil
}
// clearJoinTokenFileCache invalidates the cached join token file cache.
func (c *Config) clearJoinTokenFileCache() {
if c.cache != nil {
c.cache.Delete(c.JoinTokenFile())
}
}
// JoinTokenFile returns the path where the portal join token is stored for the
// active configuration (portal nodes use config/portal/secrets/join_token,
// regular nodes use config/node/secrets/join_token).
func (c *Config) JoinTokenFile() string {
if c.Portal() {
return c.PortalJoinTokenFile()
}
return c.NodeJoinTokenFile()
}
// PortalJoinTokenFile returns the filepath where the portal cluster join token is stored.
func (c *Config) PortalJoinTokenFile() string {
if filePath := FlagFilePath("JOIN_TOKEN"); filePath != "" {
return filePath
}
return filepath.Join(c.PortalConfigPath(), fs.SecretsDir, fs.JoinTokenFile)
}
// NodeJoinTokenFile returns the filepath where the node cluster join token is stored.
func (c *Config) NodeJoinTokenFile() string {
if filePath := FlagFilePath("JOIN_TOKEN"); filePath != "" {
return filePath
}
return filepath.Join(c.NodeConfigPath(), fs.SecretsDir, fs.JoinTokenFile)
}
// deriveNodeNameAndDomainFromHttpHost attempts to derive cluster host and domain name from the site URL.
func (c *Config) deriveNodeNameAndDomainFromHttpHost() (hostName, domainName string, found bool) {
if fqdn := c.SiteDomain(); fqdn != "" && !header.IsIP(fqdn) {
hostName, domainName, found = strings.Cut(fqdn, ".")
if hostName = clean.DNSLabel(hostName); found && dns.IsLabel(hostName) && dns.IsDomain(domainName) {
c.options.NodeName = hostName
if c.options.ClusterDomain == "" {
c.options.ClusterDomain = strings.ToLower(domainName)
}
return c.options.NodeName, c.options.ClusterDomain, found
}
}
return "", "", false
}
// NodeName returns the cluster node NAME (unique in cluster domain; [a-z0-9-]{1,32}).
func (c *Config) NodeName() string {
if n := clean.DNSLabel(c.options.NodeName); n != "" {
return n
}
if h, _, found := c.deriveNodeNameAndDomainFromHttpHost(); found && h != "" {
return h
}
// Default: portal nodes → "portal".
if c.Portal() {
return "portal"
}
// Instances/services: derive from hostname via DNSLabel normalization.
if hn, _ := dns.GetHostname(); hn != "" {
if cand := clean.DNSLabel(hn); cand != "" {
return cand
}
}
// Fallback to a stable short identifier
s := c.SerialChecksum()
return "node-" + s
}
// NodeRole returns the cluster node role (portal, app, or service).
func (c *Config) NodeRole() string {
if c.Edition() == Portal {
c.options.NodeRole = cluster.RolePortal
return c.options.NodeRole
}
switch c.options.NodeRole {
case cluster.RolePortal, cluster.RoleApp, cluster.RoleService:
return c.options.NodeRole
default:
return DefaultNodeRole
}
}
// NodeUUID returns the UUID (v7) that identifies this node.
func (c *Config) NodeUUID() string {
if c.options.NodeUUID != "" {
return c.options.NodeUUID
}
// Generate, persist, and cache a UUIDv7 if still empty.
uuid := rnd.UUIDv7()
c.options.NodeUUID = uuid
if err := c.SaveNodeUUID(uuid); err != nil {
log.Warnf("config: could not save node UUID to %s (%s)", c.OptionsYaml(), err)
}
return uuid
}
// NodeClientID returns the OAuth client ID registered with the portal (auto-assigned via join token).
func (c *Config) NodeClientID() string {
return clean.ID(c.options.NodeClientID)
}
// NodeClientSecret returns the node OAuth client secret, reading it from disk
// when necessary. Portal registration writes this secret so nodes can obtain
// access tokens in future runs.
func (c *Config) NodeClientSecret() string {
if c.options.NodeClientSecret != "" {
return c.options.NodeClientSecret
}
fileName := c.NodeClientSecretFile()
if fileName == "" {
return ""
}
if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 { //nolint:gosec // path derived from config directory
// Do not cache the value. Always read from the disk to ensure
// that updates from other processes are observed.
return string(b)
}
if err := os.Chmod(filepath.Dir(fileName), fs.ModeDir); err != nil {
log.Debugf("config: failed to set node secrets dir permissions (%s)", err)
}
if _, err := os.Stat(fileName); os.IsNotExist(err) {
log.Debugf("config: node client secret file %s not found", clean.Log(fileName))
} else if err != nil {
log.Warnf("config: failed to read node client secret from %s (%s)", clean.Log(fileName), err)
}
return c.options.NodeClientSecret
}
// SaveNodeClientSecret stores a new node client secret on disk and updates the
// in-memory value. The secret must already pass rnd.IsClientSecret.
func (c *Config) SaveNodeClientSecret(clientSecret string) (fileName string, err error) {
fileName = c.NodeClientSecretFile()
if !rnd.IsClientSecret(clientSecret) {
return fileName, errors.New("invalid node client secret")
}
dir := filepath.Dir(fileName)
if fileName == "" || dir == "" {
return fileName, fmt.Errorf("invalid node client secret filename %s", clean.Log(fileName))
}
// Create secret directory.
if err = fs.MkdirAll(dir); err != nil {
// Use memory to store client secret if directory is not writable.
c.options.NodeClientSecret = clientSecret
return fileName, fmt.Errorf("could not create node secrets path (%s)", err)
}
// Write secret to file.
if err = fs.WriteFile(fileName, []byte(clientSecret), fs.ModeSecretFile); err != nil {
// Use memory to store client secret if file is not writable.
c.options.NodeClientSecret = clientSecret
return "", fmt.Errorf("could not write node client secret (%s)", err)
}
c.options.NodeClientSecret = ""
return fileName, nil
}
// NodeClientSecretFile returns the path holding the node client secret (defaults
// to config/node/secrets/client_secret unless overridden via *_FILE).
func (c *Config) NodeClientSecretFile() string {
if filePath := FlagFilePath("NODE_CLIENT_SECRET"); filePath != "" {
return filePath
}
return filepath.Join(c.NodeConfigPath(), fs.SecretsDir, fs.ClientSecretFile)
}
// JWKSUrl returns the configured JWKS endpoint for portal-issued JWTs. Nodes normally
// persist this URL from the portal's register response, which derives it from SiteUrl;
// manual overrides are only required for custom deployments.
func (c *Config) JWKSUrl() string {
return strings.TrimSpace(c.options.JWKSUrl)
}
// SetJWKSUrl updates the configured JWKS endpoint for portal-issued JWTs.
func (c *Config) SetJWKSUrl(url string) {
if c == nil || c.options == nil {
return
}
trimmed := strings.TrimSpace(url)
if trimmed == "" {
c.options.JWKSUrl = ""
return
}
parsed, err := urlpkg.Parse(trimmed)
if err != nil || parsed == nil || parsed.Scheme == "" || parsed.Host == "" {
log.Warnf("config: ignoring JWKS URL %q (%v)", trimmed, err)
return
}
scheme := strings.ToLower(parsed.Scheme)
host := parsed.Hostname()
switch scheme {
case "https":
// Always allowed.
case "http":
if !dns.IsLoopbackHost(host) {
log.Warnf("config: rejecting JWKS URL %q (http only allowed for localhost/loopback)", trimmed)
return
}
default:
log.Warnf("config: rejecting JWKS URL %q (unsupported scheme)", trimmed)
return
}
c.options.JWKSUrl = trimmed
}
// JWKSCacheTTL returns the JWKS cache lifetime in seconds (default 300, max 3600).
func (c *Config) JWKSCacheTTL() int {
if c.options.JWKSCacheTTL <= 0 {
return 300
}
if c.options.JWKSCacheTTL > 3600 {
return 3600
}
return c.options.JWKSCacheTTL
}
// JWTLeeway returns the permitted clock skew in seconds (default 60, max 300).
func (c *Config) JWTLeeway() int {
if c.options.JWTLeeway <= 0 {
return 60
}
if c.options.JWTLeeway > 300 {
return 300
}
return c.options.JWTLeeway
}
// JWTAllowedScopes returns an optional allow-list of accepted JWT scopes.
func (c *Config) JWTAllowedScopes() list.Attr {
if s := strings.TrimSpace(c.options.JWTScope); s != "" {
parsed := list.ParseAttr(strings.ToLower(s))
if len(parsed) > 0 {
return parsed
}
}
return list.ParseAttr(DefaultJWTAllowedScopes)
}
// AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]).
// Portal validation permits HTTPS for external hosts and HTTP only for loopback
// or cluster-internal service domains (e.g., *.svc, *.cluster.local, *.internal).
func (c *Config) AdvertiseUrl() string {
if c.options.AdvertiseUrl != "" {
return strings.TrimRight(c.options.AdvertiseUrl, "/") + "/"
}
// Derive from cluster domain and node name if available; otherwise fall back to SiteUrl().
if d := c.ClusterDomain(); d != "" {
if n := c.NodeName(); n != "" && dns.IsLabel(n) {
return "https://" + n + "." + d + "/"
}
}
return c.SiteUrl()
}
// SaveClusterUUID writes or updates the ClusterUUID key in options.yml without
// touching unrelated keys. Creates the file and directories if needed.
func (c *Config) SaveClusterUUID(uuid string) error {
if !rnd.IsUUID(uuid) {
return errors.New("invalid cluster UUID")
}
// Always resolve against the current ConfigPath and remember it explicitly
// so subsequent calls don't accidentally point to a previous default.
cfgDir := c.ConfigPath()
if err := fs.MkdirAll(cfgDir); err != nil {
return err
}
fileName := c.OptionsYaml()
var m Values
if fs.FileExists(fileName) {
if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 { //nolint:gosec // path derived from config directory
_ = yaml.Unmarshal(b, &m)
}
}
if m == nil {
m = Values{}
}
m["ClusterUUID"] = uuid
if b, err := yaml.Marshal(m); err != nil {
return err
} else if err = os.WriteFile(fileName, b, fs.ModeFile); err != nil {
return err
}
c.options.ClusterUUID = uuid
// Remember options.yml path for subsequent loads and ensure in-memory options see the value.
if c.options != nil {
_ = c.options.Load(fileName)
}
return nil
}
// SaveNodeUUID writes or updates the NodeUUID key in options.yml without touching unrelated keys.
func (c *Config) SaveNodeUUID(uuid string) error {
if !rnd.IsUUID(uuid) {
return errors.New("invalid node UUID")
}
cfgDir := c.ConfigPath()
if err := fs.MkdirAll(cfgDir); err != nil {
return err
}
fileName := c.OptionsYaml()
var m Values
if fs.FileExists(fileName) {
if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 { //nolint:gosec // path derived from config directory
_ = yaml.Unmarshal(b, &m)
}
}
if m == nil {
m = Values{}
}
m["NodeUUID"] = uuid
if b, err := yaml.Marshal(m); err != nil {
return err
} else if err = os.WriteFile(fileName, b, fs.ModeFile); err != nil {
return err
}
c.options.NodeUUID = uuid
return nil
}