// Package license stellt Mandanten-Lizenzen (Capabilities, Spec Abschnitt // 4.1) aus und prüft sie. // // Eine Lizenz legt fest, was ein Mandant darf: Tier, maximale Routenzahl, // erlaubte Domains, Features und Laufzeit. Sie ist mit dem Ed25519- // Master-Key des Servers signiert; jede Änderung an einem Feld macht die // Signatur ungültig. package license import ( "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "fmt" "math" "slices" "strings" "time" ) var ( // ErrInvalid: Die Lizenz verletzt eine Formatregel. ErrInvalid = errors.New("license: ungültige Lizenz") // ErrInvalidSignature: Signatur fehlt, ist beschädigt oder passt nicht. ErrInvalidSignature = errors.New("license: Signatur ungültig") ) // Tier ist die Lizenzstufe. type Tier string const ( TierCommunity Tier = "community" TierPro Tier = "pro" TierEnterprise Tier = "enterprise" ) // Feature ist eine optional freigeschaltete Fähigkeit. type Feature string const ( FeatureWebsockets Feature = "websockets" FeatureTCPStream Feature = "tcp_stream" FeatureCustomMiddleware Feature = "custom_middleware" ) // License entspricht license.json (Spec 4.1). type License struct { TenantID string `json:"tenant_id"` Tier Tier `json:"tier"` MaxRoutes int `json:"max_routes"` // -1 = unbegrenzt AllowedDomains []string `json:"allowed_domains"` Features []Feature `json:"features"` ValidUntil time.Time `json:"valid_until"` Signature string `json:"signature"` } // signatureVersion trennt Lizenz-Signaturen von anderen Nutzungen desselben // Master-Keys (z. B. künftig signierten Server-Events). const signatureVersion = "SULTAN-LICENSE-V1\n" // signedPayload legt Reihenfolge und Format der signierten Felder fest. type signedPayload struct { TenantID string `json:"tenant_id"` Tier Tier `json:"tier"` MaxRoutes int `json:"max_routes"` AllowedDomains []string `json:"allowed_domains"` Features []Feature `json:"features"` ValidUntil string `json:"valid_until"` } func (l License) message() []byte { b, _ := json.Marshal(signedPayload{ TenantID: l.TenantID, Tier: l.Tier, MaxRoutes: l.MaxRoutes, AllowedDomains: l.AllowedDomains, Features: l.Features, ValidUntil: l.ValidUntil.UTC().Format(time.RFC3339), }) return append([]byte(signatureVersion), b...) } // Issuer stellt Lizenzen mit dem Master-Key des Servers aus. type Issuer struct { Key ed25519.PrivateKey } // Issue prüft und normalisiert l (Domains klein und ohne Punkt am Ende, // ValidUntil in UTC auf ganze Sekunden) und signiert sie. Die Eingabe wird // nicht verändert. func (i *Issuer) Issue(l License) (License, error) { if len(i.Key) != ed25519.PrivateKeySize { return License{}, errors.New("license: Issuer ohne gültigen Master-Key") } out := l out.Signature = "" out.ValidUntil = l.ValidUntil.UTC().Truncate(time.Second) out.AllowedDomains = make([]string, len(l.AllowedDomains)) for n, d := range l.AllowedDomains { p, err := normalizePattern(d) if err != nil { return License{}, err } out.AllowedDomains[n] = p } out.Features = append([]Feature{}, l.Features...) if err := out.validate(); err != nil { return License{}, err } out.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(i.Key, out.message())) return out, nil } func (l License) validate() error { switch { case strings.TrimSpace(l.TenantID) == "": return fmt.Errorf("%w: tenant_id fehlt", ErrInvalid) case l.Tier != TierCommunity && l.Tier != TierPro && l.Tier != TierEnterprise: return fmt.Errorf("%w: unbekannter Tier %q", ErrInvalid, l.Tier) case l.MaxRoutes < -1: return fmt.Errorf("%w: max_routes %d (erlaubt: -1 oder ≥ 0)", ErrInvalid, l.MaxRoutes) case len(l.AllowedDomains) == 0: return fmt.Errorf("%w: allowed_domains leer", ErrInvalid) case l.ValidUntil.IsZero(): return fmt.Errorf("%w: valid_until fehlt", ErrInvalid) } for _, d := range l.AllowedDomains { if p, err := normalizePattern(d); err != nil || p != d { return fmt.Errorf("%w: Domain %q nicht normalisiert", ErrInvalid, d) } } for _, f := range l.Features { if f != FeatureWebsockets && f != FeatureTCPStream && f != FeatureCustomMiddleware { return fmt.Errorf("%w: unbekanntes Feature %q", ErrInvalid, f) } } return nil } // Verify prüft die Signatur mit dem Public Key des Master-Keys. func (l License) Verify(pub ed25519.PublicKey) error { if len(pub) != ed25519.PublicKeySize { return fmt.Errorf("%w: kein gültiger Public Key", ErrInvalidSignature) } sig, err := base64.StdEncoding.DecodeString(l.Signature) if err != nil || len(sig) != ed25519.SignatureSize { return fmt.Errorf("%w: Signatur fehlt oder ist beschädigt", ErrInvalidSignature) } if !ed25519.Verify(pub, l.message(), sig) { return ErrInvalidSignature } return nil } // Parse liest license.json, lehnt unbekannte Felder ab und prüft Format und // Signatur. func Parse(data []byte, pub ed25519.PublicKey) (License, error) { dec := json.NewDecoder(strings.NewReader(string(data))) dec.DisallowUnknownFields() var l License if err := dec.Decode(&l); err != nil { return License{}, fmt.Errorf("%w: %v", ErrInvalid, err) } l.ValidUntil = l.ValidUntil.UTC() if l.Features == nil { l.Features = []Feature{} } if err := l.validate(); err != nil { return License{}, err } if err := l.Verify(pub); err != nil { return License{}, err } return l, nil } // Expired meldet, ob die Lizenz zum Zeitpunkt now abgelaufen ist (der // Ablaufzeitpunkt selbst zählt als abgelaufen). func (l License) Expired(now time.Time) bool { return !now.Before(l.ValidUntil) } // DaysLeft liefert die verbleibenden vollen Tage (abgerundet); nach Ablauf // negativ. Grundlage für die Checkmk-Schwellen (30/14 Tage). func (l License) DaysLeft(now time.Time) int { return int(math.Floor(l.ValidUntil.Sub(now).Hours() / 24)) } // AllowsRoutes meldet, ob n Routen im Limit liegen. func (l License) AllowsRoutes(n int) bool { return l.MaxRoutes == -1 || n <= l.MaxRoutes } // HasFeature meldet, ob f freigeschaltet ist. func (l License) HasFeature(f Feature) bool { return slices.Contains(l.Features, f) }