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

+4
View File
@@ -66,3 +66,7 @@ func (l License) AllowsDomain(host string) bool {
}
return false
}
// NormalizeDomain prüft und normalisiert einen Eintrag für allowed_domains
// (klein, ohne Punkt am Ende; Wildcard nur vorne, nicht auf einer TLD).
func NormalizeDomain(p string) (string, error) { return normalizePattern(p) }
+9
View File
@@ -55,3 +55,12 @@ func strings63() string {
}
return string(b)
}
func TestNormalizeDomain_Exported(t *testing.T) {
if got, err := NormalizeDomain(" Shop.DE. "); err != nil || got != "shop.de" {
t.Errorf("NormalizeDomain = %q, %v", got, err)
}
if _, err := NormalizeDomain("*.de"); err == nil {
t.Error("*.de akzeptiert")
}
}
+153
View File
@@ -0,0 +1,153 @@
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"math/rand/v2"
"strings"
"time"
)
// Laufzeiten der Leases (Spec 4.1a).
const (
CommunityLease = 14 * 24 * time.Hour
CommunityRenewAfter = 7 * 24 * time.Hour
CommunityRenewJitter = 12 * time.Hour
CommercialLease = 7 * 24 * time.Hour
CommercialMaintenance = 10 * 24 * time.Hour
)
// LeaseTerms sind Erneuerungs- und Ablaufzeitpunkt einer Lease.
type LeaseTerms struct {
RenewAfter time.Time
ValidUntil time.Time
}
// TermsFor berechnet die Lease-Zeiten. Kommerziell: 7 Tage (in Wartung 10),
// Erneuerung nach 2/3, höchstens bis zum Vertragsende; nach Vertragsende
// gibt es keine Lease (ok=false). Community: 14 Tage, Erneuerung nach
// 7 Tagen ± Streuung (jitter liefert einen Wert in [-max, +max]; nil =
// zufällig), Vertragsende ohne Bedeutung. override > 0 setzt eine feste
// Laufzeit (Tests, Betrieb), Erneuerung dann nach 2/3.
func TermsFor(tier Tier, maintenance bool, licenseUntil, now time.Time, override time.Duration,
jitter func(max time.Duration) time.Duration) (LeaseTerms, bool) {
commercial := tier != TierCommunity
if commercial && !now.Before(licenseUntil) {
return LeaseTerms{}, false
}
var t LeaseTerms
switch {
case override > 0:
t.ValidUntil = now.Add(override)
case !commercial:
if jitter == nil {
jitter = randomJitter
}
return LeaseTerms{
ValidUntil: now.Add(CommunityLease),
RenewAfter: now.Add(CommunityRenewAfter + jitter(CommunityRenewJitter)),
}, true
case maintenance:
t.ValidUntil = now.Add(CommercialMaintenance)
default:
t.ValidUntil = now.Add(CommercialLease)
}
if commercial && t.ValidUntil.After(licenseUntil) {
t.ValidUntil = licenseUntil
}
t.RenewAfter = now.Add(t.ValidUntil.Sub(now) * 2 / 3)
return t, true
}
func randomJitter(max time.Duration) time.Duration {
return time.Duration(rand.Int64N(int64(2*max)+1)) - max
}
// Lease ist die signierte, kurzlaufende Kopie der Lizenz für einen Knoten.
type Lease struct {
NodeID string `json:"node_id"`
TenantID string `json:"tenant_id"`
Tier Tier `json:"tier"`
MaxRoutes int `json:"max_routes"`
AllowedDomains []string `json:"allowed_domains"`
Features []Feature `json:"features"`
IssuedAt time.Time `json:"issued_at"`
RenewAfter time.Time `json:"renew_after"`
ValidUntil time.Time `json:"valid_until"`
Signature string `json:"signature"`
}
// Eigenes Präfix: Eine Lease-Signatur gilt nie als Lizenz-Signatur und umgekehrt.
const leaseSignatureVersion = "SULTAN-LEASE-V1\n"
func (l Lease) message() []byte {
c := l
c.Signature = ""
c.IssuedAt, c.RenewAfter, c.ValidUntil = c.IssuedAt.UTC(), c.RenewAfter.UTC(), c.ValidUntil.UTC()
b, _ := json.Marshal(c)
return append([]byte(leaseSignatureVersion), b...)
}
// IssueLease stellt eine Lease für nodeID aus den Werten von l aus.
func (i *Issuer) IssueLease(l License, nodeID string, terms LeaseTerms, now time.Time) (Lease, error) {
if len(i.Key) != ed25519.PrivateKeySize {
return Lease{}, fmt.Errorf("license: Issuer ohne gültigen Master-Key")
}
if strings.TrimSpace(nodeID) == "" {
return Lease{}, fmt.Errorf("%w: node_id fehlt", ErrInvalid)
}
utc := func(t time.Time) time.Time { return t.Round(0).UTC() }
lease := Lease{
NodeID: nodeID,
TenantID: l.TenantID,
Tier: l.Tier,
MaxRoutes: l.MaxRoutes,
AllowedDomains: append([]string{}, l.AllowedDomains...),
Features: append([]Feature{}, l.Features...),
IssuedAt: utc(now),
RenewAfter: utc(terms.RenewAfter),
ValidUntil: utc(terms.ValidUntil),
}
lease.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(i.Key, lease.message()))
return lease, nil
}
// Verify prüft die Signatur mit dem Public Key des Master-Keys.
func (l Lease) Verify(pub ed25519.PublicKey) error {
sig, err := base64.StdEncoding.DecodeString(l.Signature)
if err != nil || len(sig) != ed25519.SignatureSize || len(pub) != ed25519.PublicKeySize {
return ErrInvalidSignature
}
if !ed25519.Verify(pub, l.message(), sig) {
return ErrInvalidSignature
}
return nil
}
// Expired meldet, ob die Lease zum Zeitpunkt now abgelaufen ist.
func (l Lease) Expired(now time.Time) bool { return !now.Before(l.ValidUntil) }
// ParseLease liest und prüft eine Lease (unbekannte Felder sind Fehler).
func ParseLease(data []byte, pub ed25519.PublicKey) (Lease, error) {
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
var l Lease
if err := dec.Decode(&l); err != nil {
return Lease{}, fmt.Errorf("%w: %v", ErrInvalid, err)
}
if l.NodeID == "" || l.ValidUntil.IsZero() {
return Lease{}, fmt.Errorf("%w: node_id oder valid_until fehlt", ErrInvalid)
}
if l.AllowedDomains == nil {
l.AllowedDomains = []string{}
}
if l.Features == nil {
l.Features = []Feature{}
}
if err := l.Verify(pub); err != nil {
return Lease{}, err
}
return l, nil
}
+132
View File
@@ -0,0 +1,132 @@
package license
import (
"encoding/json"
"errors"
"testing"
"time"
)
var t0 = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
const day = 24 * time.Hour
func noJitter(time.Duration) time.Duration { return 0 }
func TestLeaseTerms(t *testing.T) {
far := t0.AddDate(1, 0, 0)
tests := []struct {
name string
tier Tier
maintenance bool
licenseUntil time.Time
valid, renew time.Duration // relativ zu t0
ok bool
}{
{"community", TierCommunity, false, far, 14 * day, 7 * day, true},
{"community ignoriert Vertragsende", TierCommunity, false, t0.Add(day), 14 * day, 7 * day, true},
{"pro", TierPro, false, far, 7 * day, 7 * day * 2 / 3, true},
{"pro in Wartung", TierPro, true, far, 10 * day, 10 * day * 2 / 3, true},
{"enterprise am Vertragsende gekappt", TierEnterprise, false, t0.Add(3 * day), 3 * day, 2 * day, true},
{"Vertrag abgelaufen", TierPro, false, t0, 0, 0, false},
}
for _, tt := range tests {
terms, ok := TermsFor(tt.tier, tt.maintenance, tt.licenseUntil, t0, 0, noJitter)
if ok != tt.ok {
t.Errorf("%s: ok=%v", tt.name, ok)
continue
}
if !ok {
continue
}
if !terms.ValidUntil.Equal(t0.Add(tt.valid)) || !terms.RenewAfter.Equal(t0.Add(tt.renew)) {
t.Errorf("%s: gültig bis +%v, erneuern ab +%v; erwartet +%v / +%v",
tt.name, terms.ValidUntil.Sub(t0), terms.RenewAfter.Sub(t0), tt.valid, tt.renew)
}
}
}
// Community-Erneuerung gestreut (± 12 h), damit nicht alle Agents gleichzeitig erneuern.
func TestLeaseTerms_CommunityJitter(t *testing.T) {
var maxSeen time.Duration
jitter := func(max time.Duration) time.Duration { maxSeen = max; return -5 * time.Hour }
terms, _ := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 0, jitter)
if maxSeen != 12*time.Hour {
t.Errorf("Streuung ±%v, erwartet ±12h", maxSeen)
}
if !terms.RenewAfter.Equal(t0.Add(7*day - 5*time.Hour)) {
t.Errorf("RenewAfter = +%v", terms.RenewAfter.Sub(t0))
}
// Ohne explizite Funktion: echte Streuung, aber innerhalb der Grenzen.
for range 100 {
terms, _ := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 0, nil)
if d := terms.RenewAfter.Sub(t0); d < 7*day-12*time.Hour || d > 7*day+12*time.Hour {
t.Fatalf("Streuung außerhalb: +%v", d)
}
}
}
// Override (Tests, Betrieb): feste Laufzeit, Erneuerung nach 2/3, Vertragsende gilt weiter.
func TestLeaseTerms_Override(t *testing.T) {
terms, ok := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 300*time.Millisecond, noJitter)
if !ok || !terms.ValidUntil.Equal(t0.Add(300*time.Millisecond)) || !terms.RenewAfter.Equal(t0.Add(200*time.Millisecond)) {
t.Errorf("Override: %+v", terms)
}
}
func TestIssueAndParseLease(t *testing.T) {
iss, pub := newIssuer(t)
lic := sample()
terms := LeaseTerms{RenewAfter: t0.Add(4 * day), ValidUntil: t0.Add(7 * day)}
l, err := iss.IssueLease(lic, "node-1", terms, t0)
if err != nil {
t.Fatalf("IssueLease: %v", err)
}
if l.NodeID != "node-1" || l.TenantID != lic.TenantID || !l.IssuedAt.Equal(t0) || !l.ValidUntil.Equal(terms.ValidUntil) ||
l.MaxRoutes != lic.MaxRoutes || len(l.AllowedDomains) != 2 {
t.Errorf("Lease: %+v", l)
}
doc, _ := json.Marshal(l)
parsed, err := ParseLease(doc, pub)
if err != nil {
t.Fatalf("ParseLease: %v", err)
}
if parsed.NodeID != "node-1" || !parsed.ValidUntil.Equal(terms.ValidUntil) {
t.Errorf("geparst: %+v", parsed)
}
if !l.Expired(terms.ValidUntil) || l.Expired(terms.ValidUntil.Add(-time.Second)) {
t.Error("Expired falsch")
}
for name, tamper := range map[string]func(*Lease){
"Knoten": func(l *Lease) { l.NodeID = "node-2" },
"Laufzeit": func(l *Lease) { l.ValidUntil = l.ValidUntil.Add(time.Hour) },
"Domains": func(l *Lease) { l.AllowedDomains = append([]string(nil), "bank.de") },
"Erneuerung": func(l *Lease) { l.RenewAfter = l.RenewAfter.Add(time.Hour) },
} {
c := l
tamper(&c)
if err := c.Verify(pub); !errors.Is(err, ErrInvalidSignature) {
t.Errorf("%s manipuliert: %v", name, err)
}
}
}
// Domänentrennung: Eine Lease ist keine Lizenz und umgekehrt.
func TestLeaseIsNotALicense(t *testing.T) {
iss, pub := newIssuer(t)
lic, _ := iss.Issue(sample())
lease, _ := iss.IssueLease(sample(), "node-1", LeaseTerms{RenewAfter: t0.Add(day), ValidUntil: t0.Add(2 * day)}, t0)
licDoc, _ := json.Marshal(lic)
if _, err := ParseLease(licDoc, pub); err == nil {
t.Error("Lizenz als Lease akzeptiert")
}
asLicense := License{
TenantID: lease.TenantID, Tier: lease.Tier, MaxRoutes: lease.MaxRoutes, AllowedDomains: lease.AllowedDomains,
Features: lease.Features, ValidUntil: lease.ValidUntil, Signature: lease.Signature,
}
if err := asLicense.Verify(pub); !errors.Is(err, ErrInvalidSignature) {
t.Error("Lease-Signatur als Lizenz-Signatur akzeptiert")
}
}