Files
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

176 lines
5.0 KiB
Go
Raw Permalink 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"
"strings"
"sultan/pkg/auth"
"sultan/pkg/license"
"sultan/pkg/server"
"sultan/pkg/stream"
)
// currentSigner liefert den aktuellen Signierer (rotate_key tauscht ihn aus).
func (a *Agent) currentSigner() *auth.Signer {
a.keyMu.RLock()
defer a.keyMu.RUnlock()
return a.signer
}
func (a *Agent) setSigner(s *auth.Signer) {
a.keyMu.Lock()
a.signer = s
a.keyMu.Unlock()
}
// handleEvent verarbeitet Events vom Server. Nur Events mit gültiger
// Signatur des Master-Keys werden beachtet; Fehler beenden nie den Stream.
func (a *Agent) handleEvent(ev stream.Event) error {
pub, err := auth.DecodePublicKey(a.id.MasterPublicKey)
if err != nil {
a.log.Error("Master-Key in node.json unlesbar – Event verworfen", "err", err)
return nil
}
if err := stream.VerifyEvent(ev, pub); err != nil {
a.log.Warn("Event ohne gültige Server-Signatur verworfen", "type", ev.Type, "err", err)
return nil
}
switch ev.Type {
case stream.EventLeaseRenew:
a.recordLease(ev.Payload, pub)
case stream.EventNodeCommand:
cmd, err := stream.ParseNodeCommand(ev)
if err != nil {
a.log.Warn("Befehl unlesbar", "err", err)
return nil
}
a.runCommand(cmd)
default:
a.log.Info("Ereignis vom Server", "type", ev.Type)
}
return nil
}
// recordLease prüft die Lease, legt sie als lease.json ab und protokolliert
// sie (vorerst ohne weitere Konsequenz, Spec 4a).
func (a *Agent) recordLease(doc []byte, pub ed25519.PublicKey) {
l, err := license.ParseLease(doc, pub)
if err != nil {
a.log.Warn("Lease ungültig – verworfen", "err", err)
return
}
if l.NodeID != a.id.NodeID {
a.log.Warn("Lease für anderen Knoten – verworfen", "node", l.NodeID)
return
}
tmp := a.path("lease.json.tmp")
if err := os.WriteFile(tmp, doc, 0o600); err == nil {
err = os.Rename(tmp, a.path("lease.json"))
}
if err != nil {
a.log.Warn("lease.json nicht gespeichert", "err", err)
}
a.log.Info("Lease erhalten", "valid_until", l.ValidUntil, "renew_after", l.RenewAfter, "tier", l.Tier)
}
func (a *Agent) runCommand(cmd stream.NodeCommand) {
switch cmd.Command {
case stream.CmdResync:
a.log.Info("Server fordert Zustand an (resync)")
if a.kick != nil {
a.kick()
}
case stream.CmdRotateKey:
a.log.Info("Server fordert Schlüsselwechsel an", "reason", cmd.Reason)
ctx := a.runCtx
if ctx == nil {
ctx = context.Background()
}
if err := a.rotateKey(ctx); err != nil {
a.log.Error("Schlüsselwechsel fehlgeschlagen", "err", err)
}
case stream.CmdConfig:
a.updateServerURLs(cmd.ServerURLs)
case stream.CmdDrain, stream.CmdMaintenance, stream.CmdResume:
a.log.Info("Hinweis vom Server", "command", cmd.Command, "reason", cmd.Reason)
default:
a.log.Warn("Unbekannter Befehl ignoriert", "command", cmd.Command)
}
}
// rotateKey erzeugt einen neuen Schlüssel (node.key.new) und meldet ihn.
func (a *Agent) rotateKey(ctx context.Context) error {
newPath := a.path("node.key.new")
if _, err := os.Stat(newPath); errors.Is(err, os.ErrNotExist) {
key, err := auth.GenerateKey()
if err != nil {
return err
}
if err := auth.WritePrivateKey(newPath, key); err != nil {
return err
}
}
return a.completeRotation(ctx)
}
// completeRotation meldet node.key.new beim Server und benennt die Datei erst
// danach um. Abbruch-sicher: Lehnt der Server den alten Schlüssel ab (er hat
// den neuen schon), wird mit dem neuen wiederholt.
func (a *Agent) completeRotation(ctx context.Context) error {
newPath := a.path("node.key.new")
newKey, err := auth.ReadPrivateKey(newPath)
if err != nil {
return err
}
body, _ := json.Marshal(server.RotateKeyRequest{
PublicKey: auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey)),
Proof: auth.RotationProof(a.id.NodeID, newKey),
})
newSigner := &auth.Signer{Key: newKey}
status, err := a.postSigned(ctx, "/api/v1/rotate-key", body, a.currentSigner())
if err == nil && status == http.StatusUnauthorized {
status, err = a.postSigned(ctx, "/api/v1/rotate-key", body, newSigner)
}
if err != nil {
return err
}
if status != http.StatusNoContent {
return fmt.Errorf("Server lehnt Schlüsselwechsel ab (%d)", status)
}
if err := os.Rename(newPath, a.path("node.key")); err != nil {
return err
}
a.setSigner(newSigner)
a.log.Info("Knotenschlüssel gewechselt")
return nil
}
func (a *Agent) postSigned(ctx context.Context, path string, body []byte, signer *auth.Signer) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.currentServerURL()+path, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
if err := signer.Sign(req); err != nil {
return 0, err
}
resp, err := a.httpClient().Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return resp.StatusCode, fmt.Errorf("Server antwortet %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
return resp.StatusCode, nil
}