Auth: Return and persist ClusterCIDR when registering a node #98 #5230

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-26 06:17:31 +02:00
parent 66e2027c10
commit 9f119a8cfa
9 changed files with 69 additions and 34 deletions

View File

@@ -229,6 +229,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
resp := cluster.RegisterResponse{
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Node: reg.BuildClusterNode(*n, opts),
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: dbInfo.Name, User: dbInfo.User, Driver: provisioner.DatabaseDriver},
Secrets: respSecret,
@@ -299,6 +300,8 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
}
resp := cluster.RegisterResponse{
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
Secrets: &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt},
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Driver: provisioner.DatabaseDriver, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.RotatedAt},

View File

@@ -46,10 +46,11 @@ func ClusterSummary(router *gin.RouterGroup) {
nodes, _ := regy.List()
c.JSON(http.StatusOK, cluster.SummaryResponse{
UUID: conf.ClusterUUID(),
Nodes: len(nodes),
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339),
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Nodes: len(nodes),
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339),
})
})
}

View File

@@ -419,6 +419,9 @@
"alreadyRegistered": {
"type": "boolean"
},
"clusterCidr": {
"type": "string"
},
"database": {
"$ref": "#/definitions/cluster.RegisterDatabase"
},
@@ -459,6 +462,9 @@
},
"cluster.SummaryResponse": {
"properties": {
"clusterCidr": {
"type": "string"
},
"database": {
"$ref": "#/definitions/cluster.DatabaseInfo"
},

View File

@@ -20,6 +20,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
"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"
)
@@ -330,6 +331,16 @@ func parseLabelSlice(labels []string) map[string]string {
// Persistence helpers for --write-config
func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error {
updates := map[string]any{}
if rnd.IsUUID(resp.UUID) {
updates["ClusterUUID"] = resp.UUID
}
if cidr := strings.TrimSpace(resp.ClusterCIDR); cidr != "" {
updates["ClusterCIDR"] = cidr
}
// Node client secret file
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
// Prefer PHOTOPRISM_NODE_CLIENT_SECRET_FILE; otherwise config cluster path
@@ -348,16 +359,18 @@ func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse
// DB settings (MySQL/MariaDB only)
if resp.Database.Name != "" && resp.Database.User != "" {
if err := mergeOptionsYaml(conf, map[string]any{
"DatabaseDriver": config.MySQL,
"DatabaseName": resp.Database.Name,
"DatabaseServer": fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port),
"DatabaseUser": resp.Database.User,
"DatabasePassword": resp.Database.Password,
}); err != nil {
updates["DatabaseDriver"] = config.MySQL
updates["DatabaseName"] = resp.Database.Name
updates["DatabaseServer"] = fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port)
updates["DatabaseUser"] = resp.Database.User
updates["DatabasePassword"] = resp.Database.Password
}
if len(updates) > 0 {
if err := mergeOptionsYaml(conf, updates); err != nil {
return err
}
log.Infof("updated options.yml with database settings for node %s", clean.LogQuote(resp.Node.Name))
log.Infof("updated options.yml with cluster registration settings for node %s", clean.LogQuote(resp.Node.Name))
}
return nil
}

View File

@@ -36,10 +36,11 @@ func clusterSummaryAction(ctx *cli.Context) error {
nodes, _ := r.List()
resp := cluster.SummaryResponse{
UUID: conf.ClusterUUID(),
Nodes: len(nodes),
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339),
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Nodes: len(nodes),
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339),
}
if ctx.Bool("json") {
@@ -48,8 +49,8 @@ func clusterSummaryAction(ctx *cli.Context) error {
return nil
}
cols := []string{"Portal UUID", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"}
rows := [][]string{{resp.UUID, fmt.Sprintf("%d", resp.Nodes), resp.Database.Driver, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port), resp.Time}}
cols := []string{"Portal UUID", "Cluster CIDR", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"}
rows := [][]string{{resp.UUID, resp.ClusterCIDR, fmt.Sprintf("%d", resp.Nodes), resp.Database.Driver, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port), resp.Time}}
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
return err

View File

@@ -95,9 +95,10 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
// Return NodeClientID and a fresh secret
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
UUID: rnd.UUID(),
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
UUID: rnd.UUID(),
ClusterCIDR: "203.0.113.0/24",
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
})
case "/api/v1/oauth/token":
// Expect Basic for the returned creds

