Skip to content
← Alle Beiträge
· Delta1 Labs KeyrightLizenzierung.NETTiefenanalyse

Lizenzierung einer .NET-App auf Linux und macOS: keine Registrierung, kein AppData — wo die Keyright-Lease liegt, woran der Fingerabdruck verankert ist, und der systemd-Dienst, der nie neu gestartet wird

Die meisten Anleitungen zur Lizenzierung sind für Windows geschrieben, und das Keyright-SDK verbirgt das stillschweigend: dieselben Aufrufe von Validate() und ActivateAsync() funktionieren auf Linux und macOS. Aber die drei Dinge, auf die ein Lizenzierungs-SDK angewiesen ist — ein benutzerbezogener Datenordner, eine stabile Maschinenidentität und eine App, die oft genug neu gestartet wird, um ihre Lease aufzufrischen — sehen abseits von Windows alle anders aus. Die Lease landet in ~/.local/share, wohin eine Mac-App in der Sandbox oder ein systemd-Dienst mit ProtectHome= nicht schreiben kann. Der Maschinen-Fingerabdruck ist an /etc/machine-id oder der IOPlatformUUID verankert statt an einem Registrierungswert, und ein Golden Image, in dem die machine-id nicht geleert wurde, macht eine ganze Flotte zu einer Maschine. Und ein Daemon, der einmal im Quartal startet, bekommt nie die Auffrischung beim Start, auf die sich eine Desktop-App verlässt, sodass seine 14-Tage-Lease abläuft, während der Prozess noch läuft. Dieser Beitrag geht durch, wohin das SDK auf jeder Plattform schreibt, woraus der Fingerabdruck gebaut wird und welche zwei machine-id-Fallen es gibt, eine systemd-Unit mit StateDirectory= und LoadCredential=, die dem SDK ein Zuhause gibt, die Lease-Auffrischungsschleife, die ein lang laufender Dienst braucht, den Dateimodus, den die Lease auf einem Mehrbenutzer-Host verdient, und die Prüfschleife, die das alles belegt.

Dem Keyright-SDK ist egal, auf welchem Betriebssystem es läuft. Keyright.NET zielt auf netstandard2.0 und net8.0, Validate() ist reine Berechnung über Bytes, und ActivateAsync ist ein HTTPS-Aufruf. Stellen Sie einen lizenzierten Worker auf Ubuntu bereit oder liefern Sie einen Mac-Build Ihrer Desktop-App aus, und derselbe Code läuft. Das ist die gute Nachricht, und es ist auch der Grund, warum die drei Plattformunterschiede, die tatsächlich zählen, eher als Support-Ticket auftauchen denn als Compilerfehler.

Ein Lizenzierungs-SDK ist auf drei Dinge angewiesen, die die Plattform bereitstellt: einen benutzerbezogenen Ort, an dem eine kleine signierte Datei liegen kann, eine stabile Identität für die Maschine und — weniger offensichtlich — eine Anwendung, die oft genug neu gestartet wird, um aufzufrischen, was sie zwischengespeichert hat. Auf Windows sind das %LocalAppData%, die MachineGuid in der Registrierung und die Tatsache, dass Desktop-Apps jeden Morgen gestartet werden. Abseits von Windows sind alle drei anders, und eine Keyright-Integration, die auf dem Windows-Laptop eines Entwicklers getestet wurde, kann auf der Linux-Maschine, auf der sie bereitgestellt wird, stillschweigend das Falsche tun. Dieser Beitrag ist die Linux- und macOS-Version der Geschichte: wohin die Dateien gehen, woraus der Fingerabdruck besteht, wie Sie einem systemd-Dienst ein Zuhause geben, und die Auffrischungsschleife, die ein Prozess braucht, der nie neu startet.

Wohin die Dateien gehen, wenn es kein AppData gibt

Ein Keyright-Client hält bis zu drei Dateien pro Produkt, alle unterhalb von Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData):

