Publicat el ::

.NET 10 porta una novetat que em sembla més important del que pot semblar a primera vista: dotnet run app.cs i dotnet publish app.cs. En resum: pots agafar un sol fitxer C#, sense .csproj, i executar-lo o publicar-lo directament.

Quan ho vaig veure, la lectura fàcil era "ah, finalment .NET té un mode script decent". I sí, una mica va per aquí. Microsoft ho va presentar a l'article Announcing dotnet run app.cs i també hi ha algun resum ràpid com aquest de dev.to.

Però aquest post no va d'explicar la feature en abstracte. Va d'un use-case molt concret que em sembla especialment bo: fer servir un .cs solt com a entrypoint compilat amb Native AOT per a contenidors hardened, distroless, rootless i sense shell. L'exemple real surt de vegops/containers, concretament de la imatge de qBittorrent.

El problema: contenidors sense shell

Quan dius "distroless" aquí no parles només de fer una imatge petita. Parles d'una imatge amb el mínim imprescindible a runtime. En aquest cas concret, l'entorn va sobre Wolfi/Chainguard, sense busybox, sense sh, sense apt, i sense tota la ferralla habitual que acostuma a venir amb una distribució generalista.

A més a més, el contenidor arrenca com a nonroot amb UID 65532. Això és exactament el que vols en una imatge hardened: menys superfície, menys privilegis, menys sorpreses. El problema és que abans d'arrencar el procés principal sovint encara necessites una mica de lògica d'init.

Pot ser copiar una configuració per defecte, o injectar un secret via variable d'entorn, o bé, substituir el procés correctament perquè el teu servei quedi com a PID 1 i rebi senyals directament. Amb shell això és trivial. Sense shell, has de buscar un altre mètode. Les opcions típiques són un binari petit en Go, Rust, tini, o ara també un fitxer C# compilat amb Native AOT.

En un contenidor sense shell, el problema no és "com executo una comanda". El problema real és "on poso la lògica d'entrada sense reintroduir dependències i complexitat".

La solució: un entrypoint.cs amb Native AOT

L'exemple de qBittorrent ho resolc amb un únic fitxer: entrypoint.cs. No hi ha projecte, ni dependències externes, ni tan sols runtime .NET dins la imatge final. Durant el build es compila amb Native AOT i el resultat és un binari autocontingut que acaba a /usr/bin/entrypoint.

La part interessant és que el mateix fitxer porta directives #:property per controlar la compilació. Aquí és on defineixes que vols PublishAot=true, que vols treure símbols, optimitzar per mida, retallar metadades i desactivar coses que no calen com el suport de stack traces.

El programa fa tres coses i prou: garantir que hi ha configuració, rotar la password per defecte si encara hi és, i fer un execv() sobre el binari real de qBittorrent. Res més. Just el tipus d'entrypoint petit, específic i visible que vols en un contenidor distroless.

Aquest és l'entrypoint sencer:

#:property PublishAot=true
#:property InvariantGlobalization=true
#:property StripSymbols=true
#:property OptimizationPreference=Size
#:property IlcOptimizationPreference=Size
#:property StackTraceSupport=false
#:property UseSystemResourceKeys=true
#:property IlcTrimMetadata=true
#:property AssemblyName=entrypoint

using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;

const string AppBin = "/usr/bin/qbittorrent";
const string ConfigPath = "/qbittorrent/etc/qBittorrent.conf";
const string DefaultConfigPath = "/usr/share/qbittorrent/qBittorrent.conf";
const string DefaultPasswordHash = "@ByteArray(188J/h/wfAYQ9H+mTl/7lA==:j/+e2SwJUi9g+IPiEG2+Pix9W0IOv2c20QjrmBUhr4TBUXO3fcMv6leeU6qK8834xiq8fngh8ShwYDfYO0w6lg==)";
const string Alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";

try
{
    EnsureConfig();
    RotateDefaultPassword();
    Exec();
}
catch (Exception ex)
{
    Console.Error.WriteLine(ex.Message);
    Environment.Exit(1);
}

static void EnsureConfig()
{
    Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!);

    var inlineConfig = Environment.GetEnvironmentVariable("QBITTORRENT_CONFIG");
    if (!string.IsNullOrEmpty(inlineConfig))
    {
        File.WriteAllText(ConfigPath, inlineConfig);
        return;
    }

    if (!File.Exists(ConfigPath))
        File.Copy(DefaultConfigPath, ConfigPath);
}

static void RotateDefaultPassword()
{
    var content = File.ReadAllText(ConfigPath);
    if (!content.Contains(DefaultPasswordHash, StringComparison.Ordinal))
        return;

    var password = RandomString(12);
    var salt = RandomString(16);

    var hash = Rfc2898DeriveBytes.Pbkdf2(
        password,
        Encoding.UTF8.GetBytes(salt),
        100000,
        HashAlgorithmName.SHA512,
        64);

    var replacement = $"@ByteArray({Convert.ToBase64String(Encoding.UTF8.GetBytes(salt))}:{Convert.ToBase64String(hash)})";
    var updated = content.Replace(DefaultPasswordHash, replacement, StringComparison.Ordinal);

    File.WriteAllText(ConfigPath, updated);
    Console.WriteLine($"qBittorrent admin password: {password}");
}

