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

+6
View File
@@ -38,6 +38,9 @@ type Client struct {
MinBackoff, MaxBackoff time.Duration
// OnError wird in Run vor jeder Wartezeit mit dem Grund aufgerufen.
OnError func(err error, retryIn time.Duration)
// OnConnect wird nach jedem erfolgreichen Verbindungsaufbau aufgerufen,
// bevor Events gelesen werden – z. B. um den eigenen Zustand neu zu senden.
OnConnect func()
sleep func(ctx context.Context, d time.Duration) error // für Tests ersetzbar
}
@@ -102,6 +105,9 @@ func (c *Client) connect(ctx context.Context, handle func(Event) error) (connect
return false, fmt.Errorf("%w: Content-Type %q", ErrUnexpectedResponse, resp.Header.Get("Content-Type"))
}
if c.OnConnect != nil {
c.OnConnect()
}
idle := c.idleTimeout()
timer := time.AfterFunc(idle, func() { cancel(ErrIdleTimeout) })
defer timer.Stop()
+33
View File
@@ -331,3 +331,36 @@ func TestSleepCtx(t *testing.T) {
t.Error("sleepCtx hat trotz Abbruch gewartet")
}
}
// OnConnect wird nach jedem erfolgreichen Verbindungsaufbau aufgerufen –
// nicht bei Fehlantworten.
func TestClient_OnConnect(t *testing.T) {
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requests.Add(1)%2 == 1 {
http.Error(w, "später", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/event-stream")
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var connects, sleeps atomic.Int32
c := &Client{
URL: srv.URL,
OnConnect: func() { connects.Add(1) },
sleep: func(ctx context.Context, d time.Duration) error {
if sleeps.Add(1) == 4 {
cancel()
return ctx.Err()
}
return nil
},
}
c.Run(ctx, func(Event) error { return nil })
if n := connects.Load(); n != 2 {
t.Errorf("OnConnect %d-mal, erwartet 2 (bei 4 Versuchen, davon 2 erfolgreich)", n)
}
}
+3
View File
@@ -42,6 +42,9 @@ type Event struct {
Type EventType `json:"type"`
NodeID string `json:"node_id"`
Payload json.RawMessage `json:"payload,omitempty"`
// Signature: Ed25519-Signatur des Servers über alle übrigen Felder
// (SignEvent/VerifyEvent); der Agent verwirft Events ohne gültige Signatur.
Signature string `json:"signature,omitempty"`
}
// NewEvent erzeugt ein Event mit neuer ID, aktuellem Zeitstempel und der als
+16 -6
View File
@@ -1,6 +1,7 @@
package stream
import (
"crypto/ed25519"
"errors"
"fmt"
"net/http"
@@ -41,6 +42,9 @@ type Hub struct {
// nur ein weiteres OnConnect, kein OnDisconnect.
OnConnect func(nodeID string)
OnDisconnect func(nodeID string)
// SigningKey: Ist er gesetzt, signiert der Hub jedes Event, nachdem er ID,
// Zeitstempel und Knoten-ID ergänzt hat.
SigningKey ed25519.PrivateKey
mu sync.Mutex
conns map[string]*conn
@@ -86,6 +90,15 @@ func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error
return ErrHubClosed
}
// Erst registrieren, dann die Header senden: Sobald der Client die
// Antwort sieht, muss der Knoten als verbunden gelten (der Agent schickt
// direkt danach seinen Zustand).
c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
if err := h.register(nodeID, c); err != nil {
return err
}
defer h.unregister(nodeID, c)
rc := http.NewResponseController(w)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
@@ -98,12 +111,6 @@ func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error
return err
}
c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
if err := h.register(nodeID, c); err != nil {
return err
}
defer h.unregister(nodeID, c)
ticker := time.NewTicker(h.keepAlive())
defer ticker.Stop()
write := func(p []byte) error {
@@ -185,6 +192,9 @@ func (h *Hub) Send(nodeID string, ev Event) error {
ev.Timestamp = time.Now().Unix()
}
ev.NodeID = nodeID
if h.SigningKey != nil {
ev = SignEvent(ev, h.SigningKey)
}
frame, err := encode(ev)
if err != nil {
return err
+13
View File
@@ -404,3 +404,16 @@ func TestHub_Disconnect(t *testing.T) {
t.Errorf("zweites Disconnect: erwartet ErrNodeOffline, bekommen %v", err)
}
}
// Sobald der Client die Antwort-Header hat, muss der Knoten registriert sein –
// sonst scheitert ein direkt folgender Request ("Stream nicht verbunden").
func TestHub_ConnectedWhenHeadersArrive(t *testing.T) {
for range 50 {
h := &Hub{}
srv, _ := hubServer(t, h)
openStream(t, context.Background(), srv, "n")
if !h.Connected("n") {
t.Fatal("Header empfangen, Knoten aber noch nicht registriert")
}
}
}
+78
View File
@@ -0,0 +1,78 @@
package stream
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
)
var (
// ErrUnsigned: Das Event trägt keine Signatur.
ErrUnsigned = errors.New("stream: Event nicht signiert")
// ErrBadSignature: Die Signatur passt nicht (fremder Schlüssel oder verändertes Event).
ErrBadSignature = errors.New("stream: Event-Signatur ungültig")
)
const eventSignatureVersion = "SULTAN-EVENT-V1\n"
func eventMessage(ev Event) []byte {
ev.Signature = ""
b, _ := json.Marshal(ev)
return append([]byte(eventSignatureVersion), b...)
}
// SignEvent signiert ev (alle Felder außer der Signatur) mit dem Master-Key.
func SignEvent(ev Event, key ed25519.PrivateKey) Event {
ev.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(key, eventMessage(ev)))
return ev
}
// VerifyEvent prüft die Signatur mit dem Public Key des Master-Keys.
func VerifyEvent(ev Event, pub ed25519.PublicKey) error {
if ev.Signature == "" {
return ErrUnsigned
}
sig, err := base64.StdEncoding.DecodeString(ev.Signature)
if err != nil || len(sig) != ed25519.SignatureSize || len(pub) != ed25519.PublicKeySize {
return ErrBadSignature
}
if !ed25519.Verify(pub, eventMessage(ev), sig) {
return ErrBadSignature
}
return nil
}
// Befehle in node_command – eine feste Liste, keine beliebigen Kommandos.
const (
CmdResync = "resync" // Agent sendet sofort seinen Zustand
CmdRotateKey = "rotate_key" // Agent erzeugt einen neuen node.key
CmdDrain = "drain" // Hinweis: Routen entfernt
CmdMaintenance = "maintenance" // Hinweis: Wartung angemeldet
CmdResume = "resume" // Hinweis: Wartung/drain beendet
CmdConfig = "config" // Server-Adressen (ServerURLs) für den Ausweichweg
)
// Commands sind alle bekannten Befehle.
var Commands = []string{CmdResync, CmdRotateKey, CmdDrain, CmdMaintenance, CmdResume, CmdConfig}
// NodeCommand ist die Payload eines node_command-Events.
type NodeCommand struct {
Command string `json:"command"`
Reason string `json:"reason,omitempty"`
// ServerURLs: bei config die Adressen, unter denen der Server erreichbar ist.
ServerURLs []string `json:"server_urls,omitempty"`
}
// ParseNodeCommand liest die Payload eines node_command-Events.
func ParseNodeCommand(ev Event) (NodeCommand, error) {
if ev.Type != EventNodeCommand {
return NodeCommand{}, fmt.Errorf("%w: kein node_command", ErrInvalidEvent)
}
var c NodeCommand
if err := json.Unmarshal(ev.Payload, &c); err != nil || c.Command == "" {
return NodeCommand{}, fmt.Errorf("%w: Befehl fehlt", ErrInvalidEvent)
}
return c, nil
}
+86
View File
@@ -0,0 +1,86 @@
package stream
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"errors"
"testing"
)
func TestSignAndVerifyEvent(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
ev := Event{ID: "evt_1", Timestamp: 42, Type: EventNodeCommand, NodeID: "n1", Payload: json.RawMessage(`{"command":"resync"}`)}
signed := SignEvent(ev, priv)
if signed.Signature == "" {
t.Fatal("keine Signatur")
}
if err := VerifyEvent(signed, pub); err != nil {
t.Errorf("Verify: %v", err)
}
otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
for name, tc := range map[string]struct {
ev Event
pub ed25519.PublicKey
err error
}{
"unsigniert": {ev, pub, ErrUnsigned},
"fremder Schlüssel": {signed, otherPub, ErrBadSignature},
"Payload geändert": {func() Event { e := signed; e.Payload = json.RawMessage(`{"command":"rotate_key"}`); return e }(), pub, ErrBadSignature},
"Knoten geändert": {func() Event { e := signed; e.NodeID = "n2"; return e }(), pub, ErrBadSignature},
"Signatur Müll": {func() Event { e := signed; e.Signature = "!!!"; return e }(), pub, ErrBadSignature},
} {
if err := VerifyEvent(tc.ev, tc.pub); !errors.Is(err, tc.err) {
t.Errorf("%s: erwartet %v, bekommen %v", name, tc.err, err)
}
}
}
// Mit SigningKey signiert der Hub jedes Event – nachdem er ID, Zeitstempel
// und Knoten-ID ergänzt hat, damit auch diese geschützt sind.
func TestHub_SignsEvents(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
h := &Hub{SigningKey: priv}
srv, _ := hubServer(t, h)
resp := openStream(t, context.Background(), srv, "n1")
if err := h.Send("n1", Event{Type: EventNodeCommand, Payload: json.RawMessage(`{"command":"resync"}`)}); err != nil {
t.Fatal(err)
}
ev, err := NewDecoder(resp.Body).Next()
if err != nil {
t.Fatal(err)
}
if ev.NodeID != "n1" || ev.ID == "" {
t.Errorf("Event: %+v", ev)
}
if err := VerifyEvent(ev, pub); err != nil {
t.Errorf("Signatur nach Übertragung ungültig: %v", err)
}
}
func TestNodeCommandPayload(t *testing.T) {
ev, err := NewEvent(EventNodeCommand, NodeCommand{Command: CmdRotateKey, Reason: "Routine"})
if err != nil {
t.Fatal(err)
}
cmd, err := ParseNodeCommand(ev)
if err != nil || cmd.Command != CmdRotateKey || cmd.Reason != "Routine" {
t.Errorf("ParseNodeCommand: %+v %v", cmd, err)
}
if _, err := ParseNodeCommand(Event{Type: EventLeaseRenew}); err == nil {
t.Error("falscher Event-Typ akzeptiert")
}
if _, err := ParseNodeCommand(Event{Type: EventNodeCommand, Payload: json.RawMessage(`{}`)}); err == nil {
t.Error("Befehl ohne Namen akzeptiert")
}
}
func TestNodeCommandServerURLs(t *testing.T) {
ev, _ := NewEvent(EventNodeCommand, NodeCommand{Command: CmdConfig, ServerURLs: []string{"https://a:8443", "https://b:8443"}})
cmd, err := ParseNodeCommand(ev)
if err != nil || cmd.Command != CmdConfig || len(cmd.ServerURLs) != 2 || cmd.ServerURLs[1] != "https://b:8443" {
t.Errorf("config: %+v %v", cmd, err)
}
}