{LocalApplicationData}/Keyright/{product}/lease.json     signed activation lease (cached after ActivateAsync)
{LocalApplicationData}/Keyright/{product}/state.json     first-seen / last-seen timestamps (trial clock, clock-tamper)
{LocalApplicationData}/Keyright/{product}/license.json   default location for an offline license file

Auf Unix löst .NET LocalApplicationData nach XDG-Art auf: $XDG_DATA_HOME, falls gesetzt, andernfalls $HOME/.local/share. Und das tut es auch auf macOS — nicht ~/Library/Application Support, wo ein Mac-Benutzer nachsehen würde. Für eine Benutzerin dana liegt die Lease also unter:

Linux   /home/dana/.local/share/Keyright/acme-agent/lease.json
macOS   /Users/dana/.local/share/Keyright/acme-studio/lease.json

Aus „es liegt unter $HOME” folgen drei Dinge, und sie sind die gesamte erste Hälfte dieses Beitrags.

Es ist pro Benutzer. Zwei Benutzer auf derselben Maschine haben jeweils ihren eigenen Cache und aktivieren jeweils einmal. Das sind nicht zwei Plätze: beide Aktivierungen nehmen den Fingerabdruck derselben Maschine und senden dieselbe machineId, sodass der Dienst einen Platz bindet und ihn zweimal auffrischt — genau das Verhalten auf einem gemeinsam genutzten PC, das im MSIX-Beitrag beschrieben ist. Der Cache ist pro Benutzer; der Platz war schon immer pro Maschine.

Es setzt voraus, dass HOME real und beschreibbar ist. Eine systemd-Unit mit ProtectHome=yes sieht ein leeres, schreibgeschütztes /home. Eine DynamicUser=yes-Unit läuft unter einer transienten UID, deren Home / ist. Ein von Hand angelegtes Dienstkonto wird oft mit --home /nonexistent oder --home /var/empty erstellt. In all diesen Fällen kann ~/.local/share/Keyright nicht angelegt werden. Das SDK wirft keine Ausnahme: der Lease-Cache ist bewusst best-effort, ein fehlgeschlagener Schreibzugriff wird also geschluckt, ActivateAsync liefert eine vollkommen gültige LicenseInfo, und der Dienst läuft lizenziert — bis zum nächsten Neustart, wenn es keine zwischengespeicherte Lease gibt und er erneut aktiviert. Online ist das unsichtbar; es kostet nur einen Roundtrip. Offline ist es NoLicense und ein Dienst, der auf die sichere Seite fällt (fail closed) und gestern noch funktioniert hat.

Eine Mac-App in der Sandbox hat ein anderes HOME. Unter App Sandbox wird HOME in den Container der App umgeleitet, sodass ~/.local/share/Keyright/acme-studio/lease.json unter ~/Library/Containers/com.acme.studio/Data/.local/share/Keyright/acme-studio/lease.json landet. Alles funktioniert — die Sandbox ist beschreibbar, sie überlebt Updates, und sie wird gelöscht, wenn der Benutzer die App und ihren Container entfernt. Es ist das macOS-Gegenstück zur MSIX-Schreibvirtualisierung: der Pfad, den das SDK protokolliert, ist nicht der Pfad, in dem die Bytes liegen. Machen Sie ihn mit LeaseCachePath und Konsorten explizit, falls ein Support-Techniker die Datei jemals brauchen wird.

Woran der Fingerabdruck verankert ist

Der Maschinen-Fingerabdruck, den das SDK bei der Aktivierung sendet, ist eine Menge gesalzener Hashes, KRM1:anchor:soft1:soft2, gebaut aus einer geordneten Liste von Komponenten, in der Index 0 der Anker ist, der immer passen muss, und der Rest weiche Signale sind, die NodeLockTolerance (Standard 1) eines nach dem anderen abweichen lässt. Auf Windows ist der Anker HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid. Abseits von Windows gibt es keine Registrierung, und das SDK liest das Äquivalent der Plattform:

