263 lines
11 KiB
C#
263 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),
|
|
_ => 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;
|
|
case "probe" when target == CredentialCatalog.ApiAmap:
|
|
return AmapProbe.Run(store.Read(target));
|
|
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|probe; admin-allowlist add|remove|status; doctor", 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);
|
|
}
|
|
}
|