License Manager — Cookbook
Recettes opérationnelles copier-coller. Chaque recette décrit le flux Go côté Services — la TUI expose les mêmes opérations graphiquement dès son implémentation.
Nouveau ? Lis d'abord concepts.md pour le vocabulaire et l'architecture. La Configuration liste les flags et variables d'environnement.
Recettes essentielles :
- Recette 1 — Première utilisation : créer la DB + premier Issuer
- Recette 2 — Émettre une licence simple
- Recette 3 — Émettre avec machine + password + TOTP
Recettes opérationnelles :
- Recette 4 — Fingerprint probe d'une machine distante
- Recette 5 — Révoquer + publier la CRL HTTP
- Recette 6 — Démarrer et arrêter les serveurs HTTP
- Recette 7 — Rotation de clé
- Recette 8 — Importer une licence PEM existante
- Recette 9 — Changer la passphrase de la DB
- Recette 10 — Re-issue d'une licence (remplace une existante)
- Recette 11 — Supprimer une licence (round-trip export/réimport)
Recette 1
Première utilisation : initialiser la DB et créer le premier Issuer.
Au premier lancement, si la DB n'existe pas, le wizard de démarrage s'enclenche. Voici l'équivalent programmatique :
package main
import (
"context"
"log"
"github.com/oioio-space/maldev/internal/manager/crypto"
"github.com/oioio-space/maldev/internal/manager/service"
"github.com/oioio-space/maldev/internal/manager/store"
)
func main() {
ctx := context.Background()
passphrase := "ma-passphrase-secrete"
// 1. Générer un sel KEK et dériver la KEK.
salt, err := crypto.GenerateSalt()
if err != nil {
log.Fatal(err)
}
kek, err := crypto.DeriveFromPassphrase(passphrase, salt, crypto.PresetDefault)
if err != nil {
log.Fatal(err)
}
// 2. Ouvrir (ou créer) la DB — migrations automatiques.
db, err := store.New("/var/lib/maldev/manager.db", kek)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 3. Construire le bundle de services.
svc := service.New(db, kek)
defer svc.Close()
// 4. Créer le premier Issuer (active=true automatiquement si c'est le seul).
issuer, err := svc.Issuer.Generate(ctx, "Lab EU primary", "k2026-05")
if err != nil {
log.Fatal(err)
}
log.Printf("Issuer créé : %s (kid=%s)", issuer.Name, issuer.KeyID)
}
La DB est prête. La clé privée de l'Issuer est stockée chiffrée (encrypted_priv). La passphrase n'est jamais écrite sur disque.
Recette 2
Émettre une licence simple avec Subject + Audience + durée.
import (
"time"
"github.com/oioio-space/maldev/internal/manager/service"
)
// svc est déjà construit (voir Recette 1).
issued, err := svc.License.Issue(ctx, service.IssueRequest{
IssuerID: issuer.ID,
Subject: "alice@example.com",
AudienceList: []string{"rshell"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(30 * 24 * time.Hour),
})
if err != nil {
log.Fatal(err)
}
// issued.PEM est le blob PEM prêt à être distribué.
log.Printf("Licence : %s\n%s", issued.Row.LicenseUUID, issued.PEM)
Le PEM est stocké dans la DB (License.pem) et peut être réexporté à tout moment via svc.License.ExportPEM(ctx, id).
Recette 3
Émettre avec binding machine + mot de passe + TOTP.
Cette recette combine les trois bindings les plus courants. L'opérateur fournit les fingerprints de la machine autorisée (obtenus via Recette 4 ou hostid.Local() local).
import (
"time"
"github.com/oioio-space/maldev/internal/manager/service"
)
issued, err := svc.License.Issue(ctx, service.IssueRequest{
IssuerID: issuer.ID,
Subject: "bob@example.com",
AudienceList: []string{"rshell"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(90 * 24 * time.Hour),
Bindings: []service.BindingSpec{
{
Type: "machine",
Values: []string{"a1b2c3d4e5f6..."}, // hostid.Local() hex
},
{
Type: "password",
Values: []string{"hunter2"}, // haché Argon2id à l'émission
},
{
Type: "totp", // génère un secret TOTP automatiquement
},
},
Features: []string{"pro"},
})
if err != nil {
log.Fatal(err)
}
// Récupérer les infos de provisioning TOTP (secret, URI, QR).
if len(issued.TOTPs) > 0 {
t := issued.TOTPs[0]
log.Printf("TOTP URI : %s", t.OtpauthURI)
log.Printf("QR ASCII :\n%s", t.QRImageASCII)
}
Le secret TOTP est stocké chiffré dans TOTPSecret.encrypted_secret. Pour le réafficher plus tard :
view, err := svc.TOTP.PrintQRASCII(ctx, issued.Row.ID)
// ou
err = svc.TOTP.ExportQRPNG(ctx, issued.Row.ID, "/tmp/totp.png")
Recette 4
Fingerprint probe : obtenir le hostid d'une machine distante.
Cette recette suppose que le serveur Probe est démarré (voir Recette 6).
import "time"
// 1. Créer un token probe (valide 24h par défaut).
token, err := svc.Probe.NewToken(ctx, "Alice prod box", 24*time.Hour)
if err != nil {
log.Fatal(err)
}
log.Printf("Token : %s", token.ID)
log.Printf("URL de base : https://<manager>:8445/probe/%s", token.ID)
// 2. S'abonner au résultat (channel notifié quand la machine POSTe).
resultCh := svc.Probe.Subscribe(token.ID)
// 3. Afficher le one-liner à copier-coller (aussi disponible via /snippet).
log.Printf(`One-liner Linux/macOS :
URL="https://<manager>:8445/probe/%s"
curl -fsSL "$URL/agent/$(uname -s | tr A-Z a-z)-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" \
-o /tmp/maldev-probe && chmod +x /tmp/maldev-probe \
&& /tmp/maldev-probe "$URL/result"`, token.ID)
// 4. Attendre le résultat (goroutine ou select avec timeout).
result := <-resultCh
log.Printf("Hostname : %s", result.Hostname)
log.Printf("Local : %s", result.LocalHex)
log.Printf("Composite: %s", result.CompositeHex)
Le résultat est persisté dans ProbeToken (colonnes local_hex, composite_hex, hostname, os, arch, used_at). svc.Probe.History(ctx, 20) liste les 20 derniers résultats.
Recette 5
Révoquer une licence + publier la CRL HTTP.
import "github.com/google/uuid"
licID := uuid.MustParse("73f56081-5cce-4073-9632-...")
// 1. Révoquer dans la DB (status → revoked, Revocation row créée).
err := svc.Revoke.Revoke(ctx, licID, "fin de mission")
if err != nil {
log.Fatal(err)
}
// 2. Générer et afficher la CRL signée (même opération que le serveur HTTP).
crlPEM, err := svc.Revoke.PublishSignedList(ctx)
if err != nil {
log.Fatal(err)
}
log.Printf("CRL:\n%s", crlPEM)
Si le serveur Revocation est démarré, chaque GET /revoked.pem appelle PublishSignedList à la volée — la CRL est toujours fraîche.
Pour annuler une révocation (cas d'erreur) :
err = svc.Revoke.Unrevoke(ctx, licID)
Recette 6
Démarrer et arrêter les serveurs HTTP.
Les serveurs sont configurés via ServerConfig (singleton PK=1). Ils sont tous OFF par défaut.
import (
"context"
"time"
"github.com/oioio-space/maldev/internal/manager/httpsrv"
)
// Construire le Bundle (à faire une fois, après service.New).
bundle := httpsrv.NewBundle(svc)
svc.AttachServers(bundle)
// Configurer les adresses d'écoute.
_, err := svc.Settings.UpdateServerConfig(ctx, func(u *ent.ServerConfigUpdate) {
u.SetRevocationListen(":8443").
SetHeartbeatListen(":8444").
SetProbeListen(":8445")
})
if err != nil {
log.Fatal(err)
}
// Démarrer individuellement.
if err := bundle.Revocation.Start(context.Background()); err != nil {
log.Fatal(err)
}
if err := bundle.Heartbeat.Start(context.Background()); err != nil {
log.Fatal(err)
}
if err := bundle.Probe.Start(context.Background()); err != nil {
log.Fatal(err)
}
// Statut courant.
s := bundle.Revocation.Status()
log.Printf("Revocation: running=%v addr=%s requests=%d", s.Running, s.ListenAddr, s.Requests)
// Arrêt propre (10 s timeout).
bundle.StopAll(10 * time.Second)
Les événements temps-réel (requêtes entrantes, erreurs) sont disponibles via bundle.MergedEvents().
Recette 7
Rotation de clé : générer un nouvel Issuer, le désigner actif, retirer l'ancien.
La rotation ne casse pas les licences existantes : le binaire consommateur charge plusieurs kid dans son Trusted. Seules les nouvelles licences seront signées avec la nouvelle clé.
// 1. Générer le nouvel Issuer (inactive par défaut si un autre est déjà actif).
newIssuer, err := svc.Issuer.Generate(ctx, "Lab EU rotation", "k2026-08")
if err != nil {
log.Fatal(err)
}
// 2. Désigner le nouvel Issuer comme actif.
// L'ancien Issuer perd son flag active=true automatiquement.
err = svc.Issuer.SetActive(ctx, newIssuer.ID)
if err != nil {
log.Fatal(err)
}
// 3. Exporter la nouvelle clé publique pour la distribuer avec les binaires.
pubPEM, err := svc.Issuer.ExportPublic(ctx, newIssuer.ID)
if err != nil {
log.Fatal(err)
}
log.Printf("Nouvelle clé publique :\n%s", pubPEM)
// 4. Retirer l'ancien Issuer (optionnel — signe encore les licences existantes).
oldIssuerID := uuid.MustParse("...")
err = svc.Issuer.Retire(ctx, oldIssuerID)
Issuer.Deleterefuse si des licences ont été signées par cet Issuer. UtiliseRetirepour le marquer inactif sans le supprimer.
Recette 8
Importer une licence PEM existante dans la DB.
Utile si une licence a été émise manuellement via le package license/ ou reçue d'un autre manager.
pemBytes, err := os.ReadFile("/path/to/licence.pem")
if err != nil {
log.Fatal(err)
}
row, err := svc.License.Import(ctx, pemBytes, "licence Alice importée")
if err != nil {
log.Fatal(err)
}
log.Printf("Importée : %s (status=%s)", row.LicenseUUID, row.Status)
La licence est décodée et vérifiée (signature Ed25519) avant insertion. status est mis à active si not_after est dans le futur, expired sinon.
Pour inspecter un PEM sans l'insérer :
lic, err := svc.License.Inspect(pemBytes)
// lic est un *license.License décodé, non inséré en DB.
Recette 9
Changer la passphrase de la DB.
Le changement de passphrase re-dérive la KEK et re-chiffre toutes les colonnes sensibles dans une unique transaction.
err := svc.Settings.ChangePassphrase(ctx, "ancienne-passphrase", "nouvelle-passphrase")
if err != nil {
log.Fatal(err)
}
log.Println("Passphrase mise à jour.")
ChangePassphrase :
- Vérifie l'ancienne passphrase via le canary.
- Génère un nouveau sel KEK.
- Dérive la nouvelle KEK.
- Dans une transaction unique : re-wrap chaque colonne chiffrée + met à jour
Setting.kek_salt+Setting.kek_canary. - Remplace la KEK en mémoire + l'efface.
Si la transaction échoue, la DB reste cohérente avec l'ancienne passphrase.
Recette 10
Re-issue d'une licence (remplace une licence existante).
Re-issue est utilisé pour étendre la durée, modifier les bindings ou mettre à jour le payload d'une licence existante. La licence originale passe en status=superseded et la nouvelle porte replaces_license_id.
import (
"encoding/json"
"github.com/oioio-space/maldev/internal/manager/service"
)
originalID := uuid.MustParse("73f56081-...")
reissued, err := svc.License.ReIssue(ctx, originalID, service.ReIssueOptions{
// Chaque champ non-zéro remplace la valeur héritée de l'original.
// Les champs zéro tombent en cascade sur l'original.
NotAfter: time.Now().Add(180 * 24 * time.Hour),
Features: []string{"pro", "extended"},
Audience: []string{"prod", "staging"},
Payload: json.RawMessage(`{"tier":"gold"}`),
})
if err != nil {
log.Fatal(err)
}
log.Printf("Nouvelle licence : %s", reissued.Row.LicenseUUID)
log.Printf("Remplace : %s", originalID)
L'original est listé dans l'audit avec kind=license.supersede. La chaîne replaces_license_id est navigable via svc.License.Get(ctx, id) (champ Row + Successors dans LicenseDetail).
Côté TUI : [e] sur l'écran Licences ouvre le wizard pré-rempli depuis
l'original (validity, audience, free-fields, payload). Les étapes
identity / recipient / bindings / TOTP sont héritées telles quelles ; les 4
étapes éditables enchaînent step 5 → 6 → review. La pré-fix de cette
session corrige le bug "Ré-émission OK mais nouvelle licence déjà expirée"
causé par NotAfter zéro dans les anciens ReIssueOptions.
Recette 11
Supprimer définitivement une licence (round-trip export/réimport).
License.Delete efface la ligne License et tout ce qui en dépend
(Revocation, TOTPSecret) en une seule transaction. Le but principal est
de libérer le license_uuid (UNIQUE) pour qu'un PEM précédemment exporté
puisse être réimporté sans erreur de doublon — utile pour migrer une licence
entre instances ou pour rejouer une licence de laboratoire effacée par
inadvertance.
// Sauvegarder le PEM avant suppression — la base ne le retient pas.
pem, err := svc.License.ExportPEM(ctx, licID)
if err != nil {
log.Fatal(err)
}
if err := svc.License.Delete(ctx, licID, "operator"); err != nil {
log.Fatal(err)
}
// Le license_uuid est maintenant libre — réimport possible.
row, err := svc.License.Import(ctx, pem, "reimport", "operator")
if err != nil {
log.Fatal(err)
}
log.Printf("Réimporté avec le même UUID: %s", row.LicenseUUID)
Trade-off à connaître : supprimer une licence révoquée la retire aussi
de PublishSignedList. Tout client qui n'a pas encore récupéré la CRL ne
verra jamais la révocation. Pour une licence encore en circulation, garde la
révocation (Recette 5) ; réserve Delete aux licences que tu veux
ré-importer ou qui n'ont jamais quitté le laboratoire.
L'opération est tracée dans l'audit avec kind=license.delete et conserve
license_uuid, subject, et l'ancien status même après la disparition
de la ligne.
Côté TUI : sur l'écran Licences, sélectionne la ligne et presse [D]
(majuscule, cohérent avec [E] exporter). Un confirm overlay rouge
récapitule subject + UUID court avant validation par y ou enter.
La même touche est aussi reliée à Revocation (suppression directe de
la licence sous-jacente, distinct de [x] qui ne fait que la
ré-activer), à Issuers (refuse tant que des licences référencent la
clé — message d'erreur explicite avec le compte) et à TOTP (alias de
[x] pour cohérence inter-écrans).
Issuer.Delete zéroise la clé privée chiffrée en mémoire avant le drop
SQL — la trace audit conserve name + key_id pour la forensique
post-suppression.