Ein Fingerabdruck, drei AnkerquellenWindows · AnkerHKLM\...\Cryptography\MachineGuidRegistrierung, nur lesendLinux · Anker/etc/machine-id/var/lib/dbus/machine-id32 Hex-Zeichen, Modus 0444macOS · AnkerIOPlatformUUID(IOPlatformExpertDevice)ioreg, überlebt Upgradesnichts lesbar · Fallback~/.local/share/Keyright/machine.id (zufällig, persistiert)pro Benutzer, nicht pro Maschineweich · machine-nameEnvironment.MachineName (hostname)weich · osPlatform:Version.Major z. B. Unix:6Anker muss passen;NodeLockTolerance = 1 lässteine weiche Komponente abweichengesalzenes SHA-256 pro Komponente, auf der Maschine · nur Hashes verlassen sieKRM1:7Q3K…(Anker):M2PX…(Name):A9F4…(OS) DisplayId KR-XXXX-XXXX-XXXXHostname geändert? Kernel von 5 → 6? Eine weiche Abweichung, immer noch diese Maschine. Neue /etc/machine-id? Eine neue Maschine.
  • Linux: /etc/machine-id, die 32 Hex-Zeichen lange ID, die systemd beim ersten Start erzeugt, mit /var/lib/dbus/machine-id als Fallback für Systeme, die nur die von D-Bus haben. Sie ist für alle lesbar (Modus 0444), sodass ein Dienst, der als unprivilegierter Benutzer läuft, sie ohne jede Capability lesen kann — weshalb DynamicUser=-Units den richtigen Fingerabdruck bilden.
  • macOS: die IOPlatformUUID des Eintrags IOPlatformExpertDevice in der I/O-Registry, gelesen über ioreg. Es ist die maschinenbezogene ID, die Apples eigene Dienste verwenden, und sie überlebt OS-Upgrades, Hostnamen-Änderungen und einen Benutzer, der ~/ löscht.
  • Nichts lesbar: das SDK erzeugt eine zufällige ID, persistiert sie unter ~/.local/share/Keyright/machine.id und verankert darauf. Auf Windows und auf jeder normalen Linux- oder Mac-Installation existiert diese Datei nie; sie ist der letzte Ausweg für einen Container ohne machine-id oder eine abgeschottete Sandbox, die kein ioreg starten kann. Beachten Sie, was das kostet: der Fallback ist pro Benutzer, zwei Benutzer auf einer solchen Maschine wären also zwei Maschinen und zwei Plätze.

Die beiden weichen Komponenten sind der Hostname und Environment.OSVersion.Platform plus die Hauptversion — Unix:6 auf einem Linux-6.x-Kernel, die macOS-Hauptversion auf einem Mac. Die Toleranz ist das, was sie ehrlich hält: benennen Sie den Host um, und der Anker passt weiterhin, eine weiche Komponente weicht ab, die Lease validiert. Heben Sie eine Distribution von einem 5.x- auf einen 6.x-Kernel an, dasselbe. Tun Sie beides in derselben Woche, und Sie haben zwei weiche Abweichungen, was die Standardtoleranz überschreitet — die Lease meldet MachineMismatch, und das nächste ActivateAsync bindet idempotent neu, weil der Dienst über den Anker zuordnet. Das Design ist im Beitrag zum Fingerabdruck erklärt; der Linux-spezifische Punkt ist nur, dass Ihr „Hardware”-Anker eine Datei ist.

Die zwei machine-id-Fallen

Weil der Anker eine Datei ist, kann er kopiert werden, und er kann fehlen. Beides ist ein anderer Fehlerfall.

