Files
MoShiro/backend/fleet/src/Fleet.Hosting/Hosting.cs
T
2026-10-07 16:57:34 +08:00

363 lines
14 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Fleet.Core;
using Fleet.Identity;
namespace Fleet.Hosting;
/// <summary>logfmt 日志:<c>ts=… level=info msg="…" key=value</c>。令牌、会话密钥和 MAC 不能出现在日志里。</summary>
public sealed class ConsoleNodeLog : INodeLog
{
private readonly TimeProvider _time;
private readonly TextWriter _writer;
private readonly object _gate = new();
public ConsoleNodeLog(TimeProvider time, TextWriter? writer = null)
{
_time = time;
_writer = writer ?? Console.Error;
}
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
var line = new StringBuilder();
line.Append("ts=").Append(_time.GetUtcNow().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture));
line.Append(" level=").Append(level.ToString().ToLowerInvariant());
line.Append(" msg=").Append(Quote(message));
foreach ((string key, object? value) in fields)
{
line.Append(' ').Append(key).Append('=').Append(Quote(Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""));
}
lock (_gate)
{
_writer.WriteLine(line.ToString());
}
}
private static string Quote(string text) =>
text.Length > 0 && text.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' or ':' or '/') ? text : "\"" + text.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + "\"";
}
/// <summary>可以独立运行的功能模块(连接、数据面、切块……)。</summary>
public interface IModule
{
string Name { get; }
Task RunAsync(CancellationToken ct);
}
/// <summary>
/// 模块宿主:同时运行所有模块,隔离故障(一个模块崩溃只重启它自己,指数退避),收到取消之后等所有模块退出。
/// 模块之间通过构造函数传入的接口或 <c>Channel&lt;T&gt;</c> 通信,不使用静态全局状态。
/// </summary>
public sealed class ModuleHost
{
private readonly List<IModule> _modules = [];
private readonly INodeLog _log;
private readonly TimeProvider _time;
public ModuleHost(INodeLog log, TimeProvider time)
{
_log = log;
_time = time;
}
public ModuleHost Add(IModule module)
{
_modules.Add(module);
return this;
}
public Task RunAsync(CancellationToken ct) => Task.WhenAll(_modules.Select(m => RunModuleAsync(m, ct)));
private async Task RunModuleAsync(IModule module, CancellationToken ct)
{
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
while (!ct.IsCancellationRequested)
{
try
{
await module.RunAsync(ct);
return; // 模块正常结束
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
// 模块边界:一个模块的故障不能带倒整个进程
TimeSpan delay = backoff.Next();
_log.Error("module crashed", ("module", module.Name), ("err", ex.GetType().Name + ": " + ex.Message), ("retry_ms", (long)delay.TotalMilliseconds));
await Task.Delay(delay, _time, ct);
}
}
}
}
/// <summary>
/// 启动自检:Linux 上 .NET 的加密和 TLS 依赖系统的 OpenSSL(<c>libssl.so.3</c>)。OpenWrt 默认用 mbedTLS/wolfSSL,需要另外安装 <c>libopenssl3</c>。
/// 缺少时在启动的第一步明确报错,而不是在第一次握手时抛出一个难以理解的异常。
/// </summary>
public static class PlatformCheck
{
/// <summary>返回 null 表示正常,否则是给人看的错误说明。</summary>
public static string? VerifyCrypto()
{
try
{
using var key = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
_ = key.SignData("check"u8, System.Security.Cryptography.HashAlgorithmName.SHA256);
return null;
}
catch (Exception ex) when (ex is DllNotFoundException or TypeInitializationException or System.Security.Cryptography.CryptographicException or PlatformNotSupportedException or InvalidOperationException)
{
return $"cannot use the system cryptography library ({ex.GetType().Name}: {ex.Message}). Install OpenSSL 3: Debian/Ubuntu `apt install libssl3`, Alpine `apk add libssl3`, OpenWrt `opkg install libopenssl3`";
}
}
}
/// <summary>处理 Ctrl+C 和 SIGTERM:取消令牌,让各个模块有序退出。</summary>
public static class ServiceShell
{
/// <param name="restart">每天定时有序退出一次(见 <see cref="RestartController"/>),重新拉起由服务管理器负责。</param>
public static async Task RunAsync(Func<CancellationToken, Task> run, INodeLog log, RestartController? restart = null, TimeProvider? time = null)
{
using var cts = new CancellationTokenSource();
using PosixSignalRegistration term = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{
context.Cancel = true;
log.Info("stopping", ("result", "SIGTERM"));
cts.Cancel();
});
using PosixSignalRegistration interrupt = PosixSignalRegistration.Create(PosixSignal.SIGINT, context =>
{
context.Cancel = true;
log.Info("stopping", ("result", "SIGINT"));
cts.Cancel();
});
// Windows 上 Ctrl+Break 对应 SIGQUIT(没有控制台窗口的后台运行方式用它来请求有序退出)
using PosixSignalRegistration quit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, context =>
{
context.Cancel = true;
log.Info("stopping", ("result", "SIGQUIT"));
cts.Cancel();
});
// Windows 上关闭控制台窗口 / taskkill(不带 /F)对应 SIGHUP
using PosixSignalRegistration hangup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, context =>
{
context.Cancel = true;
log.Info("stopping", ("result", "SIGHUP"));
cts.Cancel();
});
Task trimmer = TrimHeapAsync(cts.Token);
Task scheduled = restart is null ? Task.CompletedTask : WaitForRestartAsync(restart, time ?? TimeProvider.System, log, cts);
try
{
await run(cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
// 正常退出
}
finally
{
await cts.CancelAsync();
await trimmer;
await scheduled;
}
}
private static async Task WaitForRestartAsync(RestartController restart, TimeProvider time, INodeLog log, CancellationTokenSource cts)
{
while (!cts.IsCancellationRequested)
{
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
CancellationToken changed = restart.ChangeToken;
RestartSchedule? schedule = restart.Current;
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed);
try
{
if (schedule is null)
{
await Task.Delay(Timeout.Infinite, wait.Token);
continue;
}
DateTimeOffset next = schedule.NextAfter(time.GetUtcNow() + RestartSchedule.MinimumUptime);
log.Info("restart scheduled", ("at", next.ToString("O", System.Globalization.CultureInfo.InvariantCulture)), ("source", restart.IsLocal ? "local" : "panel"));
await Task.Delay(next - time.GetUtcNow(), time, wait.Token);
}
catch (OperationCanceledException)
{
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理
}
log.Info("stopping", ("result", "scheduled_restart"));
await cts.CancelAsync();
return;
}
}
/// <summary>
/// 节点进程的托管堆只有几百 KB,但工作站 GC 的第 0 代预算很大,按默认设置要攒很多垃圾才回收一次,
/// 在 1 GB 内存的小机器上表现为常驻内存缓慢爬升(不是泄漏,强制回收后曲线是平的)。
/// 每 30 秒回收一次:堆这么小,一次完整回收不到 1 ms。
/// </summary>
private static async Task TrimHeapAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
try
{
while (await timer.WaitForNextTickAsync(ct))
{
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: false);
}
}
catch (OperationCanceledException)
{
// 退出
}
}
}
/// <summary>读取 JSON 配置(源生成,不使用反射)。解析失败时给出明确的错误,而不是带着默认值悄悄运行。</summary>
public static class NodeConfig
{
public static T Load<T>(string path, JsonTypeInfo<T> typeInfo)
where T : class
{
try
{
if (!File.Exists(path))
{
throw new InvalidDataException($"config file {path} does not exist");
}
byte[] bytes = File.ReadAllBytes(path);
ReadOnlySpan<byte> json = bytes;
if (json.StartsWith(""u8)) // Windows 编辑器常带 UTF-8 BOM,System.Text.Json 不接受
{
json = json[3..];
}
return JsonSerializer.Deserialize(json, typeInfo) ?? throw new InvalidDataException($"{path} is empty");
}
catch (JsonException ex)
{
throw new InvalidDataException($"{path} is not valid: {ex.Message}", ex);
}
}
}
/// <summary>
/// 常驻业务进程里唯一允许写文件的地方,而且只能写配置/身份目录(原子替换,类 Unix 上权限 0600)。
/// 例如 Probe 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。
/// </summary>
public sealed class ConfigStore
{
private readonly string _directory;
public ConfigStore(string directory)
{
_directory = Path.GetFullPath(directory);
}
public void Write(string fileName, byte[] content)
{
string path = Path.GetFullPath(Path.Combine(_directory, fileName));
if (!path.StartsWith(_directory, StringComparison.Ordinal))
{
throw new InvalidOperationException("config files must stay inside the config directory");
}
// 相对路径里的子目录(例如 update/)也要建:新建的命名卷里只有镜像自带的目录
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string temp = path + ".tmp";
#pragma warning disable RS0030 // 纯采集边界的唯一豁免点:ConfigStore 只写配置目录,先写临时文件再原子替换
File.WriteAllBytes(temp, content);
#pragma warning restore RS0030
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(temp, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
File.Move(temp, path, overwrite: true);
}
}
/// <summary><c>node.json</c>:<c>fleet-enroll</c> 写入,节点只读。</summary>
public sealed class NodeFile
{
[JsonPropertyName("node_id")]
public string NodeId { get; set; } = "";
[JsonPropertyName("server")]
public string Server { get; set; } = "";
/// <summary>本节点 TLS 证书的 SPKI 指纹(base64),注册时上报。</summary>
[JsonPropertyName("tls_spki")]
public string TlsSpki { get; set; } = "";
}
[JsonSerializable(typeof(NodeFile))]
public sealed partial class NodeFileContext : JsonSerializerContext;
/// <summary>身份目录里的文件(<c>fleet-enroll</c> 生成):<c>node.json</c>、<c>node.key</c>(PKCS#8)、<c>tls.pfx</c>。</summary>
public sealed class LoadedNodeIdentity : IDisposable
{
public const string NodeFileName = "node.json";
public const string KeyFileName = "node.key";
public const string TlsFileName = "tls.pfx";
public LoadedNodeIdentity(NodeFile file, NodeIdentity identity, X509Certificate2 certificate)
{
File = file;
Identity = identity;
Certificate = certificate;
}
public NodeFile File { get; }
public NodeId NodeId => new(File.NodeId);
public NodeIdentity Identity { get; }
public X509Certificate2 Certificate { get; }
/// <summary>
/// 对等面的签名身份:TLS 证书的那把密钥,和连服务端用的 <see cref="Identity"/> 是两把独立的密钥,
/// 各自只用于自己的用途(一把泄露不影响另一把)。
/// </summary>
public NodeIdentity PeerIdentity => _peerIdentity ??= NodeIdentity.FromPkcs8(
(Certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("the node certificate has no ECDSA private key")).ExportPkcs8PrivateKey());
private NodeIdentity? _peerIdentity;
public static LoadedNodeIdentity Load(string directory)
{
if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName)))
{
throw new InvalidDataException($"no node identity in {directory}: run fleet-enroll first");
}
NodeFile file = NodeConfig.Load(Path.Combine(directory, NodeFileName), NodeFileContext.Default.NodeFile);
NodeIdentity identity = NodeIdentity.FromPkcs8(System.IO.File.ReadAllBytes(Path.Combine(directory, KeyFileName)));
X509Certificate2 certificate = NodeCertificate.LoadPfx(System.IO.File.ReadAllBytes(Path.Combine(directory, TlsFileName)));
return new LoadedNodeIdentity(file, identity, certificate);
}
public void Dispose()
{
Identity.Dispose();
_peerIdentity?.Dispose();
Certificate.Dispose();
}
}