Files
tyx_AI_xhs/supervisor/Dada.Supervisor/OfflineCommandRouter.cs
T
suyx c2f89453a2
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 48s
feat: add secure WP7-02 external executor
2026-08-04 15:46:38 +08:00

262 lines
11 KiB
C#

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<int> 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" => RunSecrets(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 int RunSecrets(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;
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<string>();
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; 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<string> AdminAllowlistHashes,
IReadOnlyList<string> 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<InstanceConfiguration>(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);
}
}