Kopiert: das Golden Image. Bauen Sie ein VM-Image, installieren Sie Ihren Agenten, machen Sie einen Snapshot, provisionieren Sie vierzig Hosts aus dem Snapshot. Sofern nichts /etc/machine-id geleert hat, bevor der Snapshot versiegelt wurde, booten alle vierzig mit derselben ID, hashen auf denselben Anker und präsentieren dieselbe machineId. Keyright tut, was es für eine Maschine tut, die es schon gesehen hat: der erste Host aktiviert und bindet einen Platz, die anderen neununddreißig frischen ihn auf. Das Dashboard zeigt ein Gerät. Sie haben einen Platz verkauft und betreiben vierzig — das ist die Falle, die nach offen ausfällt (fail open), und das ist die Sorte, über die Sie kein Ticket bekommen. Die Lösung liegt im Image, nicht im SDK: truncate -s0 /etc/machine-id vor dem Versiegeln (eine leere Datei sagt systemd, beim ersten Start eine frische ID zu erzeugen), oder systemd-machine-id-setup aus der First-Boot-Provisionierung ausführen. cloud-init tut das; von Hand gebaute Vorlagen oft nicht. Der Beitrag zu geklonten VMs behandelt das Erkennen der Duplikate von der Dienstseite aus, wenn Sie die Images nicht kontrollieren können. Wenn Sie wollen, dass viele Hosts als eine Bereitstellung zählen, sagen Sie das mit einem eigenen IMachineComponents, wie im Beitrag zu Containern — eine Entscheidung im Code, kein Versehen in einer Vorlage.

Fehlend: der Container. Minimale Container-Images liefern eine leere /etc/machine-id oder gar keine. Das SDK liest einen leeren String, behandelt ihn als nicht lesbar und fällt auf den persistierten zufälligen Fallback durch — der in der beschreibbaren Schicht des Containers liegt und mit dem Container verschwindet. Jeder Neustart ist eine neue Maschine und ein neuer Platz. Das ist dasselbe Problem, das der Container-Beitrag durch Überschreiben der Identitätsquelle löst, und nichts auf dieser Seite ändert daran etwas; was diese Seite hinzufügt, ist das Warum hinter dem Standardverhalten: die Datei ist leer.

Ein systemd-Dienst: dem SDK ein Zuhause geben

Nun zu der Hälfte des Beitrags, die für die meisten Linux-Bereitstellungen zählt: ein lizenzierter .NET-Worker, der unter systemd läuft. Eine Unit mit moderner Härtung sieht so aus, und jede Zeile, die für die Lizenzierung zählt, ist kommentiert:

# /etc/systemd/system/acme-agent.service
[Unit]
Description=Acme Agent
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/opt/acme/acme-agent
User=acme
Group=acme
Restart=on-failure

# Hardening: the filesystem is read-only and /home is empty. $HOME/.local/share is not writable.
ProtectSystem=strict
ProtectHome=yes

# The one directory this service may write. systemd creates /var/lib/acme-agent, chowns it to acme,
# and exports it as $STATE_DIRECTORY. This is where the lease, local state and offline license go.
StateDirectory=acme-agent
StateDirectoryMode=0700

# Site/OEM deployments: hand the service a signed license file nobody else on the host can read.
# Exposed at $CREDENTIALS_DIRECTORY/license.json, readable by this service only, not in the environment.
LoadCredential=license.json:/etc/acme/license.json

# Key-activated deployments instead: the license key, same mechanism. One or the other.
# LoadCredential=license-key:/etc/acme/license.key

[Install]
WantedBy=multi-user.target

StateDirectory= ist die Antwort auf das HOME-Problem. Es gibt dem Dienst ein Verzeichnis, das existiert, dem Dienstbenutzer gehört, Neustarts und Upgrades überlebt und von ProtectSystem=strict ausgenommen ist. StateDirectoryMode=0700 hält andere Benutzer auf dem Host davon fern. LoadCredential= ist die Antwort auf „woher kommt die Lizenz auf einer Maschine ohne Menschen”: systemd kopiert die Datei in ein dienstbezogenes tmpfs und teilt dem Prozess über CREDENTIALS_DIRECTORY mit, wo. Anders als bei einer Environment=-Zeile taucht der Inhalt nie in systemctl show, /proc/<pid>/environ oder einem Kindprozess auf.

Der Client liest beides:

using Keyright.Client;

