Sync files delete
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Flo.Installer/Flo.Installer.csproj" />
|
||||
<Project Path="src/Flo.Manifest.Tool/Flo.Manifest.Tool.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"product": "Flo",
|
||||
"version": "1.0.0",
|
||||
"channel": "stable",
|
||||
"releasedAt": "2026-04-20T00:00:00Z",
|
||||
"minBootstrapperVersion": "1.0.0",
|
||||
"artifacts": [
|
||||
{
|
||||
"id": "postgres",
|
||||
"version": "16.4",
|
||||
"platforms": {
|
||||
"win-x64": {
|
||||
"url": "https://updates.flo.example/stable/artifacts/postgres-16.4-win-x64.zip",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"sizeBytes": 157286400,
|
||||
"archive": "zip",
|
||||
"installPath": "postgres"
|
||||
},
|
||||
"linux-x64": {
|
||||
"url": "https://updates.flo.example/stable/artifacts/postgres-16.4-linux-x64.tar.gz",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"sizeBytes": 142606336,
|
||||
"archive": "tar.gz",
|
||||
"installPath": "postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ferretdb",
|
||||
"version": "1.24.0",
|
||||
"platforms": {
|
||||
"win-x64": {
|
||||
"url": "https://updates.flo.example/stable/artifacts/ferretdb-1.24.0-win-x64.zip",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"sizeBytes": 31457280,
|
||||
"archive": "zip",
|
||||
"installPath": "ferretdb"
|
||||
},
|
||||
"linux-x64": {
|
||||
"url": "https://updates.flo.example/stable/artifacts/ferretdb-1.24.0-linux-x64.tar.gz",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"sizeBytes": 28311552,
|
||||
"archive": "tar.gz",
|
||||
"installPath": "ferretdb"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flo-service",
|
||||
"version": "1.0.0",
|
||||
"platforms": {
|
||||
"win-x64": {
|
||||
"url": "https://updates.flo.example/stable/artifacts/flo-service-1.0.0-win-x64.zip",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"sizeBytes": 52428800,
|
||||
"archive": "zip",
|
||||
"installPath": "service"
|
||||
},
|
||||
"linux-x64": {
|
||||
"url": "https://updates.flo.example/stable/artifacts/flo-service-1.0.0-linux-x64.tar.gz",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"sizeBytes": 50331648,
|
||||
"archive": "tar.gz",
|
||||
"installPath": "service"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Flo.Installer;
|
||||
|
||||
internal sealed class ArtifactDownloader(HttpClient http)
|
||||
{
|
||||
public async Task DownloadAsync(
|
||||
Uri url,
|
||||
string destinationPath,
|
||||
string expectedSha256,
|
||||
long expectedSize,
|
||||
IProgress<DownloadProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
|
||||
using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var source = await response.Content.ReadAsStreamAsync(ct);
|
||||
await using var destination = File.Create(destinationPath);
|
||||
using var sha = SHA256.Create();
|
||||
|
||||
var buffer = new byte[81920];
|
||||
long totalRead = 0;
|
||||
int read;
|
||||
while ((read = await source.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
await destination.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
sha.TransformBlock(buffer, 0, read, null, 0);
|
||||
totalRead += read;
|
||||
progress?.Report(new DownloadProgress(totalRead, expectedSize));
|
||||
}
|
||||
sha.TransformFinalBlock([], 0, 0);
|
||||
|
||||
string actualHex = Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
||||
if (!string.Equals(actualHex, expectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(destinationPath);
|
||||
throw new InvalidOperationException(
|
||||
$"SHA-256 mismatch for {url}. expected={expectedSha256} actual={actualHex}");
|
||||
}
|
||||
|
||||
if (totalRead != expectedSize)
|
||||
{
|
||||
File.Delete(destinationPath);
|
||||
throw new InvalidOperationException(
|
||||
$"Size mismatch for {url}. expected={expectedSize} actual={totalRead}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct DownloadProgress(long BytesRead, long TotalBytes)
|
||||
{
|
||||
public double PercentComplete => TotalBytes > 0 ? (double)BytesRead / TotalBytes * 100.0 : 0.0;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Flo.Installer.Commands;
|
||||
|
||||
internal static class InstallCommand
|
||||
{
|
||||
public static async Task<int> ExecuteAsync(string manifestUrl, string installPath, CancellationToken ct = default)
|
||||
{
|
||||
if (TrustedPublicKey.IsPlaceholder)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Trusted public key is unset. Bake the release public key into TrustedPublicKey.Bytes before running against a real manifest.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
string rid = DetectRid();
|
||||
Console.WriteLine($"Platform: {rid}");
|
||||
Console.WriteLine($"Manifest: {manifestUrl}");
|
||||
Console.WriteLine($"Install path: {installPath}");
|
||||
Console.WriteLine();
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||
var manifestClient = new ManifestClient(http);
|
||||
var downloader = new ArtifactDownloader(http);
|
||||
|
||||
Manifest manifest;
|
||||
try
|
||||
{
|
||||
manifest = await manifestClient.DownloadAndVerifyAsync(new Uri(manifestUrl), TrustedPublicKey.Bytes, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Manifest load failed: {ex.Message}");
|
||||
return 3;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Verified manifest: {manifest.Product} {manifest.Version} ({manifest.Channel})");
|
||||
Console.WriteLine();
|
||||
|
||||
Directory.CreateDirectory(installPath);
|
||||
string cacheDir = Path.Combine(installPath, ".cache");
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
foreach (var artifact in manifest.Artifacts)
|
||||
{
|
||||
if (!artifact.Platforms.TryGetValue(rid, out var platform))
|
||||
{
|
||||
Console.WriteLine($"[skip] {artifact.Id}: no entry for {rid}.");
|
||||
continue;
|
||||
}
|
||||
|
||||
string cacheFile = Path.Combine(cacheDir, $"{artifact.Id}-{artifact.Version}-{rid}.bin");
|
||||
var progress = new Progress<DownloadProgress>(p =>
|
||||
Console.Write($"\r[get ] {artifact.Id} {artifact.Version} {p.PercentComplete,6:0.0}% "));
|
||||
|
||||
await downloader.DownloadAsync(
|
||||
new Uri(platform.Url), cacheFile, platform.Sha256, platform.SizeBytes, progress, ct);
|
||||
Console.WriteLine();
|
||||
|
||||
string target = Path.Combine(installPath, platform.InstallPath);
|
||||
Console.WriteLine($"[inst] {artifact.Id} -> {target}");
|
||||
Install(cacheFile, target, platform.Archive);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Install complete.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void Install(string source, string target, string archive)
|
||||
{
|
||||
switch (archive)
|
||||
{
|
||||
case "zip":
|
||||
Directory.CreateDirectory(target);
|
||||
ZipFile.ExtractToDirectory(source, target, overwriteFiles: true);
|
||||
break;
|
||||
case "tar.gz":
|
||||
Directory.CreateDirectory(target);
|
||||
using (var fs = File.OpenRead(source))
|
||||
using (var gz = new GZipStream(fs, CompressionMode.Decompress))
|
||||
System.Formats.Tar.TarFile.ExtractToDirectory(gz, target, overwriteFiles: true);
|
||||
break;
|
||||
case "none":
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(source, target, overwrite: true);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown archive type: {archive}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetectRid()
|
||||
{
|
||||
string os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win"
|
||||
: RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux"
|
||||
: throw new PlatformNotSupportedException("Only Windows and Linux are supported.");
|
||||
string arch = RuntimeInformation.ProcessArchitecture switch
|
||||
{
|
||||
Architecture.X64 => "x64",
|
||||
Architecture.Arm64 => "arm64",
|
||||
var a => throw new PlatformNotSupportedException($"Unsupported architecture: {a}.")
|
||||
};
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Flo.Installer.Provisioning;
|
||||
|
||||
namespace Flo.Installer.Commands;
|
||||
|
||||
internal static class ProvisionCommand
|
||||
{
|
||||
private const string DefaultSuperuser = "postgres";
|
||||
private const string DefaultRole = "flo_app";
|
||||
private const string DefaultDatabase = "flo";
|
||||
|
||||
public static async Task<int> ExecuteAsync(string installPath, CancellationToken ct = default)
|
||||
{
|
||||
var paths = new Paths(installPath);
|
||||
Directory.CreateDirectory(paths.Config);
|
||||
Directory.CreateDirectory(paths.Logs);
|
||||
|
||||
if (!File.Exists(paths.PostgresBin("initdb")))
|
||||
throw new InvalidOperationException($"Postgres binaries not found at {paths.Postgres}. Run `install` first.");
|
||||
if (!File.Exists(paths.FerretDbExe()))
|
||||
throw new InvalidOperationException($"FerretDB binary not found at {paths.FerretDbExe()}. Run `install` first.");
|
||||
|
||||
var secretsStore = new SecretsStore(paths.SecretsFile);
|
||||
|
||||
FloConfig config;
|
||||
Dictionary<string, string> secrets;
|
||||
|
||||
if (File.Exists(paths.ConfigFile) && secretsStore.Exists())
|
||||
{
|
||||
Console.WriteLine("Existing flo.json + secrets.json found — reusing ports and passwords.");
|
||||
config = FloConfig.Load(paths.ConfigFile);
|
||||
secrets = new Dictionary<string, string>(secretsStore.Load());
|
||||
}
|
||||
else
|
||||
{
|
||||
int pgPort = PortScanner.FindFreeLoopbackPort();
|
||||
int fdPort = PortScanner.FindFreeLoopbackPort();
|
||||
while (fdPort == pgPort) fdPort = PortScanner.FindFreeLoopbackPort();
|
||||
|
||||
config = new FloConfig(
|
||||
PostgresPort: pgPort,
|
||||
FerretDbPort: fdPort,
|
||||
PostgresSuperuser: DefaultSuperuser,
|
||||
ApplicationDbRole: DefaultRole,
|
||||
ApplicationDbName: DefaultDatabase);
|
||||
|
||||
secrets = new Dictionary<string, string>
|
||||
{
|
||||
[$"pg.{config.PostgresSuperuser}"] = PasswordGenerator.Generate(),
|
||||
[$"pg.{config.ApplicationDbRole}"] = PasswordGenerator.Generate(),
|
||||
};
|
||||
|
||||
config.Save(paths.ConfigFile);
|
||||
secretsStore.Save(secrets);
|
||||
Console.WriteLine($"Allocated Postgres port {pgPort}, FerretDB port {fdPort}.");
|
||||
}
|
||||
|
||||
var postgres = new PostgresProvisioner(paths);
|
||||
|
||||
if (!postgres.IsInitialized())
|
||||
{
|
||||
Console.WriteLine("Running initdb...");
|
||||
postgres.Initialize(config.PostgresSuperuser, secrets[$"pg.{config.PostgresSuperuser}"]);
|
||||
Console.WriteLine("initdb complete.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Postgres data dir already initialized; skipping initdb.");
|
||||
}
|
||||
|
||||
postgres.ConfigurePort(config.PostgresPort);
|
||||
|
||||
Console.WriteLine("Starting Postgres temporarily to create role + database...");
|
||||
await using (var pg = postgres.Start(config.PostgresPort))
|
||||
{
|
||||
await HealthCheck.WaitForPortAsync(
|
||||
"127.0.0.1", config.PostgresPort, TimeSpan.FromSeconds(30), "Postgres", ct);
|
||||
|
||||
postgres.EnsureRoleAndDatabase(
|
||||
config.PostgresPort,
|
||||
config.PostgresSuperuser, secrets[$"pg.{config.PostgresSuperuser}"],
|
||||
config.ApplicationDbRole, secrets[$"pg.{config.ApplicationDbRole}"],
|
||||
config.ApplicationDbName);
|
||||
Console.WriteLine($"Ensured role '{config.ApplicationDbRole}' and database '{config.ApplicationDbName}'.");
|
||||
|
||||
postgres.StopGracefully();
|
||||
}
|
||||
|
||||
Console.WriteLine("Provisioning complete.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Flo.Installer.Provisioning;
|
||||
|
||||
namespace Flo.Installer.Commands;
|
||||
|
||||
internal static class RunCommand
|
||||
{
|
||||
public static async Task<int> ExecuteAsync(string installPath)
|
||||
{
|
||||
var paths = new Paths(installPath);
|
||||
if (!File.Exists(paths.ConfigFile) || !File.Exists(paths.SecretsFile))
|
||||
{
|
||||
Console.Error.WriteLine("flo.json or secrets.json missing. Run `provision` first.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var config = FloConfig.Load(paths.ConfigFile);
|
||||
var secrets = new SecretsStore(paths.SecretsFile).Load();
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
||||
using var _sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>
|
||||
{
|
||||
ctx.Cancel = true;
|
||||
cts.Cancel();
|
||||
});
|
||||
|
||||
var postgres = new PostgresProvisioner(paths);
|
||||
var ferret = new FerretDbProvisioner(paths);
|
||||
|
||||
Console.WriteLine($"Starting Postgres on 127.0.0.1:{config.PostgresPort}...");
|
||||
await using var pg = postgres.Start(config.PostgresPort);
|
||||
await HealthCheck.WaitForPortAsync(
|
||||
"127.0.0.1", config.PostgresPort, TimeSpan.FromSeconds(30), "Postgres", cts.Token);
|
||||
Console.WriteLine("Postgres healthy.");
|
||||
|
||||
Console.WriteLine($"Starting FerretDB on 127.0.0.1:{config.FerretDbPort}...");
|
||||
await using var fd = ferret.Start(
|
||||
listenPort: config.FerretDbPort,
|
||||
postgresPort: config.PostgresPort,
|
||||
pgUser: config.ApplicationDbRole,
|
||||
pgPassword: secrets[$"pg.{config.ApplicationDbRole}"],
|
||||
pgDatabase: config.ApplicationDbName);
|
||||
await HealthCheck.WaitForPortAsync(
|
||||
"127.0.0.1", config.FerretDbPort, TimeSpan.FromSeconds(30), "FerretDB", cts.Token);
|
||||
Console.WriteLine("FerretDB healthy.");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Postgres: host=127.0.0.1 port={config.PostgresPort} user={config.ApplicationDbRole} db={config.ApplicationDbName}");
|
||||
Console.WriteLine($"Mongo URL: mongodb://{config.ApplicationDbRole}:<password>@127.0.0.1:{config.FerretDbPort}/{config.ApplicationDbName}");
|
||||
Console.WriteLine("(Password is in secrets.json.)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Press Ctrl-C to stop.");
|
||||
|
||||
try { await Task.Delay(Timeout.Infinite, cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Shutdown requested. Stopping FerretDB then Postgres...");
|
||||
|
||||
try { await fd.StopAsync(TimeSpan.FromSeconds(10)); }
|
||||
catch (Exception ex) { Console.Error.WriteLine($"FerretDB stop error: {ex.Message}"); }
|
||||
|
||||
try { postgres.StopGracefully(); }
|
||||
catch (Exception ex) { Console.Error.WriteLine($"Postgres graceful stop error: {ex.Message}"); }
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace Flo.Installer;
|
||||
|
||||
internal static class Ed25519Verifier
|
||||
{
|
||||
public static bool Verify(ReadOnlySpan<byte> publicKey, ReadOnlySpan<byte> data, ReadOnlySpan<byte> signature)
|
||||
{
|
||||
var algorithm = SignatureAlgorithm.Ed25519;
|
||||
var key = NSec.Cryptography.PublicKey.Import(algorithm, publicKey, KeyBlobFormat.RawPublicKey);
|
||||
return algorithm.Verify(key, data, signature);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Flo.Installer;
|
||||
|
||||
public sealed record Manifest(
|
||||
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
|
||||
[property: JsonPropertyName("product")] string Product,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("channel")] string Channel,
|
||||
[property: JsonPropertyName("releasedAt")] DateTimeOffset ReleasedAt,
|
||||
[property: JsonPropertyName("minBootstrapperVersion")] string MinBootstrapperVersion,
|
||||
[property: JsonPropertyName("artifacts")] IReadOnlyList<Artifact> Artifacts);
|
||||
|
||||
public sealed record Artifact(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("platforms")] IReadOnlyDictionary<string, PlatformArtifact> Platforms);
|
||||
|
||||
public sealed record PlatformArtifact(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("sha256")] string Sha256,
|
||||
[property: JsonPropertyName("sizeBytes")] long SizeBytes,
|
||||
[property: JsonPropertyName("archive")] string Archive,
|
||||
[property: JsonPropertyName("installPath")] string InstallPath);
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Flo.Installer;
|
||||
|
||||
internal sealed class ManifestClient(HttpClient http)
|
||||
{
|
||||
public async Task<Manifest> DownloadAndVerifyAsync(
|
||||
Uri manifestUrl,
|
||||
ReadOnlyMemory<byte> trustedPublicKey,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sigUrl = new Uri(manifestUrl + ".sig");
|
||||
|
||||
byte[] manifestBytes = await http.GetByteArrayAsync(manifestUrl, ct);
|
||||
byte[] sigText = await http.GetByteArrayAsync(sigUrl, ct);
|
||||
byte[] signature = Convert.FromBase64String(Encoding.UTF8.GetString(sigText).Trim());
|
||||
|
||||
if (!Ed25519Verifier.Verify(trustedPublicKey.Span, manifestBytes, signature))
|
||||
throw new InvalidOperationException("Manifest signature verification failed.");
|
||||
|
||||
return JsonSerializer.Deserialize<Manifest>(manifestBytes)
|
||||
?? throw new InvalidOperationException("Manifest deserialized to null.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Flo.Installer.Commands;
|
||||
|
||||
if (args.Length == 0 || args[0] is "--help" or "-h")
|
||||
{
|
||||
PrintUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return args[0] switch
|
||||
{
|
||||
"install" => await InstallCommand.ExecuteAsync(Required(args, "--manifest"), Required(args, "--install")),
|
||||
"provision" => await ProvisionCommand.ExecuteAsync(Required(args, "--install")),
|
||||
"setup" => await Setup(args),
|
||||
"run" => await RunCommand.ExecuteAsync(Required(args, "--install")),
|
||||
var cmd => Unknown(cmd)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Fatal: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
static async Task<int> Setup(string[] args)
|
||||
{
|
||||
string manifest = Required(args, "--manifest");
|
||||
string install = Required(args, "--install");
|
||||
int rc = await InstallCommand.ExecuteAsync(manifest, install);
|
||||
if (rc != 0) return rc;
|
||||
return await ProvisionCommand.ExecuteAsync(install);
|
||||
}
|
||||
|
||||
static int Unknown(string cmd)
|
||||
{
|
||||
Console.Error.WriteLine($"Unknown command: {cmd}");
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
static string Required(string[] args, string name)
|
||||
{
|
||||
int i = Array.IndexOf(args, name);
|
||||
if (i < 0 || i + 1 >= args.Length)
|
||||
throw new ArgumentException($"{name} <value> is required.");
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("Flo Installer");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Commands:");
|
||||
Console.WriteLine(" setup --manifest <url> --install <path> download + extract + provision DBs");
|
||||
Console.WriteLine(" install --manifest <url> --install <path> download + extract only");
|
||||
Console.WriteLine(" provision --install <path> provision Postgres + FerretDB (idempotent)");
|
||||
Console.WriteLine(" run --install <path> start Postgres + FerretDB, block on Ctrl-C");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Flo.Installer.Provisioning;
|
||||
|
||||
internal sealed class FerretDbProvisioner(Paths paths)
|
||||
{
|
||||
public ProcessRunner Start(int listenPort, int postgresPort, string pgUser, string pgPassword, string pgDatabase)
|
||||
{
|
||||
Directory.CreateDirectory(paths.FerretDbData);
|
||||
|
||||
string pgUrl = $"postgres://{Uri.EscapeDataString(pgUser)}:{Uri.EscapeDataString(pgPassword)}@127.0.0.1:{postgresPort}/{pgDatabase}";
|
||||
|
||||
var env = new Dictionary<string, string>
|
||||
{
|
||||
["FERRETDB_POSTGRESQL_URL"] = pgUrl,
|
||||
["FERRETDB_LISTEN_ADDR"] = $"127.0.0.1:{listenPort}",
|
||||
["FERRETDB_STATE_DIR"] = paths.FerretDbData,
|
||||
["FERRETDB_TELEMETRY"] = "disable",
|
||||
["FERRETDB_LOG_LEVEL"] = "info",
|
||||
};
|
||||
|
||||
return ProcessRunner.Start(paths.FerretDbExe(), [], env, name: "ferretdb");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Flo.Installer.Provisioning;
|
||||
|
||||
internal sealed record FloConfig(
|
||||
[property: JsonPropertyName("postgresPort")] int PostgresPort,
|
||||
[property: JsonPropertyName("ferretDbPort")] int FerretDbPort,
|
||||
[property: JsonPropertyName("postgresSuperuser")] string PostgresSuperuser,
|
||||
[property: JsonPropertyName("applicationDbRole")] string ApplicationDbRole,
|
||||
[property: JsonPropertyName("applicationDbName")] string ApplicationDbName)
|
||||
{
|
||||
public static FloConfig Load(string path)
|
||||
=> JsonSerializer.Deserialize<FloConfig>(File.ReadAllBytes(path))
|
||||
?? throw new InvalidOperationException($"Could not parse {path}");
|
||||
|
||||
public void Save(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Flo.Installer.Provisioning;
|
||||
|
||||
internal static class HealthCheck
|
||||
{
|
||||
public static async Task WaitForPortAsync(
|
||||
string host,
|
||||
int port,
|
||||
TimeSpan timeout,
|
||||
string description,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
Exception? last = null;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, ct);
|
||||
return;
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
last = ex;
|
||||
await Task.Delay(500, ct);
|
||||
}
|
||||
}
|
||||
throw new TimeoutException(
|
||||
$"{description} at {host}:{port} did not become healthy within {timeout.TotalSeconds:0}s. Last error: {last?.Message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Flo.Installer.Provisioning;
|
||||
|
||||
internal static class PasswordGenerator
|
||||
{
|
||||
private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
// Largest multiple of 62 that is <= 256; rejection sampling avoids modulo bias.
|
||||
private const byte RejectAbove = 247;
|
||||
|
||||
public static string Generate(int length = 32)
|
||||
{
|
||||
var chars = new char[length];
|
||||
Span<byte> b = stackalloc byte[1];
|
||||
for (int i = 0; i < length;)
|
||||
{
|
||||
RandomNumberGenerator.Fill(b);
|
||||
if (b[0] <= RejectAbove)
|
||||
chars[i++] = Alphabet[b[0] % Alphabet.Length];
|
||||
}
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Flo.Installer.Provisioning;
|
||||
|
||||
internal sealed record Paths(string Install)
|
||||
{
|
||||
public string Postgres => Path.Combine(Install, "postgres");
|
||||
public string FerretDb => Path.Combine(Install, "ferretdb");
|
||||
public string Service => Path.Combine(Install, "service");
|
||||
public string DataRoot => Path.Combine(Install, "data");
|
||||
public string PostgresData => Path.Combine(DataRoot, "postgres");
|
||||
public string FerretDbData => Path.Combine(DataRoot, "ferretdb");
|
||||
public string Logs => Path.Combine(Install, "logs");
|
||||
public string Config => Path.Combine(Install, "config");
|
||||
public string ConfigFile => Path.Combine(Config, "flo.json");
|
||||
public string SecretsFile => Path.Combine(Install, "secrets", "secrets.json");
|
||||
|
||||
public string PostgresBin(string tool)
|
||||
{
|
||||
string name = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? $"{tool}.exe" : tool;
|
||||
return Path.Combine(Postgres, "bin", name);
|
||||
}
|
||||
|
||||
public string FerretDbExe()
|
||||
=> Path.Combine(FerretDb, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ferretdb.exe" : "ferretdb");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Flo.Installer.Provisioning;
|
||||
|
||||
internal static class PortScanner
|
||||
{
|
||||
public static int FindFreeLoopbackPort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Flo.Installer.Provisioning;
|
||||
|
||||
internal sealed class PostgresProvisioner(Paths paths)
|
||||
{
|
||||
public bool IsInitialized() => File.Exists(Path.Combine(paths.PostgresData, "PG_VERSION"));
|
||||
|
||||
public void Initialize(string superuser, string superuserPassword)
|
||||
{
|
||||
Directory.CreateDirectory(paths.PostgresData);
|
||||
|
||||
string pwFile = Path.Combine(paths.DataRoot, ".pg-init-pwd.tmp");
|
||||
File.WriteAllText(pwFile, superuserPassword);
|
||||
try
|
||||
{
|
||||
RunToCompletion(paths.PostgresBin("initdb"),
|
||||
["--pgdata", paths.PostgresData,
|
||||
"--username", superuser,
|
||||
"--pwfile", pwFile,
|
||||
"--auth-local", "scram-sha-256",
|
||||
"--auth-host", "scram-sha-256",
|
||||
"--encoding", "UTF8"],
|
||||
env: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(pwFile)) File.Delete(pwFile);
|
||||
}
|
||||
}
|
||||
|
||||
public void ConfigurePort(int port)
|
||||
{
|
||||
string confPath = Path.Combine(paths.PostgresData, "postgresql.conf");
|
||||
var lines = File.ReadAllLines(confPath).ToList();
|
||||
SetOrAppend(lines, "listen_addresses", "'127.0.0.1'");
|
||||
SetOrAppend(lines, "port", port.ToString());
|
||||
File.WriteAllLines(confPath, lines);
|
||||
}
|
||||
|
||||
public ProcessRunner Start(int port) =>
|
||||
ProcessRunner.Start(paths.PostgresBin("postgres"),
|
||||
["-D", paths.PostgresData, "-p", port.ToString()],
|
||||
env: null,
|
||||
name: "postgres");
|
||||
|
||||
// pg_ctl stop -m fast is the portable graceful shutdown — faster than smart, cleaner than SIGKILL.
|
||||
public void StopGracefully()
|
||||
{
|
||||
RunToCompletion(paths.PostgresBin("pg_ctl"),
|
||||
["stop", "-D", paths.PostgresData, "-m", "fast", "-w"],
|
||||
env: null);
|
||||
}
|
||||
|
||||
public void EnsureRoleAndDatabase(
|
||||
int port,
|
||||
string superuser,
|
||||
string superuserPassword,
|
||||
string role,
|
||||
string rolePassword,
|
||||
string database)
|
||||
{
|
||||
if (!RoleExists(port, superuser, superuserPassword, role))
|
||||
{
|
||||
Psql(port, superuser, superuserPassword, "postgres",
|
||||
$"CREATE ROLE \"{role}\" LOGIN PASSWORD '{EscapeLiteral(rolePassword)}';");
|
||||
}
|
||||
|
||||
if (!DatabaseExists(port, superuser, superuserPassword, database))
|
||||
{
|
||||
Psql(port, superuser, superuserPassword, "postgres",
|
||||
$"CREATE DATABASE \"{database}\" OWNER \"{role}\";");
|
||||
}
|
||||
}
|
||||
|
||||
private bool RoleExists(int port, string user, string password, string role)
|
||||
=> PsqlScalar(port, user, password, "postgres",
|
||||
$"SELECT 1 FROM pg_roles WHERE rolname = '{EscapeLiteral(role)}';") == "1";
|
||||
|
||||
private bool DatabaseExists(int port, string user, string password, string database)
|
||||
=> PsqlScalar(port, user, password, "postgres",
|
||||
$"SELECT 1 FROM pg_database WHERE datname = '{EscapeLiteral(database)}';") == "1";
|
||||
|
||||
private void Psql(int port, string user, string password, string db, string sql)
|
||||
{
|
||||
var (exit, stdout, stderr) = InvokePsql(port, user, password, db, sql, quiet: false);
|
||||
if (exit != 0)
|
||||
throw new InvalidOperationException($"psql failed (exit {exit})\nstdout: {stdout}\nstderr: {stderr}");
|
||||
}
|
||||
|
||||
private string PsqlScalar(int port, string user, string password, string db, string sql)
|
||||
{
|
||||
var (exit, stdout, stderr) = InvokePsql(port, user, password, db, sql, quiet: true);
|
||||
if (exit != 0)
|
||||
throw new InvalidOperationException($"psql scalar failed (exit {exit})\nstdout: {stdout}\nstderr: {stderr}");
|
||||
return stdout.Trim();
|
||||
}
|
||||
|
||||
private (int exit, string stdout, string stderr) InvokePsql(
|
||||
int port, string user, string password, string db, string sql, bool quiet)
|
||||
{
|
||||
var psi = new ProcessStartInfo(paths.PostgresBin("psql"))
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add("-h"); psi.ArgumentList.Add("127.0.0.1");
|
||||
psi.ArgumentList.Add("-p"); psi.ArgumentList.Add(port.ToString());
|
||||
psi.ArgumentList.Add("-U"); psi.ArgumentList.Add(user);
|
||||
psi.ArgumentList.Add("-d"); psi.ArgumentList.Add(db);
|
||||
psi.ArgumentList.Add("-v"); psi.ArgumentList.Add("ON_ERROR_STOP=1");
|
||||
if (quiet)
|
||||
{
|
||||
psi.ArgumentList.Add("-At"); // unaligned, tuples-only
|
||||
}
|
||||
psi.ArgumentList.Add("-c"); psi.ArgumentList.Add(sql);
|
||||
psi.Environment["PGPASSWORD"] = password;
|
||||
|
||||
using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start psql");
|
||||
string stdout = proc.StandardOutput.ReadToEnd();
|
||||
string stderr = proc.StandardError.ReadToEnd();
|
||||
proc.WaitForExit();
|
||||
return (proc.ExitCode, stdout, stderr);
|
||||
}
|
||||
|
||||
private static string EscapeLiteral(string s) => s.Replace("'", "''");
|
||||
|
||||
private static void SetOrAppend(List<string> lines, string key, string value)
|
||||
{
|
||||
string prefix = key;
|
||||
int i = lines.FindIndex(l =>
|
||||
{
|
||||
string t = l.TrimStart();
|
||||
return t.StartsWith(prefix + " ") || t.StartsWith(prefix + "\t") || t.StartsWith(prefix + "=");
|
||||
});
|
||||
string newLine = $"{key} = {value}";
|
||||
if (i >= 0) lines[i] = newLine;
|
||||
else lines.Add(newLine);
|
||||
}
|
||||
|
||||
private static void RunToCompletion(string exe, IReadOnlyList<string> args, IReadOnlyDictionary<string, string>? env)
|
||||
{
|
||||
var psi = new ProcessStartInfo(exe)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
if (env is not null)
|
||||
foreach (var kv in env) psi.Environment[kv.Key] = kv.Value;
|
||||
|
||||
using var proc = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {exe}");
|
||||
string stdout = proc.StandardOutput.ReadToEnd();
|
||||
string stderr = proc.StandardError.ReadToEnd();
|
||||
proc.WaitForExit();
|
||||
if (proc.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"{Path.GetFileName(exe)} failed (exit {proc.ExitCode})\nstdout: {stdout}\nstderr: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Flo.Installer.Provisioning;
|
||||
|
||||
internal sealed class ProcessRunner : IAsyncDisposable
|
||||
{
|
||||
private readonly Process _process;
|
||||
private readonly string _name;
|
||||
|
||||
private ProcessRunner(Process process, string name)
|
||||
{
|
||||
_process = process;
|
||||
_name = name;
|
||||
}
|
||||
|
||||
public static ProcessRunner Start(
|
||||
string executable,
|
||||
IReadOnlyList<string> args,
|
||||
IReadOnlyDictionary<string, string>? env,
|
||||
string name)
|
||||
{
|
||||
var psi = new ProcessStartInfo(executable)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
if (env is not null)
|
||||
foreach (var kv in env) psi.Environment[kv.Key] = kv.Value;
|
||||
|
||||
var proc = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {executable}");
|
||||
|
||||
proc.OutputDataReceived += (_, e) => { if (e.Data != null) Console.WriteLine($"[{name}] {e.Data}"); };
|
||||
proc.ErrorDataReceived += (_, e) => { if (e.Data != null) Console.Error.WriteLine($"[{name}] {e.Data}"); };
|
||||
proc.BeginOutputReadLine();
|
||||
proc.BeginErrorReadLine();
|
||||
|
||||
return new ProcessRunner(proc, name);
|
||||
}
|
||||
|
||||
public bool HasExited => _process.HasExited;
|
||||
public int ExitCode => _process.ExitCode;
|
||||
public Process Process => _process;
|
||||
|
||||
public async Task StopAsync(TimeSpan timeout)
|
||||
{
|
||||
if (_process.HasExited) return;
|
||||
_process.Kill(entireProcessTree: true);
|
||||
await _process.WaitForExitAsync().WaitAsync(timeout);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
try { await StopAsync(TimeSpan.FromSeconds(10)); }
|
||||
catch (Exception ex) { Console.Error.WriteLine($"[{_name}] stop error: {ex.Message}"); }
|
||||
}
|
||||
_process.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Flo.Installer.Provisioning;
|
||||
|
||||
internal sealed class SecretsStore(string path)
|
||||
{
|
||||
public bool Exists() => File.Exists(path);
|
||||
|
||||
public IReadOnlyDictionary<string, string> Load()
|
||||
=> JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllBytes(path))
|
||||
?? throw new InvalidOperationException($"Malformed secrets file: {path}");
|
||||
|
||||
public void Save(IReadOnlyDictionary<string, string> secrets)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(secrets, new JsonSerializerOptions { WriteIndented = true }));
|
||||
RestrictPermissions(path);
|
||||
}
|
||||
|
||||
// Phase 5 will swap this for DPAPI on Windows and real ACLs; Linux keeps the file-mode approach.
|
||||
private static void RestrictPermissions(string path)
|
||||
{
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Flo.Installer;
|
||||
|
||||
internal static class TrustedPublicKey
|
||||
{
|
||||
// Placeholder. Replace before any real release.
|
||||
// 1. Generate keypair: dotnet run --project src/Flo.Manifest.Tool -- generate-key --out release-keypair.key
|
||||
// 2. Export public key: dotnet run --project src/Flo.Manifest.Tool -- export-pubkey --key release-keypair.key
|
||||
// 3. Paste the printed array literal over the line below and rebuild.
|
||||
// 4. Move release-keypair.key to offline storage. Never commit it.
|
||||
public static readonly byte[] Bytes = new byte[32];
|
||||
|
||||
public static bool IsPlaceholder => Bytes.All(b => b == 0);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,123 @@
|
||||
using NSec.Cryptography;
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return args[0] switch
|
||||
{
|
||||
"generate-key" => GenerateKey(args),
|
||||
"export-pubkey" => ExportPubkey(args),
|
||||
"sign" => Sign(args),
|
||||
"verify" => Verify(args),
|
||||
_ => UnknownCommand()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int UnknownCommand()
|
||||
{
|
||||
Console.Error.WriteLine("Unknown command.");
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int GenerateKey(string[] args)
|
||||
{
|
||||
string outPath = RequireArg(args, "--out");
|
||||
if (File.Exists(outPath))
|
||||
throw new InvalidOperationException($"{outPath} already exists. Refusing to overwrite a keypair.");
|
||||
|
||||
var algorithm = SignatureAlgorithm.Ed25519;
|
||||
using var key = Key.Create(algorithm, new KeyCreationParameters
|
||||
{
|
||||
ExportPolicy = KeyExportPolicies.AllowPlaintextExport
|
||||
});
|
||||
byte[] priv = key.Export(KeyBlobFormat.RawPrivateKey);
|
||||
byte[] pub = key.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
|
||||
File.WriteAllText(outPath, $"{Convert.ToBase64String(priv)}\n{Convert.ToBase64String(pub)}\n");
|
||||
Console.WriteLine($"Wrote keypair to {outPath}");
|
||||
Console.WriteLine("Protect this file. Losing it means you cannot ship updates; leaking it means anyone can.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int ExportPubkey(string[] args)
|
||||
{
|
||||
string keyPath = RequireArg(args, "--key");
|
||||
(_, byte[] pub) = LoadKeypair(keyPath);
|
||||
|
||||
string literal = string.Join(", ", pub.Select(b => $"0x{b:x2}"));
|
||||
Console.WriteLine("// Paste into src/Flo.Installer/TrustedPublicKey.cs");
|
||||
Console.WriteLine($"public static readonly byte[] Bytes = new byte[] {{ {literal} }};");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int Sign(string[] args)
|
||||
{
|
||||
string manifestPath = RequireArg(args, "--manifest");
|
||||
string keyPath = RequireArg(args, "--key");
|
||||
string outPath = RequireArg(args, "--out");
|
||||
|
||||
(byte[] priv, _) = LoadKeypair(keyPath);
|
||||
var algorithm = SignatureAlgorithm.Ed25519;
|
||||
using var signingKey = Key.Import(algorithm, priv, KeyBlobFormat.RawPrivateKey);
|
||||
byte[] manifestBytes = File.ReadAllBytes(manifestPath);
|
||||
byte[] signature = algorithm.Sign(signingKey, manifestBytes);
|
||||
|
||||
File.WriteAllText(outPath, Convert.ToBase64String(signature));
|
||||
Console.WriteLine($"Signed: {manifestPath} -> {outPath}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int Verify(string[] args)
|
||||
{
|
||||
string manifestPath = RequireArg(args, "--manifest");
|
||||
string sigPath = RequireArg(args, "--sig");
|
||||
string keyPath = RequireArg(args, "--key");
|
||||
|
||||
(_, byte[] pub) = LoadKeypair(keyPath);
|
||||
var algorithm = SignatureAlgorithm.Ed25519;
|
||||
var pubKey = NSec.Cryptography.PublicKey.Import(algorithm, pub, KeyBlobFormat.RawPublicKey);
|
||||
byte[] manifestBytes = File.ReadAllBytes(manifestPath);
|
||||
byte[] signature = Convert.FromBase64String(File.ReadAllText(sigPath).Trim());
|
||||
|
||||
bool ok = algorithm.Verify(pubKey, manifestBytes, signature);
|
||||
Console.WriteLine(ok ? "OK: signature valid." : "FAIL: signature INVALID.");
|
||||
return ok ? 0 : 2;
|
||||
}
|
||||
|
||||
static (byte[] priv, byte[] pub) LoadKeypair(string path)
|
||||
{
|
||||
string[] lines = File.ReadAllLines(path);
|
||||
if (lines.Length < 2)
|
||||
throw new InvalidOperationException($"Malformed keypair file: {path}");
|
||||
return (Convert.FromBase64String(lines[0]), Convert.FromBase64String(lines[1]));
|
||||
}
|
||||
|
||||
static string RequireArg(string[] args, string name)
|
||||
{
|
||||
int i = Array.IndexOf(args, name);
|
||||
if (i < 0 || i + 1 >= args.Length)
|
||||
throw new ArgumentException($"{name} <value> is required.");
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("Flo.Manifest.Tool (release-side signing helper)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Commands:");
|
||||
Console.WriteLine(" generate-key --out <keypair-file>");
|
||||
Console.WriteLine(" export-pubkey --key <keypair-file>");
|
||||
Console.WriteLine(" sign --manifest <path> --key <keypair-file> --out <sig-file>");
|
||||
Console.WriteLine(" verify --manifest <path> --sig <sig-file> --key <keypair-file>");
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user