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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user