/* * Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Extensions.Loading * File: OnDemandSecret.cs * * OnDemandSecret.cs is part of VNLib.Plugins.Extensions.Loading which is * part of the larger VNLib collection of libraries and utilities. * * VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ using System; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Threading; using System.Diagnostics; using System.Threading.Tasks; using System.Collections.Generic; using VNLib.Utils.Memory; using VNLib.Utils.Logging; using VNLib.Utils.Extensions; using VNLib.Utils.Resources; using static VNLib.Plugins.Extensions.Loading.PluginSecretConstants; namespace VNLib.Plugins.Extensions.Loading { internal sealed class OnDemandSecret(PluginBase plugin, string secretName, Func vaultCb) : IOnDemandSecret { /* * Defer loading vault until needed by a vault secret. This avoids loading the vault client * if no secrets are needed from the vault. */ private readonly LazyInitializer vault = new(vaultCb); public string SecretName { get; } = secretName ?? throw new ArgumentNullException(nameof(secretName)); /// public ISecretResult? FetchSecret() { plugin.ThrowIfUnloaded(); //Get the secret from the config file raw string? rawSecret = TryGetSecretFromConfig(secretName); if (rawSecret == null) { return null; } //Secret is a vault path, or return the raw value if (rawSecret.StartsWith(VAULT_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) { ValueTask res = GetSecretFromVault(rawSecret, false); Debug.Assert(res.IsCompleted); return res.GetAwaiter().GetResult(); } //See if the secret is an environment variable path if (rawSecret.StartsWith(ENV_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) { //try to get the environment variable string envVar = rawSecret[ENV_URL_SCHEME.Length..]; string? envVal = Environment.GetEnvironmentVariable(envVar); return envVal == null ? null : SecretResult.ToSecret(envVal); } //See if the secret is a file path if (rawSecret.StartsWith(FILE_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) { string filePath = rawSecret[FILE_URL_SCHEME.Length..]; byte[] fileData = File.ReadAllBytes(filePath); return GetResultFromFileData(fileData); } //Finally, return the raw value return SecretResult.ToSecret(rawSecret); } /// public Task FetchSecretAsync(CancellationToken cancellation) { plugin.ThrowIfUnloaded(); //Get the secret from the config file raw string? rawSecret = TryGetSecretFromConfig(secretName); if (rawSecret == null) { return Task.FromResult(null); } //Secret is a vault path, or return the raw value if (rawSecret.StartsWith(VAULT_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) { //Exec vault async ValueTask res = GetSecretFromVault(rawSecret, true); return res.AsTask(); } //See if the secret is an environment variable path if (rawSecret.StartsWith(ENV_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) { //try to get the environment variable string envVar = rawSecret[ENV_URL_SCHEME.Length..]; string? envVal = Environment.GetEnvironmentVariable(envVar); return Task.FromResult(envVal == null ? null : SecretResult.ToSecret(envVal)); } //See if the secret is a file path if (rawSecret.StartsWith(FILE_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) { string filePath = rawSecret[FILE_URL_SCHEME.Length..]; return GetResultFromFileAsync(filePath, cancellation); } //Finally, return the raw value return Task.FromResult(SecretResult.ToSecret(rawSecret)); static async Task GetResultFromFileAsync(string filePath, CancellationToken ct) { byte[] fileData = await File.ReadAllBytesAsync(filePath, ct); return GetResultFromFileData(fileData); } } /// /// Gets a secret at the given vault url (in the form of "vault://[mount-name]/[secret-path]?secret=[secret_name]") /// /// The raw vault url to lookup /// /// The string of the object at the specified vault path /// /// /// private ValueTask GetSecretFromVault(ReadOnlySpan vaultPath, bool async) { ArgumentNullException.ThrowIfNull(plugin); //print the path for debug if (plugin.IsDebug()) { plugin.Log.Debug("Retrieving secret {s} from vault", vaultPath.ToString()); } //Slice off path ReadOnlySpan paq = vaultPath.SliceAfterParam(VAULT_URL_SCHEME); ReadOnlySpan path = paq.SliceBeforeParam('?'); ReadOnlySpan query = paq.SliceAfterParam('?'); if (paq.IsEmpty) { throw new UriFormatException("Vault secret location not valid/empty "); } //Get the secret string secretTableKey = query.SliceAfterParam("secret=").SliceBeforeParam('&').ToString(); string vaultType = query.SliceBeforeParam("vault_type=").SliceBeforeParam('&').ToString(); //get mount and path int lastSep = path.IndexOf('/'); string mount = path[..lastSep].ToString(); string secret = path[(lastSep + 1)..].ToString(); //Try load client _ = vault.Instance ?? throw new KeyNotFoundException("Vault client not found"); if (async) { Task asTask = Task.Run(() => vault.Instance.ReadSecretAsync(secret, mount, secretTableKey)); return new ValueTask(asTask); } else { ISecretResult? result = vault.Instance.ReadSecret(secret, mount, secretTableKey); return new ValueTask(result); } } private string? TryGetSecretFromConfig(string secretName) { bool local = plugin.PluginConfig.TryGetProperty(SECRETS_CONFIG_KEY, out JsonElement localEl); bool host = plugin.HostConfig.TryGetProperty(SECRETS_CONFIG_KEY, out JsonElement hostEl); //total config Dictionary conf = new(StringComparer.OrdinalIgnoreCase); if (local && host) { //Load both config objects to dict Dictionary localConf = localEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); Dictionary hostConf = hostEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); //Enter all host secret objects, then follow up with plugin secert elements hostConf.ForEach(kv => conf[kv.Key] = kv.Value); localConf.ForEach(kv => conf[kv.Key] = kv.Value); } else if (local) { //Store only local config conf = localEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value, StringComparer.OrdinalIgnoreCase); } else if (host) { //store only host config conf = hostEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value, StringComparer.OrdinalIgnoreCase); } //Get the value or default json element return conf.TryGetValue(secretName, out JsonElement el) ? el.GetString() : null; } private static SecretResult GetResultFromFileData(byte[] secretFileData) { //recover the character data from the file data int chars = Encoding.UTF8.GetCharCount(secretFileData); char[] secretFileChars = new char[chars]; Encoding.UTF8.GetChars(secretFileData, secretFileChars); //Clear file data buffer MemoryUtil.InitializeBlock(secretFileData); //Keep the char array as a secret return SecretResult.ToSecret(secretFileChars); } } }