176 lines
5.0 KiB
Go
176 lines
5.0 KiB
Go
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
|
||
}
|