static KeyrightClient CreateLicenseClient()
{
    var options = new KeyrightOptions
    {
        Product         = "acme-agent",
        PublicKeyBase64 = AcmeKeys.LicensePublicKey,       // public key only; safe to ship in the binary
        ServiceUrl      = "https://licensing.acme.example",
    };

    // systemd StateDirectory= — may list several directories, colon-separated; the first is ours.
    string? state = Environment.GetEnvironmentVariable("STATE_DIRECTORY")?.Split(':')[0];
    if (!string.IsNullOrEmpty(state))
    {
        string root = Path.Combine(state, "keyright");
        options.LeaseCachePath     = Path.Combine(root, "lease.json");
        options.LocalStatePath     = Path.Combine(root, "state.json");
        options.DefaultLicensePath = Path.Combine(root, "license.json");
    }

    // systemd LoadCredential= — a site/OEM license file. Explicit path = authoritative, no fall-through.
    string? creds = Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY");
    if (creds is not null && File.Exists(Path.Combine(creds, "license.json")))
        options.LicenseFilePath = Path.Combine(creds, "license.json");

    return KeyrightClient.Initialize(options);
}

Außerhalb von systemd — ein Entwickler, der die Binary aus einer Shell startet — ist keine der beiden Variablen gesetzt, das SDK fällt auf ~/.local/share zurück, und derselbe Code ist richtig. Es gibt kein „bin ich ein Dienst”-Flag, das man falsch setzen könnte.

Der LicenseFilePath-Zweig ist dieselbe Regel für explizite Quellen wie überall sonst im SDK: ist er gesetzt, werden die umgebenden Quellen (KEYRIGHT_LICENSE, der Konfigurationspfad, der Standardpfad, die zwischengespeicherte Lease) nicht konsultiert, und eine fehlende oder ungültige Datei ist NoLicense oder SignatureInvalid statt eines Durchfallens. Für eine Bereitstellung, in der die Lizenzierung eine Betreiberentscheidung ist, ist das, was Sie wollen. Beachten Sie, dass die Lizenzdatei kein Geheimnis im Sinne eines Passworts ist — sie ist ein signiertes Dokument, das an den Fingerabdruck dieser Maschine gebunden und anderswo nutzlos ist — aber sie trägt den Lizenzschlüssel und den Namen des Lizenznehmers, und LoadCredential= ist schlicht der sauberste Weg, auf einem Linux-Host genau einem Prozess eine Datei vorzulegen.

Die macOS-Daemon-Geschichte ist dieselbe mit anderen Namen. Ein LaunchDaemon, der als dedizierter Benutzer _acme läuft, hat /var/empty als Home, das schreibgeschützt ist; HOME für einen Root-Daemon ist /var/root. Legen Sie die drei Pfade unter /Library/Application Support/Acme/keyright/ mit Modus 0700 ab, im Besitz des Daemon-Benutzers, und das SDK ist zufrieden. launchd hat kein LoadCredential=; eine 0600-Datei unter /Library/Application Support/Acme/, über LicenseFilePath referenziert, ist das Äquivalent.

Der Daemon, der nie neu gestartet wird

Hier ist der Unterschied, der nichts mit Dateipfaden zu tun hat und am härtesten zuschlägt.

Eine Aktivierungs-Lease ist ein kurzlebiges signiertes Token — standardmäßig 14 Tage — und das vom SDK empfohlene Auffrischungsmuster lautet ActivateAsync beim Start aufrufen. Für eine Desktop-App ist das eine vollständige Lösung: der Benutzer startet sie an den meisten Tagen, jeder Start frischt die Lease auf, wenn er online ist, und ein Laptop, der zwei Wochen offline bleibt, ist der Randfall, für den das Fenster bemessen wurde. Ein Dienst kehrt die Häufigkeiten um. Er startet einmal, nach einem Deploy, und läuft dann monatelang. Nichts ruft ActivateAsync erneut auf, die Lease erreicht leaseExpiresUtc, und das nächste Validate() — in einem Request-Handler, an Tag 15 — liefert Expired mit der Meldung „reconnect to re-validate”. Der Prozess läuft, das Netzwerk ist in Ordnung, die Lizenz ist bezahlt, und der Dienst ist auf die sichere Seite (fail closed) nach Free gefallen, weil ihn niemand neu gestartet hat.

