Skip to content
← Todas las publicaciones
· Delta1 Labs KeyrightLicenciamiento.NETAnálisis a fondo

El estado de la licencia como health check: mapear una licencia Keyright a Healthy, Degraded y Unhealthy sin bucles de reinicio

Un servicio .NET sin interfaz no tiene un cuadro Acerca de donde mostrar su licencia, así que el único lugar donde los operadores llegan a ver el estado de la licencia es el panel de monitorización, lo que significa que la licencia tiene que convertirse en un health check. La versión ingenua conecta la licencia directamente a la sonda de readiness: una clave caducada empieza a devolver 503, el orquestador saca la instancia de rotación y después la mata, el reemplazo arranca con la misma clave caducada y acabas de construir un bucle de reinicios a partir de un evento de facturación. Esta entrada lee qué significan realmente los tres valores de HealthStatus para un orquestador, mapea el LicenseStatus de Keyright sobre ellos de modo que una clave válida pero a punto de caducar sea Degraded y no Unhealthy, mantiene el estado de la licencia completamente fuera de la sonda de liveness para que un vencimiento nunca dispare un kill, cachea la validación para que una sonda por segundo no vuelva a verificar una firma cada vez y expone el detalle que los operadores necesitan a través del diccionario de datos del health check. Termina con la disposición de tags que separa el endpoint de licencia de readiness y liveness, y con la prueba que demuestra que una clave caducada se degrada sin tumbar el proceso.

Un servicio sin interfaz —un worker, un backend gRPC, un host de trabajos programados— no tiene dónde mostrarte su licencia. No hay cuadro Acerca de, ni barra de estado, ni pantalla de configuración. El único lugar donde un operador se dará cuenta de que la clave caducó anoche es el panel de monitorización, lo que significa que la licencia tiene que convertirse en un health check. La trampa es que el cableado evidente —la licencia en la sonda de readiness— convierte un evento de facturación en un crash loop. Esta entrada construye la versión que no lo hace.

Trata de mapear una licencia Keyright sobre el modelo de salud de ASP.NET Core de modo que válida sea verde, a punto de caducar sea ámbar y siga sirviendo, y caducada sea roja y ruidosa, sin dejar nunca que una licencia vencida dispare un reinicio.

Tres estados, y qué hace un orquestador con cada uno

Los health checks de ASP.NET Core tienen exactamente tres resultados, y todo el diseño depende de usar los tres en lugar de los dos a los que recurre la mayoría:

public enum HealthStatus { Unhealthy = 0, Degraded = 1, Healthy = 2 }

El middleware de informe los mapea a HTTP por ti: Healthy y Degraded devuelven ambos 200, y solo Unhealthy devuelve 503. Un orquestador o balanceador de carga que lee el endpoint de readiness saca una instancia de rotación con un 503 y la deja dentro con un 200. Así que Degraded es el estado que permite a una comprobación decir «algo va mal, pero sigue sirviendo», que es precisamente la forma de una licencia dentro de su ventana de gracia. Reduce el modelo a Healthy/Unhealthy y te ves obligado a declarar una ventana de gracia o bien verde (nadie ve que el reloj se agota) o bien roja (una instancia que aún tiene derecho a servir se saca de rotación). Ambas son incorrectas; Degraded es la respuesta correcta y está ahí mismo.

El mapeo

El Validate() de Keyright devuelve un LicenseInfo cuyo Status es un LicenseStatus. El health check es la tabla de traducción de ese enum a HealthStatus:

using Microsoft.Extensions.Diagnostics.HealthChecks;

public sealed class LicenseHealthCheck(LicenseSnapshot snapshot) : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken ct = default)
    {
        LicenseInfo info = snapshot.Current;   // cached; see below — no crypto on this path

        var data = new Dictionary<string, object>
        {
            ["status"]        = info.Status.ToString(),
            ["licensee"]      = info.Licensee ?? "(none)",
            ["expiryUtc"]     = info.ExpiryUtc?.ToString("O") ?? "(n/a)",
            ["daysRemaining"] = info.DaysRemaining,
        };

        HealthCheckResult result = info.Status switch
        {
            LicenseStatus.Valid when info.IsExpiringSoon
                => HealthCheckResult.Degraded(
                     $"License valid, expires in {info.DaysRemaining} day(s).", data: data),

            LicenseStatus.Valid
                => HealthCheckResult.Healthy("License valid.", data),

            // Still entitled, serving on a cached lease inside the grace window.
            LicenseStatus.Valid or LicenseStatus.Expired when snapshot.InGraceWindow
                => HealthCheckResult.Degraded(
                     "Running on cached lease inside grace window.", data: data),

            _ => HealthCheckResult.Unhealthy(
                     $"License not valid: {info.Status}. {info.Message}", data: data),
        };

        return Task.FromResult(result);
    }
}

