HashiCorp Nomad und Vault mit .NET: ASP.NET Core in einem sicheren Workload


Bicycle

Als wir unseren Beitrag HashiCorp Nomad and Vault: Dynamic Secrets veroeffentlichten, lief die Demo ausschliesslich als Python Flask-Anwendung. Seitdem ist das Repository zu einer mehrsprachigen Demo-Plattform gewachsen. Dieser Beitrag behandelt die .NET (ASP.NET Core MVC)-Implementierung — dieselben Vault-Patterns, dieselbe Nomad-Job-Struktur, eine voellig andere Runtime.

Die Motivation ist praktisch: Viele Teams betreiben gemischte Stacks. Zu zeigen, dass das Nomad + Vault Secrets-Modell unabhaengig davon funktioniert, ob man Python, Java oder C# schreibt, beseitigt einen haeufigen Grund fuer die Nicht-Adoption.

Anwendungsueberblick

Die .NET-App ist mit ASP.NET Core MVC fuer .NET 8 gebaut. Sie ist funktional identisch mit der Python-Version:

  • CRUD-Operationen auf einer customers-Tabelle in MySQL
  • Vault Transit-Verschluesselung fuer birth_date, address und salary
  • Vault dynamische Datenbank-Credentials (kurzlebige MySQL-Benutzer)
  • INI-basierte Konfiguration, gerendert durch die Nomad Template Engine
  • /health-Endpunkt fuer Nomad Service-Checks und Consul-Integration
app/dotnet/
├── Program.cs                         # Anwendungs-Einstiegspunkt
├── NomadVaultMySqlDotnet.csproj       # Projektdatei (net8.0)
├── config/config.ini                  # Lokale Dev-Konfiguration
├── Configuration/
│   └── AppRuntimeConfig.cs            # INI → C# Config-Binding
├── Services/
│   ├── VaultService.cs                # VaultSharp SDK Client
│   └── CustomerRepository.cs         # JDBC-Aequivalent: Dapper + Vault
├── Controllers/
│   └── AppController.cs              # MVC-Routen
└── Models/
    └── Customer.cs                    # Datenmodell

Konfigurations-Binding

Die Anwendung liest beim Start eine config.ini-Datei. AppRuntimeConfig verwendet den INI-Provider von Microsoft.Extensions.Configuration — gleiche Abschnitte wie die Python-Version:

1var configPath = Path.Combine(builder.Environment.ContentRootPath, "config", "config.ini");
2builder.Configuration.AddIniFile(configPath, optional: true, reloadOnChange: true);
3
4var runtimeConfig = AppRuntimeConfig.FromConfiguration(builder.Configuration);

Der Parameter reloadOnChange: true bewirkt, dass die App Nomad-Template-Neurenderings (ausgeloest durch Vault-Credential-Rotation) aufnimmt, ohne neu zu starten.

Der Vault-Service — VaultSharp SDK

VaultService.cs verwendet nun VaultSharp — einen weit verbreiteten Community-.NET-Client fuer Vault. Dies eliminiert manuelle HTTP-Codierung und bietet eine robuste, typsichere API fuer Vault-Integration.

Initialisierung

 1public async Task InitializeAsync(AppRuntimeConfig config, CancellationToken cancellationToken)
 2{
 3    _settings = config.Vault;
 4    if (!_settings.Enabled) { IsEnabled = false; return; }
 5
 6    _token = _settings.InjectToken
 7        ? (Environment.GetEnvironmentVariable("VAULT_TOKEN") ?? string.Empty).Trim()
 8        : _settings.Token.Trim();
 9
10    if (string.IsNullOrWhiteSpace(_settings.Address) || string.IsNullOrWhiteSpace(_token))
11    {
12        _logger.LogWarning("Vault-Adresse oder Token fehlen; Initialisierung uebersprungen.");
13        IsEnabled = false;
14        return;
15    }
16
17    try
18    {
19        var authMethod = new TokenAuthMethodInfo(_token);
20        var vaultClientSettings = new VaultClientSettings(_settings.Address, authMethod)
21        {
22            Namespace = string.IsNullOrWhiteSpace(_settings.Namespace) ? null : _settings.Namespace,
23            MyHttpClientProviderFunc = handler => new HttpClient(new HttpClientHandler
24            {
25                // Nur PoC: Selbstsignierte/interne Zertifikate in Demo-Umgebungen vertrauen
26                ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
27            })
28        };
29        _vaultClient = new VaultClient(vaultClientSettings);
30
31        var healthResponse = await SendVaultRequestAsync(HttpMethod.Get, "/v1/sys/health", null, cancellationToken);
32        IsEnabled = healthResponse.IsSuccessStatusCode;
33    }
34    catch (Exception ex)
35    {
36        _logger.LogError(ex, "VaultSharp-Initialisierung fehlgeschlagen");
37        IsEnabled = false;
38    }
39}

