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