software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10298 -480

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
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
}