Das Token wird aus der Umgebungsvariable VAULT_TOKEN bezogen, wenn InjectToken = True gesetzt ist. Nomad injiziert diese Variable automatisch, wenn der Task einen vault-Stanza hat — die Anwendung verwaltet den Token-Lebenszyklus nie selbst.

Dynamische Datenbank-Credentials mit VaultSharp

 1public async Task<(string User, string Password)?> ReadDatabaseCredentialsAsync(
 2    string path, CancellationToken cancellationToken)
 3{
 4    if (!IsEnabled || string.IsNullOrWhiteSpace(path) || _vaultClient is null)
 5        return null;
 6
 7    // Vault dynamische DB-Creds Pfadformat: <mount>/creds/<role>
 8    var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
 9    var credsIndex = Array.IndexOf(parts, "creds");
10    if (credsIndex > 0 && credsIndex < parts.Length - 1)
11    {
12        try
13        {
14            var mountPoint = string.Join('/', parts.Take(credsIndex));
15            var roleName = parts[credsIndex + 1];
16            var secret = await _vaultClient.V1.Secrets.Database.GetCredentialsAsync(roleName, mountPoint);
17            return (secret.Data.Username ?? string.Empty, secret.Data.Password ?? string.Empty);
18        }
19        catch (Exception ex)
20        {
21            _logger.LogError(ex, "VaultSharp DB-Credentials fehlgeschlagen; Fallback zu generischer API.");
22        }
23    }
24
25    // Fallback zu Raw HTTP, wenn VaultSharp nicht verfuegbar ist
26    var response = await SendVaultRequestAsync(
27        HttpMethod.Get, $"/v1/{path}", null, cancellationToken);
28
29    if (!response.IsSuccessStatusCode) return null;
30
31    using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
32    if (doc.RootElement.TryGetProperty("data", out var data) &&
33        data.TryGetProperty("username", out var user) &&
34        data.TryGetProperty("password", out var pwd))
35    {
36        return (user.GetString() ?? string.Empty, pwd.GetString() ?? string.Empty);
37    }
38
39    return null;
40}

Transit-Verschluesselung mit VaultSharp

 1public async Task<string> EncryptAsync(string value, CancellationToken cancellationToken)
 2{
 3    if (!IsEnabled || _vaultClient is null)
 4        return value;
 5
 6    var plaintext = Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
 7    var request = new EncryptRequestOptions { Base64EncodedPlainText = plaintext };
 8    var response = await _vaultClient.V1.Secrets.Transit.EncryptAsync(
 9        _settings.KeyName, request, mountPoint: _settings.KeyPath);
10    return response.Data.CipherText ?? value;
11}
12
13public async Task<string> DecryptAsync(string value, CancellationToken cancellationToken)
14{
15    if (!IsEnabled || _vaultClient is null || !value.StartsWith("vault:v", StringComparison.Ordinal))
16        return value;
17
18    var request = new DecryptRequestOptions { CipherText = value };
19    var response = await _vaultClient.V1.Secrets.Transit.DecryptAsync(
20        _settings.KeyName, request, mountPoint: _settings.KeyPath);
21
22    var base64PlainText = response.Data.Base64EncodedPlainText;
23    if (string.IsNullOrWhiteSpace(base64PlainText)) return value;
24
25    var decoded = Convert.FromBase64String(base64PlainText);
26    return Encoding.UTF8.GetString(decoded);
27}

VaultSharp behandelt Base64-Codierung/Dekodierung, HTTP-Serialisierung und Response-Parsing automatisch. Die API ist typsicher und entspricht Vaults Request-/Response-Struktur direkt.

Die Nomad Job-Dateien

Das Verzeichnis nomad/dotnet/ spiegelt die Python-Progression genau wider:

DateiTreiberVaultBeschreibung
app_hardcoded.hcldockerZugangsdaten im Template
app_static.hcldockerKVZugangsdaten aus KV Store
app_dynamic.hcldockerDynamic DBKurzlebige MySQL-Benutzer
app_transit.hcldockerDynamic + TransitFeldbasierte Verschluesselung
app_transit_connect.hcldockerDynamic + Transit+ Consul Connect mTLS

Transit-Job (app_transit.hcl)

