diff --git a/.woodpecker/dev-image.yml b/.woodpecker/dev-image.yml index cebc429..f6ad935 100644 --- a/.woodpecker/dev-image.yml +++ b/.woodpecker/dev-image.yml @@ -53,6 +53,9 @@ steps: - name: build-linux-amd64-probe image: bash environment: + GITEA_BASE: http://127.0.0.1:3100 + GITEA_TOKEN: + from_secret: gitea_token ZOT_REGISTRY: registry.oeo.one ZOT_USERNAME: justme ZOT_SECRET: @@ -82,6 +85,9 @@ steps: - name: build-linux-arm64-probe image: bash environment: + GITEA_BASE: http://127.0.0.1:3100 + GITEA_TOKEN: + from_secret: gitea_token ZOT_REGISTRY: registry.oeo.one ZOT_USERNAME: justme ZOT_SECRET: diff --git a/.woodpecker/verify.yml b/.woodpecker/verify.yml index b435129..43367c4 100644 --- a/.woodpecker/verify.yml +++ b/.woodpecker/verify.yml @@ -15,6 +15,12 @@ when: # On Gitea PR events, CI_COMMIT_MESSAGE is the PR title. steps: + - name: openwrt-updater-tests + image: bash + commands: + - sh -n packaging/openwrt/install.sh + - bash -n ci/publish-openwrt.sh + - python3 scripts/test-openwrt-updater.py - name: backend-tests image: bash # [SKIP TESTS] omits Backend tests only; web-build remains a dependent workflow. diff --git a/TODO.md b/TODO.md index 309f8b3..43e34ce 100644 --- a/TODO.md +++ b/TODO.md @@ -23,7 +23,7 @@ - 更新遥测(现在面板只有每个节点的「当前更新状态」) - **回滚路径的实机测试**:容器节点更新后观察 20 秒、失败回滚,只有单元和脚本测试,没有在真实节点上触发过。 - **更新的灰度和自动策略**:按标签分批发布;`POLICY=auto` 的长期验证。 -- **纯二进制节点的更新**(`fleet-updater`、`fleet-release`、Windows 服务和 OpenWrt procd 适配器)。设计要点,实现时照这个来: +- **纯二进制节点的更新**:OpenWrt Probe 已有独立的签名更新器、procd 适配和 CI dev/release 源(见部署文档),仍需真实 ARM64 路由器验证、发布密钥轮换和原生软件包。其他平台的通用 `fleet-updater`、`fleet-release`、Windows 服务适配仍待实现。设计要点: - 信任来自签名:只接受离线发布密钥签名的清单,发布源可以是面板、GitHub Releases 或任意 HTTP 服务;面板被攻破也推不出恶意二进制。 - 本地说了算:更新策略(`auto` / `notify` / `pinned:`)和信任的公钥写在节点本地配置里,面板只能「建议」;支持多把公钥以便轮换。 - 清单带 `app`、`channel`、`version`、`min_from`、各 RID 制品的 `sha256` 和大小、`key_id`、签名(对规范化 JSON 签名);本地记录已装过的最高版本,拒绝降级和旧清单重放。 diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index e4ea1db..90d912a 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -5020,6 +5020,18 @@ } } }, + "/install/openwrt.sh": { + "get": { + "tags": [ + "moshiro-backend" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/install": { "get": { "tags": [ @@ -6879,6 +6891,10 @@ }, "windows": { "type": "string" + }, + "openwrt": { + "type": "string", + "default": "" } } }, @@ -10130,6 +10146,9 @@ { "name": "ops" }, + { + "name": "moshiro-backend" + }, { "name": "release" }, diff --git a/backend/probe/README.md b/backend/probe/README.md index 62a5d16..a1f56c3 100644 --- a/backend/probe/README.md +++ b/backend/probe/README.md @@ -36,10 +36,16 @@ dotnet publish probe/src/MoShiro.Probe -c Release -r -p:PublishAot=true ## 配置与运行 +### OpenWrt 原生一键部署 + +管理端「添加 Probe」提供 **OpenWrt · 原生二进制** 命令,支持 ARM64/x86_64,以 root 执行即可注册并配置 procd。支持 dev/release 自动验签更新、失败回滚,不需要容器;详见 [部署文档](../../docs/ops/deployment.md#原生一键安装与自动更新)。 + +下面是手动注册方式;现代面板需同时传入面板提供的 `--genesis` 和 `--controllers`,否则默认属主检查会拒绝启动。 + 在管理端添加 Probe(生成 probe 角色的加入令牌),在目标机上注册(密钥由 Probe 自己生成): ```sh -fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro +fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro --genesis <信任根指纹> --controllers <属主凭证ID> ``` 写 `/etc/moshiro/probe.json`:`{"fleet":{"server":"https://panel.example.com","identity_dir":"/etc/moshiro"}}`(`install.sh probe --server … --join … --name …` 会一并完成)。随后运行: diff --git a/backend/probe/src/MoShiro.Probe/OpenWrtUpdater.cs b/backend/probe/src/MoShiro.Probe/OpenWrtUpdater.cs new file mode 100644 index 0000000..3abbe2c --- /dev/null +++ b/backend/probe/src/MoShiro.Probe/OpenWrtUpdater.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Fleet.Core; +using Fleet.Hosting; +using MoShiro.Measurement; +using MoShiro.Protocol; + +namespace MoShiro.Probe; + +/// +/// Native supervisor bridge: writes only fixed wake-up flags and a health beacon in tmpfs. +/// Never downloads, installs, verifies releases, or executes commands. The root-owned host +/// updater independently fetches and verifies its locally configured release feed. +/// +public sealed class OpenWrtUpdater +{ + private readonly ConfigStore _runtime; + private readonly string _statusPath; + private readonly TimeProvider _time; + private readonly INodeLog _log; + + public OpenWrtUpdater(string runtimeDirectory, string statusPath, TimeProvider time, INodeLog log) + { + _runtime = new ConfigStore(runtimeDirectory); + _statusPath = statusPath; + _time = time; + _log = log; + } + + public static OpenWrtUpdater? FromEnvironment(TimeProvider time, INodeLog log) => + Environment.GetEnvironmentVariable(RestartUpdater.EnvironmentVariable) == "openwrt" + ? new OpenWrtUpdater("/var/run/moshiro-probe", "/var/run/moshiro-update/status.json", time, log) + : null; + + public void Offer() => Signal("check.request"); + + public void RequestApply() => Signal("apply.request"); + + private void Signal(string fileName) + { + try + { + _runtime.Write(fileName, "check"u8.ToArray()); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.Warn("could not signal the native updater", ("err", ex.Message)); + } + } + + /// Called by the Fleet heartbeat: evidence that this version reached Backend. + public UpdateStatus? Status() + { + try + { + var health = new OpenWrtHealth(BuildInfo.Version, Environment.ProcessId, _time.GetUtcNow().ToUnixTimeSeconds()); + _runtime.Write("health.json", JsonSerializer.SerializeToUtf8Bytes(health, ProbeConfigContext.Default.OpenWrtHealth)); + if (!File.Exists(_statusPath) || new FileInfo(_statusPath).Length > 8192) + { + return null; + } + + return JsonSerializer.Deserialize(File.ReadAllBytes(_statusPath), MoShiroJsonContext.Default.UpdateStatus); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + _log.Warn("could not read native update status", ("err", ex.Message)); + return null; + } + } +} + +public sealed record OpenWrtHealth( + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("pid")] int Pid, + [property: JsonPropertyName("at")] long At); diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index 72728fb..9d85ca8 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -41,6 +41,7 @@ public sealed class ProbeApp : IDisposable directory.Changed += () => _changed.Release(); RestartUpdater? update = RestartUpdater.FromEnvironment("probe", reason => restart?.RequestNow(reason), time, log); + OpenWrtUpdater? nativeUpdate = OpenWrtUpdater.FromEnvironment(time, log); var options = new FleetLinkOptions { Server = new Uri(file.Fleet.Server), @@ -52,7 +53,7 @@ public sealed class ProbeApp : IDisposable ShutdownReason = () => restart?.NowReason == "update" ? "updating" : "stopping", HeartbeatExtra = () => { - UpdateStatus? updateStatus = update?.Status(); + UpdateStatus? updateStatus = nativeUpdate?.Status() ?? update?.Status(); NodeRuntimeStatus runtimeStatus = updateStatus?.State == "applying" ? NodeRuntimeStatus.Updating : _env.Directory.Agents.Count == 0 ? NodeRuntimeStatus.ProbePaused : NodeRuntimeStatus.Running; NodeLogBatch logBatch = _logBatchSource?.TakeBatch(16) ?? new NodeLogBatch([], 0); return JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra @@ -84,11 +85,13 @@ public sealed class ProbeApp : IDisposable .OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) => { update?.Offer(offer); + nativeUpdate?.Offer(); return ValueTask.CompletedTask; }) .OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) => { update?.RequestApply(); + nativeUpdate?.RequestApply(); return ValueTask.CompletedTask; }) .OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) => diff --git a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs index f8cb9f2..73656f3 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs @@ -84,6 +84,7 @@ public sealed class ProbeLimits [JsonSerializable(typeof(ProbeFile))] [JsonSerializable(typeof(AgentsCache))] +[JsonSerializable(typeof(OpenWrtHealth))] public sealed partial class ProbeConfigContext : JsonSerializerContext; /// agents.json:最近一次从面板同步到的 Agent 列表(配置,不是测量数据);面板连不上时 Probe 靠它继续测量。 diff --git a/backend/probe/tests/MoShiro.Probe.Tests/OpenWrtUpdaterTests.cs b/backend/probe/tests/MoShiro.Probe.Tests/OpenWrtUpdaterTests.cs new file mode 100644 index 0000000..f9e3cc2 --- /dev/null +++ b/backend/probe/tests/MoShiro.Probe.Tests/OpenWrtUpdaterTests.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Hosting; +using MoShiro.Protocol; + +namespace MoShiro.Probe.Tests; + +public sealed class OpenWrtUpdaterTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-native-" + Guid.NewGuid().ToString("N")); + + [Fact] + public void Offer_AndApply_WriteFixedFlags_NotRemoteManifestOrCommands() + { + var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance); + updater.Offer(); + updater.RequestApply(); + Assert.Equal("check", File.ReadAllText(Path.Combine(_directory, "check.request"))); + Assert.Equal("check", File.ReadAllText(Path.Combine(_directory, "apply.request"))); + Assert.Equal(2, Directory.GetFiles(_directory).Length); + } + + [Fact] + public void Status_WritesHealthAndReadsHostStatus() + { + var store = new ConfigStore(_directory); + store.Write("status.json", "{\"state\":\"rolled_back\",\"at\":123}"u8.ToArray()); + var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance); + Assert.Equal("rolled_back", updater.Status()?.State); + using JsonDocument health = JsonDocument.Parse(File.ReadAllBytes(Path.Combine(_directory, "health.json"))); + Assert.Equal(BuildInfo.Version, health.RootElement.GetProperty("version").GetString()); + Assert.Equal(Environment.ProcessId, health.RootElement.GetProperty("pid").GetInt32()); + Assert.True(health.RootElement.GetProperty("at").GetInt64() > 0); + } + + [Fact] + public void Status_MissingOrCorruptHostStatus_DoesNotStopCollector() + { + var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance); + Assert.Null(updater.Status()); + new ConfigStore(_directory).Write("status.json", "not json"u8.ToArray()); + Assert.Null(updater.Status()); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 2cefeac..a1ef467 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -154,7 +154,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), InstallScripts.QuadletSha256(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), InstallScripts.Sha256("openwrt.sh"), options.ImageNamespace == "moshiro-dev" ? "dev" : "release"); 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); @@ -165,7 +165,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), new QuadletInstallDto(created.Quadlet.Env, created.Quadlet.Url, created.Quadlet.Sha256, created.Quadlet.Command)), 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, created.InstallCommands.Openwrt), 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 6b7556b..74dcb82 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -88,7 +88,7 @@ public sealed record SetupStatusDto(bool NeedsSetup); /// 首次启动时写在后端日志里的认领码(或者部署时指定的 MoShiro__ClaimCode)。 public sealed record SetupRequest(string? ClaimCode, string? Username, string? Password, bool? ReturnSession); -public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); +public sealed record InstallCommandsDto(string Linux, string Macos, string Windows, string Openwrt = ""); /// 要存成 .env 的内容(只有公开的值)。 /// compose 文件的下载地址。 diff --git a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj index d39d74d..d636ca1 100644 --- a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj +++ b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj @@ -8,6 +8,7 @@ + diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index b865292..8b0041d 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -21,6 +21,8 @@ public static class OpsEndpoints .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403); // 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。 // 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。 + app.MapGet("/install/openwrt.sh", (HttpContext http) => Static(http, "openwrt.sh", "text/x-shellscript")); + app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript")) .Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript"); diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index 840a6ee..cc7699e 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -8,7 +8,7 @@ namespace MoShiro.Backend.Application; public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null); /// 各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。 -public sealed record InstallCommands(string Linux, string Macos, string Windows); +public sealed record InstallCommands(string Linux, string Macos, string Windows, string Openwrt = ""); /// /// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、 @@ -16,7 +16,7 @@ public sealed record InstallCommands(string Linux, string Macos, string Windows) /// /// 面板提供的这个角色的 compose 文件的 SHA-256(命令下载之后先核对)。 /// 面板提供的这个角色的 Podman Quadlet 单元的 SHA-256。 -public sealed record InstallPlan(string? Genesis, IReadOnlyList Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "", string QuadletSha256 = ""); +public sealed record InstallPlan(string? Genesis, IReadOnlyList Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "", string QuadletSha256 = "", string OpenwrtSha256 = "", string NativeChannel = "release"); /// /// compose 安装:Env 是要存成 .env 的内容(只有公开的值),Url 和 Sha256 是 compose 文件的下载地址和哈希, @@ -109,7 +109,10 @@ public sealed class JoinTokenService $"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | sha256sum -c - >/dev/null && {env} sh \"$f\"", $"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"", $"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; " - + $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)"); + + $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)", + role == ProbeRole && plan.Genesis is not null + ? $"f=$(mktemp) && (curl -fsS '{baseUrl}/install/openwrt.sh' -o \"$f\" || uclient-fetch -q -O \"$f\" '{baseUrl}/install/openwrt.sh') && echo \"{plan.OpenwrtSha256} $f\" | sha256sum -c - >/dev/null && {env} MOSHIRO_CHANNEL='{plan.NativeChannel}' sh \"$f\" install" + : ""); } /// diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index 767bacb..65ce60d 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -22,6 +22,21 @@ public sealed class InstallTests : IClassFixture return JsonNode.Parse(await response.Content.ReadAsStringAsync())!; } + [Fact] + public async Task OpenWrtScript_IsStaticAndContainsThePinnedReleasePublicKey() + { + string script = await _backend.Anonymous.GetStringAsync("/install/openwrt.sh"); + string key = await _backend.Anonymous.GetStringAsync("/install"); + Assert.Contains("BEGIN PUBLIC KEY", script, StringComparison.Ordinal); + Assert.Contains(MoShiro.Backend.Api.InstallScripts.ReleaseKey(), script, StringComparison.Ordinal); + Assert.Contains("--genesis", script, StringComparison.Ordinal); + Assert.Contains("--controllers", script, StringComparison.Ordinal); + Assert.Contains("openssl dgst -sha256 -verify", script, StringComparison.Ordinal); + Assert.DoesNotContain("BEGIN PRIVATE KEY", script + key, StringComparison.Ordinal); + JsonNode agent = await CreateAsync("agent"); + Assert.Equal("", agent["install_commands"]!["openwrt"]!.GetValue()); + } + [Theory] [InlineData("agent")] [InlineData("probe")] diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs index 7b08685..aa1bbec 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -70,6 +70,15 @@ public sealed class TrustTests : IClassFixture Assert.Contains("shasum -a 256 -c -", commands["macos"]!.GetValue(), StringComparison.Ordinal); string windowsSha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install.ps1")))); Assert.Contains($"-ne '{windowsSha}'", commands["windows"]!.GetValue(), StringComparison.Ordinal); + string openwrt = commands["openwrt"]!.GetValue(); + string nativeScript = await _backend.Anonymous.GetStringAsync("/install/openwrt.sh"); + string nativeSha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(nativeScript))); + Assert.Contains($"echo \"{nativeSha} $f\" | sha256sum -c -", openwrt, StringComparison.Ordinal); + Assert.Contains($"MOSHIRO_CODE='{code}'", openwrt, StringComparison.Ordinal); + Assert.Contains($"MOSHIRO_GENESIS='{op.Genesis}'", openwrt, StringComparison.Ordinal); + Assert.Contains(op.Passkey.Id, openwrt, StringComparison.Ordinal); + Assert.Contains("MOSHIRO_CHANNEL='release'", openwrt, StringComparison.Ordinal); + Assert.EndsWith("sh \"$f\" install", openwrt, StringComparison.Ordinal); } [Fact] diff --git a/ci/build-dev-image.sh b/ci/build-dev-image.sh index 233a4f2..716d58c 100644 --- a/ci/build-dev-image.sh +++ b/ci/build-dev-image.sh @@ -183,6 +183,20 @@ for tag in "$image_version-$arch"; do echo "pushed development image $repository:$tag" done +# Reuse the static Probe/enrollment binaries already built above; do not compile again. +# Publish before the multi-arch image offer wakes online native nodes. +if [[ "$component" == probe ]]; then + buildah rm "moshiro-openwrt-extract-$arch" >/dev/null 2>&1 || true + native_container="$(buildah from --arch "$arch" --name "moshiro-openwrt-extract-$arch" "$local_image")" + native_dir="$work/openwrt" + mkdir -p "$native_dir" + native_mount="$(buildah mount "$native_container")" + cp "$native_mount/usr/local/bin/moshiro-probe" "$native_mount/usr/local/bin/fleet-enroll" "$native_dir/" + buildah unmount "$native_container" >/dev/null + buildah rm "$native_container" >/dev/null + bash "$root/ci/publish-openwrt.sh" dev "$node_rid" "$native_dir" +fi + # Agent and Probe manifests are signed after both architecture-specific images # have been pushed. Backend is still a single-architecture development image. if [[ "$component" == backend && -n "${RELEASE_SIGNING_KEY:-}" ]]; then diff --git a/ci/publish-openwrt.sh b/ci/publish-openwrt.sh new file mode 100644 index 0000000..4f1a219 --- /dev/null +++ b/ci/publish-openwrt.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Package an already-built native Probe and publish an independently signed feed. +# Used after verify/web-build gates for dev, and after all release builds for tags. +set -euo pipefail +channel="${1:?usage: publish-openwrt.sh }" +rid="${2:?}"; source_dir="${3:?}" +[[ "$channel" == dev || "$channel" == release ]] || exit 2 +[[ "$rid" == linux-musl-arm64 || "$rid" == linux-musl-x64 ]] || exit 2 +if [[ "$channel" == release ]]; then + [[ "${CI_COMMIT_TAG:-}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'release feed requires a stable version tag' >&2; exit 2; } +else + [[ -z "${CI_COMMIT_TAG:-}" && "${CI_PIPELINE_EVENT:-}" == push && "${CI_COMMIT_BRANCH:-}" == main && "${CI_COMMIT_MESSAGE:-}" == *'[CI]'* ]] \ + || { echo 'dev feed requires a main push with [CI]' >&2; exit 2; } +fi +[[ -n "${RELEASE_SIGNING_KEY:-}" && -n "${GITEA_TOKEN:-}" ]] || { echo 'OpenWrt publishing requires signing and Gitea secrets' >&2; exit 2; } +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$root/ci/version.sh" +version="$MOSHIRO_VERSION" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir "$work/stage" +install -m 755 "$source_dir/moshiro-probe" "$source_dir/fleet-enroll" "$work/stage/" +for library in "$source_dir"/lib*.so.*; do + [[ ! -f "$library" ]] || install -m 644 "$library" "$work/stage/" +done +install -m 755 "$root/packaging/openwrt/install.sh" "$work/stage/openwrt.sh" +install -m 755 "$root/packaging/openwrt/native-probe.init" "$work/stage/probe.init" +install -m 755 "$root/packaging/openwrt/updater.init" "$work/stage/updater.init" +members=() +for file in "$work/stage/"*; do members+=("$(basename "$file")"); done +tar --sort=name --owner=0 --group=0 --numeric-owner -czf "$work/package.tar.gz" -C "$work/stage" "${members[@]}" +hash="$(sha256sum "$work/package.tar.gz" | cut -d' ' -f1)" +size="$(stat -c%s "$work/package.tar.gz")" +package="probe-$rid-$hash.tar.gz" +seq="$(git -C "$root" show -s --format=%ct HEAD)" +# Commit time is stable across retries; equal timestamps intentionally do not auto-upgrade. +python3 - "$channel" "$rid" "$version" "$package" "$hash" "$size" "$seq" > "$work/manifest.json" <<'PY' +import json, sys +channel, rid, version, package, sha256, size, seq = sys.argv[1:] +print(json.dumps(dict(schema=1, component='probe', channel=channel, rid=rid, + version=version, package=package, sha256=sha256, size=int(size), seq=int(seq)), separators=(',', ':')), end='') +PY +chmod 700 "$work" +printf '%s\n' "$RELEASE_SIGNING_KEY" > "$work/key.pem" +chmod 600 "$work/key.pem" +openssl dgst -sha256 -sign "$work/key.pem" -out "$work/signature" "$work/manifest.json" +base64 -w0 "$work/signature" > "$work/manifest.sig" +base="${GITEA_BASE:-http://127.0.0.1:3100}" +base="${base%/}/api/packages/justme/generic/moshiro-openwrt" +# Credentials only in a protected curl config, never command-line arguments or output. +printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$work/curl.conf" +chmod 600 "$work/curl.conf" +upload() { + local path="$1" file="$2" mutable="${3:-false}" code + if [[ "$mutable" == true ]]; then + code="$(curl --silent --show-error --config "$work/curl.conf" --request DELETE --output "$work/response" --write-out '%{http_code}' "$base/$path")" + [[ "$code" == 204 || "$code" == 200 || "$code" == 404 ]] || { echo "cannot replace OpenWrt feed: HTTP $code" >&2; return 1; } + fi + code="$(curl --silent --show-error --retry 3 --config "$work/curl.conf" --upload-file "$file" --output "$work/response" --write-out '%{http_code}' "$base/$path")" + # Immutable content-addressed archives may already exist after a retry. + [[ "$code" == 2* || ( "$mutable" == false && "$code" == 409 ) ]] || { echo "OpenWrt upload failed: HTTP $code" >&2; return 1; } +} +upload "$version/$package" "$work/package.tar.gz" +# A transient manifest/signature mismatch is rejected, then retried by the device. +# Publish the channel pointer only after its immutable payload is downloadable. +upload "$channel/manifest-$rid.sig" "$work/manifest.sig" true +upload "$channel/manifest-$rid.json" "$work/manifest.json" true +echo "published signed OpenWrt Probe $version ($channel/$rid)" diff --git a/ci/release-node-images.sh b/ci/release-node-images.sh index 8eadb52..0de16e7 100644 --- a/ci/release-node-images.sh +++ b/ci/release-node-images.sh @@ -70,6 +70,11 @@ build_arch_image() { install -m 0755 "$work/fleet-enroll" "$context/app/fleet-enroll" install -m 0755 "$work/moshiro-$component" "$context/app/moshiro-$component" install -m 0644 "$work"/lib*.so.* "$context/app/" + if [[ "$component" == probe && -n "${RELEASE_SIGNING_KEY:-}" && "$version" != *-* ]]; then + native_rid=linux-musl-x64 + [[ "$arch" != arm64 ]] || native_rid=linux-musl-arm64 + bash "$root/ci/publish-openwrt.sh" release "$native_rid" "$context/app" + fi : > "$context/config/.keep" install -m 0755 "$root/packaging/container/entrypoint.sh" "$context/entrypoint.sh" diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md index b35e66b..40118ef 100644 --- a/docs/ops/deployment.md +++ b/docs/ops/deployment.md @@ -173,6 +173,24 @@ Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库 ### OpenWrt(`packaging/openwrt/`) +#### 原生一键安装与自动更新 + +管理端「添加 Probe」提供 **OpenWrt · 原生二进制** 命令,以 root 执行。支持 ARM64/aarch64 和 x86_64,不需要容器或 .NET;自动安装依赖、验签下载、固定属主并注册、配置 procd。身份在 `/etc/moshiro/`,更新策略在 `/etc/moshiro-native.conf`(`CHANNEL=dev|release`、`POLICY=auto|manual`、`INTERVAL=30`)。动态 musl 正式包仍需 `libopenssl3`,实际设备兼容性需实机验证。 + +带 `[CI]` 的 main push 发布 dev,稳定 tag 发布 release,沿用 `gitea_token` 和 `release_signing_key`;Gitea generic package `moshiro-openwrt` 需允许匿名下载。制品发布后,在线节点收到通知就唤醒更新器,另有 30 秒轮询兜底,不等待 10 分钟。更新器验签、防重放、原子切换完整版本,健康检查失败回滚;Probe 本身不下载、不执行命令。 + +```sh +logread -e moshiro +/etc/init.d/moshiro-probe status +/etc/init.d/moshiro-probe-update status +/usr/libexec/moshiro-openwrt check # 立即检查;auto 策略下有新版就安装 +cat /var/run/moshiro-update/status.json +``` + +默认测量上限 20 Mbps/2000 pps,可修改 `probe.json`。sysupgrade 保留身份和更新配置,升级固件后重新校验安装脚本并执行 `install` 恢复程序,不需新加入码;旧部署需要人工迁移,不覆盖现有身份。首次安装没有旧版可回滚。发布公钥轮换需 root 手动更新本机公钥。隔离测试:`python3 scripts/test-openwrt-updater.py`。 + +#### 旧的解压包安装方式 + 只支持 Probe(Agent 面向服务器)。`install.sh probe` 安装 `/etc/init.d/moshiro-probe`(procd,崩溃自动重启,日志进 logd)并写入 `/lib/upgrade/keep.d/moshiro`,sysupgrade 时保留 `/etc/moshiro/`。 OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 24.10 之后用 `apk add libopenssl3`)。安装包会把 MsQuic、NUMA 和相关共享库放到 `/usr/lib/moshiro`,procd 会设置 `LD_LIBRARY_PATH`;OpenSSL 3 仍由系统提供。闪存很小时注意:Probe 二进制约 7.7 MB,配置只在 Agent 列表变化时才写。路由器上建议把 `probe.json` 的 `limits.max_test_mbps` 调低。 diff --git a/packaging/openwrt/install.sh b/packaging/openwrt/install.sh new file mode 100644 index 0000000..58d3c4c --- /dev/null +++ b/packaging/openwrt/install.sh @@ -0,0 +1,361 @@ +#!/bin/sh +# Native OpenWrt Probe installer/updater. No container engine or .NET runtime. +# Bootstrap: verify this script's published hash before running `sh install.sh install`. +# Trust/configuration live outside the collector's writable identity directory. +set -eu +umask 077 + +ROOT=${MOSHIRO_NATIVE_ROOT:-/usr/lib/moshiro-native} +CONF=${MOSHIRO_NATIVE_CONF:-/etc/moshiro-native.conf} +STATE=${MOSHIRO_NATIVE_STATE:-/etc/moshiro-native-state} +STATUS=${MOSHIRO_NATIVE_STATUS:-/var/run/moshiro-update} +RUNTIME=${MOSHIRO_NATIVE_RUNTIME:-/var/run/moshiro-probe} +ACTION=${1:-install} +FEED=https://git.oeo.one/api/packages/justme/generic/moshiro-openwrt +CHANNEL=${MOSHIRO_CHANNEL:-release} +POLICY=auto +INTERVAL=30 +HEALTH_TIMEOUT=90 +HEALTH_OBSERVE=20 +MAX_BYTES=67108864 +WORK= +LOCK= +STAGED= + +die() { echo "moshiro-openwrt: $*" >&2; exit 1; } +log() { echo "moshiro-openwrt: $*"; } +ctl() { + if [ "${MOSHIRO_NATIVE_TEST:-}" = 1 ]; then "$MOSHIRO_NATIVE_SERVICE" "$@" + else /etc/init.d/moshiro-probe "$@"; fi +} +field() { jsonfilter -i "$WORK/manifest.json" -e "@.$1"; } +download() { + # curl never follows redirects; all downloads stay under the locally pinned feed. + curl --fail --silent --show-error --connect-timeout 10 --max-time 120 \ + --proto '=https' --max-filesize "$2" "$1" -o "$3" + [ "$(wc -c < "$3")" -le "$2" ] || die "download exceeds size limit" +} +cleanup() { + [ -z "$WORK" ] || rm -rf "$WORK" + [ -z "$LOCK" ] || rm -rf "$LOCK" + [ -z "$STAGED" ] || rm -rf "$STAGED" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM HUP + +status() { + # Values are validated below; do not interpolate network-provided error messages. + mkdir -p "$STATUS" + chmod 755 "$STATUS" + available=null + case "$1" in available|applying|rolled_back) + if [ -n "${VERSION:-}" ]; then + available=$(printf '{"version":"%s","tag":"v%s","digest":"sha256:%s","seq":%s}' "$VERSION" "$VERSION" "$HASH" "$SEQ") + fi ;; + esac + printf '{"state":"%s","current":"%s","available":%s,"error":"%s","at":%s}\n' \ + "$1" "$(readlink "$ROOT/current" 2>/dev/null | sed 's|.*/||')" "$available" "${2:-}" "$(date +%s)" > "$STATUS/status.new" + chmod 644 "$STATUS/status.new" + mv -f "$STATUS/status.new" "$STATUS/status.json" +} + +dependencies() { + [ "$(id -u)" = 0 ] || [ "${MOSHIRO_NATIVE_TEST:-}" = 1 ] || die "run as root" + [ -f /etc/openwrt_release ] || [ "${MOSHIRO_NATIVE_TEST:-}" = 1 ] || die "OpenWrt is required" + missing=false + for tool in curl openssl jsonfilter; do command -v "$tool" >/dev/null 2>&1 || missing=true; done + if [ "$ACTION" = install ] && [ "$missing" = true ]; then + if command -v opkg >/dev/null 2>&1; then + opkg update + opkg install curl ca-bundle openssl-util jsonfilter libopenssl3 + elif command -v apk >/dev/null 2>&1; then + apk add curl ca-bundle openssl-util jsonfilter libopenssl3 + fi + fi + for tool in curl openssl jsonfilter sha256sum tar readlink ubus; do + command -v "$tool" >/dev/null 2>&1 || die "missing $tool; install curl ca-bundle openssl-util jsonfilter with opkg/apk" + done +} + +load_config() { + [ -f "$CONF" ] && [ ! -L "$CONF" ] || die "missing local updater configuration" + # Root-owned configuration is intentionally shell data, never sourced from the feed. + . "$CONF" + case "$CHANNEL" in release|dev) ;; *) die "invalid channel" ;; esac + case "$POLICY" in auto|manual) ;; *) die "invalid policy" ;; esac + case "$RID" in linux-musl-arm64|linux-musl-x64) ;; *) die "unsupported architecture" ;; esac + case "$FEED" in https://*) ;; *) die "feed must use HTTPS" ;; esac + case "$FEED" in *[!A-Za-z0-9:/._-]*) die "invalid feed URL" ;; esac + case "$INTERVAL" in ''|*[!0-9]*) die "invalid interval" ;; esac + [ "$INTERVAL" -ge 5 ] || die "interval must be at least 5 seconds" +} + +acquire() { + mkdir -p "$STATE" "$ROOT/versions" + chmod 700 "$STATE" + # /var/run is cleared on reboot. A crashed process leaves a recoverable PID lock. + lockpath="$STATUS.lock" + if ! mkdir "$lockpath" 2>/dev/null; then + if [ -f "$lockpath/pid" ]; then + owner=$(cat "$lockpath/pid") + case "$owner" in ''|*[!0-9]*) return 1 ;; esac + kill -0 "$owner" 2>/dev/null && return 1 + rm -f "$lockpath/pid" + rmdir "$lockpath" 2>/dev/null || return 1 + mkdir "$lockpath" 2>/dev/null || return 1 + else + return 1 + fi + fi + LOCK=$lockpath + echo $$ > "$LOCK/pid" + for unfinished in "$ROOT"/versions/.stage.*; do + [ ! -d "$unfinished" ] || rm -rf "$unfinished" + done + WORK=$(mktemp -d /tmp/moshiro-native.XXXXXX) +} + +verify() { + download "$FEED/$CHANNEL/manifest-$RID.json" 8192 "$WORK/manifest.json" || return 1 + download "$FEED/$CHANNEL/manifest-$RID.sig" 1024 "$WORK/manifest.sig" || return 1 + base64 -d "$WORK/manifest.sig" > "$WORK/signature" 2>/dev/null || return 1 + openssl dgst -sha256 -verify "$STATE/release-key.pem" -signature "$WORK/signature" "$WORK/manifest.json" >/dev/null 2>&1 || return 1 + [ "$(field schema)" = 1 ] && [ "$(field component)" = probe ] \ + && [ "$(field channel)" = "$CHANNEL" ] && [ "$(field rid)" = "$RID" ] || return 1 + HASH=$(field sha256); SIZE=$(field size); SEQ=$(field seq); VERSION=$(field version); PACKAGE=$(field package) + case "$HASH" in ''|*[!0-9a-f]*) return 1 ;; esac + [ "${#HASH}" = 64 ] || return 1 + case "$SEQ:$SIZE" in *[!0-9:]*) return 1 ;; esac + [ -n "$SEQ" ] && [ "${#SEQ}" -le 15 ] && [ -n "$SIZE" ] && [ "${#SIZE}" -le 9 ] && [ "$SIZE" -gt 0 ] && [ "$SIZE" -le "$MAX_BYTES" ] || return 1 + case "$VERSION" in ''|*[!A-Za-z0-9.+_-]*) return 1 ;; esac + [ "${#VERSION}" -le 100 ] && printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?(\+[A-Za-z0-9.-]+)?$' || return 1 + [ "$PACKAGE" = "probe-$RID-$HASH.tar.gz" ] || return 1 +} + +switch_to() { + rm -f "$ROOT/current.new" + ln -s "$1" "$ROOT/current.new" + mv -fT "$ROOT/current.new" "$ROOT/current" +} + +recover() { + if [ -f "$STATE/pending" ]; then + previous=$(sed -n 1p "$STATE/pending") + previous_hash=${previous#versions/} + case "$previous_hash" in ''|*[!0-9a-f]*) die "invalid pending recovery record" ;; esac + [ "$previous" = "versions/$previous_hash" ] && [ "${#previous_hash}" = 64 ] || die "invalid pending recovery record" + [ -d "$ROOT/$previous" ] || die "previous version missing during recovery" + switch_to "$previous" + ctl restart + rm -f "$STATE/pending" + status rolled_back 'interrupted update recovered' + fi +} + +stage() { + # RAM must hold compressed archive and extraction must not fill the overlay. + free_kb=$(df -Pk /tmp | awk 'END {print $4}') + overlay_kb=$(df -Pk "$ROOT" | awk 'END {print $4}') + [ "$free_kb" -gt $((SIZE / 1024 + 4096)) ] || die "not enough /tmp space" + [ "$overlay_kb" -gt $((SIZE * 6 / 1024 + 8192)) ] || die "not enough installation space (allow archive expansion and rollback)" + download "$FEED/$VERSION/$PACKAGE" "$SIZE" "$WORK/package.tar.gz" || return 1 + [ "$(wc -c < "$WORK/package.tar.gz")" -eq "$SIZE" ] || return 1 + echo "$HASH $WORK/package.tar.gz" | sha256sum -c - >/dev/null || return 1 + # Only a flat set of regular files is accepted, never symlinks, hardlinks or paths. + tar -tzf "$WORK/package.tar.gz" > "$WORK/members" || return 1 + [ "$(wc -l < "$WORK/members")" -le 32 ] || return 1 + [ "$(sort -u "$WORK/members" | wc -l)" -eq "$(wc -l < "$WORK/members")" ] || return 1 + while IFS= read -r member; do + case "$member" in + moshiro-probe|fleet-enroll|openwrt.sh|probe.init|updater.init) ;; + lib*.so.*) case "$member" in *[!A-Za-z0-9._+-]*) return 1 ;; esac ;; + *) return 1 ;; + esac + done < "$WORK/members" + tar -tvzf "$WORK/package.tar.gz" > "$WORK/types" || return 1 + grep -q '^[^-]' "$WORK/types" && return 1 + # Signed manifests are the trust boundary. Bound expansion as a second safety limit. + expanded=$(awk '{sum += $3} END {print sum+0}' "$WORK/types") + [ "$expanded" -le 134217728 ] || return 1 + [ "$overlay_kb" -gt $((expanded / 1024 + 4096)) ] || return 1 + destination="$ROOT/versions/$HASH" + [ ! -e "$destination" ] || return 0 + STAGED=$(mktemp -d "$ROOT/versions/.stage.XXXXXX") + if ! tar -xzf "$WORK/package.tar.gz" -C "$STAGED"; then + return 1 + fi + for required in moshiro-probe fleet-enroll openwrt.sh probe.init updater.init; do + [ -s "$STAGED/$required" ] || return 1 + chmod 755 "$STAGED/$required" || return 1 + done + chmod 755 "$ROOT" "$ROOT/versions" "$STAGED" || return 1 + for library in "$STAGED"/lib*.so.*; do [ ! -f "$library" ] || chmod 644 "$library" || return 1; done + mv "$STAGED" "$destination" || return 1 + STAGED= +} + +healthy() { + # A PID alone is insufficient: require this version to complete a Backend heartbeat. + elapsed=0 + stable_since=0 + stable_pid= + while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do + health="$RUNTIME/health.json" + if [ -f "$health" ] && [ ! -L "$health" ]; then + version=$(jsonfilter -i "$health" -e '@.version' 2>/dev/null || true) + pid=$(jsonfilter -i "$health" -e '@.pid' 2>/dev/null || true) + at=$(jsonfilter -i "$health" -e '@.at' 2>/dev/null || echo 0) + active=$(ubus call service list '{"name":"moshiro-probe"}' 2>/dev/null | jsonfilter -e '@["moshiro-probe"].instances.*.pid' 2>/dev/null || true) + case "$pid:$at" in ''|*[!0-9:]*) ;; *) + now=$(date +%s) + if [ "$version" = "$VERSION" ] && [ -n "$pid" ] && [ "$pid" = "$active" ] && [ "$at" -ge "$started" ] && [ $((now - at)) -le 30 ]; then + if [ "$stable_pid" != "$pid" ]; then stable_pid=$pid; stable_since=$now; fi + [ $((now - stable_since)) -lt "$HEALTH_OBSERVE" ] || return 0 + else + stable_pid= + fi ;; + esac + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + return 1 +} + +check() { + acquire || return 0 + recover + requested=false + if [ -f "$RUNTIME/apply.request" ] && [ ! -L "$RUNTIME/apply.request" ]; then requested=true; fi + rm -f "$RUNTIME/apply.request" + if ! verify; then status invalid 'feed unavailable or signature/metadata invalid'; return 1; fi + current=$(readlink "$ROOT/current" 2>/dev/null || true) + if [ "$current" = "versions/$HASH" ]; then status up_to_date; return 0; fi + high=$(cat "$STATE/$CHANNEL.seq" 2>/dev/null || echo 0) + [ "$SEQ" -gt "$high" ] || return 0 + status available + if [ "$POLICY" != auto ] && [ "$requested" = false ]; then return 0; fi + if ! stage; then status failed 'download, archive validation or extraction failed'; return 1; fi + # High-water mark survives failed installs and rollbacks. Never retry a bad release. + printf '%s\n' "$SEQ" > "$STATE/$CHANNEL.seq.new" + mv -f "$STATE/$CHANNEL.seq.new" "$STATE/$CHANNEL.seq" + printf '%s\n' "$current" > "$STATE/pending.new" + mv -f "$STATE/pending.new" "$STATE/pending" + sync + status applying + started=$(date +%s) + switch_to "versions/$HASH" + sync + if ctl restart && healthy; then + rm -f "$STATE/pending" + status up_to_date + # Keep current and exactly one previous version; no identity/config deletion. + for directory in "$ROOT"/versions/*; do + [ "$directory" = "$ROOT/versions/$HASH" ] || [ "$directory" = "$ROOT/$current" ] || rm -rf "$directory" + done + log "updated to $VERSION" + else + switch_to "$current" + ctl restart + rm -f "$STATE/pending" + status rolled_back 'new version did not complete a Backend heartbeat' + return 1 + fi +} + +install() { + restoring=false + if [ -e "$CONF" ]; then + load_config + restoring=true + fi + case "$(uname -m)" in aarch64|arm64) detected_rid=linux-musl-arm64 ;; x86_64) detected_rid=linux-musl-x64 ;; *) die "only ARM64 and x86_64 are supported" ;; esac + if [ "$restoring" = true ]; then + [ "$RID" = "$detected_rid" ] || die "saved updater configuration belongs to a different architecture" + fi + RID=$detected_rid + case "$CHANNEL" in release|dev) ;; *) die "MOSHIRO_CHANNEL must be release or dev" ;; esac + if [ "$restoring" = false ]; then + : "${MOSHIRO_SERVER:?panel URL required}" "${MOSHIRO_CODE:?join token required}" "${MOSHIRO_GENESIS:?trust root required}" "${MOSHIRO_CONTROLLERS:?owner required}" + case "$MOSHIRO_SERVER" in https://*) ;; *) die "panel must use HTTPS" ;; esac + case "$MOSHIRO_SERVER" in *[!A-Za-z0-9:/._-]*) die "unsupported panel URL characters" ;; esac + [ ! -e /etc/moshiro/node.json ] && [ ! -e /etc/moshiro/probe.json ] || die "existing native identity/config found; migrate explicitly instead of overwriting it" + else + [ -f /etc/moshiro/node.json ] && [ -f /etc/moshiro/trust.json ] && [ -f /etc/moshiro/probe.json ] || die "saved identity/config incomplete; restore a backup before reinstalling" + fi + acquire || die "another installation is running" + if [ ! -f "$STATE/release-key.pem" ]; then + cat > "$STATE/release-key.pem" <<'KEY' +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfw7EkNcl0FZqOETK+Vt55RMZ9K0q +YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA== +-----END PUBLIC KEY----- +KEY + fi + verify || die "no valid signed OpenWrt release for $CHANNEL/$RID" + high=$(cat "$STATE/$CHANNEL.seq" 2>/dev/null || echo 0) + [ "$SEQ" -ge "$high" ] || die "refusing to reinstall an older release" + stage || die "could not validate and unpack release" + id moshiro >/dev/null 2>&1 || { + uid=900 + while awk -F: -v uid="$uid" '$3 == uid {found=1} END {exit !found}' /etc/passwd /etc/group; do uid=$((uid + 1)); done + echo "moshiro:x:$uid:" >> /etc/group + echo "moshiro:x:$uid:$uid:MoShiro:/var/run/moshiro-probe:/bin/false" >> /etc/passwd + } + mkdir -p /etc/moshiro "$RUNTIME" + chmod 750 /etc/moshiro "$RUNTIME" + # Enrollment tool is a one-shot trusted installer action. No dependency on BusyBox su. + if [ "$restoring" = false ]; then + LD_LIBRARY_PATH="$ROOT/versions/$HASH" "$ROOT/versions/$HASH/fleet-enroll" \ + --server "$MOSHIRO_SERVER" --join "$MOSHIRO_CODE" --name "${MOSHIRO_NAME:-$(uname -n)}" \ + --identity-dir /etc/moshiro --genesis "$MOSHIRO_GENESIS" --controllers "$MOSHIRO_CONTROLLERS" + printf '{"fleet":{"server":"%s","identity_dir":"/etc/moshiro"},"limits":{"max_test_mbps":20,"max_total_pps":2000}}\n' "$MOSHIRO_SERVER" > /etc/moshiro/probe.json + fi + chown -R moshiro:moshiro /etc/moshiro "$RUNTIME" + chmod 600 /etc/moshiro/node.key /etc/moshiro/tls.pfx + if [ "$restoring" = false ]; then + printf "FEED='%s'\nRID='%s'\nCHANNEL='%s'\nPOLICY='auto'\nINTERVAL=30\n" "$FEED" "$RID" "$CHANNEL" > "$CONF" + fi + chmod 600 "$CONF" + switch_to "versions/$HASH" + mkdir -p /usr/libexec + ln -sf "$ROOT/current/openwrt.sh" /usr/libexec/moshiro-openwrt + cp "$ROOT/current/probe.init" /etc/init.d/moshiro-probe + cp "$ROOT/current/updater.init" /etc/init.d/moshiro-probe-update + chmod 755 /etc/init.d/moshiro-probe /etc/init.d/moshiro-probe-update + mkdir -p /lib/upgrade/keep.d + printf '/etc/moshiro/\n%s\n%s/\n' "$CONF" "$STATE" > /lib/upgrade/keep.d/moshiro + printf '%s\n' "$SEQ" > "$STATE/$CHANNEL.seq" + status applying + ctl enable + started=$(date +%s) + ctl restart + if ! healthy; then + status failed 'initial installation did not complete a Backend heartbeat; inspect logread' + die "installed but not healthy; inspect logread -e moshiro, then rerun install (identity is preserved)" + fi + status up_to_date + /etc/init.d/moshiro-probe-update enable + /etc/init.d/moshiro-probe-update restart + log "installed $VERSION ($CHANNEL/$RID); automatic signed updates enabled" +} + +dependencies +case "$ACTION" in + install) install ;; + check) load_config; check ;; + daemon) + load_config + ticks=$INTERVAL + while :; do + if [ "$ticks" -ge "$INTERVAL" ] || [ -f "$RUNTIME/check.request" ] || [ -f "$RUNTIME/apply.request" ]; then + rm -f "$RUNTIME/check.request" + /usr/libexec/moshiro-openwrt check || true + ticks=0 + fi + sleep 1 + ticks=$((ticks + 1)) + done ;; + *) die "usage: $0 install|check|daemon" ;; +esac diff --git a/packaging/openwrt/native-probe.init b/packaging/openwrt/native-probe.init new file mode 100644 index 0000000..2df9963 --- /dev/null +++ b/packaging/openwrt/native-probe.init @@ -0,0 +1,21 @@ +#!/bin/sh /etc/rc.common +START=95 +STOP=10 +USE_PROCD=1 + +start_service() { + [ -f /etc/moshiro/probe.json ] || return 1 + mkdir -p /var/run/moshiro-probe + chown moshiro:moshiro /var/run/moshiro-probe + chmod 750 /var/run/moshiro-probe + procd_open_instance + procd_set_param command /usr/lib/moshiro-native/current/moshiro-probe --config /etc/moshiro/probe.json + procd_set_param env LD_LIBRARY_PATH=/usr/lib/moshiro-native/current MOSHIRO_UPDATE=openwrt + procd_set_param user moshiro + procd_set_param group moshiro + procd_set_param respawn 3600 5 0 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param limits nofile=4096 + procd_close_instance +} diff --git a/packaging/openwrt/updater.init b/packaging/openwrt/updater.init new file mode 100644 index 0000000..5dbaa54 --- /dev/null +++ b/packaging/openwrt/updater.init @@ -0,0 +1,14 @@ +#!/bin/sh /etc/rc.common +START=96 +STOP=9 +USE_PROCD=1 + +start_service() { + [ -f /etc/moshiro-native.conf ] || return 1 + procd_open_instance + procd_set_param command /usr/libexec/moshiro-openwrt daemon + procd_set_param respawn 3600 5 0 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} diff --git a/scripts/test-openwrt-updater.py b/scripts/test-openwrt-updater.py new file mode 100644 index 0000000..5cc1284 --- /dev/null +++ b/scripts/test-openwrt-updater.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Isolated native-updater integration tests: real shell/tar/OpenSSL, fake network/procd. + +No root, containers, network access or writes to the host's /etc are needed. +""" +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +UPDATER = ROOT / "packaging/openwrt/install.sh" + +SHIM = r''' +import json, os, pathlib, shutil, sys, time +name = pathlib.Path(sys.argv[0]).name +base = pathlib.Path(os.environ['FIXTURE']) +if name == 'curl': + args = sys.argv[1:] + url = next(x for x in args if x.startswith('https://test.invalid/')) + package_api = '/api/packages/justme/generic/moshiro-openwrt/' + if package_api in url: + destination = base / 'published' / url.split(package_api, 1)[1] + if '--request' in args: + code = 204 if destination.exists() else 404 + destination.unlink(missing_ok=True) + print(code, end='') + else: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(args[args.index('--upload-file') + 1], destination) + print(201, end='') + sys.exit(0) + source = base / 'feed' / url.split('/feed/', 1)[1] + if not source.is_file(): sys.exit(22) + shutil.copyfile(source, args[args.index('-o') + 1]) +elif name == 'jsonfilter': + args = sys.argv[1:] + data = json.loads(pathlib.Path(args[args.index('-i') + 1]).read_text() if '-i' in args else sys.stdin.read()) + query = args[args.index('-e') + 1] + value = data['moshiro-probe']['instances']['default']['pid'] if query.startswith('@[') else data.get(query[2:]) + if value is None: sys.exit(1) + print(value) +elif name == 'ubus': + print(json.dumps({'moshiro-probe': {'instances': {'default': {'pid': 1234}}}})) +elif name == 'service': + with (base / 'restarts').open('a') as f: f.write('restart\n') + if os.environ.get('HEALTH') != 'good': sys.exit(0) + manifest = json.loads((base / 'feed/dev/manifest-linux-musl-arm64.json').read_text()) + runtime = base / 'runtime' + runtime.mkdir(exist_ok=True) + (runtime / 'health.json').write_text(json.dumps({'version': manifest['version'], 'pid': 1234, 'at': int(time.time())})) +''' + + +class UpdaterTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="moshiro-openwrt-", dir="/tmp/opencode" if Path("/tmp/opencode").is_dir() else None) + self.base = Path(self.temp.name) + self.root = self.base / "installation" + self.state = self.base / "state" + self.runtime = self.base / "runtime" + self.bin = self.base / "bin" + for path in (self.state, self.runtime, self.bin, self.base / "feed/dev"): + path.mkdir(parents=True) + self.old = "a" * 64 + (self.root / "versions" / self.old).mkdir(parents=True) + (self.root / "current").symlink_to(f"versions/{self.old}") + self.key = self.base / "key.pem" + subprocess.run(["openssl", "ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", str(self.key)], check=True) + subprocess.run(["openssl", "pkey", "-in", str(self.key), "-pubout", "-out", str(self.state / "release-key.pem")], check=True) + for name in ("curl", "jsonfilter", "ubus", "service"): + path = self.bin / name + path.write_text(f"#!{sys.executable}\n" + SHIM) + path.chmod(0o755) + self.conf = self.base / "config" + self.conf.write_text("FEED='https://test.invalid/feed'\nCHANNEL=dev\nRID=linux-musl-arm64\nPOLICY=auto\nINTERVAL=30\nHEALTH_TIMEOUT=2\nHEALTH_OBSERVE=0\n") + self.env = dict(os.environ, PATH=str(self.bin) + ":" + os.environ["PATH"], + FIXTURE=str(self.base), HEALTH="good", MOSHIRO_NATIVE_TEST="1", + MOSHIRO_NATIVE_ROOT=str(self.root), MOSHIRO_NATIVE_CONF=str(self.conf), + MOSHIRO_NATIVE_STATE=str(self.state), MOSHIRO_NATIVE_STATUS=str(self.base / "status"), + MOSHIRO_NATIVE_RUNTIME=str(self.runtime), MOSHIRO_NATIVE_SERVICE=str(self.bin / "service")) + self.publish() + + def tearDown(self): + self.temp.cleanup() + + def publish(self, *, seq=100, version="1.0.1-dev.1", members=None, changes=None): + payload = io.BytesIO() + members = members or {name: b"executable\n" for name in ("moshiro-probe", "fleet-enroll", "openwrt.sh", "probe.init", "updater.init")} + with tarfile.open(fileobj=payload, mode="w:gz") as archive: + for name, content in members.items(): + entry = tarfile.TarInfo(name) + entry.size = len(content) + archive.addfile(entry, io.BytesIO(content)) + data = payload.getvalue() + self.hash = hashlib.sha256(data).hexdigest() + package = f"probe-linux-musl-arm64-{self.hash}.tar.gz" + version_dir = self.base / "feed" / version + version_dir.mkdir(exist_ok=True) + (version_dir / package).write_bytes(data) + self.manifest = dict(schema=1, component="probe", channel="dev", rid="linux-musl-arm64", version=version, + package=package, sha256=self.hash, size=len(data), seq=seq) + self.manifest.update(changes or {}) + self.sign() + + def sign(self): + path = self.base / "feed/dev/manifest-linux-musl-arm64.json" + path.write_text(json.dumps(self.manifest, separators=(",", ":"))) + sig = subprocess.run(["openssl", "dgst", "-sha256", "-sign", str(self.key), str(path)], capture_output=True, check=True).stdout + import base64 + path.with_suffix(".sig").write_bytes(base64.b64encode(sig)) + + def run_check(self, success=True): + result = subprocess.run(["sh", str(UPDATER), "check"], env=self.env, capture_output=True, text=True) + if success: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + else: + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + return result + + def current(self): + return os.readlink(self.root / "current") + + def status(self): + return json.loads((self.base / "status/status.json").read_text())["state"] + + def test_healthy_update_switches_atomically_and_records_sequence(self): + self.run_check() + self.assertEqual(self.current(), f"versions/{self.hash}") + self.assertEqual((self.state / "dev.seq").read_text().strip(), "100") + self.assertEqual(self.status(), "up_to_date") + self.assertTrue((self.root / "versions" / self.old).is_dir()) + self.assertFalse((self.state / "pending").exists()) + self.run_check() # Idempotent; no second restart. + self.assertEqual((self.base / "restarts").read_text().count("restart"), 1) + + def test_ci_publisher_produces_a_signed_package_the_updater_can_install(self): + binaries = self.base / "binaries" + binaries.mkdir() + for name in ("moshiro-probe", "fleet-enroll"): + (binaries / name).write_bytes(b"native executable fixture\n") + env = dict(self.env, CI_COMMIT_TAG="", CI_PIPELINE_EVENT="push", CI_COMMIT_BRANCH="main", CI_COMMIT_MESSAGE="[CI] fixture", GITEA_BASE="https://test.invalid", + GITEA_TOKEN="test-token", RELEASE_SIGNING_KEY=self.key.read_text()) + result = subprocess.run(["bash", str(ROOT / "ci/publish-openwrt.sh"), "dev", "linux-musl-arm64", str(binaries)], + env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + shutil.copytree(self.base / "published", self.base / "feed", dirs_exist_ok=True) + metadata = json.loads((self.base / "published/dev/manifest-linux-musl-arm64.json").read_text()) + self.run_check() + self.assertEqual(self.current(), "versions/" + metadata["sha256"]) + self.assertEqual(self.status(), "up_to_date") + + def test_unhealthy_update_rolls_back_and_is_not_retried(self): + self.env["HEALTH"] = "bad" + self.run_check(False) + self.assertEqual(self.current(), f"versions/{self.old}") + self.assertEqual(self.status(), "rolled_back") + self.run_check() + self.assertEqual((self.base / "restarts").read_text().count("restart"), 2) + + def test_tampered_manifest_never_changes_current(self): + path = self.base / "feed/dev/manifest-linux-musl-arm64.json" + path.write_text(path.read_text().replace('"seq":100', '"seq":101')) + self.run_check(False) + self.assertEqual(self.status(), "invalid") + self.assertEqual(self.current(), f"versions/{self.old}") + + def test_wrong_signing_key_is_rejected(self): + other_key = self.base / "other.pem" + subprocess.run(["openssl", "ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", str(other_key)], check=True) + subprocess.run(["openssl", "pkey", "-in", str(other_key), "-pubout", "-out", str(self.state / "release-key.pem")], check=True) + self.run_check(False) + self.assertEqual(self.current(), f"versions/{self.old}") + self.assertEqual(self.status(), "invalid") + + def test_tampered_archive_never_changes_current(self): + path = self.base / "feed" / self.manifest["version"] / self.manifest["package"] + data = path.read_bytes() + path.write_bytes(b"X" + data[1:]) + self.run_check(False) + self.assertEqual(self.current(), f"versions/{self.old}") + self.assertFalse((self.state / "dev.seq").exists()) + + def test_wrong_architecture_channel_schema_and_url_are_rejected(self): + for changes in ({"rid": "linux-musl-x64"}, {"channel": "release"}, {"schema": 2}, + {"package": "../../evil"}, {"version": ".."}, {"sha256": "0" * 63}, {"seq": "999999999999999999999999999999"}): + with self.subTest(changes=changes): + self.publish(changes=changes) + self.run_check(False) + self.assertEqual(self.current(), f"versions/{self.old}") + + def test_archive_path_traversal_is_rejected_before_extraction(self): + self.publish(members={"../../escape": b"bad"}) + self.run_check(False) + self.assertFalse((self.base / "escape").exists()) + self.assertEqual(self.current(), f"versions/{self.old}") + + def test_old_sequence_does_not_replace_current(self): + (self.state / "dev.seq").write_text("101\n") + self.run_check() + self.assertEqual(self.current(), f"versions/{self.old}") + self.assertFalse((self.base / "restarts").exists()) + + def test_manual_policy_requires_request_and_consumes_it(self): + self.conf.write_text(self.conf.read_text().replace("POLICY=auto", "POLICY=manual")) + self.run_check() + self.assertEqual(self.status(), "available") + self.assertEqual(self.current(), f"versions/{self.old}") + (self.runtime / "apply.request").write_text("untrusted content is never executed") + self.run_check() + self.assertEqual(self.current(), f"versions/{self.hash}") + self.assertFalse((self.runtime / "apply.request").exists()) + + def test_interrupted_update_recovers_previous_version_before_fetching(self): + candidate = self.root / "versions" / self.hash + candidate.mkdir() + (self.root / "current").unlink() + (self.root / "current").symlink_to(f"versions/{self.hash}") + (self.state / "pending").write_text(f"versions/{self.old}\n") + (self.state / "dev.seq").write_text("100\n") + self.run_check() + self.assertEqual(self.current(), f"versions/{self.old}") + self.assertEqual(self.status(), "rolled_back") + self.assertFalse((self.state / "pending").exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/web/admin/src/pages/JoinTokenCreated.tsx b/web/admin/src/pages/JoinTokenCreated.tsx index e880dd6..7853cc4 100644 --- a/web/admin/src/pages/JoinTokenCreated.tsx +++ b/web/admin/src/pages/JoinTokenCreated.tsx @@ -30,6 +30,14 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ >
一次性码只显示这一次,请现在就复制。下面只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。
+ {kind === 'Probe' && script.openwrt && ( + + + + )}