package agent import ( "bytes" "context" "crypto/ed25519" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "slices" "strings" "time" "sultan/pkg/auth" "sultan/pkg/crypto" "sultan/pkg/server" ) // ErrNotEnrolled: Es gibt noch keine Registrierung und keine (vollständige) Einladung. var ErrNotEnrolled = errors.New("agent: noch nicht registriert – beim ersten Start --token angeben (und --server, falls die Einladung keine Adresse enthält)") // Identity ist das Ergebnis des Enrollments (node.json). ServerFingerprint // ist der Pin: Er gilt für den Server, nicht für eine Adresse – Server kann // sich ändern (DNS-Umzug, NetBird), solange das Zertifikat dasselbe ist. type Identity struct { Server string `json:"server"` ServerFingerprint crypto.Fingerprint `json:"server_fingerprint"` // ServerURLs: weitere Adressen des Servers (per config-Befehl), Ausweichweg. ServerURLs []string `json:"server_urls,omitempty"` NodeID string `json:"node_id"` TenantID string `json:"tenant_id"` MasterPublicKey string `json:"master_public_key"` } func (a *Agent) path(name string) string { return filepath.Join(a.cfg.DataDir, name) } // Enroll stellt sicher, dass der Agent registriert ist: Liegt node.json // vor, wird sie geladen; sonst wird die Einladung eingelöst. Der Server wird // dabei gegen den Fingerprint aus der Einladung geprüft. // // Server-Adresse: --server vor der Adresse aus der Einladung vor node.json. func (a *Agent) Enroll(ctx context.Context) error { if err := os.MkdirAll(a.cfg.DataDir, 0o700); err != nil { return fmt.Errorf("agent: Datenverzeichnis: %w", err) } key, err := loadOrCreateKey(a.path("node.key")) if err != nil { return err } a.setSigner(&auth.Signer{Key: key}) if data, err := os.ReadFile(a.path("node.json")); err == nil { var id Identity if err := json.Unmarshal(data, &id); err != nil { return fmt.Errorf("agent: node.json beschädigt: %w", err) } if id.ServerFingerprint == "" { return errors.New("agent: node.json ohne server_fingerprint – Datenverzeichnis leeren und neu einladen") } if a.cfg.Token != "" { // Eine stehen gebliebene Einladung desselben Servers ist harmlos; // eine Einladung eines anderen Servers ist ein Fehler. if tok, err := crypto.ParseEnrollmentToken(a.cfg.Token); err == nil && tok.Fingerprint != id.ServerFingerprint { return fmt.Errorf("agent: bereits bei einem anderen Server registriert (Fingerprint %s) – für den neuen Server das Datenverzeichnis leeren und neu einladen", id.ServerFingerprint) } } a.id = id a.serverURL = id.Server if a.cfg.ServerURL != "" { a.serverURL = a.cfg.ServerURL } return nil } else if !errors.Is(err, os.ErrNotExist) { return err } if a.cfg.Token == "" { return ErrNotEnrolled } if a.nodeIDErr != nil { return a.nodeIDErr } tok, err := crypto.ParseEnrollmentToken(a.cfg.Token) if err != nil { return err } serverURL := a.cfg.ServerURL if serverURL == "" { serverURL = tok.ServerURL } if serverURL == "" { return fmt.Errorf("%w: die Einladung enthält keine Server-Adresse – --server angeben", ErrNotEnrolled) } hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}} body, _ := json.Marshal(server.EnrollRequest{Token: tok.Secret, NodeID: a.cfg.NodeID, Hostname: a.cfg.Hostname}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL+"/api/v1/enroll", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") if err := a.currentSigner().Sign(req); err != nil { return err } resp, err := hc.Do(req) if err != nil { return fmt.Errorf("agent: Enrollment: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("agent: Enrollment abgelehnt (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) } var er server.EnrollResponse if err := json.NewDecoder(resp.Body).Decode(&er); err != nil { return fmt.Errorf("agent: Enrollment-Antwort: %w", err) } a.id = Identity{ Server: serverURL, ServerFingerprint: tok.Fingerprint, NodeID: er.NodeID, TenantID: er.TenantID, MasterPublicKey: er.MasterPublicKey, } a.serverURL = serverURL if err := writeJSON(a.path("node.json"), a.id); err != nil { return err } a.log.Info("Registriert", "node", er.NodeID, "tenant", er.TenantID, "server", serverURL) return nil } // serverCandidates liefert die Adressen in Versuchsreihenfolge: --server, // zuletzt funktionierende Adresse, dann die Liste des Servers. func (a *Agent) serverCandidates() []string { var out []string for _, u := range append([]string{a.cfg.ServerURL, a.id.Server}, a.id.ServerURLs...) { if u != "" && !slices.Contains(out, u) { out = append(out, u) } } return out } func (a *Agent) currentServerURL() string { a.mu.Lock() defer a.mu.Unlock() return a.serverURL } // nextServerURL wechselt nach einem Verbindungsfehler zur nächsten Adresse. func (a *Agent) nextServerURL() string { a.mu.Lock() defer a.mu.Unlock() cands := a.serverCandidates() next := cands[(slices.Index(cands, a.serverURL)+1)%len(cands)] a.serverURL = next return next } // updateServerURLs übernimmt die Adressliste des Servers (nur gültige). func (a *Agent) updateServerURLs(urls []string) { var valid []string for _, u := range urls { if v, err := crypto.ValidateServerURL(u); err == nil && !slices.Contains(valid, v) { valid = append(valid, v) } else if err != nil { a.log.Warn("Ungültige Server-Adresse ignoriert", "url", u) } } a.mu.Lock() defer a.mu.Unlock() if slices.Equal(valid, a.id.ServerURLs) { return } updated := a.id updated.ServerURLs = valid if err := writeJSON(a.path("node.json"), updated); err != nil { a.log.Warn("Server-Adressen nicht gespeichert", "err", err) return } a.id = updated a.log.Info("Server-Adressen übernommen", "server_urls", valid) } // rememberServerURL übernimmt eine abweichende, erfolgreich genutzte // Adresse in node.json (der Pin bleibt unverändert). func (a *Agent) rememberServerURL() { a.mu.Lock() defer a.mu.Unlock() if a.id.Server == a.serverURL { return } updated := a.id updated.Server = a.serverURL if err := writeJSON(a.path("node.json"), updated); err != nil { a.log.Warn("Neue Server-Adresse nicht gespeichert", "err", err) return } a.id = updated a.log.Info("Neue Server-Adresse übernommen", "server", a.serverURL) } func loadOrCreateKey(path string) (ed25519.PrivateKey, error) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { key, err := auth.GenerateKey() if err != nil { return nil, err } if err := auth.WritePrivateKey(path, key); err != nil { return nil, err } } return auth.ReadPrivateKey(path) } // writeJSON schreibt atomar (temporäre Datei + rename) mit Rechten 0600. func writeJSON(path string, v any) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { return err } return os.Rename(tmp, path) } // loadIdentity lädt Identität und Schlüssel eines registrierten Agenten, // ohne etwas anzulegen (für Befehle neben dem laufenden Agenten). func (a *Agent) loadIdentity() error { data, err := os.ReadFile(a.path("node.json")) if errors.Is(err, os.ErrNotExist) { return ErrNotEnrolled } if err != nil { return err } if err := json.Unmarshal(data, &a.id); err != nil { return fmt.Errorf("agent: node.json beschädigt: %w", err) } key, err := auth.ReadPrivateKey(a.path("node.key")) if err != nil { return err } a.setSigner(&auth.Signer{Key: key}) a.serverURL = a.id.Server if a.cfg.ServerURL != "" { a.serverURL = a.cfg.ServerURL } return nil } // SetMaintenance meldet beim Server Wartung für diesen Knoten an (on) oder // ab. d = 0 bedeutet unbefristet; reason ist intern, publicReason erscheint // auf der Wartungsseite. func (a *Agent) SetMaintenance(ctx context.Context, on bool, d time.Duration, reason, publicReason string) error { if err := a.loadIdentity(); err != nil { return err } req := server.MaintenanceRequest{Enabled: on} if on { req.Duration, req.Reason, req.PublicReason = d.String(), reason, publicReason } body, _ := json.Marshal(req) hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.serverURL+"/api/v1/maintenance", bytes.NewReader(body)) if err != nil { return err } hreq.Header.Set("Content-Type", "application/json") if err := a.currentSigner().Sign(hreq); err != nil { return err } hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.id.ServerFingerprint)}} resp, err := hc.Do(hreq) if err != nil { return fmt.Errorf("agent: Wartung melden: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("agent: Server lehnt Wartung ab (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) } return nil }