Wer frischt die 14-Tage-Lease auf?Tag 1Tag 8Tag 15Tag 20Desktop-App● = Start → ActivateAsync → Lease ab hier um 14 Tage verlängert. Verfällt nie.Daemon, keine SchleifeExpired → Freeein Start beim Deploy. Prozess läuft an Tag 15 noch; nichts hat die Lease aufgefrischt.Daemon + SchleifeActivateAsync alle 6 h: idempotent, derselbe Platz. Gelb = Dienst nicht erreichbar; die zwischengespeicherte Lease überbrückt es.Die Lease ist immer nur Stunden alt, ein 14-Tage-Fenster sind also 14 Tage tolerierter Ausfall, nicht 14 Tage Laufzeit.

Die Lösung ist eine Auffrischungsschleife innerhalb des Dienstes. ActivateAsync kann gefahrlos wiederholt aufgerufen werden: die erneute Aktivierung einer Maschine, die der Dienst schon gesehen hat, ist idempotent — derselbe Platz, frische Lease — und wenn der Lizenzierungsdienst nicht erreichbar ist, fällt das SDK auf die noch gültige zwischengespeicherte Lease zurück, statt sie zu verwerfen, sodass die Schleife das Fenster immer nur verlängern und nie verkürzen kann.

using Keyright.Client;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

/// <summary>Keeps the activation lease fresh in a process that may run for months without restarting.</summary>
public sealed class LeaseRefresher(KeyrightClient license, ILogger<LeaseRefresher> log) : BackgroundService
{
    private static readonly TimeSpan Interval = TimeSpan.FromHours(6);

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        // Key-activated deployment: the key arrives as a systemd credential, never in the environment.
        string? creds = Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY");
        string? keyPath = creds is null ? null : Path.Combine(creds, "license-key");
        if (keyPath is null || !File.Exists(keyPath))
        {
            log.LogInformation("No license key credential; running from a license file, nothing to refresh.");
            return;                                   // site/OEM file deployments: the file IS the license
        }
        string key = (await File.ReadAllTextAsync(keyPath, ct)).Trim();

        using var timer = new PeriodicTimer(Interval);
        do
        {
            LicenseInfo info = await license.ActivateAsync(key, ct);   // idempotent: same machine, same seat, new lease
            if (info.IsValid)
                log.LogInformation("License lease refreshed; {Status}, {DaysRemaining} days remaining.", info.Status, info.DaysRemaining);
            else
                log.LogWarning("License refresh did not yield a valid lease: {Status} — {Message}", info.Status, info.Message);
        }
        while (await timer.WaitForNextTickAsync(ct));
    }
}

Registrieren Sie sie mit services.AddHostedService<LeaseRefresher>() neben dem Singleton-Client. Sechs Stunden sind ein vernünftiges Intervall: die Lease ist dann immer höchstens sechs Stunden alt, sodass das 14-Tage-Fenster zu vierzehn Tagen tolerierten Ausfalls Ihres Lizenzierungsdienstes wird statt zu vierzehn Tagen Laufzeit. Die erste Iteration läuft sofort, was zugleich als Aktivierung beim Start dient. Protokollieren Sie den Status, nie den Schlüssel — der Beitrag zu strukturiertem Logging erklärt, warum info.Status und info.DaysRemaining die richtigen Felder sind.

Zwei Dinge tut die Schleife mit Absicht nicht. Sie startet den Prozess nicht neu, wenn der Status schlecht wird: Validate() fällt von sich aus bereits auf die sichere Seite (fail closed), und der Beitrag zum Health-Check zeigt, wie Sie eine degradierte Lizenz dem Orchestrator sichtbar machen, ohne eine Crash-Schleife auszulösen. Und sie läuft nicht für dateibasierte Bereitstellungen: eine signierte Standortlizenzdatei hat ihr eigenes Ablaufdatum und nichts aufzufrischen, die Schleife prüft also auf das Schlüssel-Credential und steigt aus.

