HashiCorp Nomad und Vault mit .NET: ASP.NET Core in einem sicheren Workload
Als wir unseren Beitrag HashiCorp Nomad and Vault: Dynamic Secrets veroeffentlichten, lief die Demo ausschliesslich als Python Flask-Anwendung. Seitdem ist das

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.
Die .NET-App ist mit ASP.NET Core MVC fuer .NET 8 gebaut. Sie ist funktional identisch mit der Python-Version:
customers-Tabelle in MySQLbirth_date, address und salary/health-Endpunkt fuer Nomad Service-Checks und Consul-Integrationapp/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
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.
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.
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.
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}
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.
Das Verzeichnis nomad/dotnet/ spiegelt die Python-Progression genau wider:
| Datei | Treiber | Vault | Beschreibung |
|---|---|---|---|
app_hardcoded.hcl | docker | ✗ | Zugangsdaten im Template |
app_static.hcl | docker | KV | Zugangsdaten aus KV Store |
app_dynamic.hcl | docker | Dynamic DB | Kurzlebige MySQL-Benutzer |
app_transit.hcl | docker | Dynamic + Transit | Feldbasierte Verschluesselung |
app_transit_connect.hcl | docker | Dynamic + Transit | + Consul Connect mTLS |
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.
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.
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.
| Aspekt | Python (Flask) | .NET (ASP.NET Core) |
|---|---|---|
| Vault SDK | hvac (nur Transit) | VaultSharp (Transit + DB-Credentials), Raw-HTTP-Fallback |
| Konfigurationsformat | config.ini | config.ini |
| Vault Token-Quelle | VAULT_TOKEN env | VAULT_TOKEN env |
| INI-Reload | App-Neustart | reloadOnChange: true |
| Container-Image | nomad-vault-mysql-python | nomad-vault-mysql-dotnet |
| Nomad-Treiber | docker | docker |
| 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.
Die .NET-Implementierung zeigt, dass das Nomad + Vault Secrets-Modell sprachunabhaengig ist:
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.
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