View File

@@ -219,6 +219,10 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota
updates["ClusterUUID"] = r.UUID
}
if cidr := strings.TrimSpace(r.ClusterCIDR); cidr != "" {
updates["ClusterCIDR"] = cidr
}
// Always persist NodeClientID (client UID) from response for future OAuth token requests.
if r.Node.ClientID != "" {
updates["NodeClientID"] = r.Node.ClientID

View File

@@ -37,10 +37,11 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
resp := cluster.RegisterResponse{
Node: cluster.Node{Name: "pp-node-01"},
UUID: rnd.UUID(),
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
JWKSUrl: jwksURL,
Node: cluster.Node{Name: "pp-node-01"},
UUID: rnd.UUID(),
ClusterCIDR: "192.0.2.0/24",
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
JWKSUrl: jwksURL,
Database: cluster.RegisterDatabase{
Driver: config.MySQL,
Host: "db.local",
@@ -84,6 +85,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
assert.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db")
assert.Equal(t, config.MySQL, c.Options().DatabaseDriver)
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
assert.Equal(t, "192.0.2.0/24", c.ClusterCIDR())
}
func TestThemeInstall_Missing(t *testing.T) {
@@ -101,7 +103,7 @@ func TestThemeInstall_Missing(t *testing.T) {
case "/api/v1/cluster/nodes/register":
w.Header().Set("Content-Type", "application/json")
// Return NodeClientID + NodeClientSecret so bootstrap can request OAuth token
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}, JWKSUrl: jwksURL2})
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), ClusterCIDR: "198.51.100.0/24", Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}, JWKSUrl: jwksURL2})
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"})
@@ -148,10 +150,11 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
resp := cluster.RegisterResponse{
Node: cluster.Node{Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
JWKSUrl: jwksURL3,
Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
Node: cluster.Node{Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
ClusterCIDR: "203.0.113.0/24",
JWKSUrl: jwksURL3,
Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
}
_ = json.NewEncoder(w).Encode(resp)
default:
@@ -179,6 +182,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
assert.Equal(t, config.SQLite3, c.DatabaseDriver())
assert.Equal(t, origDSN, c.Options().DatabaseDSN)
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
assert.Equal(t, "203.0.113.0/24", c.ClusterCIDR())
}
func TestRegister_404_NoRetry(t *testing.T) {

View File

@@ -35,10 +35,11 @@ type DatabaseInfo struct {
// SummaryResponse is the response type for GET /api/v1/cluster.
// swagger:model SummaryResponse
type SummaryResponse struct {
UUID string `json:"uuid"` // ClusterUUID
Nodes int `json:"nodes"`
Database DatabaseInfo `json:"database"`
Time string `json:"time"`
UUID string `json:"uuid"` // ClusterUUID
ClusterCIDR string `json:"clusterCidr,omitempty"`
Nodes int `json:"nodes"`
Database DatabaseInfo `json:"database"`
Time string `json:"time"`
}
// RegisterSecrets contains newly issued or rotated node secrets.
@@ -65,6 +66,7 @@ type RegisterDatabase struct {
// swagger:model RegisterResponse
type RegisterResponse struct {
UUID string `json:"uuid"` // ClusterUUID
ClusterCIDR string `json:"clusterCidr,omitempty"`
Node Node `json:"node"`
Database RegisterDatabase `json:"database"`
Secrets *RegisterSecrets `json:"secrets,omitempty"`