From 535bb3517da37c64111f4598414f53bd3d98ff95 Mon Sep 17 00:00:00 2001 From: JustMe Date: Fri, 9 Oct 2026 01:33:22 +0800 Subject: [PATCH] feat: node updates by restart, auto by default; Podman Quadlet install from the panel alongside compose [CI] --- README.md | 2 +- backend/agent/README.md | 2 +- backend/agent/src/MoShiro.Agent/AgentApp.cs | 10 +- backend/fleet/src/Fleet.Hosting/Hosting.cs | 9 +- .../src/Fleet.Hosting/RestartController.cs | 29 ++++ backend/openapi/v1.json | 63 +++++++- backend/probe/README.md | 2 +- backend/probe/src/MoShiro.Probe/ProbeApp.cs | 8 +- .../Update/RestartUpdater.cs | 153 ++++++++++++++++++ .../Update/UpdateBridge.cs | 100 ------------ .../MoShiro.Measurement.Tests.csproj | 1 + .../RestartUpdaterTests.cs | 113 +++++++++++++ .../Admin/AgentEndpoints.cs | 4 +- .../Contracts/AdminDtos.cs | 8 +- .../Contracts/ApiJsonContext.cs | 1 + .../src/MoShiro.Backend.Api/InstallScripts.cs | 6 + .../MoShiro.Backend.Api.csproj | 2 + .../src/MoShiro.Backend.Api/OpsEndpoints.cs | 4 + .../Admin/JoinTokenService.cs | 42 ++++- .../MoShiro.Backend.Tests/InstallTests.cs | 47 ++++++ .../OpenApiConformanceTests.cs | 2 +- ci/publish-gitea-release.sh | 11 +- docs/dev/project-layout.md | 2 +- docs/ops/deployment.md | 64 ++++++-- docs/security/access.md | 2 +- docs/security/enrollment.md | 6 +- packaging/container/entrypoint.sh | 2 +- packaging/quadlet/moshiro-agent.container | 54 +++++++ packaging/quadlet/moshiro-probe.container | 54 +++++++ web/admin/src/pages/JoinTokenCreated.tsx | 45 ++++-- web/admin/src/pages/System.tsx | 2 +- web/shared/api/schema.ts | 53 ++++++ 32 files changed, 751 insertions(+), 152 deletions(-) create mode 100644 backend/shared/MoShiro.Measurement/Update/RestartUpdater.cs delete mode 100644 backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs create mode 100644 backend/shared/tests/MoShiro.Measurement.Tests/RestartUpdaterTests.cs create mode 100644 packaging/quadlet/moshiro-agent.container create mode 100644 packaging/quadlet/moshiro-probe.container diff --git a/README.md b/README.md index 2855487..54f8cad 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P **它不做什么** -- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。 +- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点外面的服务管理器完成:Podman Quadlet 单元每次启动都先拉最新镜像,管理端点「更新」时节点只是有序退出(没有 Quadlet 的环境用宿主机更新器),节点自己不下载、不替换任何东西。 - **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。 - **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。 - 暂不包含告警(见文末待办)。 diff --git a/backend/agent/README.md b/backend/agent/README.md index 296d24a..14238f5 100644 --- a/backend/agent/README.md +++ b/backend/agent/README.md @@ -60,4 +60,4 @@ IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为 - 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。 - 详细安全边界与拓扑见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md),协议说明见 [`../../docs/design/protocol.md`](../../docs/design/protocol.md),部署见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。 -Docker 与 rootless/rootful Podman 共用 `packaging/compose/agent/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。 +Podman 系用 Quadlet(`packaging/quadlet/moshiro-agent.container`,默认自动更新),Docker 用 `packaging/compose/agent/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。 diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 2f4d843..ba74b8e 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -31,7 +31,7 @@ public sealed class AgentApp : IDisposable private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly AgentAdmission _admission; private readonly Outbox _outbox = new(64 << 20); - private readonly UpdateBridge _update; + private readonly RestartUpdater? _update; private readonly BackendUplink _uplink; private readonly UdpMux _udp; private readonly TcpAcceptor _tcp; @@ -46,7 +46,7 @@ public sealed class AgentApp : IDisposable { _ownedRestart = restart is null ? new RestartController() : null; _restart = restart ?? _ownedRestart!; - _update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log); + _update = RestartUpdater.FromEnvironment("agent", _restart.RequestNow, time, log); _config = config; _identity = identity; _time = time; @@ -235,7 +235,7 @@ public sealed class AgentApp : IDisposable { router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) => { - _update.WriteOffer(offer); + _update?.Offer(offer); return ValueTask.CompletedTask; }); } @@ -257,7 +257,7 @@ public sealed class AgentApp : IDisposable { router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) => { - _update.RequestApply(); + _update?.RequestApply(); return ValueTask.CompletedTask; }); } @@ -341,7 +341,7 @@ public sealed class AgentApp : IDisposable DroppedInvalid = _counters.DroppedInvalid, DroppedRateLimited = _counters.DroppedRateLimited, QueueBytes = _outbox.PendingBytes, - Update = _update.ReadStatus(), + Update = _update?.Status(), }; _lastIn = packetsIn; _lastOut = packetsOut; diff --git a/backend/fleet/src/Fleet.Hosting/Hosting.cs b/backend/fleet/src/Fleet.Hosting/Hosting.cs index c9b7697..1a5fe44 100644 --- a/backend/fleet/src/Fleet.Hosting/Hosting.cs +++ b/backend/fleet/src/Fleet.Hosting/Hosting.cs @@ -181,7 +181,7 @@ public static class ServiceShell // 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排 CancellationToken changed = restart.ChangeToken; RestartSchedule? schedule = restart.Current; - using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed); + using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed, restart.NowToken); try { if (schedule is null) @@ -196,6 +196,13 @@ public static class ServiceShell } catch (OperationCanceledException) { + if (restart.NowReason is { } reason) + { + log.Info("stopping", ("result", reason)); + await cts.CancelAsync(); + return; + } + continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理 } diff --git a/backend/fleet/src/Fleet.Hosting/RestartController.cs b/backend/fleet/src/Fleet.Hosting/RestartController.cs index a61fc36..403bdbd 100644 --- a/backend/fleet/src/Fleet.Hosting/RestartController.cs +++ b/backend/fleet/src/Fleet.Hosting/RestartController.cs @@ -8,7 +8,9 @@ public sealed class RestartController : IDisposable private readonly RestartSchedule? _local; private readonly object _gate = new(); private RestartSchedule? _remote; + private readonly CancellationTokenSource _now = new(); private CancellationTokenSource _changed = new(); + private string? _nowReason; public RestartController(RestartSchedule? local = null) { @@ -41,6 +43,32 @@ public sealed class RestartController : IDisposable } } + /// 应用要求现在就有序退出(比如管理员请求更新);退出的原因,没有就是 null。 + public string? NowReason + { + get + { + lock (_gate) + { + return _nowReason; + } + } + } + + /// 应用要求现在就退出的令牌。 + public CancellationToken NowToken => _now.Token; + + /// 要求进程现在就有序退出,由服务管理器拉起;不管有没有设置定时重启。 + public void RequestNow(string reason) + { + lock (_gate) + { + _nowReason ??= reason; + } + + _now.Cancel(); + } + /// 面板下发的设置;为空表示面板关闭了定时重启。返回 false 表示文本不合法(节点忽略它)。 public bool SetRemote(string? text) { @@ -68,6 +96,7 @@ public sealed class RestartController : IDisposable lock (_gate) { _changed.Dispose(); + _now.Dispose(); } } } diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index b47374f..f16a252 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -5000,6 +5000,40 @@ } } }, + "/install/quadlet/{file}": { + "get": { + "tags": [ + "ops" + ], + "summary": "The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone)", + "operationId": "install_quadlet", + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/install.ps1": { "get": { "tags": [ @@ -6095,7 +6129,8 @@ "token", "plaintext", "install_commands", - "compose" + "compose", + "quadlet" ], "type": "object", "properties": { @@ -6110,6 +6145,9 @@ }, "compose": { "$ref": "#/components/schemas/ComposeInstallDto" + }, + "quadlet": { + "$ref": "#/components/schemas/QuadletInstallDto" } } }, @@ -8520,6 +8558,29 @@ } } }, + "QuadletInstallDto": { + "required": [ + "env", + "url", + "sha256", + "command" + ], + "type": "object", + "properties": { + "env": { + "type": "string" + }, + "url": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "command": { + "type": "string" + } + } + }, "RawChunkMetaDto": { "required": [ "chunk_id", diff --git a/backend/probe/README.md b/backend/probe/README.md index f41e65b..1352a16 100644 --- a/backend/probe/README.md +++ b/backend/probe/README.md @@ -56,4 +56,4 @@ moshiro-probe --config /etc/moshiro/probe.json - 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。 - 测量模型和协议细节见 [`../../docs/design/measurement.md`](../../docs/design/measurement.md) 与 [`../../docs/design/protocol.md`](../../docs/design/protocol.md);整体架构见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md)。 -Docker 与 rootless/rootful Podman 共用 `packaging/compose/probe/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。 +Podman 系用 Quadlet(`packaging/quadlet/moshiro-probe.container`,默认自动更新),Docker 用 `packaging/compose/probe/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。 diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index b8b9984..5cc7f9c 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -36,7 +36,7 @@ public sealed class ProbeApp : IDisposable _env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log); directory.Changed += () => _changed.Release(); - var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log); + RestartUpdater? update = RestartUpdater.FromEnvironment("probe", reason => restart?.RequestNow(reason), time, log); var options = new FleetLinkOptions { Server = new Uri(file.Fleet.Server), @@ -45,7 +45,7 @@ public sealed class ProbeApp : IDisposable NodeId = identity.NodeId, NodeVersion = Version, Capabilities = [MoShiroCapabilities.Base], - HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra), + HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update?.Status(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra), HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta), }; var list = new StateHandler( @@ -59,12 +59,12 @@ public sealed class ProbeApp : IDisposable var router = new RpcRouter(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe) .OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) => { - update.WriteOffer(offer); + update?.Offer(offer); return ValueTask.CompletedTask; }) .OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) => { - update.RequestApply(); + update?.RequestApply(); return ValueTask.CompletedTask; }) .OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) => diff --git a/backend/shared/MoShiro.Measurement/Update/RestartUpdater.cs b/backend/shared/MoShiro.Measurement/Update/RestartUpdater.cs new file mode 100644 index 0000000..e1463cb --- /dev/null +++ b/backend/shared/MoShiro.Measurement/Update/RestartUpdater.cs @@ -0,0 +1,153 @@ +using System.Globalization; +using System.Text.Json; +using Fleet.Core; +using MoShiro.Protocol; + +namespace MoShiro.Measurement; + +/// +/// 节点的「更新」:面板建议、请求,节点只负责有序退出,换新版本由外面的服务管理器完成—— +/// 例如 Podman 的 Quadlet 单元写了 Pull=always,每次启动都先从镜像仓库拉最新的镜像,所以退出再被拉起就是更新。 +/// 节点自己不下载、不安装、不执行任何东西,所以不需要验证什么:最多让面板(管理员)请求一次重启,和定时重启是同一种能力。 +/// +/// 只有设了环境变量 MOSHIRO_UPDATE=restart 才启用(Quadlet 模板里写好了):别的部署方式退出之后不会重新拉取,开了也不会更新,所以没有启用就不报告更新状态。 +/// 策略由环境变量 MOSHIRO_UPDATE_POLICY 决定:auto(默认)发现新版本就自己退出重启,manual 等管理端请求。自动更新有保护: +/// 进程至少运行 才会自动退出,新镜像没拉到(标签没变、仓库还没更新)最多每个这么久重启一次,不会陷入重启循环。 +/// 面板转来的发布清单(面板登记时已验过 CI 签名)只用来显示「有没有新版本」:组件对、比这个程序新(seq 晚于构建时间)、tag 和提交不同。 +/// +/// +public sealed class RestartUpdater +{ + public const string EnvironmentVariable = "MOSHIRO_UPDATE"; + public const string PolicyVariable = "MOSHIRO_UPDATE_POLICY"; + + /// 自动更新之前,进程至少要运行这么久。 + public static readonly TimeSpan MinimumUptime = TimeSpan.FromMinutes(10); + + private const int MaxManifestBytes = 4096; + + private readonly string _component; + private readonly Action _requestRestart; + private readonly TimeProvider _time; + private readonly INodeLog _log; + private readonly string _tag; + private readonly string _commit; + private readonly long _builtAt; + private readonly object _gate = new(); + private readonly bool _auto; + private int _autoScheduled; + private readonly long _startedAt; + private UpdateStatus _status; + + /// 这个程序的构建标签、提交和构建时间(秒):默认取 ,测试可以传入。 + public RestartUpdater(string component, Action requestRestart, TimeProvider time, INodeLog log, string? tag = null, string? commit = null, long? builtAtEpoch = null, bool auto = false) + { + _auto = auto; + _startedAt = time.GetTimestamp(); + _component = component; + _requestRestart = requestRestart; + _time = time; + _log = log; + _tag = tag ?? BuildInfo.Tag; + _commit = commit ?? BuildInfo.Commit; + _builtAt = builtAtEpoch ?? (DateTimeOffset.TryParse(BuildInfo.BuiltAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset built) ? built.ToUnixTimeSeconds() : 0); + _status = new UpdateStatus { State = "up_to_date", At = Now() }; + } + + /// 环境变量 MOSHIRO_UPDATE=restart 才启用;否则返回 null,节点不报告更新状态。 + public static RestartUpdater? FromEnvironment(string component, Action requestRestart, TimeProvider time, INodeLog log) => + string.Equals(Environment.GetEnvironmentVariable(EnvironmentVariable), "restart", StringComparison.Ordinal) + ? new RestartUpdater(component, requestRestart, time, log, auto: !string.Equals(Environment.GetEnvironmentVariable(PolicyVariable), "manual", StringComparison.OrdinalIgnoreCase)) + : null; + + /// 面板转来的发布清单:只用来判断有没有新版本。 + public void Offer(UpdateOfferParams offer) + { + if (offer.Manifest.Length is 0 or > MaxManifestBytes) + { + _log.Warn("ignoring an update offer of an unexpected size"); + return; + } + + try + { + using JsonDocument document = JsonDocument.Parse(offer.Manifest); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || Text(root, "component") != _component || !root.TryGetProperty("seq", out JsonElement seqValue) || !seqValue.TryGetInt64(out long seq)) + { + return; + } + + string tag = Text(root, "tag"); + string commit = Text(root, "commit"); + bool same = (_tag.Length > 0 && tag == _tag) || (_commit.Length > 0 && commit.Length > 0 && commit == _commit); + bool available; + lock (_gate) + { + _status = same || seq <= _builtAt + ? new UpdateStatus { State = "up_to_date", At = Now() } + : new UpdateStatus + { + State = "available", + Available = new UpdateAvailable { Tag = tag, Version = Text(root, "version"), Digest = Text(root, "digest"), Seq = seq }, + At = Now(), + }; + available = _status.State == "available"; + } + + if (available && _auto && Interlocked.Exchange(ref _autoScheduled, 1) == 0) + { + _ = ApplyAfterMinimumUptimeAsync(); + } + } + catch (JsonException) + { + _log.Warn("ignoring an update offer that is not valid JSON"); + } + } + + /// 自动更新:等进程运行够久再退出(刚启动就发现新版本,通常是重启之后标签还没变,立刻再退出会变成重启循环)。 + private async Task ApplyAfterMinimumUptimeAsync() + { + TimeSpan wait = MinimumUptime - _time.GetElapsedTime(_startedAt); + if (wait > TimeSpan.Zero) + { + _log.Info("update available, restarting automatically later", ("in", wait.ToString("c", CultureInfo.InvariantCulture))); + await Task.Delay(wait, _time); + } + + RequestApply(); + } + + /// 面板请求更新:有新版本才退出(由外面的服务管理器拉起并拉取最新镜像)。 + public bool RequestApply() + { + lock (_gate) + { + if (_status.State != "available") + { + return false; + } + + _status = new UpdateStatus { State = "applying", Available = _status.Available, At = Now() }; + } + + _log.Info("update requested, restarting so the supervisor pulls the latest image"); + _requestRestart("update"); + return true; + } + + /// 随心跳上报的状态。 + public UpdateStatus Status() + { + lock (_gate) + { + return new UpdateStatus { State = _status.State, Current = _status.Current, Available = _status.Available, Error = _status.Error, At = _status.At }; + } + } + + private long Now() => _time.GetUtcNow().ToUnixTimeSeconds(); + + private static string Text(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : ""; +} diff --git a/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs b/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs deleted file mode 100644 index 236088c..0000000 --- a/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Text; -using System.Text.Json; -using Fleet.Core; -using MoShiro.Protocol; - -namespace MoShiro.Measurement; - -/// -/// 节点进程和宿主机更新器之间的文件交换(容器里 /etc/moshiro,也就是数据目录): -/// 节点把面板发来的清单和签名写进 update/,把「更新」请求写成 update/apply.request;更新器验签并更新,状态由宿主更新器写在只读挂载的 update-status/status.json。 -/// 节点进程不执行任何命令,也不信任清单的内容(验签在宿主机上)。没有安装更新器时目录写不了或没人读,调用只是返回 false。 -/// -public sealed class UpdateBridge -{ - private const int MaxManifestBytes = 4096; - private const int MaxSignatureBytes = 512; - private const int MaxStatusBytes = 4096; - - private readonly string _updateDirectory; - private readonly string _statusFile; - private readonly Action _write; - private readonly INodeLog _log; - - /// 写数据目录里的文件(相对路径,原子替换):由节点传入 ConfigStore.Write——常驻进程里只有它可以写文件。 - public UpdateBridge(string dataDirectory, Action write, INodeLog log) - { - _updateDirectory = Path.Combine(dataDirectory, "update"); - _statusFile = Path.Combine(dataDirectory, "update-status", "status.json"); - _write = write; - _log = log; - } - - /// 放下面板发来的清单和签名(和已有的一样就不动,免得每次重连都惊动更新器)。先写清单、后写签名:更新器监视的是签名文件。 - public bool WriteOffer(UpdateOfferParams offer) - { - byte[] manifest = Encoding.UTF8.GetBytes(offer.Manifest); - byte[] signature = Encoding.UTF8.GetBytes(offer.Signature); - if (manifest.Length is 0 or > MaxManifestBytes || signature.Length is 0 or > MaxSignatureBytes) - { - _log.Warn("ignoring an update offer of an unexpected size"); - return false; - } - - try - { - string manifestPath = Path.Combine(_updateDirectory, "offer.manifest"); - string signaturePath = Path.Combine(_updateDirectory, "offer.sig"); - if (Same(manifestPath, manifest) && Same(signaturePath, signature)) - { - return true; - } - - _write("update/offer.manifest", manifest); - _write("update/offer.sig", signature); - _log.Info("update offer received"); - return true; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - _log.Warn("could not store the update offer (is the updater installed?)", ("err", ex.Message)); - return false; - } - } - - /// 请求更新:写一个空的 apply.request(更新器验签通过、清单更新才会真的动手)。 - public bool RequestApply() - { - try - { - _write("update/apply.request", []); - _log.Info("update requested"); - return true; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - _log.Warn("could not request the update (is the updater installed?)", ("err", ex.Message)); - return false; - } - } - - /// 更新器的状态;没有安装更新器、文件不存在或内容不对时返回 null。 - public UpdateStatus? ReadStatus() - { - try - { - if (!File.Exists(_statusFile) || new FileInfo(_statusFile).Length > MaxStatusBytes) - { - return null; - } - - return JsonSerializer.Deserialize(File.ReadAllBytes(_statusFile), MoShiroJsonContext.Default.UpdateStatus); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) - { - return null; - } - } - - private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content); -} diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/MoShiro.Measurement.Tests.csproj b/backend/shared/tests/MoShiro.Measurement.Tests/MoShiro.Measurement.Tests.csproj index c8f64bd..ead6069 100644 --- a/backend/shared/tests/MoShiro.Measurement.Tests/MoShiro.Measurement.Tests.csproj +++ b/backend/shared/tests/MoShiro.Measurement.Tests/MoShiro.Measurement.Tests.csproj @@ -5,6 +5,7 @@ + diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/RestartUpdaterTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/RestartUpdaterTests.cs new file mode 100644 index 0000000..c9294d1 --- /dev/null +++ b/backend/shared/tests/MoShiro.Measurement.Tests/RestartUpdaterTests.cs @@ -0,0 +1,113 @@ +using Fleet.Core; +using Microsoft.Extensions.Time.Testing; +using MoShiro.Protocol; + +namespace MoShiro.Measurement.Tests; + +public sealed class RestartUpdaterTests +{ + private readonly List _restarts = []; + + private RestartUpdater Create(string tag = "dev-aaa", string commit = "aaa", long builtAt = 1_000) => + new("agent", _restarts.Add, TimeProvider.System, NullNodeLog.Instance, tag, commit, builtAt); + + private static UpdateOfferParams Offer(string tag, long seq, string component = "agent", string commit = "bbb") => + new() { Manifest = $"{{\"component\":\"{component}\",\"tag\":\"{tag}\",\"version\":\"0.4.0\",\"commit\":\"{commit}\",\"digest\":\"sha256:{new string('a', 64)}\",\"seq\":{seq}}}", Signature = "ignored" }; + + [Fact] + public void ANewerRelease_IsAvailable_AndOnlyARequestRestartsTheNode() + { + RestartUpdater updater = Create(); + updater.Offer(Offer("dev-bbb", 2_000)); + UpdateStatus status = updater.Status(); + Assert.Equal("available", status.State); + Assert.Equal("dev-bbb", status.Available!.Tag); + Assert.Equal(2_000, status.Available.Seq); + Assert.Empty(_restarts); + + Assert.True(updater.RequestApply()); + Assert.Equal(["update"], _restarts); + Assert.Equal("applying", updater.Status().State); + } + + [Fact] + public void TheReleaseThatIsAlreadyRunning_IsUpToDate_ByTagOrByCommit() + { + RestartUpdater updater = Create(); + updater.Offer(Offer("dev-aaa", 2_000)); + Assert.Equal("up_to_date", updater.Status().State); + updater.Offer(Offer("dev-other-tag", 2_000, commit: "aaa")); + Assert.Equal("up_to_date", updater.Status().State); + Assert.False(updater.RequestApply()); + Assert.Empty(_restarts); + } + + [Fact] + public void AnOfferOlderThanThisBuild_OrForAnotherComponent_IsNotAnUpdate() + { + RestartUpdater updater = Create(builtAt: 5_000); + updater.Offer(Offer("dev-bbb", 2_000)); + Assert.Equal("up_to_date", updater.Status().State); + updater.Offer(Offer("dev-ccc", 9_000, component: "probe")); + Assert.Equal("up_to_date", updater.Status().State); + Assert.False(updater.RequestApply()); + } + + [Fact] + public void GarbageOffers_AreIgnored() + { + RestartUpdater updater = Create(); + updater.Offer(new UpdateOfferParams { Manifest = "not json", Signature = "x" }); + updater.Offer(new UpdateOfferParams { Manifest = new string('x', 5000), Signature = "x" }); + updater.Offer(new UpdateOfferParams { Manifest = "{\"component\":\"agent\"}", Signature = "x" }); + Assert.Equal("up_to_date", updater.Status().State); + Assert.False(updater.RequestApply()); + } + + [Fact] + public async Task TheAutoPolicy_RestartsByItself_ButNotBeforeTheMinimumUptime() + { + var time = new FakeTimeProvider(DateTimeOffset.FromUnixTimeSeconds(2_000_000_000)); + var updater = new RestartUpdater("agent", _restarts.Add, time, NullNodeLog.Instance, "dev-aaa", "aaa", 1_000, auto: true); + updater.Offer(Offer("dev-bbb", 2_000)); + updater.Offer(Offer("dev-bbb", 2_000)); // 重连时面板会再发一次:不能排两个 + Assert.Empty(_restarts); + time.Advance(TimeSpan.FromMinutes(9)); + await Task.Delay(50); + Assert.Empty(_restarts); + time.Advance(TimeSpan.FromMinutes(2)); + await WaitAsync(() => _restarts.Count == 1); + Assert.Equal("applying", updater.Status().State); + await Task.Delay(50); + Assert.Single(_restarts); + } + + [Fact] + public async Task TheManualPolicy_NeverRestartsByItself() + { + var time = new FakeTimeProvider(DateTimeOffset.FromUnixTimeSeconds(2_000_000_000)); + var updater = new RestartUpdater("agent", _restarts.Add, time, NullNodeLog.Instance, "dev-aaa", "aaa", 1_000, auto: false); + updater.Offer(Offer("dev-bbb", 2_000)); + time.Advance(TimeSpan.FromHours(1)); + await Task.Delay(50); + Assert.Empty(_restarts); + Assert.Equal("available", updater.Status().State); + } + + private static async Task WaitAsync(Func condition) + { + for (int i = 0; i < 100 && !condition(); i++) + { + await Task.Delay(20); + } + + Assert.True(condition()); + } + + [Fact] + public void WithoutTheEnvironmentVariable_ThereIsNoUpdater() + { + Assert.Null(Environment.GetEnvironmentVariable(RestartUpdater.EnvironmentVariable)); + Assert.Null(RestartUpdater.FromEnvironment("agent", _restarts.Add, TimeProvider.System, NullNodeLog.Instance)); + } +} diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 5a0a58f..f618c85 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -143,7 +143,7 @@ public static class AgentEndpoints return Problems.From(http, ownerError); } - var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1), InstallScripts.ComposeSha256(request.Role ?? JoinTokenService.AgentRole)); + var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1), InstallScripts.ComposeSha256(request.Role ?? JoinTokenService.AgentRole), InstallScripts.QuadletSha256(request.Role ?? JoinTokenService.AgentRole)); http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}"); Result result = await tokens.CreateAsync( new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted); @@ -154,7 +154,7 @@ public static class AgentEndpoints http.Response.Headers.CacheControl = "no-store"; http.Response.Headers.Location = "/api/admin/join-tokens"; - return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows), new ComposeInstallDto(created.Compose.Env, created.Compose.Url, created.Compose.Sha256, created.Compose.Linux, created.Compose.Macos, created.Compose.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created); + return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows), new ComposeInstallDto(created.Compose.Env, created.Compose.Url, created.Compose.Sha256, created.Compose.Linux, created.Compose.Macos, created.Compose.Windows), new QuadletInstallDto(created.Quadlet.Env, created.Quadlet.Url, created.Quadlet.Sha256, created.Quadlet.Command)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created); } private static async Task RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index d9711e8..1818308 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -91,6 +91,12 @@ public sealed record InstallCommandsDto(string Linux, string Macos, string Windo /// Windows(Docker Desktop,PowerShell);Agent 同样是空的。 public sealed record ComposeInstallDto(string Env, string Url, string Sha256, string Linux, string Macos, string Windows); +/// 要存成 ~/.config/moshiro/<角色>.env 的内容(只有公开的值)。 +/// Quadlet 单元的下载地址。 +/// Quadlet 单元的 SHA-256:命令下载之后先核对它。 +/// Linux + Podman(rootless):写 env、下载并核对单元、指定镜像仓库、启动,合在一起的一条命令。 +public sealed record QuadletInstallDto(string Env, string Url, string Sha256, string Command); + /// 下一版里新增的 passkey。 public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt); @@ -108,7 +114,7 @@ public sealed record TrustProposalDto(string Statement, int Version, string Chal /// 和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。 public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList? OperatorDetails = null); -public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands, ComposeInstallDto Compose); +public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands, ComposeInstallDto Compose, QuadletInstallDto Quadlet); /// Agent 眼中连着的一个 Probe(Agent 上报)。 public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 468c6a7..54ae57c 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -87,6 +87,7 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(SetupStatusDto))] [JsonSerializable(typeof(SetupRequest))] [JsonSerializable(typeof(ComposeInstallDto))] +[JsonSerializable(typeof(QuadletInstallDto))] [JsonSerializable(typeof(ProposedCredentialDto))] [JsonSerializable(typeof(PasskeyDto))] [JsonSerializable(typeof(PageDto))] diff --git a/backend/src/MoShiro.Backend.Api/InstallScripts.cs b/backend/src/MoShiro.Backend.Api/InstallScripts.cs index f4a67bb..cbfe5a6 100644 --- a/backend/src/MoShiro.Backend.Api/InstallScripts.cs +++ b/backend/src/MoShiro.Backend.Api/InstallScripts.cs @@ -25,6 +25,12 @@ public static class InstallScripts /// 某个角色(agent、probe)的 compose 文件的 SHA-256;其他角色返回空。 public static string ComposeSha256(string? role) => role is "agent" or "probe" ? Sha256("compose-" + role + ".yaml") : string.Empty; + /// Quadlet 单元的资源名(agent.container、probe.container);不是这两个就是 null。 + public static string? QuadletResource(string file) => file is "agent.container" or "probe.container" ? "quadlet-" + file : null; + + /// 某个角色(agent、probe)的 Quadlet 单元的 SHA-256;其他角色返回空。 + public static string QuadletSha256(string? role) => role is "agent" or "probe" ? Sha256("quadlet-" + role + ".container") : string.Empty; + /// 脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。 public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource)))); diff --git a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj index 99b7951..d39d74d 100644 --- a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj +++ b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj @@ -13,6 +13,8 @@ + + diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index 734b669..b865292 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -38,6 +38,10 @@ public static class OpsEndpoints app.MapGet("/install/compose/{file}", (HttpContext http, string file) => InstallScripts.ComposeResource(file) is { } resource ? Static(http, resource, "text/yaml") : Problems.Create(http, MoShiroErrors.NotFound, "unknown compose file")) .Api("install_compose", "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/yaml").ProducesProblems(404); + // Podman Quadlet 单元(Linux 上推荐,节点的更新靠 Pull=always):Agent、Probe 各一份,静态,命令里先核对 SHA-256。 + app.MapGet("/install/quadlet/{file}", (HttpContext http, string file) => InstallScripts.QuadletResource(file) is { } resource ? Static(http, resource, "text/plain") : Problems.Create(http, MoShiroErrors.NotFound, "unknown quadlet file")) + .Api("install_quadlet", "The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain")) .Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain"); diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index b7d7f94..840a6ee 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -15,7 +15,8 @@ public sealed record InstallCommands(string Linux, string Macos, string Windows) /// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 Genesis 为空,节点不固定信任根。 /// /// 面板提供的这个角色的 compose 文件的 SHA-256(命令下载之后先核对)。 -public sealed record InstallPlan(string? Genesis, IReadOnlyList Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = ""); +/// 面板提供的这个角色的 Podman Quadlet 单元的 SHA-256。 +public sealed record InstallPlan(string? Genesis, IReadOnlyList Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "", string QuadletSha256 = ""); /// /// compose 安装:Env 是要存成 .env 的内容(只有公开的值),Url 和 Sha256 是 compose 文件的下载地址和哈希, @@ -23,7 +24,13 @@ public sealed record InstallPlan(string? Genesis, IReadOnlyList Controll /// public sealed record ComposeInstall(string Env, string Url, string Sha256, string Linux, string Macos, string Windows); -public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands, ComposeInstall Compose); +/// +/// Podman Quadlet 安装(Linux 上推荐):Env 存成 ~/.config/moshiro/<角色>.env,Url 和 Sha256 是 Quadlet 单元的下载地址和哈希, +/// Command 把 env、单元、镜像仓库(drop-in)、核对哈希和启动合在一起。单元写了 Pull=always,节点退出再被拉起就是更新,默认自动更新。 +/// +public sealed record QuadletInstall(string Env, string Url, string Sha256, string Command); + +public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands, ComposeInstall Compose, QuadletInstall Quadlet); /// 加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。 public sealed class JoinTokenService @@ -86,7 +93,7 @@ public sealed class JoinTokenService Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role), request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct); - return Result.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Compose(BaseUrl(requestBaseUrl), request.Role, plaintext, plan))); + return Result.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Compose(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Quadlet(BaseUrl(requestBaseUrl), request.Role, plaintext, plan))); } /// @@ -150,6 +157,35 @@ public sealed class JoinTokenService return new ComposeInstall(Env(false), url, plan.ComposeSha256, linux, macos, windows); } + /// + /// Podman Quadlet 安装:env 文件、单元文件(静态,核对哈希)、一个把镜像仓库指向面板配置的 drop-in,然后启动用户服务。 + /// 自动更新默认开(MOSHIRO_UPDATE_POLICY 缺省是 auto):env 里留了一行注释,去掉 # 就改成在管理端手动点更新。 + /// + public QuadletInstall Quadlet(string baseUrl, string role, string code, InstallPlan plan) + { + string url = $"{baseUrl}/install/quadlet/{role}.container"; + var lines = new List { $"MOSHIRO_SERVER={baseUrl}", $"MOSHIRO_JOIN={code}" }; + if (plan.Genesis is null) + { + lines.Add("MOSHIRO_ALLOW_NO_OWNER=1"); + } + else + { + lines.Add($"MOSHIRO_GENESIS={plan.Genesis}"); + lines.Add($"MOSHIRO_CONTROLLERS={string.Join(',', plan.Controllers)}"); + } + + lines.Add("# 自动更新默认开:面板发现新版本后,节点运行满 10 分钟就自动重启,Podman 启动时拉最新镜像。想改成在管理端手动点「更新」,去掉下一行的 #"); + lines.Add("# MOSHIRO_UPDATE_POLICY=manual"); + string env = string.Join('\n', lines); + string unit = $"$HOME/.config/containers/systemd/moshiro-{role}.container"; + string command = $"mkdir -p ~/.config/moshiro ~/.config/containers/systemd/moshiro-{role}.container.d && cat > ~/.config/moshiro/{role}.env <<'EOF'\n{env}\nEOF\n" + + $"chmod 600 ~/.config/moshiro/{role}.env && curl -fsSL '{url}' -o \"{unit}\" && echo \"{plan.QuadletSha256} {unit}\" | sha256sum -c - " + + $"&& printf '[Container]\\nImage={ImageRepository(role)}:latest\\n' > ~/.config/containers/systemd/moshiro-{role}.container.d/10-image.conf " + + $"&& systemctl --user daemon-reload && systemctl --user start moshiro-{role}"; + return new QuadletInstall(env, url, plan.QuadletSha256, command); + } + /// 这个角色的镜像仓库地址(不带 tag):节点镜像仓库加上命名空间。 private string ImageRepository(string role) { diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index 0f506a8..71e85b4 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -94,6 +94,53 @@ public sealed class InstallTests } } + [Theory] + [InlineData("agent")] + [InlineData("probe")] + public async Task QuadletInstall_ChecksTheUnitHash_PointsTheImageAtTheConfiguredRepository_AndLeavesAutoUpdateOn(string role) + { + JsonNode created = await CreateAsync(role); + JsonNode quadlet = created["quadlet"]!; + string env = quadlet["env"]!.GetValue(); + string command = quadlet["command"]!.GetValue(); + string url = quadlet["url"]!.GetValue(); + + Assert.EndsWith($"/install/quadlet/{role}.container", url, StringComparison.Ordinal); + Assert.Contains($"MOSHIRO_JOIN={created["plaintext"]!.GetValue()}", env, StringComparison.Ordinal); + Assert.Contains("# MOSHIRO_UPDATE_POLICY=manual", env, StringComparison.Ordinal); // 默认自动更新,env 里只留一行注释说明怎么改成手动 + Assert.DoesNotContain("\nMOSHIRO_UPDATE_POLICY=", env, StringComparison.Ordinal); + Assert.Contains($"~/.config/moshiro/{role}.env", command, StringComparison.Ordinal); + Assert.Contains("chmod 600", command, StringComparison.Ordinal); + Assert.Contains($"echo \"{quadlet["sha256"]!.GetValue()} $HOME/.config/containers/systemd/moshiro-{role}.container\" | sha256sum -c -", command, StringComparison.Ordinal); + Assert.Contains($"Image=", command, StringComparison.Ordinal); + Assert.Contains($"/moshiro-{role}:latest", command, StringComparison.Ordinal); + Assert.EndsWith($"systemctl --user daemon-reload && systemctl --user start moshiro-{role}", command, StringComparison.Ordinal); + + // 面板提供的就是命令里核对的那份:Pull=always(退出再被拉起就是更新),并告诉节点外面会重新拉取 + using HttpResponseMessage served = await _backend.Anonymous.GetAsync($"/install/quadlet/{role}.container"); + Assert.Equal(HttpStatusCode.OK, served.StatusCode); + byte[] body = await served.Content.ReadAsByteArrayAsync(); + Assert.Equal(quadlet["sha256"]!.GetValue(), Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(body))); + string unit = System.Text.Encoding.UTF8.GetString(body); + Assert.Contains("\nPull=always", unit, StringComparison.Ordinal); + Assert.Contains("Environment=MOSHIRO_UPDATE=restart", unit, StringComparison.Ordinal); + Assert.Contains("ReadOnly=true", unit, StringComparison.Ordinal); + Assert.Contains("DropCapability=ALL", unit, StringComparison.Ordinal); + Assert.Contains("NoNewPrivileges=true", unit, StringComparison.Ordinal); + Assert.DoesNotContain("jt_", unit, StringComparison.Ordinal); + } + + [Theory] + [InlineData("evil.container")] + [InlineData("backend.container")] + [InlineData("agent")] + public async Task OnlyTheTwoQuadletUnits_AreServed(string file) + { + using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install/quadlet/" + file); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + [Theory] [InlineData("agent.yaml")] [InlineData("probe.yaml")] diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs index b5303e7..881a182 100644 --- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs @@ -53,7 +53,7 @@ public sealed partial class OpenApiConformanceTests foreach ((string path, _) in doc["paths"]!.AsObject()) { string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"]; - string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/install/compose/{file}", "/api/release/offer"]; + string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/install/compose/{file}", "/install/quadlet/{file}", "/api/release/offer"]; Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers"); foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) { diff --git a/ci/publish-gitea-release.sh b/ci/publish-gitea-release.sh index 9929a29..6b95bfe 100644 --- a/ci/publish-gitea-release.sh +++ b/ci/publish-gitea-release.sh @@ -129,12 +129,16 @@ upload_local_asset() { echo "attached $name" } -# compose 文件(Agent、Probe、Backend 各一份,静态,镜像用 :latest 或 .env 里的 MOSHIRO_VERSION):作为发布资产, +# compose 文件(Agent、Probe、Backend 各一份)和节点的 Podman Quadlet 单元(Agent、Probe 各一份),都是静态的(镜像用 :latest):作为发布资产, # 第一次部署 Backend 时还没有面板,就从这里下载;它们也记进 SHA256SUMS(有签名密钥时一起签名)。 for role in agent probe backend; do cp "$root_dir/packaging/compose/$role/compose.yaml" "$tmp/moshiro-compose-$role.yaml" upload_local_asset "moshiro-compose-$role.yaml" "$tmp/moshiro-compose-$role.yaml" done +for role in agent probe; do + cp "$root_dir/packaging/quadlet/moshiro-$role.container" "$tmp/moshiro-quadlet-$role.container" + upload_local_asset "moshiro-quadlet-$role.container" "$tmp/moshiro-quadlet-$role.container" +done if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then sums="$tmp/SHA256SUMS" @@ -145,9 +149,8 @@ if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then cat "$tmp/$archive.sha256" >> "$sums" done [[ "$(wc -l < "$sums")" -eq "${#assets[@]}" ]] || { echo "SHA256SUMS does not have one line per archive" >&2; exit 1; } - for role in agent probe backend; do - printf '%s %s -' "$(sha256sum "$tmp/moshiro-compose-$role.yaml" | cut -d' ' -f1)" "moshiro-compose-$role.yaml" >> "$sums" + for file in moshiro-compose-agent.yaml moshiro-compose-probe.yaml moshiro-compose-backend.yaml moshiro-quadlet-agent.container moshiro-quadlet-probe.container; do + printf '%s %s\n' "$(sha256sum "$tmp/$file" | cut -d' ' -f1)" "$file" >> "$sums" done signing_key="$(mktemp)" chmod 600 "$signing_key" diff --git a/docs/dev/project-layout.md b/docs/dev/project-layout.md index 6c1c386..46b61e9 100644 --- a/docs/dev/project-layout.md +++ b/docs/dev/project-layout.md @@ -144,7 +144,7 @@ AgentApp Probe(ProbeApp) PeerListener / PeerHub(Probe 会话) 会话内:线路任务集 line_task_set 的调和、角色、列块发件箱(通道 17) UdpMux / TcpAcceptor(数据端口) TaskManager / TestRunner / ChunkAssembler - UpdateBridge(和宿主机更新器交换文件) UpdateBridge + RestartUpdater(只报告有无新版本、请求时退出) RestartUpdater ``` 模块之间通过构造函数传入的接口或 `Channel` 通信,禁止通过静态全局状态共享数据。 diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md index 0817918..7601cae 100644 --- a/docs/ops/deployment.md +++ b/docs/ops/deployment.md @@ -219,11 +219,32 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 这次试跑发现并修掉的问题:`hy2_ladder` 的阶梯按「块」而不是按「阶段」计算,真实的 Probe 把一个阶段切成几块,4 个阶段出了 7 步(模拟数据不切块,所以之前的测试没有发现);现在同一阶段的块先合并。 -## 容器部署(Docker / Podman 共用命名 volume) +## 容器部署(Podman Quadlet 优先,Docker 用 compose) -镜像和 Compose 配置共用。普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `moshiro-agent-config`,Probe 保存在 `moshiro-probe-config`,更新状态使用独立的 `moshiro--status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要使用 `compose down -v`,该参数会删除数据。 +普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `moshiro-agent-config`,Probe 保存在 `moshiro-probe-config`,更新状态使用独立的 `moshiro--status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要删除这些 volume(`podman volume rm`),会丢身份和配置。 -**compose 安装(Agent 和 Probe,推荐)**:管理端「添加 Agent」「添加 Probe」生成加入令牌之后,给出一段命令(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):建一个目录,把参数写进 `.env`(权限 600),下载面板提供的静态 compose 文件、核对 SHA-256,一致才启动: +**Quadlet 安装(Agent 和 Probe,Podman 系优先,Linux + Podman 4.6 及以上)**:管理端「添加 Agent」「添加 Probe」生成加入令牌之后,第一条命令就是它(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):写 env(权限 600)、从面板下载静态的 Quadlet 单元并核对 SHA-256、用一个 drop-in 把镜像仓库指向面板配置的、启动用户服务: + +```sh +mkdir -p ~/.config/moshiro ~/.config/containers/systemd/moshiro-probe.container.d && cat > ~/.config/moshiro/probe.env <<'EOF' +MOSHIRO_SERVER=https://panel.example.com +MOSHIRO_JOIN=jt_… +MOSHIRO_GENESIS=… +MOSHIRO_CONTROLLERS=… +# MOSHIRO_UPDATE_POLICY=manual +EOF +chmod 600 ~/.config/moshiro/probe.env && curl -fsSL 'https://panel.example.com/install/quadlet/probe.container' -o "$HOME/.config/containers/systemd/moshiro-probe.container" && echo " $HOME/.config/containers/systemd/moshiro-probe.container" | sha256sum -c - && printf '[Container]\nImage=registry.oeo.one/moshiro/moshiro-probe:latest\n' > ~/.config/containers/systemd/moshiro-probe.container.d/10-image.conf && systemctl --user daemon-reload && systemctl --user start moshiro-probe +``` + +- 单元是静态的(`packaging/quadlet/moshiro-agent.container`、`moshiro-probe.container`,作为嵌入资源打进 Backend,由 `/install/quadlet/{agent,probe}.container` 原样提供,不含任何一次性的值)。镜像仓库不写死在单元里,由命令里的 drop-in 指定,来自面板的 `MoShiro__NodeImageRegistry` 和 `MoShiro__ImageNamespace`(默认 `registry.oeo.one/moshiro`,开发镜像 `moshiro-dev`)。 +- 只读根文件系统、丢掉全部能力、`no-new-privileges`、命名卷;用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址),并只读挂载 `/proc`、`/sys`、`/` 读主机指标。 +- 身份和配置在命名卷 `moshiro--config` 里:镜像入口脚本(`packaging/container/entrypoint.sh`)第一次启动用 `MOSHIRO_SERVER`、`MOSHIRO_JOIN`(和信任根指纹、属主)注册并写配置,之后重启、换新镜像重建容器都不需要再给参数,所以注册成功之后可以把 env 里的 `MOSHIRO_JOIN` 删掉。缺了信任根指纹或属主就不注册(只有还没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`)。 +- 开机自启:单元的 `[Install]` 已经写了,退出登录后继续运行需要管理员执行一次 `loginctl enable-linger <用户>`。 +- **更新**:见下一节,默认自动。想固定版本,把 drop-in 里的 `:latest` 换成版本号。回滚就是改回旧版本再 `systemctl --user restart moshiro-`。 +- **信任**:和之前一样信任镜像仓库(Podman 校验它的 TLS 证书),面板被攻破也改不了镜像地址,因为单元和命令都有哈希;节点不验发布签名。 +- 没有 Podman 4.6 或不是 Linux:用下面的 compose(Docker)、一行安装脚本或发布的二进制。 + +**compose 安装(Agent 和 Probe,Docker;Podman 系请优先用上面的 Quadlet)**:管理端「添加 Agent」「添加 Probe」生成加入令牌之后,给出一段命令(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):建一个目录,把参数写进 `.env`(权限 600),下载面板提供的静态 compose 文件、核对 SHA-256,一致才启动: ```sh mkdir -p moshiro-probe && cd moshiro-probe && cat > .env <<'EOF' @@ -241,7 +262,7 @@ chmod 600 .env && curl -fsSL 'https://panel.example.com/install/compose/probe.ya - 身份和配置在命名卷 `moshiro--config` 里:镜像入口脚本(`packaging/container/entrypoint.sh`)第一次启动用 `MOSHIRO_SERVER`、`MOSHIRO_JOIN`(和信任根指纹、属主)注册并写配置,之后重启、换新镜像重建容器都不需要再给参数,所以注册成功之后可以把 `.env` 里的 `MOSHIRO_JOIN` 删掉。缺了信任根指纹或属主就不注册(只有还没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`)。 - Podman 的开机自启要靠 `podman-restart.service`(`systemctl enable --now podman-restart`;rootless 用 `--user`,并 `loginctl enable-linger`);Docker 用 compose 里的 `restart: unless-stopped`。 - **更新**:`podman compose pull && podman compose up -d`(Docker 用 `docker compose`),只重建镜像变了的容器,身份和配置不动,节点几十秒内重连。想固定版本,在 `.env` 里写 `MOSHIRO_VERSION=0.3.3`;想要更严,写成 `MOSHIRO_VERSION=0.3.3@sha256:`,digest 取自面板信任页或签名清单(那里有发布签名)。回滚就是改回旧版本再 `up -d`。 -- **信任**:compose 来自面板或 CI 渠道,面板被攻破也改不了镜像地址;但客户端不再验发布签名,拉取靠 TLS 加「注册表可信」这个假设(文档里本来就把 CI、Gitea、注册表放在可信范围内)。这条路没有管理端的「更新」列和确认更新按钮,那些依赖宿主机更新器;要有签名验证的更新,用下面的部署脚本。 +- **信任**:compose 来自面板或 CI 渠道,面板被攻破也改不了镜像地址;但客户端不再验发布签名,拉取靠 TLS 加「注册表可信」这个假设(文档里本来就把 CI、Gitea、注册表放在可信范围内)。compose 不会自动更新(见下节),也没有管理端的「更新」列和确认更新按钮。 **一行安装脚本(备用,Docker 或 Podman,Agent 和 Probe 共用)**:管理端的弹窗里折叠着一条旧的一行命令,先核对脚本哈希再执行,脚本向面板取签名清单、用内置发布公钥验签、按 digest 拉镜像: @@ -253,7 +274,7 @@ f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo "< 脚本是静态的(`packaging/install/install.sh` 和 `install.ps1`,嵌入 Backend,不替换任何值),所以它的 SHA-256 在一个版本内固定;角色、面板地址、代码和属主走环境变量。环境变量 `MOSHIRO_NAME`、`MOSHIRO_ENGINE=docker|podman`、`MOSHIRO_DRY_RUN=1`(只打印 run 命令);Podman 会顺便启用 `podman-restart`。 -**用脚本部署和更新(rootful Podman 或 Docker,推荐)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。 +**用脚本部署和更新(Backend;Agent 和 Probe 请用 Quadlet)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。 ```sh # 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起) @@ -270,13 +291,13 @@ sudo sh deploy-node.sh backend --channel dev --public-url https://<面板域名> sudo sh deploy-node.sh agent ``` -Agent 的 `public_host` 和 `ipv6_host` 由 Agent 上报自动识别(需要宿主网络,脚本和 Compose 默认如此,监听 `::`),IPv6 不用额外配置。 +Agent 的 `public_host` 和 `ipv6_host` 由 Agent 上报自动识别(需要宿主网络,Quadlet 单元和一行安装脚本默认如此,监听 `::`),IPv6 不用额外配置。 -纯 Compose 使用 `compose pull && compose up -d` 更新镜像;面板自动更新使用下述 systemd 单元和更新器。Rootless Podman 使用 `moshiro--rootless.service`,安装到 `~/.config/systemd/user/`,镜像文件为 `~/.config/moshiro/.image`;rootful Podman 使用 `moshiro--container.service`,Docker 使用 `moshiro--docker.service`,安装到 `/etc/systemd/system/`,镜像文件为 `/etc/moshiro-update/.image`。所有单元均使用相同的配置和状态 volume 名称。自动启动分别使用 `systemctl --user enable --now ` 或 `sudo systemctl enable --now `。Rootless 在退出登录后继续运行需管理员执行一次 `loginctl enable-linger <用户名>`。 +更新见「Podman(Quadlet)节点的更新」:节点退出再被拉起就是更新,默认自动。 Backend 用 `packaging/compose/backend/compose.yaml`(见上文「Backend 容器」),数据库和原始数据在命名卷 `moshiro-backend-data` 里。首次安装 Podman 与用户 subuid/subgid 映射需要管理员权限,之后普通用户运行无需 sudo。 -已在 Fedora CoreOS 的 SELinux Enforcing 环境、Podman 6.0.2 rootless 下用同一份 Compose 实测:Agent 注册、宿主指标、Probe 登录和 TLS 连接正常,IPv4/IPv6 ICMP 目标任务收到有效回包,容器保持 `cap_drop: ALL`,未添加 NET_RAW。Linux ICMP 优先使用 datagram ping socket,不依赖外部 ping 程序;需要 `net.ipv4.ping_group_range` 包含容器映射后的 GID(测试环境为 `0 2147483647`)。宿主禁止普通用户 ping 时,需管理员配置该范围;例如 rootful Docker 的用户 GID 1654 可设置 `sysctl -w "net.ipv4.ping_group_range=1654 1654"`。rootless 应使用映射后的宿主 GID,不能直接套用容器 GID。单加 `cap_add: NET_RAW` 不保证非 root 用户获得有效 capability。有原始套接字权限的部署仍保留原有 Ping 回退路径。其他操作系统继续使用 System.Net.Ping。ICMP 目标任务当前由 Agent 执行,Probe 没有独立目标任务入口。 +已在 Fedora CoreOS 的 SELinux Enforcing 环境、Podman 6.0.2 rootless 下用同一套加固设置实测:Agent 注册、宿主指标、Probe 登录和 TLS 连接正常,IPv4/IPv6 ICMP 目标任务收到有效回包,容器保持 `cap_drop: ALL`,未添加 NET_RAW。Linux ICMP 优先使用 datagram ping socket,不依赖外部 ping 程序;需要 `net.ipv4.ping_group_range` 包含容器映射后的 GID(测试环境为 `0 2147483647`)。宿主禁止普通用户 ping 时,需管理员配置该范围;例如 rootful Docker 的用户 GID 1654 可设置 `sysctl -w "net.ipv4.ping_group_range=1654 1654"`。rootless 应使用映射后的宿主 GID,不能直接套用容器 GID。单加 `cap_add: NET_RAW` 不保证非 root 用户获得有效 capability。有原始套接字权限的部署仍保留原有 Ping 回退路径。其他操作系统继续使用 System.Net.Ping。ICMP 目标任务当前由 Agent 执行,Probe 没有独立目标任务入口。 ### 定时重启 @@ -289,7 +310,7 @@ Agent 和 Probe 每天固定时刻有序退出一次,由服务管理器拉起 ### Agent 的系统指标挂载 -Agent 的 Compose 模板已经包含以下只读挂载和环境变量,systemd 示例也采用相同配置: +Agent 的 Quadlet 单元已经包含以下只读挂载和环境变量: | 宿主来源 | 容器路径 | 配置 | 指标 | | --- | --- | --- | --- | @@ -302,9 +323,30 @@ Agent 的 Compose 模板已经包含以下只读挂载和环境变量,systemd 磁盘指标是根文件系统容量,不是所有硬盘合计,也不采集磁盘 I/O。链路速率是参与流量统计的接口速率合计,单位 Mbit/s,不是互联网测速结果;任一接口不支持读取速率时显示空值。接口仍按名称排除回环、容器、网桥和部分隧道,复杂 bonding/VLAN 配置可能存在重复计数。主机名、硬件型号、MAC/IP 不作为资源指标上报。 -Probe 也上报同样的汇总数字(`probe.json` 的 `reporting.host_stats` 默认开,设为 `false` 就一个字也不上报,同时可以去掉 `/proc`、`/sys`、`/` 这三个挂载),所以 Probe 的 compose 和 Agent 一样挂载它们并设置 `HOST_*` 环境变量。Docker Desktop(macOS、Windows)上读到的是 Docker 的虚拟机,不是宿主系统本身;Windows 上直接运行的 Probe 和 Agent 用系统调用读 CPU、内存和物理网卡的流量(不读网卡名和地址);macOS 目前只有磁盘和运行时长。 +Probe 也上报同样的汇总数字(`probe.json` 的 `reporting.host_stats` 默认开,设为 `false` 就一个字也不上报,同时可以去掉 `/proc`、`/sys`、`/` 这三个挂载),所以 Probe 的 Quadlet 单元和 Agent 一样挂载它们并设置 `HOST_*` 环境变量。Docker Desktop(macOS、Windows)上读到的是 Docker 的虚拟机,不是宿主系统本身;Windows 上直接运行的 Probe 和 Agent 用系统调用读 CPU、内存和物理网卡的流量(不读网卡名和地址);macOS 目前只有磁盘和运行时长。 -## 容器节点的更新(Agent、Probe) +## Podman(Quadlet)节点的更新(Agent、Probe,推荐) + +节点跑成一个 Podman Quadlet 单元(`packaging/quadlet/moshiro-agent.container`、`moshiro-probe.container`,Podman 4.6 及以上),更新是**销毁容器、重新拉最新镜像再启动**:节点自己不下载、不替换任何东西,只是有序退出;Quadlet 生成的 systemd 服务用 `podman run --pull always` 启动,所以被拉起时先从镜像仓库拉最新的镜像。**默认自动更新**:面板发现新版本后,节点运行满 10 分钟(防止重启循环)就自己退出重启;想改成在管理端点「更新」才更新,在 env 文件里写 `MOSHIRO_UPDATE_POLICY=manual`。 + +```text +CI 推镜像到 registry ─▶ 面板收到 CI 签名的清单 ─▶ 转发给节点(只是「建议」) +节点:比对清单和自己的版本 ─▶ 心跳报「有新版本」─▶ 管理端显示 +自动(默认):节点运行满 10 分钟 ─▶ 有序退出;手动(MOSHIRO_UPDATE_POLICY=manual):管理员点「更新」─▶ 面板请求 ─▶ 节点有序退出 +然后:systemd 重启单元(Pull=always)─▶ 新镜像、新容器 +``` + +- **安装**(rootless,普通用户):管理端「添加 Agent / Probe」的第一条命令(Linux + Podman)就是它:env 写进 `~/.config/moshiro/.env`(权限 600),从面板的 `/install/quadlet/.container` 下载单元并核对 SHA-256,用一个 drop-in 把镜像仓库指向面板配置的(开发通道是 `moshiro-dev`),`systemctl --user start`。再让管理员执行一次 `loginctl enable-linger <用户>`。rootful 的路径和改法写在模板开头的注释里。身份和配置仍在命名卷 `moshiro--config`,换新镜像不丢。 +- **compose 不能自动更新**:compose 的 `restart: unless-stopped` 是引擎直接重启同一个容器,不会拉新镜像(`pull_policy: always` 只在 `compose up` 时生效),容器自己又没法重建自己,所以 compose(Docker)仍是手动 `compose pull && compose up -d`;要自动更新就用 Quadlet。 +- **信任**:信任镜像仓库(`registry.oeo.one`,Podman 校验它的 TLS 证书),并按标签拉取(默认 `latest`,想固定版本就把模板里的标签换成版本号)。节点不验证清单:它不安装任何东西,最多被管理员请求一次重启,和定时重启是同一种能力。 +- **只有设了 `MOSHIRO_UPDATE=restart` 的节点才报告更新状态**(模板里写好了):别的部署方式退出之后不会重新拉取,开了也不会更新。 +- **别的自动更新方式**:另外启用 `podman-auto-update.timer` 并在模板的 `[Container]` 里加 `AutoUpdate=registry`,Podman 按计划比对镜像 digest 并重启,不依赖面板。 +- **回滚**:新镜像起不来时 systemd 一直重试;把模板里的标签改回旧版本再 `systemctl --user restart` 即可。 +- 测试:`packaging/quadlet` 里的模板用 `/usr/libexec/podman/quadlet -dryrun -user` 检查过(Podman 5.4);节点一侧的逻辑见 `RestartUpdaterTests`。 + +## 宿主机更新器(Backend、一行脚本装的节点) + +节点的更新走上面的 Quadlet。下面的宿主机更新器现在只服务 Backend 和一行安装脚本装的节点,Backend 改成 Quadlet 之后会一并移除。 更新器支持 rootless Podman 用户服务、rootful Podman 和 Docker 系统服务。配置、身份和更新请求通过命名 volume 传递,不需要指定宿主数据目录。短时辅助容器读取请求、写入独立状态 volume;辅助镜像必须已在本机拉取,更新器不会自动拉取辅助镜像。配置、公钥和已安装版本记录留在容器外。 diff --git a/docs/security/access.md b/docs/security/access.md index 8ce03af..f930319 100644 --- a/docs/security/access.md +++ b/docs/security/access.md @@ -175,7 +175,7 @@ macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就 - 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用**脚本里内置的发布公钥**验签,再按清单里的 digest 拉镜像(`repo@sha256:…`)。Backend 改不了 digest,最多让节点装到一个旧的合法版本。 - 下载压缩包安装的人,用 Release 里签名的 `SHA256SUMS` 核对(见 [deployment.md](../ops/deployment.md))。 -**③ compose 安装(默认)** +**③ 容器安装:Podman 系用 Quadlet(默认),Docker 用 compose** - 管理端的容器安装是:建目录,把参数写进本地 `.env`(面板地址、一次性码、创世指纹、属主,都是公开的值),下载面板提供的静态 compose 文件、核对 SHA-256,`compose up -d`。compose 文件是静态的(和安装脚本一样嵌在 Backend 里,也作为 Release 资产发布),镜像是 `:latest` 或 `.env` 里固定的版本,**镜像地址由 CI 渠道决定,不由面板决定**,面板被攻破也改不了;Backend 第一次部署时没有面板,就从 Release 资产下载。 - 代价:客户端不再验发布签名,更新(`compose pull && compose up -d`)靠 TLS 加「注册表可信」这个假设;想要更严,在 `.env` 里把 `MOSHIRO_VERSION` 固定到 digest,或者用宿主机更新器(清单有发布签名,不健康自动回滚)。详见 [deployment.md](../ops/deployment.md)。 diff --git a/docs/security/enrollment.md b/docs/security/enrollment.md index 9ce8dba..599a854 100644 --- a/docs/security/enrollment.md +++ b/docs/security/enrollment.md @@ -16,11 +16,11 @@ ```text 管理员 ── 创建加入令牌 {标签, max_uses, 过期时间, auto_approve, controllers} ──> 面板(Fleet.Server.Enrollment) - <── 一段 compose 安装命令(默认):建目录、把面板地址、一次性码、创世指纹和属主写进本地 .env,下载面板提供的静态 - compose.yaml 并核对 SHA-256,再 `compose up -d`;命令里只有公开的值(见 access.md 第 10 节)。 + <── 一段 Quadlet 安装命令(默认,Podman 系;Docker 另有 compose 命令):把面板地址、一次性码、创世指纹和属主写进本地 env,下载面板提供的静态 + Quadlet 单元并核对 SHA-256,再启动用户服务;命令里只有公开的值(见 access.md 第 10 节)。 备用:一行安装脚本(先核对脚本哈希再执行),参数走环境变量 -两条路径做的事一样:compose 的入口脚本,或 install.sh / install.ps1(packaging/install/,嵌入 Backend,静态文件,不替换任何值): +两条路径做的事一样:容器的入口脚本,或 install.sh / install.ps1(packaging/install/,嵌入 Backend,静态文件,不替换任何值): ├─ 向面板取 CI 签名的发布清单,用脚本里内置的发布公钥验签,按清单里的 digest 拉镜像 ├─ 用 Docker 或 Podman 起容器,身份和配置存在命名卷里;节点名字默认取主机名 └─ 容器入口用代码注册:fleet-enroll --server … --join jt_… --genesis … --controllers … diff --git a/packaging/container/entrypoint.sh b/packaging/container/entrypoint.sh index 08de46f..934dce6 100644 --- a/packaging/container/entrypoint.sh +++ b/packaging/container/entrypoint.sh @@ -4,7 +4,7 @@ set -eu role="${MOSHIRO_ROLE:?MOSHIRO_ROLE is set by the image}" -# compose 默认用宿主网络;Docker Desktop(没有宿主网络)的 .env 里写 MOSHIRO_NETWORK_SCOPE=container +# Quadlet 单元和 compose 默认用宿主网络;Docker Desktop(没有宿主网络)的 .env 里写 MOSHIRO_NETWORK_SCOPE=container export MOSHIRO_NETWORK_SCOPE="${MOSHIRO_NETWORK_SCOPE:-host}" dir=/etc/moshiro identity="$dir/$role-id" diff --git a/packaging/quadlet/moshiro-agent.container b/packaging/quadlet/moshiro-agent.container new file mode 100644 index 0000000..f6cee5e --- /dev/null +++ b/packaging/quadlet/moshiro-agent.container @@ -0,0 +1,54 @@ +# MoShiro Agent 的 Podman Quadlet 单元(Podman 4.6 及以上)。节点的更新也靠它:systemd 每次启动这个单元时 Pull=always 先从镜像仓库拉最新的镜像, +# 所以节点退出再被拉起就是更新(节点自己不下载、不替换任何东西)。默认自动更新:面板发现新版本后,节点运行满 10 分钟就自己有序退出; +# 想改成在管理端点「更新」才更新,在 env 文件里写 MOSHIRO_UPDATE_POLICY=manual。 +# +# 安装(rootless,普通用户,不用 sudo): +# mkdir -p ~/.config/containers/systemd ~/.config/moshiro +# 把面板「添加 Agent」给的 .env 内容存成 ~/.config/moshiro/agent.env,chmod 600 +# 把本文件放进 ~/.config/containers/systemd/ +# systemctl --user daemon-reload && systemctl --user start moshiro-agent # Quadlet 会自动生成服务,不用 enable;[Install] 让它开机自启 +# loginctl enable-linger $USER # 退出登录后继续运行(需要管理员执行一次) +# rootful:本文件放进 /etc/containers/systemd/,.env 内容存成 /etc/moshiro/agent.env,把下面的 EnvironmentFile 改成这个路径、 +# WantedBy 改成 multi-user.target,用 systemctl(不加 --user)。 +# 开发通道:把 Image 的 moshiro 换成 moshiro-dev。固定版本:把 :latest 换成 :0.3.3(固定之后更新不会换版本)。 +# 想要完全自动、不用在管理端点:另外启用 podman-auto-update.timer,并在 [Container] 里加 AutoUpdate=registry(Pull=always 也保留)。 +# 说明见 docs/ops/deployment.md。 +[Unit] +Description=MoShiro Agent +After=network-online.target +Wants=network-online.target + +[Container] +Image=registry.oeo.one/moshiro/moshiro-agent:latest +ContainerName=moshiro-agent +Pull=always +Network=host +RunInit=true +ReadOnly=true +NoNewPrivileges=true +DropCapability=ALL +SecurityLabelDisable=true +Tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 +StopTimeout=20 +Volume=moshiro-agent-config:/etc/moshiro +Volume=/etc/os-release:/etc/os-release:ro +# 主机资源占用(只有汇总数字):只读挂载宿主机的 /proc、/sys 和根目录;不想上报就删掉这三行和下面三个 HOST_ 变量 +Volume=/proc:/host/proc:ro +Volume=/sys:/host/sys:ro +Volume=/:/host/root:ro +Environment=HOST_PROC=/host/proc +Environment=HOST_SYS=/host/sys +Environment=HOST_ROOT=/host/root +Environment=MOSHIRO_NETWORK_SCOPE=host +# 告诉节点:退出之后外面会重新拉取,所以「更新」就是退出(没有这一行,节点不报告更新状态) +Environment=MOSHIRO_UPDATE=restart +EnvironmentFile=%h/.config/moshiro/agent.env + +[Service] +Restart=always +RestartSec=5 +# 第一次拉取镜像可能要一会儿 +TimeoutStartSec=600 + +[Install] +WantedBy=default.target diff --git a/packaging/quadlet/moshiro-probe.container b/packaging/quadlet/moshiro-probe.container new file mode 100644 index 0000000..3be36f4 --- /dev/null +++ b/packaging/quadlet/moshiro-probe.container @@ -0,0 +1,54 @@ +# MoShiro Probe 的 Podman Quadlet 单元(Podman 4.6 及以上)。节点的更新也靠它:systemd 每次启动这个单元时 Pull=always 先从镜像仓库拉最新的镜像, +# 所以节点退出再被拉起就是更新(节点自己不下载、不替换任何东西)。默认自动更新:面板发现新版本后,节点运行满 10 分钟就自己有序退出; +# 想改成在管理端点「更新」才更新,在 env 文件里写 MOSHIRO_UPDATE_POLICY=manual。 +# +# 安装(rootless,普通用户,不用 sudo): +# mkdir -p ~/.config/containers/systemd ~/.config/moshiro +# 把面板「添加 Probe」给的 .env 内容存成 ~/.config/moshiro/probe.env,chmod 600 +# 把本文件放进 ~/.config/containers/systemd/ +# systemctl --user daemon-reload && systemctl --user start moshiro-probe # Quadlet 会自动生成服务,不用 enable;[Install] 让它开机自启 +# loginctl enable-linger $USER # 退出登录后继续运行(需要管理员执行一次) +# rootful:本文件放进 /etc/containers/systemd/,.env 内容存成 /etc/moshiro/probe.env,把下面的 EnvironmentFile 改成这个路径、 +# WantedBy 改成 multi-user.target,用 systemctl(不加 --user)。 +# 开发通道:把 Image 的 moshiro 换成 moshiro-dev。固定版本:把 :latest 换成 :0.3.3(固定之后更新不会换版本)。 +# 想要完全自动、不用在管理端点:另外启用 podman-auto-update.timer,并在 [Container] 里加 AutoUpdate=registry(Pull=always 也保留)。 +# 说明见 docs/ops/deployment.md。 +[Unit] +Description=MoShiro Probe +After=network-online.target +Wants=network-online.target + +[Container] +Image=registry.oeo.one/moshiro/moshiro-probe:latest +ContainerName=moshiro-probe +Pull=always +Network=host +RunInit=true +ReadOnly=true +NoNewPrivileges=true +DropCapability=ALL +SecurityLabelDisable=true +Tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 +StopTimeout=20 +Volume=moshiro-probe-config:/etc/moshiro +Volume=/etc/os-release:/etc/os-release:ro +# 主机资源占用(只有汇总数字):只读挂载宿主机的 /proc、/sys 和根目录;不想上报就删掉这三行和下面三个 HOST_ 变量 +Volume=/proc:/host/proc:ro +Volume=/sys:/host/sys:ro +Volume=/:/host/root:ro +Environment=HOST_PROC=/host/proc +Environment=HOST_SYS=/host/sys +Environment=HOST_ROOT=/host/root +Environment=MOSHIRO_NETWORK_SCOPE=host +# 告诉节点:退出之后外面会重新拉取,所以「更新」就是退出(没有这一行,节点不报告更新状态) +Environment=MOSHIRO_UPDATE=restart +EnvironmentFile=%h/.config/moshiro/probe.env + +[Service] +Restart=always +RestartSec=5 +# 第一次拉取镜像可能要一会儿 +TimeoutStartSec=600 + +[Install] +WantedBy=default.target diff --git a/web/admin/src/pages/JoinTokenCreated.tsx b/web/admin/src/pages/JoinTokenCreated.tsx index b19822d..e880dd6 100644 --- a/web/admin/src/pages/JoinTokenCreated.tsx +++ b/web/admin/src/pages/JoinTokenCreated.tsx @@ -4,14 +4,16 @@ import { CommandTabs, CopyButton, Dialog, Field, guessPlatform, type CommandTab /** * 加入令牌创建之后的对话框:Agent 和 Probe 共用同一份。 - * 首选 compose:把面板给的 .env 存下来,下载 compose.yaml,`compose up -d`,以后更新就是 `compose pull && compose up -d`。 - * .env 里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥;脚本一行命令留作备用。 + * Podman 系(Linux)首选 Quadlet(Podman 4.6+):env、单元文件(核对哈希)、启动用户服务;单元 Pull=always,节点发现新版本后自己退出重启,所以默认自动更新。 + * Docker(Linux、macOS、Windows)用 compose:把面板给的 .env 存下来,下载 compose.yaml,`compose up -d`;compose 不会自动更新,手动 `compose pull && compose up -d`。 + * env/.env 里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥;一行安装脚本留作备用。 */ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJoinTokenDto; kind: 'Agent' | 'Probe'; onClose: () => void }) { + const quadlet = created.quadlet const compose = created.compose const script = created.install_commands const composeTabs: CommandTab[] = [ - { id: 'linux', label: 'Linux · Podman', command: compose.linux }, + { id: 'linux', label: 'Linux · compose', command: compose.linux }, ...(compose.macos ? [{ id: 'macos', label: 'macOS · Docker', command: compose.macos }] : []), ...(compose.windows ? [{ id: 'windows', label: 'Windows · Docker', command: compose.windows }] : []), ] @@ -29,15 +31,40 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
一次性码只显示这一次,请现在就复制。下面只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。
+ + +

