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; /// logfmt 日志:ts=… level=info msg="…" key=value。令牌、会话密钥和 MAC 不能出现在日志里。 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) + "\""; } /// 可以独立运行的功能模块(连接、数据面、切块……)。 public interface IModule { string Name { get; } Task RunAsync(CancellationToken ct); } /// /// 模块宿主:同时运行所有模块,隔离故障(一个模块崩溃只重启它自己,指数退避),收到取消之后等所有模块退出。 /// 模块之间通过构造函数传入的接口或 Channel<T> 通信,不使用静态全局状态。 /// public sealed class ModuleHost { private readonly List _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); } } } } /// /// 启动自检:Linux 上 .NET 的加密和 TLS 依赖系统的 OpenSSL(libssl.so.3)。OpenWrt 默认用 mbedTLS/wolfSSL,需要另外安装 libopenssl3。 /// 缺少时在启动的第一步明确报错,而不是在第一次握手时抛出一个难以理解的异常。 /// public static class PlatformCheck { /// 返回 null 表示正常,否则是给人看的错误说明。 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`"; } } } /// 处理 Ctrl+C 和 SIGTERM:取消令牌,让各个模块有序退出。 public static class ServiceShell { /// 每天定时有序退出一次(见 ),重新拉起由服务管理器负责。 public static async Task RunAsync(Func 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; } } /// /// 节点进程的托管堆只有几百 KB,但工作站 GC 的第 0 代预算很大,按默认设置要攒很多垃圾才回收一次, /// 在 1 GB 内存的小机器上表现为常驻内存缓慢爬升(不是泄漏,强制回收后曲线是平的)。 /// 每 30 秒回收一次:堆这么小,一次完整回收不到 1 ms。 /// 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) { // 退出 } } } /// 读取 JSON 配置(源生成,不使用反射)。解析失败时给出明确的错误,而不是带着默认值悄悄运行。 public static class NodeConfig { public static T Load(string path, JsonTypeInfo typeInfo) where T : class { try { if (!File.Exists(path)) { throw new InvalidDataException($"config file {path} does not exist"); } byte[] bytes = File.ReadAllBytes(path); ReadOnlySpan 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); } } } /// /// 常驻业务进程里唯一允许写文件的地方,而且只能写配置/身份目录(原子替换,类 Unix 上权限 0600)。 /// 例如 Probe 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。 /// 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); } } /// node.json:fleet-enroll 写入,节点只读。 public sealed class NodeFile { [JsonPropertyName("node_id")] public string NodeId { get; set; } = ""; [JsonPropertyName("server")] public string Server { get; set; } = ""; /// 本节点 TLS 证书的 SPKI 指纹(base64),注册时上报。 [JsonPropertyName("tls_spki")] public string TlsSpki { get; set; } = ""; } [JsonSerializable(typeof(NodeFile))] public sealed partial class NodeFileContext : JsonSerializerContext; /// 身份目录里的文件(fleet-enroll 生成):node.json、node.key(PKCS#8)、tls.pfx。 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; } /// /// 对等面的签名身份:TLS 证书的那把密钥,和连服务端用的 是两把独立的密钥, /// 各自只用于自己的用途(一把泄露不影响另一把)。 /// 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(); } }