Security: Improve credential handling across the cluster tooling #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-20 00:06:17 +02:00
parent 83d69f59cc
commit f23069dd2c
10 changed files with 311 additions and 34 deletions

View File

@@ -20,6 +20,7 @@ var ClusterCommands = &cli.Command{
ClusterHealthCommand,
ClusterNodesCommands,
ClusterRegisterCommand,
ClusterJoinTokenCommand,
ClusterThemePullCommand,
},
}

View File

@@ -0,0 +1,57 @@
package commands
import (
"fmt"
"github.com/manifoldco/promptui"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
)
var joinTokenSaveFlag = SaveFlag("write the generated join token to config/portal/secrets/join_token")
// ClusterJoinTokenCommand generates cluster join tokens for nodes.
var ClusterJoinTokenCommand = &cli.Command{
Name: "join-token",
Usage: "Generates a portal join token for registering nodes",
Flags: []cli.Flag{
joinTokenSaveFlag,
YesFlag(),
},
Action: clusterJoinTokenAction,
}
// clusterJoinTokenAction generates a portal join token for registering nodes.
func clusterJoinTokenAction(ctx *cli.Context) error {
// Always print a freshly generated token; saving it is optional.
token := rnd.JoinToken()
fmt.Println(token)
if !ctx.Bool("save") {
return nil
}
return CallWithDependencies(ctx, func(conf *config.Config) error {
tokenFile := conf.PortalJoinTokenFile()
if fs.FileExistsNotEmpty(tokenFile) && !RunNonInteractively(ctx.Bool("yes")) {
prompt := promptui.Prompt{Label: fmt.Sprintf("Replace existing join token in %s?", clean.Log(tokenFile)), IsConfirm: true}
if _, err := prompt.Run(); err != nil {
log.Infof("cluster: join token was not updated")
return nil
}
}
_, savedFile, err := conf.SaveJoinToken(token)
if err != nil {
return cli.Exit(fmt.Errorf("failed to write join token: %w", err), 1)
}
log.Infof("cluster: new join token saved to %s", clean.Log(savedFile))
return nil
})
}

View File

@@ -0,0 +1,53 @@
package commands
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
)
func firstLine(s string) string {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return ""
}
if idx := strings.IndexRune(trimmed, '\n'); idx >= 0 {
return trimmed[:idx]
}
return trimmed
}
func TestClusterJoinToken_PrintOnly(t *testing.T) {
out, err := RunWithTestContext(ClusterJoinTokenCommand, []string{"join-token"})
assert.NoError(t, err)
token := firstLine(out)
assert.True(t, rnd.IsJoinToken(token, false))
}
func TestClusterJoinToken_Save(t *testing.T) {
conf := get.Config()
prevRole := conf.Options().NodeRole
conf.Options().NodeRole = cluster.RolePortal
t.Cleanup(func() {
conf.Options().NodeRole = prevRole
})
targetFile := conf.PortalJoinTokenFile()
_ = os.Remove(targetFile)
out, err := RunWithTestContext(ClusterJoinTokenCommand, []string{"join-token", "--save", "--yes"})
assert.NoError(t, err)
token := firstLine(out)
assert.True(t, rnd.IsJoinToken(token, false))
data, readErr := os.ReadFile(conf.PortalJoinTokenFile())
assert.NoError(t, readErr)
assert.Equal(t, token, strings.TrimSpace(string(data)))
}

View File

@@ -75,10 +75,10 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
}
token := ctx.String("join-token")
if token == "" {
token = conf.JoinToken()
token = os.Getenv(config.EnvVar("join-token"))
}
if token == "" {
token = os.Getenv(config.EnvVar("join-token"))
token = conf.JoinToken()
}
// Default: rotate DB only if no flag given (safer default)

View File