Expired, Revoked, SignatureInvalid, MachineMismatch, ClockTampered y NoLicense caen todos en la rama _ y salen como Unhealthy: el servicio está funcionando sin licencia y el responsable necesita saberlo ya. Las dos ramas Degraded son el sentido de todo el ejercicio: una clave que es válida pero está dentro de su ventana de renovación, y una clave que ha vencido pero sigue dentro de una ventana de gracia con un lease en caché. Ambas mantienen el servicio en rotación y ambas pintan el panel de ámbar.

LicenseStatus → HealthStatus → HTTP → sondaValidValid + IsExpiringSoon(ventana de renovación)Valid/Expired +InGraceWindowExpired · RevokedSignatureInvalidMachineMismatchClockTamperedNoLicenseHealthy · 200Degraded · 200en rotación, ámbarUnhealthy · 503fuera de rotaciónQué sonda/health/live → nunca comprueba la licencia/health/ready → la licencia es opcional aquí/health/license → la monitorización lo lee siempreDegraded sigue sirviendo mientrasse alerta al operador; livenessnunca lo lleva: un reiniciono puede renovar una clave.

Mantenlo fuera de liveness: el bucle de reinicios

Este es el fallo que produce el cableado en la sonda de readiness. Liveness responde a «¿está este proceso bloqueado? ¿lo reinicio?». Readiness responde a «¿debe esta instancia recibir tráfico?». Pon la licencia en liveness y la secuencia es:

  1. La clave caduca a medianoche. La comprobación de liveness pasa a Unhealthy y devuelve 503.
  2. El orquestador mata el contenedor.
  3. El reemplazo arranca, lee la misma licencia del mismo sitio, y la comprobación de liveness vuelve a pasar a Unhealthy.
  4. Vuelve al paso 2.

Has construido un crash loop cuya causa raíz es un evento de facturación, y los reinicios entierran la única línea de log que te lo habría dicho. Un reinicio puede arreglar un proceso bloqueado; no puede renovar una licencia. Así que la comprobación de licencia va en readiness como mucho, preferiblemente en su propio endpoint, y nunca en liveness. Registra las comprobaciones con tags y separa los endpoints por tag:

builder.Services
    .AddSingleton<LicenseSnapshot>()                          // the cached snapshot (next section)
    .AddHealthChecks()
    .AddCheck<LicenseHealthCheck>("license", tags: ["license"])
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live", "ready"]);

var app = builder.Build();

// Liveness: nothing that a restart cannot fix. No license here.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = r => r.Tags.Contains("live"),
});

// Readiness: the license may ride along, but a Degraded grace window stays 200.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = r => r.Tags.Contains("ready"),
});

// A dedicated endpoint monitoring scrapes for the amber/red license signal.
app.MapHealthChecks("/health/license", new HealthCheckOptions
{
    Predicate      = r => r.Tags.Contains("license"),
    ResponseWriter = WriteLicenseJson,          // emit the data bag, below
});

Si tu plataforma trata readiness estrictamente como 200 o fuera, deja "license" fuera del predicado ready como se muestra y deja que solo /health/license lo lleve, para que una ventana de gracia —que es Degraded/200— nunca pueda ser lo que retire una instancia que aún tiene derecho a servir. Eres tú quien decide qué significa el tráfico degradado; no dejas que una sonda lo decida matando la máquina.

La instantánea: no vuelvas a verificar criptografía en cada sonda

Una sonda de readiness puede dispararse cada segundo. El estado de la licencia cambia como mucho una vez al día. Validar una firma en cada sonda es trabajo desperdiciado y, peor aún, te tienta a poner una llamada de red en la ruta de la sonda. Envuelve el cliente en una instantánea memoizada que se refresque con una cadencia lenta, para que el health check lea un campo:

