Files
sultan/pkg/agent/identity.go
T
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

287 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}