@@ -27,6 +27,14 @@ func YesFlag() *cli.BoolFlag {
return &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}
}
// SaveFlag returns a reusable flag definition for commands that can persist generated values.
func SaveFlag(usage string) *cli.BoolFlag {
if usage == "" {
usage = "save the generated value to the configuration"
}
return &cli.BoolFlag{Name: "save", Aliases: []string{"s"}, Usage: usage}
}
// PicturesCountFlag returns a shared flag definition limiting how many pictures a batch operation processes.
// Usage: commands from the vision or import tooling that need to cap result size per invocation.
func PicturesCountFlag() *cli.IntFlag {

View File

@@ -2,6 +2,7 @@ package config
import (
"errors"
"fmt"
urlpkg "net/url"
"os"
"path/filepath"
@@ -126,21 +127,23 @@ func (c *Config) NodeThemeVersion() string {
return ""
}
// JoinToken returns the token required to use the node register API endpoint.
// Example: k9sEFe6-A7gt6zqm-gY9gFh0
// 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 {
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 fileName := c.JoinTokenFile(); 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)
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) {
c.options.JoinToken = s
return s
} else {
log.Warnf("config: portal join token from %s is shorter than %d characters", fileName, rnd.JoinTokenLength)
log.Warnf("config: cluster join token from %s is shorter than %d characters", fileName, rnd.JoinTokenLength)
}
}
@@ -148,32 +151,82 @@ func (c *Config) JoinToken() string {
return ""
}
fileName := filepath.Join(c.PortalConfigPath(), "secrets", "join_token")
token, _, err := c.SaveJoinToken("")
if err != nil {
log.Errorf("config: %v", err)
return ""
}
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
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 err = fs.MkdirAll(dir); err != nil {
return "", "", fmt.Errorf("could not create cluster secrets path (%w)", err)
}
if customToken != "" {
if !rnd.IsJoinToken(customToken, false) {
return "", "", fmt.Errorf("insecure custom cluster join token specified")
}
token = customToken
} 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()
token = rnd.JoinToken()
if !rnd.IsJoinToken(token, true) {
return ""
return "", "", fmt.Errorf("invalid cluster join token generated")
}
}
if err := fs.WriteFile(fileName, []byte(token), fs.ModeSecretFile); err != nil {
log.Errorf("config: could not write portal join token (%s)", err)
return ""
if err = fs.WriteFile(fileName, []byte(token), fs.ModeSecretFile); err != nil {
return "", "", fmt.Errorf("could not write cluster join token (%w)", err)
}
c.options.JoinToken = token
return token
return token, fileName, nil
}
// 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.
@@ -256,7 +309,9 @@ func (c *Config) NodeClientID() string {
return clean.ID(c.options.NodeClientID)
}
// NodeClientSecret returns the OAuth client SECRET registered with the portal (auto-assigned via join token).
// 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
@@ -270,6 +325,43 @@ func (c *Config) NodeClientSecret() string {
}
}
// 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))
}
if err = fs.MkdirAll(dir); err != nil {
return fileName, fmt.Errorf("could not create node secrets path (%s)", err)
}
if err = fs.WriteFile(fileName, []byte(clientSecret), fs.ModeSecretFile); err != nil {
return "", fmt.Errorf("could not write node client secret (%s)", err)
}
c.options.NodeClientSecret = clientSecret
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.

View File