static string RandomString(int length)
{
    var bytes = RandomNumberGenerator.GetBytes(length);
    var chars = new char[length];

    for (var i = 0; i < bytes.Length; i++)
        chars[i] = Alphabet[bytes[i] % Alphabet.Length];

    return new string(chars);
}

static void Exec()
{
    var args = Environment.GetCommandLineArgs();
    var argv = new string?[args.Length + 1];
    argv[0] = "qbittorrent";
    Array.Copy(args, 1, argv, 1, args.Length - 1);
    argv[^1] = null;

    var result = NativeMethods.execv(AppBin, argv);
    throw new Exception($"execv({AppBin}) failed: errno {Marshal.GetLastPInvokeError()}, result {result}");
}

static class NativeMethods
{
    [DllImport("libc", SetLastError = true)]
    internal static extern int execv(string filename, string?[] argv);
}

Desglossament del codi

Configuració: EnsureConfig()

La primera responsabilitat és assegurar que qBittorrent té un fitxer de configuració usable. Si existeix la variable d'entorn QBITTORRENT_CONFIG, el contingut s'escriu directament a /qbittorrent/etc/qBittorrent.conf. Si no, i encara no hi ha config, es copia el fitxer per defecte des de /usr/share/qbittorrent/qBittorrent.conf.

És una lògica simple, però és exactament la mena de coses que abans acabaven en un entrypoint.sh. La diferència és que aquí no depens de tenir shell ni eines externes a runtime.

Un bon detall és que la configuració inline via variable d'entorn evita haver de muntar scripts auxiliars només per injectar quatre línies de config.

Rotació de password: RotateDefaultPassword()

La segona responsabilitat és evitar que qBittorrent arrenqui amb el hash per defecte. El codi llegeix el fitxer, busca el DefaultPasswordHash conegut, i si encara hi es genera una password aleatòria i un salt nou.

Després deriva el hash amb Rfc2898DeriveBytes.Pbkdf2, fent servir SHA512, 100000 iteracions i una sortida de 64 bytes. Amb això construeix el format @ByteArray(...) que qBittorrent espera, substitueix el hash per defecte i escriu el fitxer actualitzat.

Finalment imprimeix la nova password a stdout. En un contenidor això és pràctic perquè la pots recuperar directament dels logs de la primera arrencada, sense inventar un mecanisme paral·lel.

Process replacement: Exec()

La tercera peça és la més pròpia de contenidors "de debò": fer un execv() via P/Invoke a libc. Això substitueix el procés actual pel binari real de qBittorrent, en lloc de llançar un subprocess i quedar-te amb un wrapper pel mig.

Aquest detall importa perquè l'entrypoint acaba desapareixent i qBittorrent queda com a PID 1. Això vol dir senyals directes, menys problemes de reaping, i un comportament molt més net quan el runtime del contenidor envia SIGTERM.

Si has fet servir scripts shell com a entrypoint, ja saps el patró: o fas exec, o tard o d'hora acabes amb shutdowns estranys. Aquí es resol igual, però sense shell.

Com es compila i s'integra

Al pipeline de build, el fitxer es compila directament des de melange.yaml:

- name: Build entrypoint (Native AOT)
  runs: |
    arch="$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')"
    dotnet publish entrypoint.cs -c Release -r "linux-${arch}" -o .

- name: Install artifacts
  runs: |
    install -Dm755 entrypoint "${{targets.destdir}}/usr/bin/entrypoint"

Després, a apko.yaml, la imatge final defineix aquest binari com a entrypoint i arrenca com a usuari nonroot:

accounts:
  groups:
    - groupname: nonroot
      gid: 65532
  users:
    - username: nonroot
      uid: 65532
      gid: 65532
  run-as: "65532"

entrypoint:
  command: /usr/bin/entrypoint

El resultat és bastant elegant: el binari viu a /usr/bin/entrypoint, la imatge no necessita ni runtime de .NET ni shell, i tota la lògica d'inicialització queda encapsulada en una peça petita i auditable.

Per què C# i no Go/Rust/shell?

Si ja tens .NET disponible al pipeline de build, afegir un fitxer .cs és gairebé trivial. No has de crear cap projecte, no has de mantenir dependències de tercers, i no has d'obrir un repositori auxiliar només per un entrypoint de poques línies.

Go i Rust continuen sent opcions perfectament bones i vàlides per això. I si tens shell disponible, un script encara pot ser suficient en casos simples. Però en un entorn on ja tens dotnet-10-sdk al build i vols un binari Native AOT petit i autocontingut, C# passa de ser una opció curiosa a ser probablement la més convenient.

hooded

No dic que sigui l'única resposta bona. Dic que, si ja vius dins l'ecosistema .NET, ara tens una resposta molt pragmàtica.

Tancament

La novetat de single-file C# a .NET 10 és fàcil de vendre com una millora de DX. Però crec que el més interessant és una altra cosa: obre la porta a un scripting "seriós" amb C#, prou simple per a tasques petites i prou sòlid per acabar a producció.

En aquest cas, no estem parlant d'un hello world. Estem parlant d'un entrypoint Native AOT per a una imatge rootless, distroless i sense shell. I això, sincerament, em sembla un use-case molt millor que qualsevol demo.

Si vols veure l'exemple complet, amb entrypoint.cs, melange.yaml i apko.yaml, aquí el tens: vegops/containers qBittorrent.

Taula de continguts