+ 自动更新默认开:面板发现新版本后,节点运行满 10 分钟就自己退出,Podman 重新启动时先拉最新镜像(Pull=always)。 + 想改成在管理端点「更新」才更新,把 env 里 # MOSHIRO_UPDATE_POLICY=manual 的 # 去掉。固定版本:把单元里的 :latest 换成版本号。 +

+
- 分步做:.env 内容、compose 下载地址和哈希 + 分步做:Quadlet 的 env 和单元,compose 的 .env 和 compose.yaml
- + +
+
{quadlet.env}
+ +
+
+ +
+
+ {quadlet.url} + +
+
SHA-256 {quadlet.sha256}
+
+
+
{compose.env}
@@ -55,8 +82,8 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ

- 以后更新:podman compose pull && podman compose up -d(Docker 把 podman 换成 docker);身份和配置在命名卷里,不会动。 - 想固定版本,在 .env 里写 MOSHIRO_VERSION=版本号。注册成功之后,可以把 .env 里的 MOSHIRO_JOIN 一行删掉。 + compose 更新:podman compose pull && podman compose up -d(Docker 把 podman 换成 docker);身份和配置在命名卷里,不会动。 + 注册成功之后,可以把 env 或 .env 里的 MOSHIRO_JOIN 一行删掉。 {kind === 'Agent' ? (created.token.auto_approve ? ' 使用它注册的 Agent 会自动启用。' : ' 使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''} 可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。

diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index 7b51e36..8c45f3b 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -323,7 +323,7 @@ function Versions() { {nodes.length === 0 && !info.loading ? 还没有节点上报过版本 : null}

版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。 - 「更新」一列来自节点宿主机上的更新器:清单是 CI 签名的,面板只负责通知和请求,验签和拉取都在节点上完成;没装更新器的节点这一列是「—」。 + 「更新」一列来自节点:Podman Quadlet 节点(MOSHIRO_UPDATE=restart)点更新就是有序退出、由 systemd 重新拉取最新镜像;宿主机更新器节点由更新器验签和拉取:清单是 CI 签名的,面板只负责通知和请求,验签和拉取都在节点上完成;没装更新器的节点这一列是「—」。

) diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index a406c69..aa1d0cd 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -1195,6 +1195,23 @@ export interface paths { patch?: never; trace?: never; }; + "/install/quadlet/{file}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone) */ + get: operations["install_quadlet"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/install.ps1": { parameters: { query?: never; @@ -1485,6 +1502,7 @@ export interface components { plaintext: string; install_commands: components["schemas"]["InstallCommandsDto"]; compose: components["schemas"]["ComposeInstallDto"]; + quadlet: components["schemas"]["QuadletInstallDto"]; }; DimensionDto: { id: string; @@ -2013,6 +2031,12 @@ export interface components { manifest: null | string; signature: null | string; }; + QuadletInstallDto: { + env: string; + url: string; + sha256: string; + command: string; + }; RawChunkMetaDto: { chunk_id: string; agent_id: string; @@ -6238,6 +6262,35 @@ export interface operations { }; }; }; + install_quadlet: { + parameters: { + query?: never; + header?: never; + path: { + file: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; install_ps1: { parameters: { query?: never;