probe: add native OpenWrt deployment and signed automatic updates [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/dev-image Pipeline failed

This commit is contained in:
justme committed 2026-10-20 14:15:01 +08:00
1 parent 24ba05fbb6
commit cd193378a6
26 files changed
+987 -9

No files matched your search

+6
View File
@@ -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:
+6
View File
@@ -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.
+1 -1
View File
@@ -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:<version>`)和信任的公钥写在节点本地配置里,面板只能「建议」;支持多把公钥以便轮换。
- 清单带 `app`、`channel`、`version`、`min_from`、各 RID 制品的 `sha256` 和大小、`key_id`、签名(对规范化 JSON 签名);本地记录已装过的最高版本,拒绝降级和旧清单重放。
+19
View File
@@ -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"
},
+7 -1
View File
@@ -36,10 +36,16 @@ dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -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 …` 会一并完成)。随后运行:
@@ -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;
/// <summary>
/// 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.
/// </summary>
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));
}
}
/// <summary>Called by the Fleet heartbeat: evidence that this version reached Backend.</summary>
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);
+4 -1
View File
@@ -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, _) =>
@@ -84,6 +84,7 @@ public sealed class ProbeLimits
[JsonSerializable(typeof(ProbeFile))]
[JsonSerializable(typeof(AgentsCache))]
[JsonSerializable(typeof(OpenWrtHealth))]
public sealed partial class ProbeConfigContext : JsonSerializerContext;
/// <summary><c>agents.json</c>:最近一次从面板同步到的 Agent 列表(配置,不是测量数据);面板连不上时 Probe 靠它继续测量。</summary>
@@ -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);
}
}
}
@@ -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<CreatedJoinToken> 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<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct)
@@ -88,7 +88,7 @@ public sealed record SetupStatusDto(bool NeedsSetup);
/// <param name="ClaimCode">首次启动时写在后端日志里的认领码(或者部署时指定的 <c>MoShiro__ClaimCode</c>)。</param>
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 = "");
/// <param name="Env">要存成 <c>.env</c> 的内容(只有公开的值)。</param>
/// <param name="Url">compose 文件的下载地址。</param>
@@ -8,6 +8,7 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
<EmbeddedResource Include="..\..\..\packaging\openwrt\install.sh" LogicalName="openwrt.sh" />
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
<EmbeddedResource Include="..\..\..\packaging\compose\agent\compose.yaml" LogicalName="compose-agent.yaml" />
@@ -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");
@@ -8,7 +8,7 @@ namespace MoShiro.Backend.Application;
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null);
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
public sealed record InstallCommands(string Linux, string Macos, string Windows);
public sealed record InstallCommands(string Linux, string Macos, string Windows, string Openwrt = "");
/// <summary>
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
@@ -16,7 +16,7 @@ public sealed record InstallCommands(string Linux, string Macos, string Windows)
/// </summary>
/// <param name="ComposeSha256">面板提供的这个角色的 compose 文件的 SHA-256(命令下载之后先核对)。</param>
/// <param name="QuadletSha256">面板提供的这个角色的 Podman Quadlet 单元的 SHA-256。</param>
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "", string QuadletSha256 = "");
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "", string QuadletSha256 = "", string OpenwrtSha256 = "", string NativeChannel = "release");
/// <summary>
/// compose 安装:<c>Env</c> 是要存成 <c>.env</c> 的内容(只有公开的值),<c>Url</c> 和 <c>Sha256</c> 是 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"
: "");
}
/// <summary>
@@ -22,6 +22,21 @@ public sealed class InstallTests : IClassFixture<BackendFixture>
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<string>());
}
[Theory]
[InlineData("agent")]
[InlineData("probe")]
@@ -70,6 +70,15 @@ public sealed class TrustTests : IClassFixture<BackendFixture>
Assert.Contains("shasum -a 256 -c -", commands["macos"]!.GetValue<string>(), StringComparison.Ordinal);
string windowsSha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install.ps1"))));
Assert.Contains($"-ne '{windowsSha}'", commands["windows"]!.GetValue<string>(), StringComparison.Ordinal);
string openwrt = commands["openwrt"]!.GetValue<string>();
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]
+14
View File
@@ -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
+68
View File
@@ -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 <dev|release> <rid> <binary-directory>}"
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)"
+5
View File
@@ -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"
+18
View File
@@ -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` 调低。
+361
View File
@@ -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
+21
View File
@@ -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
}
+14
View File
@@ -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
}
+235
View File
@@ -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)
+8
View File
@@ -30,6 +30,14 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
>
<div className="stack">
<div className="notice warn">一次性码只显示这一次,请现在就复制。下面只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。</div>
{kind === 'Probe' && script.openwrt && (
<Field
label="OpenWrt · 原生二进制(自动更新)"
hint="以 root 运行,支持 ARM64 和 x86_64,不需要 Docker/Podman。通过 curl 或 OpenWrt 自带的 uclient-fetch 下载,缺少依赖时自动用 opkg/apk 安装。动态 musl 正式包需 libopenssl3。自动注册属主并配置 procd;发布后自动验签、安装,失败回滚。默认限制 20 Mbps,可在 /etc/moshiro/probe.json 修改。"
>
<CommandTabs storageKey="moshiro.openwrt_tab" initial="openwrt" tabs={[{ id: 'openwrt', label: 'OpenWrt · procd', command: script.openwrt }]} />
</Field>
)}
<Field
label={`Podman(推荐,自动更新):在要加入的机器上运行(${kind})`}
hint="Linux,Podman 4.6 及以上,用普通用户运行(rootless,不用 sudo)。命令把参数写进 ~/.config/moshiro/(权限 600),下载 Quadlet 单元并核对 SHA-256,一致才启动。退出登录后继续运行需要管理员执行一次 loginctl enable-linger 用户名。节点名字默认取主机名;想自己定,在 env 里加一行 MOSHIRO_NAME=名字(1–64 个字母、数字、点、下划线、横线)。"
+35
View File
@@ -1161,6 +1161,39 @@ export interface paths {
patch?: never;
trace?: never;
};
"/install/openwrt.sh": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/install": {
parameters: {
query?: never;
@@ -1671,6 +1704,8 @@ export interface components {
linux: string;
macos: string;
windows: string;
/** @default */
openwrt: string;
};
Ipv6DiagnosticDto: {
address_configured: null | boolean;