Files
sultan/pkg/license/lease.go
T
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

154 lines
4.9 KiB
Go

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
}