public sealed class LicenseSnapshot
{
    static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
    {
        Product         = "acme-worker",
        PublicKeyBase64 = AcmeKeys.LicensePublicKey,          // public key only; safe to ship
        ServiceUrl      = "https://licensing.acme.example",
    });

    readonly object _gate = new();
    LicenseInfo _info = License.Validate();
    DateTime _asOf = DateTime.UtcNow;

    public LicenseInfo Current
    {
        get
        {
            lock (_gate)
            {
                if (DateTime.UtcNow - _asOf > TimeSpan.FromSeconds(60))
                {
                    _info = License.Validate();               // crypto + any phone-home here, off the probe
                    _asOf = DateTime.UtcNow;
                }
                return _info;
            }
        }
    }

    public bool InGraceWindow => _info.Status == LicenseStatus.Valid && _info.IsExpiringSoon
                              || (_info.Status == LicenseStatus.Expired && _info.DaysRemaining > -7);
}

Ahora CheckHealthAsync es una lectura en tiempo constante de _info, la sonda nunca se bloquea esperando a la red, y el refresco de una vez por minuto es donde ocurre la renovación del lease o el phone-home en segundo plano, fuera de la ruta de la sonda, que es la otra razón por la que la comprobación nunca debe estar en liveness. (KeyrightClient ya cachea internamente un resultado verificado; la instantánea añade la cadencia de refresco y el único sitio desde el que leerlo.)

Lo que los operadores leen en realidad

El diccionario data de cada HealthCheckResult es la carga útil que convierte esto en algo útil y no en un simple punto de color. Emítelo como JSON desde el endpoint de licencia:

static Task WriteLicenseJson(HttpContext ctx, HealthReport report)
{
    ctx.Response.ContentType = "application/json";
    var entry = report.Entries["license"];
    var payload = new
    {
        status = entry.Status.ToString(),                     // Healthy | Degraded | Unhealthy
        detail = entry.Description,
        data   = entry.Data,                                  // licensee, expiryUtc, daysRemaining
    };
    return ctx.Response.WriteAsJsonAsync(payload);
}

Recoge daysRemaining en tus métricas y tendrás una alerta que salta tres semanas antes de que venza una clave perpetua con mantenimiento, no tres semanas después. Conserva los campos licensee y status; no pongas la clave de licencia en bruto, la firma ni nada de lo que llevaría un aviso de renovación en un endpoint que puede estar menos protegido que la propia aplicación.

Prueba la ventana de gracia, no solo válida y caducada

La prueba que importa es la que el diseño con la sonda de readiness habría suspendido: una clave caducada pero en gracia debe devolver Degraded y seguir sirviendo, y una clave caducada del todo debe devolver Unhealthy sin que el endpoint de liveness se entere jamás.

[Fact]
public async Task Expiring_key_is_degraded_and_stays_in_rotation()
{
    var snapshot = FakeSnapshot.With(status: LicenseStatus.Valid,
                                     expiryUtc: DateTime.UtcNow.AddDays(5));   // IsExpiringSoon
    var check  = new LicenseHealthCheck(snapshot);

    var result = await check.CheckHealthAsync(new HealthCheckContext());

    Assert.Equal(HealthStatus.Degraded, result.Status);     // amber, not red
    // Degraded is HTTP 200 → the instance is NOT pulled from rotation.
}

[Fact]
public async Task Revoked_key_is_unhealthy()
{
    var snapshot = FakeSnapshot.With(status: LicenseStatus.Revoked);
    var result   = await new LicenseHealthCheck(snapshot)
                         .CheckHealthAsync(new HealthCheckContext());

    Assert.Equal(HealthStatus.Unhealthy, result.Status);    // 503 on the license endpoint
}

Combínalas con una aserción de que el predicado "live" nunca resuelve la comprobación "license", para que una refactorización futura no pueda mover silenciosamente la licencia a liveness y reconstruir el crash loop. Ejecuta todo el conjunto contra un servicio de emisión falso si quieres cubrir también el viaje de ida y vuelta del refresco.

La conclusión

La licencia de un servicio sin interfaz solo es visible en el panel, así que tiene que ser un health check; pero los tres niveles de HealthStatus existen por una razón, y el estado de la licencia los usa todos. Valid es Healthy y 200; válida pero a punto de caducar, o vencida dentro de la gracia, es Degraded y sigue siendo 200, así que la instancia sigue sirviendo mientras el panel se pone en ámbar; todo lo demás es Unhealthy y 503. Mantén la comprobación fuera de liveness para que un evento de facturación nunca pueda disparar un bucle de reinicios, léela desde una instantánea memoizada para que una sonda por segundo nunca vuelva a ejecutar criptografía, y emite daysRemaining para que la alerta salte antes de que la clave venza, no después. La licencia deja de ser una bomba de relojería invisible en un servicio sin UI y se convierte en una línea de color más que operaciones ya sabe vigilar.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.