feat(setup): Cluster-Node-Modus im Setup-Wizard + Install-Script-Port-Fix

Setup-Wizard zeigt jetzt beim ersten Aufruf eine Modusauswahl:
- "Neuinstallation" → bisheriger Flow (Admin-Account + FQDN + ACME)
- "Cluster-Knoten beitreten" → nur FQDN + ACME, kein Admin-Account;
  nach dem Submit werden die cluster-join-Befehle direkt angezeigt

Backend: NodeRequest + Store.CompleteAsNode() + POST /setup/complete-node
State: is_cluster_node Flag; login via PG-Replikation vom Primary

Install-Script: Setup-URL zeigt jetzt korrekt :3443/setup statt /setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 16:07:19 +02:00
parent a0b3bccbc6
commit 7e5a7a83c0
10 changed files with 782 additions and 359 deletions

View File

@@ -34,6 +34,7 @@ func (h *SetupHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/setup")
g.GET("/status", h.Status)
g.POST("/complete", h.Complete)
g.POST("/complete-node", h.CompleteAsNode)
}
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
@@ -111,3 +112,21 @@ func (h *SetupHandler) Complete(c *gin.Context) {
"fqdn": st.FQDN,
})
}
func (h *SetupHandler) CompleteAsNode(c *gin.Context) {
var req setup.NodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
st, err := h.Store.CompleteAsNode(req)
if err != nil {
response.BadRequest(c, err)
return
}
response.OK(c, gin.H{
"completed": st.Completed,
"is_cluster_node": st.IsClusterNode,
"fqdn": st.FQDN,
})
}

View File

@@ -38,6 +38,9 @@ type State struct {
LicenseKey string `json:"license_key,omitempty"`
Completed bool `json:"completed"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
// IsClusterNode marks this node as a secondary cluster member.
// Admin credentials are absent — they arrive via PG replication.
IsClusterNode bool `json:"is_cluster_node,omitempty"`
// MaintenanceMode (whole-box): wenn true rendert HAProxy auf
// :443 nur einen 503-Block für ALLE Customer-Domains. Mgmt-UI
@@ -75,6 +78,13 @@ type Request struct {
LicenseKey string `json:"license_key,omitempty"`
}
// NodeRequest is the JSON body POST /api/v1/setup/complete-node accepts.
// No admin credentials — they are replicated from the primary via PG.
type NodeRequest struct {
FQDN string `json:"fqdn" binding:"required"`
ACMEEmail string `json:"acme_email" binding:"required,email"`
}
type Store struct {
Dir string
}
@@ -155,6 +165,37 @@ func (s *Store) Complete(req Request) (*State, error) {
return st, nil
}
// CompleteAsNode configures this node as a secondary cluster member.
// No admin credentials are created; login becomes available after PG
// replication brings users from the primary.
func (s *Store) CompleteAsNode(req NodeRequest) (*State, error) {
if !looksLikeFQDN(req.FQDN) {
return nil, fmt.Errorf("fqdn %q does not look like a fully-qualified hostname", req.FQDN)
}
if _, err := mail.ParseAddress(req.ACMEEmail); err != nil {
return nil, fmt.Errorf("invalid acme_email: %w", err)
}
prev, err := s.Load()
if err != nil {
return nil, err
}
if prev.Completed {
return nil, errors.New("setup already completed")
}
now := time.Now().UTC()
st := &State{
FQDN: strings.TrimSpace(req.FQDN),
ACMEEmail: strings.ToLower(strings.TrimSpace(req.ACMEEmail)),
IsClusterNode: true,
Completed: true,
CompletedAt: &now,
}
if err := s.Save(st); err != nil {
return nil, err
}
return st, nil
}
// VerifyAdminPassword does constant-time bcrypt comparison.
func (st *State) VerifyAdminPassword(plaintext string) bool {
return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil