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

+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);
}
}
}