@@ -46,7 +46,7 @@ func TestConfig_PortalUrl(t *testing.T) {
assert.True(t, rnd.IsJoinToken(token, false))
assert.True(t, rnd.IsJoinToken(token, true))
secretFile := filepath.Join(c.PortalConfigPath(), "secrets", "join_token")
secretFile := filepath.Join(c.PortalConfigPath(), fs.SecretsDir, fs.JoinTokenFile)
assert.FileExists(t, secretFile)
info, err := os.Stat(secretFile)
assert.NoError(t, err)
@@ -282,6 +282,67 @@ func TestConfig_Cluster(t *testing.T) {
assert.NoError(t, os.Chtimes(appJS, modTime, modTime))
assert.Equal(t, modTime.Format(time.RFC3339), c.NodeThemeVersion())
})
t.Run("SaveJoinToken", 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
token, tokenFile, err := c.SaveJoinToken("")
assert.NoError(t, err)
assert.True(t, rnd.IsJoinToken(token, false))
assert.FileExists(t, tokenFile)
data, readErr := os.ReadFile(tokenFile)
assert.NoError(t, readErr)
assert.Equal(t, token, strings.TrimSpace(string(data)))
})
t.Run("SaveNodeClientSecret", func(t *testing.T) {
tempCfg := t.TempDir()
ctx := CliTestContext()
assert.NoError(t, ctx.Set("config-path", tempCfg))
c := NewConfig(ctx)
fileName, err := c.SaveNodeClientSecret(cluster.ExampleClientSecret)
assert.NoError(t, err)
assert.FileExists(t, fileName)
data, readErr := os.ReadFile(fileName)
assert.NoError(t, readErr)
assert.Equal(t, cluster.ExampleClientSecret, strings.TrimSpace(string(data)))
})
t.Run("JoinTokenFilePortal", 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
expected := filepath.Join(c.PortalConfigPath(), fs.SecretsDir, fs.JoinTokenFile)
assert.Equal(t, expected, c.JoinTokenFile())
assert.Equal(t, expected, c.PortalJoinTokenFile())
})
t.Run("JoinTokenFileInstance", func(t *testing.T) {
tempCfg := t.TempDir()
ctx := CliTestContext()
assert.NoError(t, ctx.Set("config-path", tempCfg))
c := NewConfig(ctx)
c.options.NodeRole = cluster.RoleInstance
expected := filepath.Join(c.NodeConfigPath(), fs.SecretsDir, fs.JoinTokenFile)
assert.Equal(t, expected, c.JoinTokenFile())
assert.Equal(t, expected, c.NodeJoinTokenFile())
})
t.Run("NodeClientSecretFile", func(t *testing.T) {
tempCfg := t.TempDir()
ctx := CliTestContext()
assert.NoError(t, ctx.Set("config-path", tempCfg))
c := NewConfig(ctx)
expected := filepath.Join(c.NodeConfigPath(), fs.SecretsDir, fs.ClientSecretFile)
assert.Equal(t, expected, c.NodeClientSecretFile())
})
t.Run("AbsolutePaths", func(t *testing.T) {
c := NewConfig(CliTestContext())
tempCfg := t.TempDir()

View File

@@ -24,18 +24,18 @@ const (
EnvTest = "test"
)
// EnvVar returns the name of the environment variable for the specified config flag.
// EnvVar returns the environment variable that backs the given CLI flag name.
func EnvVar(flag string) string {
return clean.EnvVar(flag)
}
// EnvVars returns the names of the environment variable for the specified config flag.
// EnvVars converts a list of flag names to their corresponding environment variables.
func EnvVars(flags ...string) (vars []string) {
return clean.EnvVars(flags...)
}
// Env checks whether the specified boolean command-line or environment flag is set and can be used independently,
// i.e. before the options are initialized with the values found in config files, the environment or CLI flags.
// Env reports whether any of the provided boolean flags are set via environment
// variable or CLI switch, before configuration files are processed.
func Env(vars ...string) bool {
for _, s := range vars {
if (txt.Bool(os.Getenv(EnvVar(s))) || list.Contains(os.Args, "--"+s)) &&
@@ -47,12 +47,12 @@ func Env(vars ...string) bool {
return false
}
// FlagFileVar returns the name of the environment variable that can contain a filename to load a config value from.
// FlagFileVar returns the environment variable that contains a file path for a flag.
func FlagFileVar(flag string) string {
return EnvVar(flag) + "_FILE"
}
// FlagFilePath returns the name of the that contains the value of the specified config flag, if any.
// FlagFilePath resolves the path provided via the *_FILE environment variable for a flag.
func FlagFilePath(flag string) string {
if envVar := os.Getenv(FlagFileVar(flag)); envVar == "" {
return ""

View File

@@ -321,7 +321,9 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota
// Persist node client secret only if missing locally and provided by server.
if r.Secrets != nil && r.Secrets.ClientSecret != "" && c.NodeClientSecret() == "" {
updates.SetNodeClientSecret(r.Secrets.ClientSecret)
if _, err := c.SaveNodeClientSecret(r.Secrets.ClientSecret); err != nil {
return fmt.Errorf("failed to persist node client secret: %w", err)
}
}
if jwksUrl := strings.TrimSpace(r.JWKSUrl); jwksUrl != "" {

View File

@@ -18,6 +18,7 @@ const (
CertificatesDir = "certificates"
NodeDir = "node"
PortalDir = "portal"
SecretsDir = "secrets"
CmdDir = "cmd"
ConfigDir = "config"
ExamplesDir = "examples"
@@ -47,4 +48,6 @@ const (
ManifestJsonFile = "manifest.json"
SwJsFile = "sw.js"
VersionTxtFile = "version.txt"
JoinTokenFile = "join_token"
ClientSecretFile = "client_secret"
)