Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4609048865 | ||
|
|
900bbde46a | ||
|
|
2d31bd091e | ||
|
|
c9c21f8056 | ||
|
|
b2b63c065d | ||
|
|
0873acf66a | ||
|
|
be04045c8c | ||
|
|
006814823b | ||
|
|
7ff0d0f4e6 | ||
|
|
a9f78f99df | ||
|
|
a3338e1cf3 | ||
|
|
044ce0c339 |
No files matched your search
@@ -7,12 +7,19 @@ clone:
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
# 要拿到 tag 才算得出开发版本号(ci/version.sh:自最近 tag 的提交数)
|
||||
tags: true
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
|
||||
|
||||
# 先等后端测试和前端构建都通过再出镜像(提交信息写 [SKIP TESTS] 只跳过后端测试)
|
||||
depends_on:
|
||||
- verify
|
||||
- web-build
|
||||
|
||||
concurrency: 1
|
||||
|
||||
steps:
|
||||
@@ -28,7 +35,7 @@ steps:
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh backend
|
||||
- bash ci/build-dev-image.sh backend
|
||||
depends_on: []
|
||||
|
||||
- name: build-linux-amd64-agent
|
||||
@@ -43,7 +50,7 @@ steps:
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh agent
|
||||
- bash ci/build-dev-image.sh agent
|
||||
depends_on: []
|
||||
|
||||
- name: build-linux-amd64-probe
|
||||
@@ -58,5 +65,20 @@ steps:
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh probe
|
||||
- bash ci/build-dev-image.sh probe
|
||||
depends_on: []
|
||||
|
||||
# 慢的端到端测试:等三个镜像都构建、推送完再跑,不和 NativeAOT 编译抢内存(同时跑过一次,Backend 的 AOT 编译被 OOM 杀掉了)。
|
||||
# 它失败不影响已经推出去的镜像,只让流水线变红。没带 [CI] 的推送由 e2e.yml 跑。
|
||||
- name: backend-e2e-tests
|
||||
image: bash
|
||||
when:
|
||||
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
commands:
|
||||
- bash ci/verify-backend.sh slow
|
||||
depends_on:
|
||||
- build-linux-amd64-backend
|
||||
- build-linux-amd64-agent
|
||||
- build-linux-amd64-probe
|
||||
@@ -0,0 +1,32 @@
|
||||
labels:
|
||||
ci: domain-linux-native
|
||||
platform: linux/amd64
|
||||
|
||||
clone:
|
||||
- name: git
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
|
||||
# 带 [CI] 的推送会出镜像,慢测试放在 dev-image 里、等镜像构建完再跑(不和 AOT 编译抢内存);这个 workflow 管其余的:PR 和没带 [CI] 的推送
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: main
|
||||
evaluate: 'not (CI_COMMIT_MESSAGE contains "[CI]")'
|
||||
|
||||
# 等 verify 编译完、快的测试过了再跑(共用它的增量编译缓存,不重新编译)
|
||||
depends_on:
|
||||
- verify
|
||||
|
||||
steps:
|
||||
- name: backend-e2e-tests
|
||||
image: bash
|
||||
# [SKIP TESTS] 和 verify 一样,跳过后端测试
|
||||
when:
|
||||
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
commands:
|
||||
# 标了 Category=Slow 的端到端测试,见 ci/verify-backend.sh
|
||||
- bash ci/verify-backend.sh slow
|
||||
@@ -22,6 +22,8 @@ steps:
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /home/justme/.npm
|
||||
commands:
|
||||
# 版本取自 tag:先写进工作区(仓库里的版本号只是占位),前端才会带上对的版本
|
||||
- bash ci/version.sh apply
|
||||
- npm ci --prefix web --prefer-offline
|
||||
- timeout 600 npm run build --prefix web
|
||||
depends_on: []
|
||||
|
||||
@@ -11,7 +11,7 @@ clone:
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: dev
|
||||
branch: main
|
||||
|
||||
# On Gitea PR events, CI_COMMIT_MESSAGE is the PR title.
|
||||
steps:
|
||||
@@ -23,4 +23,5 @@ steps:
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
commands:
|
||||
- cd backend && dotnet test --solution MoShiro.slnx -c Release
|
||||
# 增量编译 + 快的那批测试(约 20 秒);慢的端到端测试在 e2e workflow 里和出镜像并行跑。见 ci/verify-backend.sh
|
||||
- bash ci/verify-backend.sh fast
|
||||
@@ -11,17 +11,14 @@ clone:
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: dev
|
||||
|
||||
depends_on:
|
||||
- verify
|
||||
branch: main
|
||||
|
||||
# 和 verify 并行:前端构建不依赖后端测试,两个同时跑
|
||||
steps:
|
||||
- name: web-build
|
||||
image: bash
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /home/justme/.npm
|
||||
commands:
|
||||
- npm ci --prefix web --prefer-offline
|
||||
- npm run build --prefix web
|
||||
- npm run lint:css --prefix web
|
||||
# node_modules 按锁文件哈希缓存,展示端和管理端并行构建,再检查 CSS,见 ci/web-build.sh
|
||||
- bash ci/web-build.sh
|
||||
@@ -6,8 +6,9 @@ MoShiro is a self-hosted network path quality monitor. Backend coordinates Agent
|
||||
|
||||
## Branch and workspace safety
|
||||
|
||||
- Treat `main` as the stable release branch and `dev` as the integration branch. Keep ordinary implementation work on `dev` or a feature branch; integrate into `main` through a pull request.
|
||||
- A push to `main` builds images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. When squashing or pushing to `main`, add `[CI]` unless a build is explicitly not wanted.
|
||||
- `main` is the only working branch (the old `dev` branch is retired and no pipeline uses it). Commit and push to `main` directly; use a pull request when a review is wanted.
|
||||
- A push to `main` publishes development images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. The fast Backend tests (`verify`) and the web build run on every `main` push and must pass first; the slow end-to-end tests (`[Trait("Category", "Slow")]`) run after the images are built and pushed (inside `dev-image` for `[CI]` pushes, workflow `e2e` otherwise) and never block them; `[SKIP TESTS]` skips only the Backend tests.
|
||||
- Versions are filled in by CI (`ci/version.sh`): a `vX.Y.Z` tag builds exactly `X.Y.Z`; a `[CI]` build on `main` is `vX.Y.(Z+1)-dev.<N>-<sha7>` (`N` = commits since the latest tag). The `<Version>` in `backend/Directory.Build.props` and the `version` in the `web` `package.json` files are placeholders (`0.0.0-dev`); never bump them by hand.
|
||||
- Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit.
|
||||
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests). CI registers the signed manifests at `/api/release/offer`, which needs no token because the panel verifies the signature. The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
|
||||
- Keep development OCI images in the `moshiro-dev/` repository path and release images in `moshiro/`. Do not let a development build overwrite release tags such as `latest`.
|
||||
@@ -40,4 +41,4 @@ Follow the user's requested validation scope. Do not start a full tag/release bu
|
||||
|
||||
CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/ops/deployment.md` before changing or describing them. In particular, check the distinction between `[CI]` main builds, `[SKIP TESTS]`, and version-tag releases in the current branch.
|
||||
|
||||
A version tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline. Development image builds and releases must remain isolated in their respective Zot repository paths.
|
||||
A version tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline; it needs no version-bump commit, because CI takes the version from the tag. Development image builds and releases must remain isolated in their respective Zot repository paths.
|
||||
@@ -5,7 +5,7 @@
|
||||
## 测量
|
||||
|
||||
- 路由跳数、路径漂移和 trace
|
||||
- QUIC 握手耗时:TCP 建连和 TLS 握手已经有了(`connect` 任务)。.NET 的 QUIC 依赖系统里的 msquic 原生库,和节点「只依赖 OpenSSL、NativeAOT 单文件」冲突,要做只能自己写最小的 QUIC Initial 握手探测
|
||||
- QUIC 握手耗时:TCP 建连和 TLS 握手已经有了(`connect` 任务)。.NET 的 QUIC 依赖系统里的 msquic 原生库,和节点「只依赖 OpenSSL、NativeAOT 单文件」冲突,要做只能自己写最小的 QUIC Initial 握手探测;另外 .NET 还没有 QUIC datagram([dotnet/runtime#123418](https://github.com/dotnet/runtime/issues/123418) 只是提案),`System.Net.Quic` 只有可靠的流,重传会盖住丢包,测不了 UDP 丢包率
|
||||
- 网页长截图
|
||||
- pcap / eBPF 采集
|
||||
|
||||
@@ -14,10 +14,6 @@
|
||||
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
|
||||
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security/security.md`](docs/security/security.md) 第 9.2 节
|
||||
|
||||
## 部署
|
||||
|
||||
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
|
||||
[security.md](docs/security/security.md)
|
||||
## 访客页面
|
||||
|
||||
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
||||
<Deterministic>true</Deterministic>
|
||||
<Version>0.4.0</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<NuGetAudit>false</NuGetAudit>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -143,6 +143,7 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
|
||||
_udp.Bind();
|
||||
|
||||
@@ -78,8 +78,9 @@ public sealed class PeerHub
|
||||
{
|
||||
private const int DedupeWindow = 1024;
|
||||
|
||||
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。</summary>
|
||||
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(75);
|
||||
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。
|
||||
/// 要比线路可能的中断长得多:差的线路上几十秒甚至一两分钟的黑洞时有发生(TCP RTT 能到 30 秒),75 秒就关会话会把一次中断变成几分钟的数据空白(要重新握手,而握手在差线路上也慢)。</summary>
|
||||
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(180);
|
||||
|
||||
private readonly RpcRouter<PeerContext> _router;
|
||||
private readonly SessionRegistry _sessions;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
|
||||
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
|
||||
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
|
||||
5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
|
||||
5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点由退出后 systemd 重新拉镜像、Backend 和脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
|
||||
|
||||
## 身份
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ public sealed class PeerListenerOptions
|
||||
|
||||
public int MaxSessions { get; init; } = 8;
|
||||
|
||||
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
/// <summary>敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。</summary>
|
||||
public TimeSpan HandshakeTimeout { get; init; } = PeerTimings.Handshake;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -209,7 +210,8 @@ public sealed class PeerListener
|
||||
}
|
||||
|
||||
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
|
||||
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
|
||||
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 30 秒后每 10 秒探测,连续 6 次无响应才断开,约 90 秒),主机真的没了的时候连接不会一直挂着;
|
||||
/// 但线路短暂中断(几十秒的黑洞)不能把会话杀掉——线路中断本来就是要测的东西,会话得扛过去,恢复之后接着测。</summary>
|
||||
internal static class PeerSockets
|
||||
{
|
||||
public static void EnableKeepAlive(Socket socket)
|
||||
@@ -217,9 +219,9 @@ internal static class PeerSockets
|
||||
try
|
||||
{
|
||||
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 6);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
|
||||
{
|
||||
@@ -228,6 +230,13 @@ internal static class PeerSockets
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>点对点会话的时间常数,Probe 和 Agent 两侧共用。</summary>
|
||||
public static class PeerTimings
|
||||
{
|
||||
/// <summary>建立会话(连接、敲门、TLS、挑战应答)的总时限。</summary>
|
||||
public static readonly TimeSpan Handshake = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public static class PeerConnector
|
||||
{
|
||||
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
|
||||
@@ -240,7 +249,7 @@ public static class PeerConnector
|
||||
PeerSockets.EnableKeepAlive(client.Client);
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
|
||||
using var timeout = new CancellationTokenSource(PeerTimings.Handshake, time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
await client.ConnectAsync(host, port, linked.Token);
|
||||
Stream stream = client.GetStream();
|
||||
|
||||
@@ -47,7 +47,8 @@ public sealed class AgentConnection
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(20));
|
||||
// 重连的间隔别拉太长:每次尝试本来就要等好几秒(握手上限 30 秒),线路一恢复要尽快接上;带抖动,不会和别的节点撞在一起
|
||||
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(8));
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -103,6 +103,7 @@ public sealed class ProbeApp : IDisposable
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_log.Info("moshiro probe starting", MoShiro.Protocol.BuildInfo.Fields);
|
||||
if (_env.Directory.Agents.Count == 0)
|
||||
{
|
||||
_log.Info("no agents known yet, waiting for the panel to send the list");
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>构建信息的人能读的形式(启动时打进日志);常量部分由 MoShiro.Protocol.csproj 的 WriteBuildInfo 在构建时生成。</summary>
|
||||
public static partial class BuildInfo
|
||||
{
|
||||
/// <summary>版本、tag、提交、构建时间的字段,没有的(本地构建不设环境变量)写 unknown。</summary>
|
||||
public static (string Key, object? Value)[] Fields { get; } =
|
||||
[
|
||||
("version", Or(Version)),
|
||||
("tag", Or(Tag)),
|
||||
("commit", Or(Commit)),
|
||||
("built_at", Or(BuiltAt)),
|
||||
];
|
||||
|
||||
/// <summary>一行摘要,例如:0.4.1-dev.2-a3338e1 (tag v0.4.1-dev.2-a3338e1, commit a3338e1b2c, built 2026-10-09T03:00:00Z)。</summary>
|
||||
public static string Summary => $"{Or(Version)} (tag {Or(Tag)}, commit {Or(Commit)}, built {Or(BuiltAt)})";
|
||||
|
||||
private static string Or(string value) => string.IsNullOrWhiteSpace(value) ? "unknown" : value;
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
<ItemGroup>
|
||||
<BuildInfoLine Include="namespace MoShiro.Protocol%3B" />
|
||||
<BuildInfoLine Include="/// <summary>这个程序是什么时候、从哪个版本构建出来的(构建时生成)。</summary>" />
|
||||
<BuildInfoLine Include="public static class BuildInfo" />
|
||||
<BuildInfoLine Include="public static partial class BuildInfo" />
|
||||
<BuildInfoLine Include="{" />
|
||||
<BuildInfoLine Include=" public const string Version = "$(Version)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Tag = "$(MOSHIRO_TAG)"%3B" />
|
||||
|
||||
@@ -55,9 +55,14 @@ public sealed class BackendUpdates : IHostedService
|
||||
return "release";
|
||||
}
|
||||
|
||||
return BuildInfo.Tag is { } tag && (tag.StartsWith("dev-", StringComparison.Ordinal) || tag.StartsWith("main-", StringComparison.Ordinal)) ? "dev" : "release";
|
||||
return IsDevTag(BuildInfo.Tag) ? "dev" : "release";
|
||||
}
|
||||
|
||||
/// <summary>开发构建的 tag:新格式 v0.4.1-dev.12-abc1234(含 -dev.),以及旧的 dev-<sha>、main-<sha>。</summary>
|
||||
public static bool IsDevTag(string? tag) =>
|
||||
tag is not null
|
||||
&& (tag.Contains("-dev.", StringComparison.Ordinal) || tag.StartsWith("dev-", StringComparison.Ordinal) || tag.StartsWith("main-", StringComparison.Ordinal));
|
||||
|
||||
public async ValueTask<BackendUpdateView> GetAsync(CancellationToken ct)
|
||||
{
|
||||
string channel = ChannelOf(_options);
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
using MoShiro.Backend;
|
||||
|
||||
await BackendHost.Build(args).RunAsync();
|
||||
WebApplication app = BackendHost.Build(args);
|
||||
// 启动时打一行构建信息:版本、tag、提交、构建时间(和 Agent、Probe 启动时打的是同一组)
|
||||
if (app.Logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
app.Logger.LogInformation("moshiro backend starting: version {BuildInfo}", MoShiro.Protocol.BuildInfo.Summary);
|
||||
}
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -257,7 +257,9 @@ public sealed class AgentEndToEndTests
|
||||
}
|
||||
}
|
||||
|
||||
// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh
|
||||
[Fact]
|
||||
[Trait("Category", "Slow")]
|
||||
public async Task Hy2Ladder_EndToEnd_AgentSendsTheTrain_ProbeReportsReceipts_AndTheTestSucceeds()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
|
||||
@@ -30,6 +30,18 @@ public sealed class BackendUpdateTests : IAsyncLifetime
|
||||
Assert.Equal(expected, BackendUpdates.ChannelOf(new BackendOptions { UpdateChannel = "auto", ImageNamespace = imageNamespace }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("v0.4.1-dev.12-044ce0c", true)]
|
||||
[InlineData("dev-725fe17a7de8", true)]
|
||||
[InlineData("main-725fe17a7d", true)]
|
||||
[InlineData("v0.4.0", false)]
|
||||
[InlineData("v0.4.1-rc.1", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsDevTag_RecognisesDevelopmentBuilds(string? tag, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, BackendUpdates.IsDevTag(tag));
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> PublishAsync(long seq, string channel = "dev")
|
||||
{
|
||||
string manifest = $$"""{"component":"backend","channel":"{{channel}}","image":"registry.example/moshiro-{{channel}}/moshiro-backend","digest":"{{Digest}}","tag":"{{channel}}-{{seq}}","version":"0.1.1","seq":{{seq}}}""";
|
||||
|
||||
@@ -221,6 +221,7 @@ public sealed class HostStatusTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Slow")]
|
||||
public async Task AProbeThatTurnsReportingOff_SendsNothing()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
|
||||
@@ -9,7 +9,9 @@ using MoShiro.TestSupport;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>真实双栈回环:同一身份的两种会话、原始块、查询与测试结果必须各自独立。</summary>
|
||||
// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh
|
||||
[Collection("probe-e2e")]
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class Ipv6Tests
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
@@ -67,6 +67,7 @@ public sealed class OnDemandTestsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Slow")]
|
||||
public async Task Hy2Ladder_RunsEndToEnd_AndProducesAResultWithScores()
|
||||
{
|
||||
await using AgentSimulator agent = await AgentWithProbeAsync();
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Text.RegularExpressions;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 对照 docs/api/api-guidelines.md 检查运行时生成的 OpenAPI 文档:operationId、路径风格、错误响应格式、字段命名。
|
||||
/// 对照 docs/api/api.md 第 7 节 检查运行时生成的 OpenAPI 文档:operationId、路径风格、错误响应格式、字段命名。
|
||||
/// 新增端点违反规范时,这里会直接失败(等价于文档里的 <c>eng/check-openapi</c>)。
|
||||
/// </summary>
|
||||
[Collection("backend")]
|
||||
|
||||
@@ -179,7 +179,9 @@ public sealed class OperationsAndSecurityTests
|
||||
Assert.Equal("fleet_protocol_violation", (await agent.RequestRawAsync("fleet.exec")).Error!.Data!.Code);
|
||||
}
|
||||
|
||||
// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh
|
||||
[Fact]
|
||||
[Trait("Category", "Slow")]
|
||||
public async Task ARepeatOffender_IsDisconnected_AndANodeCannotInitiateWhatOnlyTheBackendMay()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
|
||||
@@ -3,7 +3,9 @@ using System.Text.Json.Nodes;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>真实的后端 + 真实的 Agent + 真实的 Probe(都在进程内,回环网络)。</summary>
|
||||
// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh
|
||||
[Collection("probe-e2e")]
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class ProbeEndToEndTests
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and push one opt-in main Linux amd64 image to the dev repository in Zot.
|
||||
# Build and push one opt-in Linux amd64 development image (main push with [CI]) to the moshiro-dev repository in Zot.
|
||||
set -euo pipefail
|
||||
|
||||
[[ "${CI_PIPELINE_EVENT:-}" == push && "${CI_COMMIT_BRANCH:-}" == main && "${CI_COMMIT_MESSAGE:-}" == *"[CI]"* ]] || {
|
||||
echo "main amd64 builds require a main push with [CI] in the commit message" >&2
|
||||
echo "development image builds require a main push with [CI] in the commit message" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
component="${1:?usage: build-main-amd64.sh <backend|agent|probe>}"
|
||||
component="${1:?usage: build-dev-image.sh <backend|agent|probe>}"
|
||||
case "$component" in
|
||||
backend|agent|probe) ;;
|
||||
*) echo "unsupported component: $component" >&2; exit 2 ;;
|
||||
@@ -22,20 +22,22 @@ esac
|
||||
}
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# 版本:v<下一个补丁版本>-dev.<自最近 tag 的提交数>-<提交前 7 位>(见 ci/version.sh);写进工作区的 Directory.Build.props,镜像标签、构建信息、签名清单都用它
|
||||
source "$root/ci/version.sh"
|
||||
moshiro_apply_version
|
||||
registry="${ZOT_REGISTRY:-registry.oeo.one}"
|
||||
repository="$registry/moshiro-dev/moshiro-$component"
|
||||
work="$root/.release/main-amd64-build"
|
||||
base_home="/home/justme/woodpecker/buildah-home/main-amd64-build-$component"
|
||||
short_sha="${CI_COMMIT_SHA:0:12}"
|
||||
dev_tag="dev-$short_sha"
|
||||
work="$root/.release/dev-image-build"
|
||||
base_home="/home/justme/woodpecker/buildah-home/dev-image-build-$component"
|
||||
# 镜像标签不带 v(和正式版一致):latest 和 <版本>;构建信息和清单里的 tag 带 v
|
||||
image_version="$MOSHIRO_VERSION"
|
||||
mkdir -p "$work" "$base_home"
|
||||
|
||||
if [[ "$component" == backend ]]; then
|
||||
export MOSHIRO_TAG="$dev_tag"
|
||||
BUILDAH_HOME="$base_home" \
|
||||
ZOT_REGISTRY="$registry" \
|
||||
ZOT_IMAGE_REPOSITORY="$repository" \
|
||||
IMAGE_TAGS="latest,$dev_tag" \
|
||||
IMAGE_TAGS="latest,$image_version" \
|
||||
IMAGE_PUBLISH=true \
|
||||
bash "$root/ci/release-linux.sh" amd64 backend
|
||||
exit 0
|
||||
@@ -45,12 +47,12 @@ export HOME="$base_home"
|
||||
export BUILDAH_HOME="$base_home"
|
||||
export REGISTRY_AUTH_FILE="$work/$component-auth.json"
|
||||
context="$work/$component"
|
||||
local_image="localhost/moshiro-$component:main-amd64-check"
|
||||
local_image="localhost/moshiro-$component:dev-amd64-check"
|
||||
|
||||
python3 "$root/ci/stage-node-image.py" "$component" "$context"
|
||||
buildah bud --layers --network host --tls-verify=true --arch amd64 \
|
||||
--build-arg APP_VERSION=main \
|
||||
--build-arg "MOSHIRO_TAG=$dev_tag" --build-arg "MOSHIRO_COMMIT=${CI_COMMIT_SHA:0:10}" --build-arg "MOSHIRO_BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--build-arg "APP_VERSION=$image_version" \
|
||||
--build-arg "MOSHIRO_TAG=$MOSHIRO_TAG" --build-arg "MOSHIRO_COMMIT=${CI_COMMIT_SHA:0:10}" --build-arg "MOSHIRO_BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-f "$context/Containerfile" \
|
||||
-t "$local_image" "$context"
|
||||
|
||||
@@ -67,7 +69,7 @@ for attempt in 1 2 3 4; do
|
||||
done
|
||||
[[ "$logged_in" == true ]] || { echo "could not authenticate to Zot after 4 attempts" >&2; exit 1; }
|
||||
|
||||
for tag in latest "$dev_tag"; do
|
||||
for tag in latest "$image_version"; do
|
||||
pushed=false
|
||||
for attempt in 1 2 3; do
|
||||
if buildah push --tls-verify=true --digestfile "$work/$component.digest" "$local_image" "docker://$repository:$tag"; then
|
||||
@@ -86,5 +88,5 @@ done
|
||||
# 发布清单:给镜像的 digest 签名(私钥只在 CI 密钥里),放进镜像仓库(<仓库>-manifest:dev),面板在线时再登记一份加速。
|
||||
# 没有配置签名密钥(例如 fork)就只构建镜像、不发清单。
|
||||
if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then
|
||||
bash "$root/ci/publish-release-offer.sh" "$component" "$repository" "$(cat "$work/$component.digest")" "$dev_tag" dev "$work/$component-release"
|
||||
bash "$root/ci/publish-release-offer.sh" "$component" "$repository" "$(cat "$work/$component.digest")" "$MOSHIRO_TAG" dev "$work/$component-release"
|
||||
fi
|
||||
@@ -10,10 +10,10 @@ if (-not $env:GITEA_TOKEN) {
|
||||
$version = $Matches[1]
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$backend = Join-Path $root 'backend'
|
||||
$projectVersion = [regex]::Match((Get-Content (Join-Path $backend 'Directory.Build.props') -Raw), '<Version>([^<]+)</Version>').Groups[1].Value
|
||||
if ($version -ne $projectVersion) {
|
||||
throw "Tag version $version does not match project version $projectVersion."
|
||||
}
|
||||
# 版本取自 tag:写进工作区的 Directory.Build.props(仓库里的 <Version> 只是占位,发版不用再提交 bump)
|
||||
$propsPath = Join-Path $backend 'Directory.Build.props'
|
||||
$props = Get-Content $propsPath -Raw
|
||||
Set-Content -LiteralPath $propsPath -Value ([regex]::Replace($props, '<Version>[^<]*</Version>', "<Version>$version</Version>")) -NoNewline
|
||||
|
||||
$tarProject = Join-Path $PSScriptRoot 'MoShiro.Tar\MoShiro.Tar.csproj'
|
||||
$tarTool = Join-Path $PSScriptRoot 'MoShiro.Tar\bin\Release\net10.0\MoShiro.Tar.dll'
|
||||
|
||||
@@ -20,10 +20,10 @@ if (-not $env:CI_COMMIT_TAG -or $env:CI_COMMIT_TAG -notmatch '^v(\d+\.\d+\.\d+(?
|
||||
$version = $Matches[1]
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$backend = Join-Path $root 'backend'
|
||||
$projectVersion = [regex]::Match((Get-Content (Join-Path $backend 'Directory.Build.props') -Raw), '<Version>([^<]+)</Version>').Groups[1].Value
|
||||
if ($version -ne $projectVersion) {
|
||||
throw "Tag version $version does not match project version $projectVersion."
|
||||
}
|
||||
# 版本取自 tag:写进工作区的 Directory.Build.props(仓库里的 <Version> 只是占位,发版不用再提交 bump)
|
||||
$propsPath = Join-Path $backend 'Directory.Build.props'
|
||||
$props = Get-Content $propsPath -Raw
|
||||
Set-Content -LiteralPath $propsPath -Value ([regex]::Replace($props, '<Version>[^<]*</Version>', "<Version>$version</Version>")) -NoNewline
|
||||
|
||||
if ($RuntimeIdentifier -in @('win-x64', 'win-arm64')) {
|
||||
$vcArchitecture = if ($RuntimeIdentifier -eq 'win-x64') { 'x64' } else { 'arm64' }
|
||||
@@ -52,7 +52,7 @@ $buildRoot = Join-Path $root ".release\build\$RuntimeIdentifier-$Component"
|
||||
$null = New-Item -ItemType Directory -Force -Path $output
|
||||
|
||||
# 构建信息:程序里显示的 tag、提交、构建时间
|
||||
$env:MOSHIRO_TAG = if ($env:CI_COMMIT_TAG) { $env:CI_COMMIT_TAG } else { "main-$($env:CI_COMMIT_SHA.Substring(0, 10))" }
|
||||
$env:MOSHIRO_TAG = $env:CI_COMMIT_TAG
|
||||
$env:MOSHIRO_COMMIT = $env:CI_COMMIT_SHA.Substring(0, 10)
|
||||
$env:MOSHIRO_BUILT_AT = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
|
||||
|
||||
@@ -6,19 +6,16 @@ set -euo pipefail
|
||||
arch="${1:?usage: release-linux.sh <amd64|arm64>}"
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
tag="${CI_COMMIT_TAG:-}"
|
||||
if [[ -n "$tag" ]]; then
|
||||
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || {
|
||||
echo "release must run from a vMAJOR.MINOR.PATCH tag" >&2
|
||||
exit 2
|
||||
}
|
||||
version="${tag#v}"
|
||||
else
|
||||
if [[ -z "$tag" ]]; then
|
||||
[[ "${CI_PIPELINE_EVENT:-}" == push && "${CI_COMMIT_BRANCH:-}" == main && "${CI_COMMIT_MESSAGE:-}" == *"[CI]"* ]] || {
|
||||
echo "untagged backend builds require a main push with [CI] in the commit message" >&2
|
||||
exit 2
|
||||
}
|
||||
version=main
|
||||
fi
|
||||
# 版本:tag 发版就是 tag 的版本,开发构建是 v<下一个补丁版本>-dev.<N>-<提交>(ci/version.sh);写进工作区的 Directory.Build.props
|
||||
source "$root/ci/version.sh"
|
||||
moshiro_apply_version
|
||||
version="$MOSHIRO_VERSION"
|
||||
image_publish="${IMAGE_PUBLISH:-true}"
|
||||
[[ "$image_publish" == true || "$image_publish" == false ]] || {
|
||||
echo "IMAGE_PUBLISH must be true or false" >&2; exit 2;
|
||||
@@ -34,12 +31,6 @@ case "$arch" in
|
||||
*) echo "unsupported architecture: $arch" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
project_version="$(sed -n 's#.*<Version>\(.*\)</Version>.*#\1#p' "$root/backend/Directory.Build.props" | head -1)"
|
||||
if [[ -n "$tag" && "$version" != "$project_version" ]]; then
|
||||
echo "tag version $version does not match project version $project_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
registry="${ZOT_REGISTRY:-registry.oeo.one}"
|
||||
image="${ZOT_IMAGE_REPOSITORY:-$registry/moshiro/moshiro-backend}"
|
||||
output="$root/.release/linux-$arch/backend"
|
||||
@@ -47,8 +38,7 @@ build_root="$root/.release/build/linux-$arch-backend"
|
||||
container_context="$root/.release/container/linux-$arch"
|
||||
mkdir -p "$output" "$build_root" "$container_context/app" "$container_context/data"
|
||||
|
||||
# 构建信息:tag 发布用 tag,main 构建用 main-<提交>
|
||||
export MOSHIRO_TAG="${MOSHIRO_TAG:-${tag:-main-${CI_COMMIT_SHA:0:10}}}"
|
||||
# 构建信息:tag 发布用 tag,开发构建用 v<版本>-dev.<N>-<提交>(MOSHIRO_TAG 已由 ci/version.sh 设好)
|
||||
export MOSHIRO_COMMIT="${MOSHIRO_COMMIT:-${CI_COMMIT_SHA:0:10}}"
|
||||
export MOSHIRO_BUILT_AT="${MOSHIRO_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ repository="${2:?}"; digest="${3:?}"; tag="${4:?}"; channel="${5:?}"; outdir="${
|
||||
[[ -n "${RELEASE_SIGNING_KEY:-}" ]] || { echo "RELEASE_SIGNING_KEY is required" >&2; exit 2; }
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
version="$(sed -n 's#.*<Version>\(.*\)</Version>.*#\1#p' "$root/backend/Directory.Build.props" | head -1)"
|
||||
source "$root/ci/version.sh"
|
||||
version="$MOSHIRO_VERSION"
|
||||
commit="$(git -C "$root" rev-parse --short=10 HEAD 2>/dev/null || true)"
|
||||
built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
seq="$(date +%s)"
|
||||
|
||||
@@ -15,11 +15,9 @@ tag="${CI_COMMIT_TAG:-}"
|
||||
|
||||
version="${tag#v}"
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
project_version="$(sed -n 's#.*<Version>\(.*\)</Version>.*#\1#p' "$root/backend/Directory.Build.props" | head -1)"
|
||||
if [[ "$version" != "$project_version" ]]; then
|
||||
echo "tag version $version does not match project version $project_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 版本取自 tag:写进工作区的 Directory.Build.props(仓库里的 <Version> 只是占位,发版不用再提交 bump)
|
||||
source "$root/ci/version.sh"
|
||||
moshiro_apply_version
|
||||
|
||||
gitea_base="${GITEA_BASE:-http://127.0.0.1:3100}"
|
||||
gitea_base="${gitea_base%/}"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# 后端测试,带增量编译:把后端源码同步到一个固定目录,在那里 dotnet build / test。
|
||||
# 用法:verify-backend.sh [fast|slow]
|
||||
# fast(默认):除了标了 Category=Slow 的以外的全部测试,约 20 秒,出镜像之前要过;
|
||||
# slow:只跑标了 Category=Slow 的端到端测试(真实时间的阶梯、要等测量块出来,合起来一百来秒),和出镜像并行跑,失败照样红,但不拦着出镜像。
|
||||
#
|
||||
# 为什么:每条流水线的工作区都是新克隆的(路径、文件时间都是新的),MSBuild 没法增量,每次从零编译整个解决方案要两分钟。
|
||||
# 固定目录里 obj/bin 留着,同步时只更新内容真的变了的文件(按内容比较,没变的文件时间不动),MSBuild 就只重编译受影响的项目;
|
||||
# 改动小的时候整个测试 20–30 秒。
|
||||
#
|
||||
# 同一时间只允许一条流水线用这份缓存(flock);增量编译失败(缓存坏了)就清掉缓存从头编译一次,所以最坏也只是回到原来的速度。
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
mode="${1:-fast}"
|
||||
case "$mode" in
|
||||
fast) filter=(--filter-not-trait "Category=Slow") ;;
|
||||
slow) filter=(--filter-trait "Category=Slow") ;;
|
||||
*) echo "usage: verify-backend.sh [fast|slow]" >&2; exit 2 ;;
|
||||
esac
|
||||
cache="${MOSHIRO_CI_CACHE:-/home/justme/woodpecker/cache/backend-tests}"
|
||||
src="$cache/src"
|
||||
mkdir -p "$cache"
|
||||
|
||||
# .NET 第一次运行的欢迎信息、遥测、开发证书,在每条流水线的临时 HOME 里都要白做一遍
|
||||
export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 DOTNET_GENERATE_ASPNET_CERTIFICATE=false
|
||||
|
||||
exec 9> "$cache/.lock"
|
||||
flock -w 900 9 || { echo "could not get the backend test cache lock" >&2; exit 1; }
|
||||
|
||||
sync_sources() {
|
||||
mkdir -p "$src"
|
||||
local list
|
||||
list="$(mktemp)"
|
||||
# 构建和测试要用的:backend/、docs/(有的测试读协议文档)、packaging/(Backend.Api 把安装脚本、compose、Quadlet 单元和发布公钥当嵌入资源编进去)、根目录的几个文件(Directory.Packages.props、global.json、NuGet 配置)
|
||||
(cd "$root" && git ls-files -z -- backend packaging docs Directory.Packages.props global.json 'NuGet.*' 'nuget.*') | tr '\0' '\n' | LC_ALL=C sort -u > "$list"
|
||||
local changed=0 file
|
||||
while IFS= read -r file; do
|
||||
[[ -f "$root/$file" ]] || continue
|
||||
if ! cmp -s "$root/$file" "$src/$file" 2>/dev/null; then
|
||||
mkdir -p "$(dirname "$src/$file")"
|
||||
cp "$root/$file" "$src/$file"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
done < "$list"
|
||||
# 仓库里已经删掉的文件,缓存里也要删(构建产物 artifacts 不动)
|
||||
local stale=0
|
||||
while IFS= read -r file; do
|
||||
[[ -n "$file" ]] || continue
|
||||
rm -f "$src/$file"
|
||||
stale=$((stale + 1))
|
||||
done < <(cd "$src" && find backend packaging docs -type f -not -path 'backend/artifacts/*' -not -path '*/bin/*' -not -path '*/obj/*' 2>/dev/null | LC_ALL=C sort | LC_ALL=C comm -23 - <(grep -E '^(backend|packaging|docs)/' "$list"))
|
||||
rm -f "$list"
|
||||
echo "synced sources: $changed changed, $stale removed"
|
||||
}
|
||||
|
||||
build() { (cd "$src/backend" && dotnet build MoShiro.slnx -c Release --nologo -v:q); }
|
||||
|
||||
sync_sources
|
||||
if ! build; then
|
||||
echo "incremental build failed; wiping the cache and building from scratch" >&2
|
||||
rm -rf "$src"
|
||||
sync_sources
|
||||
build
|
||||
fi
|
||||
|
||||
# 测试在缓存目录里跑完整的解决方案(不再重复编译)
|
||||
log="$cache/last-test.log"
|
||||
status=0
|
||||
# 别的程序集里没有匹配的测试会因为「零个测试」返回 8,忽略它
|
||||
(cd "$src/backend" && dotnet test --solution MoShiro.slnx -c Release --no-build "${filter[@]}" --ignore-exit-code 8) 2>&1 | tee "$log" || status=${PIPESTATUS[0]}
|
||||
[[ "$status" == 0 ]] && exit 0
|
||||
|
||||
# 个别端到端测试会偶发失败(比如 Hy2 阶梯测试里探针掉线):失败的测试单独重跑(最多两次),重跑通过就当通过(日志里留着第一次的失败);
|
||||
# 失败太多说明是真的坏了,不重跑
|
||||
mapfile -t failed < <(grep -E '^(failed|失败) ' "$log" | awk '{print $2}' | awk -F. '{print $NF}' | sort -u)
|
||||
if ((${#failed[@]} == 0 || ${#failed[@]} > 3)); then
|
||||
echo "${#failed[@]} failed test(s); not retrying" >&2
|
||||
exit "$status"
|
||||
fi
|
||||
for name in "${failed[@]}"; do
|
||||
passed=false
|
||||
# 最多重跑两次:这个端到端测试在 CI 主机上不稳,一次重跑不一定够
|
||||
for attempt in 1 2; do
|
||||
echo "retrying $name (attempt $attempt of 2)"
|
||||
# 过滤要带通配符;别的程序集里没有匹配的测试,会因为「零个测试」返回 8,忽略它
|
||||
if (cd "$src/backend" && dotnet test --solution MoShiro.slnx -c Release --no-build --filter-method "*$name*" --ignore-exit-code 8); then
|
||||
passed=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ "$passed" == true ]] || { echo "$name failed again" >&2; exit 1; }
|
||||
done
|
||||
echo "the failed tests passed on retry"
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# 算出这次构建的版本号。用法:
|
||||
# source ci/version.sh # 得到 MOSHIRO_VERSION(不带 v)和 MOSHIRO_TAG(带 v)
|
||||
# bash ci/version.sh apply # 同上,并把 MOSHIRO_VERSION 写进工作区里的 backend/Directory.Build.props 和三个 web/*/package.json(只改工作区,不提交)
|
||||
#
|
||||
# 规则:
|
||||
# - 打 tag 发版(CI_COMMIT_TAG 是 vMAJOR.MINOR.PATCH):版本就是 tag 的版本。仓库里的 <Version> 只是占位,发版不用再提交一次 bump;
|
||||
# - 其余(main 上带 [CI] 的开发构建):vMAJOR.MINOR.(PATCH+1)-dev.<N>-<提交前 7 位>,
|
||||
# 基准是最近一个 v* tag,N 是这个 tag 之后的提交数。开发版本按语义化版本排在下一个正式版之前,同一个提交算出来永远一样。
|
||||
# 最近的 tag 要拿得到:CI 的克隆要带 tags(见 .woodpecker/dev-image.yml 的 clone 设置);没有任何 tag 时基准是 0.0.0。
|
||||
_moshiro_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
_moshiro_compute_version() {
|
||||
local tag="${CI_COMMIT_TAG:-}"
|
||||
if [[ -n "$tag" ]]; then
|
||||
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || {
|
||||
echo "release must run from a vMAJOR.MINOR.PATCH tag" >&2
|
||||
return 2
|
||||
}
|
||||
MOSHIRO_VERSION="${tag#v}"
|
||||
else
|
||||
local sha="${CI_COMMIT_SHA:-$(git -C "$_moshiro_root" rev-parse HEAD)}"
|
||||
local last base count major minor patch
|
||||
last="$(git -C "$_moshiro_root" describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' "$sha" 2>/dev/null || true)"
|
||||
if [[ -n "$last" ]]; then
|
||||
base="${last#v}"
|
||||
base="${base%%-*}"
|
||||
count="$(git -C "$_moshiro_root" rev-list --count "$last..$sha")"
|
||||
else
|
||||
base="0.0.0"
|
||||
count="$(git -C "$_moshiro_root" rev-list --count "$sha")"
|
||||
fi
|
||||
IFS=. read -r major minor patch <<< "$base"
|
||||
MOSHIRO_VERSION="$major.$minor.$((patch + 1))-dev.$count-${sha:0:7}"
|
||||
fi
|
||||
MOSHIRO_TAG="v$MOSHIRO_VERSION"
|
||||
export MOSHIRO_VERSION MOSHIRO_TAG
|
||||
}
|
||||
|
||||
# 把算出来的版本写进工作区的 backend/Directory.Build.props(所有 .NET 项目、清单、镜像标签都从那里读版本)和三个 package.json
|
||||
moshiro_apply_version() {
|
||||
[[ -n "${MOSHIRO_VERSION:-}" ]] || _moshiro_compute_version
|
||||
sed -i "s#<Version>[^<]*</Version>#<Version>$MOSHIRO_VERSION</Version>#" "$_moshiro_root/backend/Directory.Build.props"
|
||||
# 前端在构建时从 web/package.json 读版本(web/build-info.mjs),其余两个工作区包的版本也一起对上
|
||||
local manifest
|
||||
for manifest in web/package.json web/display/package.json web/admin/package.json; do
|
||||
sed -i -E '0,/"version": "[^"]*"/s//"version": "'"$MOSHIRO_VERSION"'"/' "$_moshiro_root/$manifest"
|
||||
done
|
||||
}
|
||||
|
||||
_moshiro_compute_version
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
if [[ "${1:-}" == apply ]]; then moshiro_apply_version; fi
|
||||
echo "$MOSHIRO_VERSION"
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# 前端构建和 CSS 检查,带依赖缓存:node_modules 按「锁文件 + package.json」的哈希缓存起来,同样的依赖不再 npm ci(约 20 秒);
|
||||
# 展示端和管理端并行构建。缓存里放的是硬链接副本(cp -al),恢复几乎是瞬间;依赖变了才重新安装并更新缓存,只留最近 3 份。
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cache="${MOSHIRO_CI_CACHE:-/home/justme/woodpecker/cache/web-node-modules}"
|
||||
export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-/home/justme/.npm}"
|
||||
web="$root/web"
|
||||
dirs=(node_modules display/node_modules admin/node_modules)
|
||||
mkdir -p "$cache"
|
||||
|
||||
key="$(cat "$web/package-lock.json" "$web/package.json" "$web/display/package.json" "$web/admin/package.json" | sha256sum | cut -c1-16)"
|
||||
|
||||
restore() {
|
||||
[[ -d "$cache/$key/node_modules" ]] || return 1
|
||||
local d
|
||||
for d in "${dirs[@]}"; do
|
||||
[[ -d "$cache/$key/$d" ]] || continue
|
||||
mkdir -p "$(dirname "$web/$d")"
|
||||
cp -al "$cache/$key/$d" "$web/$d" 2>/dev/null || cp -a "$cache/$key/$d" "$web/$d"
|
||||
done
|
||||
}
|
||||
|
||||
save() {
|
||||
local tmp="$cache/$key.tmp.$$" d
|
||||
rm -rf "$tmp"
|
||||
for d in "${dirs[@]}"; do
|
||||
[[ -d "$web/$d" ]] || continue
|
||||
mkdir -p "$(dirname "$tmp/$d")"
|
||||
cp -al "$web/$d" "$tmp/$d" 2>/dev/null || cp -a "$web/$d" "$tmp/$d"
|
||||
done
|
||||
rm -rf "$cache/$key"
|
||||
mv "$tmp" "$cache/$key"
|
||||
# 只留最近用过的 3 份
|
||||
(cd "$cache" && ls -1t | grep -v '\.tmp\.' | tail -n +4 | xargs -r rm -rf)
|
||||
}
|
||||
|
||||
if restore; then
|
||||
echo "restored node_modules from the cache ($key)"
|
||||
else
|
||||
echo "no cache for $key; running npm ci"
|
||||
rm -rf "$web/node_modules" "$web/display/node_modules" "$web/admin/node_modules"
|
||||
npm ci --prefix "$web" --prefer-offline
|
||||
save || echo "could not save the node_modules cache" >&2
|
||||
fi
|
||||
|
||||
# 展示端和管理端并行构建,再检查 CSS
|
||||
pids=()
|
||||
(cd "$web" && timeout 600 npm run build -w display) & pids+=($!)
|
||||
(cd "$web" && timeout 600 npm run build -w admin) & pids+=($!)
|
||||
failed=0
|
||||
for pid in "${pids[@]}"; do wait "$pid" || failed=1; done
|
||||
[[ "$failed" == 0 ]] || { echo "web build failed" >&2; exit 1; }
|
||||
npm run lint:css --prefix "$web"
|
||||
@@ -11,7 +11,7 @@
|
||||
| 安全 | [security.md](security/security.md) | 零信任:威胁模型、缺口与状态、协议卡口、监听隐身、extension |
|
||||
| | [access.md](security/access.md) | 访问控制与入网信任链:能力、API key、passkey、操作员集合、属主、安装的供应链 |
|
||||
| | [enrollment.md](security/enrollment.md) | 注册与身份:Agent、Probe 的接入,节点本地硬上限 |
|
||||
| 接口 | [api.md](api/api.md)、[api-guidelines.md](api/api-guidelines.md) | 端点说明;API 规范与约束 |
|
||||
| 接口 | [api.md](api/api.md) | 端点说明;API 设计约束 |
|
||||
| 运维 | [deployment.md](ops/deployment.md) | 构建、发布、CI、部署与更新、发布密钥 |
|
||||
| 开发 | [project-layout.md](dev/project-layout.md)、[coding-style.md](dev/coding-style.md) | 仓库结构与依赖规则;代码风格 |
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# API 设计约束
|
||||
|
||||
本文是所有 Backend REST 端点都必须遵守的**规则**;具体端点见 [api.md](api.md)。规则参考了 [Microsoft Azure REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md) 和 RFC 9457,并根据本项目做了取舍。内部协议(Probe↔Agent、Agent↔Backend)的兼容规则见 [protocol.md](../design/protocol.md) 第 4 节。
|
||||
|
||||
## 1. 契约生成链路
|
||||
|
||||
```text
|
||||
Minimal API 端点(TypedResults 返回类型 + 显式元数据)
|
||||
│ dotnet build(Microsoft.Extensions.ApiDescription.Server)
|
||||
▼
|
||||
backend/openapi/v1.json(OpenAPI 3.1,提交到仓库)
|
||||
│ CI:git diff --exit-code,未提交的变更会导致失败
|
||||
▼
|
||||
web:openapi-typescript 生成类型,调用方的类型错误会直接暴露
|
||||
```
|
||||
|
||||
- 端点**必须**声明精确的返回类型,例如 `Results<Ok<AgentDto>, NotFound<ProblemDetails>>`,不能返回 `IResult` 或匿名对象,否则 OpenAPI 文档会不准确。
|
||||
- 每个端点都必须设置 `WithName("agents_list")`(即 operationId,snake_case、`资源_动作`)、`WithSummary`、`WithTags`。
|
||||
- 请求和响应 DTO 放在 `MoShiro.Backend.Api/Contracts/`,并注册到 Backend 的 JSON 源生成上下文。**不要直接暴露 `MoShiro.Protocol` 中的线协议类型**,因为 REST 与线协议的演进节奏不同。
|
||||
- **结构强类型,指标弱类型**:资源结构(Agent、任务、测试等)必须是强类型 DTO;而**指标值和评分**统一使用字典 `metrics: { "<metric_id>": number }`、`scores: { "<model_id>": number }`,键的含义、单位、版本由 `/api/public/catalog/*` 给出。这样新增指标或模型不需要改 OpenAPI,也不需要改前端类型。
|
||||
|
||||
## 2. 版本与兼容性
|
||||
|
||||
- 当前版本为 v1,路径前缀为 `/api/public`、`/api/admin`、`/api/agent/v1`。出现破坏性变更时,新增 `/api/v2/public`、`/api/v2/admin` 等路径。v1 内只允许**非破坏性变更**:
|
||||
|
||||
| 非破坏性变更(v1 内允许) | 破坏性变更(需要新建 v2) |
|
||||
| --- | --- |
|
||||
| 新增端点 | 删除或重命名端点、字段 |
|
||||
| 响应中新增字段 | 修改字段类型、单位或语义 |
|
||||
| 新增可选的请求字段或查询参数 | 可选字段改为必填 |
|
||||
| 枚举新增值 | 删除枚举值 |
|
||||
| 放宽校验 | 收紧校验、修改默认值 |
|
||||
| 新增错误码 | 修改已有错误码对应的 HTTP 状态 |
|
||||
|
||||
- 客户端必须忽略未知字段,也必须能处理未知的枚举值。
|
||||
- 需要 v2 时,v1 和 v2 至少并存一个发布周期;OpenAPI 文档按版本分文件(`v1.json`、`v2.json`)。
|
||||
|
||||
## 3. URL 与方法
|
||||
|
||||
- **按调用方分层**:`/api/public`(只读展示)、`/api/admin`(管理)、`/api/agent`(节点)。同一个资源在不同层中可以有不同的 DTO,例如 public 层不暴露 `pubkey`、`local_limits`。
|
||||
- 路径全部小写,资源名用复数名词,多个单词用 `-` 连接:`/api/admin/join-tokens`。
|
||||
- 嵌套最多一层;子资源一旦有全局唯一 ID,就提供顶层路径(`/probes/{probe_id}`)。
|
||||
- 查询参数和 JSON 字段使用 snake_case。
|
||||
|
||||
| 操作 | 方法 | 成功状态 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 列表 | GET 集合 | 200 | 分页 |
|
||||
| 读取 | GET 单个 | 200 | |
|
||||
| 创建 | POST 集合 | 201 + `Location` | 响应体返回完整资源 |
|
||||
| 部分更新 | PATCH | 200 | `application/merge-patch+json`(RFC 7396) |
|
||||
| 删除 | DELETE | 204 | 资源不存在时同样返回 204(幂等) |
|
||||
| 非 CRUD 动作 | POST `/{resource}/{id}/{verb}` | 200 或 204 | 例如 `revoke`、`rotate-token` |
|
||||
|
||||
- GET 请求不能产生副作用。PUT 不使用:配置类资源都通过 PATCH 修改。
|
||||
|
||||
## 4. 数据表示
|
||||
|
||||
| 类别 | 规则 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 时间点 | RFC 3339 UTC,以 `Z` 结尾,精确到毫秒。**唯一例外**:列式时序响应中的时间轴数组 `t` 使用 Unix 毫秒(见 [query.md](../design/query.md)) | `2026-09-27T10:00:00.000Z` |
|
||||
| 时长 | 整数 + 单位后缀 | `interval_ms: 1000` |
|
||||
| 延迟 | 带小数的毫秒值(指标字典中的单位见目录) | `rtt_p95_ms: 58.93` |
|
||||
| 百分比 | 0–100 的数字 + `_pct` | `loss_pct: 0.5` |
|
||||
| 计数 | 整数,不使用浮点数 | `attempts: 600` |
|
||||
| ID | 带类型前缀的 ULID 字符串 | `prb_01J9Z...` |
|
||||
| 枚举 | snake_case 字符串 | `"udp_echo"` |
|
||||
| 缺失值 | 字段始终存在;未知值写 `null`,不省略字段 | `"last_seen_at": null` |
|
||||
| 布尔值 | 使用 `is_`/`has_` 前缀,或者用意义明确的形容词 | `online`、`enabled` |
|
||||
|
||||
## 5. 错误
|
||||
|
||||
所有 4xx/5xx 响应都使用 `application/problem+json`(RFC 9457),并附加扩展字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "about:blank",
|
||||
"title": "Validation failed",
|
||||
"status": 400,
|
||||
"code": "validation_failed",
|
||||
"detail": "interval_ms must be >= 200",
|
||||
"trace_id": "00-4bf92f...-01",
|
||||
"errors": { "interval_ms": ["must be >= 200"] }
|
||||
}
|
||||
```
|
||||
|
||||
- `code` 取自共享的 `ErrorCode` 枚举,是客户端做判断的**唯一**依据;`title`/`detail` 只供人阅读,内容可能变化。
|
||||
- `errors` 只在 400 `validation_failed` 中出现,键名是 JSON 字段路径。
|
||||
- 5xx 响应不能泄露异常信息或调用栈,只提供 `trace_id`,用于在日志中定位问题。
|
||||
|
||||
| 状态码 | 使用场景 |
|
||||
| --- | --- |
|
||||
| 400 | 请求格式错误或校验失败 |
|
||||
| 401 | 未认证 |
|
||||
| 404 | 资源不存在 |
|
||||
| 403 | 已认证但作用域不足 |
|
||||
| 409 | 名称冲突、状态冲突(例如 `probe_offline`) |
|
||||
| 410 | 资源曾经存在但已过保留期(例如 `raw_expired`) |
|
||||
| 412 | `If-Match` 不匹配 |
|
||||
| 413 | 请求体超过上限 |
|
||||
| 422 | 语义上无法满足(例如 `range_too_large`) |
|
||||
| 429 | 超过限流,需要带 `Retry-After` |
|
||||
| 503 | 存储不可用,或正在关闭 |
|
||||
|
||||
## 6. 集合与查询
|
||||
|
||||
- 分页由服务端驱动:`?limit=`(默认 50,最大 500)和 `?cursor=`(不透明字符串);响应格式为 `{ "items": [...], "next_cursor": string | null }`。排序必须稳定(按 ID 或时间 + ID)。
|
||||
- 时间范围参数统一使用 `from`/`to`,区间为左闭右开 `[from, to)`。缺省时 `to = now`,`from = to − 1h`。
|
||||
- 所有查询都有上限(点数、时间跨度、结果条数);超出时返回 422 并在 `detail` 中给出可行的替代方案,不能悄悄截断。
|
||||
|
||||
## 7. 并发控制
|
||||
|
||||
- 配置类资源(Agent、Probe、任务)在响应中返回 `ETag`,PATCH/DELETE 支持 `If-Match`,不匹配时返回 412。没有带 `If-Match` 的请求按“最后写入者获胜”处理。
|
||||
|
||||
## 8. 安全
|
||||
|
||||
- 只提供 HTTPS。认证方式有两种:
|
||||
- 浏览器使用 Cookie 会话:`HttpOnly; Secure; SameSite=Strict`;所有非 GET 请求都必须带 `X-CSRF-Token` 请求头;
|
||||
- 脚本使用 `Authorization: Bearer <api_token>`,作用域为 `read` 或 `admin`。
|
||||
- 凭据不能放在 URL 或查询参数中;SSE 依靠 Cookie 或同源令牌认证。
|
||||
- 登录接口需要限流(例如每个 IP 每分钟 10 次),失败时统一返回 401,不区分“用户不存在”和“密码错误”。
|
||||
- public 层根据站点设置和 `public_visible` 过滤数据:被隐藏的资源对匿名访客一律返回 404,而不是 403。
|
||||
- 令牌明文只在创建或轮换的响应中出现一次;这类响应带 `Cache-Control: no-store`。
|
||||
- 请求体上限为 64 KiB;查询接口有超时(默认 10 秒)。
|
||||
|
||||
## 9. 实时推送(SSE)
|
||||
|
||||
- 实时数据使用 `text/event-stream`。`event` 名称就是数据类型(`line`、`node`、`point`、`test`);`data` 是单行 JSON,遵守第 4 节的数据表示规则。
|
||||
- 每 15 秒发送一行注释作为心跳。重连后服务端先推送当前快照,再推送增量,不补发历史。
|
||||
- 订阅的主题数和连接数都有上限,超出时返回 429 `too_many_streams`。
|
||||
- 推送的内容必须能由对应的 REST 查询重建:SSE 只负责“更快地获得变化”,不承载独有的数据。
|
||||
|
||||
## 10. 新增端点检查清单
|
||||
|
||||
1. 符合第 3 节的路径与方法规则,并有 operationId、summary 和 tag;
|
||||
2. 返回类型精确(`TypedResults`),错误使用 problem+json,错误码来自 `ErrorCode`;
|
||||
3. DTO 符合第 4 节的数据表示规则,并注册到 JSON 源生成上下文;
|
||||
4. 列表支持分页,查询有上限;
|
||||
5. 选择正确的层(public / admin / agent),public 层只暴露可以公开的字段;
|
||||
6. 重新生成并提交 `openapi/v1.json`,在 PR 中说明是否属于破坏性变更;
|
||||
7. 测试覆盖:成功路径、校验失败、404、鉴权失败、隐藏资源;
|
||||
8. 同步更新 [api.md](api.md)。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend API 规范(v1)
|
||||
|
||||
本文列出 Backend 的全部对外接口。通用规则见 [api-guidelines.md](api-guidelines.md);OpenAPI 3.1 文档在运行时由 `/openapi/v1.json` 提供,构建时同时生成快照 `backend/openapi/v1.json`。两者冲突时,以 OpenAPI 为准。
|
||||
本文列出 Backend 的全部对外接口。通用规则见本文第 7 节「设计约束」;OpenAPI 3.1 文档在运行时由 `/openapi/v1.json` 提供,构建时同时生成快照 `backend/openapi/v1.json`。两者冲突时,以 OpenAPI 为准。
|
||||
|
||||
## 1. 分层
|
||||
|
||||
@@ -191,3 +191,145 @@
|
||||
| 422 | `range_too_large`、`invalid_step`、`unknown_metric`、`unknown_preset`、`too_many_metrics` |
|
||||
| 429 | `too_many_requests`、`too_many_streams` |
|
||||
| 503 | `storage_unavailable` |
|
||||
|
||||
## 7. 设计约束
|
||||
|
||||
本文是所有 Backend REST 端点都必须遵守的**规则**;具体端点见上面第 1–6 节。规则参考了 [Microsoft Azure REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md) 和 RFC 9457,并根据本项目做了取舍。内部协议(Probe↔Agent、Agent↔Backend)的兼容规则见 [protocol.md](../design/protocol.md) 第 4 节。
|
||||
|
||||
### 7.1 契约生成链路
|
||||
|
||||
```text
|
||||
Minimal API 端点(TypedResults 返回类型 + 显式元数据)
|
||||
│ dotnet build(Microsoft.Extensions.ApiDescription.Server)
|
||||
▼
|
||||
backend/openapi/v1.json(OpenAPI 3.1,提交到仓库)
|
||||
│ CI:git diff --exit-code,未提交的变更会导致失败
|
||||
▼
|
||||
web:openapi-typescript 生成类型,调用方的类型错误会直接暴露
|
||||
```
|
||||
|
||||
- 端点**必须**声明精确的返回类型,例如 `Results<Ok<AgentDto>, NotFound<ProblemDetails>>`,不能返回 `IResult` 或匿名对象,否则 OpenAPI 文档会不准确。
|
||||
- 每个端点都必须设置 `WithName("agents_list")`(即 operationId,snake_case、`资源_动作`)、`WithSummary`、`WithTags`。
|
||||
- 请求和响应 DTO 放在 `MoShiro.Backend.Api/Contracts/`,并注册到 Backend 的 JSON 源生成上下文。**不要直接暴露 `MoShiro.Protocol` 中的线协议类型**,因为 REST 与线协议的演进节奏不同。
|
||||
- **结构强类型,指标弱类型**:资源结构(Agent、任务、测试等)必须是强类型 DTO;而**指标值和评分**统一使用字典 `metrics: { "<metric_id>": number }`、`scores: { "<model_id>": number }`,键的含义、单位、版本由 `/api/public/catalog/*` 给出。这样新增指标或模型不需要改 OpenAPI,也不需要改前端类型。
|
||||
|
||||
### 7.2 版本与兼容性
|
||||
|
||||
- 当前版本为 v1,路径前缀为 `/api/public`、`/api/admin`、`/api/agent/v1`。出现破坏性变更时,新增 `/api/v2/public`、`/api/v2/admin` 等路径。v1 内只允许**非破坏性变更**:
|
||||
|
||||
| 非破坏性变更(v1 内允许) | 破坏性变更(需要新建 v2) |
|
||||
| --- | --- |
|
||||
| 新增端点 | 删除或重命名端点、字段 |
|
||||
| 响应中新增字段 | 修改字段类型、单位或语义 |
|
||||
| 新增可选的请求字段或查询参数 | 可选字段改为必填 |
|
||||
| 枚举新增值 | 删除枚举值 |
|
||||
| 放宽校验 | 收紧校验、修改默认值 |
|
||||
| 新增错误码 | 修改已有错误码对应的 HTTP 状态 |
|
||||
|
||||
- 客户端必须忽略未知字段,也必须能处理未知的枚举值。
|
||||
- 需要 v2 时,v1 和 v2 至少并存一个发布周期;OpenAPI 文档按版本分文件(`v1.json`、`v2.json`)。
|
||||
|
||||
### 7.3 URL 与方法
|
||||
|
||||
- **按调用方分层**:`/api/public`(只读展示)、`/api/admin`(管理)、`/api/agent`(节点)。同一个资源在不同层中可以有不同的 DTO,例如 public 层不暴露 `pubkey`、`local_limits`。
|
||||
- 路径全部小写,资源名用复数名词,多个单词用 `-` 连接:`/api/admin/join-tokens`。
|
||||
- 嵌套最多一层;子资源一旦有全局唯一 ID,就提供顶层路径(`/probes/{probe_id}`)。
|
||||
- 查询参数和 JSON 字段使用 snake_case。
|
||||
|
||||
| 操作 | 方法 | 成功状态 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 列表 | GET 集合 | 200 | 分页 |
|
||||
| 读取 | GET 单个 | 200 | |
|
||||
| 创建 | POST 集合 | 201 + `Location` | 响应体返回完整资源 |
|
||||
| 部分更新 | PATCH | 200 | `application/merge-patch+json`(RFC 7396) |
|
||||
| 删除 | DELETE | 204 | 资源不存在时同样返回 204(幂等) |
|
||||
| 非 CRUD 动作 | POST `/{resource}/{id}/{verb}` | 200 或 204 | 例如 `revoke`、`rotate-token` |
|
||||
|
||||
- GET 请求不能产生副作用。PUT 不使用:配置类资源都通过 PATCH 修改。
|
||||
|
||||
### 7.4 数据表示
|
||||
|
||||
| 类别 | 规则 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 时间点 | RFC 3339 UTC,以 `Z` 结尾,精确到毫秒。**唯一例外**:列式时序响应中的时间轴数组 `t` 使用 Unix 毫秒(见 [query.md](../design/query.md)) | `2026-09-27T10:00:00.000Z` |
|
||||
| 时长 | 整数 + 单位后缀 | `interval_ms: 1000` |
|
||||
| 延迟 | 带小数的毫秒值(指标字典中的单位见目录) | `rtt_p95_ms: 58.93` |
|
||||
| 百分比 | 0–100 的数字 + `_pct` | `loss_pct: 0.5` |
|
||||
| 计数 | 整数,不使用浮点数 | `attempts: 600` |
|
||||
| ID | 带类型前缀的 ULID 字符串 | `prb_01J9Z...` |
|
||||
| 枚举 | snake_case 字符串 | `"udp_echo"` |
|
||||
| 缺失值 | 字段始终存在;未知值写 `null`,不省略字段 | `"last_seen_at": null` |
|
||||
| 布尔值 | 使用 `is_`/`has_` 前缀,或者用意义明确的形容词 | `online`、`enabled` |
|
||||
|
||||
### 7.5 错误
|
||||
|
||||
所有 4xx/5xx 响应都使用 `application/problem+json`(RFC 9457),并附加扩展字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "about:blank",
|
||||
"title": "Validation failed",
|
||||
"status": 400,
|
||||
"code": "validation_failed",
|
||||
"detail": "interval_ms must be >= 200",
|
||||
"trace_id": "00-4bf92f...-01",
|
||||
"errors": { "interval_ms": ["must be >= 200"] }
|
||||
}
|
||||
```
|
||||
|
||||
- `code` 取自共享的 `ErrorCode` 枚举,是客户端做判断的**唯一**依据;`title`/`detail` 只供人阅读,内容可能变化。
|
||||
- `errors` 只在 400 `validation_failed` 中出现,键名是 JSON 字段路径。
|
||||
- 5xx 响应不能泄露异常信息或调用栈,只提供 `trace_id`,用于在日志中定位问题。
|
||||
|
||||
| 状态码 | 使用场景 |
|
||||
| --- | --- |
|
||||
| 400 | 请求格式错误或校验失败 |
|
||||
| 401 | 未认证 |
|
||||
| 404 | 资源不存在 |
|
||||
| 403 | 已认证但作用域不足 |
|
||||
| 409 | 名称冲突、状态冲突(例如 `probe_offline`) |
|
||||
| 410 | 资源曾经存在但已过保留期(例如 `raw_expired`) |
|
||||
| 412 | `If-Match` 不匹配 |
|
||||
| 413 | 请求体超过上限 |
|
||||
| 422 | 语义上无法满足(例如 `range_too_large`) |
|
||||
| 429 | 超过限流,需要带 `Retry-After` |
|
||||
| 503 | 存储不可用,或正在关闭 |
|
||||
|
||||
### 7.6 集合与查询
|
||||
|
||||
- 分页由服务端驱动:`?limit=`(默认 50,最大 500)和 `?cursor=`(不透明字符串);响应格式为 `{ "items": [...], "next_cursor": string | null }`。排序必须稳定(按 ID 或时间 + ID)。
|
||||
- 时间范围参数统一使用 `from`/`to`,区间为左闭右开 `[from, to)`。缺省时 `to = now`,`from = to − 1h`。
|
||||
- 所有查询都有上限(点数、时间跨度、结果条数);超出时返回 422 并在 `detail` 中给出可行的替代方案,不能悄悄截断。
|
||||
|
||||
### 7.7 并发控制
|
||||
|
||||
- 配置类资源(Agent、Probe、任务)在响应中返回 `ETag`,PATCH/DELETE 支持 `If-Match`,不匹配时返回 412。没有带 `If-Match` 的请求按“最后写入者获胜”处理。
|
||||
|
||||
### 7.8 安全
|
||||
|
||||
- 只提供 HTTPS。认证方式有两种:
|
||||
- 浏览器使用 Cookie 会话:`HttpOnly; Secure; SameSite=Strict`;所有非 GET 请求都必须带 `X-CSRF-Token` 请求头;
|
||||
- 脚本使用 `Authorization: Bearer <api_token>`,作用域为 `read` 或 `admin`。
|
||||
- 凭据不能放在 URL 或查询参数中;SSE 依靠 Cookie 或同源令牌认证。
|
||||
- 登录接口需要限流(例如每个 IP 每分钟 10 次),失败时统一返回 401,不区分“用户不存在”和“密码错误”。
|
||||
- public 层根据站点设置和 `public_visible` 过滤数据:被隐藏的资源对匿名访客一律返回 404,而不是 403。
|
||||
- 令牌明文只在创建或轮换的响应中出现一次;这类响应带 `Cache-Control: no-store`。
|
||||
- 请求体上限为 64 KiB;查询接口有超时(默认 10 秒)。
|
||||
|
||||
### 7.9 实时推送(SSE)
|
||||
|
||||
- 实时数据使用 `text/event-stream`。`event` 名称就是数据类型(`line`、`node`、`point`、`test`);`data` 是单行 JSON,遵守第 7.4 节的数据表示规则。
|
||||
- 每 15 秒发送一行注释作为心跳。重连后服务端先推送当前快照,再推送增量,不补发历史。
|
||||
- 订阅的主题数和连接数都有上限,超出时返回 429 `too_many_streams`。
|
||||
- 推送的内容必须能由对应的 REST 查询重建:SSE 只负责“更快地获得变化”,不承载独有的数据。
|
||||
|
||||
### 7.10 新增端点检查清单
|
||||
|
||||
1. 符合第 7.3 节的路径与方法规则,并有 operationId、summary 和 tag;
|
||||
2. 返回类型精确(`TypedResults`),错误使用 problem+json,错误码来自 `ErrorCode`;
|
||||
3. DTO 符合第 7.4 节的数据表示规则,并注册到 JSON 源生成上下文;
|
||||
4. 列表支持分页,查询有上限;
|
||||
5. 选择正确的层(public / admin / agent),public 层只暴露可以公开的字段;
|
||||
6. 重新生成并提交 `openapi/v1.json`,在 PR 中说明是否属于破坏性变更;
|
||||
7. 测试覆盖:成功路径、校验失败、404、鉴权失败、隐藏资源;
|
||||
8. 同步更新本文上面的端点说明。
|
||||
@@ -60,7 +60,7 @@
|
||||
| --- | --- | --- |
|
||||
| 节点框架 | 注册、身份、连接、RPC、可靠上报、状态同步、宿主都由与业务无关的 **Fleet** 提供;MoShiro 只写业务,其他产品也能复用 | [../backend/fleet/README.md](../../backend/fleet/README.md) |
|
||||
| 注册与身份 | 使用 `fleet-enroll` 和加入令牌自动注册,之后用签名认证;Probe 用自己的身份直连面板,Agent 列表由面板下发 | [enrollment.md](../security/enrollment.md) |
|
||||
| 更新 | 只接受发布密钥签名的清单,更新策略由节点本地决定,失败自动回滚。容器节点(Agent、Probe)已实现:CI 对镜像 digest 签名,宿主机上的 `moshiro-update` 验签、按 digest 拉取、重启;纯二进制节点的更新是待办([TODO.md](../../TODO.md)) | [deployment.md](../ops/deployment.md) |
|
||||
| 更新 | 只接受发布密钥签名的清单,更新策略由节点本地决定,失败自动回滚。CI 对镜像 digest 签名;Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 验签、按 digest 拉取、失败回滚;Podman Quadlet 装的 Agent、Probe 更简单:节点有序退出,systemd 用 `--pull always` 重新拉最新镜像,节点自己不下载、不验证清单;纯二进制节点的更新是待办([TODO.md](../../TODO.md)) | [deployment.md](../ops/deployment.md) |
|
||||
| 任务模型 | `Task = {kind, target, params, schedule}`;持续采集、按需测试、目标任务统一建模;下发“期望任务集”,由节点自行调和 | [measurement.md](measurement.md) |
|
||||
| 采集扩展 | Kind 由积木组合声明;采集密度靠参数调整;原始块自描述 | [measurement.md](measurement.md) |
|
||||
| 统计扩展 | 指标注册制:少数几种累加器 + 统计算子;一行 `MetricDef` 就是一个新指标 | [analysis.md](analysis.md) |
|
||||
@@ -69,23 +69,23 @@
|
||||
| 展示查询 | 任意时间段(相对或绝对);自动步长(每条曲线最多约 720 个点);1s 内存热层(1 小时)加上 10s/1m/5m/1h 各层;累加器现场合并,汇总精确;列式响应;SSE 实时跟随 | [query.md](query.md) |
|
||||
| API | `/api/public`(只读,可设为公开)、`/api/admin`(登录)、`/api/agent`(注册与 RPC);实时数据用 SSE | [api.md](../api/api.md) |
|
||||
|
||||
### 4.0 容器节点的更新
|
||||
### 4.0 节点的更新
|
||||
|
||||
发布清单(镜像 digest)由 CI 签名,放进镜像仓库(`<仓库>-manifest` 小镜像的标签里);Backend 定时取、验签后登记(也接受 CI 直接 `POST /api/release/offer`),再原样转发给在线的 Agent / Probe。之后分两条路:
|
||||
|
||||
```text
|
||||
CI ──签名的发布清单(镜像 digest)──▶ 镜像仓库(<仓库>-manifest 小镜像的标签里)
|
||||
│ │
|
||||
Backend 定时取、验签后登记(也接受 CI 直接 POST /api/release/offer)
|
||||
▼ │
|
||||
Backend ──原样转发(offer)──▶ Agent / Probe 进程 │ 宿主机更新器也可以直接读
|
||||
│ 只写进数据目录的 update/ │
|
||||
▼ ▼
|
||||
宿主机 moshiro-update(root):用本机公钥验签 → 核对组件、仓库和 seq → podman pull 仓库@digest → 重启 → 不健康就回滚
|
||||
│ 状态写进只读的 update-status/
|
||||
▼
|
||||
节点随心跳上报更新状态 ──▶ Backend ──▶ 管理端(有新版本时点「更新」)
|
||||
Podman Quadlet 节点(Agent、Probe,推荐)
|
||||
节点比对清单和自己的版本 ──▶ 心跳报「有新版本」──▶ 管理端显示
|
||||
自动(默认,运行满 10 分钟)或管理员点「更新」──▶ 节点有序退出
|
||||
──▶ systemd 重启单元(Pull=always)──▶ 新镜像、新容器
|
||||
节点不下载、不替换任何东西,也不验证清单;最多被请求一次有序重启
|
||||
|
||||
Backend 和一行脚本装的节点
|
||||
宿主机 moshiro-update(root):用本机公钥验签 → 核对组件、仓库和 seq → podman pull 仓库@digest → 重启 → 不健康就回滚
|
||||
──▶ 状态写进只读的 update-status/ ──▶ 节点随心跳上报 ──▶ Backend ──▶ 管理端(有新版本时点「更新」)
|
||||
```
|
||||
|
||||
细节见 [deployment.md](../ops/deployment.md)「容器节点的更新」。
|
||||
细节见 [deployment.md](../ops/deployment.md)「Podman(Quadlet)节点的更新」和「宿主机更新器」。
|
||||
|
||||
### 4.1 回显的四个时间戳
|
||||
|
||||
@@ -155,7 +155,7 @@ Kind 目录编译在二进制中,节点不接受远程下发代码(安全边
|
||||
| D13 | Probe/Agent 运行时只依赖 BCL;.NET 10 NativeAOT | 轻量 |
|
||||
| D14 | 节点机制抽成与业务无关的 Fleet 框架,放在同一仓库的 `backend/fleet/` 目录,暂不独立(由用户确认;不设拆分时间表,需要时再评估;不使用 PublicApiAnalyzers) | 可复用;不引用任何 `MoShiro.*` 的约束由 `ArchitectureTests` 在构建时检查 |
|
||||
| D15 | 注册独立为 `fleet-enroll` 工具;常驻进程不包含注册代码 | 常驻进程去掉 HttpClient,边界更纯 |
|
||||
| D16 | 更新与业务进程分离:发布密钥签名、本地策略、健康确认与回滚。容器节点已实现(宿主机 `moshiro-update`,清单里是镜像 digest);纯二进制节点的更新见 [TODO.md](../../TODO.md) | 面板被攻破时也无法推送恶意二进制 |
|
||||
| D16 | 更新与业务进程分离:发布密钥签名、本地策略、健康确认与回滚。已实现:宿主机 `moshiro-update`(Backend、脚本装的节点,清单里是镜像 digest),Quadlet 节点则靠退出后由 systemd 重新拉镜像;纯二进制节点的更新见 [TODO.md](../../TODO.md) | 面板被攻破时也无法推送恶意二进制 |
|
||||
| D17 | Probe 支持 OpenWrt:正式支持 `linux-musl-x64`、`linux-musl-arm64`;armv7 视工具链验证结果决定;MIPS 不支持,改为在局域网设备上运行 Probe | 由用户确认;NativeAOT 不支持 MIPS |
|
||||
| D18 | 默认测量端口不使用 443:控制 7443/tcp,UDP 数据 7443/udp,TCP 数据 7444/tcp;443 只在 Agent 本地显式开启时才允许使用 | 由用户确认;避免与 VPS 上的真实服务冲突;结果会记录实际端口 |
|
||||
| D19 | 展示查询最细 1s:最近 1 小时来自内存热层,更早的数据来自 10s/1m/5m/1h 各层,现场合并;时间段内的汇总由累加器精确合并得出 | 由用户确认;5 分钟、15 分钟这类视图足够精细且快 |
|
||||
|
||||
@@ -123,7 +123,7 @@ id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节
|
||||
}
|
||||
```
|
||||
|
||||
- 时间轴 `t` 使用 Unix 毫秒,只出现一次;`values` 中每个数组的长度都与 `t` 相同,可以直接交给 uPlot 或 ECharts。**这是 [api-guidelines.md](../api/api-guidelines.md) 中“时间用 RFC 3339”规则的明确例外**,理由是减小体积、方便绘图;其余时间字段仍然使用 RFC 3339。
|
||||
- 时间轴 `t` 使用 Unix 毫秒,只出现一次;`values` 中每个数组的长度都与 `t` 相同,可以直接交给 uPlot 或 ECharts。**这是 [api.md](../api/api.md) 第 7.4 节“时间用 RFC 3339”规则的明确例外**,理由是减小体积、方便绘图;其余时间字段仍然使用 RFC 3339。
|
||||
- 某个桶没有数据时填 `null`(不填 0),前端据此绘制断线。
|
||||
- `summary` 是**整个时间段**的精确合并结果。`coverage` = 实际数据量 / 期望数据量,用来提示前端“数据是否完整”。
|
||||
- `gaps` 和 `events` 用于在曲线上做标注(断线、端点变化、时钟跳变等)。
|
||||
|
||||
@@ -108,4 +108,4 @@
|
||||
|
||||
- 提交信息使用 `<范围>: <摘要>` 格式,范围取值为 `probe`、`agent`、`backend`、`shared`、`web`、`docs`、`scripts`。
|
||||
- 修改 `backend/shared/MoShiro.Protocol` 的 PR 必须说明兼容性影响(新增字段、行为变化、需要升级版本)。
|
||||
- 修改 `backend/openapi/v1.json` 的 PR 必须按照 [api-guidelines.md](../api/api-guidelines.md) 第 2 节判断是否属于破坏性变更。
|
||||
- 修改 `backend/openapi/v1.json` 的 PR 必须按照 [api.md](../api/api.md) 第 7 节的「版本与兼容性」判断是否属于破坏性变更。
|
||||
@@ -49,7 +49,7 @@ moshiro/
|
||||
└─ MoShiro.Backend.slnx
|
||||
```
|
||||
|
||||
节点机器上的发布物是两个独立进程,注册和采集分开:`fleet-enroll`(一次性运行)和 `moshiro-agent`(或 `moshiro-probe`,常驻)。容器节点的更新由宿主机上的 `moshiro-update` 完成,见 [deployment.md](../ops/deployment.md)。
|
||||
节点机器上的发布物是两个独立进程,注册和采集分开:`fleet-enroll`(一次性运行)和 `moshiro-agent`(或 `moshiro-probe`,常驻)。更新:Quadlet 装的 Agent、Probe 由节点退出后 systemd 重新拉镜像,Backend 和一行脚本装的节点由宿主机上的 `moshiro-update` 完成,见 [deployment.md](../ops/deployment.md)。
|
||||
|
||||
## 3. 依赖关系
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ scripts/publish.sh <agent|probe|backend|fleet-enroll> <rid> → backend/art
|
||||
scripts/package.sh <agent|probe> <rid> → backend/artifacts/packages/moshiro-<role>-<version>-<rid>.tar.gz(附 .sha256)
|
||||
```
|
||||
|
||||
构建信息(程序里显示的 tag、提交、构建时间):`publish.sh` 会用 `git describe --tags --always --dirty`、`git rev-parse` 和当前时间自动设置环境变量 `MOSHIRO_TAG`、`MOSHIRO_COMMIT`、`MOSHIRO_BUILT_AT`,直接用 `dotnet publish` 或 CI 构建时自己设置它们(容器镜像的 `*.NativeBuild.Containerfile` 用同名的 `--build-arg`,CI 脚本会传);不设置就是空,本地构建照常可重复。版本号取自 `backend/Directory.Build.props` 的 `Version`。前端的信息由 `web/build-info.mjs` 在构建时写入(版本取自 `web/package.json`)。
|
||||
构建信息(程序里显示的 tag、提交、构建时间):`publish.sh` 会用 `git describe --tags --always --dirty`、`git rev-parse` 和当前时间自动设置环境变量 `MOSHIRO_TAG`、`MOSHIRO_COMMIT`、`MOSHIRO_BUILT_AT`,直接用 `dotnet publish` 或 CI 构建时自己设置它们(容器镜像的 `*.NativeBuild.Containerfile` 用同名的 `--build-arg`,CI 脚本会传);不设置就是空,本地构建照常可重复。版本号取自 `backend/Directory.Build.props` 的 `Version`;仓库里它只是占位(`0.0.0-dev`),CI 构建时由 `ci/version.sh` 写进工作区(见下文「版本号」)。前端的信息由 `web/build-info.mjs` 在构建时写入(版本取自 `web/package.json`,同样由 CI 填写)。Agent、Probe、Backend 启动时都会在日志里打印一行构建信息:版本、tag、提交、构建时间(没设置环境变量的本地构建显示 `unknown`)。
|
||||
|
||||
| rid | 目标 | 构建方式 |
|
||||
| --- | --- | --- |
|
||||
@@ -74,25 +74,44 @@ Woodpecker 在 `vMAJOR.MINOR.PATCH` tag 上,由 Windows runner 使用 AotAnywh
|
||||
|
||||
## CI 工作流
|
||||
|
||||
### Pull request 与 dev 验证
|
||||
### Pull request 与 main 验证
|
||||
|
||||
Pull request 和 `dev` push 会运行 Backend tests;通过后,独立的 `web-build` workflow 才会构建前端并检查 CSS。提交信息后缀 `[SKIP TESTS]` 只跳过 Backend tests,前端构建仍会运行。Gitea PR 事件的 `CI_COMMIT_MESSAGE` 是 PR 标题,因此在 PR 中跳过测试时,把后缀写入 PR 标题;普通分支 push 则写在 commit message 中。
|
||||
Pull request 和 `main` 的 push 会跑三个 workflow:
|
||||
|
||||
- `verify`:后端的快测试(除了标了 `Category=Slow` 的全部,约 20 秒);
|
||||
- `web-build`:前端构建和 CSS 检查,和 `verify` 并行(约 15 秒);
|
||||
- 标了 `Category=Slow` 的端到端测试(真实时间的 Hy2 阶梯、IPv6 和真探针的几条,一百来秒):带 `[CI]` 的推送,在 `dev-image` 里**等三个镜像都构建、推送完**再跑(不和 NativeAOT 编译抢内存——同时跑过一次,Backend 的 AOT 编译被内存不足杀掉了);PR 和没带 `[CI]` 的推送由 `e2e` workflow 在 `verify` 之后跑。失败照样红,但不影响已经推出去的镜像。
|
||||
|
||||
后端测试用增量编译(`ci/verify-backend.sh`):源码按内容同步到 CI 主机上一个固定目录,保留 `obj/bin`,只重编译变了的项目;同一时间只有一条流水线用这份缓存。失败的测试会单独重跑(最多两次),重跑通过算通过。给新的慢测试打标记:`[Trait("Category", "Slow")]`。
|
||||
|
||||
提交信息后缀 `[SKIP TESTS]` 跳过 `verify` 和 `e2e` 里的后端测试,前端构建仍会运行。Gitea PR 事件的 `CI_COMMIT_MESSAGE` 是 PR 标题,因此在 PR 中跳过测试时,把后缀写入 PR 标题;普通分支 push 则写在 commit message 中。项目只用 `main` 一个工作分支,旧的 `dev` 分支不再被任何流水线使用。
|
||||
|
||||
### 版本号
|
||||
|
||||
版本都由 CI 填写,仓库里写死的只是占位(`backend/Directory.Build.props` 的 `<Version>` 和三个 `web/**/package.json` 的 `version` 都是 `0.0.0-dev`),发版不需要提交 bump。规则在 `ci/version.sh`:
|
||||
|
||||
| 构建 | 版本 | 构建信息里的 tag |
|
||||
| --- | --- | --- |
|
||||
| 推 `vX.Y.Z` tag(正式版) | `X.Y.Z` | `vX.Y.Z` |
|
||||
| `main` 上带 `[CI]` 的 push(开发镜像) | `X.Y.(Z+1)-dev.<N>-<提交前 7 位>` | `v` + 同一个版本 |
|
||||
|
||||
开发版本的基准是最近一个 `v*` tag(`X.Y.Z`),`N` 是这个 tag 之后的提交数(`git rev-list --count`),所以同一个提交算出来永远一样,也按语义化版本排在下一个正式版之前,例如 `0.4.1-dev.12-044ce0c`。算版本要拿得到 tag,所以开发镜像的 workflow 克隆时带 `tags: true`。镜像标签不带 `v`(和正式版一致),构建信息和签名清单里的 `tag` 带 `v`。
|
||||
|
||||
### main 开发镜像
|
||||
|
||||
`main` 上只有提交信息包含 `[CI]` 的 push 才构建镜像。合并 PR 时,请把 `[CI]` 放入 PR 标题或合并提交标题。Backend、Agent、Probe 的 Linux/amd64 镜像会并行构建,并推送到同一个 Zot Registry 下独立的 `moshiro-dev/` 仓库:
|
||||
`main` 上只有提交信息包含 `[CI]` 的 push 才构建镜像,并且要等上面的 `verify`(快测试)和 `web-build` 都通过(提交信息写 `[SKIP TESTS]` 可以跳过测试);慢的端到端测试在镜像推完之后才跑,不拦着出镜像。合并 PR 时,请把 `[CI]` 放入 PR 标题或合并提交标题。Backend、Agent、Probe 的 Linux/amd64 镜像会并行构建,并推送到同一个 Zot Registry 下独立的 `moshiro-dev/` 仓库:
|
||||
|
||||
| 镜像 | 滚动标签 | 固定提交标签 |
|
||||
| 镜像 | 滚动标签 | 固定版本标签 |
|
||||
| --- | --- | --- |
|
||||
| Backend | `registry.oeo.one/moshiro-dev/moshiro-backend:latest` | `registry.oeo.one/moshiro-dev/moshiro-backend:dev-<12位SHA>` |
|
||||
| Agent | `registry.oeo.one/moshiro-dev/moshiro-agent:latest` | `registry.oeo.one/moshiro-dev/moshiro-agent:dev-<12位SHA>` |
|
||||
| Probe | `registry.oeo.one/moshiro-dev/moshiro-probe:latest` | `registry.oeo.one/moshiro-dev/moshiro-probe:dev-<12位SHA>` |
|
||||
| Backend | `registry.oeo.one/moshiro-dev/moshiro-backend:latest` | `registry.oeo.one/moshiro-dev/moshiro-backend:<版本>`(如 `0.4.1-dev.12-044ce0c`) |
|
||||
| Agent | `registry.oeo.one/moshiro-dev/moshiro-agent:latest` | `registry.oeo.one/moshiro-dev/moshiro-agent:<版本>` |
|
||||
| Probe | `registry.oeo.one/moshiro-dev/moshiro-probe:latest` | `registry.oeo.one/moshiro-dev/moshiro-probe:<版本>` |
|
||||
|
||||
Dev 镜像目前仅支持 amd64;Agent/Probe 在 `sdk:10.0-alpine-aot` 中构建 `linux-musl-x64` 产物,运行基底为 `runtime-deps:10.0-alpine`。它们不创建 Gitea Release,也不修改正式版 `moshiro/` 仓库中的标签。普通 main push 不会构建镜像。
|
||||
Dev 镜像目前仅支持 amd64;Agent/Probe 在 `sdk:10.0-alpine-aot` 中构建 `linux-musl-x64` 产物,运行基底为 `runtime-deps:10.0-alpine`。它们不创建 Gitea Release,也不修改正式版 `moshiro/` 仓库中的标签。普通 main push 不会构建镜像。节点和 Backend 判断自己是不是开发构建,看构建信息里的 tag 含 `-dev.`(旧的 `dev-<sha>`、`main-<sha>` 也认)。
|
||||
|
||||
### 正式版发布
|
||||
|
||||
推送匹配项目版本的 `vMAJOR.MINOR.PATCH` tag 会触发完整跨平台构建、正式 OCI 镜像推送和 Gitea Release。正式镜像使用 `registry.oeo.one/moshiro/` 路径;Agent、Probe 提供 amd64 与 arm64 多架构标签,稳定版本更新正式仓库自己的 `latest`。这与 `moshiro-dev/` 的 dev `latest` 相互独立。
|
||||
在 `main` 上推送 `vMAJOR.MINOR.PATCH` tag 会触发完整跨平台构建、正式 OCI 镜像推送和 Gitea Release,版本取自 tag,不需要先提交版本号。正式镜像使用 `registry.oeo.one/moshiro/` 路径;Agent、Probe 提供 amd64 与 arm64 多架构标签,稳定版本更新正式仓库自己的 `latest`。这与 `moshiro-dev/` 的 dev `latest` 相互独立。
|
||||
|
||||
Agent 和 Probe 镜像分别发布为 `registry.oeo.one/moshiro/moshiro-agent:<version>` 与 `registry.oeo.one/moshiro/moshiro-probe:<version>`,包含 amd64、arm64 多架构索引;也可指定 `:<version>-amd64` 或 `:<version>-arm64`。稳定版本同时更新 `latest`。镜像以非 root 用户(UID/GID 1654)运行,默认读取 `/etc/moshiro/agent.json` 或 `/etc/moshiro/probe.json`。使用 `packaging/compose/agent`、`probe` 下的标准 Compose 模板,配置和身份放在命名 volume 中;首次注册/导入、宿主指标挂载与更新器见下文。
|
||||
|
||||
@@ -156,68 +175,19 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2
|
||||
|
||||
实测常驻内存(RSS,回环,默认模板,稳定后):Linux 上 Probe 约 21 MB、Agent 约 23 MB;Windows 上约 22 MB、25 MB。
|
||||
|
||||
### 跨机器的真实链路(Windows Probe → WSL2 里的 Agent 和 Backend)
|
||||
### 故障注入
|
||||
|
||||
`scripts/smoke-wsl.py` 用 Windows 上的 NativeAOT Probe 连 WSL2 里的 Agent(真实的虚拟网络,两端的单调时钟原点相差约 38 小时),并用 `tc netem` 在 WSL 里注入故障。结果(Probe 的 RTT 基线约 0.4 ms):
|
||||
`scripts/smoke-wsl.py`:Windows 上的 NativeAOT Probe 连 WSL2 里的 Agent 和 Backend(真实的虚拟网络,两端的单调时钟原点不同),用 `tc netem` 注入单向丢包和延迟、8 秒黑洞、Agent / Probe 重启,检查指标落在预期的一侧:上行丢包只出现在 `loss.up`、下行只出现在 `loss.down`;黑洞期间 UDP 连续丢包、TCP 不丢包只在 RTT 里出现几秒的长尾;重启之后几秒内自动恢复。具体数字以脚本的输出为准,不记在这里。
|
||||
|
||||
| 注入 | 期望 | 实测 |
|
||||
| --- | --- | --- |
|
||||
| Agent→Probe 方向 10% 丢包、30 ms ± 5 ms 延迟(egress netem,只匹配 7443/7444) | 丢包在下行,下行抖动变大 | `loss.down` 8.2%、`loss.up` 0;`jitter.down.p95` 8.7 ms、`jitter.up.p95` 0.18 ms;RTT +31 ms;TCP:`tcp.rtt.p99` 1.2 s、`tcp.slow` 28.8%、`tcp.retrans` 5.9%(Agent 一侧内核计数) |
|
||||
| Probe→Agent 方向 10% 丢包、20 ms ± 3 ms 延迟(ingress + ifb) | 丢包在上行 | `loss.up` 9.4%、`loss.down` 0;`jitter.up.p95` 5.2 ms、`jitter.down.p95` 0 |
|
||||
| 8 秒黑洞(tc ingress drop 7443/7444) | UDP 连续丢包 ~80 个;TCP 不丢包,只有几秒的长尾 | UDP 最长连续丢包 80(10 Hz × 8 s);`tcp.rtt.max` 9.5 s、`tcp.stall_max` 9.6 s |
|
||||
| `hy2_ladder`(10/25/50/100 Mbit/s) | 无丢包 | `train.loss` 0.006%、`train.jitter.p95` 0.64 ms、可持续 100 Mbit/s |
|
||||
| 杀掉 Agent / 杀掉 Probe 再启动 | 自动恢复 | Agent 重启后 2.1 s 恢复在线并重新连上 Probe;Probe 重启后 0.5 s 重新连上 |
|
||||
这类验证发现过的几个问题,都已经修掉,改动时要留意它们为什么存在:Probe 重启后序号从 0 开始,Agent 不能沿用上一个会话的记录(否则真正的上行丢失会被算成下行丢失);TCP 回显的几秒中断必须记成延迟而不是丢包(TCP 的行要等回复,最长 30 秒才交出);`hy2_ladder` 的阶梯要把同一阶段的块先合并再算(真实的 Probe 会把一个阶段切成几块)。
|
||||
|
||||
验证中发现并修掉的两个问题:
|
||||
### 家宽机器上的 Backend
|
||||
|
||||
- **Probe 重启后,Agent 沿用了上一个会话的记录**:Probe 重启之后序号从 0 开始,Agent 里同一个序号的旧 t2/t3 还在,于是真正的上行丢失被算成了下行丢失(黑洞测试里 `loss.up` 0、`loss.down` 7.4%)。现在新的 Probe 会话开始时 Agent 清空所有流的记录。
|
||||
- **TCP 回显的几秒中断被记成丢包**:Probe 像 UDP 那样等 2 秒就把没有回复的行交出去,TCP 随后才到的回复没有行可以回填,最严重的长尾反而在 RTT 里看不到(黑洞期间 `tcp.rtt.max` 只有 4 ms)。现在 TCP 的行等回复,30 秒仍然没有才当作连接已断(t4 缺失)。
|
||||
|
||||
没有覆盖的:Windows 上的 Agent(需要给 Windows 防火墙加入站规则)、NAT 重绑定。跨公网的线路见下一节。
|
||||
|
||||
### 跨公网的真实线路(广东移动家宽 → 日本 IIJ 的 VPS)
|
||||
|
||||
2026-10-03 在一条真实线路上试跑过:家宽机器(Debian 13,用户态运行,没有公网 IPv4 入站)跑 Probe 和 Backend,VPS(Debian 12,1 核 470 MB,上面本来就跑着 xray、hysteria2、caddy 等服务)跑 Agent。
|
||||
|
||||
```text
|
||||
浏览器 / 本机前端 ──► https://<公网域名> ──隧道(Cloudflare Tunnel 之类)──► 家宽机器: Backend (:29181)
|
||||
家宽机器: Probe ──WSS 到 https://<公网域名>──► Backend(身份、Agent 列表)
|
||||
家宽机器: Probe ──TCP/UDP 7443、TCP 7444──► VPS: Agent
|
||||
VPS: Agent ──WSS 到 https://<公网域名>──► Backend
|
||||
```
|
||||
|
||||
做法和要点:
|
||||
|
||||
- **Backend 通过隧道暴露**:隧道把一个公网域名转到家宽机器的 `29181`;Backend 监听 `http://0.0.0.0:29181`(只写一个监听地址,同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用),家宽路由器的防火墙不放行这个端口,公网只能经隧道进来。Agent 用 `fleet-enroll --server https://<公网域名>` 注册,之后用 WSS 连回 Backend。
|
||||
- **对外地址自动识别**:Agent 在 hello 里上报本机的公网地址(IPv4 公网地址、IPv6 全球单播地址,排除私网、CGNAT、临时地址),面板自动填 `public_host` 和 `ipv6_host`,不需要手填。经隧道注册时来源地址是隧道出口,面板不会用它。只有 Agent 在 NAT 后面、本机看不到公网地址时才需要在管理端手填;手填的优先,勾选“自动识别”可以撤销。
|
||||
- **Probe 直连面板**:`probe.json` 里的 `fleet.server` 就是面板地址,Probe 用它连面板,Agent 列表由面板下发;密钥由 Probe 注册时自己生成,身份目录(`node.json`、`node.key`、`tls.pfx`)在 `fleet.identity_dir`。
|
||||
- **小 VPS 把 `limits.max_total_mbps` 调低**(试跑时用 100);Agent 常驻约 23 MB,VPS 上原有的服务不受影响,结束后删除 `/opt/…`、停掉进程即可,没有留下任何东西。
|
||||
- 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。
|
||||
- 本地电脑没有直连 VPS 的路径时,用家宽机器当跳板(`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=force`,密码放环境变量,不写进文件)。
|
||||
|
||||
**常驻(开机自启)**:
|
||||
|
||||
- 家宽机器(没有 root 时):`~/.config/systemd/user/moshiro-backend.service` 和 `moshiro-probe.service`,`EnvironmentFile` 指向放配置的 `backend.env`,`Restart=always`,`systemctl --user enable --now …`,再用 `sudo loginctl enable-linger <用户>` 让它们不登录也能起来。Probe 的命令行是 `moshiro-probe --config <probe.json 路径>`(默认路径是 `/etc/moshiro/probe.json`)。
|
||||
- VPS:`/etc/systemd/system/moshiro-agent.service`,`Restart=always`,加 `NoNewPrivileges`、`ProtectSystem=strict`、`ReadWritePaths=/opt/moshiro`、`ProtectHome`、`PrivateTmp`。这是不走安装脚本的手动方式;正式安装用上面的 `packaging/systemd`。
|
||||
- 日志进 journal:`journalctl -u moshiro-agent`、`journalctl --user -u moshiro-probe`。重启 Backend 用 `systemctl` 而不是手动再起一个进程,否则会端口冲突。
|
||||
没有公网入站的家宽机器上跑 Backend 时,用隧道(Cloudflare Tunnel 之类)把一个公网域名转到 Backend 的端口;Backend 只写一个监听地址(同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用);Probe 的 `fleet.server` 就是这个面板地址。小 VPS 上把 `limits.max_total_mbps` 调低。家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,用 IPv6 直连时地址要跟着换;走隧道的域名不受影响。
|
||||
|
||||
**国家和运营商**:Backend 用 Probe、Agent 的来源地址向 IP 归属服务查一次,展示在国旗和运营商图标上(`MoShiro__GeoLookupUrl`,缺省 `https://ip.007995.xyz`;留空就不查;这个服务只供公益使用,署名和使用条款由部署的人自己遵守)。Probe、Agent 自己不访问外部服务,所以客户的防火墙审计里不会多出出站连接;Backend 发出去的只有公网 IP(内网地址不发)。
|
||||
|
||||
**从别处看这个面板**:本机跑前端,在页面里把数据源指向这个域名,用 API 令牌访问,Backend 设 `MoShiro__CorsOrigins` 放行前端的来源(见 [web/README.md](../../web/README.md))。
|
||||
|
||||
实测(Probe 在广东移动家宽,Agent 在日本 IIJ):
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | --- |
|
||||
| 延迟 p50 / p95 / p99 | 107 / 107 / 114 ms |
|
||||
| 丢包 | 上行 0%,下行 8.1%(`hy2_ladder` 在 10、25、50、100 Mbit/s 四档下的丢包都是 8~10%,与速率无关:随机丢包,不是限速) |
|
||||
| 抖动 p95(上 / 下) | 0.9 / 5.2 ms |
|
||||
| TCP | 重传 1.9%(批量传输时 5.8%),RTT 最大 412 ms |
|
||||
| `reality_session` | 建连成功率 100%,TCP 建连 p95 115 ms,TLS 握手 p95 117 ms,30 秒长连接无重置;4 秒批量传输峰值 133 Mbit/s、中位数 90 Mbit/s(Agent 上限 100) |
|
||||
| 评分 | REALITY 82(含按需测试)、Hy2 42 |
|
||||
| Agent 主机状态 | CPU 1.5%,内存 274 / 493 MB,已运行约 20.5 天 |
|
||||
|
||||
这次试跑发现并修掉的问题:`hy2_ladder` 的阶梯按「块」而不是按「阶段」计算,真实的 Probe 把一个阶段切成几块,4 个阶段出了 7 步(模拟数据不切块,所以之前的测试没有发现);现在同一阶段的块先合并。
|
||||
**从别处看这个面板**:本机跑前端,在页面里把数据源指向面板的地址,用 API 令牌访问,Backend 设 `MoShiro__CorsOrigins` 放行前端的来源(见 [web/README.md](../../web/README.md))。
|
||||
|
||||
## 容器部署(Podman Quadlet 优先,Docker 用 compose)
|
||||
|
||||
@@ -261,7 +231,7 @@ chmod 600 .env && curl -fsSL 'https://panel.example.com/install/compose/probe.ya
|
||||
- 只读根文件系统、丢掉全部能力、`no-new-privileges`、命名卷;Linux 上用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址,并只读挂载 `/proc`、`/sys`、`/` 读主机指标)。**Agent 的 compose 只适用于 Linux**;Probe 在 macOS 和 Windows(Docker Desktop)上没有宿主网络,面板给的命令会在 `.env` 里写 `MOSHIRO_NETWORK_MODE=bridge`。
|
||||
- 身份和配置在命名卷 `moshiro-<role>-config` 里:镜像入口脚本(`packaging/container/entrypoint.sh`)第一次启动用 `MOSHIRO_SERVER`、`MOSHIRO_JOIN`(和信任根指纹、属主)注册并写配置,之后重启、换新镜像重建容器都不需要再给参数,所以注册成功之后可以把 `.env` 里的 `MOSHIRO_JOIN` 删掉。缺了信任根指纹或属主就不注册(只有还没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`)。
|
||||
- Podman 的开机自启要靠 `podman-restart.service`(`systemctl enable --now podman-restart`;rootless 用 `--user`,并 `loginctl enable-linger`);Docker 用 compose 里的 `restart: unless-stopped`。
|
||||
- **更新**:`podman compose pull && podman compose up -d`(Docker 用 `docker compose`),只重建镜像变了的容器,身份和配置不动,节点几十秒内重连。想固定版本,在 `.env` 里写 `MOSHIRO_VERSION=0.3.3`;想要更严,写成 `MOSHIRO_VERSION=0.3.3@sha256:<digest>`,digest 取自面板信任页或签名清单(那里有发布签名)。回滚就是改回旧版本再 `up -d`。
|
||||
- **更新**:`podman compose pull && podman compose up -d`(Docker 用 `docker compose`),只重建镜像变了的容器,身份和配置不动,节点几十秒内重连。想固定版本,在 `.env` 里写 `MOSHIRO_VERSION=<版本号>`;想要更严,写成 `MOSHIRO_VERSION=<版本号>@sha256:<digest>`,digest 取自面板信任页或签名清单(那里有发布签名)。回滚就是改回旧版本再 `up -d`。
|
||||
- **信任**:compose 来自面板或 CI 渠道,面板被攻破也改不了镜像地址;但客户端不再验发布签名,拉取靠 TLS 加「注册表可信」这个假设(文档里本来就把 CI、Gitea、注册表放在可信范围内)。compose 不会自动更新(见下节),也没有管理端的「更新」列和确认更新按钮。
|
||||
|
||||
**一行安装脚本(备用,Docker 或 Podman,Agent 和 Probe 共用)**:管理端的弹窗里折叠着一条旧的一行命令,先核对脚本哈希再执行,脚本向面板取签名清单、用内置发布公钥验签、按 digest 拉镜像:
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| Probe | Fleet 节点身份(连面板)+ 另一把独立的密钥(连 Agent,放在 `tls.pfx`) | `fleet-enroll`,和 Agent 一样用加入令牌(`role=probe`);可以有多个 Probe,每个独立吊销 | 两把公钥 |
|
||||
| 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 |
|
||||
| API 令牌 | 随机高熵令牌,带一组能力(见 [access.md](access.md)) | 面板 | SHA-256 |
|
||||
| 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在节点宿主机的更新器里配置公钥(容器节点是 `moshiro-update`,见 [deployment.md](../ops/deployment.md);纯二进制节点的更新是待办),**面板不保存私钥**;登记清单时面板用公钥验签,节点宿主机的更新器之后再验一次 |
|
||||
| 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在宿主机的更新器里配置公钥(Backend 和脚本装的节点用 `moshiro-update`,见 [deployment.md](../ops/deployment.md);Quadlet 节点不验证清单,只是退出后重新拉镜像;纯二进制节点的更新是待办),**面板不保存私钥**;登记清单时面板用公钥验签,宿主机的更新器之后再验一次 |
|
||||
|
||||
## 2. Agent 接入流程
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# 把面板给的 .env 内容存成 .env,再下载本文件为 compose.yaml
|
||||
# podman compose up -d # Docker 用 docker compose
|
||||
# 更新:podman compose pull && podman compose up -d (身份和配置在命名卷里,不会动)
|
||||
# 固定版本:在 .env 里写 MOSHIRO_VERSION=0.3.3;想要更严,写成 MOSHIRO_VERSION=0.3.3@sha256:<digest>。
|
||||
# 固定版本:在 .env 里写 MOSHIRO_VERSION=<版本号>;想要更严,写成 MOSHIRO_VERSION=<版本号>@sha256:<digest>。
|
||||
# .env 里的 MOSHIRO_SERVER、MOSHIRO_JOIN(只在第一次启动用来注册,之后可以删掉)、MOSHIRO_GENESIS、MOSHIRO_CONTROLLERS、MOSHIRO_NAME 原样传进容器。
|
||||
# 说明见 docs/ops/deployment.md。
|
||||
services:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# 把面板给的 .env 内容存成 .env,再下载本文件为 compose.yaml
|
||||
# podman compose up -d # Docker 用 docker compose
|
||||
# 更新:podman compose pull && podman compose up -d (身份和配置在命名卷里,不会动)
|
||||
# 固定版本:在 .env 里写 MOSHIRO_VERSION=0.3.3;想要更严,写成 MOSHIRO_VERSION=0.3.3@sha256:<digest>。
|
||||
# 固定版本:在 .env 里写 MOSHIRO_VERSION=<版本号>;想要更严,写成 MOSHIRO_VERSION=<版本号>@sha256:<digest>。
|
||||
# macOS、Windows 的 Docker Desktop 没有宿主网络:面板给的 .env 里已经写了 MOSHIRO_NETWORK_MODE=bridge。
|
||||
# .env 里的 MOSHIRO_SERVER、MOSHIRO_JOIN(只在第一次启动用来注册,之后可以删掉)、MOSHIRO_GENESIS、MOSHIRO_CONTROLLERS、MOSHIRO_NAME 原样传进容器。
|
||||
# 说明见 docs/ops/deployment.md。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# loginctl enable-linger $USER # 退出登录后继续运行(需要管理员执行一次)
|
||||
# rootful:本文件放进 /etc/containers/systemd/,.env 内容存成 /etc/moshiro/agent.env,把下面的 EnvironmentFile 改成这个路径、
|
||||
# WantedBy 改成 multi-user.target,用 systemctl(不加 --user)。
|
||||
# 开发通道:把 Image 的 moshiro 换成 moshiro-dev。固定版本:把 :latest 换成 :0.3.3(固定之后更新不会换版本)。
|
||||
# 开发通道:把 Image 的 moshiro 换成 moshiro-dev。固定版本:把 :latest 换成 :<版本号>(固定之后更新不会换版本)。
|
||||
# 想要完全自动、不用在管理端点:另外启用 podman-auto-update.timer,并在 [Container] 里加 AutoUpdate=registry(Pull=always 也保留)。
|
||||
# 说明见 docs/ops/deployment.md。
|
||||
[Unit]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# loginctl enable-linger $USER # 退出登录后继续运行(需要管理员执行一次)
|
||||
# rootful:本文件放进 /etc/containers/systemd/,.env 内容存成 /etc/moshiro/probe.env,把下面的 EnvironmentFile 改成这个路径、
|
||||
# WantedBy 改成 multi-user.target,用 systemctl(不加 --user)。
|
||||
# 开发通道:把 Image 的 moshiro 换成 moshiro-dev。固定版本:把 :latest 换成 :0.3.3(固定之后更新不会换版本)。
|
||||
# 开发通道:把 Image 的 moshiro 换成 moshiro-dev。固定版本:把 :latest 换成 :<版本号>(固定之后更新不会换版本)。
|
||||
# 想要完全自动、不用在管理端点:另外启用 podman-auto-update.timer,并在 [Container] 里加 AutoUpdate=registry(Pull=always 也保留)。
|
||||
# 说明见 docs/ops/deployment.md。
|
||||
[Unit]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "moshiro-admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0-dev",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "moshiro-display",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0-dev",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { APPEARANCE_DEFAULTS, APPEARANCE_RANGES, resetAppearance, setAppearance, useAppearance, type AppearanceSettings } from './appearance'
|
||||
import { PHOTO_GROUPS, PHOTOS } from './backgrounds'
|
||||
|
||||
type Tab = 'weather' | 'background'
|
||||
|
||||
@@ -8,6 +9,8 @@ type SliderName = keyof typeof APPEARANCE_RANGES
|
||||
interface Slider {
|
||||
name: SliderName
|
||||
label: string
|
||||
/** 悬停提示。 */
|
||||
hint?: string
|
||||
/** 数值怎么显示:默认是百分比。 */
|
||||
format?: (value: number) => string
|
||||
}
|
||||
@@ -29,17 +32,26 @@ const WEATHER_SWITCHES: { name: 'mouse' | 'bullet'; label: string; hint: string
|
||||
]
|
||||
|
||||
const BACKGROUND_SLIDERS: Slider[] = [
|
||||
{ name: 'bgBlur', label: '模糊', format: (value) => `${value}px` },
|
||||
{ name: 'bgBlur', label: '再模糊', format: (value) => `${value}px` },
|
||||
{ name: 'parallax', label: '晃动' },
|
||||
{ name: 'bgVeil', label: '遮罩', hint: '盖在背景图上的那层灰色(暗色)/ 白色(亮色),0% 就是不盖' },
|
||||
]
|
||||
|
||||
const WEATHER_KEYS: (keyof AppearanceSettings)[] = ['enabled', 'density', 'speed', 'wind', 'size', 'blur', 'opacity', 'bullet', 'mouse']
|
||||
const BACKGROUND_KEYS: (keyof AppearanceSettings)[] = ['bgBlur', 'parallax']
|
||||
type Quality = 'blur' | 'sharp' | 'original'
|
||||
|
||||
const QUALITY_OPTIONS: { value: Quality; label: string; hint: string }[] = [
|
||||
{ value: 'blur', label: '模糊', hint: '预先模糊过的图,最小(约 200–400KB)' },
|
||||
{ value: 'sharp', label: '清晰', hint: '不模糊,压缩过的图(约 200–400KB)' },
|
||||
{ value: 'original', label: '原图', hint: '不模糊,没压缩的原图(每张约 0.3–2MB,PNG 的更大)' },
|
||||
]
|
||||
|
||||
const BACKGROUND_KEYS: (keyof AppearanceSettings)[] = ['photo', 'bgBlurred', 'bgBlur', 'bgOriginal', 'sway', 'parallax', 'bgVeil']
|
||||
|
||||
function SliderRow({ slider, settings, disabled }: { slider: Slider; settings: AppearanceSettings; disabled?: boolean }) {
|
||||
const { name, label, format = percent } = slider
|
||||
const { name, label, hint, format = percent } = slider
|
||||
return (
|
||||
<label className="appearance-row">
|
||||
<label className="appearance-row" title={hint}>
|
||||
<span>{label}</span>
|
||||
<input type="range" {...APPEARANCE_RANGES[name]} value={settings[name]} disabled={disabled} onChange={(event) => setAppearance({ [name]: Number(event.target.value) })} />
|
||||
<output>{format(settings[name])}</output>
|
||||
@@ -85,6 +97,9 @@ export function AppearancePanel() {
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// 画质:模糊 / 清晰(不模糊,压缩)/ 原图(不模糊,没压缩)
|
||||
const quality: Quality = settings.bgBlurred ? 'blur' : settings.bgOriginal ? 'original' : 'sharp'
|
||||
const setQuality = (value: Quality) => setAppearance({ bgBlurred: value === 'blur', bgOriginal: value === 'original' })
|
||||
const keys = tab === 'weather' ? WEATHER_KEYS : BACKGROUND_KEYS
|
||||
const changed = keys.some((name) => settings[name] !== APPEARANCE_DEFAULTS[name])
|
||||
|
||||
@@ -111,9 +126,33 @@ export function AppearancePanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="appearance-body" key="background">
|
||||
{BACKGROUND_SLIDERS.map((slider) => (
|
||||
<SliderRow key={slider.name} slider={slider} settings={settings} />
|
||||
{PHOTO_GROUPS.map((group) => (
|
||||
<div key={group} role="group" aria-label={group}>
|
||||
<div className="appearance-group-title">{group}</div>
|
||||
<div className="appearance-choices">
|
||||
{PHOTOS.filter((photo) => photo.group === group).map(({ id, label, light }) => (
|
||||
<button key={id} type="button" className="appearance-choice" aria-pressed={settings.photo === id} onClick={() => setAppearance({ photo: id })}>
|
||||
<img src={light.blur} alt="" loading="lazy" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="appearance-row appearance-row-segment" role="group" aria-label="背景画质">
|
||||
<span>画质</span>
|
||||
<div className="appearance-segment">
|
||||
{QUALITY_OPTIONS.map(({ value, label, hint }) => (
|
||||
<button key={value} type="button" title={hint} aria-pressed={quality === value} onClick={() => setQuality(value)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SliderRow slider={BACKGROUND_SLIDERS[0]} settings={settings} disabled={quality !== 'blur'} />
|
||||
<SwitchRow label="背景晃动" hint="关掉就不跟着鼠标晃" checked={settings.sway} onChange={(sway) => setAppearance({ sway })} />
|
||||
<SliderRow slider={BACKGROUND_SLIDERS[1]} settings={settings} disabled={!settings.sway} />
|
||||
<SliderRow slider={BACKGROUND_SLIDERS[2]} settings={settings} />
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="appearance-reset" disabled={!changed} tabIndex={open ? 0 : -1} onClick={() => resetAppearance(keys)}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { backgroundUrl, DEFAULT_PHOTO, PHOTOS } from './backgrounds'
|
||||
|
||||
/**
|
||||
* 展示页右下角「外观」里能调的参数:都是相对默认值的倍数(1 = 原样),存在浏览器里。
|
||||
@@ -23,10 +24,20 @@ export interface AppearanceSettings {
|
||||
bullet: boolean
|
||||
/** 鼠标拨动雪。 */
|
||||
mouse: boolean
|
||||
/** 背景在预先模糊的基础上再模糊多少(像素,0 是不再模糊)。 */
|
||||
/** 用哪张照片(id,见 backgrounds.ts)。 */
|
||||
photo: string
|
||||
/** 背景模糊:开是随主题的两张预先模糊过的图,关是不模糊的图(两张,也是随主题)。 */
|
||||
bgBlurred: boolean
|
||||
/** 背景模糊关着的时候:开是加载没压缩的原图(PNG,近 2MB 一张),关是压缩过的(JPG,不到 300KB)。 */
|
||||
bgOriginal: boolean
|
||||
/** 背景模糊开着的时候,在预先模糊的基础上再模糊多少(像素,0 是不再模糊)。 */
|
||||
bgBlur: number
|
||||
/** 背景跟着鼠标晃动的幅度(0 是不动,1 是默认)。 */
|
||||
/** 背景跟着鼠标晃动:总开关,关了背景就完全不动。 */
|
||||
sway: boolean
|
||||
/** 背景跟着鼠标晃动的幅度(1 是默认)。 */
|
||||
parallax: number
|
||||
/** 盖在背景图上的那层遮罩(亮色是白、暗色是灰)的浓度:0 是不盖,1 是默认(亮色 50%、暗色 62%)。 */
|
||||
bgVeil: number
|
||||
}
|
||||
|
||||
export const APPEARANCE_DEFAULTS: AppearanceSettings = {
|
||||
@@ -39,8 +50,13 @@ export const APPEARANCE_DEFAULTS: AppearanceSettings = {
|
||||
opacity: 1,
|
||||
bullet: true,
|
||||
mouse: true,
|
||||
photo: DEFAULT_PHOTO,
|
||||
bgBlurred: true,
|
||||
bgOriginal: false,
|
||||
bgBlur: 0,
|
||||
sway: true,
|
||||
parallax: 1,
|
||||
bgVeil: 1,
|
||||
}
|
||||
|
||||
/** 每个滑块的范围(和 AppearanceSettings 里同名的数值项对应)。 */
|
||||
@@ -53,6 +69,7 @@ export const APPEARANCE_RANGES = {
|
||||
opacity: { min: 0.2, max: 2.5, step: 0.05 },
|
||||
bgBlur: { min: 0, max: 30, step: 1 },
|
||||
parallax: { min: 0, max: 1.5, step: 0.05 },
|
||||
bgVeil: { min: 0, max: 1.6, step: 0.05 },
|
||||
} as const
|
||||
|
||||
const KEY = 'moshiro.appearance'
|
||||
@@ -64,7 +81,10 @@ function load(): AppearanceSettings {
|
||||
const merged: Record<string, unknown> = { ...APPEARANCE_DEFAULTS }
|
||||
for (const name of Object.keys(APPEARANCE_DEFAULTS) as (keyof AppearanceSettings)[]) {
|
||||
const value = saved[name]
|
||||
if (typeof value === typeof APPEARANCE_DEFAULTS[name]) merged[name] = value
|
||||
if (typeof value !== typeof APPEARANCE_DEFAULTS[name]) continue
|
||||
// 照片只认列表里有的,图删了、改名了就回到默认
|
||||
if (name === 'photo' && !PHOTOS.some((photo) => photo.id === value)) continue
|
||||
merged[name] = value
|
||||
}
|
||||
return merged as unknown as AppearanceSettings
|
||||
} catch {
|
||||
@@ -96,14 +116,28 @@ export function subscribeAppearance(listener: () => void): () => void {
|
||||
|
||||
export const useAppearance = () => useSyncExternalStore(subscribeAppearance, getAppearance)
|
||||
|
||||
/** 把背景的额外模糊写进 <html> 的 --bg-filter(display.css 里 .bg-layer 用它;不模糊的时候是 none,不给浏览器多一层滤镜)。 */
|
||||
/**
|
||||
* 把当前该用的背景图写进 <html> 的 --bg-image(照片、主题、背景模糊、不压缩一起决定用哪个版本;display.css 里 .bg-layer 用它),
|
||||
* 把遮罩的浓度写进 --bg-veil-scale,把背景的额外模糊写进 --bg-filter(不模糊的时候是 none,不给浏览器多一层滤镜),
|
||||
* 把晃动的开关写进 data-sway(关了就按窗口的原始比例铺满,display.css 里 data-sway='off')。切换主题时也重新算。
|
||||
*/
|
||||
export function useAppearanceVars() {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const apply = () => {
|
||||
const blur = getAppearance().bgBlur
|
||||
document.documentElement.style.setProperty('--bg-filter', blur > 0 ? `blur(${blur}px)` : 'none')
|
||||
const { bgBlur, bgBlurred, bgOriginal, photo, bgVeil, sway } = getAppearance()
|
||||
root.style.setProperty('--bg-veil-scale', String(bgVeil))
|
||||
root.style.setProperty('--bg-filter', bgBlurred && bgBlur > 0 ? `blur(${bgBlur}px)` : 'none')
|
||||
root.style.setProperty('--bg-image', `url("${backgroundUrl(photo, root.dataset.theme === 'dark', bgBlurred, bgOriginal)}")`)
|
||||
root.dataset.sway = sway ? 'on' : 'off'
|
||||
}
|
||||
apply()
|
||||
return subscribeAppearance(apply)
|
||||
const observer = new MutationObserver(apply)
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
const unsubscribe = subscribeAppearance(apply)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 369 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 314 KiB |
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import bgLight from './assets/bg.jpg'
|
||||
import bgDark from './assets/bg-dark.jpg'
|
||||
import { getAppearance, subscribeAppearance } from './appearance'
|
||||
import { backgroundUrl } from './backgrounds'
|
||||
|
||||
/** 取样的缩略图大小:背景本来就是预先模糊过的,缩得很小就够算平均色。 */
|
||||
const SAMPLE_WIDTH = 32
|
||||
@@ -46,8 +46,8 @@ function toHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 暗色下玻璃的颜色,和盖在背景图上的底色,都从当前背景图自己的平均色算出来:
|
||||
* 色相照搬图里的,饱和度打个折(免得发艳、发蓝),亮度压到很暗。写进 <html> 的 --bg-tint(玻璃)和 --bg-veil-tint(底色),
|
||||
* 暗色下玻璃的颜色,和盖在背景图上的底色,都从当前背景图(选了哪组照片、哪个主题)自己的平均色算出来:
|
||||
* 色相照搬图里的,饱和度保留大半,亮度跟着图本身的亮度走但压得很暗。写进 <html> 的 --bg-tint(玻璃)和 --bg-veil-tint(底色),
|
||||
* tokens.css 里暗色的 --glass-tint / --glass-veil 用它们;切换主题时重新取。亮色不用这个。
|
||||
*/
|
||||
export function useBackgroundTint() {
|
||||
@@ -58,12 +58,18 @@ export function useBackgroundTint() {
|
||||
const image = images.get(url)
|
||||
const average = image && averageColor(image)
|
||||
if (!average) return
|
||||
const [h, s] = toHsl(...average)
|
||||
root.style.setProperty('--bg-tint', `hsl(${h.toFixed(0)} ${(s * 55).toFixed(0)}% 13%)`)
|
||||
root.style.setProperty('--bg-veil-tint', `hsl(${h.toFixed(0)} ${(s * 45).toFixed(0)}% 8%)`)
|
||||
const [h, s, l] = toHsl(...average)
|
||||
// 玻璃:色相照搬,饱和度保留大半(上限 45%),亮度跟着图本身的亮度走(图越亮,玻璃越亮,10%–22%);
|
||||
// 底色(盖在背景图上的遮罩)同理,但更暗(6%–14%)。所以换一张照片,暗色玻璃的颜色和深浅都会跟着变
|
||||
const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(high, value))
|
||||
const glass = clamp(9 + l * 26, 10, 22)
|
||||
const veil = clamp(5 + l * 14, 6, 14)
|
||||
root.style.setProperty('--bg-tint', `hsl(${h.toFixed(0)} ${clamp(s * 90, 0, 45).toFixed(0)}% ${glass.toFixed(1)}%)`)
|
||||
root.style.setProperty('--bg-veil-tint', `hsl(${h.toFixed(0)} ${clamp(s * 75, 0, 38).toFixed(0)}% ${veil.toFixed(1)}%)`)
|
||||
}
|
||||
const update = () => {
|
||||
const url = root.dataset.theme === 'dark' ? bgDark : bgLight
|
||||
// 取样用预先模糊的那个版本:和不模糊的原图平均色一样,图小、取得快
|
||||
const url = backgroundUrl(getAppearance().photo, root.dataset.theme === 'dark', true, false)
|
||||
if (images.has(url)) {
|
||||
apply(url)
|
||||
return
|
||||
@@ -76,6 +82,10 @@ export function useBackgroundTint() {
|
||||
update()
|
||||
const observer = new MutationObserver(update)
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
return () => observer.disconnect()
|
||||
const unsubscribe = subscribeAppearance(update)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import bgBlurLight from './assets/bg.jpg'
|
||||
import bgBlurDark from './assets/bg-dark.jpg'
|
||||
import bgSharpLight from './assets/bg-sharp.jpg'
|
||||
import bgSharpDark from './assets/bg-sharp-dark.jpg'
|
||||
import bgOriginalLight from './assets/bg-sharp.png'
|
||||
import bgOriginalDark from './assets/bg-sharp-dark.png'
|
||||
import banaBlur from './assets/bg-bana.jpg'
|
||||
import banaSharp from './assets/bg-bana-sharp.jpg'
|
||||
import banaOriginal from './assets/bg-bana-sharp.png'
|
||||
import bana2Blur from './assets/bg-bana2.jpg'
|
||||
import bana2Sharp from './assets/bg-bana2-sharp.jpg'
|
||||
import bana2Original from './assets/bg-bana2-sharp.png'
|
||||
import photo86_3Blur from './assets/photo86-3-blur.jpg'
|
||||
import photo86_3 from './assets/photo86-3.jpg'
|
||||
import photo86_3Original from './assets/photo86-3-original.png'
|
||||
import photo86_7Blur from './assets/photo86-7-blur.jpg'
|
||||
import photo86_7 from './assets/photo86-7.jpg'
|
||||
import photo86_7Original from './assets/photo86-7-original.jpg'
|
||||
import photo86_2Blur from './assets/photo86-2-blur.jpg'
|
||||
import photo86_2 from './assets/photo86-2.jpg'
|
||||
import photo86_2Original from './assets/photo86-2-original.jpg'
|
||||
import photo86_8Blur from './assets/photo86-8-blur.jpg'
|
||||
import photo86_8 from './assets/photo86-8.jpg'
|
||||
import photo86_8Original from './assets/photo86-8-original.jpg'
|
||||
import photo86_5Blur from './assets/photo86-5-blur.jpg'
|
||||
import photo86_5 from './assets/photo86-5.jpg'
|
||||
import photo86_5Original from './assets/photo86-5-original.jpg'
|
||||
import photo86_6Blur from './assets/photo86-6-blur.jpg'
|
||||
import photo86_6 from './assets/photo86-6.jpg'
|
||||
import photo86_6Original from './assets/photo86-6-original.jpg'
|
||||
|
||||
/** 一张照片的三个版本:blur 是预先模糊过的,sharp 是不模糊但压缩过的,original 是没压缩的原图。 */
|
||||
export interface Variants {
|
||||
blur: string
|
||||
sharp: string
|
||||
original: string
|
||||
}
|
||||
|
||||
/** 可选的背景照片:group 是分组(面板里一组一行),亮色和暗色可以是不同的两张,也可以是同一张。 */
|
||||
export interface Photo {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
light: Variants
|
||||
dark: Variants
|
||||
}
|
||||
|
||||
const same = (variants: Variants) => ({ light: variants, dark: variants })
|
||||
|
||||
/** 加一张照片:把它的三个版本放进 assets/,在这里加一项。第一项是默认的。 */
|
||||
export const PHOTOS: Photo[] = [
|
||||
{
|
||||
id: 'sleep',
|
||||
group: 'shirobana',
|
||||
label: '睡颜',
|
||||
// 亮色睁眼、暗色闭眼
|
||||
light: { blur: bgBlurLight, sharp: bgSharpLight, original: bgOriginalLight },
|
||||
dark: { blur: bgBlurDark, sharp: bgSharpDark, original: bgOriginalDark },
|
||||
},
|
||||
{ id: 'bana', group: 'shirobana', label: '伸手', ...same({ blur: banaBlur, sharp: banaSharp, original: banaOriginal }) },
|
||||
{ id: 'bana2', group: 'shirobana', label: '海边', ...same({ blur: bana2Blur, sharp: bana2Sharp, original: bana2Original }) },
|
||||
{ id: '86-3', group: '86', label: '小憩', ...same({ blur: photo86_3Blur, sharp: photo86_3, original: photo86_3Original }) },
|
||||
{ id: '86-7', group: '86', label: '窗边', ...same({ blur: photo86_7Blur, sharp: photo86_7, original: photo86_7Original }) },
|
||||
{ id: '86-2', group: '86', label: '伞下', ...same({ blur: photo86_2Blur, sharp: photo86_2, original: photo86_2Original }) },
|
||||
{ id: '86-8', group: '86', label: '驾驶', ...same({ blur: photo86_8Blur, sharp: photo86_8, original: photo86_8Original }) },
|
||||
{ id: '86-5', group: '86', label: '怀中', ...same({ blur: photo86_5Blur, sharp: photo86_5, original: photo86_5Original }) },
|
||||
{ id: '86-6', group: '86', label: '倚靠', ...same({ blur: photo86_6Blur, sharp: photo86_6, original: photo86_6Original }) },
|
||||
]
|
||||
|
||||
export const DEFAULT_PHOTO = PHOTOS[0].id
|
||||
|
||||
/** 照片所在的分组,按出现的顺序。 */
|
||||
export const PHOTO_GROUPS = [...new Set(PHOTOS.map((photo) => photo.group))]
|
||||
|
||||
export const findPhoto = (id: string): Photo => PHOTOS.find((photo) => photo.id === id) ?? PHOTOS[0]
|
||||
|
||||
/** 当前该用哪张图:哪张照片、哪个主题,再按「背景模糊」「不压缩」选版本。 */
|
||||
export function backgroundUrl(id: string, dark: boolean, blurred: boolean, original: boolean): string {
|
||||
const variants = dark ? findPhoto(id).dark : findPhoto(id).light
|
||||
return blurred ? variants.blur : original ? variants.original : variants.sharp
|
||||
}
|
||||
@@ -1008,7 +1008,7 @@
|
||||
z-index: -1;
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent), color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent)),
|
||||
url('./assets/bg.jpg');
|
||||
var(--bg-image, url('./assets/bg.jpg'));
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
pointer-events: none;
|
||||
@@ -1017,18 +1017,24 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-veil: 50%;
|
||||
/* 盖在背景图上的遮罩的浓度:默认亮色 50%、暗色 62%;右下角「外观」里按倍数调(--bg-veil-scale,0 是不盖) */
|
||||
--bg-veil: min(100%, calc(50% * var(--bg-veil-scale, 1)));
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg-veil: 62%;
|
||||
--bg-veil: min(100%, calc(62% * var(--bg-veil-scale, 1)));
|
||||
}
|
||||
|
||||
/* 暗色模式换一张背景图 */
|
||||
/* 用哪张图由「外观」里的选择决定(backgrounds.ts,JS 把它写进 --bg-image);这里的回退只管 JS 还没跑的第一帧:暗色先用暗色那张 */
|
||||
:root[data-theme="dark"] .bg-layer {
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent), color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent)),
|
||||
url('./assets/bg-dark.jpg');
|
||||
var(--bg-image, url('./assets/bg-dark.jpg'));
|
||||
}
|
||||
|
||||
/* 关了背景晃动:背景不会再移动,就不用向外多留一圈边距了,按窗口的原始比例铺满(不会被放大裁掉四周) */
|
||||
:root[data-sway='off'] .bg-layer {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* ── 亚克力(毛玻璃)规范:所有玻璃面(卡片、线路面板、顶部栏、时间轴)只用下面这组变量;颜色来自 tokens.css 的 --glass-*,换风格改那里 ──
|
||||
@@ -1313,8 +1319,8 @@ input {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 悬浮提示弹在卡片右边,会盖到下一张卡片上:每张卡片都有自己的层叠上下文(毛玻璃),所以悬停(提示打开)的那张要整张抬到兄弟卡片之上 */
|
||||
.agent-card:hover,
|
||||
/* 悬浮提示弹在卡片右边,会盖到下一张卡片上:每张卡片都有自己的层叠上下文(毛玻璃),所以提示打开的那张要整张抬到兄弟卡片之上。
|
||||
只在提示真的打开时才抬,不要鼠标一碰到卡片就抬:z-index 一变,带 backdrop-filter 的整张卡片要重新分层,那一帧底部会闪黑 */
|
||||
.agent-card:has(.pop.open) {
|
||||
z-index: 30;
|
||||
}
|
||||
@@ -1393,8 +1399,7 @@ input {
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
/* 滤色:粒子只会把背后的图提亮,边缘羽化的地方自然融进图里 */
|
||||
mix-blend-mode: screen;
|
||||
/* 不要用 mix-blend-mode(滤色等):画布在带 backdrop-filter 的卡片后面每帧都在变,再叠一层混合,Chrome 给卡片取背景的时候会整块重新分层,卡片底部就闪黑;边缘的羽化用颜色和透明度做,已经是浅色半透明的,效果和滤色差不多 */
|
||||
}
|
||||
|
||||
@media (max-width: 640px), ((pointer: coarse) and (max-width: 900px)) {
|
||||
@@ -1624,6 +1629,38 @@ input {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
.appearance-row-segment {
|
||||
grid-template-columns: 52px 1fr;
|
||||
}
|
||||
|
||||
/* 三选一:连在一起的三个按钮,选中的那个是实心的 */
|
||||
.appearance-segment {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
padding: 2px;
|
||||
border-radius: var(--r-pill);
|
||||
background: color-mix(in srgb, var(--app-ink) 8%, transparent);
|
||||
}
|
||||
|
||||
.appearance-segment button {
|
||||
padding: 3px 0;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
color: var(--app-ink-muted);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.appearance-segment button[aria-pressed='true'] {
|
||||
color: var(--app-ink);
|
||||
font-weight: 600;
|
||||
background: var(--app-card);
|
||||
box-shadow: 0 1px 3px color-mix(in srgb, var(--glass-shade) 25%, transparent);
|
||||
}
|
||||
|
||||
.appearance-reset {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
@@ -1657,3 +1694,55 @@ input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 照片的选择:按组分行,每组一个小标题,下面是缩略图,选中的描一圈 */
|
||||
.appearance-group-title {
|
||||
margin-bottom: 4px;
|
||||
color: var(--app-ink-faint);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.appearance-choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.appearance-choice {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: var(--app-ink-muted);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.appearance-choice img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: var(--r-control);
|
||||
box-shadow: inset 0 0 0 1px var(--glass-ring);
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.appearance-choice:hover img {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.appearance-choice[aria-pressed='true'] {
|
||||
color: var(--app-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.appearance-choice[aria-pressed='true'] img {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--app-primary),
|
||||
0 0 0 4px color-mix(in srgb, var(--app-primary) 25%, transparent);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
import { getAppearance } from './appearance'
|
||||
import { getAppearance, subscribeAppearance } from './appearance'
|
||||
|
||||
/** 指针在窗口里的位置带来的偏移上限(像素):鼠标停在窗口边缘时背景偏这么多。 */
|
||||
const POSITION_SHIFT = 30
|
||||
@@ -53,7 +53,7 @@ export function useBackgroundParallax(layer: RefObject<HTMLElement | null>) {
|
||||
const clamp = (value: number) => Math.max(-SWAY_MAX, Math.min(SWAY_MAX, value))
|
||||
const move = (event: PointerEvent) => {
|
||||
// 位置:指针在窗口里的位置换成 -1 到 1(背景朝同一个方向偏移,想要反方向就把符号取反)
|
||||
const amount = getAppearance().parallax
|
||||
const amount = swayAmount()
|
||||
baseX = ((event.clientX / window.innerWidth) * 2 - 1) * POSITION_SHIFT * amount
|
||||
baseY = ((event.clientY / window.innerHeight) * 2 - 1) * POSITION_SHIFT * amount
|
||||
// 晃动:这一次移动了多远,就再推背景一把
|
||||
@@ -65,6 +65,11 @@ export function useBackgroundParallax(layer: RefObject<HTMLElement | null>) {
|
||||
lastY = event.clientY
|
||||
schedule()
|
||||
}
|
||||
// 晃动的幅度:总开关关了就是 0
|
||||
const swayAmount = () => {
|
||||
const { sway, parallax } = getAppearance()
|
||||
return sway ? parallax : 0
|
||||
}
|
||||
const leave = () => {
|
||||
baseX = 0
|
||||
baseY = 0
|
||||
@@ -75,7 +80,16 @@ export function useBackgroundParallax(layer: RefObject<HTMLElement | null>) {
|
||||
|
||||
window.addEventListener('pointermove', move, { passive: true })
|
||||
document.documentElement.addEventListener('mouseleave', leave)
|
||||
// 设置里把晃动关了:背景慢慢回到中间,不用等下一次鼠标移动
|
||||
const unsubscribe = subscribeAppearance(() => {
|
||||
if (swayAmount() === 0) {
|
||||
swayX = 0
|
||||
swayY = 0
|
||||
leave()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
unsubscribe()
|
||||
window.removeEventListener('pointermove', move)
|
||||
document.documentElement.removeEventListener('mouseleave', leave)
|
||||
cancelAnimationFrame(frame)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "moshiro-web",
|
||||
"version": "0.4.0",
|
||||
"version": "0.0.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "moshiro-web",
|
||||
"version": "0.4.0",
|
||||
"version": "0.0.0-dev",
|
||||
"workspaces": [
|
||||
"display",
|
||||
"admin"
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"admin": {
|
||||
"name": "moshiro-admin",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0-dev",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.2.8",
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"display": {
|
||||
"name": "moshiro-display",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0-dev",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.2.8",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "moshiro-web",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.0.0-dev",
|
||||
"workspaces": [
|
||||
"display",
|
||||
"admin"
|
||||
|
||||