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