mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Security: Improve credential handling across the cluster tooling #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -20,6 +20,7 @@ var ClusterCommands = &cli.Command{
|
||||
ClusterHealthCommand,
|
||||
ClusterNodesCommands,
|
||||
ClusterRegisterCommand,
|
||||
ClusterJoinTokenCommand,
|
||||
ClusterThemePullCommand,
|
||||
},
|
||||
}
|
||||
|
||||
57
internal/commands/cluster_join_token.go
Normal file
57
internal/commands/cluster_join_token.go
Normal 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
|
||||
})
|
||||
}
|
||||
53
internal/commands/cluster_join_token_test.go
Normal file
53
internal/commands/cluster_join_token_test.go
Normal 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)))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user