Sync files delete

This commit is contained in:
2026-04-23 15:49:41 +05:30
parent 0cfcbe8ed6
commit 6c2ee11ccc
25 changed files with 1080 additions and 0 deletions
+6
View File
@@ -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>
+70
View File
@@ -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>
+24
View File
@@ -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.");
}
}
+59
View File
@@ -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>
+123
View File
@@ -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>");
}