Der Dateimodus, den die Lease verdient

Auf Windows liegt die Lease unter einem Profil, das nur sein Benutzer lesen kann. Auf Linux legt File.WriteAllText Dateien mit 0666 & ~umask an, was mit der üblichen 022 0644 ergibt — lesbar für jedes Konto auf dem Host. Die Lease zu lesen ist nicht gefährlich: sie ist signiert, Bearbeiten ergibt also SignatureInvalid, und sie ist an den Fingerabdruck dieser Maschine gebunden, Kopieren anderswohin ergibt also MachineMismatch. Aber sie trägt sehr wohl den Lizenzschlüssel, und ein Schlüssel erlaubt seinem Besitzer, von jeder Maschine aus eine Aktivierung gegen Ihr Platzlimit zu versuchen. Auf einem Einzelbenutzer-Laptop ist das nichts; auf einem gemeinsam genutzten Build-Host oder einer Jump-Box ist es ein Schlüssel-Leck per ls.

StateDirectoryMode=0700 schließt das für einen systemd-Dienst bereits. Für alles andere ziehen Sie das Verzeichnis fest, bevor das SDK hineinschreibt — KeyrightClient.Initialize legt die Verzeichnisse nicht an, der erste Schreibzugriff tut das, also gewinnt, wer sie vorher selbst anlegt:

using System.IO;

static void SecureLicenseDirectory(string leaseCachePath)
{
    if (OperatingSystem.IsWindows()) return;                  // ACLs on the profile already do this
    string dir = Path.GetDirectoryName(leaseCachePath)!;
    Directory.CreateDirectory(dir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);   // 0700
    if (File.Exists(leaseCachePath))
        File.SetUnixFileMode(leaseCachePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);                   // 0600
}

Directory.CreateDirectory(path, UnixFileMode) und File.SetUnixFileMode sind .NET-7+-APIs; auf älteren Zielen tut ein chmod-P/Invoke dasselbe. Ein 0700-Verzeichnis reicht für sich allein — eine 0644-Datei in einem Verzeichnis, das sonst niemand durchqueren kann, ist nicht lesbar — der Aufruf für den Dateimodus ist also doppelt genäht für eine Lease, die schon existierte, bevor Sie das Verzeichnis festgezogen haben.

Ausgeben, was das Gate sehen wird

Jeder Beitrag dieser Reihe landet bei derselben Diagnose-Empfehlung, denn sie ist das, was ein Ticket zum Einzeiler macht: ein --license-info-Flag, das die Pfade und den Fingerabdruck aus dem Codepfad ausgibt, den das Gate nutzt, sodass das, was es zeigt, das ist, was das SDK tut.

using Keyright.Client;
using Keyright.NodeLock;

// acme-agent --license-info
static void PrintLicenseInfo(KeyrightClient license)
{
    var fp = MachineFingerprint.Current();
    LicenseInfo info = license.Validate(out LicenseSourceKind source);

    Console.WriteLine($"Machine id    : {fp.ToBoundString()}");              // what the dashboard's Devices panel lists
    Console.WriteLine($"Display id    : {fp.DisplayId}");                    // short form for a support conversation
    Console.WriteLine($"Anchor source : {AnchorSource()}");
    Console.WriteLine($"Lease cache   : {license.LeaseCachePath}  (exists: {File.Exists(license.LeaseCachePath)})");
    Console.WriteLine($"Local state   : {license.LocalStatePath}");
    Console.WriteLine($"License file  : {license.DefaultPath}");
    Console.WriteLine($"Status        : {info.Status} via {source}  — {info.Message}");
}

