diff --git a/Installer/Installer.slnx b/Installer/Installer.slnx new file mode 100644 index 0000000..20d8570 --- /dev/null +++ b/Installer/Installer.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/Installer/samples/manifest.example.json b/Installer/samples/manifest.example.json new file mode 100644 index 0000000..07c2652 --- /dev/null +++ b/Installer/samples/manifest.example.json @@ -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" + } + } + } + ] +} diff --git a/Installer/src/Flo.Installer/ArtifactDownloader.cs b/Installer/src/Flo.Installer/ArtifactDownloader.cs new file mode 100644 index 0000000..1fb3bac --- /dev/null +++ b/Installer/src/Flo.Installer/ArtifactDownloader.cs @@ -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? 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; +} diff --git a/Installer/src/Flo.Installer/Commands/InstallCommand.cs b/Installer/src/Flo.Installer/Commands/InstallCommand.cs new file mode 100644 index 0000000..3192619 --- /dev/null +++ b/Installer/src/Flo.Installer/Commands/InstallCommand.cs @@ -0,0 +1,107 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; + +namespace Flo.Installer.Commands; + +internal static class InstallCommand +{ + public static async Task 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(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}"; + } +} diff --git a/Installer/src/Flo.Installer/Commands/ProvisionCommand.cs b/Installer/src/Flo.Installer/Commands/ProvisionCommand.cs new file mode 100644 index 0000000..a3aa0bc --- /dev/null +++ b/Installer/src/Flo.Installer/Commands/ProvisionCommand.cs @@ -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 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 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(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 + { + [$"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; + } +} diff --git a/Installer/src/Flo.Installer/Commands/RunCommand.cs b/Installer/src/Flo.Installer/Commands/RunCommand.cs new file mode 100644 index 0000000..1ec2441 --- /dev/null +++ b/Installer/src/Flo.Installer/Commands/RunCommand.cs @@ -0,0 +1,69 @@ +using System.Runtime.InteropServices; +using Flo.Installer.Provisioning; + +namespace Flo.Installer.Commands; + +internal static class RunCommand +{ + public static async Task 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}:@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; + } +} diff --git a/Installer/src/Flo.Installer/Ed25519Verifier.cs b/Installer/src/Flo.Installer/Ed25519Verifier.cs new file mode 100644 index 0000000..eb9681c --- /dev/null +++ b/Installer/src/Flo.Installer/Ed25519Verifier.cs @@ -0,0 +1,13 @@ +using NSec.Cryptography; + +namespace Flo.Installer; + +internal static class Ed25519Verifier +{ + public static bool Verify(ReadOnlySpan publicKey, ReadOnlySpan data, ReadOnlySpan signature) + { + var algorithm = SignatureAlgorithm.Ed25519; + var key = NSec.Cryptography.PublicKey.Import(algorithm, publicKey, KeyBlobFormat.RawPublicKey); + return algorithm.Verify(key, data, signature); + } +} diff --git a/Installer/src/Flo.Installer/Flo.Installer.csproj b/Installer/src/Flo.Installer/Flo.Installer.csproj new file mode 100644 index 0000000..8106eec --- /dev/null +++ b/Installer/src/Flo.Installer/Flo.Installer.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/Installer/src/Flo.Installer/Manifest.cs b/Installer/src/Flo.Installer/Manifest.cs new file mode 100644 index 0000000..03f1c81 --- /dev/null +++ b/Installer/src/Flo.Installer/Manifest.cs @@ -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 Artifacts); + +public sealed record Artifact( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("platforms")] IReadOnlyDictionary 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); diff --git a/Installer/src/Flo.Installer/ManifestClient.cs b/Installer/src/Flo.Installer/ManifestClient.cs new file mode 100644 index 0000000..c278812 --- /dev/null +++ b/Installer/src/Flo.Installer/ManifestClient.cs @@ -0,0 +1,25 @@ +using System.Text; +using System.Text.Json; + +namespace Flo.Installer; + +internal sealed class ManifestClient(HttpClient http) +{ + public async Task DownloadAndVerifyAsync( + Uri manifestUrl, + ReadOnlyMemory 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(manifestBytes) + ?? throw new InvalidOperationException("Manifest deserialized to null."); + } +} diff --git a/Installer/src/Flo.Installer/Program.cs b/Installer/src/Flo.Installer/Program.cs new file mode 100644 index 0000000..977a421 --- /dev/null +++ b/Installer/src/Flo.Installer/Program.cs @@ -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 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} is required."); + return args[i + 1]; +} + +static void PrintUsage() +{ + Console.WriteLine("Flo Installer"); + Console.WriteLine(); + Console.WriteLine("Commands:"); + Console.WriteLine(" setup --manifest --install download + extract + provision DBs"); + Console.WriteLine(" install --manifest --install download + extract only"); + Console.WriteLine(" provision --install provision Postgres + FerretDB (idempotent)"); + Console.WriteLine(" run --install start Postgres + FerretDB, block on Ctrl-C"); +} diff --git a/Installer/src/Flo.Installer/Provisioning/FerretDbProvisioner.cs b/Installer/src/Flo.Installer/Provisioning/FerretDbProvisioner.cs new file mode 100644 index 0000000..d5b97b4 --- /dev/null +++ b/Installer/src/Flo.Installer/Provisioning/FerretDbProvisioner.cs @@ -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 + { + ["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"); + } +} diff --git a/Installer/src/Flo.Installer/Provisioning/FloConfig.cs b/Installer/src/Flo.Installer/Provisioning/FloConfig.cs new file mode 100644 index 0000000..025fe50 --- /dev/null +++ b/Installer/src/Flo.Installer/Provisioning/FloConfig.cs @@ -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(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 })); + } +} diff --git a/Installer/src/Flo.Installer/Provisioning/HealthCheck.cs b/Installer/src/Flo.Installer/Provisioning/HealthCheck.cs new file mode 100644 index 0000000..7571d0c --- /dev/null +++ b/Installer/src/Flo.Installer/Provisioning/HealthCheck.cs @@ -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}"); + } +} diff --git a/Installer/src/Flo.Installer/Provisioning/PasswordGenerator.cs b/Installer/src/Flo.Installer/Provisioning/PasswordGenerator.cs new file mode 100644 index 0000000..232ccf6 --- /dev/null +++ b/Installer/src/Flo.Installer/Provisioning/PasswordGenerator.cs @@ -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 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); + } +} diff --git a/Installer/src/Flo.Installer/Provisioning/Paths.cs b/Installer/src/Flo.Installer/Provisioning/Paths.cs new file mode 100644 index 0000000..1639df7 --- /dev/null +++ b/Installer/src/Flo.Installer/Provisioning/Paths.cs @@ -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"); +} diff --git a/Installer/src/Flo.Installer/Provisioning/PortScanner.cs b/Installer/src/Flo.Installer/Provisioning/PortScanner.cs new file mode 100644 index 0000000..6010e22 --- /dev/null +++ b/Installer/src/Flo.Installer/Provisioning/PortScanner.cs @@ -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; + } +} diff --git a/Installer/src/Flo.Installer/Provisioning/PostgresProvisioner.cs b/Installer/src/Flo.Installer/Provisioning/PostgresProvisioner.cs new file mode 100644 index 0000000..8090b9a --- /dev/null +++ b/Installer/src/Flo.Installer/Provisioning/PostgresProvisioner.cs @@ -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 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 args, IReadOnlyDictionary? 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}"); + } +} diff --git a/Installer/src/Flo.Installer/Provisioning/ProcessRunner.cs b/Installer/src/Flo.Installer/Provisioning/ProcessRunner.cs new file mode 100644 index 0000000..8a0c0b9 --- /dev/null +++ b/Installer/src/Flo.Installer/Provisioning/ProcessRunner.cs @@ -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 args, + IReadOnlyDictionary? 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(); + } +} diff --git a/Installer/src/Flo.Installer/Provisioning/SecretsStore.cs b/Installer/src/Flo.Installer/Provisioning/SecretsStore.cs new file mode 100644 index 0000000..8f80ac3 --- /dev/null +++ b/Installer/src/Flo.Installer/Provisioning/SecretsStore.cs @@ -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 Load() + => JsonSerializer.Deserialize>(File.ReadAllBytes(path)) + ?? throw new InvalidOperationException($"Malformed secrets file: {path}"); + + public void Save(IReadOnlyDictionary 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); + } +} diff --git a/Installer/src/Flo.Installer/TrustedPublicKey.cs b/Installer/src/Flo.Installer/TrustedPublicKey.cs new file mode 100644 index 0000000..0689873 --- /dev/null +++ b/Installer/src/Flo.Installer/TrustedPublicKey.cs @@ -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); +} diff --git a/Installer/src/Flo.Manifest.Tool/Flo.Manifest.Tool.csproj b/Installer/src/Flo.Manifest.Tool/Flo.Manifest.Tool.csproj new file mode 100644 index 0000000..8106eec --- /dev/null +++ b/Installer/src/Flo.Manifest.Tool/Flo.Manifest.Tool.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/Installer/src/Flo.Manifest.Tool/Program.cs b/Installer/src/Flo.Manifest.Tool/Program.cs new file mode 100644 index 0000000..a54d976 --- /dev/null +++ b/Installer/src/Flo.Manifest.Tool/Program.cs @@ -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} 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 "); + Console.WriteLine(" export-pubkey --key "); + Console.WriteLine(" sign --manifest --key --out "); + Console.WriteLine(" verify --manifest --sig --key "); +} diff --git a/docs/VAT findings.pdf b/docs/VAT findings.pdf new file mode 100644 index 0000000..f343016 Binary files /dev/null and b/docs/VAT findings.pdf differ diff --git a/docs/VAT_Remediation_Plan.docx b/docs/VAT_Remediation_Plan.docx new file mode 100644 index 0000000..850f9a9 Binary files /dev/null and b/docs/VAT_Remediation_Plan.docx differ