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

+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]