static string AnchorSource()
{
    if (OperatingSystem.IsWindows()) return "registry MachineGuid";
    if (OperatingSystem.IsMacOS())   return "IOPlatformUUID";
    foreach (var p in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" })
        if (File.Exists(p) && File.ReadAllText(p).Trim().Length > 0) return p;
    return "persisted fallback (~/.local/share/Keyright/machine.id) — per user, not per machine";
}

Die Zeile Anchor source ist die, die die Golden-Image- und Container-Fallen fängt, bevor sie die Produktion erreichen: /etc/machine-id auf einem echten Host, der Fallback in einem nackten Container. Das Suffix via {source} — Lease, ExplicitFile, EnvironmentVariable, DefaultPath — sagt Ihnen, welche der Quellen gewonnen hat, was die erste Frage ist, wenn ein Dienst, der eine Credential-Datei lesen sollte, stattdessen irgendwie online aktiviert.

Überprüfen Sie es

Die gesamte Argumentation ist auf einem Host in wenigen Minuten testbar. Der wichtige Trick steckt in Schritt 2: führen Sie die Diagnose innerhalb der Umgebung der Unit aus, nicht aus Ihrer eigenen Shell als Dienstbenutzer — sudo -u acme gibt Ihnen den Benutzer, aber nicht STATE_DIRECTORY oder die Credentials, und die Pfade, die es ausgibt, sind die falschen.

# 1. The anchor. Non-empty, and different on every host built from the same image.
cat /etc/machine-id
#    → e.g. 8c3f1a2b...   (empty? the image was not cleared: truncate -s0 /etc/machine-id and reboot)

# 2. Activate once, as the unit would. systemd-run builds a transient unit with the same properties.
sudo systemd-run --pty --wait -p User=acme -p StateDirectory=acme-agent \
    -p LoadCredential=license-key:/etc/acme/license.key \
    /opt/acme/acme-agent license activate
sudo systemd-run --pty --wait -p User=acme -p StateDirectory=acme-agent \
    /opt/acme/acme-agent --license-info
#    → Lease cache : /var/lib/acme-agent/keyright/lease.json (exists: True), Status: Valid via Lease
#    Dashboard: the license shows 1 device, the KRM1:… machine id from the line above.

# 3. Owner and mode: the service user, nobody else.
sudo ls -la /var/lib/acme-agent/keyright/
#    drwx------ acme acme .     -rw------- acme acme lease.json

# 4. Restart: the lease survives, no second seat. Dashboard: still 1 device.
sudo systemctl restart acme-agent && sudo journalctl -u acme-agent -n 3
#    → "License lease refreshed; Valid, 364 days remaining."

# 5. The loop. Block the licensing service for an hour and watch the warning line; unblock and watch it recover.
#    Then confirm with the dashboard's last-seen time that the lease is refreshed every 6 hours without a restart.

Fügen Sie den Schritt hinzu, der für eine Flotte zählt: provisionieren Sie einen zweiten Host aus demselben Image und führen Sie Schritt 1 auf beiden aus. Wenn die beiden IDs übereinstimmen, halten Sie an und reparieren Sie das Image; nichts danach ist aussagekräftig, bis sie sich unterscheiden.

Was Sie mitnehmen sollten

Keyright behandelt Linux und macOS als erstklassig, und die SDK-Aufrufe sind identisch, aber die Plattform unter einem Lizenzierungs-SDK ist es nicht. Der Datenordner ist auf beiden ~/.local/share und pro Benutzer, ein gehärteter Dienst braucht also ein explizites Zuhause — StateDirectory= gibt ihm eines, und LoadCredential= reicht ihm eine Lizenzdatei oder einen Schlüssel ohne ein einziges Geheimnis in der Umgebung. Der Fingerabdruck ist an /etc/machine-id oder der IOPlatformUUID verankert statt an einem Registrierungswert, was genauso stabil ist — solange Ihre Images nicht eine ID an vierzig Hosts ausliefern und Ihre Container nicht gar keine. Und die Lease wird durch Neustarten aufgefrischt, was ein Daemon nie tut, also geben Sie ihm eine Schleife. Machen Sie die Pfade explizit, geben Sie sie und die Ankerquelle in einer Diagnose aus, und an dem Tag, an dem ein Support-Techniker fragt, warum ein lizenzierter Dienst an Tag 15 auf Free gefallen ist, steht die Antwort bereits im Log.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.