Licenciar una app .NET en Linux y macOS: sin registro, sin AppData — dónde vive el lease de Keyright, a qué se ancla la huella y el servicio systemd que nunca se relanza
La mayoría de las guías de licenciamiento están escritas para Windows, y el SDK de Keyright lo disimula sin decirlo: las mismas llamadas a Validate() y ActivateAsync() funcionan en Linux y macOS. Pero las tres cosas de las que depende un SDK de licencias —una carpeta de datos por usuario, una identidad de máquina estable y una app que se relanza con la frecuencia suficiente para refrescar su lease— son distintas fuera de Windows. El lease acaba en ~/.local/share, donde una app de Mac en sandbox o un servicio systemd con ProtectHome= no pueden escribir. La huella de máquina se ancla en /etc/machine-id o en el IOPlatformUUID en lugar de en un valor del registro, y una imagen maestra que olvidó limpiar el machine-id convierte toda una flota en una sola máquina. Y un daemon que arranca una vez al trimestre nunca recibe el refresco de arranque en el que confía una app de escritorio, así que su lease de 14 días caduca con el proceso aún en ejecución. Esta entrada recorre dónde escribe el SDK en cada plataforma, de qué se construye la huella y las dos trampas del machine-id, una unidad de systemd con StateDirectory= y LoadCredential= que le da un hogar al SDK, el bucle de refresco del lease que necesita un servicio de larga duración, el modo de archivo que el lease merece en un host multiusuario y el bucle de verificación que lo demuestra.
Al SDK de Keyright no le importa en qué sistema operativo se ejecuta. Keyright.NET apunta a netstandard2.0 y net8.0, Validate() es cómputo puro sobre bytes, y ActivateAsync es una llamada HTTPS. Despliega un worker licenciado en Ubuntu o distribuye una compilación para Mac de tu app de escritorio y el mismo código funciona. Esa es la buena noticia, y también es la razón por la que las tres diferencias de plataforma que sí importan tienden a aparecer como un ticket de soporte y no como un error de compilación.
Un SDK de licencias depende de tres cosas que proporciona la plataforma: un lugar por usuario donde guardar un pequeño archivo firmado, una identidad estable para la máquina y —menos obvio— una aplicación que se relanza con la frecuencia suficiente para refrescar lo que tiene en caché. En Windows esas cosas son %LocalAppData%, el MachineGuid del registro y el hecho de que las apps de escritorio arrancan cada mañana. Fuera de Windows las tres son distintas, y una integración de Keyright que se probó en el portátil Windows de un desarrollador puede hacer silenciosamente lo incorrecto en la máquina Linux en la que se despliega. Esta entrada es la versión Linux y macOS de la historia: adónde van los archivos, de qué está hecha la huella, cómo darle un hogar a un servicio systemd y el bucle de refresco que necesita un proceso que nunca se reinicia.
Adónde van los archivos cuando no hay AppData
Un cliente Keyright mantiene hasta tres archivos por producto, todos con raíz en 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
En Unix, .NET resuelve LocalApplicationData a la manera XDG: $XDG_DATA_HOME si está definida, y si no $HOME/.local/share. Y lo hace también en macOS — no ~/Library/Application Support, que es donde miraría un usuario de Mac. Así que para una usuaria dana el lease está en:
Linux /home/dana/.local/share/Keyright/acme-agent/lease.json
macOS /Users/dana/.local/share/Keyright/acme-studio/lease.json
De «está bajo $HOME» se derivan tres cosas, y son toda la primera mitad de esta entrada.
Es por usuario. Dos usuarios en la misma máquina tienen cada uno su propia caché y cada uno activa una vez. Eso no son dos puestos: ambas activaciones toman la huella de la misma máquina y envían el mismo machineId, así que el servicio vincula un puesto y lo refresca dos veces — exactamente el comportamiento del PC compartido descrito en la entrada sobre MSIX. La caché es por usuario; el puesto siempre fue por máquina.
Depende de que HOME sea real y escribible. Una unidad de systemd con ProtectHome=yes ve un /home vacío y de solo lectura. Una unidad con DynamicUser=yes se ejecuta como un UID transitorio cuyo home es /. Una cuenta de servicio creada a mano suele crearse con --home /nonexistent o --home /var/empty. En todos esos casos, ~/.local/share/Keyright no puede crearse. El SDK no lanza una excepción: la caché del lease es deliberadamente de mejor esfuerzo, así que una escritura fallida se traga en silencio, ActivateAsync devuelve un LicenseInfo perfectamente válido y el servicio se ejecuta licenciado — hasta el siguiente reinicio, cuando no hay lease en caché y vuelve a activar. En línea, eso es invisible; solo cuesta un viaje de ida y vuelta. Sin conexión, es NoLicense y un servicio que falla cerrado cuando ayer funcionaba.
Una app de Mac en sandbox tiene un HOME distinto. Bajo App Sandbox, HOME se redirige al contenedor de la app, así que ~/.local/share/Keyright/acme-studio/lease.json acaba en ~/Library/Containers/com.acme.studio/Data/.local/share/Keyright/acme-studio/lease.json. Todo funciona — el sandbox es escribible, sobrevive a las actualizaciones y se borra cuando el usuario elimina la app y su contenedor. Es el equivalente en macOS de la virtualización de escritura de MSIX: la ruta que el SDK registra no es la ruta donde están los bytes. Hazla explícita con LeaseCachePath y compañía si un ingeniero de soporte va a necesitar alguna vez el archivo.
A qué se ancla la huella
La huella de máquina que el SDK envía al activar es un conjunto de hashes con sal, KRM1:anchor:soft1:soft2, construido a partir de una lista ordenada de componentes en la que el índice 0 es el ancla, que siempre debe coincidir, y el resto son señales blandas que NodeLockTolerance (por defecto 1) deja variar de una en una. En Windows el ancla es HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid. Fuera de Windows no hay registro, y el SDK lee el equivalente de la plataforma:
- Linux:
/etc/machine-id, el id de 32 caracteres hexadecimales que systemd genera en el primer arranque, con/var/lib/dbus/machine-idcomo alternativa para los sistemas que solo tienen el de D-Bus. Es legible por todo el mundo (modo0444), así que un servicio que se ejecuta como usuario sin privilegios puede leerlo sin ninguna capability — por eso las unidades conDynamicUser=toman la huella correctamente. - macOS: el
IOPlatformUUIDde la entradaIOPlatformExpertDeviceen el registro de E/S, leído medianteioreg. Es el id por máquina que usan los propios servicios de Apple, y sobrevive a las actualizaciones del sistema operativo, a los cambios de nombre de host y a que un usuario borre~/. - Nada legible: el SDK genera un id aleatorio, lo persiste en
~/.local/share/Keyright/machine.idy se ancla en él. En Windows y en cualquier instalación normal de Linux o Mac este archivo nunca existe; es el último recurso para un contenedor sinmachine-id, o un sandbox cerrado que no puede lanzarioreg. Fíjate en lo que cuesta: la alternativa es por usuario, así que dos usuarios en una máquina así serían dos máquinas y dos puestos.
Los dos componentes blandos son el nombre de host y Environment.OSVersion.Platform más la versión mayor — Unix:6 en un kernel Linux 6.x, la versión mayor de macOS en un Mac. La tolerancia es lo que los mantiene razonables: renombra el host y el ancla sigue coincidiendo, un componente blando difiere, el lease valida. Actualiza una distribución de un kernel 5.x a uno 6.x, lo mismo. Haz las dos cosas en la misma semana y tienes dos discrepancias blandas, lo que supera la tolerancia por defecto — el lease informa MachineMismatch y el siguiente ActivateAsync vuelve a vincular, de forma idempotente, porque el servicio casa por el ancla. El diseño se explica en la entrada sobre la huella de máquina; el punto específico de Linux es simplemente que tu ancla de «hardware» es un archivo.
Las dos trampas del machine-id
Como el ancla es un archivo, puede copiarse y puede faltar. Cada caso es un fallo distinto.
Copiado: la imagen maestra. Construye una imagen de VM, instala tu agente, haz un snapshot, aprovisiona cuarenta hosts desde el snapshot. A menos que algo haya limpiado /etc/machine-id antes de sellar el snapshot, los cuarenta arrancan con el mismo id, producen el mismo hash de ancla y presentan el mismo machineId. Keyright hace lo que hace con una máquina que ya ha visto: el primer host activa y vincula un puesto, los otros treinta y nueve lo refrescan. El panel de control muestra un dispositivo. Vendiste un puesto y estás ejecutando cuarenta — esta es la trampa que falla abierta, de las que no te llega ningún ticket. La solución vive en la imagen, no en el SDK: truncate -s0 /etc/machine-id antes de sellar (un archivo vacío le dice a systemd que genere un id nuevo en el primer arranque), o ejecuta systemd-machine-id-setup desde el aprovisionamiento del primer arranque. cloud-init lo hace; las plantillas construidas a mano a menudo no. La entrada sobre VMs clonadas cubre cómo detectar los duplicados desde el lado del servicio cuando no controlas las imágenes. Si quieres que muchos hosts cuenten como un solo despliegue, dilo con un IMachineComponents personalizado, como en la entrada sobre contenedores — una decisión en código, no un accidente en una plantilla.
Ausente: el contenedor. Las imágenes de contenedor mínimas traen un /etc/machine-id vacío o ninguno en absoluto. El SDK lee una cadena vacía, la trata como ilegible y cae a la alternativa aleatoria persistida — que vive en la capa escribible del contenedor y desaparece con el contenedor. Cada reinicio es una máquina nueva y un puesto nuevo. Es el mismo problema que la entrada sobre contenedores resuelve sobrescribiendo la fuente de identidad, y nada en esta página lo cambia; lo que esta página añade es el porqué del comportamiento por defecto: el archivo está vacío.
Un servicio systemd: darle un hogar al SDK
Ahora la mitad de la entrada que importa a la mayoría de los despliegues Linux: un worker .NET licenciado ejecutándose bajo systemd. Una unidad escrita con el endurecimiento moderno tiene este aspecto, y cada línea que importa al licenciamiento está anotada:
# /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= es la respuesta al problema de HOME. Le da al servicio un directorio que existe, pertenece al usuario del servicio, sobrevive a reinicios y actualizaciones, y está exento de ProtectSystem=strict. StateDirectoryMode=0700 mantiene fuera a los demás usuarios del host. LoadCredential= es la respuesta a «¿de dónde sale la licencia en una máquina sin humanos?»: systemd copia el archivo a un tmpfs por servicio y le dice al proceso dónde está mediante CREDENTIALS_DIRECTORY. A diferencia de una línea Environment=, el contenido nunca aparece en systemctl show, en /proc/<pid>/environ ni en un proceso hijo.
El cliente lee ambas:
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);
}
Fuera de systemd —un desarrollador ejecutando el binario desde una shell— ninguna de las dos variables está definida, el SDK usa por defecto ~/.local/share, y el mismo código es correcto. No hay ningún indicador «¿soy un servicio?» en el que equivocarse.
La rama de LicenseFilePath es la misma regla de fuente explícita que en el resto del SDK: cuando está establecida, las fuentes ambientales (KEYRIGHT_LICENSE, la ruta de configuración, la ruta por defecto, el lease en caché) no se consultan, y un archivo ausente o inválido es NoLicense o SignatureInvalid en lugar de una caída a la siguiente fuente. Para un despliegue en el que el licenciamiento es una decisión del operador, eso es lo que quieres. Ten en cuenta que el archivo de licencia no es un secreto en el sentido en que lo es una contraseña — es un documento firmado vinculado a la huella de esta máquina, inútil en cualquier otro sitio — pero sí lleva la clave de licencia y el nombre del licenciatario, y LoadCredential= es simplemente la forma más limpia de poner un archivo delante de exactamente un proceso en un host Linux.
La historia del daemon en macOS es la misma con otros nombres. Un LaunchDaemon que se ejecuta como un usuario dedicado _acme tiene como home /var/empty, que es de solo lectura; el HOME de un daemon root es /var/root. Pon las tres rutas bajo /Library/Application Support/Acme/keyright/ con modo 0700, propiedad del usuario del daemon, y el SDK está contento. launchd no tiene LoadCredential=; un archivo 0600 bajo /Library/Application Support/Acme/ referenciado mediante LicenseFilePath es el equivalente.
El daemon que nunca se relanza
Aquí está la diferencia que no tiene nada que ver con rutas de archivos y que más duele.
Un lease de activación es un token firmado de corta duración —14 días por defecto— y el patrón de refresco que recomienda el SDK es llamar a ActivateAsync al arrancar. Para una app de escritorio eso es una solución completa: el usuario la lanza casi todos los días, cada lanzamiento refresca el lease cuando hay conexión, y un portátil que pasa dos semanas sin conexión es el caso límite para el que se dimensionó la ventana. Un servicio invierte las frecuencias. Se lanza una vez, tras un despliegue, y luego se ejecuta durante meses. Nada vuelve a llamar a ActivateAsync, el lease llega a leaseExpiresUtc, y el siguiente Validate() —en un manejador de peticiones, el día 15— devuelve Expired con el mensaje «reconnect to re-validate». El proceso está arriba, la red está bien, la licencia está pagada, y el servicio ha fallado cerrado a Free porque nadie lo relanzó.
La solución es un bucle de refresco dentro del servicio. ActivateAsync puede llamarse repetidamente sin riesgo: reactivar una máquina que el servicio ya ha visto es idempotente —mismo puesto, lease nuevo— y si el servicio de licencias no está disponible el SDK recurre al lease en caché aún válido en lugar de descartarlo, así que el bucle solo puede extender la ventana, nunca acortarla.
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));
}
}
Regístralo con services.AddHostedService<LeaseRefresher>() junto al cliente singleton. Seis horas es un intervalo razonable: el lease tiene entonces siempre menos de seis horas de antigüedad, así que la ventana de 14 días se convierte en catorce días de interrupción tolerada de tu servicio de licencias en lugar de catorce días de uptime. La primera iteración se ejecuta inmediatamente, lo que sirve también como activación de arranque. Registra el estado, nunca la clave — la entrada sobre logging estructurado explica por qué info.Status e info.DaysRemaining son los campos correctos.
Dos cosas que el bucle no hace, a propósito. No reinicia el proceso cuando el estado se degrada: Validate() ya falla cerrado por sí solo, y la entrada sobre health checks muestra cómo exponer una licencia degradada al orquestador sin un bucle de reinicios. Y no se ejecuta para los despliegues basados en archivo: un archivo de licencia de sitio firmado tiene su propia caducidad y nada que refrescar, así que el bucle comprueba la credencial de la clave y se retira.
El modo de archivo que el lease merece
En Windows el lease vive bajo un perfil que solo su usuario puede leer. En Linux, File.WriteAllText crea los archivos con 0666 & ~umask, que con el habitual 022 es 0644 — legible por todas las cuentas del host. El lease no es peligroso de leer: está firmado, así que editarlo produce SignatureInvalid, y está vinculado a la huella de esta máquina, así que copiarlo a otro sitio produce MachineMismatch. Pero sí lleva la clave de licencia, y una clave permite a quien la tenga intentar una activación contra tu límite de puestos desde cualquier máquina. En un portátil de un solo usuario eso no es nada; en un host de compilación compartido o un jump box es una fuga de clave vía ls.
StateDirectoryMode=0700 ya cierra esto para un servicio systemd. Para todo lo demás, restringe el directorio antes de que el SDK escriba en él — KeyrightClient.Initialize no crea los directorios, los crea la primera escritura, así que crearlos tú primero gana:
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) y File.SetUnixFileMode son APIs de .NET 7+; en targets más antiguos un P/Invoke a chmod hace lo mismo. Un directorio 0700 basta por sí solo —un archivo 0644 dentro de un directorio que nadie más puede atravesar es ilegible—, así que la llamada de modo de archivo es cinturón y tirantes para un lease que ya existía antes de que restringieras el directorio.
Imprime lo que verá la puerta
Todas las entradas de esta serie acaban recomendando el mismo diagnóstico, porque es lo que convierte un ticket en una sola línea: un indicador --license-info que imprime las rutas y la huella desde la ruta de código que usa la puerta, para que lo que muestra sea lo que hace el SDK.
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";
}
La línea Anchor source es la que atrapa las trampas de la imagen maestra y del contenedor antes de que lleguen a producción: /etc/machine-id en un host real, la alternativa en un contenedor pelado. El sufijo via {source} —Lease, ExplicitFile, EnvironmentVariable, DefaultPath— te dice cuál de las fuentes ganó, que es la primera pregunta cuando un servicio que debería estar leyendo un archivo de credencial está, de algún modo, activando en línea.
Verifícalo
Todo el argumento se puede probar en un solo host en unos minutos. El truco importante está en el paso 2: ejecuta el diagnóstico dentro del entorno de la unidad, no desde tu propia shell como el usuario del servicio — sudo -u acme te da el usuario pero no STATE_DIRECTORY ni las credenciales, y las rutas que imprime son las equivocadas.
# 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.
Añade la que importa para una flota: aprovisiona un segundo host desde la misma imagen y ejecuta el paso 1 en ambos. Si los dos ids coinciden, para y arregla la imagen; nada de lo que viene después tiene sentido hasta que sean distintos.
Qué llevarse
Keyright trata Linux y macOS como ciudadanos de primera, y las llamadas al SDK son idénticas, pero la plataforma que hay debajo de un SDK de licencias no lo es. La carpeta de datos es ~/.local/share en ambos y es por usuario, así que un servicio endurecido necesita un hogar explícito — StateDirectory= se lo da, y LoadCredential= le entrega un archivo de licencia o una clave sin un solo secreto en el entorno. La huella se ancla en /etc/machine-id o en el IOPlatformUUID en lugar de en un valor del registro, que es igual de estable — siempre que tus imágenes no distribuyan un mismo id a cuarenta hosts, y tus contenedores no distribuyan ninguno. Y el lease se refresca relanzando, cosa que un daemon nunca hace, así que dale un bucle. Haz las rutas explícitas, imprímelas junto con la fuente del ancla en un diagnóstico, y el día que un ingeniero de soporte pregunte por qué un servicio licenciado pasó a Free el día 15, la respuesta ya estará en el log.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.