software steht so weit.
jetzt build + docker build und testen
This commit is contained in:
1 parent
42f0c0accb
commit
ca6cdcca63
90 files changed
+10298
-480
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user