feat(update): update the backend like nodes via quadlet restart [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-18 00:36:38 +08:00
1 parent 34190f5a04
commit 68c7113adf
23 files changed
+304 -352

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点由退出后 systemd 重新拉镜像、Backend 和脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点和 Backend 由退出后 systemd 重新拉镜像、脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
## 身份
@@ -52,7 +52,6 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<ReleaseService>();
services.AddSingleton<ScheduleService>();
services.AddSingleton<BackendUpdates>();
services.AddHostedService(p => p.GetRequiredService<BackendUpdates>());
services.AddSingleton<JoinTokenService>();
services.AddSingleton<RawBrowserService>();
services.AddSingleton<EventService>();
@@ -15,11 +15,11 @@ public sealed class BackendOptions
/// <summary>节点镜像所在的仓库地址(管理端「添加 Agent / Probe」生成的容器命令用它拼镜像名)。</summary>
public string NodeImageRegistry { get; set; } = "registry.oeo.one";
/// <summary>容器内更新请求目录;留空使用数据目录下的 update/。</summary>
public string? BackendUpdateDirectory { get; set; }
/// <summary>宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。</summary>
public string? BackendUpdateStatusDirectory { get; set; }
/// <summary>
/// 更新方式:<c>restart</c>(环境变量 <c>MOSHIRO_UPDATE=restart</c>,Quadlet 模板里写好了)表示管理员确认之后 Backend 有序退出,外面的服务管理器拉最新镜像再启动,和 Agent、Probe 一样;
/// 留空就不能从管理端更新(别的部署方式退出之后不会重新拉取)。
/// </summary>
public string? UpdateMode { get; set; } = Environment.GetEnvironmentVariable("MOSHIRO_UPDATE");
/// <summary>
/// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
@@ -1,35 +1,53 @@
using System.Globalization;
using Fleet.Core;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>宿主机更新器的文件信箱端口。面板只写签名清单和确认的 seq,不执行命令。</summary>
public interface IBackendUpdateFiles
/// <summary>让外面的服务管理器(Quadlet 的 <c>Pull=always</c> 加 <c>Restart=always</c>)换上新镜像:Backend 只负责有序退出。</summary>
public interface IBackendRestart
{
ValueTask WriteOfferAsync(string manifest, string signature, CancellationToken ct);
void Request();
}
ValueTask<UpdateStatus?> ReadStatusAsync(CancellationToken ct);
ValueTask<long?> ReadRequestAsync(CancellationToken ct);
ValueTask RequestAsync(long seq, CancellationToken ct);
/// <summary>更新之前备份数据库:新版启动时会迁移数据库,旧版不认比自己新的库,回滚要靠这份备份。</summary>
public interface IDatabaseBackup
{
/// <summary>在 <paramref name="directory"/> 里写一份一致的数据库副本,返回文件路径。</summary>
ValueTask<string> BackupAsync(string directory, string name, CancellationToken ct);
}
public sealed record BackendUpdateView(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseOffer? Available, string? Error);
/// <summary>Backend 的发布通知和确认。新清单只显示为可用更新,宿主机必须收到对应 seq 的确认才会重启。</summary>
public sealed class BackendUpdates : IHostedService
/// <summary>
/// Backend 的发布通知和确认,和 Agent、Probe 是同一种更新:面板登记 CI 签名的清单,管理员确认之后 Backend 有序退出,
/// Quadlet 单元每次启动都先拉最新镜像(<c>Pull=always</c>),所以退出再被拉起就是更新。Backend 自己不下载、不替换任何东西。
/// 只有设了环境变量 <c>MOSHIRO_UPDATE=restart</c>(Quadlet 模板里写好了)才启用;别的部署方式退出之后不会重新拉取。
/// 只提示不自动重启:Backend 指挥所有节点,重启会让它们全部断一两分钟。
/// </summary>
public sealed partial class BackendUpdates
{
private readonly ReleaseService _releases;
private readonly IBackendUpdateFiles _files;
private readonly BackendOptions _options;
/// <summary>更新前的数据库备份保留几份。</summary>
private const int BackupsKept = 3;
public BackendUpdates(ReleaseService releases, IBackendUpdateFiles files, BackendOptions options)
private readonly ReleaseService _releases;
private readonly BackendOptions _options;
private readonly IBackendRestart _restart;
private readonly IDatabaseBackup _backup;
private readonly ILogger<BackendUpdates> _log;
private readonly TimeProvider _time;
private readonly object _gate = new();
private long _applyingSeq;
public BackendUpdates(ReleaseService releases, BackendOptions options, IBackendRestart restart, IDatabaseBackup backup, ILogger<BackendUpdates> log, TimeProvider time)
{
_releases = releases;
_files = files;
_options = options;
_restart = restart;
_backup = backup;
_log = log;
_time = time;
}
public static string ChannelOf(BackendOptions options)
@@ -63,45 +81,63 @@ public sealed class BackendUpdates : IHostedService
tag is not null
&& (tag.Contains("-dev.", StringComparison.Ordinal) || tag.StartsWith("dev-", StringComparison.Ordinal) || tag.StartsWith("main-", StringComparison.Ordinal));
/// <summary>环境变量 <c>MOSHIRO_UPDATE=restart</c> 才启用。</summary>
public bool Enabled => string.Equals(_options.UpdateMode, "restart", StringComparison.Ordinal);
public async ValueTask<BackendUpdateView> GetAsync(CancellationToken ct)
{
string channel = ChannelOf(_options);
ReleaseOffer? offer = await _releases.GetBackendAsync(channel, ct);
UpdateStatus? status = await _files.ReadStatusAsync(ct);
bool installed = offer is not null && (status?.Current == offer.Digest || BuildInfo.Tag == offer.Tag);
// 只有更新器确认过同一份清单才允许按钮执行;旧状态不能被套到新的清单上。
bool verified = offer is not null && status?.Available?.Seq == offer.Seq && status.Available.Digest == offer.Digest;
string state = status?.State ?? "unavailable";
if (installed)
bool installed = offer is not null && IsInstalled(offer);
long applying;
lock (_gate)
{
state = "up_to_date";
}
else if (offer is not null && !verified && state is not ("invalid" or "failed" or "rolled_back"))
{
state = status is null ? "unavailable" : "checking";
applying = _applyingSeq;
}
if (verified && state == "available" && await _files.ReadRequestAsync(ct) == offer!.Seq)
{
state = "requested";
}
return new BackendUpdateView(channel, status is not null, state, BuildInfo.Tag, status?.Current, installed ? null : offer, status?.Error);
string state = !Enabled ? "unavailable"
: installed || offer is null ? "up_to_date"
: applying == offer.Seq ? "applying"
: "available";
return new BackendUpdateView(channel, Enabled, state, BuildInfo.Tag, null, installed ? null : offer, null);
}
public async ValueTask<Result<bool>> ConfirmAsync(long seq, CancellationToken ct)
{
// 与 webhook 共用发布锁:确认之后即使又来了一个 tag,信箱中的请求仍只认本次确认的 seq。
// 与 webhook 共用发布锁:确认的是此刻面板上登记的那一份清单,确认之后又来了新 tag 也不会改变要更新到哪里。
await _releases.BackendGate.WaitAsync(ct);
try
{
BackendUpdateView view = await GetAsync(ct);
if (!view.Enabled || view.State != "available" || view.Available?.Seq != seq)
{
return Result<bool>.Fail(MoShiroErrors.PreconditionFailed, "the offered release changed or has not been verified by the host updater; refresh before confirming");
return Result<bool>.Fail(MoShiroErrors.PreconditionFailed, view.Enabled
? "the offered release changed or is already installed; refresh before confirming"
: "updating needs MOSHIRO_UPDATE=restart (the Quadlet unit sets it)");
}
await _files.RequestAsync(seq, ct);
string name = $"pre-update-{_time.GetUtcNow():yyyyMMddTHHmmssZ}-{seq}.db";
string directory = Path.Combine(_options.DataDirectory, "backups");
try
{
string path = await _backup.BackupAsync(directory, name, ct);
BackedUp(path);
Prune(directory);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException)
{
// 备份不了就不更新:旧版不认新库,没有备份就没有回头路
BackupFailed(ex);
return Result<bool>.Fail(MoShiroErrors.Internal, "the database could not be backed up; the update was not started");
}
lock (_gate)
{
_applyingSeq = seq;
}
Confirmed(view.Available.Tag);
_restart.Request();
return Result<bool>.Ok(true);
}
finally
@@ -110,14 +146,31 @@ public sealed class BackendUpdates : IHostedService
}
}
public async Task StartAsync(CancellationToken cancellationToken)
[LoggerMessage(Level = LogLevel.Information, Message = "database backed up before the update to {Path}")]
private partial void BackedUp(string path);
[LoggerMessage(Level = LogLevel.Error, Message = "the database backup failed; the update was not started")]
private partial void BackupFailed(Exception ex);
[LoggerMessage(Level = LogLevel.Information, Message = "update to {Tag} confirmed; exiting so the supervisor pulls the latest image")]
private partial void Confirmed(string tag);
private static bool IsInstalled(ReleaseOffer offer)
{
string channel = ChannelOf(_options);
if (await _releases.GetBackendAsync(channel, cancellationToken) is { } offer)
if (BuildInfo.Tag.Length > 0 && offer.Tag == BuildInfo.Tag)
{
await _files.WriteOfferAsync(offer.Manifest, offer.Signature, cancellationToken);
}
return true;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// 清单的 seq 是发布时间(秒):不比正在运行的这个构建新,就没有更新
return DateTimeOffset.TryParse(BuildInfo.BuiltAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset built) && offer.Seq <= built.ToUnixTimeSeconds();
}
private static void Prune(string directory)
{
foreach (FileInfo old in new DirectoryInfo(directory).GetFiles("pre-update-*.db").OrderByDescending(f => f.Name, StringComparer.Ordinal).Skip(BackupsKept))
{
old.Delete();
}
}
}
@@ -24,20 +24,18 @@ public sealed partial class ReleaseService : IDisposable
private readonly ISettingsStore _settings;
private readonly IAgentGateway _gateway;
private readonly IBackendUpdateFiles _backendFiles;
private readonly BackendOptions _options;
private readonly ReleaseKeyService _keys;
private readonly ReleaseFeed _feed;
private readonly ConcurrentDictionary<string, long> _feedChecked = new();
private readonly ConcurrentDictionary<string, UpdateStatus> _status = new();
public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options, ReleaseKeyService keys, ReleaseFeed feed)
public ReleaseService(ISettingsStore settings, IAgentGateway gateway, BackendOptions options, ReleaseKeyService keys, ReleaseFeed feed)
{
_keys = keys;
_feed = feed;
_settings = settings;
_gateway = gateway;
_backendFiles = backendFiles;
_options = options;
}
@@ -71,14 +69,7 @@ public sealed partial class ReleaseService : IDisposable
string componentKey = backend ? BackendComponent + "." + offer.Channel : offer.Component;
await _settings.SetAsync(Key(componentKey, "manifest"), offer.Manifest, ct);
await _settings.SetAsync(Key(componentKey, "signature"), offer.Signature, ct);
if (backend)
{
if (offer.Channel == BackendUpdates.ChannelOf(_options))
{
await _backendFiles.WriteOfferAsync(offer.Manifest, offer.Signature, ct);
}
}
else
if (!backend)
{
foreach (NodeId node in OnlineOf(offer.Component))
{
@@ -1,89 +0,0 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Storage.Sqlite;
/// <summary>Backend 到宿主机的文件信箱。状态卷只读挂载,业务进程改写不了验签和安装结果。</summary>
public sealed class BackendUpdateFiles : IBackendUpdateFiles
{
private readonly string _requests;
private readonly string _status;
public BackendUpdateFiles(BackendOptions options)
{
_requests = Path.GetFullPath(options.BackendUpdateDirectory ?? Path.Combine(options.DataDirectory, "update"));
_status = Path.GetFullPath(options.BackendUpdateStatusDirectory ?? Path.Combine(options.DataDirectory, "update-status"));
}
public async ValueTask WriteOfferAsync(string manifest, string signature, CancellationToken ct)
{
await WriteAsync("offer.manifest", manifest, ct);
await WriteAsync("offer.sig", signature, ct);
}
public async ValueTask<UpdateStatus?> ReadStatusAsync(CancellationToken ct)
{
string? json = await ReadAsync(Path.Combine(_status, "status.json"), 4096, ct);
if (json is null)
{
return null;
}
try
{
return JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.UpdateStatus);
}
catch (JsonException)
{
return null;
}
}
public async ValueTask<long?> ReadRequestAsync(CancellationToken ct)
{
string? request = await ReadAsync(Path.Combine(_requests, "apply.request"), 64, ct);
return long.TryParse(request, NumberStyles.None, CultureInfo.InvariantCulture, out long seq) ? seq : null;
}
public ValueTask RequestAsync(long seq, CancellationToken ct) => WriteAsync("apply.request", seq.ToString(CultureInfo.InvariantCulture), ct);
private async ValueTask WriteAsync(string name, string value, CancellationToken ct)
{
Directory.CreateDirectory(_requests);
string path = Path.Combine(_requests, name);
string temporary = Path.Combine(_requests, $".{name}.{Guid.NewGuid():N}");
try
{
await File.WriteAllTextAsync(temporary, value, new UTF8Encoding(false), ct);
File.Move(temporary, path, overwrite: true);
}
finally
{
if (File.Exists(temporary))
{
File.Delete(temporary);
}
}
}
private static async ValueTask<string?> ReadAsync(string path, int limit, CancellationToken ct)
{
try
{
var file = new FileInfo(path);
if (!file.Exists || file.LinkTarget is not null || file.Length > limit)
{
return null;
}
return await File.ReadAllTextAsync(path, ct);
}
catch (IOException)
{
return null; // 更新器正做原子替换,下一次读取再试
}
}
}
@@ -0,0 +1,37 @@
using Microsoft.Data.Sqlite;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Storage.Sqlite;
/// <summary>用 <c>VACUUM INTO</c> 写一份一致的数据库副本:不停服务,WAL 里还没合并的写入也在里面。</summary>
public sealed class SqliteDatabaseBackup : IDatabaseBackup
{
private readonly SqliteDatabase _database;
public SqliteDatabaseBackup(SqliteDatabase database)
{
_database = database;
}
public ValueTask<string> BackupAsync(string directory, string name, CancellationToken ct)
{
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, name);
try
{
_database.Read(connection =>
{
using SqliteCommand command = connection.CreateCommand();
command.CommandText = "VACUUM INTO $path";
command.Parameters.AddWithValue("$path", path);
return command.ExecuteNonQuery();
});
}
catch (SqliteException ex)
{
throw new IOException("the database backup failed: " + ex.Message, ex);
}
return ValueTask.FromResult(path);
}
}
@@ -27,7 +27,7 @@ public static class StorageServiceCollectionExtensions
services.AddSingleton<IRawStore, SqliteRawStore>();
services.AddSingleton<IMetricStore, SqliteMetricStore>();
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();
services.AddSingleton<IBackendUpdateFiles, BackendUpdateFiles>();
services.AddSingleton<IDatabaseBackup, SqliteDatabaseBackup>();
return services;
}
}
@@ -48,6 +48,7 @@ public static class BackendHost
services.AddSingleton<IGeoLookup>(new HttpGeoLookup(options.GeoLookupUrl));
}
services.AddSingleton<IBackendRestart, HostBackendRestart>();
services.AddMoShiroApplication(options);
services.AddMoShiroGateway();
services.AddMoShiroApi();
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Hosting;
using MoShiro.Backend.Application;
namespace MoShiro.Backend;
/// <summary>更新确认之后让 Backend 有序退出:晚一会儿再停,先把确认请求的响应送出去。外面的 Quadlet 单元(<c>Restart=always</c>、<c>Pull=always</c>)会拉最新镜像再启动。</summary>
public sealed class HostBackendRestart : IBackendRestart
{
private static readonly TimeSpan s_delay = TimeSpan.FromSeconds(1);
private readonly IHostApplicationLifetime _lifetime;
private readonly TimeProvider _time;
public HostBackendRestart(IHostApplicationLifetime lifetime, TimeProvider time)
{
_lifetime = lifetime;
_time = time;
}
public void Request() =>
_ = Task.Run(async () =>
{
await Task.Delay(s_delay, _time);
_lifetime.StopApplication();
});
}
@@ -21,6 +21,15 @@ internal static class Defaults
/// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询),
/// 并提供管理员客户端(Bearer 令牌)和假 Agent。
/// </summary>
public sealed class RecordingBackendRestart : MoShiro.Backend.Application.IBackendRestart
{
private int _count;
public int Count => Volatile.Read(ref _count);
public void Request() => Interlocked.Increment(ref _count);
}
public class BackendFixture : IAsyncLifetime
{
public const string AdminUser = "admin";
@@ -47,6 +56,9 @@ public class BackendFixture : IAsyncLifetime
/// <summary>带 admin 作用域的 API 令牌的客户端。</summary>
public HttpClient Admin { get; private set; } = null!;
/// <summary>Backend 更新确认之后「请求退出」的次数(测试里不真的退出)。</summary>
public RecordingBackendRestart Restarts { get; } = new();
public IServiceProvider Services => App.Services;
/// <summary>夹具创建的默认 Probe:合成块(<see cref="ChunkFactory"/>)和单线路的查询都用它。</summary>
@@ -267,7 +279,11 @@ public class BackendFixture : IAsyncLifetime
options.NodeImageRegistry = string.Empty; // 同理:后台不去真实的镜像仓库取清单
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
},
configureServices: services => services.AddSingleton<MoShiro.Backend.Application.IReleaseKey>(TestReleaseKey.Instance)); // 登记清单要验发布签名:测试用自己的密钥签
configureServices: services =>
{
services.AddSingleton<MoShiro.Backend.Application.IReleaseKey>(TestReleaseKey.Instance); // 登记清单要验发布签名:测试用自己的密钥签
services.AddSingleton<MoShiro.Backend.Application.IBackendRestart>(Restarts); // 更新确认之后只记一笔,不真的退出
});
await App.StartAsync();
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
BaseAddress = new Uri(address);
@@ -1,5 +1,4 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
@@ -15,7 +14,9 @@ public sealed class BackendUpdateTests : IAsyncLifetime
public async ValueTask InitializeAsync()
{
await _backend.InitializeAsync();
_backend.Services.GetRequiredService<BackendOptions>().UpdateChannel = "dev";
BackendOptions options = _backend.Services.GetRequiredService<BackendOptions>();
options.UpdateChannel = "dev";
options.UpdateMode = "restart";
}
public ValueTask DisposeAsync() => _backend.DisposeAsync();
@@ -50,19 +51,6 @@ public sealed class BackendUpdateTests : IAsyncLifetime
private Task<JsonNode> ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update");
private async Task VerifiedAsync(long seq)
{
string directory = Path.Combine(_backend.DataDirectory, "update-status");
Directory.CreateDirectory(directory);
var status = new UpdateStatus
{
State = "available",
Current = "sha256:" + new string('b', 64),
Available = new UpdateAvailable { Seq = seq, Digest = Digest, Tag = "dev-" + seq, Version = "0.1.1" },
};
await File.WriteAllTextAsync(Path.Combine(directory, "status.json"), JsonSerializer.Serialize(status, MoShiroJsonContext.Default.UpdateStatus));
}
[Fact]
public async Task Webhook_PreservesChannels_AndNeverConfirmsAnUpdateByItself()
{
@@ -73,9 +61,8 @@ public sealed class BackendUpdateTests : IAsyncLifetime
JsonNode view = await ViewAsync();
Assert.Equal("dev", view["channel"]!.GetValue<string>());
Assert.Equal("dev-1", view["available"]!["tag"]!.GetValue<string>());
string requestDirectory = Path.Combine(_backend.DataDirectory, "update");
Assert.False(File.Exists(Path.Combine(requestDirectory, "apply.request")));
Assert.Contains("\"channel\":\"dev\"", await File.ReadAllTextAsync(Path.Combine(requestDirectory, "offer.manifest")), StringComparison.Ordinal);
Assert.Equal("available", view["state"]!.GetValue<string>());
Assert.Equal(0, _backend.Restarts.Count); // 发布只提示,不会自己重启
ReleaseOffer? stable = await _backend.Services.GetRequiredService<ReleaseService>().GetBackendAsync("release", default);
Assert.Equal("release-1", stable!.Tag);
using HttpResponseMessage replay = await PublishAsync(1);
@@ -83,27 +70,41 @@ public sealed class BackendUpdateTests : IAsyncLifetime
}
[Fact]
public async Task Confirm_RequiresHostVerification_AndRejectsASupersededSequence()
public async Task Confirm_BacksUpTheDatabase_ThenExits_AndRejectsASupersededSequence()
{
using HttpResponseMessage first = await PublishAsync(10);
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
using HttpResponseMessage unverified = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 }));
Assert.Equal(HttpStatusCode.PreconditionFailed, unverified.StatusCode);
await VerifiedAsync(10);
using HttpResponseMessage wrong = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 9 }));
Assert.Equal(HttpStatusCode.PreconditionFailed, wrong.StatusCode);
Assert.Equal(0, _backend.Restarts.Count);
using HttpResponseMessage accepted = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 }));
Assert.Equal(HttpStatusCode.NoContent, accepted.StatusCode);
string path = Path.Combine(_backend.DataDirectory, "update", "apply.request");
Assert.Equal("10", await File.ReadAllTextAsync(path));
Assert.Equal("requested", (await ViewAsync())["state"]!.GetValue<string>());
Assert.Equal(1, _backend.Restarts.Count);
Assert.Equal("applying", (await ViewAsync())["state"]!.GetValue<string>());
string backup = Assert.Single(Directory.GetFiles(Path.Combine(_backend.DataDirectory, "backups"), "pre-update-*.db"));
Assert.True(new FileInfo(backup).Length > 0);
using HttpResponseMessage second = await PublishAsync(11);
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
using HttpResponseMessage stale = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 }));
Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode);
Assert.Equal("10", await File.ReadAllTextAsync(path)); // 新 webhook 不会把确认请求悄悄改成 11
await VerifiedAsync(11);
Assert.Equal(1, _backend.Restarts.Count); // 新清单没有被悄悄当成已确认
using HttpResponseMessage newConfirmation = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 11 }));
Assert.Equal(HttpStatusCode.NoContent, newConfirmation.StatusCode);
Assert.Equal("11", await File.ReadAllTextAsync(path));
Assert.Equal(2, _backend.Restarts.Count);
}
[Fact]
public async Task Confirm_IsRefusedWithoutRestartMode()
{
_backend.Services.GetRequiredService<BackendOptions>().UpdateMode = null;
using HttpResponseMessage published = await PublishAsync(30);
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
Assert.False((await ViewAsync())["enabled"]!.GetValue<bool>());
using HttpResponseMessage refused = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 30 }));
Assert.Equal(HttpStatusCode.PreconditionFailed, refused.StatusCode);
Assert.Equal(0, _backend.Restarts.Count);
}
[Fact]
@@ -119,6 +120,6 @@ public sealed class BackendUpdateTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.Created, release.StatusCode);
SseEvent updated = await live.WaitForAsync(e => e.Name == "backend_update" && e.Data?["available"]?["seq"]?.GetValue<long>() == 20);
Assert.Equal("dev", updated.Data!["channel"]!.GetValue<string>());
Assert.False(File.Exists(Path.Combine(_backend.DataDirectory, "update", "apply.request")));
Assert.Equal(0, _backend.Restarts.Count);
}
}
@@ -109,7 +109,7 @@ public sealed class ReleaseFeedTests : IAsyncLifetime
Assert.Equal(seq, offer!.Seq);
Assert.Equal(seq, (await releases.GetBackendAsync("dev", default))!.Seq);
Assert.Equal(manifest, await File.ReadAllTextAsync(Path.Combine(directory, "update", "offer.manifest")));
}
finally
{
+6 -4
View File
@@ -31,7 +31,7 @@
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出(Agent 固定 QUIC 端口,Probe 本地源端口轮换)
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
更新(Agent、Probe、Backend):面板登记 CI 签名的清单 → 管理端确认 → 节点(Backend 先备份数据库)有序退出 → systemd Pull=always 拉最新镜像重启
```
- **线路(line)** = 一对(Probe,Agent)。可以有多个 Probe(身份由 Probe 自己生成并用加入令牌注册,可以吊销、删除),每个 Probe 连接多个 Agent,每个 Agent 同时服务多个 Probe;一条线路的数据按 `line:<probe_id>` 单独存放,互不混合。对外的线路 id 是 `<agent_id>~<probe_id>`。
@@ -74,18 +74,20 @@
发布清单(镜像 digest)由 CI 签名,放进镜像仓库(`<仓库>-manifest` 小镜像的标签里);Backend 定时取、验签后登记(也接受 CI 直接 `POST /api/release/offer`),再原样转发给在线的 Agent / Probe。之后分两条路:
```text
Podman Quadlet 节点(Agent、Probe,推荐)
Podman Quadlet(Agent、Probe、Backend,推荐)
节点比对清单和自己的版本 ──▶ 心跳报「有新版本」──▶ 管理端显示
自动(默认,运行满 10 分钟)或管理员点「更新」──▶ 节点有序退出
──▶ systemd 重启单元(Pull=always)──▶ 新镜像、新容器
节点不下载、不替换任何东西,也不验证清单;最多被请求一次有序重启
Backend 和一行脚本装的节点
Backend:不自动,只有管理员点「更新并重启」;退出之前先把数据库备份到 backups/
一行脚本装的节点
宿主机 moshiro-update(root):用本机公钥验签 → 核对组件、仓库和 seq → podman pull 仓库@digest → 重启 → 不健康就回滚
──▶ 状态写进只读的 update-status/ ──▶ 节点随心跳上报 ──▶ Backend ──▶ 管理端(有新版本时点「更新」)
```
细节见 [deployment.md](../ops/deployment.md)「Podman(Quadlet)节点的更新」和「宿主机更新器」。
细节见 [deployment.md](../ops/deployment.md)「Podman(Quadlet)的更新」(一行脚本装的节点另见「宿主机更新器」)。
### 4.1 回显的四个时间戳
+13 -29
View File
@@ -56,7 +56,7 @@ EOF
podman compose up -d # Docker 用 docker compose
```
**更新**:先备份数据卷,再 `podman compose pull && podman compose up -d`。新版启动时自动迁移数据库,但**旧版不认比自己新的数据库**:想回滚要先恢复备份,只改 `MOSHIRO_VERSION` 不够。Backend 能指挥所有节点,更新它尽量用宿主机更新器(见下文「Backend 签名更新」,清单有发布签名,更新前自动备份,不健康自动回滚),或者在 `.env` 里把 `MOSHIRO_VERSION` 固定到经过核对的版本或 digest。
**更新**:先备份数据卷,再 `podman compose pull && podman compose up -d`。新版启动时自动迁移数据库,但**旧版不认比自己新的数据库**:想回滚要先恢复备份,只改 `MOSHIRO_VERSION` 不够。Backend 能指挥所有节点,更新它尽量用 Quadlet(`packaging/quadlet/moshiro-backend.container`,见下文「Backend 的更新」:管理端确认,更新前自动备份数据库),或者在 `.env` 里把 `MOSHIRO_VERSION` 固定到经过核对的版本或 digest。
**首次认领(没有默认管理员)**:面板里还没有任何用户时,打开 `/admin/` 会进入认领向导:
1. 填认领码(首次启动时随机生成并写进后端日志,`journalctl -u moshiro-backend` 或 `docker logs`;也可以用 `MoShiro__ClaimCode` / `MOSHIRO_CLAIM_CODE` 指定)、管理员的用户名和密码;
@@ -258,19 +258,12 @@ f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo "<
脚本是静态的(`packaging/install/install.sh` 和 `install.ps1`,嵌入 Backend,不替换任何值),所以它的 SHA-256 在一个版本内固定;角色、面板地址、代码和属主走环境变量。环境变量 `MOSHIRO_NAME`、`MOSHIRO_ENGINE=docker|podman`、`MOSHIRO_DRY_RUN=1`(只打印 run 命令);Podman 会顺便启用 `podman-restart`。
**用脚本部署和更新(Backend;Agent 和 Probe 请用 Quadlet)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。
**用脚本部署和更新(Agent、Probe;推荐用 Quadlet,Backend 只有 Quadlet 和 compose)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。
```sh
# 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起)
sudo sh deploy-node.sh agent --image-repo registry.oeo.one/moshiro/moshiro-agent --server https://<backend> --join <token> --name <name>
sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe --server https://<backend> --join <probe 令牌> --name <name>
# Backend(只支持 Podman,更新策略固定为管理端确认;--port 默认 29181,环境文件写在 /etc/moshiro/backend.env,已有的不覆盖)
sudo sh deploy-node.sh backend --channel dev --public-url https://<面板域名>
# 没有默认管理员:首次打开面板按页面引导认领(认领码写在后端日志里,或用 --env MoShiro__ClaimCode=… 自己指定)
# 其他环境变量用 --env 原样写入(只在首次创建 backend.env 时生效):本地开发前端要连这个面板时加
# --env MoShiro__CorsOrigins=http://localhost:5173,http://localhost:5174 --env MoShiro__SecureCookies=false
# 面板前面有反向代理(Caddy、frp、Cloudflare)时,登录限流和审计看到的是代理的地址:把代理的地址写进可信代理,才会采信转发头里的客户端地址
# --env MoShiro__TrustedProxies=127.0.0.1,172.16.0.0/12 (头默认 X-Forwarded-For,可用 MoShiro__TrustedProxyHeader 改成 CF-Connecting-IP)
# 之后更新:不带参数(旧版 Probe 的 probe.json 没有 fleet 段,加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old)
sudo sh deploy-node.sh agent
```
@@ -309,7 +302,7 @@ Agent 的 Quadlet 单元已经包含以下只读挂载和环境变量:
Probe 也上报同样的汇总数字(`probe.json` 的 `reporting.host_stats` 默认开,设为 `false` 就一个字也不上报,同时可以去掉 `/proc`、`/sys`、`/` 这三个挂载),所以 Probe 的 Quadlet 单元和 Agent 一样挂载它们并设置 `HOST_*` 环境变量。Docker Desktop(macOS、Windows)上读到的是 Docker 的虚拟机,不是宿主系统本身;Windows 上直接运行的 Probe 和 Agent 用系统调用读 CPU、内存和物理网卡的流量(不读网卡名和地址);macOS 目前只有磁盘和运行时长。
## Podman(Quadlet)节点的更新(Agent、Probe,推荐)
## Podman(Quadlet)的更新(Agent、Probe、Backend,推荐)
节点跑成一个 Podman Quadlet 单元(`packaging/quadlet/moshiro-agent.container`、`moshiro-probe.container`,Podman 4.6 及以上),更新是**销毁容器、重新拉最新镜像再启动**:节点自己不下载、不替换任何东西,只是有序退出;Quadlet 生成的 systemd 服务用 `podman run --pull always` 启动,所以被拉起时先从镜像仓库拉最新的镜像。**默认自动更新**:面板发现新版本后,节点运行满 10 分钟(防止重启循环)就自己退出重启;想改成在管理端点「更新」才更新,在 env 文件里写 `MOSHIRO_UPDATE_POLICY=manual`。
@@ -328,9 +321,9 @@ CI 推镜像到 registry ─▶ 面板收到 CI 签名的清单 ─▶ 转发给
- **回滚**:新镜像起不来时 systemd 一直重试;把模板里的标签改回旧版本再 `systemctl --user restart` 即可。
- 测试:`packaging/quadlet` 里的模板用 `/usr/libexec/podman/quadlet -dryrun -user` 检查过(Podman 5.4);节点一侧的逻辑见 `RestartUpdaterTests`。
## 宿主机更新器(Backend、一行脚本装的节点)
## 宿主机更新器(一行脚本装的节点)
节点的更新走上面的 Quadlet。下面的宿主机更新器现在只服务 Backend 和一行安装脚本装的节点,Backend 改成 Quadlet 之后会一并移除。
节点和 Backend 的更新都走上面的 Quadlet。下面的宿主机更新器现在只服务一行安装脚本装的节点(它们没有 systemd 的 Pull=always 可用)。
更新器支持 rootless Podman 用户服务、rootful Podman 和 Docker 系统服务。配置、身份和更新请求通过命名 volume 传递,不需要指定宿主数据目录。短时辅助容器读取请求、写入独立状态 volume;辅助镜像必须已在本机拉取,更新器不会自动拉取辅助镜像。配置、公钥和已安装版本记录留在容器外。
@@ -378,24 +371,15 @@ CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给
- **发版**:打 `vMAJOR.MINOR.PATCH` tag 时,节点镜像(Agent、Probe 的多架构镜像)也和 Backend 一样签清单,放到 `moshiro/…-manifest:release`。
- **压缩包的校验和也签名**:Release 里除了每个压缩包的 `.sha256`,还有汇总的 `SHA256SUMS` 和 `SHA256SUMS.sig`(用发布私钥签,base64 的 DER ECDSA P-256,和清单同一把密钥)。验证:`base64 -d SHA256SUMS.sig > s.bin && openssl dgst -sha256 -verify packaging/update/release-key.pub.pem -signature s.bin SHA256SUMS`,通过之后再 `sha256sum -c SHA256SUMS`。macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要苹果和微软的证书,目前没有做。
- **首次部署**:`deploy-node.sh` 默认就走这条路,不需要面板,也不用手动指定镜像(Backend 第一次部署时本来就没有面板)。`--tag` 指定标签时不验签并有警告;`--image` 直接用你给的引用。
- **别人自己部署后端,什么都不用改**:仓库对匿名读开放(`moshiro/*` 和各 `*-manifest` 仓库),然后 `sudo sh deploy-node.sh backend --channel release --public-url https://<自己的域名>`,只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(`--image-repo`)并重新构建,这是 fork 的正常代价。
- **别人自己部署后端,什么都不用改**:仓库对匿名读开放(`moshiro/*` 和各 `*-manifest` 仓库),然后按 `packaging/quadlet/moshiro-backend.container` 的说明装 Backend(`MoShiro__PublicUrl` 写自己的域名),只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(`--image-repo`)并重新构建,这是 fork 的正常代价。
- 测试:`bash packaging/update/tests/feed-test.sh`(假的 podman + 真的 openssl,覆盖篡改、别的密钥、旧 seq、换钥、仓库不可用回退、首次部署的验签)。
## Backend 签名更新
## Backend 的更新
Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。
Backend 和节点是同一种更新:`packaging/quadlet/moshiro-backend.container`(Quadlet,`Pull=always`、`Restart=always`、`Environment=MOSHIRO_UPDATE=restart`)。CI 推镜像并把 CI 签名的清单登记到面板,管理端显示「Backend 有新版本」,管理员点「更新并重启」(`POST /api/admin/backend/update`,带当时登记的清单 `seq`,清单变了就要重新确认);Backend 先把数据库备份到数据卷的 `backups/pre-update-<时间>-<seq>.db`(`VACUUM INTO`,保留最近 3 份,备份失败就不更新),再有序退出,systemd 重启单元并拉最新镜像。
`MOSHIRO_UPDATE_CHANNEL=auto` 时,`MOSHIRO_IMAGE_NAMESPACE=moshiro-dev` 选择 `dev`,`moshiro` 选择 `release`。CI 发布流水线使用对应 channel 签名并登记到 Backend webhook。旧版 Backend 不识别 Backend 清单,因此首次升级需按常规部署流程引导一次;该期间 CI 对这类旧版面板的明确“不认识 backend 组件”响应会提示引导信息,其他 webhook 错误仍会令发布失败。
更新器以手动策略运行,并通过独立状态 volume 将状态只读挂载回 Backend:
```sh
sudo packaging/update/install-updater.sh backend --engine podman \
--volume moshiro-backend-data --status-volume moshiro-backend-status \
--helper-image registry.oeo.one/moshiro-dev/moshiro-backend:<version> \
--image-repo registry.oeo.one/moshiro-dev/moshiro-backend \
--unit moshiro-backend.service --policy manual --channel dev \
--health-url http://127.0.0.1:29181/healthz
```
Backend 服务需要将 `moshiro-backend-status` 挂载到 `/data/update-status:ro`,并将镜像引用文件 `/etc/moshiro-update/backend.image` 作为 `IMAGE_FILE` 提供给 systemd 单元。release 实例使用 `--channel release` 及 `registry.oeo.one/moshiro` 仓库。更新请求由管理端确认后写入状态接口,不要启用全局 Podman 自动更新 timer。
- **不自动重启**:Backend 指挥所有节点,重启会让它们全部断一两分钟,所以只提示,不像节点默认那样自动退出。
- **没有 `MOSHIRO_UPDATE=restart` 就不能从管理端更新**(compose、一行脚本装的 Backend 没有重新拉取的机制),管理端会提示;这种部署继续用 `compose pull && compose up -d`。
- **通道**:`MoShiro__UpdateChannel` 为 `auto` 时,镜像命名空间 `moshiro-dev` 选 `dev`,`moshiro` 选 `release`;没写命名空间就按当前运行的构建 tag 判断。
- **回滚**:新版启动时会迁移数据库,旧版不认比自己新的库。要回旧版:停服务,把 `backups/pre-update-*.db` 复制成数据卷里的 `moshiro.db`(删掉旧的 `-wal`、`-shm`),把单元里的标签固定成旧版本号再启动。新版起不来时 systemd 会一直重试同一个镜像,没有自动回滚,也没有 `/healthz` 的探针(镜像里没有 curl)。
- **首次升级**:还没有这个机制的旧版 Backend 不认 `MOSHIRO_UPDATE`,第一次要手动 `systemctl --user restart moshiro-backend`(Pull=always 拉到新镜像),之后就能从管理端更新。
+1 -1
View File
@@ -65,7 +65,7 @@
- **对账测试**:所有 `/api/admin` 端点必须声明能力,没声明测试就失败;OpenAPI 的每个操作带 `x-moshiro-capability`。
- **信任类**是界面上特别标出的(吊销、账户管理),授权和其他能力一样靠 API key 或会话。真正由 passkey 签名保护的是操作员集合,那是节点自己验证的。
- **不能越权**:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集。
- **Backend 更新确认**:更新清单由 CI 的发布密钥签名,`seq` 防回滚,真正的门是发布密钥,不是确认这一步。
- **Backend 更新确认**:更新清单由 CI 的发布密钥签名(面板登记时验签),`seq` 防重放;确认只是让 Backend 有序退出并备份数据库,拉哪个镜像由 Quadlet 单元里写死的仓库和标签决定,面板决定不了。
## 4. 登录与会话
-3
View File
@@ -27,7 +27,6 @@ services:
MoShiro__DataDirectory: /data
volumes:
- moshiro-backend-data:/data
- moshiro-backend-status:/data/update-status:ro
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,mode=1777
@@ -40,5 +39,3 @@ services:
volumes:
moshiro-backend-data:
name: moshiro-backend-data
moshiro-backend-status:
name: moshiro-backend-status
+6 -58
View File
@@ -1,29 +1,25 @@
#!/bin/sh
# 部署或更新容器服务(Backend、Agent、Probe)。可以重复运行:每次都会拉取镜像、把配置迁移到当前版本需要的样子、
# 部署或更新容器服务(Agent、Probe;Backend 用 Quadlet,见 packaging/quadlet/moshiro-backend.container)。可以重复运行:每次都会拉取镜像、把配置迁移到当前版本需要的样子、
# 重新安装单元和更新器,然后重启节点;已有的身份和配置不会被覆盖。
#
# 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1
# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --server https://panel --join jt_xxx --name home-1
# 首次部署 Backend:sudo sh deploy-node.sh backend --channel dev [--public-url https://panel] [--port 29181] [--env KEY=VALUE]...
# 没有默认管理员:部署完打开面板,按页面引导自己创建管理员、注册 passkey、建立信任根;页面要的认领码在后端日志里(journalctl -u moshiro-backend)。
# 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置)
# 旧版 Probe(probe.json 里没有 fleet 段):加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old
#
# Backend 只支持 Podman,更新策略固定为 manual(管理端确认),监听宿主网络的 --port。
# 需要 root、systemd,以及 Podman 或 Docker;脚本要和 packaging/ 里的 systemd/、update/ 放在一起。
# rootless Podman 见 docs/ops/deployment.md。
set -eu
usage() {
echo "usage: deploy-node.sh <agent|probe|backend> [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2
echo "usage: deploy-node.sh <agent|probe> [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2
echo " [--server URL --join TOKEN --name NAME] [--policy auto|manual] [--authfile F]" >&2
echo " backend: [--channel dev|release] [--port N] [--admin-user U --admin-password P:只给自动化用,正常不要] [--public-url URL] [--env KEY=VALUE]..." >&2
exit 2
}
ROLE="${1:-}"; shift || true
case "$ROLE" in agent|probe|backend) ;; *) usage ;; esac
ENGINE=""; REPO=""; TAG=""; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""; PORT=""; ADMIN_USER=""; ADMIN_PASSWORD=""; PUBLIC_URL=""; EXTRA_ENV=""
case "$ROLE" in agent|probe) ;; *) usage ;; esac
ENGINE=""; REPO=""; TAG=""; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""
while [ $# -gt 0 ]; do
case "$1" in
--engine) ENGINE="$2"; shift 2 ;;
@@ -36,12 +32,6 @@ while [ $# -gt 0 ]; do
--policy) POLICY="$2"; shift 2 ;;
--authfile) AUTHFILE="$2"; shift 2 ;;
--channel) CHANNEL="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-password) ADMIN_PASSWORD="$2"; shift 2 ;;
--public-url) PUBLIC_URL="$2"; shift 2 ;;
--env) EXTRA_ENV="$EXTRA_ENV$2
"; shift 2 ;;
*) usage ;;
esac
done
@@ -59,13 +49,6 @@ conf_get() { [ -r "$CONF" ] && sed -n "s/^$1='\\(.*\\)'\$/\\1/p" "$CONF" | head
[ -n "$AUTHFILE" ] || AUTHFILE="$(conf_get AUTHFILE)"
[ -n "$ENGINE" ] || ENGINE="$(conf_get ENGINE)"
[ -n "$CHANNEL" ] || CHANNEL="$(conf_get CHANNEL)"
if [ "$ROLE" = backend ]; then
[ -n "$CHANNEL" ] || { echo "backend needs --channel dev or release" >&2; exit 2; }
case "$CHANNEL" in dev) NAMESPACE=moshiro-dev ;; release) NAMESPACE=moshiro ;; *) echo "--channel must be dev or release" >&2; exit 2 ;; esac
[ -n "$REPO" ] || REPO="registry.oeo.one/$NAMESPACE/moshiro-backend"
[ -n "$PORT" ] || PORT=29181
ENGINE=podman; POLICY=manual
fi
[ -n "$REPO" ] || REPO="registry.oeo.one/moshiro/moshiro-$ROLE"
[ -n "$POLICY" ] || POLICY=manual
if [ -z "$ENGINE" ]; then
@@ -78,14 +61,13 @@ case "$ENGINE" in
esac
V_CFG="moshiro-$ROLE-config"
V_STATUS="moshiro-$ROLE-status"
if [ "$ROLE" = backend ]; then UNIT=moshiro-backend.service; V_CFG=moshiro-backend-data; fi
for f in "$PACKAGING/systemd/$UNIT_SRC" "$PACKAGING/update/install-updater.sh" "$PACKAGING/update/moshiro-update.sh" "$PACKAGING/update/release-key.pub.pem"; do
[ -r "$f" ] || { echo "missing $f (keep deploy-node.sh next to the packaging/ directory)" >&2; exit 1; }
done
# 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用。
# 默认从仓库里的签名清单取(<仓库>-manifest:<通道>,CI 发布的),用随脚本带的发布公钥验签,按清单里的 digest 固定——
# 仓库和网络都不需要可信,也不需要面板在线(Backend 第一次部署时还没有面板)。
# 仓库和网络都不需要可信,也不需要面板在线。
# --tag 指定标签就不验签(拉那个标签再固定 digest);--image 直接用你给的引用。
if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi
pull_quiet() {
@@ -137,39 +119,15 @@ in_volume() {
"$ENGINE" run --rm -i --pull=never --network none --cap-drop ALL --security-opt no-new-privileges \
-v "$V_CFG:/etc/moshiro" --entrypoint /bin/sh "$IMAGE" -c "$1"
}
[ "$ROLE" = backend ] || in_volume 'mkdir -p /etc/moshiro/update' </dev/null
in_volume 'mkdir -p /etc/moshiro/update' </dev/null
# 3. 配置:身份由节点自己生成(Agent 和 Probe 一样),注册之后面板只认这把公钥
if [ "$ROLE" = backend ]; then
# Backend 的环境文件放在宿主机上(单元用 --env-file 读取);已有的不会被覆盖
install -d -m 755 /etc/moshiro
if [ ! -f /etc/moshiro/backend.env ]; then
(
umask 077
{
echo "MoShiro__DataDirectory=/data"
echo "MoShiro__UpdateChannel=$CHANNEL"
echo "MoShiro__ImageNamespace=$NAMESPACE"
# 没有默认管理员:第一个管理员由部署的人在首次打开的页面上自己填,认领码在后端日志里(--admin-password 只给自动化用)
if [ -n "$ADMIN_PASSWORD" ]; then
echo "MoShiro__InitialAdminUser=${ADMIN_USER:-admin}"
echo "MoShiro__InitialAdminPassword=$ADMIN_PASSWORD"
fi
[ -z "$PUBLIC_URL" ] || echo "MoShiro__PublicUrl=$PUBLIC_URL"
# 其他设置原样写入,例如 --env MoShiro__CorsOrigins=http://localhost:5173 --env MoShiro__SecureCookies=false
[ -z "$EXTRA_ENV" ] || printf '%s' "$EXTRA_ENV"
} > /etc/moshiro/backend.env
)
echo "config: created /etc/moshiro/backend.env"
fi
else
ID_DIR="/etc/moshiro/$ROLE-id"
if ! in_volume "test -f $ID_DIR/node.json" </dev/null; then
[ -n "$SERVER" ] && [ -n "$JOIN" ] && [ -n "$NAME" ] || { echo "first deployment needs --server, --join and --name" >&2; exit 1; }
"$ENGINE" run --rm --pull=never --network host -v "$V_CFG:/etc/moshiro" --entrypoint /usr/local/bin/fleet-enroll "$IMAGE" \
--server "$SERVER" --join "$JOIN" --name "$NAME" --identity-dir "$ID_DIR"
fi
fi
if [ "$ROLE" = agent ]; then
if ! in_volume 'test -f /etc/moshiro/agent.json' </dev/null; then
[ -n "$SERVER" ] || { echo "agent.json is missing; pass --server to create it" >&2; exit 1; }
@@ -199,10 +157,8 @@ fi
install -d -m 755 "$CONF_DIR"
printf 'IMAGE=%s\n' "$IMAGE" > "$CONF_DIR/$ROLE.image"
install -m 644 "$PACKAGING/systemd/$UNIT_SRC" "/etc/systemd/system/$UNIT"
[ "$ROLE" != backend ] || sed -i "s/:29181/:$PORT/" "/etc/systemd/system/$UNIT"
set -- "$ROLE" --engine "$ENGINE" --volume "$V_CFG" --status-volume "$V_STATUS" --helper-image "$IMAGE" \
--image-repo "$REPO" --unit "$UNIT" --policy "$POLICY" --no-enable
[ "$ROLE" != backend ] || set -- "$@" --channel "$CHANNEL" --health-url "http://127.0.0.1:$PORT/healthz"
[ -z "$AUTHFILE" ] || set -- "$@" --authfile "$AUTHFILE"
sh "$PACKAGING/update/install-updater.sh" "$@"
@@ -211,13 +167,5 @@ systemctl daemon-reload
systemctl enable "$UNIT" "moshiro-update-$ROLE.timer" >/dev/null 2>&1
systemctl restart "$UNIT"
systemctl start "moshiro-update-$ROLE.timer"
if [ "$ROLE" = backend ]; then
n=0
until curl -fsS -o /dev/null "http://127.0.0.1:$PORT/healthz" 2>/dev/null; do
n=$((n + 1)); [ "$n" -lt 30 ] || { echo "backend did not become healthy" >&2; systemctl status "$UNIT" --no-pager >&2 || true; exit 1; }
sleep 2
done
else
sleep 5
fi
systemctl is-active "$UNIT"
@@ -0,0 +1,50 @@
# MoShiro Backend(面板)的 Podman Quadlet 单元(Podman 4.6 及以上),更新方式和 Agent、Probe 完全一样:
# systemd 每次启动这个单元时 Pull=always 先从镜像仓库拉最新的镜像,Backend 在管理端确认更新之后有序退出(先把数据库备份到数据卷的 backups/),
# Restart=always 把它拉起来,退出再被拉起就是更新(Backend 自己不下载、不替换任何东西)。
# 不会自动重启:Backend 指挥所有节点,更新要管理员在管理端点「更新并重启」。
# 回滚:新版启动时会迁移数据库,旧版不认比自己新的数据库。想回到旧版,先停服务,把 backups/pre-update-*.db 复制成数据卷里的 moshiro.db,再把 Image 固定成旧版本号。
#
# 安装(rootless,普通用户,不用 sudo):
# mkdir -p ~/.config/containers/systemd ~/.config/moshiro
# 写 ~/.config/moshiro/backend.env,chmod 600,每行一个变量,至少:
# MoShiro__DataDirectory=/data
# MoShiro__PublicUrl=https://panel.example.com # 面板对外的地址;passkey 绑定在这个域名上,改了会让已注册的 passkey 失效
# ASPNETCORE_HTTP_PORTS=8080
# 前面有反向代理(Caddy、cloudflared、frp)时再加 MoShiro__TrustedProxies(代理连到 Backend 的地址,rootless pasta 网络下是宿主的局域网地址和 127.0.0.1)
# 和 MoShiro__TrustedProxyHeader(Cloudflare 用 CF-Connecting-IP,其他用 X-Forwarded-For),否则登录限流、审计和节点的国家识别看到的都是代理的地址。
# 把本文件放进 ~/.config/containers/systemd/
# systemctl --user daemon-reload && systemctl --user start moshiro-backend # Quadlet 会自动生成服务,不用 enable;[Install] 让它开机自启
# loginctl enable-linger $USER # 退出登录后继续运行(需要管理员执行一次)
# 第一次打开面板按页面引导认领:没有默认管理员,认领码在日志里(podman logs moshiro-backend)。
# rootful:本文件放进 /etc/containers/systemd/,env 文件放 /etc/moshiro/backend.env,把下面的 EnvironmentFile 改成这个路径、
# WantedBy 改成 multi-user.target,用 systemctl(不加 --user)。
# 开发通道:把 Image 的 moshiro 换成 moshiro-dev,并在 env 文件里加 MoShiro__UpdateChannel=dev。固定版本:把 :latest 换成 :<版本号>(固定之后更新不会换版本)。
# 说明见 docs/ops/deployment.md。
[Unit]
Description=MoShiro Backend
After=network-online.target
Wants=network-online.target
[Container]
Image=registry.oeo.one/moshiro/moshiro-backend:latest
ContainerName=moshiro-backend
Pull=always
PublishPort=127.0.0.1:8080:8080
EnvironmentFile=%h/.config/moshiro/backend.env
Volume=moshiro-backend-data:/data
ReadOnly=true
NoNewPrivileges=true
DropCapability=ALL
Tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777
StopTimeout=30
# 告诉 Backend:退出之后外面会重新拉取,所以「更新」就是退出(没有这一行,管理端不能更新 Backend)
Environment=MOSHIRO_UPDATE=restart
[Service]
Restart=always
RestartSec=5
# 第一次拉取镜像可能要一会儿
TimeoutStartSec=600
[Install]
WantedBy=default.target
@@ -1,16 +0,0 @@
[Unit]
Description=MoShiro Backend (signed container updates)
After=network-online.target
Wants=network-online.target
[Service]
EnvironmentFile=/etc/moshiro-update/backend.image
Restart=always
RestartSec=5
TimeoutStopSec=30
ExecStartPre=-/usr/bin/podman rm -f moshiro-backend
ExecStart=/usr/bin/podman run --rm --name moshiro-backend --network host -e ASPNETCORE_URLS=http://0.0.0.0:29181 --read-only --tmpfs /tmp --security-opt no-new-privileges --cap-drop all --env-file /etc/moshiro/backend.env -v moshiro-backend-data:/data -v moshiro-backend-status:/data/update-status:ro ${IMAGE}
ExecStop=/usr/bin/podman stop -t 20 moshiro-backend
[Install]
WantedBy=multi-user.target
+1 -5
View File
@@ -5,7 +5,7 @@
set -eu
COMPONENT="${1:-}"; shift || true
case "$COMPONENT" in agent|probe|backend) ;; *) echo "usage: install-updater.sh <agent|probe|backend> [--user] [--engine podman|docker] --data-dir D --image-repo R --unit U [--policy auto|manual] [--channel dev|release] [--health-url URL] [--authfile F] [--no-enable]" >&2; exit 2 ;; esac
case "$COMPONENT" in agent|probe) ;; *) echo "usage: install-updater.sh <agent|probe> [--user] [--engine podman|docker] --data-dir D --image-repo R --unit U [--policy auto|manual] [--channel dev|release] [--health-url URL] [--authfile F] [--no-enable]" >&2; exit 2 ;; esac
DATA_DIR=""; IMAGE_REPO=""; UNIT=""; POLICY=manual; AUTHFILE=""; ENGINE=podman; SERVICE_SCOPE=system; ENABLE=true; DATA_VOLUME=""; STATUS_VOLUME=""; HELPER_IMAGE=""
CHANNEL=""; HEALTH_URL=""
while [ $# -gt 0 ]; do
@@ -31,10 +31,6 @@ if [ -z "$IMAGE_REPO" ] || [ -z "$UNIT" ] || { [ -z "$DATA_DIR" ] && [ -z "$DATA
fi
case "$POLICY" in auto|manual) ;; *) echo "--policy must be auto or manual" >&2; exit 2 ;; esac
case "$ENGINE" in podman|docker) ;; *) echo "--engine must be podman or docker" >&2; exit 2 ;; esac
if [ "$COMPONENT" = backend ]; then
[ "$POLICY" = manual ] && [ -n "$DATA_VOLUME" ] && [ -n "$HEALTH_URL" ] || { echo "Backend requires manual policy, a data volume and --health-url" >&2; exit 2; }
case "$CHANNEL" in dev|release) ;; *) echo "Backend requires --channel dev or release" >&2; exit 2 ;; esac
fi
if [ -n "$DATA_VOLUME" ]; then
[ -z "$DATA_DIR" ] || { echo "use --volume or --data-dir, not both" >&2; exit 2; }
[ -n "$HELPER_IMAGE" ] || { echo "--volume needs --helper-image (an already pulled MoShiro image)" >&2; exit 2; }
+5 -44
View File
@@ -14,8 +14,8 @@ umask 077
CMD="${1:-}"
COMPONENT="${2:-}"
case "$CMD" in check|apply) ;; *) echo "usage: moshiro-update <check|apply> <agent|probe|backend>" >&2; exit 2 ;; esac
case "$COMPONENT" in agent|probe|backend) ;; *) echo "component must be agent, probe or backend" >&2; exit 2 ;; esac
case "$CMD" in check|apply) ;; *) echo "usage: moshiro-update <check|apply> <agent|probe>" >&2; exit 2 ;; esac
case "$COMPONENT" in agent|probe) ;; *) echo "component must be agent or probe" >&2; exit 2 ;; esac
if [ "$(id -u)" -eq 0 ]; then
DEFAULT_CONF_DIR=/etc/moshiro-update
@@ -37,12 +37,6 @@ DATA_VOLUME="${DATA_VOLUME:-}"
STATUS_VOLUME="${STATUS_VOLUME:-moshiro-$COMPONENT-status}"
CHANNEL="${CHANNEL:-}"
HEALTH_URL="${HEALTH_URL:-}"
if [ "$COMPONENT" = backend ]; then
[ "$POLICY" = manual ] || { echo "Backend updates require manual confirmation" >&2; exit 2; }
case "$CHANNEL" in dev|release) ;; *) echo "Backend needs CHANNEL=dev or release" >&2; exit 2 ;; esac
[ -n "$HEALTH_URL" ] || { echo "Backend needs a local HEALTH_URL" >&2; exit 2; }
[ -n "$DATA_VOLUME" ] || { echo "Backend backup needs DATA_VOLUME" >&2; exit 2; }
fi
case "$ENGINE" in podman|docker) ;; *) echo "unsupported engine: $ENGINE" >&2; exit 2 ;; esac
case "$SERVICE_SCOPE" in system|user) ;; *) echo "unsupported service scope: $SERVICE_SCOPE" >&2; exit 2 ;; esac
service_ctl() {
@@ -174,38 +168,17 @@ seed_installed() {
current_digest() { [ -r "$INSTALLED" ] && sed -n 2p "$INSTALLED" || echo ""; }
installed_seq() { [ -r "$INSTALLED" ] && sed -n 1p "$INSTALLED" || echo 0; }
# Backend 的请求带用户确认的 seq;复制以后再验清单,避免后来到达的 tag 被自动套用。
# 面板(管理员)请求更新:apply.request 在数据卷里放着就是请求了
fetch_request() {
if [ -n "$DATA_VOLUME" ]; then
"$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
-v "$DATA_VOLUME:/etc/moshiro:ro" --entrypoint /bin/sh "$HELPER_IMAGE" \
-c 'f=/etc/moshiro/update/apply.request; if [ -f "$f" ] && [ ! -L "$f" ]; then if [ "$1" = backend ]; then head -c 64 "$f"; else printf yes; fi; fi' sh "$COMPONENT"
-c 'f=/etc/moshiro/update/apply.request; if [ -f "$f" ] && [ ! -L "$f" ]; then printf yes; fi'
elif [ -f "$UPDATE_DIR/apply.request" ] && [ ! -L "$UPDATE_DIR/apply.request" ]; then
if [ "$COMPONENT" = backend ]; then head -c 64 "$UPDATE_DIR/apply.request"; else printf yes; fi
printf yes
fi
}
backend_backup() {
BACKUP="$STATE_DIR/backend-before-$M_SEQ.tar.gz"
service_ctl stop "$UNIT"
# 容器的标准输出就是备份本身:不能再让引擎把它当日志写进 journal(每次更新会多灌一份备份大小的日志)
if ! "$ENGINE" run --pull=never --rm --log-driver=none --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
-v "$DATA_VOLUME:/data:ro" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'tar -czf - -C /data .' > "$BACKUP.tmp"; then
rm -f "$BACKUP.tmp"
service_ctl start "$UNIT" || true
write_status failed "$avail" "database backup failed"
return 1
fi
chmod 600 "$BACKUP.tmp"
mv -f "$BACKUP.tmp" "$BACKUP"
}
backend_restore() {
service_ctl stop "$UNIT" || true
"$ENGINE" run --pull=never --rm -i --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
-v "$DATA_VOLUME:/data" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'tar -xzf - --no-same-owner -C /data' < "$BACKUP"
}
# 从扁平的清单里取字段(清单由 CI 生成,只有字符串和整数,没有嵌套)
field() { sed -n 's/.*"'"$1"'":"\([^"]*\)".*/\1/p' "$OFFER_MANIFEST" | head -n 1; }
num_field() { sed -n 's/.*"'"$1"'":\([0-9][0-9]*\).*/\1/p' "$OFFER_MANIFEST" | head -n 1; }
@@ -277,7 +250,6 @@ do_check() {
}
do_apply() {
requested_seq="$(fetch_request)"
if [ -n "$DATA_VOLUME" ]; then
"$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
-v "$DATA_VOLUME:/etc/moshiro" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'rm -f /etc/moshiro/update/apply.request'
@@ -287,10 +259,6 @@ do_apply() {
fetch_offer
verify_offer || { write_status invalid null "$ERR"; echo "refusing to apply: $ERR" >&2; return 1; }
adopt_next_key
if [ "$COMPONENT" = backend ]; then
case "$requested_seq" in ''|*[!0-9]*) write_status failed "$(available_json)" "a confirmed release sequence is required"; return 1 ;; esac
[ "$requested_seq" = "$M_SEQ" ] || { write_status available "$(available_json)" "release changed; confirm again"; return 1; }
fi
avail="$(available_json)"
write_status applying "$avail" ""
ref="$IMAGE_REPO@$M_DIGEST"
@@ -298,7 +266,6 @@ do_apply() {
write_status failed "$avail" "pull failed"; echo "pull failed: $ref" >&2; return 1
fi
prev="$(cat "$ENV_FILE" 2>/dev/null || true)"
if [ "$COMPONENT" = backend ]; then backend_backup || return 1; fi
printf 'IMAGE=%s\n' "$ref" > "$ENV_FILE.new" && mv -f "$ENV_FILE.new" "$ENV_FILE"
service_ctl restart "$UNIT" || true
# 健康确认:服务在跑,并且 20 秒内没有再被重启(崩溃循环算失败)
@@ -317,12 +284,6 @@ do_apply() {
return 0
fi
echo "new version did not become healthy, rolling back" >&2
if [ "$COMPONENT" = backend ]; then
if ! backend_restore; then
write_status failed "$avail" "database restore failed; backup retained"
return 1
fi
fi
if [ -n "$prev" ]; then printf '%s\n' "$prev" > "$ENV_FILE"; fi
service_ctl restart "$UNIT" || true
write_status rolled_back "$avail" "new version did not become healthy"
+6 -11
View File
@@ -5,7 +5,7 @@ import type { BackendUpdateDto } from '@shared/api/types'
import { Confirm, ErrorNotice, useAction } from '@shared/ui'
import { useAuth } from './auth'
/** webhook 发布后显示当前通道的更新;宿主机验签完成且用户确认同一个 seq 后才升级。 */
/** webhook 发布后显示当前通道的更新;用户确认同一个 seq 后,Backend 先备份数据库再退出,由 Quadlet 拉最新镜像重启(和 Agent、Probe 一样)。 */
export function BackendUpdateNotice() {
const { me } = useAuth()
const [update, setUpdate] = useState<BackendUpdateDto>()
@@ -32,19 +32,14 @@ export function BackendUpdateNotice() {
useEffect(() => {
if (!requested || !update) return
if (update.state === 'up_to_date' || (update.current_tag && update.current_tag !== originalTag.current)) location.reload()
else if (['failed', 'rolled_back', 'invalid'].includes(update.state) || (update.state === 'available' && update.error)) setRequested(false)
}, [update, requested])
if (!update || (!update.available && !requested && update.state !== 'applying')) return null
const waiting = requested || update.state === 'requested' || update.state === 'applying'
const waiting = requested || update.state === 'applying'
const canUpdate = update.enabled && update.state === 'available' && update.available
const message = update.state === 'applying' ? 'Backend 正在升级重启,连接恢复后会自动刷新。'
: waiting ? '已确认 Backend 更新,正在等待宿主机更新器执行。'
: update.state === 'checking' ? '新版本已发布,正在等待宿主机验签。'
: !update.enabled ? '新版本已发布,需要在宿主机安装 Backend 更新器。'
: update.state === 'invalid' ? '发布清单未通过更新器校验。'
: update.state === 'rolled_back' ? '更新失败,已回滚。'
: update.state === 'failed' ? '更新失败。' : 'Backend 有新版本。'
const message = waiting ? '已确认 Backend 更新,正在备份数据并重启,连接恢复后会自动刷新。'
: !update.enabled ? '新版本已发布,但这个 Backend 没有启用重启式更新(需要环境变量 MOSHIRO_UPDATE=restart,Quadlet 模板里已写好)。'
: 'Backend 有新版本。'
return (
<div className="notice" style={{ marginBottom: 16 }}>
@@ -66,7 +61,7 @@ export function BackendUpdateNotice() {
setRequested(true)
}
}}>
将从 {update.current_tag || '当前版本'} 升级到 {confirming.available.tag}({confirming.channel})。宿主机会先备份数据,再升级并重启,期间面板会短暂断开;恢复后自动刷新。如果发布清单已变化,需要重新确认。
将从 {update.current_tag || '当前版本'} 升级到 {confirming.available.tag}({confirming.channel})。Backend 会先备份数据库(数据目录下的 backups/),再退出,由 systemd 拉取最新镜像重新启动,期间面板会断开一两分钟,所有节点也会短暂断连;恢复后自动刷新。如果发布清单已变化,需要重新确认。
</Confirm>
) : null}
</div>