Die einzigen Unterschiede zum Python Transit-Job sind das Container-Image und der Config-Datei-Mount-Pfad:

 1task "dynamic-app" {
 2  driver = "docker"
 3
 4  config {
 5    image = "quay.io/infralovers/nomad-vault-mysql-dotnet"
 6    volumes = [
 7      "local/config.ini:/app/config/config.ini"   # .NET-Pfad, nicht /usr/src/app
 8    ]
 9    ports = ["web"]
10  }
11
12  template {
13    destination = "local/config.ini"
14    data        = <<EOF
15[DEFAULT]
16LogLevel = DEBUG
17Port = 8080
18
19[DATABASE]
20{{ range service "mysql-server" }}
21Address = {{ .Address }}
22Port    = {{ .Port }}
23{{ end }}
24{{ with secret "dynamic-app/kv/database" }}
25Database = {{ .Data.data.database }}
26{{ end }}
27{{ with secret "dynamic-app/db/creds/app" }}
28User     = {{ .Data.username }}
29Password = {{ .Data.password }}
30{{ end }}
31
32[VAULT]
33Enabled     = True
34InjectToken = True
35Namespace   =
36Address     = {{ env "VAULT_ADDR" }}
37KeyPath     = dynamic-app/transit
38KeyName     = app
39EOF
40  }
41
42  resources {
43    cpu    = 256
44    memory = 256
45  }
46}

Der vault-Stanza, die Consul-Template-Ausdruecke, der Policy-Name (nomad-dynamic-app) und die Secret-Pfade sind identisch mit dem Python-Job. An der Vault- oder Nomad-Konfiguration aendert sich nichts — nur die Anwendungs-Runtime aendert sich.

Container-Image: CI/CD

Das .NET-Image wird bei jedem Push auf main gebaut und nach Quay veroeffentlicht:

1# .github/workflows/ci.yml (dotnet-Ziel)
2- name: .NET-Image bauen und pushen
3  uses: docker/build-push-action@v5
4  with:
5    context: app/dotnet
6    push: true
7    tags: quay.io/infralovers/nomad-vault-mysql-dotnet:latest

Der Nomad-Job referenziert quay.io/infralovers/nomad-vault-mysql-dotnet.

Lokal ausfuehren

Das docker-compose.dotnet.yml im Repository-Root ermoeglicht das Ausfuehren der .NET-App zusammen mit MySQL lokal ohne Nomad-Cluster:

1docker compose -f docker-compose.dotnet.yml up -d --build

Die Anwendung ist unter http://localhost:8080 verfuegbar. Konfigurieren Sie den Vault-Zugriff durch Setzen von VAULT_ADDR und VAULT_TOKEN in Ihrer Umgebung oder durch Bearbeiten von app/dotnet/config/config.ini.

Python- und .NET-Implementierungen im Vergleich

AspektPython (Flask).NET (ASP.NET Core)
Vault SDKhvac (nur Transit)VaultSharp (Transit + DB-Credentials), Raw-HTTP-Fallback
Konfigurationsformatconfig.iniconfig.ini
Vault Token-QuelleVAULT_TOKEN envVAULT_TOKEN env
INI-ReloadApp-NeustartreloadOnChange: true
Container-Imagenomad-vault-mysql-pythonnomad-vault-mysql-dotnet
Nomad-Treiberdockerdocker
Config Mount-Pfad/usr/src/app/config//app/config/

Die Python-Implementierung verwendet die hvac-Bibliothek fuer Transit-Operationen. Die .NET-Version nutzt VaultSharp fuer Transit und dynamische DB-Credentials und faellt bei Bedarf auf direkte Vault-HTTP-Aufrufe zurueck, um das Verhalten robust und nachvollziehbar zu halten.

Zusammenfassung

Die .NET-Implementierung zeigt, dass das Nomad + Vault Secrets-Modell sprachunabhaengig ist:

  • Die Nomad-Job-Struktur (vault-Stanza, Template Engine, Service-Registrierung) bleibt unveraendert.
  • Die Vault-Konfiguration (Policies, Secret-Pfade, Engines) bleibt unveraendert.
  • Nur das Anwendungs-Image und der Config-Datei-Mount-Pfad unterscheiden sich.

Das ist relevant fuer mehrsprachige Organisationen: Die Einfuehrung von Vault erfordert kein Einsprachigkeits-Mandat. Jede Anwendung, die einen HTTP-Aufruf machen und eine Datei lesen kann, kann am selben Secrets-Lifecycle teilnehmen.

Der vollstaendige Quellcode ist unter github.com/infralovers/nomad-vault-mysql verfuegbar.

Zurück Unsere Trainings entdecken

Wir sind für Sie da

Sie interessieren sich für unsere Trainings oder haben einfach eine Frage, die beantwortet werden muss? Sie können uns jederzeit kontaktieren! Wir werden unser Bestes tun, um alle Ihre Fragen zu beantworten.

Hier kontaktieren