287 lines
9.0 KiB
Go
287 lines
9.0 KiB
Go
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
|
||
}
|