using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace Dada.Supervisor; internal static class OfflineCommandRouter { private const string MutexName = "Dada.P0A.Instance"; private const string OpenPipeName = "Dada.P0A.Open"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = true }; internal static async Task RunAsync(string[] args, ICredentialStore credentials) { var modifying = IsModifying(args); using var instance = modifying ? await SingleInstanceCoordinator.TryAcquireAsync(MutexName, OpenPipeName) : null; if (instance is { IsPrimary: false }) { WriteResult("instance_running", false); return 3; } try { return args[0] switch { "configure" => RunConfigure(args.Skip(1).ToArray()), "secrets" => await RunSecretsAsync(args.Skip(1).ToArray(), credentials), "admin-allowlist" => RunAdminAllowlist(args.Skip(1).ToArray(), credentials), "doctor" when args.Length == 1 => RunDoctor(credentials), "validate-external" => await ControlledExternalValidationLauncher.RunAsync(args.Skip(1).ToArray(), credentials), _ => Usage(), }; } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or IOException or UnauthorizedAccessException) { WriteResult(exception.Message, false); return 2; } } private static int RunConfigure(string[] args) { var store = new InstanceConfigurationStore(); var current = store.Load(); InstanceConfiguration next; if (args is ["init", var initialDataRoot, var initialAssetRoot]) { next = new InstanceConfiguration(1, current.Revision + 1, ValidateDataRoot(initialDataRoot), ValidateAssetRoot(initialAssetRoot), current.AdminAllowlistHashes, []); } else if (args is ["data-root", var updatedDataRoot]) { next = current with { Revision = current.Revision + 1, LocalDataRoot = ValidateDataRoot(updatedDataRoot), AdminRecoveryHashes = [] }; } else if (args is ["asset-root", var updatedAssetRoot]) { next = current with { Revision = current.Revision + 1, AssetRoot = ValidateAssetRoot(updatedAssetRoot), AdminRecoveryHashes = [] }; } else { return Usage(); } store.Save(next); WriteResult("configuration_updated", true, next.Revision); return 0; } private static async Task RunSecretsAsync(string[] args, ICredentialStore store) { if (args.Length != 2 || !TryResolveCredential(args[1], out var target)) return Usage(); switch (args[0]) { case "status": WriteResult(store.IsConfigured(target) ? "configured" : "not_configured", true); return 0; case "clear": store.Delete(target); WriteResult("credential_cleared", true); return 0; case "set": var value = ReadHiddenValue(); if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("credential_value_required"); store.Write(target, value); WriteResult("credential_saved", true); return 0; case "probe" when target == CredentialCatalog.ApiAmap: return AmapProbe.Run(store.Read(target)); case "probe" when target == CredentialCatalog.WorkerAiGateway: return await AiGatewayProbe.RunAsync(store); default: return Usage(); } } private static int RunAdminAllowlist(string[] args, ICredentialStore credentials) { var configStore = new InstanceConfigurationStore(); var current = configStore.Load(); if (args is ["status"]) { WriteAllowlistResult("allowlist_status", current.AdminAllowlistHashes.Count, current.Revision); return 0; } if (args is not [var action, var email] || action is not ("add" or "remove")) return Usage(); var pepper = credentials.Read(CredentialCatalog.AdminPepper) ?? throw new InvalidOperationException("admin_pepper_not_configured"); var normalized = email.Trim().ToLowerInvariant(); if (!normalized.Contains('@') || normalized.Length > 254) throw new ArgumentException("invalid_email"); var digest = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(pepper), Encoding.UTF8.GetBytes(normalized))); var hashes = current.AdminAllowlistHashes.ToHashSet(StringComparer.Ordinal); if (action == "add") hashes.Add(digest); else hashes.Remove(digest); var recoveries = action == "add" ? new[] { digest } : Array.Empty(); var orderedHashes = hashes.Order().ToArray(); if (action == "remove" && orderedHashes.SequenceEqual(current.AdminAllowlistHashes)) { WriteAllowlistResult("allowlist_unchanged", orderedHashes.Length, current.Revision); return 0; } configStore.Save(current with { Revision = current.Revision + 1, AdminAllowlistHashes = orderedHashes, AdminRecoveryHashes = recoveries, }); WriteAllowlistResult(action == "add" ? "allowlist_entry_added" : "allowlist_entry_removed", hashes.Count, current.Revision + 1); return 0; } private static int RunDoctor(ICredentialStore credentials) { var report = DiagnosticReport.Create( LoopbackPortGuard.Check(), [DiagnosticCheck.Pass("bind_host", "loopback_only"), DiagnosticCheck.Pass("fixed_port", "port_checked")], new DiagnosticStorageSummary(0, 0, 0), [ new CredentialStatus("api_resend", credentials.IsConfigured(CredentialCatalog.ApiResend)), new CredentialStatus("api_amap", credentials.IsConfigured(CredentialCatalog.ApiAmap)), new CredentialStatus("worker_ai_gateway", credentials.IsConfigured(CredentialCatalog.WorkerAiGateway)), ]); Console.WriteLine(JsonSerializer.Serialize(report, JsonOptions)); return 0; } private static string ValidateDataRoot(string path) { var fullPath = ValidateDirectory(path); var probe = Path.Combine(fullPath, $".dada-write-{Guid.NewGuid():N}.tmp"); using (File.Create(probe)) { } File.Delete(probe); return fullPath; } private static string ValidateAssetRoot(string path) => ValidateDirectory(path); private static string ValidateDirectory(string path) { var fullPath = Path.GetFullPath(path); if (!Directory.Exists(fullPath)) throw new ArgumentException("directory_not_found"); return fullPath; } private static bool TryResolveCredential(string alias, out string target) { target = alias switch { "api-resend" => CredentialCatalog.ApiResend, "api-amap" => CredentialCatalog.ApiAmap, "worker-ai-gateway" => CredentialCatalog.WorkerAiGateway, "admin-pepper" => CredentialCatalog.AdminPepper, _ => string.Empty, }; return target.Length > 0; } private static string ReadHiddenValue() { if (Console.IsInputRedirected) return Console.ReadLine() ?? string.Empty; var value = new StringBuilder(); while (true) { var key = Console.ReadKey(intercept: true); if (key.Key == ConsoleKey.Enter) break; if (key.Key == ConsoleKey.Backspace && value.Length > 0) value.Length--; else if (!char.IsControl(key.KeyChar)) value.Append(key.KeyChar); } Console.WriteLine(); return value.ToString(); } private static bool IsModifying(string[] args) => args.FirstOrDefault() switch { "configure" => true, "secrets" => args.ElementAtOrDefault(1) is "set" or "clear", "admin-allowlist" => args.ElementAtOrDefault(1) is "add" or "remove", _ => false, }; private static void WriteResult(string code, bool success, int? revisionOrCount = null) => Console.WriteLine(JsonSerializer.Serialize(new { code, revision_or_count = revisionOrCount, success }, JsonOptions)); private static void WriteAllowlistResult(string code, int count, int revision) => Console.WriteLine(JsonSerializer.Serialize(new { code, count, secure_config_revision = revision, success = true }, JsonOptions)); private static int Usage() { WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear|probe; admin-allowlist add|remove|status; doctor; validate-external", false); return 2; } } internal sealed record InstanceConfiguration( [property: JsonPropertyName("schema_version")] int SchemaVersion, [property: JsonPropertyName("secure_config_revision")] int Revision, string? LocalDataRoot, string? AssetRoot, IReadOnlyList AdminAllowlistHashes, IReadOnlyList AdminRecoveryHashes) { internal static InstanceConfiguration Empty { get; } = new(1, 0, null, null, [], []); } internal sealed class InstanceConfigurationStore { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = true }; private readonly string path; internal InstanceConfigurationStore(string? path = null) { this.path = path ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json"); } internal InstanceConfiguration Load() { if (!File.Exists(path)) return InstanceConfiguration.Empty; var json = File.ReadAllText(path); var configuration = JsonSerializer.Deserialize(json, JsonOptions) ?? InstanceConfiguration.Empty; using var document = JsonDocument.Parse(json); if (configuration.Revision == 0 && document.RootElement.TryGetProperty("revision", out var legacyRevision)) { configuration = configuration with { Revision = legacyRevision.GetInt32() }; } return configuration with { SchemaVersion = configuration.SchemaVersion == 0 ? 1 : configuration.SchemaVersion, AdminAllowlistHashes = configuration.AdminAllowlistHashes ?? [], AdminRecoveryHashes = configuration.AdminRecoveryHashes ?? [], }; } internal void Save(InstanceConfiguration configuration) { if (configuration.SchemaVersion != 1 || configuration.Revision < 0) throw new InvalidOperationException("secure_config_invalid"); var validHash = new System.Text.RegularExpressions.Regex("^[A-F0-9]{64}$", System.Text.RegularExpressions.RegexOptions.CultureInvariant); if (configuration.AdminAllowlistHashes.Any(hash => !validHash.IsMatch(hash)) || configuration.AdminRecoveryHashes.Any(hash => !validHash.IsMatch(hash)) || configuration.AdminRecoveryHashes.Any(hash => !configuration.AdminAllowlistHashes.Contains(hash, StringComparer.Ordinal))) { throw new InvalidOperationException("secure_config_invalid"); } Directory.CreateDirectory(Path.GetDirectoryName(path)!); var temporary = path + ".tmp"; File.WriteAllText(temporary, JsonSerializer.Serialize(configuration, JsonOptions) + Environment.NewLine); File.Move(temporary, path, overwrite: true); } }