Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675a2bc5b4 | ||
|
|
a4f3d1892a | ||
|
|
76efc15e9a | ||
|
|
cab505b837 | ||
|
|
3ee8b6bb3d | ||
|
|
cd9e677331 | ||
|
|
0fa2f2a26a | ||
|
|
879d1c8593 | ||
|
|
0e7a8c14fb | ||
|
|
4a1e48db20 | ||
|
|
4a15dba8de | ||
|
|
f22fdf8b68 | ||
|
|
2ae63653c4 | ||
|
|
8c37db1577 | ||
|
|
8410735377 | ||
|
|
0b384a58f5 | ||
|
|
6310358490 | ||
|
|
4841f7b15b | ||
|
|
117d34a2af | ||
|
|
4106f831dc | ||
|
|
0dc8c6fe34 | ||
|
|
f704c66dd9 | ||
|
|
1f70c8d31e | ||
|
|
fbda9487d5 | ||
|
|
4a3631f82b | ||
|
|
3b88a3419d | ||
|
|
0e0b925c8b | ||
|
|
f01bb8fd63 | ||
|
|
96990c6f4e | ||
|
|
bd35a1a966 | ||
|
|
b47e01d944 | ||
|
|
7ee860cb27 | ||
|
|
699ed8c411 | ||
|
|
0012df7b91 | ||
|
|
d5967b6110 | ||
|
|
450748f3bc | ||
|
|
5bd53f8880 | ||
|
|
643f9e1ea3 | ||
|
|
ac0bcd8e1f | ||
|
|
f4aa3f2482 | ||
|
|
180e54cb3c | ||
|
|
a19517a57d | ||
|
|
83e5120377 | ||
|
|
17b35223aa | ||
|
|
4d329f5e16 | ||
|
|
ad17170664 | ||
|
|
710aa9e99f | ||
|
|
71d2866b9c | ||
|
|
48664c0ada | ||
|
|
198ee556d6 | ||
|
|
da465866d5 | ||
|
|
3a751a0f89 | ||
|
|
d69581dcb5 | ||
|
|
8f44e924a4 | ||
|
|
0c39485a78 | ||
|
|
7a71bf57f9 | ||
|
|
22a98f2eb0 | ||
|
|
0a66f5e07a | ||
|
|
48072737c9 | ||
|
|
604cbe3123 | ||
|
|
b242701e9e | ||
|
|
83f54978d2 | ||
|
|
1e5c660d05 | ||
|
|
2e470607fa | ||
|
|
a70b89b95c | ||
|
|
a9538eae4a | ||
|
|
91c63d0cf2 | ||
|
|
193fc3a95c | ||
|
|
134c06d605 | ||
|
|
d1be70cda2 | ||
|
|
672f6e3dcb | ||
|
|
9a375ffd9e | ||
|
|
407c09f6a5 | ||
|
|
fffee8937c | ||
|
|
91485db500 | ||
|
|
fa34869f18 | ||
|
|
deb5a97802 | ||
|
|
479f8c25b0 | ||
|
|
2b80bb66d1 | ||
|
|
078eacf1cb | ||
|
|
3db6d5b159 | ||
|
|
a7a8212c08 | ||
|
|
68406843de | ||
|
|
fae8c45c90 | ||
|
|
3bf12ec73d | ||
|
|
a9ee4ec34c | ||
|
|
6b12049df0 | ||
|
|
d4b4869501 | ||
|
|
92b72d4443 | ||
|
|
67b428991f | ||
|
|
55756f7695 | ||
|
|
17a69c1c7a | ||
|
|
50d8c07939 | ||
|
|
2e549b230d | ||
|
|
ace42e5da7 | ||
|
|
ad32afd038 | ||
|
|
286c9ff7cd | ||
|
|
8aaa574485 | ||
|
|
ce30828ed3 | ||
|
|
a46f45cce3 | ||
|
|
ce3ba51c02 | ||
|
|
faf1203f0a | ||
|
|
ee422ca366 | ||
|
|
c33003c279 | ||
|
|
31af31b96f | ||
|
|
9b6c4e0fd8 | ||
|
|
ceef543854 | ||
|
|
299f86dbee |
No files matched your search
@@ -27,6 +27,9 @@ steps:
|
|||||||
ZOT_USERNAME: justme
|
ZOT_USERNAME: justme
|
||||||
ZOT_SECRET:
|
ZOT_SECRET:
|
||||||
from_secret: zot_secret
|
from_secret: zot_secret
|
||||||
|
RELEASE_API_URL: https://mo.oeo.one
|
||||||
|
RELEASE_SIGNING_KEY:
|
||||||
|
from_secret: release_signing_key
|
||||||
commands:
|
commands:
|
||||||
- bash ci/release-node-images.sh
|
- bash ci/release-node-images.sh
|
||||||
depends_on: []
|
depends_on: []
|
||||||
@@ -37,6 +40,8 @@ steps:
|
|||||||
GITEA_BASE: http://127.0.0.1:3100
|
GITEA_BASE: http://127.0.0.1:3100
|
||||||
GITEA_TOKEN:
|
GITEA_TOKEN:
|
||||||
from_secret: gitea_token
|
from_secret: gitea_token
|
||||||
|
RELEASE_SIGNING_KEY:
|
||||||
|
from_secret: release_signing_key
|
||||||
commands:
|
commands:
|
||||||
- bash ci/publish-gitea-release.sh
|
- bash ci/publish-gitea-release.sh
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ when:
|
|||||||
concurrency: 1
|
concurrency: 1
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# 前端先打包好(几十秒),两个架构的 Backend 构建再并行:它们把 web/*/dist 内嵌进程序,只读这份产物,不再各自 npm ci
|
||||||
|
# (并行的两个 npm ci 会同时删 node_modules,互相搞坏)。步骤加了超时:卡住就几分钟内失败,不是耗着。
|
||||||
|
- name: web-build
|
||||||
|
image: bash
|
||||||
|
environment:
|
||||||
|
NPM_CONFIG_CACHE: /home/justme/.npm
|
||||||
|
commands:
|
||||||
|
- npm ci --prefix web --prefer-offline
|
||||||
|
- timeout 600 npm run build --prefix web
|
||||||
|
depends_on: []
|
||||||
|
|
||||||
- name: linux-amd64-backend
|
- name: linux-amd64-backend
|
||||||
image: bash
|
image: bash
|
||||||
environment:
|
environment:
|
||||||
@@ -25,7 +36,8 @@ steps:
|
|||||||
from_secret: zot_secret
|
from_secret: zot_secret
|
||||||
commands:
|
commands:
|
||||||
- bash ci/release-linux.sh amd64 backend
|
- bash ci/release-linux.sh amd64 backend
|
||||||
depends_on: []
|
depends_on:
|
||||||
|
- web-build
|
||||||
|
|
||||||
- name: linux-arm64-backend
|
- name: linux-arm64-backend
|
||||||
image: bash
|
image: bash
|
||||||
@@ -37,7 +49,8 @@ steps:
|
|||||||
from_secret: zot_secret
|
from_secret: zot_secret
|
||||||
commands:
|
commands:
|
||||||
- bash ci/release-linux.sh arm64 backend
|
- bash ci/release-linux.sh arm64 backend
|
||||||
depends_on: []
|
depends_on:
|
||||||
|
- web-build
|
||||||
|
|
||||||
- name: backend-multiarch-index
|
- name: backend-multiarch-index
|
||||||
image: bash
|
image: bash
|
||||||
@@ -50,8 +63,6 @@ steps:
|
|||||||
RELEASE_API_URL: https://mo.oeo.one
|
RELEASE_API_URL: https://mo.oeo.one
|
||||||
RELEASE_SIGNING_KEY:
|
RELEASE_SIGNING_KEY:
|
||||||
from_secret: release_signing_key
|
from_secret: release_signing_key
|
||||||
RELEASE_API_TOKEN:
|
|
||||||
from_secret: release_api_token
|
|
||||||
commands:
|
commands:
|
||||||
- bash ci/index-backend-image.sh
|
- bash ci/index-backend-image.sh
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ steps:
|
|||||||
RELEASE_API_URL: https://mo.oeo.one
|
RELEASE_API_URL: https://mo.oeo.one
|
||||||
RELEASE_SIGNING_KEY:
|
RELEASE_SIGNING_KEY:
|
||||||
from_secret: release_signing_key
|
from_secret: release_signing_key
|
||||||
RELEASE_API_TOKEN:
|
|
||||||
from_secret: release_api_token
|
|
||||||
commands:
|
commands:
|
||||||
- bash ci/build-main-amd64.sh backend
|
- bash ci/build-main-amd64.sh backend
|
||||||
depends_on: []
|
depends_on: []
|
||||||
@@ -44,8 +42,6 @@ steps:
|
|||||||
RELEASE_API_URL: https://mo.oeo.one
|
RELEASE_API_URL: https://mo.oeo.one
|
||||||
RELEASE_SIGNING_KEY:
|
RELEASE_SIGNING_KEY:
|
||||||
from_secret: release_signing_key
|
from_secret: release_signing_key
|
||||||
RELEASE_API_TOKEN:
|
|
||||||
from_secret: release_api_token
|
|
||||||
commands:
|
commands:
|
||||||
- bash ci/build-main-amd64.sh agent
|
- bash ci/build-main-amd64.sh agent
|
||||||
depends_on: []
|
depends_on: []
|
||||||
@@ -61,8 +57,6 @@ steps:
|
|||||||
RELEASE_API_URL: https://mo.oeo.one
|
RELEASE_API_URL: https://mo.oeo.one
|
||||||
RELEASE_SIGNING_KEY:
|
RELEASE_SIGNING_KEY:
|
||||||
from_secret: release_signing_key
|
from_secret: release_signing_key
|
||||||
RELEASE_API_TOKEN:
|
|
||||||
from_secret: release_api_token
|
|
||||||
commands:
|
commands:
|
||||||
- bash ci/build-main-amd64.sh probe
|
- bash ci/build-main-amd64.sh probe
|
||||||
depends_on: []
|
depends_on: []
|
||||||
@@ -9,7 +9,7 @@ MoShiro is a self-hosted network path quality monitor. Backend coordinates Agent
|
|||||||
- 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.
|
- 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.
|
- 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.
|
||||||
- Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit.
|
- 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, `release_api_token` with the `release` scope). The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
|
- 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`.
|
- 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`.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||||
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
||||||
<Deterministic>true</Deterministic>
|
<Deterministic>true</Deterministic>
|
||||||
<Version>0.2.0</Version>
|
<Version>0.3.3</Version>
|
||||||
<NuGetAudit>false</NuGetAudit>
|
<NuGetAudit>false</NuGetAudit>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+1
-1
@@ -49,6 +49,6 @@ cd backend
|
|||||||
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
|
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
首次启动时可用 `MoShiro__InitialAdminUser` 和 `MoShiro__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
|
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
|
||||||
|
|
||||||
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
|
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
|
||||||
@@ -47,6 +47,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
_ownedRestart = restart is null ? new RestartController() : null;
|
_ownedRestart = restart is null ? new RestartController() : null;
|
||||||
_restart = restart ?? _ownedRestart!;
|
_restart = restart ?? _ownedRestart!;
|
||||||
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
|
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
|
||||||
|
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
|
||||||
_config = config;
|
_config = config;
|
||||||
_identity = identity;
|
_identity = identity;
|
||||||
_time = time;
|
_time = time;
|
||||||
@@ -276,6 +277,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
Server = new Uri(_config.Fleet.Server),
|
Server = new Uri(_config.Fleet.Server),
|
||||||
Identity = _identity.Identity,
|
Identity = _identity.Identity,
|
||||||
ServerVerifier = _trust,
|
ServerVerifier = _trust,
|
||||||
|
Attestation = _update.Attest,
|
||||||
NodeId = _identity.NodeId,
|
NodeId = _identity.NodeId,
|
||||||
NodeVersion = Version,
|
NodeVersion = Version,
|
||||||
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
|
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
|
||||||
@@ -302,6 +304,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
{
|
{
|
||||||
var meta = new AgentHelloMeta
|
var meta = new AgentHelloMeta
|
||||||
{
|
{
|
||||||
|
Owners = [.. _trust.Controllers],
|
||||||
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
||||||
Ipv6Supported = _ipv6Supported,
|
Ipv6Supported = _ipv6Supported,
|
||||||
Addresses = [.. LocalAddresses.Sample()],
|
Addresses = [.. LocalAddresses.Sample()],
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ public sealed class FleetSection
|
|||||||
|
|
||||||
[JsonPropertyName("identity_dir")]
|
[JsonPropertyName("identity_dir")]
|
||||||
public string IdentityDir { get; set; } = "/etc/moshiro";
|
public string IdentityDir { get; set; } = "/etc/moshiro";
|
||||||
|
|
||||||
|
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>。</summary>
|
||||||
|
[JsonPropertyName("require_owner")]
|
||||||
|
public bool RequireOwner { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ListenSection
|
public sealed class ListenSection
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ try
|
|||||||
config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
|
config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
|
||||||
restart = new RestartController(RestartSchedule.Resolve(config.RestartAt));
|
restart = new RestartController(RestartSchedule.Resolve(config.RestartAt));
|
||||||
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir);
|
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir);
|
||||||
|
if (!OwnerGuard.Check(config.Fleet.IdentityDir, config.Fleet.RequireOwner, log))
|
||||||
|
{
|
||||||
|
identity.Dispose();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
|
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Fleet.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点握手时自述的「我跑的是哪个二进制」:正在运行的可执行文件的 SHA-256,以及(容器节点)宿主机更新器验过的 CI 签名清单和签名。
|
||||||
|
/// 服务端在节点第一次握手成功的那一刻把哈希绑定到这个节点,之后哈希对不上就不让它继续运行。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BinaryAttestation(string Sha256, string? Manifest, string? Signature);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 正在运行的二进制的哈希。Linux 上读 <c>/proc/self/exe</c>:它指向进程启动时映射的那个映像,磁盘上的文件之后被换掉也不影响,
|
||||||
|
/// 所以「运行期间被替换、重启后换了别的」的情况下一次握手就会对不上。算一次,之后缓存。
|
||||||
|
/// </summary>
|
||||||
|
public static class NodeBinary
|
||||||
|
{
|
||||||
|
private static readonly Lazy<string?> s_hash = new(Compute);
|
||||||
|
|
||||||
|
/// <summary>小写十六进制;读不到可执行文件时为 null(这样的节点不做绑定)。</summary>
|
||||||
|
public static string? Sha256Hex => s_hash.Value;
|
||||||
|
|
||||||
|
/// <summary>在后台先算好,免得第一次握手等它。</summary>
|
||||||
|
public static void Prime() => _ = Task.Run(() => s_hash.Value);
|
||||||
|
|
||||||
|
private static string? Compute()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? path = OperatingSystem.IsLinux() && File.Exists("/proc/self/exe") ? "/proc/self/exe" : Environment.ProcessPath;
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16);
|
||||||
|
return Convert.ToHexStringLower(SHA256.HashData(stream));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,4 +27,6 @@ public static class FleetErrors
|
|||||||
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
|
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
|
||||||
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
|
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
|
||||||
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
|
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
|
||||||
|
/// <summary>节点现在跑的二进制和它第一次握手时绑定的不一致,也不在 CI 签名的更新清单里。</summary>
|
||||||
|
public static readonly ErrorCode BinaryMismatch = new("fleet_binary_mismatch");
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,12 @@ public static class EnrollmentClient
|
|||||||
TrustBundle? bundle = null;
|
TrustBundle? bundle = null;
|
||||||
if (genesis is not null)
|
if (genesis is not null)
|
||||||
{
|
{
|
||||||
|
if (controllers is not { Count: > 0 })
|
||||||
|
{
|
||||||
|
// 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作
|
||||||
|
return Result<EnrollmentResult>.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)");
|
||||||
|
}
|
||||||
|
|
||||||
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
|
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
|
||||||
if (!trusted.TryGet(out bundle, out FleetError? trustError))
|
if (!trusted.TryGet(out bundle, out FleetError? trustError))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Fleet.Core;
|
||||||
|
|
||||||
|
namespace Fleet.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
|
||||||
|
/// <para>
|
||||||
|
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
|
||||||
|
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
|
||||||
|
/// 这是节点本地的强制,不依赖面板:面板被攻破也改不了节点已经固定的属主。
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class OwnerGuard
|
||||||
|
{
|
||||||
|
/// <summary>设为 <c>1</c> 放行没有属主的节点(只给没有信任根的老面板)。</summary>
|
||||||
|
public const string AllowEnvironmentVariable = "MOSHIRO_ALLOW_NO_OWNER";
|
||||||
|
|
||||||
|
/// <summary>没问题返回 null;否则是要告诉运维的原因。</summary>
|
||||||
|
public static string? Problem(TrustAnchor trust, bool require)
|
||||||
|
{
|
||||||
|
if (!require || Environment.GetEnvironmentVariable(AllowEnvironmentVariable) == "1")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trust.Pinned)
|
||||||
|
{
|
||||||
|
return "this node has no owner: it was not enrolled with a trust root and owner passkeys; enroll it again with the command from the panel (or set " + AllowEnvironmentVariable + "=1 for a panel without a trust root)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return trust.Controllers.Count == 0
|
||||||
|
? "none of this node's owner passkeys is in the operator set any more; enroll it again with a command from the panel"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>读身份目录里固定的信任根,检查属主;不行就记一条错误并返回 false(调用方应当退出)。</summary>
|
||||||
|
public static bool Check(string identityDirectory, bool require, INodeLog log)
|
||||||
|
{
|
||||||
|
TrustAnchor trust = TrustAnchor.Load(identityDirectory, log);
|
||||||
|
if (Problem(trust, require) is { } problem)
|
||||||
|
{
|
||||||
|
log.Error("startup failed", ("err", problem));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,9 @@ public sealed class FleetLinkOptions
|
|||||||
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
|
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IServerVerifier? ServerVerifier { get; init; }
|
public IServerVerifier? ServerVerifier { get; init; }
|
||||||
|
|
||||||
|
/// <summary>握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。</summary>
|
||||||
|
public Func<BinaryAttestation?>? Attestation { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -223,6 +226,16 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
||||||
|
private void Attest(AuthParams auth)
|
||||||
|
{
|
||||||
|
if (_options.Attestation?.Invoke() is { } attestation)
|
||||||
|
{
|
||||||
|
auth.Binary = attestation.Sha256;
|
||||||
|
auth.Release = attestation.Manifest;
|
||||||
|
auth.ReleaseSignature = attestation.Signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
|
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
||||||
@@ -257,6 +270,7 @@ public sealed class FleetLink
|
|||||||
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
|
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
|
||||||
NodeEphemeral = nodeEphemeralText,
|
NodeEphemeral = nodeEphemeralText,
|
||||||
};
|
};
|
||||||
|
Attest(authParams);
|
||||||
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||||
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
|
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
|
||||||
@@ -264,6 +278,7 @@ public sealed class FleetLink
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
|
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
|
||||||
|
Attest(authParams);
|
||||||
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ public sealed class AuthParams
|
|||||||
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
|
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
|
||||||
[JsonPropertyName("node_eph")]
|
[JsonPropertyName("node_eph")]
|
||||||
public string? NodeEphemeral { get; set; }
|
public string? NodeEphemeral { get; set; }
|
||||||
|
|
||||||
|
/// <summary>正在运行的二进制的 SHA-256(小写十六进制):服务端在第一次握手时绑定,之后核对。旧节点没有这个字段。</summary>
|
||||||
|
[JsonPropertyName("binary")]
|
||||||
|
public string? Binary { get; set; }
|
||||||
|
|
||||||
|
/// <summary>安装这个二进制时宿主机更新器验过的 CI 签名清单(原文)和签名(base64):服务端用 CI 公钥验过,才相信它列出的官方哈希。</summary>
|
||||||
|
[JsonPropertyName("release")]
|
||||||
|
public string? Release { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("release_sig")]
|
||||||
|
public string? ReleaseSignature { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AuthResult
|
public sealed class AuthResult
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ public interface IServerIdentity
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
|
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
|
||||||
|
/// <summary>节点握手时自述的二进制(旧节点没有)。</summary>
|
||||||
|
public sealed record BinaryEvidence(string? Sha256, string? Manifest, string? Signature);
|
||||||
|
|
||||||
|
/// <summary>是否让这个节点继续:不放行时 <c>Reason</c> 会原样告诉节点(这是它自己的信息,不是秘密)。</summary>
|
||||||
|
public sealed record BinaryDecision(bool Accept, string Reason = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务端对节点二进制的把关:节点第一次握手成功的那一刻绑定它的二进制哈希,之后哈希对不上、又不在 CI 签名的更新清单里,就不让它继续运行。
|
||||||
|
/// 没有注册实现时不做任何检查。
|
||||||
|
/// </summary>
|
||||||
|
public interface IBinaryAttestor
|
||||||
|
{
|
||||||
|
ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
public interface IOperatorTrustSource
|
public interface IOperatorTrustSource
|
||||||
{
|
{
|
||||||
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
|
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
|||||||
IEnumerable<IInbox> inboxes,
|
IEnumerable<IInbox> inboxes,
|
||||||
IEnumerable<INodeEventSink> sinks,
|
IEnumerable<INodeEventSink> sinks,
|
||||||
FleetRouter router,
|
FleetRouter router,
|
||||||
IServerIdentity? serverIdentity = null)
|
IServerIdentity? serverIdentity = null,
|
||||||
|
IBinaryAttestor? binaryAttestor = null)
|
||||||
{
|
{
|
||||||
ServerIdentity = serverIdentity;
|
ServerIdentity = serverIdentity;
|
||||||
|
BinaryAttestor = binaryAttestor;
|
||||||
Store = store;
|
Store = store;
|
||||||
Options = options;
|
Options = options;
|
||||||
Time = time;
|
Time = time;
|
||||||
@@ -43,6 +45,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
|||||||
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
|
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
|
||||||
internal IServerIdentity? ServerIdentity { get; }
|
internal IServerIdentity? ServerIdentity { get; }
|
||||||
|
|
||||||
|
/// <summary>节点二进制的把关;没有时不检查。</summary>
|
||||||
|
internal IBinaryAttestor? BinaryAttestor { get; }
|
||||||
|
|
||||||
internal FleetServerOptions Options { get; }
|
internal FleetServerOptions Options { get; }
|
||||||
|
|
||||||
internal TimeProvider Time { get; }
|
internal TimeProvider Time { get; }
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ public static class FleetServerExtensions
|
|||||||
provider.GetServices<IInbox>(),
|
provider.GetServices<IInbox>(),
|
||||||
provider.GetServices<INodeEventSink>(),
|
provider.GetServices<INodeEventSink>(),
|
||||||
provider.GetRequiredService<FleetRouter>(),
|
provider.GetRequiredService<FleetRouter>(),
|
||||||
provider.GetService<IServerIdentity>()));
|
provider.GetService<IServerIdentity>(),
|
||||||
|
provider.GetService<IBinaryAttestor>()));
|
||||||
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||||
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||||
return builder;
|
return builder;
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ internal sealed partial class NodeConnection
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 二进制:第一次握手成功的这一刻绑定,之后哈希对不上又不在 CI 签名的更新清单里,就不让它继续运行
|
||||||
|
if (_host.BinaryAttestor is { } attestor)
|
||||||
|
{
|
||||||
|
BinaryDecision decision = await attestor.CheckAsync(node, new BinaryEvidence(auth!.Binary, auth.Release, auth.ReleaseSignature), ct);
|
||||||
|
if (!decision.Accept)
|
||||||
|
{
|
||||||
|
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.BinaryMismatch, decision.Reason)), ct);
|
||||||
|
await RejectAsync(null, "binary mismatch: " + decision.Reason, ct);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
|
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
|
||||||
await SendAsync(
|
await SendAsync(
|
||||||
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
|
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Fleet.Core;
|
||||||
|
using Fleet.Hosting;
|
||||||
|
using Fleet.Identity;
|
||||||
|
|
||||||
|
namespace Fleet.Tests;
|
||||||
|
|
||||||
|
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
|
||||||
|
public sealed class OwnerGuardTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly TestAuthenticator _alice = new();
|
||||||
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-owner-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
public OwnerGuardTests()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_alice.Dispose();
|
||||||
|
Directory.Delete(_directory, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Pin(params string[] controllers)
|
||||||
|
{
|
||||||
|
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key]);
|
||||||
|
SignedStatement genesis = _alice.Sign(OperatorTrust.Encode(set));
|
||||||
|
TrustAnchor.Pin(_directory, OperatorTrust.Fingerprint(genesis.Statement), [genesis.ToDto()], controllers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ANodeEnrolledWithoutATrustRoot_HasNoOwner_AndDoesNotRun()
|
||||||
|
{
|
||||||
|
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||||
|
|
||||||
|
Assert.NotNull(OwnerGuard.Problem(none, require: true));
|
||||||
|
Assert.False(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ANodePinnedToAnOwnerPasskey_Runs()
|
||||||
|
{
|
||||||
|
Pin(_alice.CredentialId);
|
||||||
|
|
||||||
|
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||||
|
|
||||||
|
Assert.Null(OwnerGuard.Problem(pinned, require: true));
|
||||||
|
Assert.True(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
|
||||||
|
Assert.Equal([_alice.CredentialId], pinned.Controllers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APinnedNodeWhoseOwnerIsNoLongerInTheOperatorSet_DoesNotRun()
|
||||||
|
{
|
||||||
|
Pin("some-passkey-that-is-not-in-the-set");
|
||||||
|
|
||||||
|
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||||
|
|
||||||
|
Assert.Empty(pinned.Controllers);
|
||||||
|
Assert.NotNull(OwnerGuard.Problem(pinned, require: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheRequirementCanBeTurnedOff_ForAPanelWithoutATrustRoot()
|
||||||
|
{
|
||||||
|
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||||
|
|
||||||
|
Assert.Null(OwnerGuard.Problem(none, require: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,12 @@ if (Arg("--meta") is { } metaText)
|
|||||||
string? genesis = Arg("--genesis");
|
string? genesis = Arg("--genesis");
|
||||||
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
if (genesis is not null && controllers.Length == 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("--genesis needs --controllers: every node has an owner (the passkey credential ids from the panel's command)");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||||
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
|
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
|
||||||
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
|
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
|
||||||
|
|||||||
+381
-278
@@ -1187,6 +1187,96 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/admin/auth/setup": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"admin-auth"
|
||||||
|
],
|
||||||
|
"summary": "Whether the panel has no users yet and still has to be claimed",
|
||||||
|
"operationId": "setup_status",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SetupStatusDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"admin-auth"
|
||||||
|
],
|
||||||
|
"summary": "Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists)",
|
||||||
|
"operationId": "setup_complete",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SetupRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Created",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/LoginResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/admin/auth/passkey/options": {
|
"/api/admin/auth/passkey/options": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1625,208 +1715,6 @@
|
|||||||
"x-moshiro-capability": "authenticated"
|
"x-moshiro-capability": "authenticated"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/admin/auth/elevation": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"admin-auth"
|
|
||||||
],
|
|
||||||
"summary": "The elevation windows this session has open (sensitive actions need one)",
|
|
||||||
"operationId": "auth_elevation_list",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/PageDtoOfElevationDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-moshiro-capability": "authenticated"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"admin-auth"
|
|
||||||
],
|
|
||||||
"summary": "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)",
|
|
||||||
"operationId": "auth_elevate",
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ElevationAssertionRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ElevationDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"429": {
|
|
||||||
"description": "Too Many Requests",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-moshiro-capability": "authenticated"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"tags": [
|
|
||||||
"admin-auth"
|
|
||||||
],
|
|
||||||
"summary": "Close all elevation windows of this session",
|
|
||||||
"operationId": "auth_elevation_end",
|
|
||||||
"responses": {
|
|
||||||
"204": {
|
|
||||||
"description": "No Content",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-moshiro-capability": "authenticated"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/admin/auth/elevation/options": {
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"admin-auth"
|
|
||||||
],
|
|
||||||
"summary": "A challenge for opening an elevation window with a passkey touch",
|
|
||||||
"operationId": "auth_elevation_options",
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ElevationRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/PasskeyRequestOptionsDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"429": {
|
|
||||||
"description": "Too Many Requests",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-moshiro-capability": "authenticated"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/admin/auth/me": {
|
"/api/admin/auth/me": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3932,7 +3820,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"admin-trust"
|
"admin-trust"
|
||||||
],
|
],
|
||||||
"summary": "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)",
|
"summary": "Store the next operator set, signed with a passkey (the first one sets the genesis)",
|
||||||
"operationId": "trust_commit",
|
"operationId": "trust_commit",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
@@ -4584,6 +4472,56 @@
|
|||||||
"x-moshiro-capability": "update.confirm"
|
"x-moshiro-capability": "update.confirm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/admin/nodes/{id}/binary": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"admin-build"
|
||||||
|
],
|
||||||
|
"summary": "Accept the node's current binary: clear its binding so the next handshake binds it again",
|
||||||
|
"operationId": "nodes_binary_clear",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-moshiro-capability": "fleet.write"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/admin/backend/update": {
|
"/api/admin/backend/update": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -5020,6 +4958,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/release/offer": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"summary": "Register a release manifest that carries a valid CI signature (no token needed)",
|
||||||
|
"operationId": "release_offer",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PublishReleaseRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Created",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReleaseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/agent/v1/enroll": {
|
"/api/agent/v1/enroll": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -6168,59 +6167,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ElevationAssertionRequest": {
|
|
||||||
"required": [
|
|
||||||
"class",
|
|
||||||
"credential_id",
|
|
||||||
"authenticator_data",
|
|
||||||
"client_data_json",
|
|
||||||
"signature"
|
|
||||||
],
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"class": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"credential_id": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"authenticator_data": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"client_data_json": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"signature": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ElevationDto": {
|
|
||||||
"required": [
|
|
||||||
"class",
|
|
||||||
"expires_at"
|
|
||||||
],
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"class": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"expires_at": {
|
|
||||||
"format": "date-time"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ElevationRequest": {
|
|
||||||
"required": [
|
|
||||||
"class"
|
|
||||||
],
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"class": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"EventDto": {
|
"EventDto": {
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
@@ -7193,6 +7139,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"NodeBinaryDto": {
|
||||||
|
"required": [
|
||||||
|
"sha256",
|
||||||
|
"signed",
|
||||||
|
"seq",
|
||||||
|
"bound_at",
|
||||||
|
"violation",
|
||||||
|
"violation_at"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sha256": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"signed": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"seq": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"bound_at": {
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"violation": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"violation_at": {
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"NodeBuildDto": {
|
"NodeBuildDto": {
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
@@ -7264,6 +7249,25 @@
|
|||||||
"$ref": "#/components/schemas/NodeUpdateDto"
|
"$ref": "#/components/schemas/NodeUpdateDto"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"binary": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/NodeBinaryDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"owners": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"array"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/NodeOwnerDto"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7336,6 +7340,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"NodeOwnerDto": {
|
||||||
|
"required": [
|
||||||
|
"credential_id",
|
||||||
|
"passkey",
|
||||||
|
"account"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"credential_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"passkey": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"NodeUpdateDto": {
|
"NodeUpdateDto": {
|
||||||
"required": [
|
"required": [
|
||||||
"state",
|
"state",
|
||||||
@@ -7571,27 +7600,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"PageDtoOfElevationDto": {
|
|
||||||
"required": [
|
|
||||||
"items",
|
|
||||||
"next_cursor"
|
|
||||||
],
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"items": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/ElevationDto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"next_cursor": {
|
|
||||||
"type": [
|
|
||||||
"null",
|
|
||||||
"string"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"PageDtoOfEventDto": {
|
"PageDtoOfEventDto": {
|
||||||
"required": [
|
"required": [
|
||||||
"items",
|
"items",
|
||||||
@@ -8416,6 +8424,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ProposedCredentialDto": {
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"created_at"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"PublishReleaseRequest": {
|
"PublishReleaseRequest": {
|
||||||
"required": [
|
"required": [
|
||||||
"manifest",
|
"manifest",
|
||||||
@@ -8836,6 +8863,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SetupRequest": {
|
||||||
|
"required": [
|
||||||
|
"claim_code",
|
||||||
|
"username",
|
||||||
|
"password",
|
||||||
|
"return_session"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"claim_code": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"return_session": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"boolean"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SetupStatusDto": {
|
||||||
|
"required": [
|
||||||
|
"needs_setup"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"needs_setup": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SignedStatementDto": {
|
"SignedStatementDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9211,7 +9284,10 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"statement",
|
"statement",
|
||||||
"version",
|
"version",
|
||||||
"challenge"
|
"challenge",
|
||||||
|
"added",
|
||||||
|
"removed",
|
||||||
|
"server_key_changed"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9228,6 +9304,21 @@
|
|||||||
},
|
},
|
||||||
"challenge": {
|
"challenge": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"added": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ProposedCredentialDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"removed": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server_key_changed": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -9305,6 +9396,15 @@
|
|||||||
"server_key_listed": {
|
"server_key_listed": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"operator_details": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"array"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/NodeOwnerDto"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -9384,6 +9484,9 @@
|
|||||||
{
|
{
|
||||||
"name": "ops"
|
"name": "ops"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "release"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "agent"
|
"name": "agent"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,16 +33,18 @@ public sealed class ProbeApp : IDisposable
|
|||||||
directory.Changed += () => _changed.Release();
|
directory.Changed += () => _changed.Release();
|
||||||
|
|
||||||
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
|
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
|
||||||
|
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
|
||||||
var options = new FleetLinkOptions
|
var options = new FleetLinkOptions
|
||||||
{
|
{
|
||||||
Server = new Uri(file.Fleet.Server),
|
Server = new Uri(file.Fleet.Server),
|
||||||
Identity = identity.Identity,
|
Identity = identity.Identity,
|
||||||
ServerVerifier = trust,
|
ServerVerifier = trust,
|
||||||
|
Attestation = update.Attest,
|
||||||
NodeId = identity.NodeId,
|
NodeId = identity.NodeId,
|
||||||
NodeVersion = Version,
|
NodeVersion = Version,
|
||||||
Capabilities = [MoShiroCapabilities.Base],
|
Capabilities = [MoShiroCapabilities.Base],
|
||||||
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
|
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
|
||||||
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
|
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
|
||||||
};
|
};
|
||||||
var list = new StateHandler<ProbeAgentList>(
|
var list = new StateHandler<ProbeAgentList>(
|
||||||
ProbeAgentList.StateType,
|
ProbeAgentList.StateType,
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ public sealed class ProbeFleetSection
|
|||||||
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
|
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
|
||||||
[JsonPropertyName("identity_dir")]
|
[JsonPropertyName("identity_dir")]
|
||||||
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
|
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
|
||||||
|
|
||||||
|
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>(Agent 和 Probe 共用)。</summary>
|
||||||
|
[JsonPropertyName("require_owner")]
|
||||||
|
public bool RequireOwner { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class KnownAgent
|
public sealed class KnownAgent
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ try
|
|||||||
identity.Dispose();
|
identity.Dispose();
|
||||||
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token");
|
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!OwnerGuard.Check(file.Fleet.IdentityDir, file.Fleet.RequireOwner, log))
|
||||||
|
{
|
||||||
|
identity.Dispose();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
|
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -96,5 +96,34 @@ public sealed class UpdateBridge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 握手时自述的二进制:正在运行的可执行文件的哈希,加上宿主机更新器验过的、安装这个版本时用的 CI 签名清单(更新器写在只读的 <c>update-status/</c>)。
|
||||||
|
/// 容器节点以外(没有更新器)没有清单,只有哈希;读不到可执行文件时返回 null(不做绑定)。
|
||||||
|
/// </summary>
|
||||||
|
public BinaryAttestation? Attest()
|
||||||
|
{
|
||||||
|
if (NodeBinary.Sha256Hex is not { } hash)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? manifest = ReadSmall("installed.manifest", MaxManifestBytes);
|
||||||
|
string? signature = ReadSmall("installed.sig", MaxSignatureBytes);
|
||||||
|
return manifest is not null && signature is not null ? new BinaryAttestation(hash, manifest, signature) : new BinaryAttestation(hash, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ReadSmall(string name, int maxBytes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string path = Path.Combine(Path.GetDirectoryName(_statusFile)!, name);
|
||||||
|
return File.Exists(path) && new FileInfo(path).Length <= maxBytes ? File.ReadAllText(path, Encoding.UTF8) : null;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content);
|
private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content);
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@ public static class MoShiroErrors
|
|||||||
public static readonly ErrorCode CsrfFailed = new("csrf_failed");
|
public static readonly ErrorCode CsrfFailed = new("csrf_failed");
|
||||||
public static readonly ErrorCode Forbidden = new("forbidden");
|
public static readonly ErrorCode Forbidden = new("forbidden");
|
||||||
public static readonly ErrorCode SitePrivate = new("site_private");
|
public static readonly ErrorCode SitePrivate = new("site_private");
|
||||||
public static readonly ErrorCode ElevationRequired = new("elevation_required");
|
|
||||||
public static readonly ErrorCode NotFound = new("not_found");
|
public static readonly ErrorCode NotFound = new("not_found");
|
||||||
public static readonly ErrorCode AgentNotFound = new("agent_not_found");
|
public static readonly ErrorCode AgentNotFound = new("agent_not_found");
|
||||||
public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
|
public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
|
||||||
|
|||||||
@@ -306,6 +306,10 @@ public sealed class ProbeHeartbeatExtra
|
|||||||
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
|
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
|
||||||
public sealed class ProbeHelloMeta
|
public sealed class ProbeHelloMeta
|
||||||
{
|
{
|
||||||
|
/// <summary>这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
|
||||||
|
[JsonPropertyName("owners")]
|
||||||
|
public List<string>? Owners { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("ipv6")]
|
[JsonPropertyName("ipv6")]
|
||||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||||
|
|
||||||
@@ -316,6 +320,10 @@ public sealed class ProbeHelloMeta
|
|||||||
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
|
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
|
||||||
public sealed class AgentHelloMeta
|
public sealed class AgentHelloMeta
|
||||||
{
|
{
|
||||||
|
/// <summary>这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
|
||||||
|
[JsonPropertyName("owners")]
|
||||||
|
public List<string>? Owners { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("ipv6")]
|
[JsonPropertyName("ipv6")]
|
||||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -137,10 +137,10 @@ public static class AgentEndpoints
|
|||||||
// 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把
|
// 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把
|
||||||
OperatorSet? set = await trust.CurrentAsync(http.RequestAborted);
|
OperatorSet? set = await trust.CurrentAsync(http.RequestAborted);
|
||||||
string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis;
|
string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis;
|
||||||
string[] controllers = set is null ? [] : [.. request.Controllers ?? [.. set.Keys.Select(k => k.CredentialId)]];
|
Result<string[]> owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers);
|
||||||
if (set is not null && controllers.Any(c => set.Keys.All(k => k.CredentialId != c)))
|
if (!owner.TryGet(out string[]? controllers, out FleetError? ownerError))
|
||||||
{
|
{
|
||||||
return Problems.From(http, ValidationError.Single("controllers", "must be passkeys of the operator set"));
|
return Problems.From(http, ownerError);
|
||||||
}
|
}
|
||||||
|
|
||||||
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
|
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
|
|||||||
|
|
||||||
namespace MoShiro.Backend.Api;
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
/// <summary>审计日志:管理接口里每个写操作、登录和提权都会记一行,这里只读。</summary>
|
/// <summary>审计日志:管理接口里每个写操作、登录都会记一行,这里只读。</summary>
|
||||||
public static class AuditEndpoints
|
public static class AuditEndpoints
|
||||||
{
|
{
|
||||||
private const string Tag = "admin-audit";
|
private const string Tag = "admin-audit";
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ public static class AuthEndpoints
|
|||||||
.AllowsAnonymous()
|
.AllowsAnonymous()
|
||||||
.Api("auth_login", "Sign in with a username and password", Tag)
|
.Api("auth_login", "Sign in with a username and password", Tag)
|
||||||
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
||||||
|
open.MapGet("/auth/setup", SetupStatusAsync)
|
||||||
|
.AllowsAnonymous()
|
||||||
|
.Api("setup_status", "Whether the panel has no users yet and still has to be claimed", Tag)
|
||||||
|
.Returns<SetupStatusDto>();
|
||||||
|
open.MapPost("/auth/setup", SetupAsync)
|
||||||
|
.AllowsAnonymous()
|
||||||
|
.Api("setup_complete", "Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists)", Tag)
|
||||||
|
.Returns<LoginResponse>(StatusCodes.Status201Created).ProducesProblems(400, 401, 409, 429);
|
||||||
open.MapPost("/auth/passkey/options", PasskeyLoginOptions)
|
open.MapPost("/auth/passkey/options", PasskeyLoginOptions)
|
||||||
.AllowsAnonymous()
|
.AllowsAnonymous()
|
||||||
.Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag)
|
.Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag)
|
||||||
@@ -51,22 +59,6 @@ public static class AuthEndpoints
|
|||||||
.Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
|
.Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
|
||||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
|
||||||
.Requires(Capability.Authenticated);
|
.Requires(Capability.Authenticated);
|
||||||
guarded.MapGet("/auth/elevation", ListElevation)
|
|
||||||
.Api("auth_elevation_list", "The elevation windows this session has open (sensitive actions need one)", Tag)
|
|
||||||
.Returns<PageDto<ElevationDto>>().ProducesProblems(401)
|
|
||||||
.Requires(Capability.Authenticated);
|
|
||||||
guarded.MapPost("/auth/elevation/options", ElevationOptionsAsync)
|
|
||||||
.Api("auth_elevation_options", "A challenge for opening an elevation window with a passkey touch", Tag)
|
|
||||||
.Returns<PasskeyRequestOptionsDto>().ProducesProblems(400, 401, 403, 429)
|
|
||||||
.Requires(Capability.Authenticated);
|
|
||||||
guarded.MapPost("/auth/elevation", ElevateAsync)
|
|
||||||
.Api("auth_elevate", "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)", Tag)
|
|
||||||
.Returns<ElevationDto>().ProducesProblems(400, 401, 403, 429)
|
|
||||||
.Requires(Capability.Authenticated);
|
|
||||||
guarded.MapDelete("/auth/elevation", EndElevation)
|
|
||||||
.Api("auth_elevation_end", "Close all elevation windows of this session", Tag)
|
|
||||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
|
|
||||||
.Requires(Capability.Authenticated);
|
|
||||||
guarded.MapGet("/auth/me", Me)
|
guarded.MapGet("/auth/me", Me)
|
||||||
.Api("auth_me", "The current identity", Tag)
|
.Api("auth_me", "The current identity", Tag)
|
||||||
.Returns<MeDto>().ProducesProblems(401)
|
.Returns<MeDto>().ProducesProblems(401)
|
||||||
@@ -93,6 +85,32 @@ public static class AuthEndpoints
|
|||||||
return IssueSession(http, login, request.ReturnSession == true, options);
|
return IssueSession(http, login, request.ReturnSession == true, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> SetupStatusAsync(SetupService setup, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return TypedResults.Json(new SetupStatusDto(await setup.NeedsSetupAsync(ct)), ApiJsonContext.Default.SetupStatusDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> SetupAsync(HttpContext http, SetupRequest request, SetupService setup, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||||
|
{
|
||||||
|
IResult limit = Limited(http, limiter, out bool ok, "setup:");
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
string remote = http.ClientAddress();
|
||||||
|
Result<LoginResult> result = await setup.CompleteAsync(request.ClaimCode, request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||||
|
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||||
|
{
|
||||||
|
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "setup.complete", null, null, remote, false, http.RequestAborted);
|
||||||
|
return Problems.From(http, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "setup.complete", null, null, remote, true, http.RequestAborted);
|
||||||
|
Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> session = IssueSession(http, login, request.ReturnSession == true, options);
|
||||||
|
return TypedResults.Json(session.Value!, ApiJsonContext.Default.LoginResponse, statusCode: StatusCodes.Status201Created);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。</summary>
|
/// <summary>发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。</summary>
|
||||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options)
|
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options)
|
||||||
{
|
{
|
||||||
@@ -179,20 +197,8 @@ public static class AuthEndpoints
|
|||||||
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto);
|
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>已经有 passkey 的用户再加一个,要先用已有的触碰一次;第一个 passkey(还没有可触碰的)不要求。</summary>
|
|
||||||
private static async Task<IResult?> CredentialGateAsync(HttpContext http, PasskeyService passkeys, bool consume)
|
|
||||||
{
|
|
||||||
string? userId = http.GetPrincipal().UserId;
|
|
||||||
return userId is null || (await passkeys.ListAsync(userId, http.RequestAborted)).Count == 0 ? null : ElevationGate.Require(http, ElevationClass.Credential, consume);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
|
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
|
||||||
{
|
{
|
||||||
if (await CredentialGateAsync(http, passkeys, consume: false) is { } needed)
|
|
||||||
{
|
|
||||||
return needed;
|
|
||||||
}
|
|
||||||
|
|
||||||
Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
|
Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
|
||||||
if (!result.TryGet(out CreationOptions? o, out FleetError? error))
|
if (!result.TryGet(out CreationOptions? o, out FleetError? error))
|
||||||
{
|
{
|
||||||
@@ -205,11 +211,6 @@ public static class AuthEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options)
|
private static async Task<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options)
|
||||||
{
|
{
|
||||||
if (await CredentialGateAsync(http, passkeys, consume: true) is { } needed)
|
|
||||||
{
|
|
||||||
return needed;
|
|
||||||
}
|
|
||||||
|
|
||||||
Result<PasskeyRecord> result = await passkeys.RegisterAsync(
|
Result<PasskeyRecord> result = await passkeys.RegisterAsync(
|
||||||
http.GetPrincipal(), new PasskeyRegistration(request.Name ?? "", request.CredentialId ?? "", request.AttestationObject ?? "", request.ClientDataJson ?? "", request.Transports), PasskeyContextOf(http, options), http.RequestAborted);
|
http.GetPrincipal(), new PasskeyRegistration(request.Name ?? "", request.CredentialId ?? "", request.AttestationObject ?? "", request.ClientDataJson ?? "", request.Transports), PasskeyContextOf(http, options), http.RequestAborted);
|
||||||
return result.TryGet(out PasskeyRecord? record, out FleetError? error)
|
return result.TryGet(out PasskeyRecord? record, out FleetError? error)
|
||||||
@@ -219,89 +220,13 @@ public static class AuthEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id)
|
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id)
|
||||||
{
|
{
|
||||||
if (ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
|
||||||
{
|
|
||||||
return needed;
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
|
return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
|
||||||
? TypedResults.NoContent()
|
? TypedResults.NoContent()
|
||||||
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey");
|
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<PageDto<ElevationDto>> ListElevation(HttpContext http, ElevationService elevation)
|
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||||
{
|
{
|
||||||
IReadOnlyList<ElevationWindow> open = http.GetPrincipal().SessionId is { } session ? elevation.Active(session) : [];
|
|
||||||
return TypedResults.Json(new PageDto<ElevationDto>([.. open.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoElevationDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ElevationOptionsAsync(HttpContext http, ElevationRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
|
|
||||||
{
|
|
||||||
IResult limit = Limited(http, limiter, out bool ok, "elevation-options:", limit: PasskeyOptionsPerMinute);
|
|
||||||
if (!ok)
|
|
||||||
{
|
|
||||||
return limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Elevation.TryParse(request.Class, out ElevationClass elevation))
|
|
||||||
{
|
|
||||||
return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Result<RequestOptions> result = await passkeys.BeginElevationAsync(http.GetPrincipal(), elevation, PasskeyContextOf(http, options), http.RequestAborted);
|
|
||||||
if (!result.TryGet(out RequestOptions? o, out FleetError? error))
|
|
||||||
{
|
|
||||||
return Problems.From(http, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Response.Headers.CacheControl = "no-store";
|
|
||||||
return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs, o.AllowCredentials), ApiJsonContext.Default.PasskeyRequestOptionsDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ElevateAsync(HttpContext http, ElevationAssertionRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
|
||||||
{
|
|
||||||
IResult limit = Limited(http, limiter, out bool ok, "elevation:");
|
|
||||||
if (!ok)
|
|
||||||
{
|
|
||||||
return limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Elevation.TryParse(request.Class, out ElevationClass elevation))
|
|
||||||
{
|
|
||||||
return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Principal principal = http.GetPrincipal();
|
|
||||||
Result<ElevationWindow> result = await passkeys.ElevateAsync(
|
|
||||||
principal, elevation, new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
|
|
||||||
if (!result.TryGet(out ElevationWindow? window, out FleetError? error))
|
|
||||||
{
|
|
||||||
await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.ClientAddress(), false, http.RequestAborted);
|
|
||||||
return Problems.From(http, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
http.SetDetail(Elevation.IdOf(elevation));
|
|
||||||
http.Response.Headers.CacheControl = "no-store";
|
|
||||||
return TypedResults.Json(Mappers.ToDto(window), ApiJsonContext.Default.ElevationDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Microsoft.AspNetCore.Http.HttpResults.NoContent EndElevation(HttpContext http, ElevationService elevation)
|
|
||||||
{
|
|
||||||
if (http.GetPrincipal().SessionId is { } session)
|
|
||||||
{
|
|
||||||
elevation.End(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
return TypedResults.NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth, ElevationService elevation)
|
|
||||||
{
|
|
||||||
if (http.GetPrincipal().SessionId is { } own)
|
|
||||||
{
|
|
||||||
elevation.End(own);
|
|
||||||
}
|
|
||||||
|
|
||||||
string? bearer = PrincipalResolver.BearerOf(http);
|
string? bearer = PrincipalResolver.BearerOf(http);
|
||||||
if (bearer is not null)
|
if (bearer is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ public static class BuildEndpoints
|
|||||||
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
|
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
|
||||||
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
|
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
|
||||||
.Requires(Capability.UpdateConfirm);
|
.Requires(Capability.UpdateConfirm);
|
||||||
|
group.MapDelete("/nodes/{id}/binary", ClearBinaryAsync)
|
||||||
|
.Api("nodes_binary_clear", "Accept the node's current binary: clear its binding so the next handshake binds it again", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.FleetWrite);
|
||||||
group.MapGet("/backend/update", BackendUpdateAsync)
|
group.MapGet("/backend/update", BackendUpdateAsync)
|
||||||
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
|
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
|
||||||
.Requires(Capability.UpdateRead);
|
.Requires(Capability.UpdateRead);
|
||||||
@@ -45,12 +48,39 @@ public static class BuildEndpoints
|
|||||||
.Requires(Capability.ReleasePublish);
|
.Requires(Capability.ReleasePublish);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct)
|
/// <summary>
|
||||||
|
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
|
||||||
|
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
|
||||||
|
/// 旧清单重放由 <c>seq</c> 递增挡住,节点的更新器还会用本机公钥再验一次。
|
||||||
|
/// </summary>
|
||||||
|
public static void MapOffer(IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
|
app.MapPost("/api/release/offer", OfferReleaseAsync)
|
||||||
|
.Api("release_offer", "Register a release manifest that carries a valid CI signature (no token needed)", "release")
|
||||||
|
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request)
|
||||||
|
{
|
||||||
|
string manifest = request.Manifest ?? "";
|
||||||
|
string signature = request.Signature ?? "";
|
||||||
|
if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes
|
||||||
|
|| !ReleaseSignature.Verify(InstallScripts.ReleaseKey(), manifest, signature))
|
||||||
|
{
|
||||||
|
return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await PublishReleaseAsync(http, releases, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(ct);
|
||||||
|
Dictionary<string, NodeBinding> bound = (await bindings.ListAsync(ct)).ToDictionary(b => b.NodeId, StringComparer.Ordinal);
|
||||||
var items = (await nodes.ListAsync(ct))
|
var items = (await nodes.ListAsync(ct))
|
||||||
.OrderBy(n => n.Kind, StringComparer.Ordinal)
|
.OrderBy(n => n.Kind, StringComparer.Ordinal)
|
||||||
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
|
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
|
||||||
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId)))))
|
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null, ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
|
||||||
.ToList();
|
.ToList();
|
||||||
http.Response.Headers.CacheControl = "no-store";
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
|
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
|
||||||
@@ -108,6 +138,17 @@ public static class BuildEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static NodeBinaryDto ToDto(NodeBinding b) =>
|
||||||
|
new(b.Sha256, b.Signed, b.Seq, DateTimeOffset.FromUnixTimeMilliseconds(b.BoundAtMs), b.Violation, b.ViolationAtMs is { } at ? DateTimeOffset.FromUnixTimeMilliseconds(at) : null);
|
||||||
|
|
||||||
|
/// <summary>管理员确认新的二进制是自己要的:清除绑定,节点下一次握手重新绑定(没有绑定可清也返回 204,幂等)。</summary>
|
||||||
|
private static async Task<IResult> ClearBinaryAsync(HttpContext http, BinaryBindingService bindings, string id)
|
||||||
|
{
|
||||||
|
http.SetDetail("binary binding cleared");
|
||||||
|
await bindings.ClearAsync(id, http.RequestAborted);
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
private static NodeUpdateDto? ToDto(UpdateStatus? status) =>
|
private static NodeUpdateDto? ToDto(UpdateStatus? status) =>
|
||||||
status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null);
|
status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null);
|
||||||
}
|
}
|
||||||
@@ -64,11 +64,6 @@ public static class SystemEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
||||||
{
|
{
|
||||||
if (CapabilityCatalog.Expand(request.Capabilities ?? []).Any(c => CapabilityCatalog.Find(c)?.Trust == true) && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
|
||||||
{
|
|
||||||
return needed; // 带信任类能力的 API key 等于把信任操作交给一份密钥:创建它要用 passkey 签一次
|
|
||||||
}
|
|
||||||
|
|
||||||
http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
|
http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
|
||||||
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted);
|
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted);
|
||||||
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
|
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ public static class TrustEndpoints
|
|||||||
.Returns<TrustStateDto>().ProducesProblems(401, 403)
|
.Returns<TrustStateDto>().ProducesProblems(401, 403)
|
||||||
.Requires(Capability.FleetRead);
|
.Requires(Capability.FleetRead);
|
||||||
guarded.MapPost("/trust", CommitAsync)
|
guarded.MapPost("/trust", CommitAsync)
|
||||||
.Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)", Tag)
|
.Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis)", Tag)
|
||||||
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||||
.Requires(Capability.AccountsManage)
|
.Requires(Capability.AccountsManage);
|
||||||
.ElevationExempt();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
|
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
|
||||||
@@ -60,12 +59,6 @@ public static class TrustEndpoints
|
|||||||
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
||||||
{
|
{
|
||||||
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options);
|
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options);
|
||||||
// 第一次(创世)是整个信任根的起点:除了 passkey 签名,还要凭证窗口,避免只拿到一个密码会话的人抢先立根
|
|
||||||
if ((await trust.StateAsync(context, http.RequestAborted)).Initialized is false && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
|
||||||
{
|
|
||||||
return needed;
|
|
||||||
}
|
|
||||||
|
|
||||||
Result<TrustState> result = await trust.CommitAsync(signed, context, http.RequestAborted);
|
Result<TrustState> result = await trust.CommitAsync(signed, context, http.RequestAborted);
|
||||||
if (!result.TryGet(out TrustState? state, out FleetError? error))
|
if (!result.TryGet(out TrustState? state, out FleetError? error))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public static class ApiModule
|
|||||||
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
|
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<SseLimiter>();
|
services.AddSingleton<SseLimiter>();
|
||||||
|
services.AddSingleton<MoShiro.Backend.Application.IReleaseKey, EmbeddedReleaseKey>();
|
||||||
services.AddSingleton<ClientAddressResolver>();
|
services.AddSingleton<ClientAddressResolver>();
|
||||||
services.Configure<JsonOptions>(options =>
|
services.Configure<JsonOptions>(options =>
|
||||||
{
|
{
|
||||||
@@ -118,7 +119,9 @@ public static class ApiModule
|
|||||||
LinkEndpoints.Map(guarded);
|
LinkEndpoints.Map(guarded);
|
||||||
|
|
||||||
OpsEndpoints.Map(app);
|
OpsEndpoints.Map(app);
|
||||||
|
BuildEndpoints.MapOffer(app);
|
||||||
app.MapOpenApi("/openapi/v1.json");
|
app.MapOpenApi("/openapi/v1.json");
|
||||||
|
app.MapStaticSite();
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,6 @@ public static class EndpointConventions
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 这个端点自己要求 passkey 签名(请求里带着断言),比提权窗口更强,所以不再要求窗口:守卫跳过信任类能力的提权检查。
|
|
||||||
/// </summary>
|
|
||||||
public static RouteHandlerBuilder ElevationExempt(this RouteHandlerBuilder builder) => builder.WithMetadata(new ElevationExemptMarker());
|
|
||||||
|
|
||||||
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
|
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
|
||||||
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
|
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
|
||||||
builder.WithMetadata(new CapabilityRequirement(null));
|
builder.WithMetadata(new CapabilityRequirement(null));
|
||||||
|
|||||||
@@ -60,9 +60,6 @@ public static class PrincipalResolver
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record CapabilityRequirement(string? Capability);
|
public sealed record CapabilityRequirement(string? Capability);
|
||||||
|
|
||||||
/// <summary>端点自己验 passkey 签名(见 <see cref="EndpointConventions.ElevationExempt"/>)。</summary>
|
|
||||||
public sealed record ElevationExemptMarker;
|
|
||||||
|
|
||||||
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||||
public static class CapabilityGuard
|
public static class CapabilityGuard
|
||||||
{
|
{
|
||||||
@@ -92,12 +89,6 @@ public static class CapabilityGuard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;API key 自己带着能力,不经过这里
|
|
||||||
if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && http.GetEndpoint()?.Metadata.GetMetadata<ElevationExemptMarker>() is null && ElevationGate.Require(http, elevation, consume: true) is { } needed)
|
|
||||||
{
|
|
||||||
return needed;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
|
if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
|
||||||
{
|
{
|
||||||
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
|
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
|
||||||
@@ -120,24 +111,6 @@ public static class CapabilityGuard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>提权检查:登录用户(含用 return_session 拿到的 Bearer 会话)要有对应种类的有效窗口,API key 不需要。</summary>
|
|
||||||
public static class ElevationGate
|
|
||||||
{
|
|
||||||
/// <summary>没问题返回 null;否则 403 <c>elevation_required</c>,detail 是缺的窗口种类,前端据此触碰 passkey 后重试。</summary>
|
|
||||||
public static IResult? Require(HttpContext http, ElevationClass elevation, bool consume)
|
|
||||||
{
|
|
||||||
Principal principal = http.GetPrincipal();
|
|
||||||
if (principal.UserId is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return principal.SessionId is { } session && http.RequestServices.GetRequiredService<ElevationService>().Check(session, elevation, consume)
|
|
||||||
? null
|
|
||||||
: Problems.Create(http, MoShiroErrors.ElevationRequired, Elevation.IdOf(elevation));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
||||||
public static class PublicGuard
|
public static class PublicGuard
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -156,9 +156,8 @@ public static class Mappers
|
|||||||
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
||||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
|
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
|
||||||
|
|
||||||
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
|
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed, [.. (s.OperatorDetails ?? []).Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))]);
|
||||||
|
|
||||||
public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs));
|
|
||||||
|
|
||||||
public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success);
|
public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success);
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ public static class ErrorCodeHttpMap
|
|||||||
|
|
||||||
Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams);
|
Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams);
|
||||||
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized);
|
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized);
|
||||||
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, MoShiroErrors.ElevationRequired, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
||||||
Add(
|
Add(
|
||||||
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
|
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
|
||||||
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound);
|
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound);
|
||||||
|
|||||||
@@ -10,16 +10,10 @@ public sealed record LoginRequest(string Username, string Password, bool? Return
|
|||||||
public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
|
public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
|
||||||
|
|
||||||
/// <summary>浏览器 <c>navigator.credentials.get</c> 要的参数(可发现凭证:不需要先输用户名)。</summary>
|
/// <summary>浏览器 <c>navigator.credentials.get</c> 要的参数(可发现凭证:不需要先输用户名)。</summary>
|
||||||
/// <param name="AllowCredentials">只有提权的挑战会带:这个用户自己的 passkey 的凭证 id。</param>
|
/// <param name="AllowCredentials">列出时限定可选的 passkey(凭证 id);登录的挑战不带。</param>
|
||||||
public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs, IReadOnlyList<string>? AllowCredentials = null);
|
public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs, IReadOnlyList<string>? AllowCredentials = null);
|
||||||
|
|
||||||
/// <param name="Class"><c>enroll</c>(加节点)、<c>revoke</c>(吊销和删除)、<c>credential</c>(改凭证,用一次就没有)。</param>
|
/// <param name="Class"><c>enroll</c>(加节点)、<c>revoke</c>(吊销和删除)、<c>credential</c>(改凭证,用一次就没有)。</param>
|
||||||
public sealed record ElevationRequest(string Class);
|
|
||||||
|
|
||||||
public sealed record ElevationAssertionRequest(string Class, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
|
||||||
|
|
||||||
public sealed record ElevationDto(string Class, DateTimeOffset ExpiresAt);
|
|
||||||
|
|
||||||
public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
|
public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
|
||||||
|
|
||||||
public sealed record PasskeyLoginRequest(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature, bool? ReturnSession);
|
public sealed record PasskeyLoginRequest(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature, bool? ReturnSession);
|
||||||
@@ -28,7 +22,7 @@ public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt
|
|||||||
|
|
||||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||||
|
|
||||||
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c>(没有任何能力,只能注册 passkey)或 <c>passkey</c>;API 令牌为空。</param>
|
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c> 或 <c>passkey</c>(能力相同,信任类操作都要提权窗口);API 令牌为空。</param>
|
||||||
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null);
|
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null);
|
||||||
|
|
||||||
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
||||||
@@ -81,17 +75,30 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string>
|
|||||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
|
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
|
||||||
|
|
||||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||||
|
/// <summary>面板是不是还没有任何用户(需要首次初始化)。</summary>
|
||||||
|
public sealed record SetupStatusDto(bool NeedsSetup);
|
||||||
|
|
||||||
|
/// <param name="ClaimCode">首次启动时写在后端日志里的认领码(或者部署时指定的 <c>MoShiro__ClaimCode</c>)。</param>
|
||||||
|
public sealed record SetupRequest(string? ClaimCode, string? Username, string? Password, bool? ReturnSession);
|
||||||
|
|
||||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||||
|
|
||||||
public sealed record TrustProposalDto(string Statement, int Version, string Challenge);
|
/// <summary>下一版里新增的 passkey。</summary>
|
||||||
|
public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
/// <param name="Added">相对当前生效的集合新增的 passkey;签名之前必须看一眼是不是自己加的。</param>
|
||||||
|
/// <param name="Removed">相对当前生效的集合移除的 passkey 凭证 id。</param>
|
||||||
|
/// <param name="ServerKeyChanged">服务器密钥有变化(换了新的)。</param>
|
||||||
|
public sealed record TrustProposalDto(string Statement, int Version, string Challenge, IReadOnlyList<ProposedCredentialDto> Added, IReadOnlyList<string> Removed, bool ServerKeyChanged);
|
||||||
|
|
||||||
/// <param name="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
|
/// <param name="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
|
||||||
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
||||||
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
|
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
|
||||||
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
|
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
|
||||||
|
/// <param name="OperatorDetails">每个操作员是哪个账户的哪把 passkey(对不上已注册的 passkey 时名称和账户为空)。</param>
|
||||||
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param>
|
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param>
|
||||||
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param>
|
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param>
|
||||||
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false);
|
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwnerDto>? OperatorDetails = null);
|
||||||
|
|
||||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||||
|
|
||||||
@@ -178,7 +185,13 @@ public sealed record ChangePasswordRequest(string Password);
|
|||||||
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
|
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
|
||||||
|
|
||||||
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
|
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
|
||||||
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null);
|
/// <summary>节点二进制的绑定:第一次握手成功时绑定的 SHA-256。<c>Signed</c> 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);<c>Violation</c> 是最近一次不符的原因,下一次握手符合了就清掉。</summary>
|
||||||
|
public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt);
|
||||||
|
|
||||||
|
/// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
|
||||||
|
public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account);
|
||||||
|
|
||||||
|
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null, IReadOnlyList<NodeOwnerDto>? Owners = null);
|
||||||
|
|
||||||
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
|
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
|
||||||
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
|
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ namespace MoShiro.Backend.Api;
|
|||||||
[JsonSerializable(typeof(PublishReleaseRequest))]
|
[JsonSerializable(typeof(PublishReleaseRequest))]
|
||||||
[JsonSerializable(typeof(AboutDto))]
|
[JsonSerializable(typeof(AboutDto))]
|
||||||
[JsonSerializable(typeof(NodeBuildDto))]
|
[JsonSerializable(typeof(NodeBuildDto))]
|
||||||
|
[JsonSerializable(typeof(NodeBinaryDto))]
|
||||||
|
[JsonSerializable(typeof(NodeOwnerDto))]
|
||||||
[JsonSerializable(typeof(BuildInfoDto))]
|
[JsonSerializable(typeof(BuildInfoDto))]
|
||||||
[JsonSerializable(typeof(BackendUpdateDto))]
|
[JsonSerializable(typeof(BackendUpdateDto))]
|
||||||
[JsonSerializable(typeof(ConfirmBackendUpdateRequest))]
|
[JsonSerializable(typeof(ConfirmBackendUpdateRequest))]
|
||||||
@@ -81,10 +83,9 @@ namespace MoShiro.Backend.Api;
|
|||||||
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||||
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
||||||
[JsonSerializable(typeof(TrustStateDto))]
|
[JsonSerializable(typeof(TrustStateDto))]
|
||||||
[JsonSerializable(typeof(ElevationRequest))]
|
[JsonSerializable(typeof(SetupStatusDto))]
|
||||||
[JsonSerializable(typeof(ElevationAssertionRequest))]
|
[JsonSerializable(typeof(SetupRequest))]
|
||||||
[JsonSerializable(typeof(ElevationDto))]
|
[JsonSerializable(typeof(ProposedCredentialDto))]
|
||||||
[JsonSerializable(typeof(PageDto<ElevationDto>))]
|
|
||||||
[JsonSerializable(typeof(PasskeyDto))]
|
[JsonSerializable(typeof(PasskeyDto))]
|
||||||
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||||
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
|
/// <summary>内嵌在程序里的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)。</summary>
|
||||||
|
public sealed class EmbeddedReleaseKey : IReleaseKey
|
||||||
|
{
|
||||||
|
public string Pem => InstallScripts.ReleaseKey();
|
||||||
|
}
|
||||||
@@ -11,4 +11,11 @@
|
|||||||
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
||||||
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
|
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
|
||||||
|
<ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')">
|
||||||
|
<EmbeddedResource Include="..\..\..\web\display\dist\**\*" Exclude="..\..\..\web\display\dist\**\*.map" LogicalName="www/display/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup Condition="Exists('..\..\..\web\admin\dist\index.html')">
|
||||||
|
<EmbeddedResource Include="..\..\..\web\admin\dist\**\*" Exclude="..\..\..\web\admin\dist\**\*.map" LogicalName="www/admin/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -42,7 +42,7 @@ public static class OpsEndpoints
|
|||||||
{
|
{
|
||||||
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||||
InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted);
|
InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted);
|
||||||
ReleaseOffer? offer = target is null ? null : await releases.GetAsync(target.Role, http.RequestAborted);
|
ReleaseOffer? offer = target is null ? null : await releases.GetOrFetchAsync(target.Role, http.RequestAborted);
|
||||||
if (offer is null)
|
if (offer is null)
|
||||||
{
|
{
|
||||||
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
|
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
|
/// <summary>一个可提供的前端文件。<c>Html</c> 的页面带 CSP;其余带最严格的 CSP,免得直接打开 SVG 时执行脚本。</summary>
|
||||||
|
public sealed record SiteAsset(byte[] Content, string ContentType, string ETag, string CacheControl, bool Html);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析的结果:<c>Asset</c> 是要返回的文件;<c>Redirect</c> 是要跳转的地址(只会是站内固定的路径,不来自请求);都为空就是 404。
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct SiteResolution(SiteAsset? Asset, string? Redirect);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内嵌在 Backend 里的前端:展示端在 <c>/</c>,管理端在 <c>/admin/</c>。
|
||||||
|
/// <para>
|
||||||
|
/// 安全做法(线上常见的静态文件穿越和 SPA 回退问题):
|
||||||
|
/// 文件全部是启动时从程序集资源读进内存的<b>白名单</b>,请求时<b>不碰文件系统</b>,所以没有路径穿越和符号链接的问题;
|
||||||
|
/// 路径里出现 <c>..</c>、<c>.</c>、反斜杠、<c>%</c>(没解码干净的编码)、控制字符、空段(<c>//</c>)、过长,一律 404,不去「规范化」它;
|
||||||
|
/// SPA 回退只给没有扩展名的路径,缺失的文件(<c>/assets/x.js</c>)返回 404 而不是 HTML;
|
||||||
|
/// <c>/api</c> 等后端自己的路径由调用方先排除,所以回退不会吞掉 API 的 404。
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StaticSite
|
||||||
|
{
|
||||||
|
public const string AdminPrefix = "/admin/";
|
||||||
|
private const int MaxPathLength = 1024;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, SiteAsset> _files = new(StringComparer.Ordinal);
|
||||||
|
private readonly SiteAsset? _displayIndex;
|
||||||
|
private readonly SiteAsset? _adminIndex;
|
||||||
|
|
||||||
|
/// <param name="files">(站内路径,内容);站内路径以 <c>/</c> 开头,管理端的以 <c>/admin/</c> 开头。</param>
|
||||||
|
public StaticSite(IEnumerable<(string Path, byte[] Content)> files)
|
||||||
|
{
|
||||||
|
foreach ((string path, byte[] content) in files)
|
||||||
|
{
|
||||||
|
if (!IsCleanPath(path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_files[path] = Describe(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
_displayIndex = _files.GetValueOrDefault("/index.html");
|
||||||
|
_adminIndex = _files.GetValueOrDefault(AdminPrefix + "index.html");
|
||||||
|
if (_displayIndex is not null)
|
||||||
|
{
|
||||||
|
_files["/"] = _displayIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_adminIndex is not null)
|
||||||
|
{
|
||||||
|
_files[AdminPrefix] = _adminIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Count => _files.Count;
|
||||||
|
|
||||||
|
/// <summary>从程序集的嵌入资源读取:<c>www/display/…</c> 对应 <c>/…</c>,<c>www/admin/…</c> 对应 <c>/admin/…</c>。没有嵌入前端(本地没构建)时是空站点。</summary>
|
||||||
|
public static StaticSite FromAssembly(Assembly assembly)
|
||||||
|
{
|
||||||
|
var files = new List<(string, byte[])>();
|
||||||
|
foreach (string name in assembly.GetManifestResourceNames())
|
||||||
|
{
|
||||||
|
string? url = name.StartsWith("www/display/", StringComparison.Ordinal) ? "/" + name["www/display/".Length..]
|
||||||
|
: name.StartsWith("www/admin/", StringComparison.Ordinal) ? AdminPrefix + name["www/admin/".Length..]
|
||||||
|
: null;
|
||||||
|
if (url is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
using Stream stream = assembly.GetManifestResourceStream(name)!;
|
||||||
|
using var memory = new MemoryStream();
|
||||||
|
stream.CopyTo(memory);
|
||||||
|
files.Add((url.Replace('\\', '/'), memory.ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StaticSite(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SiteResolution Resolve(string? path)
|
||||||
|
{
|
||||||
|
if (!IsCleanPath(path))
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_files.TryGetValue(path, out SiteAsset? exact))
|
||||||
|
{
|
||||||
|
return new SiteResolution(exact, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path == "/admin")
|
||||||
|
{
|
||||||
|
return _adminIndex is null ? default : new SiteResolution(null, AdminPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缺失的文件不回退成 HTML(否则脚本和样式 404 变成「HTML 当脚本」,很难排查,也可能被缓存污染)
|
||||||
|
string last = path[(path.LastIndexOf('/') + 1)..];
|
||||||
|
if (last.Contains('.', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
SiteAsset? index = path.StartsWith(AdminPrefix, StringComparison.Ordinal) ? _adminIndex : _displayIndex;
|
||||||
|
return index is null ? default : new SiteResolution(index, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 路径是不是「干净」的:以 <c>/</c> 开头、不过长、没有反斜杠、<c>%</c>、控制字符、<c>.</c> / <c>..</c> 段和中间的空段。
|
||||||
|
/// 不「规范化」,而是直接拒绝——规范化正是各种穿越绕过(<c>%2e%2e</c>、<c>..%5c</c>、双重编码)的来源。
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsCleanPath([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path) || path[0] != '/' || path.Length > MaxPathLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (char c in path)
|
||||||
|
{
|
||||||
|
if (c is '\\' or '%' or '\0' || c < 0x20 || c == 0x7f || c > 0x7e)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] segments = path.Split('/');
|
||||||
|
for (int i = 1; i < segments.Length; i++)
|
||||||
|
{
|
||||||
|
string segment = segments[i];
|
||||||
|
bool trailing = i == segments.Length - 1;
|
||||||
|
if (segment is "." or ".." || (segment.Length == 0 && !trailing))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteAsset Describe(string path, byte[] content)
|
||||||
|
{
|
||||||
|
string extension = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
bool html = extension == ".html";
|
||||||
|
string etag = "\"" + Convert.ToHexStringLower(SHA256.HashData(content))[..32] + "\"";
|
||||||
|
string cache = html ? "no-cache" : path.Contains("/assets/", StringComparison.Ordinal) ? "public, max-age=31536000, immutable" : "public, max-age=3600";
|
||||||
|
return new SiteAsset(content, ContentTypeOf(extension), etag, cache, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ContentTypeOf(string extension) => extension switch
|
||||||
|
{
|
||||||
|
".html" => "text/html; charset=utf-8",
|
||||||
|
".js" or ".mjs" => "text/javascript; charset=utf-8",
|
||||||
|
".css" => "text/css; charset=utf-8",
|
||||||
|
".svg" => "image/svg+xml",
|
||||||
|
".png" => "image/png",
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".ico" => "image/x-icon",
|
||||||
|
".woff2" => "font/woff2",
|
||||||
|
".woff" => "font/woff",
|
||||||
|
".json" => "application/json",
|
||||||
|
".webmanifest" => "application/manifest+json",
|
||||||
|
".txt" => "text/plain; charset=utf-8",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
|
/// <summary>把内嵌前端挂到根路径:只响应 GET 和 HEAD,最低优先级,不覆盖任何后端自己的路径。</summary>
|
||||||
|
public static class StaticSiteEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>后端自己的路径前缀:这些路径没有匹配到端点时要得到后端的 404(JSON),不能被前端的 HTML 回退吞掉。</summary>
|
||||||
|
private static readonly string[] s_reserved = ["/api", "/openapi", "/install", "/healthz"];
|
||||||
|
|
||||||
|
// 页面的 CSP:脚本只许同源;样式允许内联(React 的 style 属性);连接允许 https(管理端可以添加别的后端作为数据源)和本机 http
|
||||||
|
private const string PageCsp =
|
||||||
|
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; "
|
||||||
|
+ "connect-src 'self' https: http://localhost:* http://127.0.0.1:*; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
|
||||||
|
|
||||||
|
private const string AssetCsp = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapStaticSite(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
StaticSite site = StaticSite.FromAssembly(typeof(StaticSite).Assembly);
|
||||||
|
app.MapMethods("/{**path}", ["GET", "HEAD"], (HttpContext http) => Serve(site, http)).ExcludeFromDescription();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IResult Serve(StaticSite site, HttpContext http)
|
||||||
|
{
|
||||||
|
string path = http.Request.Path.Value ?? "/";
|
||||||
|
if (IsReserved(path))
|
||||||
|
{
|
||||||
|
return Results.StatusCode(StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
SiteResolution resolution = site.Resolve(path);
|
||||||
|
if (resolution.Redirect is { } location)
|
||||||
|
{
|
||||||
|
// 固定的站内路径,不拼请求里的任何东西(没有开放重定向,也不依赖 Host)
|
||||||
|
return Results.Redirect(location, permanent: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolution.Asset is not { } asset)
|
||||||
|
{
|
||||||
|
return Results.StatusCode(StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
IHeaderDictionary headers = http.Response.Headers;
|
||||||
|
headers.XContentTypeOptions = "nosniff";
|
||||||
|
headers["Referrer-Policy"] = "no-referrer";
|
||||||
|
headers.ETag = asset.ETag;
|
||||||
|
headers.CacheControl = asset.CacheControl;
|
||||||
|
headers.ContentSecurityPolicy = asset.Html ? PageCsp : AssetCsp;
|
||||||
|
if (asset.Html)
|
||||||
|
{
|
||||||
|
headers.XFrameOptions = "DENY";
|
||||||
|
headers["Permissions-Policy"] = "publickey-credentials-get=(self), publickey-credentials-create=(self), camera=(), microphone=(), geolocation=()";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (http.Request.Headers.IfNoneMatch.Contains(asset.ETag))
|
||||||
|
{
|
||||||
|
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Bytes(asset.Content, asset.ContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsReserved(string path)
|
||||||
|
{
|
||||||
|
foreach (string prefix in s_reserved)
|
||||||
|
{
|
||||||
|
if (path.StartsWith(prefix, StringComparison.Ordinal) && (path.Length == prefix.Length || path[prefix.Length] == '/' || path[prefix.Length] == '.'))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Fleet.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点的属主:创建加入码时选定的 passkey 凭证 id,写进安装命令,节点注册时固定在本地。
|
||||||
|
/// 属主必须是操作员集合里的凭证;<see cref="BackendOptions.RequireOwner"/> 打开时(默认)必须已经有信任根,并且至少选一把——
|
||||||
|
/// 不选属主的节点没有人能授权它的敏感操作,也就不该存在。没有明确指定时默认是操作员集合里的全部 passkey。
|
||||||
|
/// </summary>
|
||||||
|
public static class OwnerPolicy
|
||||||
|
{
|
||||||
|
public static Result<string[]> Resolve(bool required, OperatorSet? set, IReadOnlyList<string>? requested)
|
||||||
|
{
|
||||||
|
if (set is null)
|
||||||
|
{
|
||||||
|
return required
|
||||||
|
? ValidationError.Fail<string[]>("controllers", "set up the trust root first (System, Trust): every node needs an owner passkey")
|
||||||
|
: Result<string[]>.Ok([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] chosen = requested is null ? [.. set.Keys.Select(k => k.CredentialId)] : [.. requested.Distinct(StringComparer.Ordinal)];
|
||||||
|
if (chosen.Any(c => set.Keys.All(k => k.CredentialId != c)))
|
||||||
|
{
|
||||||
|
return ValidationError.Fail<string[]>("controllers", "must be passkeys of the operator set");
|
||||||
|
}
|
||||||
|
|
||||||
|
return required && chosen.Length == 0
|
||||||
|
? ValidationError.Fail<string[]>("controllers", "pick at least one owner passkey: a node's owner is who its sensitive sessions and data are bound to")
|
||||||
|
: Result<string[]>.Ok(chosen);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,8 +26,13 @@ public sealed partial class AgentService
|
|||||||
private readonly LinkService _links;
|
private readonly LinkService _links;
|
||||||
private readonly TrustService _trust;
|
private readonly TrustService _trust;
|
||||||
|
|
||||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
|
private readonly BinaryBindingService _bindings;
|
||||||
|
private readonly NodeOwnerService _owners;
|
||||||
|
|
||||||
|
public AgentService(BinaryBindingService bindings, NodeOwnerService owners, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
|
||||||
{
|
{
|
||||||
|
_bindings = bindings;
|
||||||
|
_owners = owners;
|
||||||
_trust = trust;
|
_trust = trust;
|
||||||
_links = links;
|
_links = links;
|
||||||
_nodes = nodes;
|
_nodes = nodes;
|
||||||
@@ -147,6 +152,8 @@ public sealed partial class AgentService
|
|||||||
await _gateway.DisconnectAsync(id, "deleted", ct);
|
await _gateway.DisconnectAsync(id, "deleted", ct);
|
||||||
await _agents.DeleteAsync(id, ct);
|
await _agents.DeleteAsync(id, ct);
|
||||||
await _nodes.ForgetAsync(id.Value, ct);
|
await _nodes.ForgetAsync(id.Value, ct);
|
||||||
|
await _bindings.ForgetAsync(id.Value, ct);
|
||||||
|
_owners.Forget(id.Value);
|
||||||
await _links.ForgetNodeAsync(id.Value, ct);
|
await _links.ForgetNodeAsync(id.Value, ct);
|
||||||
_runtime.Remove(id);
|
_runtime.Remove(id);
|
||||||
await AfterAgentSetChangedAsync(ct);
|
await AfterAgentSetChangedAsync(ct);
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Fleet.Core;
|
||||||
|
using Fleet.Server;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>CI 发布公钥(PEM):验节点握手时带来的签名清单。由 Api 层提供(它就是内嵌在程序里的那一把)。</summary>
|
||||||
|
public interface IReleaseKey
|
||||||
|
{
|
||||||
|
string Pem { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点二进制的绑定:节点第一次握手成功的那一刻,把它正在运行的二进制的 SHA-256 和这个节点绑在一起;之后哈希对不上,就不让它继续运行。
|
||||||
|
/// <para>
|
||||||
|
/// 合法的升级:新哈希出现在一份 <b>CI 签名的清单</b>里(节点握手时带着宿主机更新器验过的清单,这里用内嵌的 CI 公钥再验一遍,
|
||||||
|
/// 所以不依赖面板有没有收到过这次发布),并且清单的 seq 不低于绑定时的——不许回退到更旧的版本。其余情况(文件被换掉、降级、没人签过的二进制、
|
||||||
|
/// 手工替换了二进制的非容器节点)都算不符;管理员确认过之后可以清除绑定,下一次握手重新绑定。
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 边界:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希;这一层抓的是文件被换、版本被改、降级和起了不对的二进制。
|
||||||
|
/// 容器节点要堵住撒谎,需要宿主机更新器背书(容器之外),见 docs/access.md。
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BinaryBindingService : IBinaryAttestor
|
||||||
|
{
|
||||||
|
public const string Enforce = "enforce";
|
||||||
|
public const string Warn = "warn";
|
||||||
|
public const string Off = "off";
|
||||||
|
|
||||||
|
private readonly INodeBindingStore _store;
|
||||||
|
private readonly ISettingsStore _settings;
|
||||||
|
private readonly IReleaseKey _key;
|
||||||
|
private readonly BackendOptions _options;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
|
||||||
|
public BinaryBindingService(INodeBindingStore store, ISettingsStore settings, IReleaseKey key, BackendOptions options, TimeProvider time)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_store = store;
|
||||||
|
_key = key;
|
||||||
|
_options = options;
|
||||||
|
_time = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||||
|
|
||||||
|
/// <summary>管理员确认新的二进制是自己要的(比如手工升级了非容器节点):清除绑定,下一次握手重新绑定。</summary>
|
||||||
|
public ValueTask<bool> ClearAsync(string nodeId, CancellationToken ct) => _store.DeleteAsync(nodeId, ct);
|
||||||
|
|
||||||
|
/// <summary>节点删除之后顺手清掉它的绑定。</summary>
|
||||||
|
public async ValueTask ForgetAsync(string nodeId, CancellationToken ct) => await _store.DeleteAsync(nodeId, ct);
|
||||||
|
|
||||||
|
public async ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string policy = _options.BinaryPolicy;
|
||||||
|
if (policy == Off || ReleaseService.ComponentOf(node.Id) is not { } component)
|
||||||
|
{
|
||||||
|
return new BinaryDecision(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
|
NodeBinding? binding = await _store.GetAsync(node.Id.Value, ct);
|
||||||
|
|
||||||
|
if (evidence.Sha256 is null)
|
||||||
|
{
|
||||||
|
// 旧节点不上报:还没绑定过就放行(未绑定);绑定过的不许退回去,否则换个旧版本就绕过了
|
||||||
|
return binding is null ? new BinaryDecision(true) : await ViolationAsync(binding, "the node stopped reporting its binary hash", policy, now, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
string hash = evidence.Sha256;
|
||||||
|
if (!IsHash(hash))
|
||||||
|
{
|
||||||
|
return new BinaryDecision(policy != Enforce, "the reported binary hash is malformed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节点自己带来的清单(宿主机更新器验过的那份);没有更新器的节点(一键容器安装、二进制安装)没有,就用面板自己手里的:
|
||||||
|
// 面板登记它们时已经验过 CI 签名,这里再验一遍。只有节点自带的清单才能因为「不在清单里」而被拒绝,面板手里的只用来把绑定标成已签名。
|
||||||
|
Official? official = VerifyManifest(evidence.Manifest, evidence.Signature, component);
|
||||||
|
bool carried = official is not null;
|
||||||
|
official ??= await StoredOfficialAsync(component, ct);
|
||||||
|
bool listed = official is not null && official.Binaries.Contains(hash);
|
||||||
|
|
||||||
|
if (binding is null)
|
||||||
|
{
|
||||||
|
if (carried && !listed)
|
||||||
|
{
|
||||||
|
// 带着一份有效的签名清单,自己的哈希却不在里面:它不是这份清单对应的官方二进制
|
||||||
|
return new BinaryDecision(policy != Enforce, "this binary is not in the CI-signed manifest it was installed from");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, listed ? official!.Seq : 0, listed, now, null, null), ct);
|
||||||
|
return new BinaryDecision(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.Sha256 == hash)
|
||||||
|
{
|
||||||
|
if (listed && (official!.Seq > binding.Seq || !binding.Signed || binding.Violation is not null))
|
||||||
|
{
|
||||||
|
await _store.UpsertAsync(binding with { Seq = Math.Max(binding.Seq, official!.Seq), Signed = true, Violation = null, ViolationAtMs = null }, ct);
|
||||||
|
}
|
||||||
|
else if (binding.Violation is not null)
|
||||||
|
{
|
||||||
|
await _store.UpsertAsync(binding with { Violation = null, ViolationAtMs = null }, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BinaryDecision(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 哈希变了:必须是一份不低于绑定时 seq 的 CI 签名清单里的官方二进制(合法升级),否则不符
|
||||||
|
if (listed && official!.Seq >= binding.Seq)
|
||||||
|
{
|
||||||
|
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, official.Seq, true, now, null, null), ct);
|
||||||
|
return new BinaryDecision(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ViolationAsync(binding, $"the running binary changed (bound {Short(binding.Sha256)}, now {Short(hash)}) and is not in a newer CI-signed manifest", policy, now, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<BinaryDecision> ViolationAsync(NodeBinding binding, string reason, string policy, long now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _store.UpsertAsync(binding with { Violation = reason, ViolationAtMs = now }, ct);
|
||||||
|
return new BinaryDecision(policy != Enforce, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Official(long Seq, HashSet<string> Binaries);
|
||||||
|
|
||||||
|
/// <summary>用内嵌的 CI 公钥验清单;验过且是这个组件的才返回它列出的官方二进制哈希。</summary>
|
||||||
|
private async ValueTask<Official?> StoredOfficialAsync(string component, CancellationToken ct) =>
|
||||||
|
VerifyManifest(await _settings.GetAsync(ReleaseService.Key(component, "manifest"), ct), await _settings.GetAsync(ReleaseService.Key(component, "signature"), ct), component);
|
||||||
|
|
||||||
|
private Official? VerifyManifest(string? manifestText, string? signatureText, string component)
|
||||||
|
{
|
||||||
|
if (manifestText is not { Length: > 0 and <= ReleaseService.MaxManifestBytes } manifest
|
||||||
|
|| signatureText is not { Length: > 0 and <= ReleaseService.MaxSignatureBytes } signature
|
||||||
|
|| !ReleaseSignature.Verify(_key.Pem, manifest, signature))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(manifest);
|
||||||
|
JsonElement root = document.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object
|
||||||
|
|| !root.TryGetProperty("component", out JsonElement c) || c.GetString() != component
|
||||||
|
|| !root.TryGetProperty("seq", out JsonElement s) || !s.TryGetInt64(out long seq) || seq <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var binaries = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
if (root.TryGetProperty("binaries", out JsonElement list) && list.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
foreach (string item in (list.GetString() ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
if (IsHash(item))
|
||||||
|
{
|
||||||
|
binaries.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Official(seq, binaries);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsHash(string value) => value.Length == 64 && value.All(ch => ch is (>= '0' and <= '9') or (>= 'a' and <= 'f'));
|
||||||
|
|
||||||
|
private static string Short(string hash) => hash.Length > 12 ? hash[..12] : hash;
|
||||||
|
}
|
||||||
@@ -69,9 +69,11 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
|||||||
private readonly ReleaseService _releases;
|
private readonly ReleaseService _releases;
|
||||||
private readonly ScheduleService _schedule;
|
private readonly ScheduleService _schedule;
|
||||||
private readonly IProbeStore _probes;
|
private readonly IProbeStore _probes;
|
||||||
|
private readonly NodeOwnerService _owners;
|
||||||
|
|
||||||
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes)
|
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners)
|
||||||
{
|
{
|
||||||
|
_owners = owners;
|
||||||
_releases = releases;
|
_releases = releases;
|
||||||
_schedule = schedule;
|
_schedule = schedule;
|
||||||
_probes = probes;
|
_probes = probes;
|
||||||
@@ -148,6 +150,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
|||||||
{
|
{
|
||||||
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
|
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
|
||||||
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
|
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
|
||||||
|
_owners.Report(id.Value, meta?.Owners);
|
||||||
if (meta?.Ipv6 is { } ipv6)
|
if (meta?.Ipv6 is { } ipv6)
|
||||||
{
|
{
|
||||||
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
|
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
|
||||||
@@ -229,6 +232,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_owners.Report(id.Value, meta.Owners);
|
||||||
AgentRecord? before = await _agents.GetAsync(id, ct);
|
AgentRecord? before = await _agents.GetAsync(id, ct);
|
||||||
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
||||||
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>节点的一个属主:它报上来的 passkey 凭证 id,以及面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
|
||||||
|
public sealed record NodeOwner(string CredentialId, string? PasskeyName, string? Username);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点的属主:Agent 和 Probe 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。
|
||||||
|
/// <b>属主的强制在节点本地</b>(没有属主的节点不运行,见 <c>OwnerGuard</c>),面板这边只是顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上——
|
||||||
|
/// 以后给节点更多的接口和操作(extension 会话、加密内容)时,据此判断谁有资格用。上报的只是自述,真正的授权由节点自己对 passkey 签名的校验把关。
|
||||||
|
/// 在内存里:节点每次连上来都会重新报。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NodeOwnerService
|
||||||
|
{
|
||||||
|
private const int MaxOwners = 16;
|
||||||
|
private const int MaxCredentialIdLength = 256;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, string[]> _reported = new(StringComparer.Ordinal);
|
||||||
|
private readonly IAuthStore _auth;
|
||||||
|
|
||||||
|
public NodeOwnerService(IAuthStore auth)
|
||||||
|
{
|
||||||
|
_auth = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Report(string nodeId, IReadOnlyList<string>? credentialIds)
|
||||||
|
{
|
||||||
|
string[] clean = credentialIds is null ? [] : [.. credentialIds.Where(id => id.Length is > 0 and <= MaxCredentialIdLength).Distinct(StringComparer.Ordinal).Take(MaxOwners)];
|
||||||
|
if (clean.Length == 0)
|
||||||
|
{
|
||||||
|
_reported.TryRemove(nodeId, out _);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_reported[nodeId] = clean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Forget(string nodeId) => _reported.TryRemove(nodeId, out _);
|
||||||
|
|
||||||
|
/// <summary>每个节点的属主(只含报过的节点)。</summary>
|
||||||
|
public async ValueTask<IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>>> ListAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, IReadOnlyList<NodeOwner>>(StringComparer.Ordinal);
|
||||||
|
var users = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||||
|
foreach ((string nodeId, string[] ids) in _reported)
|
||||||
|
{
|
||||||
|
var owners = new List<NodeOwner>();
|
||||||
|
foreach (string id in ids)
|
||||||
|
{
|
||||||
|
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(id, ct);
|
||||||
|
string? username = null;
|
||||||
|
if (passkey is not null && !users.TryGetValue(passkey.UserId, out username))
|
||||||
|
{
|
||||||
|
username = (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
|
||||||
|
users[passkey.UserId] = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
owners.Add(new NodeOwner(id, passkey?.Name, username));
|
||||||
|
}
|
||||||
|
|
||||||
|
result[nodeId] = owners;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,13 @@ public sealed partial class ProbeService
|
|||||||
private readonly NodeInfoService _nodes;
|
private readonly NodeInfoService _nodes;
|
||||||
private readonly LinkService _links;
|
private readonly LinkService _links;
|
||||||
|
|
||||||
public ProbeService(IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
private readonly BinaryBindingService _bindings;
|
||||||
|
private readonly NodeOwnerService _owners;
|
||||||
|
|
||||||
|
public ProbeService(BinaryBindingService bindings, NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
||||||
{
|
{
|
||||||
|
_bindings = bindings;
|
||||||
|
_owners = owners;
|
||||||
_links = links;
|
_links = links;
|
||||||
_nodes = nodes;
|
_nodes = nodes;
|
||||||
_probes = probes;
|
_probes = probes;
|
||||||
@@ -94,6 +99,8 @@ public sealed partial class ProbeService
|
|||||||
if (deleted)
|
if (deleted)
|
||||||
{
|
{
|
||||||
await _nodes.ForgetAsync(id, ct);
|
await _nodes.ForgetAsync(id, ct);
|
||||||
|
await _bindings.ForgetAsync(id, ct);
|
||||||
|
_owners.Forget(id);
|
||||||
await _links.ForgetNodeAsync(id, ct);
|
await _links.ForgetNodeAsync(id, ct);
|
||||||
_gateway.NotifyAllTaskSetsChanged();
|
_gateway.NotifyAllTaskSetsChanged();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
services.AddSingleton<PasskeyService>();
|
services.AddSingleton<PasskeyService>();
|
||||||
services.AddSingleton<AuditService>();
|
services.AddSingleton<AuditService>();
|
||||||
services.AddSingleton<ServerIdentityService>();
|
services.AddSingleton<ServerIdentityService>();
|
||||||
|
services.AddSingleton<SetupService>();
|
||||||
|
services.AddSingleton<ReleaseFeed>();
|
||||||
|
services.AddSingleton<BinaryBindingService>();
|
||||||
|
services.AddSingleton<NodeOwnerService>();
|
||||||
|
services.AddSingleton<Fleet.Server.IBinaryAttestor>(sp => sp.GetRequiredService<BinaryBindingService>());
|
||||||
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
|
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
|
||||||
services.AddSingleton<TrustService>();
|
services.AddSingleton<TrustService>();
|
||||||
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
||||||
services.AddSingleton<ElevationService>();
|
|
||||||
services.AddSingleton<SettingsService>();
|
services.AddSingleton<SettingsService>();
|
||||||
services.AddSingleton<ReleaseService>();
|
services.AddSingleton<ReleaseService>();
|
||||||
services.AddSingleton<ScheduleService>();
|
services.AddSingleton<ScheduleService>();
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ public enum PrincipalKind
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||||
{
|
{
|
||||||
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):只有 passkey 登录的会话以后能提权。API key 为空。</summary>
|
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):登录方式只用于审计和显示;敏感操作靠提权窗口(触碰 passkey)。API key 为空。</summary>
|
||||||
public string AuthMethod { get; init; } = "";
|
public string AuthMethod { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。</summary>
|
/// <summary>会话的标识(令牌哈希的十六进制):API key 为空。</summary>
|
||||||
public string? SessionId { get; init; }
|
public string? SessionId { get; init; }
|
||||||
|
|
||||||
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
|
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public sealed partial class AuthService
|
|||||||
}
|
}
|
||||||
|
|
||||||
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
|
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
|
||||||
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession(session.AuthMethod)), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
|
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
|
public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public static class Capability
|
|||||||
public const string WebhooksManage = "webhooks.manage";
|
public const string WebhooksManage = "webhooks.manage";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>一项能力的元数据。<c>Trust</c>:改变信任的能力(加节点、吊销、账户),后续会要求 passkey 签名;<c>Reserved</c>:还没有对应的功能。</summary>
|
/// <summary>一项能力的元数据。<c>Trust</c>:改变信任或有破坏性的能力(吊销、账户),界面上特别标出,授权与其他能力一样靠 API key 或登录会话;真正由 passkey 签名保护的是操作员集合,那是节点自己验证的;<c>Reserved</c>:还没有对应的功能。</summary>
|
||||||
public sealed record CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies);
|
public sealed record CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies);
|
||||||
|
|
||||||
public sealed record CapabilityPreset(string Id, IReadOnlyList<string> Capabilities);
|
public sealed record CapabilityPreset(string Id, IReadOnlyList<string> Capabilities);
|
||||||
@@ -132,12 +132,10 @@ public static class CapabilityCatalog
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>)。</summary>
|
/// <summary>
|
||||||
|
/// 登录会话的能力:全部,包括信任类,密码登录和 passkey 登录一样。区别在于敏感操作:信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>),
|
||||||
|
/// 窗口要当场触碰 passkey 才能开,所以密码泄露的人做不了这些,日常管理却不需要每次触碰。
|
||||||
|
/// </summary>
|
||||||
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
|
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按登录方式给会话能力:passkey 登录的拿全部;<b>密码登录的什么能力都没有</b>,只能做「已登录」就能做的事——
|
|
||||||
/// 看自己是谁、注册自己的第一个 passkey。管理员要先用密码进来加一个 passkey,之后用 passkey 登录才能管事。
|
|
||||||
/// </summary>
|
|
||||||
public static IReadOnlyList<string> ForSession(string authMethod) => authMethod == AuthMethods.Passkey ? ForSession() : [];
|
|
||||||
}
|
}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
namespace MoShiro.Backend.Application;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 提权窗口的种类(docs/access.md 第 4 节):加节点(15 分钟)、吊销和删除(5 分钟)、改凭证(60 秒,用一次就没有)。
|
|
||||||
/// 窗口属于会话,不属于用户:换一台设备、一个浏览器就要重新触碰 passkey。
|
|
||||||
/// </summary>
|
|
||||||
public enum ElevationClass
|
|
||||||
{
|
|
||||||
Enroll,
|
|
||||||
Revoke,
|
|
||||||
Credential,
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record ElevationWindow(ElevationClass Class, long ExpiresAtMs);
|
|
||||||
|
|
||||||
public static class Elevation
|
|
||||||
{
|
|
||||||
public static string IdOf(ElevationClass elevation) => elevation switch
|
|
||||||
{
|
|
||||||
ElevationClass.Enroll => "enroll",
|
|
||||||
ElevationClass.Revoke => "revoke",
|
|
||||||
_ => "credential",
|
|
||||||
};
|
|
||||||
|
|
||||||
public static bool TryParse(string? id, out ElevationClass elevation)
|
|
||||||
{
|
|
||||||
(bool ok, elevation) = id switch
|
|
||||||
{
|
|
||||||
"enroll" => (true, ElevationClass.Enroll),
|
|
||||||
"revoke" => (true, ElevationClass.Revoke),
|
|
||||||
"credential" => (true, ElevationClass.Credential),
|
|
||||||
_ => (false, default),
|
|
||||||
};
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。</summary>
|
|
||||||
public static ElevationClass? For(string capability) => capability switch
|
|
||||||
{
|
|
||||||
Capability.FleetRevoke => ElevationClass.Revoke,
|
|
||||||
Capability.AccountsManage => ElevationClass.Credential,
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 会话的提权窗口,只在内存里:重启之后需要重新触碰 passkey,这正是想要的。API key 不经过这里(它自己带着能力,创建时已经签过)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ElevationService
|
|
||||||
{
|
|
||||||
private readonly BackendOptions _options;
|
|
||||||
private readonly TimeProvider _time;
|
|
||||||
private readonly object _gate = new();
|
|
||||||
private readonly Dictionary<string, Dictionary<ElevationClass, long>> _windows = [];
|
|
||||||
|
|
||||||
public ElevationService(BackendOptions options, TimeProvider time)
|
|
||||||
{
|
|
||||||
_options = options;
|
|
||||||
_time = time;
|
|
||||||
}
|
|
||||||
|
|
||||||
public TimeSpan LifetimeOf(ElevationClass elevation) => elevation switch
|
|
||||||
{
|
|
||||||
ElevationClass.Enroll => TimeSpan.FromMinutes(_options.ElevationEnrollMinutes),
|
|
||||||
ElevationClass.Revoke => TimeSpan.FromMinutes(_options.ElevationRevokeMinutes),
|
|
||||||
_ => TimeSpan.FromSeconds(_options.ElevationCredentialSeconds),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>开窗口;已经有的窗口重新计时。返回到期时间。</summary>
|
|
||||||
public long Grant(string sessionId, ElevationClass elevation)
|
|
||||||
{
|
|
||||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
||||||
long expires = now + (long)LifetimeOf(elevation).TotalMilliseconds;
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
Prune(now);
|
|
||||||
if (!_windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own))
|
|
||||||
{
|
|
||||||
_windows[sessionId] = own = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
own[elevation] = expires;
|
|
||||||
}
|
|
||||||
|
|
||||||
return expires;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>窗口是否有效。<paramref name="consume"/> 且是凭证类:同时用掉(一次有效)。</summary>
|
|
||||||
public bool Check(string sessionId, ElevationClass elevation, bool consume)
|
|
||||||
{
|
|
||||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
if (!_windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own) || !own.TryGetValue(elevation, out long expires) || expires <= now)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (consume && elevation == ElevationClass.Credential)
|
|
||||||
{
|
|
||||||
own.Remove(elevation);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<ElevationWindow> Active(string sessionId)
|
|
||||||
{
|
|
||||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
return _windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own)
|
|
||||||
? [.. own.Where(w => w.Value > now).OrderBy(w => w.Key).Select(w => new ElevationWindow(w.Key, w.Value))]
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>主动结束全部窗口(点「结束」或退出登录)。</summary>
|
|
||||||
public void End(string sessionId)
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_windows.Remove(sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Prune(long now)
|
|
||||||
{
|
|
||||||
foreach (string session in _windows.Where(p => p.Value.All(w => w.Value <= now)).Select(p => p.Key).ToList())
|
|
||||||
{
|
|
||||||
_windows.Remove(session);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,7 +33,6 @@ public sealed class PasskeyService
|
|||||||
|
|
||||||
private readonly IAuthStore _store;
|
private readonly IAuthStore _store;
|
||||||
private readonly AuthService _auth;
|
private readonly AuthService _auth;
|
||||||
private readonly ElevationService _elevation;
|
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly Dictionary<string, PendingChallenge> _challenges = [];
|
private readonly Dictionary<string, PendingChallenge> _challenges = [];
|
||||||
@@ -43,13 +42,10 @@ public sealed class PasskeyService
|
|||||||
private const string RegisterPurpose = "register";
|
private const string RegisterPurpose = "register";
|
||||||
private const string LoginPurpose = "login";
|
private const string LoginPurpose = "login";
|
||||||
|
|
||||||
private static string ElevatePurpose(ElevationClass elevation) => "elevate:" + Elevation.IdOf(elevation);
|
public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time)
|
||||||
|
|
||||||
public PasskeyService(IAuthStore store, AuthService auth, ElevationService elevation, TimeProvider time)
|
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
_elevation = elevation;
|
|
||||||
_time = time;
|
_time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,37 +114,6 @@ public sealed class PasskeyService
|
|||||||
: await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct);
|
: await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>提权的挑战:绑定用户和窗口种类,并列出这个用户的 passkey 让浏览器只选它们。没有 passkey 的用户没法提权。</summary>
|
|
||||||
public async ValueTask<Result<RequestOptions>> BeginElevationAsync(Principal principal, ElevationClass elevation, PasskeyContext context, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (principal.UserId is not { } userId || principal.SessionId is null)
|
|
||||||
{
|
|
||||||
return Result<RequestOptions>.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already");
|
|
||||||
}
|
|
||||||
|
|
||||||
IReadOnlyList<PasskeyRecord> own = await _store.ListPasskeysAsync(userId, ct);
|
|
||||||
if (own.Count == 0)
|
|
||||||
{
|
|
||||||
return Result<RequestOptions>.Fail(MoShiroErrors.ValidationFailed, "register a passkey first (Users, My passkeys)");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result<RequestOptions>.Ok(new RequestOptions(Issue(ElevatePurpose(elevation), userId), context.RpId, 60_000, [.. own.Select(p => p.Id)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>验证提权的断言(必须是这个用户自己的 passkey)并给这个会话开窗口。失败一律 unauthorized。</summary>
|
|
||||||
public async ValueTask<Result<ElevationWindow>> ElevateAsync(Principal principal, ElevationClass elevation, PasskeyAssertion request, PasskeyContext context, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (principal.UserId is not { } userId || principal.SessionId is not { } sessionId)
|
|
||||||
{
|
|
||||||
return Result<ElevationWindow>.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already");
|
|
||||||
}
|
|
||||||
|
|
||||||
PasskeyRecord? passkey = await VerifyAsync(request, context, ElevatePurpose(elevation), userId, ct);
|
|
||||||
return passkey is null || passkey.UserId != userId
|
|
||||||
? Result<ElevationWindow>.Fail(MoShiroErrors.Unauthorized, "invalid credentials")
|
|
||||||
: Result<ElevationWindow>.Ok(new ElevationWindow(elevation, _elevation.Grant(sessionId, elevation)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。</summary>
|
/// <summary>验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。</summary>
|
||||||
private async ValueTask<PasskeyRecord?> VerifyAsync(PasskeyAssertion request, PasskeyContext context, string purpose, string? userId, CancellationToken ct)
|
private async ValueTask<PasskeyRecord?> VerifyAsync(PasskeyAssertion request, PasskeyContext context, string purpose, string? userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Fleet.Core;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 首次认领:面板里还没有任何用户时,由部署的人自己填管理员的用户名和密码——没有默认账户,也没有默认密码。
|
||||||
|
/// <para>
|
||||||
|
/// 谁先打开页面谁就能当管理员,所以要有一个只有部署的人才拿得到的<b>认领码</b>:首次启动时随机生成并写进后端的日志
|
||||||
|
/// (<c>journalctl -u moshiro-backend</c> / <c>podman logs</c>),或者用 <c>MoShiro__ClaimCode</c> 自己指定。
|
||||||
|
/// 创建完第一个管理员,认领码立刻作废,这个入口也就关闭了。
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class SetupService : IDisposable
|
||||||
|
{
|
||||||
|
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
private const int CodeLength = 16;
|
||||||
|
|
||||||
|
private readonly AuthService _auth;
|
||||||
|
private readonly IAuthStore _store;
|
||||||
|
private readonly BackendOptions _options;
|
||||||
|
private readonly ILogger<SetupService> _log;
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
private string? _code;
|
||||||
|
|
||||||
|
public SetupService(AuthService auth, IAuthStore store, BackendOptions options, ILogger<SetupService> log)
|
||||||
|
{
|
||||||
|
_auth = auth;
|
||||||
|
_store = store;
|
||||||
|
_options = options;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _gate.Dispose();
|
||||||
|
|
||||||
|
/// <summary>面板里还没有任何用户。</summary>
|
||||||
|
public async ValueTask<bool> NeedsSetupAsync(CancellationToken ct) => await _store.CountUsersAsync(ct) == 0;
|
||||||
|
|
||||||
|
/// <summary>启动时调用:需要初始化就准备好认领码,并写进日志(只有能看到日志的人才拿得到)。</summary>
|
||||||
|
public async ValueTask AnnounceAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await NeedsSetupAsync(ct))
|
||||||
|
{
|
||||||
|
LogClaimCode(_log, _options.PublicUrl ?? "the panel's address", Code());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<LoginResult>> CompleteAsync(string? claimCode, string username, string password, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _gate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await NeedsSetupAsync(ct))
|
||||||
|
{
|
||||||
|
return Result<LoginResult>.Fail(MoShiroErrors.NameConflict, "the panel has already been claimed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claimCode is null || !AuthService.FixedTimeEquals(Normalize(claimCode), Normalize(Code())))
|
||||||
|
{
|
||||||
|
return Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "the claim code is not valid (see the backend's log)");
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<UserRecord> created = await _auth.CreateUserAsync(username, password, ct);
|
||||||
|
if (!created.TryGet(out _, out FleetError? error))
|
||||||
|
{
|
||||||
|
return Result<LoginResult>.Fail(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
_code = null; // 一次有效:第一个管理员创建完,这个入口就关了
|
||||||
|
return await _auth.LoginAsync(username, password, ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Code() => _code ??= string.IsNullOrWhiteSpace(_options.ClaimCode) ? Generate() : _options.ClaimCode.Trim();
|
||||||
|
|
||||||
|
private static string Generate()
|
||||||
|
{
|
||||||
|
char[] chars = new char[CodeLength];
|
||||||
|
for (int i = 0; i < chars.Length; i++)
|
||||||
|
{
|
||||||
|
chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
string raw = new(chars);
|
||||||
|
return string.Join('-', Enumerable.Range(0, CodeLength / 4).Select(g => raw.Substring(g * 4, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>比较时不分大小写、忽略分隔符和空格。</summary>
|
||||||
|
private static string Normalize(string code) => new(code.Where(char.IsLetterOrDigit).Select(char.ToUpperInvariant).ToArray());
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "First start: no administrator exists yet. Open {Address} and finish the setup there with this claim code: {Code}")]
|
||||||
|
private static partial void LogClaimCode(ILogger logger, string address, string code);
|
||||||
|
}
|
||||||
@@ -8,11 +8,14 @@ using MoShiro.Protocol;
|
|||||||
namespace MoShiro.Backend.Application;
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
/// <summary>要操作员用 passkey 签的下一版操作员集合:<c>Challenge</c> 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。</summary>
|
/// <summary>要操作员用 passkey 签的下一版操作员集合:<c>Challenge</c> 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。</summary>
|
||||||
public sealed record TrustProposal(string Statement, int Version, string Challenge);
|
public sealed record TrustProposal(string Statement, int Version, string Challenge, IReadOnlyList<ProposedCredential> Added, IReadOnlyList<string> Removed, bool ServerKeyChanged);
|
||||||
|
|
||||||
|
/// <summary>下一版里新增的 passkey:签名的人要看清楚是不是自己加的,攻击者往账号里塞一把 passkey 再等人签名,是这个流程唯一的风险。</summary>
|
||||||
|
public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs);
|
||||||
|
|
||||||
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
|
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
|
||||||
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param>
|
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param>
|
||||||
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false);
|
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwner>? OperatorDetails = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
|
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
|
||||||
@@ -53,7 +56,16 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
|||||||
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
|
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
|
||||||
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
|
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
|
||||||
bool inSync = current is not null && proposed is not null && Same(current, proposed);
|
bool inSync = current is not null && proposed is not null && Same(current, proposed);
|
||||||
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true);
|
// 每个操作员是哪个账户的哪把 passkey:添加节点时选属主、信任页都要显示
|
||||||
|
var details = new List<NodeOwner>();
|
||||||
|
foreach (OperatorKey key in current?.Keys ?? [])
|
||||||
|
{
|
||||||
|
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(key.CredentialId, ct);
|
||||||
|
string? account = passkey is null ? null : (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
|
||||||
|
details.Add(new NodeOwner(key.CredentialId, passkey?.Name, account));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true, details);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<TrustProposal?> ProposeAsync(IReadOnlyList<SignedStatementDto> chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
|
private async ValueTask<TrustProposal?> ProposeAsync(IReadOnlyList<SignedStatementDto> chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
|
||||||
@@ -72,7 +84,12 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
|||||||
[.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))],
|
[.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))],
|
||||||
[_server.PublicKeySpki]);
|
[_server.PublicKeySpki]);
|
||||||
string statement = OperatorTrust.Encode(next);
|
string statement = OperatorTrust.Encode(next);
|
||||||
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))));
|
HashSet<string> before = [.. current?.Keys.Select(k => k.CredentialId) ?? []];
|
||||||
|
HashSet<string> after = [.. next.Keys.Select(k => k.CredentialId)];
|
||||||
|
ProposedCredential[] added = [.. passkeys.Where(p => !before.Contains(p.Id)).Select(p => new ProposedCredential(p.Id, p.Name, p.CreatedAtMs))];
|
||||||
|
string[] removed = [.. before.Where(id => !after.Contains(id)).Order(StringComparer.Ordinal)];
|
||||||
|
bool serverChanged = current is not null && !(current.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((next.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal));
|
||||||
|
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))), added, removed, serverChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool Same(OperatorSet a, OperatorSet b) =>
|
private static bool Same(OperatorSet a, OperatorSet b) =>
|
||||||
@@ -82,7 +99,7 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
|
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
|
||||||
/// 谁能调用由端点的能力和提权窗口把关;这里不看调用者。
|
/// 谁能调用由端点的能力把关;这里不看调用者,信任来自节点自己验证的 passkey 签名。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async ValueTask<Result<TrustState>> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct)
|
public async ValueTask<Result<TrustState>> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ public sealed class BackendOptions
|
|||||||
/// <summary>宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。</summary>
|
/// <summary>宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。</summary>
|
||||||
public string? BackendUpdateStatusDirectory { get; set; }
|
public string? BackendUpdateStatusDirectory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点二进制绑定的策略:<c>enforce</c>(默认)二进制和绑定不符、又不在 CI 签名的更新清单里就拒绝连接;<c>warn</c> 只记录不拒绝;<c>off</c> 不检查。
|
||||||
|
/// </summary>
|
||||||
|
public string BinaryPolicy { get; set; } = "enforce";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
|
||||||
|
/// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireOwner { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>面板的对外地址(用于生成安装命令)。</summary>
|
/// <summary>面板的对外地址(用于生成安装命令)。</summary>
|
||||||
public string? PublicUrl { get; set; }
|
public string? PublicUrl { get; set; }
|
||||||
|
|
||||||
@@ -32,15 +43,6 @@ public sealed class BackendOptions
|
|||||||
/// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary>
|
/// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary>
|
||||||
public string? WebAuthnOrigins { get; set; }
|
public string? WebAuthnOrigins { get; set; }
|
||||||
|
|
||||||
/// <summary>提权窗口的长度(passkey 触碰一次之后):加节点类的分钟数。</summary>
|
|
||||||
public int ElevationEnrollMinutes { get; set; } = 15;
|
|
||||||
|
|
||||||
/// <summary>吊销和删除类的分钟数。</summary>
|
|
||||||
public int ElevationRevokeMinutes { get; set; } = 5;
|
|
||||||
|
|
||||||
/// <summary>改凭证类(用一次就没有)的秒数。</summary>
|
|
||||||
public int ElevationCredentialSeconds { get; set; } = 60;
|
|
||||||
|
|
||||||
/// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
|
/// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
|
||||||
public bool SecureCookies { get; set; } = true;
|
public bool SecureCookies { get; set; } = true;
|
||||||
|
|
||||||
@@ -106,6 +108,12 @@ public sealed class BackendOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
|
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 首次认领的认领码(可选):面板里还没有任何用户时,第一个管理员由部署的人自己在页面上填,需要这个码才能创建。
|
||||||
|
/// 不设置就在首次启动时随机生成并写进后端的日志。创建完第一个管理员后作废。
|
||||||
|
/// </summary>
|
||||||
|
public string? ClaimCode { get; set; }
|
||||||
|
|
||||||
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
|
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
|
||||||
public string? InitialAdminUser { get; set; }
|
public string? InitialAdminUser { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,20 @@ public interface IEventStore
|
|||||||
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
|
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>节点的二进制绑定:第一次握手成功时绑定的 SHA-256,对应的签名清单 seq(没有签名清单时为 0),以及最近一次不符的原因。</summary>
|
||||||
|
public sealed record NodeBinding(string NodeId, string Sha256, long Seq, bool Signed, long BoundAtMs, string? Violation, long? ViolationAtMs);
|
||||||
|
|
||||||
|
public interface INodeBindingStore
|
||||||
|
{
|
||||||
|
ValueTask<NodeBinding?> GetAsync(string nodeId, CancellationToken ct);
|
||||||
|
|
||||||
|
ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct);
|
||||||
|
|
||||||
|
ValueTask<bool> DeleteAsync(string nodeId, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>操作员集合的链(passkey 签的版本,按版本号递增)。</summary>
|
/// <summary>操作员集合的链(passkey 签的版本,按版本号递增)。</summary>
|
||||||
public interface IOperatorSetStore
|
public interface IOperatorSetStore
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Hosting;
|
|||||||
namespace MoShiro.Backend.Application;
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 启动时最先执行:截断没有索引记录的段文件尾部、创建初始管理员、创建默认的线路任务模板。
|
/// 启动时最先执行:截断没有索引记录的段文件尾部、(用配置里的凭据创建管理员,没有的话把首次认领码写进日志)创建默认的线路任务模板。
|
||||||
/// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。
|
/// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StartupTasks : IHostedService
|
public sealed class StartupTasks : IHostedService
|
||||||
@@ -12,9 +12,11 @@ public sealed class StartupTasks : IHostedService
|
|||||||
private readonly AuthService _auth;
|
private readonly AuthService _auth;
|
||||||
private readonly TaskService _tasks;
|
private readonly TaskService _tasks;
|
||||||
private readonly ServerIdentityService _identity;
|
private readonly ServerIdentityService _identity;
|
||||||
|
private readonly SetupService _setup;
|
||||||
|
|
||||||
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity)
|
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity, SetupService setup)
|
||||||
{
|
{
|
||||||
|
_setup = setup;
|
||||||
_identity = identity;
|
_identity = identity;
|
||||||
_raw = raw;
|
_raw = raw;
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
@@ -25,6 +27,7 @@ public sealed class StartupTasks : IHostedService
|
|||||||
{
|
{
|
||||||
await _raw.RecoverAsync(cancellationToken);
|
await _raw.RecoverAsync(cancellationToken);
|
||||||
await _auth.EnsureInitialAdminAsync(cancellationToken);
|
await _auth.EnsureInitialAdminAsync(cancellationToken);
|
||||||
|
await _setup.AnnounceAsync(cancellationToken);
|
||||||
await _tasks.SeedDefaultsAsync(cancellationToken);
|
await _tasks.SeedDefaultsAsync(cancellationToken);
|
||||||
await _identity.EnsureAsync(cancellationToken);
|
await _identity.EnsureAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 镜像仓库里的签名清单:CI 每次构建都把签名清单放在 <c><命名空间>/moshiro-<组件>-manifest:<通道></c> 这个没有文件层的小镜像的标签里
|
||||||
|
/// (<c>org.moshiro.manifest</c> 是 base64 的清单,<c>org.moshiro.signature</c> 是签名)。面板没有登记过这个组件的清单时(清过库、CI 登记那一步网络失败),
|
||||||
|
/// 直接按 OCI 分发协议匿名读取它——只读两个小 JSON,不拉任何镜像层。取回来的只是候选:调用方要用 CI 的发布公钥验过签才采用,所以仓库被改也只会验签失败。
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ReleaseFeed : IDisposable
|
||||||
|
{
|
||||||
|
private const int MaxBytes = 1 << 20;
|
||||||
|
|
||||||
|
private readonly BackendOptions _options;
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
|
public ReleaseFeed(BackendOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _http.Dispose();
|
||||||
|
|
||||||
|
/// <summary>取不到(仓库不可读、没有这个标签、格式不对)返回 null;不抛异常。</summary>
|
||||||
|
public async ValueTask<(string Manifest, string Signature)?> FetchAsync(string component, string channel, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string registry = (_options.NodeImageRegistry ?? "").Trim().TrimEnd('/');
|
||||||
|
if (registry.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string baseUrl = registry.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || registry.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? registry : "https://" + registry;
|
||||||
|
string repository = $"{(channel == "dev" ? "moshiro-dev" : "moshiro")}/moshiro-{component}-manifest";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string manifestJson = await GetAsync($"{baseUrl}/v2/{repository}/manifests/{channel}", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json", ct);
|
||||||
|
using JsonDocument manifest = JsonDocument.Parse(manifestJson);
|
||||||
|
string? digest = manifest.RootElement.GetProperty("config").GetProperty("digest").GetString();
|
||||||
|
if (digest is null || !Digest().IsMatch(digest))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string configJson = await GetAsync($"{baseUrl}/v2/{repository}/blobs/{digest}", "application/json", ct);
|
||||||
|
using JsonDocument config = JsonDocument.Parse(configJson);
|
||||||
|
JsonElement labels = config.RootElement.GetProperty("config").GetProperty("Labels");
|
||||||
|
string? encoded = labels.GetProperty("org.moshiro.manifest").GetString();
|
||||||
|
string? signature = labels.GetProperty("org.moshiro.signature").GetString();
|
||||||
|
return encoded is null || signature is null ? null : (Encoding.UTF8.GetString(Convert.FromBase64String(encoded)), signature);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or KeyNotFoundException or InvalidOperationException or FormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetAsync(string url, string accept, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.TryAddWithoutValidation("Accept", accept);
|
||||||
|
using HttpResponseMessage response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
if (response.Content.Headers.ContentLength is > MaxBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("the registry response is too large");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||||||
|
using var limited = new MemoryStream();
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int read;
|
||||||
|
while ((read = await stream.ReadAsync(buffer, ct)) > 0)
|
||||||
|
{
|
||||||
|
limited.Write(buffer, 0, read);
|
||||||
|
if (limited.Length > MaxBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("the registry response is too large");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(limited.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("^sha256:[0-9a-f]{64}$")]
|
||||||
|
private static partial Regex Digest();
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ public sealed record ReleaseOffer(string Component, long Seq, string Tag, string
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。
|
/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。
|
||||||
/// <b>面板不验签、也不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
|
/// 登记入口(<c>/api/release/offer</c>)只收带有效 CI 签名的清单;面板<b>不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
|
||||||
/// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。
|
/// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class ReleaseService : IDisposable
|
public sealed partial class ReleaseService : IDisposable
|
||||||
@@ -26,10 +26,15 @@ public sealed partial class ReleaseService : IDisposable
|
|||||||
private readonly IAgentGateway _gateway;
|
private readonly IAgentGateway _gateway;
|
||||||
private readonly IBackendUpdateFiles _backendFiles;
|
private readonly IBackendUpdateFiles _backendFiles;
|
||||||
private readonly BackendOptions _options;
|
private readonly BackendOptions _options;
|
||||||
|
private readonly IReleaseKey _key;
|
||||||
|
private readonly ReleaseFeed _feed;
|
||||||
|
private readonly ConcurrentDictionary<string, long> _feedChecked = new();
|
||||||
private readonly ConcurrentDictionary<string, UpdateStatus> _status = new();
|
private readonly ConcurrentDictionary<string, UpdateStatus> _status = new();
|
||||||
|
|
||||||
public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options)
|
public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options, IReleaseKey key, ReleaseFeed feed)
|
||||||
{
|
{
|
||||||
|
_key = key;
|
||||||
|
_feed = feed;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_gateway = gateway;
|
_gateway = gateway;
|
||||||
_backendFiles = backendFiles;
|
_backendFiles = backendFiles;
|
||||||
@@ -84,6 +89,33 @@ public sealed partial class ReleaseService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 这个组件的清单;面板里没有(清过库、CI 登记那一步网络失败)或者已经放了一阵,就去镜像仓库里取一次最新的:
|
||||||
|
/// 取回来的清单要用 CI 的发布公钥验过签才登记,所以仓库被改也只会被拒绝。取不到就用手里有的。
|
||||||
|
/// </summary>
|
||||||
|
public async ValueTask<ReleaseOffer?> GetOrFetchAsync(string component, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ReleaseOffer? stored = await GetAsync(component, ct);
|
||||||
|
long now = Environment.TickCount64;
|
||||||
|
long wait = stored is null ? 30_000 : 600_000; // 没有就 30 秒试一次,已经有了就隔 10 分钟看一眼有没有更新的
|
||||||
|
if (_feedChecked.TryGetValue(component, out long last) && now - last < wait)
|
||||||
|
{
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
_feedChecked[component] = now;
|
||||||
|
if (await _feed.FetchAsync(component, BackendUpdates.ChannelOf(_options), ct) is { } fetched && ReleaseSignature.Verify(_key.Pem, fetched.Manifest, fetched.Signature))
|
||||||
|
{
|
||||||
|
Result<ReleaseOffer> published = await PublishAsync(fetched.Manifest, fetched.Signature, ct);
|
||||||
|
if (published.IsSuccess)
|
||||||
|
{
|
||||||
|
return published.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask<ReleaseOffer?> GetAsync(string component, CancellationToken ct)
|
public async ValueTask<ReleaseOffer?> GetAsync(string component, CancellationToken ct)
|
||||||
{
|
{
|
||||||
string? manifest = await _settings.GetAsync(Key(component, "manifest"), ct);
|
string? manifest = await _settings.GetAsync(Key(component, "manifest"), ct);
|
||||||
@@ -145,12 +177,12 @@ public sealed partial class ReleaseService : IDisposable
|
|||||||
|
|
||||||
private IEnumerable<NodeId> OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component);
|
private IEnumerable<NodeId> OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component);
|
||||||
|
|
||||||
private static string? ComponentOf(NodeId node) =>
|
internal static string? ComponentOf(NodeId node) =>
|
||||||
node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent
|
node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent
|
||||||
: node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe
|
: node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
private static string Key(string component, string part) => $"release.{component}.{part}";
|
internal static string Key(string component, string part) => $"release.{component}.{part}";
|
||||||
|
|
||||||
private static bool TryParse(string manifest, string signature, [NotNullWhen(true)] out ReleaseOffer? offer, [NotNullWhen(false)] out string? problem)
|
private static bool TryParse(string manifest, string signature, [NotNullWhen(true)] out ReleaseOffer? offer, [NotNullWhen(false)] out string? problem)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发布清单的签名校验:用 Backend 内嵌的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)验 ECDSA P-256 / SHA-256(DER)签名,签名对象是清单的原始字节。
|
||||||
|
/// 登记清单因此不需要任何令牌:能被登记的只有 CI 签过的清单,节点的更新器之后还会用本机的公钥再验一遍。
|
||||||
|
/// </summary>
|
||||||
|
public static class ReleaseSignature
|
||||||
|
{
|
||||||
|
public static bool Verify(string publicKeyPem, string manifest, string signatureBase64)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = ECDsa.Create();
|
||||||
|
key.ImportFromPem(publicKeyPem);
|
||||||
|
return key.VerifyData(Encoding.UTF8.GetBytes(manifest), Convert.FromBase64String(signatureBase64), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
|||||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||||
internal static class Schema
|
internal static class Schema
|
||||||
{
|
{
|
||||||
public const int CurrentVersion = 13;
|
public const int CurrentVersion = 14;
|
||||||
|
|
||||||
public static void Migrate(SqliteConnection connection)
|
public static void Migrate(SqliteConnection connection)
|
||||||
{
|
{
|
||||||
@@ -221,6 +221,26 @@ internal static class Schema
|
|||||||
connection.Exec("PRAGMA user_version = 13");
|
connection.Exec("PRAGMA user_version = 13");
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 14)
|
||||||
|
{
|
||||||
|
// 节点的二进制绑定:第一次握手成功时绑定正在运行的二进制的 SHA-256
|
||||||
|
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||||
|
connection.Exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE node_bindings (
|
||||||
|
node_id TEXT PRIMARY KEY,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
signed INTEGER NOT NULL,
|
||||||
|
bound_at INTEGER NOT NULL,
|
||||||
|
violation TEXT,
|
||||||
|
violation_at INTEGER
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
connection.Exec("PRAGMA user_version = 14");
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Storage.Sqlite;
|
||||||
|
|
||||||
|
public sealed class SqliteNodeBindingStore : INodeBindingStore
|
||||||
|
{
|
||||||
|
private const string Columns = "node_id, sha256, seq, signed, bound_at, violation, violation_at";
|
||||||
|
|
||||||
|
private readonly SqliteDatabase _db;
|
||||||
|
|
||||||
|
public SqliteNodeBindingStore(SqliteDatabase db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<NodeBinding?> GetAsync(string nodeId, CancellationToken ct) =>
|
||||||
|
_db.ReadAsync<NodeBinding?>(c => c.QueryOne($"SELECT {Columns} FROM node_bindings WHERE node_id = @id", Read, ("@id", nodeId)), ct);
|
||||||
|
|
||||||
|
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) =>
|
||||||
|
_db.ReadAsync<IReadOnlyList<NodeBinding>>(c => c.Query($"SELECT {Columns} FROM node_bindings ORDER BY node_id", Read), ct);
|
||||||
|
|
||||||
|
public async ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct) =>
|
||||||
|
await _db.WriteAsync(
|
||||||
|
c => c.Exec(
|
||||||
|
$"INSERT INTO node_bindings ({Columns}) VALUES (@id, @sha, @seq, @signed, @at, @violation, @violation_at) "
|
||||||
|
+ "ON CONFLICT(node_id) DO UPDATE SET sha256 = excluded.sha256, seq = excluded.seq, signed = excluded.signed, bound_at = excluded.bound_at, violation = excluded.violation, violation_at = excluded.violation_at",
|
||||||
|
("@id", binding.NodeId), ("@sha", binding.Sha256), ("@seq", binding.Seq), ("@signed", binding.Signed ? 1 : 0), ("@at", binding.BoundAtMs), ("@violation", binding.Violation), ("@violation_at", binding.ViolationAtMs)),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
public ValueTask<bool> DeleteAsync(string nodeId, CancellationToken ct) =>
|
||||||
|
_db.WriteAsync(c => c.Exec("DELETE FROM node_bindings WHERE node_id = @id", ("@id", nodeId)) > 0, ct);
|
||||||
|
|
||||||
|
private static NodeBinding Read(Microsoft.Data.Sqlite.SqliteDataReader r) =>
|
||||||
|
new(r.GetString(0), r.GetString(1), r.GetInt64(2), r.GetInt64(3) != 0, r.GetInt64(4), r.NullableString(5), r.NullableLong(6));
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ public static class StorageServiceCollectionExtensions
|
|||||||
services.AddSingleton<IAuthStore, SqliteAuthStore>();
|
services.AddSingleton<IAuthStore, SqliteAuthStore>();
|
||||||
services.AddSingleton<IAuditStore, SqliteAuditStore>();
|
services.AddSingleton<IAuditStore, SqliteAuditStore>();
|
||||||
services.AddSingleton<IOperatorSetStore, SqliteOperatorSetStore>();
|
services.AddSingleton<IOperatorSetStore, SqliteOperatorSetStore>();
|
||||||
|
services.AddSingleton<INodeBindingStore, SqliteNodeBindingStore>();
|
||||||
services.AddSingleton<IRawStore, SqliteRawStore>();
|
services.AddSingleton<IRawStore, SqliteRawStore>();
|
||||||
services.AddSingleton<IMetricStore, SqliteMetricStore>();
|
services.AddSingleton<IMetricStore, SqliteMetricStore>();
|
||||||
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();
|
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
@@ -25,6 +26,9 @@ public class BackendFixture : IAsyncLifetime
|
|||||||
public const string AdminUser = "admin";
|
public const string AdminUser = "admin";
|
||||||
public const string AdminPassword = "correct horse battery";
|
public const string AdminPassword = "correct horse battery";
|
||||||
|
|
||||||
|
/// <summary>首次认领码:和线上一样,第一个管理员通过 <c>/api/admin/auth/setup</c> 创建,不再用配置里的默认账户。</summary>
|
||||||
|
public const string ClaimCode = "TEST-CLAIM-CODE-0001";
|
||||||
|
|
||||||
private readonly List<AgentSimulator> _agents = [];
|
private readonly List<AgentSimulator> _agents = [];
|
||||||
private string _adminToken = "";
|
private string _adminToken = "";
|
||||||
private int _counter;
|
private int _counter;
|
||||||
@@ -256,9 +260,9 @@ public class BackendFixture : IAsyncLifetime
|
|||||||
options.SecureCookies = false;
|
options.SecureCookies = false;
|
||||||
options.WindowGrace = TimeSpan.Zero;
|
options.WindowGrace = TimeSpan.Zero;
|
||||||
options.AnalysisPollInterval = TimeSpan.FromMilliseconds(100);
|
options.AnalysisPollInterval = TimeSpan.FromMilliseconds(100);
|
||||||
options.InitialAdminUser = AdminUser;
|
options.ClaimCode = ClaimCode;
|
||||||
options.InitialAdminPassword = AdminPassword;
|
|
||||||
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
|
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
|
||||||
|
options.RequireOwner = false; // 大部分测试不建立信任根;属主必选的测试自己打开(OwnerTests)
|
||||||
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
|
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
|
||||||
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
|
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
|
||||||
});
|
});
|
||||||
@@ -275,14 +279,14 @@ public class BackendFixture : IAsyncLifetime
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey;
|
/// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey;
|
||||||
/// 注册之后用 passkey 登录才有能力,再触碰一次开凭证窗口,才能创建带信任类能力的 key。
|
/// 注册之后用 passkey 登录(要签操作员集合等节点会验证的事时才需要 passkey)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<string> CreateAdminTokenAsync()
|
private async Task<string> CreateAdminTokenAsync()
|
||||||
{
|
{
|
||||||
using HttpClient session = NewClient();
|
using HttpClient session = NewClient();
|
||||||
using HttpResponseMessage login = await session.PostAsync(
|
using HttpResponseMessage login = await session.PostAsync(
|
||||||
"/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
|
"/api/admin/auth/setup", Json(new JsonObject { ["claim_code"] = ClaimCode, ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
|
||||||
login.EnsureSuccessStatusCode();
|
Assert.Equal(HttpStatusCode.Created, login.StatusCode);
|
||||||
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||||
|
|
||||||
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
|
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
|
||||||
@@ -306,15 +310,6 @@ public class BackendFixture : IAsyncLifetime
|
|||||||
strong.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await signedIn.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
strong.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await signedIn.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||||
}
|
}
|
||||||
|
|
||||||
using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/elevation/options", Json(new JsonObject { ["class"] = "credential" })))
|
|
||||||
{
|
|
||||||
options.EnsureSuccessStatusCode();
|
|
||||||
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
|
||||||
assertion["class"] = "credential";
|
|
||||||
using HttpResponseMessage elevated = await strong.PostAsync("/api/admin/auth/elevation", Json(assertion));
|
|
||||||
elevated.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
using HttpResponseMessage created = await strong.PostAsync("/api/admin/api-tokens", Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }));
|
using HttpResponseMessage created = await strong.PostAsync("/api/admin/api-tokens", Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }));
|
||||||
created.EnsureSuccessStatusCode();
|
created.EnsureSuccessStatusCode();
|
||||||
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Fleet.Core;
|
||||||
|
using Fleet.Server;
|
||||||
|
using MoShiro.Backend.Application;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点二进制绑定:第一次握手成功的那一刻绑定正在运行的二进制的哈希,之后对不上又不在 CI 签名的更新清单里,就不让它继续运行。
|
||||||
|
/// 官方哈希集合来自 CI 签名的清单(节点握手时带着),所以被攻破的 Backend 伪造不了,也不依赖面板有没有收到过这次发布。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BinaryBindingTests : IDisposable
|
||||||
|
{
|
||||||
|
private const string A = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";
|
||||||
|
private const string B = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2";
|
||||||
|
private const string C = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3";
|
||||||
|
|
||||||
|
private readonly ECDsa _ci = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
private readonly ECDsa _other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
private readonly MemoryStore _store = new();
|
||||||
|
private readonly MemorySettings _settings = new();
|
||||||
|
private readonly NodeRecord _agent = Node(AgentIds.Prefix + "_test1");
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ci.Dispose();
|
||||||
|
_other.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Hash(string seed) => seed[..2] + new string(seed[1], 62);
|
||||||
|
|
||||||
|
private static NodeRecord Node(string id) => new(new NodeId(id), "n", NodeStatus.Active, [1], [2], [], null, null, 0, null);
|
||||||
|
|
||||||
|
private BinaryBindingService Service(string policy = "enforce") =>
|
||||||
|
new(_store, _settings, new TestKey(_ci), new BackendOptions { BinaryPolicy = policy }, TimeProvider.System);
|
||||||
|
|
||||||
|
private sealed class TestKey(ECDsa key) : IReleaseKey
|
||||||
|
{
|
||||||
|
public string Pem => key.ExportSubjectPublicKeyInfoPem();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sign(ECDsa key, string manifest) =>
|
||||||
|
Convert.ToBase64String(key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
|
||||||
|
|
||||||
|
/// <summary>节点握手时带的证据:哈希,加上(可选)某把密钥签名的清单。</summary>
|
||||||
|
private BinaryEvidence Evidence(string hash, long? seq = null, string[]? binaries = null, string component = "agent", ECDsa? signer = null)
|
||||||
|
{
|
||||||
|
if (seq is null)
|
||||||
|
{
|
||||||
|
return new BinaryEvidence(hash, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
string manifest = $"{{\"schema\":1,\"component\":\"{component}\",\"seq\":{seq},\"binaries\":\"{string.Join(',', binaries ?? [hash])}\"}}";
|
||||||
|
return new BinaryEvidence(hash, manifest, Sign(signer ?? _ci, manifest));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string H(char c) => new(c, 64);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheFirstHandshakeBinds_AndTheSameBinaryKeepsWorking()
|
||||||
|
{
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||||
|
Assert.Equal(H('a'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256);
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AReplacedBinary_IsRefused_AndTheReasonIsKept()
|
||||||
|
{
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||||
|
|
||||||
|
BinaryDecision decision = await service.CheckAsync(_agent, Evidence(H('b')), default);
|
||||||
|
|
||||||
|
Assert.False(decision.Accept);
|
||||||
|
Assert.Contains("changed", decision.Reason, StringComparison.Ordinal);
|
||||||
|
NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!;
|
||||||
|
Assert.Equal(H('a'), bound.Sha256); // 绑定不会因为一次不符而被改写
|
||||||
|
Assert.NotNull(bound.Violation);
|
||||||
|
|
||||||
|
// 原来的二进制回来了:恢复,原因清掉
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||||
|
Assert.Null((await _store.GetAsync(_agent.Id.Value, default))!.Violation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUpgrade_NeedsACiSignedManifestThatListsTheNewBinary()
|
||||||
|
{
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default);
|
||||||
|
|
||||||
|
// 新二进制在一份更新的 CI 签名清单里:合法升级,绑定跟着改
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('b'), seq: 200, binaries: [H('b')]), default)).Accept);
|
||||||
|
NodeBinding upgraded = (await _store.GetAsync(_agent.Id.Value, default))!;
|
||||||
|
Assert.Equal(H('b'), upgraded.Sha256);
|
||||||
|
Assert.Equal(200, upgraded.Seq);
|
||||||
|
Assert.True(upgraded.Signed);
|
||||||
|
|
||||||
|
// 退回旧版本(清单 seq 更低):降级,拒绝
|
||||||
|
Assert.False((await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default)).Accept);
|
||||||
|
// 同一份清单里的另一个官方二进制(比如另一个平台的构建)不算降级
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('c'), seq: 200, binaries: [H('b'), H('c')]), default)).Accept);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AManifestSignedByAnotherKey_OrForAnotherComponent_ProvesNothing()
|
||||||
|
{
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||||
|
|
||||||
|
Assert.False((await service.CheckAsync(_agent, Evidence(H('b'), seq: 500, binaries: [H('b')], signer: _other), default)).Accept);
|
||||||
|
Assert.False((await service.CheckAsync(_agent, Evidence(H('b'), seq: 500, binaries: [H('b')], component: "probe"), default)).Accept);
|
||||||
|
Assert.Equal(H('a'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ASignedManifestThatDoesNotListTheRunningBinary_IsRefusedAtTheFirstHandshake()
|
||||||
|
{
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
|
||||||
|
BinaryDecision decision = await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('b')]), default);
|
||||||
|
|
||||||
|
Assert.False(decision.Accept);
|
||||||
|
Assert.Null(await _store.GetAsync(_agent.Id.Value, default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUnsignedBinding_BecomesSignedWhenTheManifestArrivesLater()
|
||||||
|
{
|
||||||
|
// 容器节点第一次握手时更新器可能还没写出清单:先按首次握手绑定,清单到了再标成已签名
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||||
|
Assert.False((await _store.GetAsync(_agent.Id.Value, default))!.Signed);
|
||||||
|
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default)).Accept);
|
||||||
|
|
||||||
|
NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!;
|
||||||
|
Assert.True(bound.Signed);
|
||||||
|
Assert.Equal(100, bound.Seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ALegacyNode_IsTolerated_ButCannotGoBackOnceBound()
|
||||||
|
{
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
NodeRecord legacy = Node(ProbeId.Prefix + "_old");
|
||||||
|
|
||||||
|
Assert.True((await service.CheckAsync(legacy, new BinaryEvidence(null, null, null), default)).Accept);
|
||||||
|
Assert.Null(await _store.GetAsync(legacy.Id.Value, default));
|
||||||
|
|
||||||
|
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||||
|
Assert.False((await service.CheckAsync(_agent, new BinaryEvidence(null, null, null), default)).Accept); // 绑定过的不许退回不上报
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MalformedHashes_AreRefused()
|
||||||
|
{
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
|
||||||
|
Assert.False((await service.CheckAsync(_agent, new BinaryEvidence("not-a-hash", null, null), default)).Accept);
|
||||||
|
Assert.False((await service.CheckAsync(_agent, new BinaryEvidence(new string('A', 64), null, null), default)).Accept); // 要小写十六进制
|
||||||
|
Assert.Null(await _store.GetAsync(_agent.Id.Value, default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheAdministratorCanAcceptANewBinary_ByClearingTheBinding()
|
||||||
|
{
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||||
|
Assert.False((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept);
|
||||||
|
|
||||||
|
Assert.True(await service.ClearAsync(_agent.Id.Value, default));
|
||||||
|
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept);
|
||||||
|
Assert.Equal(H('b'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WarnRecordsButDoesNotRefuse_AndOffDoesNothing()
|
||||||
|
{
|
||||||
|
BinaryBindingService warn = Service("warn");
|
||||||
|
await warn.CheckAsync(_agent, Evidence(H('a')), default);
|
||||||
|
BinaryDecision decision = await warn.CheckAsync(_agent, Evidence(H('b')), default);
|
||||||
|
Assert.True(decision.Accept);
|
||||||
|
Assert.NotNull((await _store.GetAsync(_agent.Id.Value, default))!.Violation);
|
||||||
|
|
||||||
|
BinaryBindingService off = Service("off");
|
||||||
|
NodeRecord other = Node(AgentIds.Prefix + "_test2");
|
||||||
|
Assert.True((await off.CheckAsync(other, Evidence(H('a')), default)).Accept);
|
||||||
|
Assert.Null(await _store.GetAsync(other.Id.Value, default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EachNodeHasItsOwnBinding()
|
||||||
|
{
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
NodeRecord second = Node(AgentIds.Prefix + "_test3");
|
||||||
|
|
||||||
|
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||||
|
await service.CheckAsync(second, Evidence(H('b')), default);
|
||||||
|
|
||||||
|
Assert.False((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept); // 别的节点的二进制,不是自己绑定的
|
||||||
|
Assert.True((await service.CheckAsync(second, Evidence(H('b')), default)).Accept);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>面板手里的、CI 签名的清单(登记时存下的):没有更新器的节点握手时没有自己的清单,就拿它来认官方二进制。</summary>
|
||||||
|
private void PanelHolds(string component, long seq, string[] binaries, ECDsa? signer = null)
|
||||||
|
{
|
||||||
|
string manifest = $"{{\"schema\":1,\"component\":\"{component}\",\"seq\":{seq},\"binaries\":\"{string.Join(',', binaries)}\"}}";
|
||||||
|
_settings.Values["release." + component + ".manifest"] = manifest;
|
||||||
|
_settings.Values["release." + component + ".signature"] = Sign(signer ?? _ci, manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ANodeWithoutAnUpdater_IsMarkedSigned_WhenThePanelHoldsAManifestThatListsItsBinary()
|
||||||
|
{
|
||||||
|
// 一键容器安装的节点没有宿主机更新器,握手时带不来清单:面板用自己手里(登记时验过 CI 签名)的那份
|
||||||
|
PanelHolds("agent", seq: 100, [H('a')]);
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||||
|
|
||||||
|
NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!;
|
||||||
|
Assert.True(bound.Signed);
|
||||||
|
Assert.Equal(100, bound.Seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheManifestThePanelHolds_NeverRefusesANodeAtFirstBinding_OnlyLabelsIt()
|
||||||
|
{
|
||||||
|
// 老一点的官方版本、自己构建的版本:不在面板手里最新的清单里,但没有证据说它不是官方的,所以不拒绝,只是不标成已签名
|
||||||
|
PanelHolds("agent", seq: 100, [H('b')]);
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||||
|
|
||||||
|
NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!;
|
||||||
|
Assert.False(bound.Signed);
|
||||||
|
Assert.Equal(0, bound.Seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AManifestThePanelHoldsThatIsSignedByAnotherKey_ProvesNothing()
|
||||||
|
{
|
||||||
|
PanelHolds("agent", seq: 100, [H('a')], signer: _other);
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||||
|
|
||||||
|
Assert.False((await _store.GetAsync(_agent.Id.Value, default))!.Signed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUpgradeOfANodeWithoutAnUpdater_IsFollowedFromTheManifestThePanelHolds()
|
||||||
|
{
|
||||||
|
PanelHolds("agent", seq: 100, [H('a')]);
|
||||||
|
BinaryBindingService service = Service();
|
||||||
|
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||||
|
|
||||||
|
// 之后发布了新版,面板登记了新的清单;节点换成新二进制重连
|
||||||
|
PanelHolds("agent", seq: 200, [H('b')]);
|
||||||
|
Assert.True((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept);
|
||||||
|
Assert.Equal(200, (await _store.GetAsync(_agent.Id.Value, default))!.Seq);
|
||||||
|
|
||||||
|
// 没人签过的二进制仍然被拒绝
|
||||||
|
Assert.False((await service.CheckAsync(_agent, Evidence(H('c')), default)).Accept);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MemorySettings : ISettingsStore
|
||||||
|
{
|
||||||
|
public Dictionary<string, string> Values { get; } = [];
|
||||||
|
|
||||||
|
public ValueTask<string?> GetAsync(string key, CancellationToken ct) => new(Values.GetValueOrDefault(key));
|
||||||
|
|
||||||
|
public ValueTask SetAsync(string key, string value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Values[key] = value;
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken ct) => new(Values);
|
||||||
|
|
||||||
|
public ValueTask<long> IncrementAsync(string key, CancellationToken ct) => new(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MemoryStore : INodeBindingStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, NodeBinding> _rows = [];
|
||||||
|
|
||||||
|
public ValueTask<NodeBinding?> GetAsync(string nodeId, CancellationToken ct) => new(_rows.GetValueOrDefault(nodeId));
|
||||||
|
|
||||||
|
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) => new([.. _rows.Values]);
|
||||||
|
|
||||||
|
public ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_rows[binding.NodeId] = binding;
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<bool> DeleteAsync(string nodeId, CancellationToken ct) => new(_rows.Remove(nodeId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>真实的 Agent 在进程里连上 Backend:第一次握手就绑定了它正在运行的二进制,管理端看得到,也可以清除。</summary>
|
||||||
|
[Collection("probe-e2e")]
|
||||||
|
public sealed class BinaryBindingEndToEndTests
|
||||||
|
{
|
||||||
|
private readonly BackendFixture _backend;
|
||||||
|
|
||||||
|
public BinaryBindingEndToEndTests(BackendFixture backend)
|
||||||
|
{
|
||||||
|
_backend = backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ARealAgent_IsBoundOnItsFirstHandshake_AndTheAdministratorCanClearIt()
|
||||||
|
{
|
||||||
|
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||||
|
string? expected = NodeBinary.Sha256Hex;
|
||||||
|
Assert.NotNull(expected);
|
||||||
|
|
||||||
|
JsonNode? binary = null;
|
||||||
|
await BackendFixture.EventuallyAsync(
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||||
|
binary = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id)?["binary"];
|
||||||
|
return binary is not null;
|
||||||
|
},
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
"the agent is bound on its first handshake");
|
||||||
|
|
||||||
|
Assert.Equal(expected, binary!["sha256"]!.GetValue<string>());
|
||||||
|
Assert.False(binary["signed"]!.GetValue<bool>()); // 测试里的 Agent 没有更新器,也就没有 CI 签名的清单
|
||||||
|
Assert.Null(binary["violation"]);
|
||||||
|
|
||||||
|
using HttpResponseMessage cleared = await _backend.Admin.DeleteAsync("/api/admin/nodes/" + agent.Id + "/binary");
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, cleared.StatusCode);
|
||||||
|
JsonNode after = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||||
|
Assert.Null(after["nodes"]!.AsArray().First(n => n!["id"]!.GetValue<string>() == agent.Id)!["binary"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ public sealed class CapabilityTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 只有登录(密码和 passkey)是匿名的
|
// 只有登录(密码和 passkey)是匿名的
|
||||||
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
|
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options", "/api/admin/auth/setup"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
namespace MoShiro.Backend.Tests;
|
|
||||||
|
|
||||||
/// <summary>提权窗口:信任类操作(加节点、吊销、改凭证)要在登录之外再触碰一次 passkey;API key 自带能力不受影响。设计见 docs/access.md 第 4 节。</summary>
|
|
||||||
[Collection("probe-e2e")]
|
|
||||||
public sealed class ElevationTests
|
|
||||||
{
|
|
||||||
private const string Password = "correct horse battery";
|
|
||||||
|
|
||||||
private readonly BackendFixture _backend;
|
|
||||||
|
|
||||||
public ElevationTests(BackendFixture backend)
|
|
||||||
{
|
|
||||||
_backend = backend;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority);
|
|
||||||
|
|
||||||
private string RpId => _backend.BaseAddress.Host;
|
|
||||||
|
|
||||||
/// <summary>新建一个用户并用密码登录(会话令牌走 Bearer)。每个测试用自己的用户,互不影响。</summary>
|
|
||||||
private async Task<HttpClient> NewSessionAsync()
|
|
||||||
{
|
|
||||||
string name = _backend.UniqueName("elev");
|
|
||||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password }));
|
|
||||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
|
||||||
HttpClient client = _backend.NewClient();
|
|
||||||
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true }));
|
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(HttpClient, SoftwarePasskey)> StrongAsync()
|
|
||||||
{
|
|
||||||
(HttpClient client, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("elev");
|
|
||||||
return (client, passkey);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<SoftwarePasskey> RegisterAsync(HttpClient session, string name = "key")
|
|
||||||
{
|
|
||||||
var passkey = new SoftwarePasskey(RpId, Origin);
|
|
||||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null);
|
|
||||||
Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync());
|
|
||||||
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
|
||||||
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, challenge)));
|
|
||||||
Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync());
|
|
||||||
return passkey;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<HttpResponseMessage> ElevateAsync(HttpClient session, SoftwarePasskey passkey, string cls, string? optionsFor = null)
|
|
||||||
{
|
|
||||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = optionsFor ?? cls }));
|
|
||||||
Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync());
|
|
||||||
JsonNode body = JsonNode.Parse(await options.Content.ReadAsStringAsync())!;
|
|
||||||
Assert.Contains(passkey.Id, body["allow_credentials"]!.AsArray().Select(x => x!.GetValue<string>())); // 浏览器只会让用户选自己的 passkey
|
|
||||||
JsonObject assertion = passkey.Assert(body["challenge"]!.GetValue<string>());
|
|
||||||
assertion["class"] = cls;
|
|
||||||
return await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue<string>();
|
|
||||||
|
|
||||||
/// <summary>需要「吊销」窗口的操作(删除节点):有窗口时返回 204(删除是幂等的),没有窗口返回 403 elevation_required。</summary>
|
|
||||||
private static Task<HttpResponseMessage> Gated(HttpClient client) => client.DeleteAsync("/api/admin/agents/agt_nobody");
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens()
|
|
||||||
{
|
|
||||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
|
||||||
using HttpClient sessionScope = session;
|
|
||||||
using SoftwarePasskey passkeyScope = passkey;
|
|
||||||
|
|
||||||
using HttpResponseMessage denied = await Gated(session);
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
|
||||||
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
|
|
||||||
Assert.Contains("revoke", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
|
||||||
|
|
||||||
using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "revoke");
|
|
||||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 有窗口:到了业务层(删除是幂等的)
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 窗口内不用再触碰
|
|
||||||
|
|
||||||
// 窗口只管它那一种
|
|
||||||
using HttpResponseMessage credential = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("x"), ["password"] = Password }));
|
|
||||||
Assert.Equal("elevation_required", await ProblemCodeAsync(credential));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow()
|
|
||||||
{
|
|
||||||
using HttpResponseMessage allowed = await Gated(_backend.Admin);
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); // API key 自带能力,不经过提权窗口
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TheWindowCanBeListed_AndEndedEarly()
|
|
||||||
{
|
|
||||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
|
||||||
using HttpClient sessionScope = session;
|
|
||||||
using SoftwarePasskey passkeyScope = passkey;
|
|
||||||
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
|
|
||||||
|
|
||||||
(await ElevateAsync(session, passkey, "revoke")).Dispose();
|
|
||||||
JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray();
|
|
||||||
Assert.Equal("revoke", Assert.Single(open)!["class"]!.GetValue<string>());
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/elevation")).StatusCode);
|
|
||||||
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(session)).StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ACredentialWindow_IsSingleUse()
|
|
||||||
{
|
|
||||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
|
||||||
using HttpClient sessionScope = session;
|
|
||||||
using SoftwarePasskey passkeyScope = passkey;
|
|
||||||
|
|
||||||
(await ElevateAsync(session, passkey, "credential")).Dispose();
|
|
||||||
using HttpResponseMessage first = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
|
|
||||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
|
||||||
using HttpResponseMessage second = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
|
|
||||||
Assert.Equal("elevation_required", await ProblemCodeAsync(second));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow()
|
|
||||||
{
|
|
||||||
(HttpClient mine, SoftwarePasskey myPasskey) = await StrongAsync();
|
|
||||||
(HttpClient theirs, SoftwarePasskey theirPasskey) = await StrongAsync();
|
|
||||||
|
|
||||||
// 别人的 passkey 签了我的挑战:不行(也不在 allow_credentials 里)
|
|
||||||
using HttpResponseMessage options = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
|
||||||
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
|
||||||
JsonObject foreign = theirPasskey.Assert(challenge);
|
|
||||||
foreign["class"] = "enroll";
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(foreign))).StatusCode);
|
|
||||||
|
|
||||||
// 挑战是给 revoke 要的,提交成 enroll:不行
|
|
||||||
using HttpResponseMessage wrongKind = await ElevateAsync(mine, myPasskey, "enroll", optionsFor: "revoke");
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, wrongKind.StatusCode);
|
|
||||||
|
|
||||||
// 挑战只能用一次
|
|
||||||
using HttpResponseMessage options2 = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
|
||||||
string challenge2 = JsonNode.Parse(await options2.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
|
||||||
JsonObject once = myPasskey.Assert(challenge2);
|
|
||||||
once["class"] = "enroll";
|
|
||||||
Assert.Equal(HttpStatusCode.OK, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
|
|
||||||
|
|
||||||
// 另一个用户的会话没有因此提权
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(theirs)).StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task WithoutAPasskey_ThereIsNothingToTouch_AndTheFirstPasskeyNeedsNoWindow()
|
|
||||||
{
|
|
||||||
using HttpClient session = await NewSessionAsync();
|
|
||||||
using HttpResponseMessage none = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode);
|
|
||||||
|
|
||||||
using SoftwarePasskey first = await RegisterAsync(session, "first"); // 第一个:不要求提权
|
|
||||||
using HttpResponseMessage second = await session.PostAsync("/api/admin/auth/passkeys/options", null);
|
|
||||||
Assert.Equal("elevation_required", await ProblemCodeAsync(second)); // 第二个起要先用已有的触碰
|
|
||||||
(await ElevateAsync(session, first, "credential")).Dispose();
|
|
||||||
using SoftwarePasskey added = await RegisterAsync(session, "second");
|
|
||||||
|
|
||||||
// 删除也要窗口
|
|
||||||
(await ElevateAsync(session, first, "credential")).Dispose();
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/passkeys/" + added.Id)).StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await session.DeleteAsync("/api/admin/auth/passkeys/" + first.Id)).StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate()
|
|
||||||
{
|
|
||||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
|
||||||
using HttpClient sessionScope = session;
|
|
||||||
using SoftwarePasskey passkeyScope = passkey;
|
|
||||||
var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.revoke") };
|
|
||||||
var harmless = new JsonObject { ["name"] = _backend.UniqueName("ro"), ["capabilities"] = new JsonArray("fleet.read") };
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(harmless))).StatusCode);
|
|
||||||
using HttpResponseMessage denied = await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted));
|
|
||||||
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
|
|
||||||
(await ElevateAsync(session, passkey, "credential")).Dispose();
|
|
||||||
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted))).StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ListingUsers_NeedsOnlyTheReadCapability()
|
|
||||||
{
|
|
||||||
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
|
||||||
using HttpClient sessionScope = session;
|
|
||||||
using SoftwarePasskey passkeyScope = passkey;
|
|
||||||
Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,7 @@ public sealed partial class OpenApiConformanceTests
|
|||||||
foreach ((string path, _) in doc["paths"]!.AsObject())
|
foreach ((string path, _) in doc["paths"]!.AsObject())
|
||||||
{
|
{
|
||||||
string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"];
|
string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"];
|
||||||
string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig"];
|
string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/api/release/offer"];
|
||||||
Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers");
|
Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers");
|
||||||
foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||||
{
|
{
|
||||||
@@ -122,7 +122,7 @@ public sealed partial class OpenApiConformanceTests
|
|||||||
public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures()
|
public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures()
|
||||||
{
|
{
|
||||||
JsonObject doc = await DocumentAsync();
|
JsonObject doc = await DocumentAsync();
|
||||||
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options")))
|
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options" or "/api/admin/auth/setup")))
|
||||||
{
|
{
|
||||||
JsonObject responses = operation["responses"]!.AsObject();
|
JsonObject responses = operation["responses"]!.AsObject();
|
||||||
Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401");
|
Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401");
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Fleet.Identity;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每个 Agent 和 Probe 都必须有属主(passkey):属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地。
|
||||||
|
/// 没有信任根、没选属主、选了操作员集合之外的凭证,都不能创建加入码;安装脚本和容器入口缺了属主也不装。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OwnerPolicyTests
|
||||||
|
{
|
||||||
|
private static OperatorSet Set(params string[] ids) =>
|
||||||
|
new(1, OperatorTrust.Genesis, "example.test", ["https://example.test"], [.. ids.Select(id => new OperatorKey(id, [1, 2, 3]))]);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithoutATrustRoot_AnOwnerCannotBeChosen_WhenOwnersAreRequired()
|
||||||
|
{
|
||||||
|
Assert.False(OwnerPolicy.Resolve(required: true, null, null).IsSuccess);
|
||||||
|
Assert.False(OwnerPolicy.Resolve(required: true, null, ["a"]).IsSuccess);
|
||||||
|
Assert.Empty(OwnerPolicy.Resolve(required: false, null, null).Value); // 没有信任根的老部署:不固定属主
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ByDefaultEveryOperatorIsAnOwner_AndAnExplicitChoiceMustBeNonEmptyAndKnown()
|
||||||
|
{
|
||||||
|
OperatorSet set = Set("a", "b");
|
||||||
|
|
||||||
|
Assert.Equal(["a", "b"], OwnerPolicy.Resolve(true, set, null).Value);
|
||||||
|
Assert.Equal(["b"], OwnerPolicy.Resolve(true, set, ["b", "b"]).Value);
|
||||||
|
Assert.False(OwnerPolicy.Resolve(true, set, []).IsSuccess); // 一把都不选:不行
|
||||||
|
Assert.False(OwnerPolicy.Resolve(true, set, ["a", "stranger"]).IsSuccess); // 不是操作员集合里的凭证
|
||||||
|
Assert.False(OwnerPolicy.Resolve(false, set, ["stranger"]).IsSuccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection("probe-e2e")]
|
||||||
|
public sealed class OwnerEndToEndTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly BackendFixture _backend;
|
||||||
|
private readonly BackendOptions _options;
|
||||||
|
private readonly bool _before;
|
||||||
|
|
||||||
|
public OwnerEndToEndTests(BackendFixture backend)
|
||||||
|
{
|
||||||
|
_backend = backend;
|
||||||
|
_options = backend.App.Services.GetRequiredService<BackendOptions>();
|
||||||
|
_before = _options.RequireOwner;
|
||||||
|
_options.RequireOwner = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _options.RequireOwner = _before;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ANodeCannotBeAddedWithoutAnOwner()
|
||||||
|
{
|
||||||
|
Operator op = await SharedOperator.GetAsync(_backend); // 共享的操作员:不要释放它
|
||||||
|
HttpClient session = op.Session;
|
||||||
|
string known = op.Passkey.Id;
|
||||||
|
|
||||||
|
using HttpResponseMessage none = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["controllers"] = new JsonArray() }));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode);
|
||||||
|
Assert.Contains("owner", await none.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
using HttpResponseMessage stranger = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["controllers"] = new JsonArray("not-an-operator") }));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, stranger.StatusCode);
|
||||||
|
|
||||||
|
// 明确选了一把,或者不指定(默认是全部操作员):可以,命令里带着创世指纹和属主
|
||||||
|
using HttpResponseMessage chosen = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["controllers"] = new JsonArray(known) }));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, chosen.StatusCode);
|
||||||
|
string linux = JsonNode.Parse(await chosen.Content.ReadAsStringAsync())!["install_commands"]!["linux"]!.GetValue<string>();
|
||||||
|
Assert.Contains("MOSHIRO_GENESIS=", linux, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("MOSHIRO_CONTROLLERS=", linux, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(known, linux, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
using HttpResponseMessage defaults = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe" }));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, defaults.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheInstallScripts_RefuseANodeWithoutAnOwner()
|
||||||
|
{
|
||||||
|
// 脚本是静态的,所以可以直接检查:缺了信任根指纹或属主就不装,只有明确设置 MOSHIRO_ALLOW_NO_OWNER=1(没有信任根的老面板)才放行
|
||||||
|
using HttpClient anonymous = _backend.NewClient();
|
||||||
|
foreach (string path in new[] { "/install", "/install.ps1" })
|
||||||
|
{
|
||||||
|
string script = await anonymous.GetStringAsync(path);
|
||||||
|
Assert.Contains("MOSHIRO_ALLOW_NO_OWNER", script, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("this node has no owner", script, StringComparison.Ordinal);
|
||||||
|
// 只按容器判断「已经装过」:podman 的 inspect 不分类型,本机残留的旧镜像会被误当成已安装的容器
|
||||||
|
Assert.Contains("container inspect", script, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("inspect \"$container\"", script.Replace("container inspect", ""), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,7 +112,7 @@ public sealed class PasskeyTests
|
|||||||
|
|
||||||
private string _user = "";
|
private string _user = "";
|
||||||
|
|
||||||
/// <summary>每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,再加就要提权,而这里测的是第一个 passkey 的流程。</summary>
|
/// <summary>每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,而这里测的是第一个 passkey 的流程。</summary>
|
||||||
private async Task<HttpClient> FreshSessionAsync()
|
private async Task<HttpClient> FreshSessionAsync()
|
||||||
{
|
{
|
||||||
_user = _backend.UniqueName("pk");
|
_user = _backend.UniqueName("pk");
|
||||||
@@ -121,16 +121,6 @@ public sealed class PasskeyTests
|
|||||||
return await PasswordSessionAsync(_user, "correct horse battery");
|
return await PasswordSessionAsync(_user, "correct horse battery");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>用已有的 passkey 触碰一次,开凭证窗口(删除 passkey 需要)。</summary>
|
|
||||||
private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey)
|
|
||||||
{
|
|
||||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" }));
|
|
||||||
JsonObject assertion = passkey.Assert((await JsonOf(options))["challenge"]!.GetValue<string>());
|
|
||||||
assertion["class"] = "credential";
|
|
||||||
using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
|
|
||||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<JsonNode> JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
|
private static async Task<JsonNode> JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
|
||||||
|
|
||||||
private async Task<string> RegistrationChallengeAsync(HttpClient session)
|
private async Task<string> RegistrationChallengeAsync(HttpClient session)
|
||||||
@@ -251,12 +241,8 @@ public sealed class PasskeyTests
|
|||||||
// 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册
|
// 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册
|
||||||
string challenge = await RegistrationChallengeAsync(session);
|
string challenge = await RegistrationChallengeAsync(session);
|
||||||
Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge)));
|
Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge)));
|
||||||
// 已经有 passkey 了,再注册要先触碰一次(凭证窗口,用一次就没有)
|
|
||||||
await ElevateAsync(session, passkey);
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次
|
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次
|
||||||
await ElevateAsync(session, passkey);
|
|
||||||
Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session))));
|
Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session))));
|
||||||
await ElevateAsync(session, passkey);
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync())));
|
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,11 +256,9 @@ public sealed class PasskeyTests
|
|||||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = other, ["password"] = "correct horse battery" }));
|
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = other, ["password"] = "correct horse battery" }));
|
||||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||||
using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery");
|
using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery");
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 没有窗口,连试都不行
|
Assert.Equal(HttpStatusCode.NotFound, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 别人的 passkey:看不见,也删不了
|
||||||
Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == passkey.Id);
|
Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == passkey.Id);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 删除要先触碰
|
|
||||||
await ElevateAsync(admin, passkey);
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync())));
|
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync())));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面板没有登记过清单时(清过库、CI 登记那一步网络失败),直接从镜像仓库里匿名读取签名清单;取回来的要用 CI 的发布公钥验过才采用,
|
||||||
|
/// 所以仓库(或者路上的人)给一份伪造的,面板拒绝,安装脚本也就得不到它。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReleaseFeedTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private WebApplication _registry = null!;
|
||||||
|
private string _registryUrl = "";
|
||||||
|
private string _label = "";
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(["--urls", "http://127.0.0.1:0"]);
|
||||||
|
_registry = builder.Build();
|
||||||
|
const string ConfigDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000001";
|
||||||
|
_registry.MapGet("/v2/moshiro-dev/moshiro-agent-manifest/manifests/dev", () => Results.Text(new JsonObject { ["schemaVersion"] = 2, ["config"] = new JsonObject { ["digest"] = ConfigDigest } }.ToJsonString(), "application/vnd.oci.image.manifest.v1+json"));
|
||||||
|
_registry.MapGet("/v2/moshiro-dev/moshiro-agent-manifest/blobs/" + ConfigDigest, () => Results.Text(
|
||||||
|
new JsonObject { ["config"] = new JsonObject { ["Labels"] = new JsonObject { ["org.moshiro.manifest"] = _label, ["org.moshiro.signature"] = "c2ln" } } }.ToJsonString(), "application/json"));
|
||||||
|
await _registry.StartAsync();
|
||||||
|
_registryUrl = _registry.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _registry.StopAsync();
|
||||||
|
await _registry.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheManifestAndSignature_AreReadFromTheLabelsOfTheSmallImage()
|
||||||
|
{
|
||||||
|
string manifest = "{\"schema\":1,\"component\":\"agent\",\"seq\":7}";
|
||||||
|
_label = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifest));
|
||||||
|
using var feed = new ReleaseFeed(new BackendOptions { NodeImageRegistry = _registryUrl });
|
||||||
|
|
||||||
|
(string Manifest, string Signature)? fetched = await feed.FetchAsync("agent", "dev", default);
|
||||||
|
|
||||||
|
Assert.Equal(manifest, fetched!.Value.Manifest);
|
||||||
|
Assert.Equal("c2ln", fetched.Value.Signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("probe", "dev")] // 仓库里没有这个组件的清单
|
||||||
|
[InlineData("agent", "release")] // 没有这个通道
|
||||||
|
public async Task NothingThere_MeansNull_NotAnException(string component, string channel)
|
||||||
|
{
|
||||||
|
using var feed = new ReleaseFeed(new BackendOptions { NodeImageRegistry = _registryUrl });
|
||||||
|
|
||||||
|
Assert.Null(await feed.FetchAsync(component, channel, default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUnreachableOrEmptyRegistry_MeansNull()
|
||||||
|
{
|
||||||
|
using var unreachable = new ReleaseFeed(new BackendOptions { NodeImageRegistry = "http://127.0.0.1:1" });
|
||||||
|
using var empty = new ReleaseFeed(new BackendOptions { NodeImageRegistry = "" });
|
||||||
|
|
||||||
|
Assert.Null(await unreachable.FetchAsync("agent", "dev", default));
|
||||||
|
Assert.Null(await empty.FetchAsync("agent", "dev", default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AForgedManifestInTheRegistry_IsNeverServedToTheInstaller()
|
||||||
|
{
|
||||||
|
// 起一个指向这个假仓库的后端:仓库里是一份没有 CI 签名的清单,安装脚本向面板要清单时必须拿不到
|
||||||
|
string directory = Path.Combine(Path.GetTempPath(), "moshiro-feed-" + Guid.NewGuid().ToString("N"));
|
||||||
|
_label = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"schema\":1,\"component\":\"agent\",\"channel\":\"dev\",\"image\":\"evil/agent\",\"digest\":\"sha256:" + new string('a', 64) + "\",\"tag\":\"x\",\"version\":\"9\",\"seq\":999999999999}"));
|
||||||
|
await using WebApplication panel = BackendHost.Build(
|
||||||
|
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
|
||||||
|
options =>
|
||||||
|
{
|
||||||
|
options.DataDirectory = directory;
|
||||||
|
options.Pbkdf2Iterations = 1000;
|
||||||
|
options.SecureCookies = false;
|
||||||
|
options.ClaimCode = "FEED-TEST-CODE";
|
||||||
|
options.NodeImageRegistry = _registryUrl;
|
||||||
|
options.UpdateChannel = "dev";
|
||||||
|
options.GeoLookupUrl = string.Empty;
|
||||||
|
options.RequireOwner = false;
|
||||||
|
});
|
||||||
|
await panel.StartAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { BaseAddress = new Uri(panel.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
|
||||||
|
using HttpResponseMessage setup = await http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = "FEED-TEST-CODE", ["username"] = "owner", ["password"] = "correct horse battery", ["return_session"] = true }));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, setup.StatusCode);
|
||||||
|
http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", JsonNode.Parse(await setup.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||||
|
using HttpResponseMessage created = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||||
|
string code = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||||
|
|
||||||
|
using HttpResponseMessage manifest = await http.GetAsync("/install/manifest?code=" + Uri.EscapeDataString(code));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, manifest.StatusCode); // 没有有效的 CI 签名,面板不采用
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await panel.StopAsync();
|
||||||
|
await panel.DisposeAsync();
|
||||||
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// 系统临时目录里的残留
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using MoShiro.Backend.Api;
|
||||||
|
using MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CI 登记清单不需要令牌,但清单必须带 CI 发布密钥的有效签名:令牌存在面板的数据库里,清库、重装或还没有 passkey 的时候就拿不到,
|
||||||
|
/// 而清单本来就是签名的,面板直接验签更简单,也没有长期令牌可偷。
|
||||||
|
/// </summary>
|
||||||
|
[Collection("probe-e2e")]
|
||||||
|
public sealed class ReleaseOfferTests
|
||||||
|
{
|
||||||
|
// openssl 生成的测试密钥对和签名(`openssl dgst -sha256 -sign`,DER):证明验签接受 CI 脚本实际产出的格式
|
||||||
|
private const string TestKey = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQwlE2KjYWszeaTsng6CfPxhOqeIV\n+7dm940f7XfHJr9A6/OTiZ2YnhFWWipZ4Z4poPtb6hMW04tIARO8+Km34w==\n-----END PUBLIC KEY-----";
|
||||||
|
private const string Manifest = "{\"schema\":1,\"component\":\"agent\",\"seq\":7}";
|
||||||
|
private const string OpenSslSignature = "MEUCIDrOloi3otWBiCFcXEsPg6kHCS7G09PrO3hW+3AKXAiZAiEAvXgt9K+iZXnWiEgKdanBKbEoL0LyT/xuaEW6dAG0jDY=";
|
||||||
|
|
||||||
|
private readonly BackendFixture _backend;
|
||||||
|
|
||||||
|
public ReleaseOfferTests(BackendFixture backend)
|
||||||
|
{
|
||||||
|
_backend = backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ASignatureFromOpenSsl_Verifies_AndAnyChangeDoesNot()
|
||||||
|
{
|
||||||
|
Assert.True(ReleaseSignature.Verify(TestKey, Manifest, OpenSslSignature));
|
||||||
|
|
||||||
|
Assert.False(ReleaseSignature.Verify(TestKey, Manifest.Replace("7", "8", StringComparison.Ordinal), OpenSslSignature));
|
||||||
|
Assert.False(ReleaseSignature.Verify(TestKey, Manifest + " ", OpenSslSignature));
|
||||||
|
|
||||||
|
byte[] tampered = Convert.FromBase64String(OpenSslSignature);
|
||||||
|
tampered[10] ^= 1;
|
||||||
|
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, Convert.ToBase64String(tampered)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnotherKey_GarbageAndEmptyInputs_AreRejected()
|
||||||
|
{
|
||||||
|
using var other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
string signature = Convert.ToBase64String(other.SignData(Encoding.UTF8.GetBytes(Manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
|
||||||
|
|
||||||
|
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, signature));
|
||||||
|
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, "not base64!!"));
|
||||||
|
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, ""));
|
||||||
|
Assert.False(ReleaseSignature.Verify("not a key", Manifest, OpenSslSignature));
|
||||||
|
// P1363(节点和 WebAuthn 用的格式)不是 CI 签名的格式
|
||||||
|
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, Convert.ToBase64String(new byte[64])));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheEmbeddedReleaseKey_IsAUsableP256PublicKey()
|
||||||
|
{
|
||||||
|
using var key = ECDsa.Create();
|
||||||
|
key.ImportFromPem(InstallScripts.ReleaseKey());
|
||||||
|
|
||||||
|
Assert.Equal(256, key.KeySize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{\"component\":\"agent\",\"channel\":\"dev\",\"image\":\"r/a\",\"digest\":\"sha256:0000000000000000000000000000000000000000000000000000000000000000\",\"seq\":99999999999}", "AAAA")]
|
||||||
|
[InlineData("", "")]
|
||||||
|
[InlineData("not json", "MEUCIDrOloi3otWBiCFcXEsPg6kHCS7G09PrO3hW+3AKXAiZAiEAvXgt9K+iZXnWiEgKdanBKbEoL0LyT/xuaEW6dAG0jDY=")]
|
||||||
|
public async Task OfferWithoutAValidCiSignature_IsForbidden_EvenWithoutAnyToken(string manifest, string signature)
|
||||||
|
{
|
||||||
|
using HttpClient anonymous = _backend.NewClient();
|
||||||
|
using HttpResponseMessage response = await anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature }));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheAdminEndpoint_StillNeedsTheCapability()
|
||||||
|
{
|
||||||
|
using HttpClient anonymous = _backend.NewClient();
|
||||||
|
using HttpResponseMessage response = await anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest, ["signature"] = OpenSslSignature }));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 首次认领:面板里没有任何用户时,部署的人自己填管理员的用户名和密码——没有默认账户、没有默认密码。
|
||||||
|
/// 谁先打开页面谁就能当管理员,所以要有只有部署的人才拿得到的认领码(写在后端日志里,或者部署时指定);创建完第一个管理员,入口就关了。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SetupTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string Code = "ABCD-EFGH-JKLM-NPQR";
|
||||||
|
|
||||||
|
private WebApplication _app = null!;
|
||||||
|
private HttpClient _http = null!;
|
||||||
|
private string _directory = "";
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
_directory = Path.Combine(Path.GetTempPath(), "moshiro-setup-" + Guid.NewGuid().ToString("N"));
|
||||||
|
_app = BackendHost.Build(
|
||||||
|
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
|
||||||
|
options =>
|
||||||
|
{
|
||||||
|
options.DataDirectory = _directory;
|
||||||
|
options.Pbkdf2Iterations = 1000;
|
||||||
|
options.SecureCookies = false;
|
||||||
|
options.ClaimCode = Code;
|
||||||
|
options.LoginAttemptsPerMinute = 1000;
|
||||||
|
options.GeoLookupUrl = string.Empty;
|
||||||
|
});
|
||||||
|
await _app.StartAsync();
|
||||||
|
_http = new HttpClient { BaseAddress = new Uri(_app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_http.Dispose();
|
||||||
|
await _app.StopAsync();
|
||||||
|
await _app.DisposeAsync();
|
||||||
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); // 连接池还拿着数据库文件
|
||||||
|
TryDelete(_directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>临时目录:尽力删除。SQLite 的 WAL 临时文件可能还没释放,留下一个临时目录比让测试失败好。</summary>
|
||||||
|
private static void TryDelete(string directory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// 系统临时目录里的残留,会被清理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<HttpResponseMessage> SetupAsync(string? code, string username, string password, bool returnSession = false) =>
|
||||||
|
_http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = code, ["username"] = username, ["password"] = password, ["return_session"] = returnSession }));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ABrandNewPanel_HasNoUsers_AndNoDefaultAccountToSignInWith()
|
||||||
|
{
|
||||||
|
JsonNode status = JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!;
|
||||||
|
Assert.True(status["needs_setup"]!.GetValue<bool>());
|
||||||
|
|
||||||
|
foreach (string username in new[] { "admin", "administrator", "root" })
|
||||||
|
{
|
||||||
|
using HttpResponseMessage login = await _http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = username, ["password"] = "admin" }));
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheClaimCode_IsRequired_SoTheFirstVisitorCannotTakeThePanel()
|
||||||
|
{
|
||||||
|
using HttpResponseMessage none = await SetupAsync(null, "owner", "correct horse battery");
|
||||||
|
using HttpResponseMessage wrong = await SetupAsync("AAAA-BBBB-CCCC-DDDD", "owner", "correct horse battery");
|
||||||
|
using HttpResponseMessage empty = await SetupAsync("", "owner", "correct horse battery");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, none.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, empty.StatusCode);
|
||||||
|
Assert.True(JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>()); // 还是没人
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheCredentialsAreValidated_AndAMistakeDoesNotBurnTheCode()
|
||||||
|
{
|
||||||
|
using HttpResponseMessage shortPassword = await SetupAsync(Code, "owner", "short");
|
||||||
|
using HttpResponseMessage badName = await SetupAsync(Code, "x", "correct horse battery");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, shortPassword.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, badName.StatusCode);
|
||||||
|
|
||||||
|
// 填错了还能再来
|
||||||
|
using HttpResponseMessage ok = await SetupAsync(Code, "owner", "correct horse battery");
|
||||||
|
Assert.Equal(HttpStatusCode.Created, ok.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheFirstAdministrator_IsCreatedByTheDeployer_SignedInRightAway_AndTheDoorCloses()
|
||||||
|
{
|
||||||
|
// 认领码不分大小写、不看分隔符
|
||||||
|
using HttpResponseMessage created = await SetupAsync("abcd efgh-jklm npqr", "owner", "correct horse battery", returnSession: true);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||||
|
JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
|
||||||
|
Assert.Equal("owner", body["username"]!.GetValue<string>());
|
||||||
|
Assert.False(string.IsNullOrEmpty(body["session_token"]?.GetValue<string>())); // 马上登录,接着引导 passkey 和信任根
|
||||||
|
|
||||||
|
// 入口关了:再用同一个码、别的用户名也不行,状态也变了
|
||||||
|
using HttpResponseMessage again = await SetupAsync(Code, "intruder", "correct horse battery");
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
||||||
|
Assert.False(JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>());
|
||||||
|
|
||||||
|
// 管理员能用自己填的账号密码正常登录(密码登录的会话有能力,passkey 之后再加)
|
||||||
|
using HttpResponseMessage login = await _http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "owner", ["password"] = "correct horse battery" }));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WithoutAConfiguredCode_TheBackendGeneratesOne_AndNothingElseUnlocksTheSetup()
|
||||||
|
{
|
||||||
|
// 另起一个没有指定认领码的后端:随机生成并写进日志,猜不到;任何别的码都进不去
|
||||||
|
string directory = Path.Combine(Path.GetTempPath(), "moshiro-setup-" + Guid.NewGuid().ToString("N"));
|
||||||
|
await using WebApplication generated = BackendHost.Build(
|
||||||
|
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
|
||||||
|
options =>
|
||||||
|
{
|
||||||
|
options.DataDirectory = directory;
|
||||||
|
options.Pbkdf2Iterations = 1000;
|
||||||
|
options.SecureCookies = false;
|
||||||
|
options.LoginAttemptsPerMinute = 1000;
|
||||||
|
options.GeoLookupUrl = string.Empty;
|
||||||
|
});
|
||||||
|
await generated.StartAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { BaseAddress = new Uri(generated.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
|
||||||
|
foreach (string guess in new[] { Code, "0000-0000-0000-0000", "admin", "TEST-CLAIM-CODE-0001" })
|
||||||
|
{
|
||||||
|
using HttpResponseMessage response = await http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = guess, ["username"] = "owner", ["password"] = "correct horse battery" }));
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(JsonNode.Parse(await http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await generated.StopAsync();
|
||||||
|
await generated.DisposeAsync();
|
||||||
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||||
|
TryDelete(directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Fleet.Identity;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Tests;
|
||||||
|
|
||||||
|
/// <summary>一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。</summary>
|
||||||
|
internal sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 共享的后端上只有一个信任根(创世只能签一次):谁先需要,谁来建立,其余的测试用同一个操作员。
|
||||||
|
/// 信任相关的测试都从这里拿,不要各自去建立信任根,否则测试的先后顺序会决定谁失败。
|
||||||
|
/// </summary>
|
||||||
|
internal static class SharedOperator
|
||||||
|
{
|
||||||
|
private static readonly ConditionalWeakTable<BackendFixture, Operator> s_operators = [];
|
||||||
|
private static readonly SemaphoreSlim s_gate = new(1, 1);
|
||||||
|
|
||||||
|
public static async Task<Operator> GetAsync(BackendFixture backend)
|
||||||
|
{
|
||||||
|
await s_gate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (s_operators.TryGetValue(backend, out Operator? existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作员:有 passkey、用 passkey 登录的会话
|
||||||
|
(HttpClient session, SoftwarePasskey passkey, _) = await backend.CreateStrongSessionAsync("operator");
|
||||||
|
|
||||||
|
// 创世:用 passkey 对集合本身签名(节点据此验链)
|
||||||
|
JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!;
|
||||||
|
Assert.False(state["initialized"]!.GetValue<bool>());
|
||||||
|
string statement = state["proposal"]!["statement"]!.GetValue<string>();
|
||||||
|
Assert.Equal(Base64UrlOf(statement), state["proposal"]!["challenge"]!.GetValue<string>());
|
||||||
|
using HttpResponseMessage genesis = await session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(passkey, statement)));
|
||||||
|
Assert.True(genesis.StatusCode == HttpStatusCode.Created, await genesis.Content.ReadAsStringAsync());
|
||||||
|
string fingerprint = JsonNode.Parse(await genesis.Content.ReadAsStringAsync())!["genesis"]!.GetValue<string>();
|
||||||
|
Assert.Equal(OperatorTrust.Fingerprint(statement), fingerprint);
|
||||||
|
|
||||||
|
var made = new Operator(session, passkey, fingerprint);
|
||||||
|
s_operators.Add(backend, made);
|
||||||
|
return made;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
s_gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
|
||||||
|
|
||||||
|
/// <summary>passkey 对语句签名(挑战是语句的 SHA-256),拼成接口要的断言对象。</summary>
|
||||||
|
public static JsonObject Sign(SoftwarePasskey passkey, string statement)
|
||||||
|
{
|
||||||
|
JsonObject assertion = passkey.Assert(Base64UrlOf(statement));
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["statement"] = statement,
|
||||||
|
["credential_id"] = assertion["credential_id"]!.DeepClone(),
|
||||||
|
["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(),
|
||||||
|
["client_data_json"] = assertion["client_data_json"]!.DeepClone(),
|
||||||
|
["signature"] = assertion["signature"]!.DeepClone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using MoShiro.Backend.Api;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内嵌前端:白名单、不碰文件系统,路径穿越的各种写法全部 404;SPA 回退只给没有扩展名的路径,不吞 API 的 404;
|
||||||
|
/// 页面带 CSP 等响应头,哈希文件名的资源长缓存、入口页不缓存。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StaticSiteTests
|
||||||
|
{
|
||||||
|
private static StaticSite Site() => new(
|
||||||
|
[
|
||||||
|
("/index.html", Encoding.UTF8.GetBytes("<html>display</html>")),
|
||||||
|
("/assets/app-1a2b.js", Encoding.UTF8.GetBytes("console.log(1)")),
|
||||||
|
("/flags/cn.svg", Encoding.UTF8.GetBytes("<svg/>")),
|
||||||
|
("/admin/index.html", Encoding.UTF8.GetBytes("<html>admin</html>")),
|
||||||
|
("/admin/assets/app-3c4d.css", Encoding.UTF8.GetBytes("body{}")),
|
||||||
|
("/../secret.txt", Encoding.UTF8.GetBytes("must never be registered")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
private static string Text(SiteAsset? asset) => Encoding.UTF8.GetString(asset!.Content);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KnownFiles_AreServed_WithTypeAndCaching()
|
||||||
|
{
|
||||||
|
StaticSite site = Site();
|
||||||
|
|
||||||
|
Assert.Equal("<html>display</html>", Text(site.Resolve("/").Asset));
|
||||||
|
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/").Asset));
|
||||||
|
|
||||||
|
SiteAsset? js = site.Resolve("/assets/app-1a2b.js").Asset;
|
||||||
|
Assert.StartsWith("text/javascript", js!.ContentType, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("immutable", js.CacheControl, StringComparison.Ordinal);
|
||||||
|
Assert.False(js.Html);
|
||||||
|
|
||||||
|
SiteAsset? index = site.Resolve("/index.html").Asset;
|
||||||
|
Assert.Equal("no-cache", index!.CacheControl);
|
||||||
|
Assert.True(index.Html);
|
||||||
|
Assert.Equal("image/svg+xml", site.Resolve("/flags/cn.svg").Asset!.ContentType);
|
||||||
|
Assert.StartsWith("text/css", site.Resolve("/admin/assets/app-3c4d.css").Asset!.ContentType, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdminWithoutSlash_RedirectsToAFixedPath()
|
||||||
|
{
|
||||||
|
SiteResolution resolution = Site().Resolve("/admin");
|
||||||
|
|
||||||
|
Assert.Null(resolution.Asset);
|
||||||
|
Assert.Equal("/admin/", resolution.Redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/../secret.txt")]
|
||||||
|
[InlineData("/admin/../../etc/passwd")]
|
||||||
|
[InlineData("/assets/../index.html")]
|
||||||
|
[InlineData("/./index.html")]
|
||||||
|
[InlineData("/%2e%2e/secret.txt")]
|
||||||
|
[InlineData("/%2E%2E%2Fsecret.txt")]
|
||||||
|
[InlineData("/..%2fsecret.txt")]
|
||||||
|
[InlineData("/..%5csecret.txt")]
|
||||||
|
[InlineData("/%252e%252e/secret.txt")]
|
||||||
|
[InlineData("/..\\secret.txt")]
|
||||||
|
[InlineData("/assets\\app-1a2b.js")]
|
||||||
|
[InlineData("/assets//app-1a2b.js")]
|
||||||
|
[InlineData("//assets/app-1a2b.js")]
|
||||||
|
[InlineData("/index.html\0.png")]
|
||||||
|
[InlineData("/index.html\r\nSet-Cookie: a=b")]
|
||||||
|
[InlineData("/文件")]
|
||||||
|
[InlineData("assets/app-1a2b.js")]
|
||||||
|
[InlineData("")]
|
||||||
|
public void TraversalAndMalformedPaths_AreNotFound_NeverNormalized(string path)
|
||||||
|
{
|
||||||
|
SiteResolution resolution = Site().Resolve(path);
|
||||||
|
|
||||||
|
Assert.Null(resolution.Asset);
|
||||||
|
Assert.Null(resolution.Redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OverlongPath_IsNotFound()
|
||||||
|
{
|
||||||
|
Assert.Null(Site().Resolve("/" + new string('a', 5000)).Asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpaFallback_OnlyForPathsWithoutAnExtension()
|
||||||
|
{
|
||||||
|
StaticSite site = Site();
|
||||||
|
|
||||||
|
Assert.Equal("<html>display</html>", Text(site.Resolve("/lines/abc").Asset));
|
||||||
|
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/agents").Asset));
|
||||||
|
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/system/trust").Asset));
|
||||||
|
|
||||||
|
// 缺失的文件不是页面:脚本、样式、图片 404 要保持 404,不能变成 HTML
|
||||||
|
Assert.Null(site.Resolve("/assets/missing.js").Asset);
|
||||||
|
Assert.Null(site.Resolve("/admin/assets/missing.css").Asset);
|
||||||
|
Assert.Null(site.Resolve("/favicon.ico").Asset);
|
||||||
|
Assert.Null(site.Resolve("/robots.txt").Asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdminAndDisplay_AreNotMixed()
|
||||||
|
{
|
||||||
|
StaticSite site = Site();
|
||||||
|
|
||||||
|
// 展示端的路径永远不会得到管理端的页面,反过来管理端的前缀之外也一样
|
||||||
|
Assert.Equal("<html>display</html>", Text(site.Resolve("/administrator").Asset));
|
||||||
|
Assert.Equal("<html>display</html>", Text(site.Resolve("/adminx/agents").Asset));
|
||||||
|
Assert.Null(site.Resolve("/admin/index.htm").Asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptySite_ServesNothing()
|
||||||
|
{
|
||||||
|
var site = new StaticSite([]);
|
||||||
|
|
||||||
|
Assert.Equal(0, site.Count);
|
||||||
|
Assert.Null(site.Resolve("/").Asset);
|
||||||
|
Assert.Null(site.Resolve("/admin/agents").Asset);
|
||||||
|
Assert.Null(site.Resolve("/admin").Redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UncleanPathsAreRejectedWhenRegistering()
|
||||||
|
{
|
||||||
|
StaticSite site = Site();
|
||||||
|
|
||||||
|
Assert.Equal(5, site.Count - 2); // 五个干净的文件;两个别名 "/" 和 "/admin/" 不算;"/../secret.txt" 没登记
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>HTTP 层:后端自己的路径不会被前端的回退吞掉;响应头对。</summary>
|
||||||
|
[Collection("probe-e2e")]
|
||||||
|
public sealed class StaticSiteHttpTests
|
||||||
|
{
|
||||||
|
private readonly BackendFixture _backend;
|
||||||
|
|
||||||
|
public StaticSiteHttpTests(BackendFixture backend)
|
||||||
|
{
|
||||||
|
_backend = backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/api/admin/does-not-exist")]
|
||||||
|
[InlineData("/api/public/does-not-exist")]
|
||||||
|
[InlineData("/api/agent/v1/nope")]
|
||||||
|
[InlineData("/api")]
|
||||||
|
[InlineData("/openapi/missing.json")]
|
||||||
|
[InlineData("/install/missing")]
|
||||||
|
[InlineData("/healthz/x")]
|
||||||
|
public async Task BackendPaths_KeepTheirOwnNotFound_NotTheFrontend(string path)
|
||||||
|
{
|
||||||
|
using HttpClient client = _backend.NewClient();
|
||||||
|
using HttpResponseMessage response = await client.GetAsync(path);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
string body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.DoesNotContain("<html", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/api/admin/agents")]
|
||||||
|
[InlineData("/healthz")]
|
||||||
|
public async Task ExistingEndpoints_AreNotShadowedByTheCatchAll(string path)
|
||||||
|
{
|
||||||
|
using HttpClient client = _backend.NewClient();
|
||||||
|
using HttpResponseMessage response = await client.GetAsync(path);
|
||||||
|
|
||||||
|
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
Assert.DoesNotContain("<html", await response.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/../etc/passwd")]
|
||||||
|
[InlineData("/%2e%2e/etc/passwd")]
|
||||||
|
[InlineData("/admin/..%2f..%2fetc/passwd")]
|
||||||
|
[InlineData("/..%5c..%5cwindows/win.ini")]
|
||||||
|
public async Task Traversal_NeverReturnsAFileFromTheHost(string path)
|
||||||
|
{
|
||||||
|
using HttpClient client = _backend.NewClient();
|
||||||
|
using HttpResponseMessage response = await client.GetAsync(path);
|
||||||
|
string body = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.DoesNotContain("root:", body, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("[fonts]", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WritesToTheSite_AreRejected()
|
||||||
|
{
|
||||||
|
using HttpClient client = _backend.NewClient();
|
||||||
|
foreach (HttpMethod method in new[] { HttpMethod.Post, HttpMethod.Put, HttpMethod.Delete, HttpMethod.Patch })
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(method, "/admin/index.html");
|
||||||
|
using HttpResponseMessage response = await client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.True(response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotFound, $"{method}: {response.StatusCode}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pages_CarryTheSecurityHeaders_WhenTheFrontendIsEmbedded()
|
||||||
|
{
|
||||||
|
using HttpClient client = _backend.NewClient();
|
||||||
|
using HttpResponseMessage response = await client.GetAsync("/admin/");
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
return; // 本地没构建前端时不嵌入(CI 的 Backend 测试同样没有);响应头的逻辑由上面的单元测试和发布构建覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single());
|
||||||
|
Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single());
|
||||||
|
string csp = response.Headers.GetValues("Content-Security-Policy").Single();
|
||||||
|
Assert.Contains("script-src 'self'", csp, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("frame-ancestors 'none'", csp, StringComparison.Ordinal);
|
||||||
|
Assert.Equal("no-cache", response.Headers.CacheControl!.ToString());
|
||||||
|
|
||||||
|
string etag = response.Headers.ETag!.ToString();
|
||||||
|
using var conditional = new HttpRequestMessage(HttpMethod.Get, "/admin/");
|
||||||
|
conditional.Headers.TryAddWithoutValidation("If-None-Match", etag);
|
||||||
|
using HttpResponseMessage notModified = await client.SendAsync(conditional);
|
||||||
|
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,6 @@ namespace MoShiro.Backend.Tests;
|
|||||||
[Collection("probe-e2e")]
|
[Collection("probe-e2e")]
|
||||||
public sealed class TrustTests
|
public sealed class TrustTests
|
||||||
{
|
{
|
||||||
private static readonly ConditionalWeakTable<BackendFixture, Operator> s_operators = [];
|
|
||||||
private static readonly SemaphoreSlim s_gate = new(1, 1);
|
|
||||||
|
|
||||||
private readonly BackendFixture _backend;
|
private readonly BackendFixture _backend;
|
||||||
|
|
||||||
public TrustTests(BackendFixture backend)
|
public TrustTests(BackendFixture backend)
|
||||||
@@ -30,67 +27,11 @@ public sealed class TrustTests
|
|||||||
_backend = backend;
|
_backend = backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。</summary>
|
private Task<Operator> OperatorAsync() => SharedOperator.GetAsync(_backend);
|
||||||
private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis);
|
|
||||||
|
|
||||||
private async Task<Operator> OperatorAsync()
|
private static string Base64UrlOf(string statement) => SharedOperator.Base64UrlOf(statement);
|
||||||
{
|
|
||||||
await s_gate.WaitAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (s_operators.TryGetValue(_backend, out Operator? existing))
|
|
||||||
{
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有)
|
private static JsonObject Sign(SoftwarePasskey passkey, string statement) => SharedOperator.Sign(passkey, statement);
|
||||||
(HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator");
|
|
||||||
|
|
||||||
// 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身
|
|
||||||
await ElevateAsync(session, passkey);
|
|
||||||
JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!;
|
|
||||||
Assert.False(state["initialized"]!.GetValue<bool>());
|
|
||||||
string statement = state["proposal"]!["statement"]!.GetValue<string>();
|
|
||||||
Assert.Equal(Base64UrlOf(statement), state["proposal"]!["challenge"]!.GetValue<string>());
|
|
||||||
using HttpResponseMessage genesis = await session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(passkey, statement)));
|
|
||||||
Assert.True(genesis.StatusCode == HttpStatusCode.Created, await genesis.Content.ReadAsStringAsync());
|
|
||||||
string fingerprint = JsonNode.Parse(await genesis.Content.ReadAsStringAsync())!["genesis"]!.GetValue<string>();
|
|
||||||
Assert.Equal(OperatorTrust.Fingerprint(statement), fingerprint);
|
|
||||||
|
|
||||||
var made = new Operator(session, passkey, fingerprint);
|
|
||||||
s_operators.Add(_backend, made);
|
|
||||||
return made;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
s_gate.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
|
|
||||||
|
|
||||||
/// <summary>passkey 对语句签名(挑战是语句的 SHA-256),拼成接口要的断言对象。</summary>
|
|
||||||
private static JsonObject Sign(SoftwarePasskey passkey, string statement)
|
|
||||||
{
|
|
||||||
JsonObject assertion = passkey.Assert(Base64UrlOf(statement));
|
|
||||||
return new JsonObject
|
|
||||||
{
|
|
||||||
["statement"] = statement,
|
|
||||||
["credential_id"] = assertion["credential_id"]!.DeepClone(),
|
|
||||||
["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(),
|
|
||||||
["client_data_json"] = assertion["client_data_json"]!.DeepClone(),
|
|
||||||
["signature"] = assertion["signature"]!.DeepClone(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey)
|
|
||||||
{
|
|
||||||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" }));
|
|
||||||
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
|
||||||
assertion["class"] = "credential";
|
|
||||||
using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
|
|
||||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
|
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
|
||||||
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null)
|
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null)
|
||||||
@@ -235,9 +176,9 @@ public sealed class TrustTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch()
|
public async Task APasswordSession_ManagesAsUsual_AndCreatingACodeNeedsNoPasskey()
|
||||||
{
|
{
|
||||||
// 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 passkey
|
// 密码登录保留:日常管理(看列表、创建加入码)不需要 passkey;passkey 只用在节点自己会验证的事上(操作员集合的签名)
|
||||||
string name = _backend.UniqueName("pwonly");
|
string name = _backend.UniqueName("pwonly");
|
||||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" }));
|
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" }));
|
||||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||||
@@ -245,14 +186,14 @@ public sealed class TrustTests
|
|||||||
using HttpResponseMessage login = await password.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true }));
|
using HttpResponseMessage login = await password.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true }));
|
||||||
password.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
password.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||||
JsonNode me = JsonNode.Parse(await password.GetStringAsync("/api/admin/auth/me"))!;
|
JsonNode me = JsonNode.Parse(await password.GetStringAsync("/api/admin/auth/me"))!;
|
||||||
Assert.Empty(me["capabilities"]!.AsArray());
|
Assert.NotEmpty(me["capabilities"]!.AsArray());
|
||||||
Assert.Equal("password", me["auth_method"]!.GetValue<string>());
|
Assert.Equal("password", me["auth_method"]!.GetValue<string>());
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await password.GetAsync("/api/admin/agents")).StatusCode);
|
Assert.Equal(HttpStatusCode.OK, (await password.GetAsync("/api/admin/agents")).StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode);
|
Assert.Equal(HttpStatusCode.Created, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode);
|
||||||
using HttpResponseMessage options = await password.PostAsync("/api/admin/auth/passkeys/options", null);
|
using HttpResponseMessage options = await password.PostAsync("/api/admin/auth/passkeys/options", null);
|
||||||
Assert.Equal(HttpStatusCode.OK, options.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, options.StatusCode);
|
||||||
|
|
||||||
// passkey 登录的会话创建码:不需要提权窗口、不需要再触碰(创建码不是信任动作,信任在属主固定里)
|
// passkey 登录的会话创建码:同样不需要再触碰(创建码不是信任动作,信任在属主固定里)
|
||||||
(HttpClient strong, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("creator");
|
(HttpClient strong, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("creator");
|
||||||
using HttpClient strongScope = strong;
|
using HttpClient strongScope = strong;
|
||||||
using SoftwarePasskey passkeyScope = passkey;
|
using SoftwarePasskey passkeyScope = passkey;
|
||||||
@@ -260,6 +201,62 @@ public sealed class TrustTests
|
|||||||
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTrustState_SaysWhichAccountsPasskeyEachOperatorIs()
|
||||||
|
{
|
||||||
|
// 添加节点时选属主、信任页都要显示「哪个账户的哪把 passkey」,不只是一串凭证 id
|
||||||
|
Operator op = await OperatorAsync();
|
||||||
|
|
||||||
|
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
|
||||||
|
|
||||||
|
JsonNode detail = state["operator_details"]!.AsArray().First(d => d!["credential_id"]!.GetValue<string>() == op.Passkey.Id)!;
|
||||||
|
Assert.False(string.IsNullOrEmpty(detail["passkey"]?.GetValue<string>()));
|
||||||
|
Assert.False(string.IsNullOrEmpty(detail["account"]?.GetValue<string>()));
|
||||||
|
Assert.Equal(state["operators"]!.AsArray().Count, state["operator_details"]!.AsArray().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APinnedAgent_ReportsItsOwner_AndThePanelFindsTheAccountAndPasskey()
|
||||||
|
{
|
||||||
|
// 宿主(属主)要求在节点这一侧:节点把安装时固定的属主报上来,面板顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上
|
||||||
|
Operator op = await OperatorAsync();
|
||||||
|
(string code, _, _) = await CreateCodeAsync(role: "agent", controllers: [op.Passkey.Id]);
|
||||||
|
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, pin: (code, op.Genesis, [op.Passkey.Id]));
|
||||||
|
|
||||||
|
JsonNode? owner = null;
|
||||||
|
await BackendFixture.EventuallyAsync(
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||||
|
owner = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id)?["owners"]?.AsArray().FirstOrDefault();
|
||||||
|
return owner is not null;
|
||||||
|
},
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
"the agent reports its owner in hello");
|
||||||
|
|
||||||
|
Assert.Equal(op.Passkey.Id, owner!["credential_id"]!.GetValue<string>());
|
||||||
|
Assert.False(string.IsNullOrEmpty(owner["passkey"]?.GetValue<string>()));
|
||||||
|
Assert.False(string.IsNullOrEmpty(owner["account"]?.GetValue<string>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheProposal_ListsEveryNewPasskey_SoTheSignerSeesWhatTheyAreAuthorizing()
|
||||||
|
{
|
||||||
|
// 往账号里塞一把 passkey 再等人签名是这个流程唯一的风险:提案要明确列出新增的凭证
|
||||||
|
Operator op = await OperatorAsync();
|
||||||
|
(HttpClient other, SoftwarePasskey added, _) = await _backend.CreateStrongSessionAsync("added");
|
||||||
|
using HttpClient otherScope = other;
|
||||||
|
using SoftwarePasskey addedScope = added;
|
||||||
|
|
||||||
|
JsonNode proposal = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!["proposal"]!;
|
||||||
|
JsonArray list = proposal["added"]!.AsArray();
|
||||||
|
Assert.Contains(list, a => a!["id"]!.GetValue<string>() == added.Id);
|
||||||
|
Assert.All(list, a => Assert.False(string.IsNullOrWhiteSpace(a!["name"]!.GetValue<string>())));
|
||||||
|
Assert.DoesNotContain(list, a => a!["id"]!.GetValue<string>() == op.Passkey.Id);
|
||||||
|
Assert.Empty(proposal["removed"]!.AsArray());
|
||||||
|
Assert.False(proposal["server_key_changed"]!.GetValue<bool>());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck()
|
public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 镜像里某个可执行文件的 SHA-256:ci/binary-hash.sh <镜像> <镜像里的路径>
|
||||||
|
# 节点握手时上报的就是它正在运行的那个文件的哈希,CI 把这个哈希写进签名的清单(binaries),Backend 据此认「官方二进制」。
|
||||||
|
# 先试在镜像里运行 sha256sum(同架构),不行再挂载镜像文件系统直接读。
|
||||||
|
set -euo pipefail
|
||||||
|
image="${1:?usage: binary-hash.sh <image> <path>}"; path="${2:?path in the image}"
|
||||||
|
# 镜像是本地刚构建的,不需要仓库认证;CI 里 REGISTRY_AUTH_FILE 指向的文件在登录之前还不存在,buildah 会因此报错
|
||||||
|
unset REGISTRY_AUTH_FILE
|
||||||
|
container="$(buildah from "$image")"
|
||||||
|
trap 'buildah rm "$container" >/dev/null 2>&1 || true' EXIT
|
||||||
|
if out="$(buildah run "$container" -- sha256sum "$path" 2>/dev/null)" && [[ "$out" =~ ^([0-9a-f]{64})[[:space:]] ]]; then
|
||||||
|
echo "${BASH_REMATCH[1]}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
buildah unshare bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
mount="$(buildah mount "$1")"
|
||||||
|
sha256sum "$mount$2" | cut -d" " -f1
|
||||||
|
buildah umount "$1" >/dev/null
|
||||||
|
' _ "$container" "$path"
|
||||||
@@ -54,6 +54,11 @@ buildah bud --layers --network host --tls-verify=true --arch amd64 \
|
|||||||
-f "$context/Containerfile" \
|
-f "$context/Containerfile" \
|
||||||
-t "$local_image" "$context"
|
-t "$local_image" "$context"
|
||||||
|
|
||||||
|
# 镜像里节点二进制的哈希:写进签名的清单(binaries),Backend 凭它认「官方二进制」,节点握手时上报的就是它正在运行的这个文件的哈希
|
||||||
|
binary_sha256="$(bash "$root/ci/binary-hash.sh" "$local_image" "/usr/local/bin/moshiro-$component")"
|
||||||
|
[[ "$binary_sha256" =~ ^[0-9a-f]{64}$ ]] || { echo "could not hash the $component binary in the image" >&2; exit 1; }
|
||||||
|
echo "$component binary sha256: $binary_sha256"
|
||||||
|
|
||||||
logged_in=false
|
logged_in=false
|
||||||
for attempt in 1 2 3 4; do
|
for attempt in 1 2 3 4; do
|
||||||
if buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$registry" <<< "$ZOT_SECRET" >/dev/null; then
|
if buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$registry" <<< "$ZOT_SECRET" >/dev/null; then
|
||||||
@@ -83,14 +88,8 @@ for tag in latest "$dev_tag"; do
|
|||||||
echo "pushed development image $repository:$tag"
|
echo "pushed development image $repository:$tag"
|
||||||
done
|
done
|
||||||
|
|
||||||
# 发布清单:给镜像的 digest 签名(私钥只在 CI 密钥里),再登记到面板;面板把它原样转给在线的节点,节点宿主机上的更新器用本机的公钥验签后才会更新。
|
# 发布清单:给镜像的 digest 签名(私钥只在 CI 密钥里),放进镜像仓库(<仓库>-manifest:dev),面板在线时再登记一份加速。
|
||||||
# 没有配置签名密钥(例如 fork)就只构建镜像、不发清单。
|
# 没有配置签名密钥(例如 fork)就只构建镜像、不发清单。
|
||||||
if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then
|
if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then
|
||||||
digest="$(cat "$work/$component.digest")"
|
RELEASE_BINARIES="$binary_sha256" bash "$root/ci/publish-release-offer.sh" "$component" "$repository" "$(cat "$work/$component.digest")" "$dev_tag" dev "$work/$component-release"
|
||||||
release_dir="$work/$component-release"
|
|
||||||
bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$dev_tag" dev "$release_dir"
|
|
||||||
if [[ -n "${RELEASE_API_URL:-}" && -n "${RELEASE_API_TOKEN:-}" ]]; then
|
|
||||||
python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' "$release_dir/manifest.json" "$release_dir/manifest.sig" | curl --fail-with-body --silent --show-error --retry 3 -X POST "${RELEASE_API_URL%/}/api/admin/releases" -H "Authorization: Bearer $RELEASE_API_TOKEN" -H 'Content-Type: application/json' --data-binary @- >/dev/null
|
|
||||||
echo "registered the signed $component release with the panel"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
@@ -114,4 +114,40 @@ for pid in "${pids[@]}"; do
|
|||||||
wait "$pid"
|
wait "$pid"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# 所有压缩包的校验和汇成一份 SHA256SUMS,用发布私钥签名(base64 的 DER ECDSA,和签名的发布清单是同一把密钥、同一种格式):
|
||||||
|
# 不走容器的安装(直接下载压缩包)先验这份签名再核对压缩包的哈希,发布页被改也骗不到;没有配置签名密钥(例如 fork)就跳过。
|
||||||
|
upload_local_asset() {
|
||||||
|
local name="$1" file="$2" upload_response="$tmp/$1.upload.json" status
|
||||||
|
if grep -Eq "\"name\"[[:space:]]*:[[:space:]]*\"$name\"" "$assets_json"; then
|
||||||
|
echo "release asset $name already exists; keeping the existing asset"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \
|
||||||
|
--request POST --header 'Content-Type: application/octet-stream' --data-binary "@$file" \
|
||||||
|
--output "$upload_response" --write-out '%{http_code}' "$api/releases/$release_id/assets?name=$name")"
|
||||||
|
[[ "$status" == 2* ]] || { echo "Gitea Release asset upload failed for $name (HTTP $status)" >&2; cat "$upload_response" >&2; return 1; }
|
||||||
|
echo "attached $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then
|
||||||
|
sums="$tmp/SHA256SUMS"
|
||||||
|
: > "$sums"
|
||||||
|
for archive in "${assets[@]}"; do
|
||||||
|
curl --fail --silent --show-error --retry 3 --netrc-file "$netrc" \
|
||||||
|
"$gitea_base/api/packages/justme/generic/moshiro-node/$version/$archive.sha256" -o "$tmp/$archive.sha256"
|
||||||
|
cat "$tmp/$archive.sha256" >> "$sums"
|
||||||
|
done
|
||||||
|
[[ "$(wc -l < "$sums")" -eq "${#assets[@]}" ]] || { echo "SHA256SUMS does not have one line per archive" >&2; exit 1; }
|
||||||
|
signing_key="$(mktemp)"
|
||||||
|
chmod 600 "$signing_key"
|
||||||
|
printf '%s\n' "$RELEASE_SIGNING_KEY" > "$signing_key"
|
||||||
|
openssl dgst -sha256 -sign "$signing_key" -out "$tmp/SHA256SUMS.sig.bin" "$sums"
|
||||||
|
rm -f "$signing_key"
|
||||||
|
base64 -w0 "$tmp/SHA256SUMS.sig.bin" > "$tmp/SHA256SUMS.sig" 2>/dev/null || base64 "$tmp/SHA256SUMS.sig.bin" | tr -d '\n' > "$tmp/SHA256SUMS.sig"
|
||||||
|
upload_local_asset SHA256SUMS "$sums"
|
||||||
|
upload_local_asset SHA256SUMS.sig "$tmp/SHA256SUMS.sig"
|
||||||
|
else
|
||||||
|
echo "release signing is not configured; SHA256SUMS is not signed"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "published Gitea Release $tag: https://git.oeo.one/justme/MoShiro/releases/tag/$tag"
|
echo "published Gitea Release $tag: https://git.oeo.one/justme/MoShiro/releases/tag/$tag"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 把签名清单放进镜像仓库:ci/publish-manifest.sh <镜像仓库> <通道> <清单目录>
|
||||||
|
# 清单目录里是 release-manifest.sh 产出的 manifest.json 和 manifest.sig。这里把它们装进一个没有任何文件层的小镜像的标签里,
|
||||||
|
# 推到 <镜像仓库>-manifest:<通道>(例如 registry/moshiro-dev/moshiro-backend-manifest:dev)。
|
||||||
|
# 宿主机更新器和 deploy-node.sh 直接从仓库取这个镜像、用本机的发布公钥验签,所以更新不依赖面板在线,也不依赖 Release 页面;
|
||||||
|
# 仓库只是存放处,被篡改只会让验签失败。需要登录过仓库(buildah login),或设置 ZOT_USERNAME / ZOT_SECRET。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repository="${1:?usage: publish-manifest.sh <repository> <channel> <manifest-dir>}"
|
||||||
|
channel="${2:?channel}"
|
||||||
|
dir="${3:?manifest dir}"
|
||||||
|
[[ "$repository" =~ ^[a-z0-9./_:-]+$ && "$channel" =~ ^[a-z0-9._-]+$ ]] || { echo "unexpected characters in repository or channel" >&2; exit 2; }
|
||||||
|
[[ -s "$dir/manifest.json" && -s "$dir/manifest.sig" ]] || { echo "$dir needs manifest.json and manifest.sig" >&2; exit 2; }
|
||||||
|
|
||||||
|
ref="${repository}-manifest:${channel}"
|
||||||
|
local_name="localhost/moshiro-manifest-build:$$"
|
||||||
|
container="$(buildah from scratch)"
|
||||||
|
trap 'buildah rm "$container" >/dev/null 2>&1 || true; buildah rmi "$local_name" >/dev/null 2>&1 || true' EXIT
|
||||||
|
manifest_b64="$(base64 < "$dir/manifest.json" | tr -d '\n')"
|
||||||
|
signature="$(tr -d '\n' < "$dir/manifest.sig")"
|
||||||
|
buildah config \
|
||||||
|
--label "org.moshiro.manifest=$manifest_b64" \
|
||||||
|
--label "org.moshiro.signature=$signature" \
|
||||||
|
--label "org.opencontainers.image.title=MoShiro signed release manifest ($channel)" \
|
||||||
|
"$container"
|
||||||
|
buildah commit --format oci "$container" "$local_name" >/dev/null
|
||||||
|
|
||||||
|
creds=()
|
||||||
|
if [[ -n "${ZOT_USERNAME:-}" && -n "${ZOT_SECRET:-}" ]]; then creds=(--creds "$ZOT_USERNAME:$ZOT_SECRET"); fi
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if buildah push --tls-verify=true ${creds[@]+"${creds[@]}"} "$local_name" "docker://$ref" >/dev/null; then
|
||||||
|
echo "published the signed manifest at $ref"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "$attempt" -lt 3 ]]; then echo "manifest push attempt $attempt failed; retrying in 5 seconds" >&2; sleep 5; fi
|
||||||
|
done
|
||||||
|
echo "could not publish the manifest at $ref" >&2
|
||||||
|
exit 1
|
||||||
+12
-13
@@ -1,25 +1,24 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# CI 发布 webhook:镜像已推送成功后,签名并登记清单。面板不会因此自动重启 Backend。
|
# CI 发布:镜像已推送成功后,签名清单,并发布出去。ci/publish-release-offer.sh <组件> <镜像仓库> <digest> <tag> <通道> <输出目录>
|
||||||
|
# 1. 清单放进镜像仓库(<镜像仓库>-manifest:<通道>):更新器和首次部署都从这里取,不依赖面板,必须成功;
|
||||||
|
# 2. 面板在线时再登记一份(/api/release/offer,面板验 CI 签名,不需要令牌)让在线的节点更快收到:只是加速,失败只警告,不让构建失败。
|
||||||
|
# 面板不会因此自动重启 Backend。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
component="${1:?component}"; repository="${2:?repository}"; digest="${3:?digest}"; tag="${4:?tag}"; channel="${5:?channel}"; outdir="${6:?outdir}"
|
component="${1:?component}"; repository="${2:?repository}"; digest="${3:?digest}"; tag="${4:?tag}"; channel="${5:?channel}"; outdir="${6:?outdir}"
|
||||||
[[ -n "${RELEASE_SIGNING_KEY:-}" ]] || { echo "release signing is not configured; no update offer published"; exit 0; }
|
[[ -n "${RELEASE_SIGNING_KEY:-}" ]] || { echo "release signing is not configured; no update offer published"; exit 0; }
|
||||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$tag" "$channel" "$outdir"
|
bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$tag" "$channel" "$outdir"
|
||||||
if [[ -n "${RELEASE_API_URL:-}" && -n "${RELEASE_API_TOKEN:-}" ]]; then
|
bash "$root/ci/publish-manifest.sh" "$repository" "$channel" "$outdir"
|
||||||
|
|
||||||
|
if [[ -n "${RELEASE_API_URL:-}" ]]; then
|
||||||
python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' \
|
python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' \
|
||||||
"$outdir/manifest.json" "$outdir/manifest.sig" > "$outdir/payload.json"
|
"$outdir/manifest.json" "$outdir/manifest.sig" > "$outdir/payload.json"
|
||||||
response="$outdir/response.json"
|
response="$outdir/response.json"
|
||||||
status="$(curl --silent --show-error --retry 3 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/admin/releases" \
|
status="$(curl --silent --show-error --retry 3 --max-time 30 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/release/offer" \
|
||||||
-H "Authorization: Bearer $RELEASE_API_TOKEN" -H 'Content-Type: application/json' --data-binary "@$outdir/payload.json")"
|
-H 'Content-Type: application/json' --data-binary "@$outdir/payload.json" 2>/dev/null || true)"
|
||||||
if [[ "$status" != 2* ]]; then
|
if [[ "$status" == 2* ]]; then
|
||||||
if [[ "$component" == backend && "$status" == 400 ]] && grep -Fq 'manifest needs a known component' "$response"; then
|
echo "also registered the signed $component release with the panel ($channel)"
|
||||||
echo "the panel does not support Backend update offers yet; bootstrap it once before future Backend offers"
|
|
||||||
else
|
else
|
||||||
echo "release offer registration failed (HTTP $status)" >&2
|
echo "warning: the panel did not take the $component offer (HTTP ${status:-none}); updaters still get it from the registry" >&2
|
||||||
cat "$response" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "registered the signed $component release with the panel ($channel)"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一个组件在所有平台、所有架构的发布压缩包里的可执行文件的 SHA-256(逗号分隔,输出到标准输出)。
|
||||||
|
# ci/release-binary-hashes.sh <agent|probe> <版本,不带 v> 环境变量:GITEA_BASE、NETRC(curl 的 netrc 文件)
|
||||||
|
# 签名的发布清单里的 binaries 就是它:节点握手时上报正在运行的可执行文件的哈希,Backend 凭 CI 签名的清单认「官方二进制」——
|
||||||
|
# 容器、Linux glibc/musl 压缩包、macOS、Windows 的 x64/arm64 都一样,不只是容器镜像里的那两个。
|
||||||
|
set -euo pipefail
|
||||||
|
component="${1:?usage: release-binary-hashes.sh <agent|probe> <version>}"; version="${2:?version}"
|
||||||
|
[[ "$component" =~ ^(agent|probe)$ ]] || { echo "component must be agent or probe" >&2; exit 2; }
|
||||||
|
gitea_base="${GITEA_BASE:?GITEA_BASE is required}"; gitea_base="${gitea_base%/}"
|
||||||
|
netrc=(); [[ -n "${NETRC:-}" ]] && netrc=(--netrc-file "$NETRC")
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
# 和 ci/publish-gitea-release.sh 里发布的那 8 种一样(每个组件)
|
||||||
|
targets=(linux-amd64 linux-arm64 linux-musl-amd64 linux-musl-arm64 macos-x64 macos-arm64 windows-amd64 windows-arm64)
|
||||||
|
hashes=()
|
||||||
|
for target in "${targets[@]}"; do
|
||||||
|
if [[ "$target" == windows-* ]]; then archive="moshiro-$component-v$version-$target.zip"; member="moshiro-$component.exe"
|
||||||
|
else archive="moshiro-$component-v$version-$target.tar.gz"; member="moshiro-$component"; fi
|
||||||
|
curl --fail --silent --show-error --retry 3 ${netrc[@]+"${netrc[@]}"} "$gitea_base/api/packages/justme/generic/moshiro-node/$version/$archive" -o "$work/$archive"
|
||||||
|
if [[ "$archive" == *.zip ]]; then
|
||||||
|
hash="$(cd "$work" && python3 -c 'import hashlib,sys,zipfile; print(hashlib.sha256(zipfile.ZipFile(sys.argv[1]).read(sys.argv[2])).hexdigest())' "$archive" "$member")"
|
||||||
|
else
|
||||||
|
hash="$(tar -xzOf "$work/$archive" "$member" | sha256sum | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
[[ "$hash" =~ ^[0-9a-f]{64}$ ]] || { echo "could not hash $member in $archive" >&2; exit 1; }
|
||||||
|
hashes+=("$hash")
|
||||||
|
rm -f "$work/$archive"
|
||||||
|
done
|
||||||
|
# 不同平台的二进制哈希不会相同;去重只是防御
|
||||||
|
printf '%s\n' "${hashes[@]}" | sort -u | paste -sd,
|
||||||
@@ -52,6 +52,14 @@ export MOSHIRO_TAG="${MOSHIRO_TAG:-${tag:-main-${CI_COMMIT_SHA:0:10}}}"
|
|||||||
export MOSHIRO_COMMIT="${MOSHIRO_COMMIT:-${CI_COMMIT_SHA:0:10}}"
|
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)}"
|
export MOSHIRO_BUILT_AT="${MOSHIRO_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
|
||||||
|
|
||||||
|
# 前端先构建:Backend.Api 在 web/*/dist 存在时把它们内嵌进程序(展示端 /,管理端 /admin/),这样 passkey 能绑定在面板自己的域名上
|
||||||
|
# 发版流水线里有单独的 web-build 步骤先构建好了(并行的两个架构不能同时 npm ci);main 的 [CI] 构建只有一个 Backend 步骤,这里自己构建
|
||||||
|
if [[ ! -f "$root/web/display/dist/index.html" || ! -f "$root/web/admin/dist/index.html" ]]; then
|
||||||
|
if [[ -z "${NPM_CONFIG_CACHE:-}" && -d /home/justme/.npm ]]; then export NPM_CONFIG_CACHE=/home/justme/.npm; fi
|
||||||
|
(cd "$root" && npm ci --prefix web --prefer-offline && npm run build --prefix web)
|
||||||
|
fi
|
||||||
|
[[ -f "$root/web/display/dist/index.html" && -f "$root/web/admin/dist/index.html" ]] || { echo "the web build did not produce both apps" >&2; exit 1; }
|
||||||
|
|
||||||
(cd "$root/backend" && dotnet publish src/MoShiro.Backend \
|
(cd "$root/backend" && dotnet publish src/MoShiro.Backend \
|
||||||
-m:3 -c Release -r "$rid" --self-contained true \
|
-m:3 -c Release -r "$rid" --self-contained true \
|
||||||
-p:DebugType=none -p:DebugSymbols=false \
|
-p:DebugType=none -p:DebugSymbols=false \
|
||||||
|
|||||||
@@ -28,9 +28,16 @@ if [[ -n "${RELEASE_NEXT_KEY:-}" ]]; then
|
|||||||
[[ -n "$next_key" ]] || { echo "RELEASE_NEXT_KEY is not a public key" >&2; exit 2; }
|
[[ -n "$next_key" ]] || { echo "RELEASE_NEXT_KEY is not a public key" >&2; exit 2; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 官方二进制的哈希(逗号分隔的小写十六进制):节点握手时上报正在运行的二进制的哈希,Backend 用这份签名的清单认「官方」
|
||||||
|
binaries=""
|
||||||
|
if [[ -n "${RELEASE_BINARIES:-}" ]]; then
|
||||||
|
[[ "$RELEASE_BINARIES" =~ ^[0-9a-f]{64}(,[0-9a-f]{64})*$ ]] || { echo "RELEASE_BINARIES must be comma-separated sha256 hashes" >&2; exit 2; }
|
||||||
|
binaries=",\"binaries\":\"$RELEASE_BINARIES\""
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$outdir"
|
mkdir -p "$outdir"
|
||||||
printf '{"schema":1,"component":"%s","channel":"%s","image":"%s","digest":"%s","tag":"%s","version":"%s","commit":"%s","built_at":"%s","seq":%s,"install_sh":"%s","install_ps1":"%s"%s}' \
|
printf '{"schema":1,"component":"%s","channel":"%s","image":"%s","digest":"%s","tag":"%s","version":"%s","commit":"%s","built_at":"%s","seq":%s,"install_sh":"%s","install_ps1":"%s"%s%s}' \
|
||||||
"$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" "$install_sh" "$install_ps1" "${next_key:+,\"next_key\":\"$next_key\"}" > "$outdir/manifest.json"
|
"$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" "$install_sh" "$install_ps1" "${next_key:+,\"next_key\":\"$next_key\"}" "$binaries" > "$outdir/manifest.json"
|
||||||
|
|
||||||
key="$(mktemp)"
|
key="$(mktemp)"
|
||||||
trap 'rm -f "$key" "$outdir/manifest.sig.bin"' EXIT
|
trap 'rm -f "$key" "$outdir/manifest.sig.bin"' EXIT
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ publish_index() {
|
|||||||
buildah manifest create "$manifest" >/dev/null
|
buildah manifest create "$manifest" >/dev/null
|
||||||
buildah manifest add "$manifest" "docker://$image:$version-amd64" >/dev/null
|
buildah manifest add "$manifest" "docker://$image:$version-amd64" >/dev/null
|
||||||
buildah manifest add "$manifest" "docker://$image:$version-arm64" >/dev/null
|
buildah manifest add "$manifest" "docker://$image:$version-arm64" >/dev/null
|
||||||
buildah manifest push --all --format oci "$manifest" "docker://$image:$version" >/dev/null
|
buildah manifest push --all --format oci --digestfile "$work/$component.digest" "$manifest" "docker://$image:$version" >/dev/null
|
||||||
if [[ "$version" != *-* ]]; then
|
if [[ "$version" != *-* ]]; then
|
||||||
buildah manifest push --all --format oci "$manifest" "docker://$image:latest" >/dev/null
|
buildah manifest push --all --format oci "$manifest" "docker://$image:latest" >/dev/null
|
||||||
fi
|
fi
|
||||||
@@ -113,4 +113,12 @@ for component in agent probe; do
|
|||||||
build_arch_image "$component" amd64 musl-amd64
|
build_arch_image "$component" amd64 musl-amd64
|
||||||
build_arch_image "$component" arm64 musl-arm64
|
build_arch_image "$component" arm64 musl-arm64
|
||||||
publish_index "$component"
|
publish_index "$component"
|
||||||
|
# 发布清单:给多架构镜像的 digest 签名(私钥只在 CI 密钥里),放进镜像仓库(<仓库>-manifest:release),面板在线时再登记一份加速。
|
||||||
|
# 没有配置签名密钥(例如 fork)就只发镜像、不发清单。
|
||||||
|
if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then
|
||||||
|
digest="$(cat "$work/$component.digest")"
|
||||||
|
# 清单里的 binaries:所有平台、所有架构的发布压缩包里的可执行文件(不只是镜像里的 musl 那两个),节点握手时上报的哈希对得上其中任何一个
|
||||||
|
binaries="$(NETRC="$netrc" GITEA_BASE="$gitea_base" bash "$root/ci/release-binary-hashes.sh" "$component" "$version")"
|
||||||
|
RELEASE_BINARIES="$binaries" bash "$root/ci/publish-release-offer.sh" "$component" "$image_root/moshiro-$component" "$digest" "$tag" release "$work/$component-release"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# release-binary-hashes.sh 的测试:用本地的假 Gitea 包仓库(python http.server),造 8 个平台的压缩包(tar.gz 和 zip),
|
||||||
|
# 核对脚本算出来的哈希就是压缩包里可执行文件的 SHA-256。运行:bash ci/tests/binary-hashes-test.sh
|
||||||
|
set -eu
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
T="$(mktemp -d)"
|
||||||
|
SERVER=
|
||||||
|
trap '[ -z "$SERVER" ] || kill "$SERVER" 2>/dev/null || true; rm -rf "$T" 2>/dev/null || true' EXIT
|
||||||
|
V=9.9.9
|
||||||
|
PKG="$T/srv/api/packages/justme/generic/moshiro-node/$V"
|
||||||
|
mkdir -p "$PKG"
|
||||||
|
fail=0
|
||||||
|
expected=()
|
||||||
|
for component in agent probe; do
|
||||||
|
for target in linux-amd64 linux-arm64 linux-musl-amd64 linux-musl-arm64 macos-x64 macos-arm64 windows-amd64 windows-arm64; do
|
||||||
|
stage="$T/stage-$component-$target"; mkdir -p "$stage"
|
||||||
|
if [[ "$target" == windows-* ]]; then member="moshiro-$component.exe"; else member="moshiro-$component"; fi
|
||||||
|
printf 'binary of %s for %s\n' "$component" "$target" > "$stage/$member"
|
||||||
|
printf 'noise' > "$stage/fleet-enroll"; printf 'readme' > "$stage/README.md"
|
||||||
|
if [[ "$target" == windows-* ]]; then
|
||||||
|
(cd "$stage" && python3 -c "import zipfile,sys; z=zipfile.ZipFile('out.zip','w'); [z.write(n) for n in sys.argv[1:]]" "$member" fleet-enroll README.md && mv out.zip "$PKG/moshiro-$component-v$V-$target.zip")
|
||||||
|
else
|
||||||
|
tar -czf "$PKG/moshiro-$component-v$V-$target.tar.gz" -C "$stage" "$member" fleet-enroll README.md
|
||||||
|
fi
|
||||||
|
[[ "$component" == agent ]] && expected+=("$(sha256sum "$stage/$member" | cut -d' ' -f1)")
|
||||||
|
done
|
||||||
|
done
|
||||||
|
want="$(printf '%s\n' "${expected[@]}" | sort -u | paste -sd,)"
|
||||||
|
|
||||||
|
(cd "$T/srv" && python3 -u -m http.server 0 --bind 127.0.0.1 >"$T/server.log" 2>&1 & echo $! > "$T/pid")
|
||||||
|
SERVER="$(cat "$T/pid")"
|
||||||
|
for _ in $(seq 1 50); do port="$(grep -o 'port [0-9]*' "$T/server.log" | head -1 | cut -d' ' -f2 || true)"; [[ -n "$port" ]] && break; sleep 0.2; done
|
||||||
|
[[ -n "${port:-}" ]] || { echo "FAIL the fake package server did not start"; cat "$T/server.log"; exit 1; }
|
||||||
|
|
||||||
|
got="$(GITEA_BASE="http://127.0.0.1:$port" bash "$ROOT/ci/release-binary-hashes.sh" agent "$V")"
|
||||||
|
if [[ "$got" == "$want" ]]; then echo "ok agent: all 8 platform binaries hashed ($(echo "$got" | tr ',' '\n' | wc -l) distinct)"; else echo "FAIL agent hashes differ"; echo " want $want"; echo " got $got"; fail=1; fi
|
||||||
|
[[ "$(echo "$got" | tr ',' '\n' | wc -l)" -eq 8 ]] && echo "ok eight different binaries, none dropped" || { echo "FAIL expected 8 hashes"; fail=1; }
|
||||||
|
|
||||||
|
# 缺一个平台的压缩包:整个脚本失败,不能悄悄少签一个
|
||||||
|
rm "$PKG/moshiro-probe-v$V-macos-arm64.tar.gz"
|
||||||
|
if GITEA_BASE="http://127.0.0.1:$port" bash "$ROOT/ci/release-binary-hashes.sh" probe "$V" >/dev/null 2>&1; then echo "FAIL a missing archive was ignored"; fail=1; else echo "ok a missing archive fails the script instead of signing fewer binaries"; fi
|
||||||
|
if bash "$ROOT/ci/release-binary-hashes.sh" other "$V" >/dev/null 2>&1; then echo "FAIL unknown component accepted"; fail=1; else echo "ok unknown component is refused"; fi
|
||||||
|
exit "$fail"
|
||||||
+2
-2
@@ -11,8 +11,8 @@ services:
|
|||||||
MoShiro__DataDirectory: /data
|
MoShiro__DataDirectory: /data
|
||||||
MoShiro__UpdateChannel: ${MOSHIRO_UPDATE_CHANNEL:-auto}
|
MoShiro__UpdateChannel: ${MOSHIRO_UPDATE_CHANNEL:-auto}
|
||||||
MoShiro__ImageNamespace: ${MOSHIRO_IMAGE_NAMESPACE:-moshiro}
|
MoShiro__ImageNamespace: ${MOSHIRO_IMAGE_NAMESPACE:-moshiro}
|
||||||
MoShiro__InitialAdminUser: ${MOSHIRO_ADMIN_USER:-admin}
|
# 没有默认管理员:第一次打开面板时按页面引导自己创建(需要后端日志里的认领码,也可以在这里指定 MOSHIRO_CLAIM_CODE)
|
||||||
MoShiro__InitialAdminPassword: "${MOSHIRO_ADMIN_PASSWORD:?Set MOSHIRO_ADMIN_PASSWORD in the environment}"
|
MoShiro__ClaimCode: "${MOSHIRO_CLAIM_CODE:-}"
|
||||||
volumes:
|
volumes:
|
||||||
- moshiro-backend-data:/data
|
- moshiro-backend-data:/data
|
||||||
- moshiro-backend-status:/data/update-status:ro
|
- moshiro-backend-status:/data/update-status:ro
|
||||||
|
|||||||
+61
-34
@@ -26,12 +26,15 @@
|
|||||||
节点和 Backend 之间:集合里登记了服务器密钥之后,节点的认证挑战要求服务器用它签名;两边各出一把临时 ECDH 密钥,
|
节点和 Backend 之间:集合里登记了服务器密钥之后,节点的认证挑战要求服务器用它签名;两边各出一把临时 ECDH 密钥,
|
||||||
认证签名覆盖双方的临时公钥,之后控制链路每一帧都是 AES-256-GCM(每个方向一把密钥、严格递增的计数器)。
|
认证签名覆盖双方的临时公钥,之后控制链路每一帧都是 AES-256-GCM(每个方向一把密钥、严格递增的计数器)。
|
||||||
所以 TLS 在 Cloudflare 或被冒充的证书那里终止,中间人没有服务器密钥,既冒充不了服务器,也读不了、改不了这条链路
|
所以 TLS 在 Cloudflare 或被冒充的证书那里终止,中间人没有服务器密钥,既冒充不了服务器,也读不了、改不了这条链路
|
||||||
密码登录的会话没有任何能力,只能注册 passkey;有能力的会话来自 passkey 登录;信任类操作要提权窗口(passkey 触碰)
|
密码登录和 passkey 登录的会话拿同样的能力,授权只看能力;passkey 只用在节点自己会验证的事上(现在是操作员集合的签名,以后是 extension 会话和加密数据)。
|
||||||
|
Backend 自己拦自己没有意义,所以没有提权窗口
|
||||||
```
|
```
|
||||||
|
|
||||||
**发布密钥可以换**:见 `docs/deployment.md` 的「换发布密钥」;回滚已经由清单的 `seq` 防住(更新器只收比已安装更大的)。
|
**发布密钥可以换**:见 `docs/deployment.md` 的「换发布密钥」;回滚已经由清单的 `seq` 防住(更新器只收比已安装更大的)。
|
||||||
|
|
||||||
**还没有的**:吊销表;任务和配置内容的操作员签名;节点自证跑的是官方构建;extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。
|
**节点二进制绑定**(第 16 节):节点第一次握手成功的那一刻,Backend 把它正在运行的二进制的 SHA-256 绑定到这个节点;之后哈希对不上、又不在 CI 签名的更新清单里,就拒绝连接。
|
||||||
|
|
||||||
|
**还没有的**:吊销表;任务和配置内容的操作员签名;节点向别人**证明**自己没有撒谎(二进制绑定抓的是文件被换、降级、起了不对的二进制,抓不到故意照抄哈希的恶意二进制,见第 16 节);extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。
|
||||||
|
|
||||||
## 1. 名词
|
## 1. 名词
|
||||||
|
|
||||||
@@ -41,7 +44,6 @@
|
|||||||
| 角色 | `anonymous`、`admin`、`owner`,决定能力的**上限** |
|
| 角色 | `anonymous`、`admin`、`owner`,决定能力的**上限** |
|
||||||
| 能力(capability) | 一项具体权限,如 `fleet.write`。端点声明自己需要哪项能力 |
|
| 能力(capability) | 一项具体权限,如 `fleet.write`。端点声明自己需要哪项能力 |
|
||||||
| 会话 | 登录得到的状态,只带 L1 能力 |
|
| 会话 | 登录得到的状态,只带 L1 能力 |
|
||||||
| 提权窗口 | 用 passkey 触控换来的、有时限的信任类能力(类似 sudo) |
|
|
||||||
| 操作员集合(OpSet) | 节点固定的、带版本的 passkey 公钥列表,每个带角色和限制 |
|
| 操作员集合(OpSet) | 节点固定的、带版本的 passkey 公钥列表,每个带角色和限制 |
|
||||||
| 委托(Grant) | passkey 签名授权一把临时密钥,在范围、时间内代表它签名 |
|
| 委托(Grant) | passkey 签名授权一把临时密钥,在范围、时间内代表它签名 |
|
||||||
| API key | 一串种子;`enroll` 类的 key 创建时由 passkey 签出一份委托 |
|
| API key | 一串种子;`enroll` 类的 key 创建时由 passkey 签出一份委托 |
|
||||||
@@ -79,10 +81,10 @@
|
|||||||
| 角色 | 能力 |
|
| 角色 | 能力 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `anonymous` | 只有 `public.read`,且站点公开时才有。不是账户 |
|
| `anonymous` | 只有 `public.read`,且站点公开时才有。不是账户 |
|
||||||
| `admin` | 除 `accounts.manage` 外的全部;信任类(`fleet.enroll`、`fleet.revoke`)要提权 |
|
| `admin` | 除 `accounts.manage` 外的全部 |
|
||||||
| `owner` | 全部;`accounts.manage` 每次都要当场触控 |
|
| `owner` | 全部;`accounts.manage` 每次都要当场触控 |
|
||||||
|
|
||||||
- 角色是上限:能力是否可用取决于角色和当前窗口。
|
- 角色是上限:能力是否可用取决于角色。
|
||||||
- **不能越权**:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集;带信任类能力的 key 创建时要 passkey 签一次。
|
- **不能越权**:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集;带信任类能力的 key 创建时要 passkey 签一次。
|
||||||
- 角色写在操作员集合里,**节点自己执行**:admin 凭证签的入网券有效;只有 owner 凭证签的操作员集合更新才有效。被攻破的 Backend 给自己造一个 admin 账户也加不进节点。
|
- 角色写在操作员集合里,**节点自己执行**:admin 凭证签的入网券有效;只有 owner 凭证签的操作员集合更新才有效。被攻破的 Backend 给自己造一个 admin 账户也加不进节点。
|
||||||
|
|
||||||
@@ -90,12 +92,12 @@
|
|||||||
|
|
||||||
| 登录方式 | 能得到 | 说明 |
|
| 登录方式 | 能得到 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| passkey | L1 会话,并且可以提权 | WebAuthn,只支持 ES256,attestation 选 `none` |
|
| passkey | L1 会话;还能给操作员集合签名(节点验证) | WebAuthn,只支持 ES256,attestation 选 `none` |
|
||||||
| 密码 | L1 会话 | 保留用于首次引导和兜底;PBKDF2 |
|
| 密码 | L1 会话 | 保留用于首次引导和兜底;PBKDF2 |
|
||||||
| OIDC | L1 会话 | 账户绑定 `iss + sub`,登录时按它找账户,不自动建账户。放在 passkey 之后做 |
|
| OIDC | L1 会话 | 账户绑定 `iss + sub`,登录时按它找账户,不自动建账户。放在 passkey 之后做 |
|
||||||
|
|
||||||
- 密码和 OIDC **永远不能提权**:节点验证不了 IdP 的断言,也不该让 IdP 或密码进入信任域。
|
- 密码和 OIDC 会话可以管理(有能力),但**进不了信任域**:节点验证不了 IdP 的断言,所以它们改不了操作员集合。
|
||||||
- **引导**:初始管理员用密码登录,注册第一个 passkey;这是唯一一次不需要提权的注册。之后新增 passkey 都要提权,并写进操作员集合(见第 7 节)。
|
- **引导(认领向导,没有默认账户)**:面板里还没有任何用户时,打开 `/admin/` 进向导——用后端日志里的认领码(或 `MoShiro__ClaimCode`)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(`POST /api/admin/auth/setup` 之后返回 409)。后两步是引导不是门槛,可以稍后补,没有 passkey 也能用密码登录管理。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认(见第 7 节)。
|
||||||
- 建议每个账户至少注册两个 passkey(手机加硬件密钥)。
|
- 建议每个账户至少注册两个 passkey(手机加硬件密钥)。
|
||||||
- **离线恢复密钥**:创建 owner 时同时生成一把离线的 ECDSA 恢复密钥,作为 `type=ecdsa`、`recovery=true` 的 owner 凭证写进操作员集合;私钥打印或放进密码管理器,平时不用。节点强制它**只能签操作员集合更新**,不能签委托、入网券和吊销表,所以即使被偷走,也加不了节点;它的用处是 passkey 全部丢失时签一版新的操作员集合、重新加 passkey,节点不用重装。
|
- **离线恢复密钥**:创建 owner 时同时生成一把离线的 ECDSA 恢复密钥,作为 `type=ecdsa`、`recovery=true` 的 owner 凭证写进操作员集合;私钥打印或放进密码管理器,平时不用。节点强制它**只能签操作员集合更新**,不能签委托、入网券和吊销表,所以即使被偷走,也加不了节点;它的用处是 passkey 全部丢失时签一版新的操作员集合、重新加 passkey,节点不用重装。
|
||||||
|
|
||||||
@@ -105,22 +107,22 @@
|
|||||||
- 校验:挑战在内存里,一次有效、5 分钟过期,并且区分注册和登录、绑定用户;`rpIdHash`、UP 和 UV 标志、`clientDataJSON` 的 type、challenge、origin、ES256 签名;签名计数器只增不减(两边都是 0 的同步型 passkey 不检查)。登录失败一律 401,不区分原因,和密码登录共用限流。
|
- 校验:挑战在内存里,一次有效、5 分钟过期,并且区分注册和登录、绑定用户;`rpIdHash`、UP 和 UV 标志、`clientDataJSON` 的 type、challenge、origin、ES256 签名;签名计数器只增不减(两边都是 0 的同步型 passkey 不检查)。登录失败一律 401,不区分原因,和密码登录共用限流。
|
||||||
- 验证断言的纯函数是 `Fleet.Identity.WebAuthnAssertion`(只用 BCL,节点以后验证 passkey 签出的委托也用它);注册里的 CBOR 和 attestation 解析在 Backend(`WebAuthnRegistration`),attestation 一律当 `none`,节点不带 CBOR。
|
- 验证断言的纯函数是 `Fleet.Identity.WebAuthnAssertion`(只用 BCL,节点以后验证 passkey 签出的委托也用它);注册里的 CBOR 和 attestation 解析在 Backend(`WebAuthnRegistration`),attestation 一律当 `none`,节点不带 CBOR。
|
||||||
- 依赖方(RP):`MoShiro__WebAuthnRpId`(缺省取 `PublicUrl` 的主机,没有就取请求的主机)和 `MoShiro__WebAuthnOrigins`(缺省取 `PublicUrl` 的来源)。**passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。**只有和后端同站的页面能用 passkey 登录,管理端在「添加其他后端」(跨域)时不显示这个入口。
|
- 依赖方(RP):`MoShiro__WebAuthnRpId`(缺省取 `PublicUrl` 的主机,没有就取请求的主机)和 `MoShiro__WebAuthnOrigins`(缺省取 `PublicUrl` 的来源)。**passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。**只有和后端同站的页面能用 passkey 登录,管理端在「添加其他后端」(跨域)时不显示这个入口。
|
||||||
- 会话记录登录方式(`password` 或 `passkey`,`sessions.auth_method`),只有 passkey 登录的会话以后能提权;API key 不是用户,不能注册 passkey。
|
- 会话记录登录方式(`password` 或 `passkey`,`sessions.auth_method`),只用于审计和显示;API key 不是用户,不能注册 passkey。
|
||||||
- 测试:`SoftwarePasskey` 在测试里模拟认证器走完整的注册和登录,覆盖错误的挑战、来源、RP、签名、缺少 UV、挑战重放、计数器回退、只能管理自己的 passkey;管理端用 Chromium 的 WebAuthn 虚拟认证器验证过注册和登录。
|
- 测试:`SoftwarePasskey` 在测试里模拟认证器走完整的注册和登录,覆盖错误的挑战、来源、RP、签名、缺少 UV、挑战重放、计数器回退、只能管理自己的 passkey;管理端用 Chromium 的 WebAuthn 虚拟认证器验证过注册和登录。
|
||||||
|
|
||||||
## 4. 提权窗口(信任类能力各管各的)
|
## 4. passkey 用在哪里(没有提权窗口)
|
||||||
|
|
||||||
| 窗口 | 覆盖 | 默认时长 | 行为 |
|
原则:**passkey 不是 Backend 用来拦操作的一道门**(Backend 被攻破时,它自己做的拦截也会被一起绕过),而是节点或密钥持有者需要它才能验证、解密的东西。
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 接入 | `fleet.enroll` | 15 分钟(5–60 可调) | 窗口内连续生成代码不再弹 passkey |
|
|
||||||
| 吊销 | `fleet.revoke` | 5 分钟 | 破坏性操作,窗口更短 |
|
|
||||||
| 凭证 | `accounts.manage`、创建带信任类能力的 key | 无窗口 | 每次都要当场触控 |
|
|
||||||
|
|
||||||
- 窗口互不覆盖:开了接入窗口不能吊销节点。
|
| 事 | 谁验证 | 要 passkey |
|
||||||
- 「用到才提权」:页面照常打开,点信任类操作时没有窗口才弹 passkey,通过后接着做原来的动作。
|
| --- | --- | --- |
|
||||||
- 管理端顶部显示生效的窗口和剩余时间,每个窗口有「立即结束」。
|
| 建立、更新操作员集合(信任根) | **节点**验链 | 是(现在唯一做了的) |
|
||||||
- 接入窗口的实现:一次触控让 passkey 签一份委托(见第 7 节),授权浏览器里生成的不可导出临时密钥 D;窗口内用 D 签入网券,到期自动失效。
|
| extension 会话(web ssh 等) | **节点**验 passkey 签名的临时密钥 | 是(还没做) |
|
||||||
- **默认值待确认**(15 分钟、5 分钟)。
|
| 读加密存储的数据 | 私钥持有者 | 是(还没做) |
|
||||||
|
| 吊销或删除节点、账号、API key、加入码、设置 | 只有 Backend | 否,只看能力 |
|
||||||
|
|
||||||
|
- 这些「只有 Backend」的事靠能力限权:API key 只带需要的能力,登录会话按角色。密码泄露的后果是这些能力被滥用,改不了信任根,也用不了以后的 extension。
|
||||||
|
- 唯一的风险是往账号里塞一把 passkey 再等人签名:信任页的提案会列出**新增和移除的凭证**,签名之前要看一眼是不是自己加的;签了才算授权。
|
||||||
|
|
||||||
## 5. API key
|
## 5. API key
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@
|
|||||||
|
|
||||||
### 5.2 `enroll` key:自动化添加节点
|
### 5.2 `enroll` key:自动化添加节点
|
||||||
|
|
||||||
**当前实现:它就是一把带 `fleet.enroll` 的普通 API key**,用 `POST /api/admin/join-tokens` 创建加入码,响应里直接给出安装命令(带创世指纹、属主和脚本哈希,没有任何私钥)。因为创建码不是信任动作,所以不需要 passkey,也不需要提权窗口;CI 和脚本照常工作。信任在两处:命令里的创世指纹和属主由面板生成,节点注册时固定;以后改属主和集合要操作员 passkey 签名。
|
**当前实现:它就是一把带 `fleet.enroll` 的普通 API key**,用 `POST /api/admin/join-tokens` 创建加入码,响应里直接给出安装命令(带创世指纹、属主和脚本哈希,没有任何私钥)。因为创建码不是信任动作,所以不需要 passkey;CI 和脚本照常工作。信任在两处:命令里的创世指纹和属主由面板生成,节点注册时固定;以后改属主和集合要操作员 passkey 签名。
|
||||||
|
|
||||||
早先设计过的「委托密钥」(passkey 签一次委托,API key 自己签入网券,节点验证整条链)属于第 7.1~7.3 节的完整设计,当前没有采用,见第 0 节的取舍。
|
早先设计过的「委托密钥」(passkey 签一次委托,API key 自己签入网券,节点验证整条链)属于第 7.1~7.3 节的完整设计,当前没有采用,见第 0 节的取舍。
|
||||||
|
|
||||||
@@ -159,7 +161,7 @@
|
|||||||
- **key 上预留的字段**:
|
- **key 上预留的字段**:
|
||||||
- `resource_filter {agent_ids?, labels?}`:限制这把 key 能看到哪些节点、线路;事件和 webhook 同样受它限制。
|
- `resource_filter {agent_ids?, labels?}`:限制这把 key 能看到哪些节点、线路;事件和 webhook 同样受它限制。
|
||||||
- `rate_limit`(每分钟请求数)和 `quota`(每天拉取的行数或字节数):超出返回 429 带 `Retry-After`,计入审计。
|
- `rate_limit`(每分钟请求数)和 `quota`(每天拉取的行数或字节数):超出返回 429 带 `Retry-After`,计入审计。
|
||||||
- **webhook 的形状**:订阅 `{id, url, events[], secret, 创建者(key 或账户), enabled}`。事件:节点上线或下线、线路告警阈值、安全事件(节点准入、吊销、key 创建、提权)等。投递是 `POST` JSON,头带 `X-MoShiro-Signature: sha256=<HMAC(secret, 时间戳 ‖ body)>`、时间戳和投递 id;至少一次投递、指数退避重试、接收方按投递 id 幂等。
|
- **webhook 的形状**:订阅 `{id, url, events[], secret, 创建者(key 或账户), enabled}`。事件:节点上线或下线、线路告警阈值、安全事件(节点准入、吊销、key 创建)等。投递是 `POST` JSON,头带 `X-MoShiro-Signature: sha256=<HMAC(secret, 时间戳 ‖ body)>`、时间戳和投递 id;至少一次投递、指数退避重试、接收方按投递 id 幂等。
|
||||||
- **订阅受创建者能力约束**:只能订阅创建者自己读得到的事件,不能借 webhook 越权。
|
- **订阅受创建者能力约束**:只能订阅创建者自己读得到的事件,不能借 webhook 越权。
|
||||||
- **SSRF 约束**(TODO 里已有):URL 解析后检查地址,只放行公网地址;不跟随重定向到内网;连接和响应有超时和大小上限;`secret` 只在创建时显示一次。
|
- **SSRF 约束**(TODO 里已有):URL 解析后检查地址,只放行公网地址;不跟随重定向到内网;连接和响应有超时和大小上限;`secret` 只在创建时显示一次。
|
||||||
- 这些都属于 L1:改的是 Backend 的集成配置,不改变节点信任;但每次增删订阅和改 URL 都进审计日志。
|
- 这些都属于 L1:改的是 Backend 的集成配置,不改变节点信任;但每次增删订阅和改 URL 都进审计日志。
|
||||||
@@ -167,7 +169,7 @@
|
|||||||
## 6. 归属与审计
|
## 6. 归属与审计
|
||||||
|
|
||||||
- 账户表:`id`、名字、角色、登录方式、passkey;加入代码记 `created_by`,节点记 `added_by`;管理页的 Agent、Probe 详情显示「由 xxx 于 xxx 添加」。
|
- 账户表:`id`、名字、角色、登录方式、passkey;加入代码记 `created_by`,节点记 `added_by`;管理页的 Agent、Probe 详情显示「由 xxx 于 xxx 添加」。
|
||||||
- 审计日志 `(时间, 账户, 凭证 id, 动作, 对象, 详情)`:创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、提权、因能力不足被拒绝的请求。保留 365 天(同事件表)。
|
- 审计日志 `(时间, 账户, 凭证 id, 动作, 对象, 详情)`:创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、因能力不足被拒绝的请求。保留 365 天(同事件表)。
|
||||||
- **可验证的一层**:委托里写进签名它的 passkey 凭证 id,入网券和节点绑定带着它,节点在 hello 里报告「由哪个凭证准入」。「谁加的」不只是 Backend 的一面之词。
|
- **可验证的一层**:委托里写进签名它的 passkey 凭证 id,入网券和节点绑定带着它,节点在 hello 里报告「由哪个凭证准入」。「谁加的」不只是 Backend 的一面之词。
|
||||||
|
|
||||||
## 7. 入网信任链(完整设计,当前没有采用;当前实现见第 0 节)
|
## 7. 入网信任链(完整设计,当前没有采用;当前实现见第 0 节)
|
||||||
@@ -248,7 +250,7 @@
|
|||||||
- **服务器身份和安全链路**:`ChallengeParams` 带服务器的临时公钥和签名(`server_eph`、`server_sig`),`AuthParams` 带节点的临时公钥(`node_eph`),节点签名的用途是 `auth2`,覆盖双方的临时公钥;通过后两边用 HKDF 派生每个方向一把 AES-256-GCM 密钥,每帧 `计数器(8) ‖ 密文 ‖ 标签(16)`,计数器严格递增,明文帧、改动、重放、乱序一律断开。集合里没有服务器密钥的节点(旧节点)保持明文,不会被降级成放行。
|
- **服务器身份和安全链路**:`ChallengeParams` 带服务器的临时公钥和签名(`server_eph`、`server_sig`),`AuthParams` 带节点的临时公钥(`node_eph`),节点签名的用途是 `auth2`,覆盖双方的临时公钥;通过后两边用 HKDF 派生每个方向一把 AES-256-GCM 密钥,每帧 `计数器(8) ‖ 密文 ‖ 标签(16)`,计数器严格递增,明文帧、改动、重放、乱序一律断开。集合里没有服务器密钥的节点(旧节点)保持明文,不会被降级成放行。
|
||||||
- **服务器密钥**存在 Backend 的设置里(`server.identity_key`):它防的是面板被冒充,不是面板被攻破;面板被攻破时它也泄露,那时要由 passkey 签一个换掉它的新集合版本。集合里没有这个面板的服务器密钥的版本,Backend 不收(否则固定了它的节点就认不出这个面板了)。
|
- **服务器密钥**存在 Backend 的设置里(`server.identity_key`):它防的是面板被冒充,不是面板被攻破;面板被攻破时它也泄露,那时要由 passkey 签一个换掉它的新集合版本。集合里没有这个面板的服务器密钥的版本,Backend 不收(否则固定了它的节点就认不出这个面板了)。
|
||||||
- **安装脚本是静态的**:不替换任何值,所以 SHA-256 固定;CI 把哈希写进签名的发布清单和发布页,面板的信任页显示它和清单是否一致;命令先下载、核对哈希,一致才执行。
|
- **安装脚本是静态的**:不替换任何值,所以 SHA-256 固定;CI 把哈希写进签名的发布清单和发布页,面板的信任页显示它和清单是否一致;命令先下载、核对哈希,一致才执行。
|
||||||
- **密码登录的会话没有任何能力**,只能注册 passkey;创建加入码需要 `fleet.enroll`,不需要提权窗口。
|
- **密码登录和 passkey 登录的会话能力相同**;创建加入码需要 `fleet.enroll`,没有提权窗口。passkey 只用在节点自己会验证的事上。
|
||||||
- **现状的缺口**:没有吊销表;删除一把操作员 passkey 只是让它不能再签新版本;任务和配置没有操作员签名;节点没有自证构建;服务器密钥换新要靠签一个新集合版本,忘了签会让固定了集合的节点认不出面板(fail closed)。
|
- **现状的缺口**:没有吊销表;删除一把操作员 passkey 只是让它不能再签新版本;任务和配置没有操作员签名;节点没有自证构建;服务器密钥换新要靠签一个新集合版本,忘了签会让固定了集合的节点认不出面板(fail closed)。
|
||||||
|
|
||||||
## 8. 节点属主与 extension 授权(预留)
|
## 8. 节点属主与 extension 授权(预留)
|
||||||
@@ -282,12 +284,15 @@
|
|||||||
### 8.0 范围:这一节先固定协议和要求,extension 本身先不做
|
### 8.0 范围:这一节先固定协议和要求,extension 本身先不做
|
||||||
|
|
||||||
- **web ssh、文件、抓包等 extension 现在都不实现**,Agent 和 Probe 一样(Probe 是节点,同样要在本地执行下面的要求)。这里只把「谁来强制」和「怎么证明」定下来,以后实现时不改信任链,也不用重装节点。
|
- **web ssh、文件、抓包等 extension 现在都不实现**,Agent 和 Probe 一样(Probe 是节点,同样要在本地执行下面的要求)。这里只把「谁来强制」和「怎么证明」定下来,以后实现时不改信任链,也不用重装节点。
|
||||||
- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。默认是操作员集合里的全部凭证,空 = 不允许任何 extension 会话。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。
|
- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。**每个 Agent 和 Probe 都必须有属主,至少一把——而且这是节点自己的要求,不是 Backend 的要求**:节点启动时检查本地固定的属主,没有(或者属主的 passkey 已经不在操作员集合里)就不运行(Agent 和 Probe 共用 `Fleet.Hosting` 里的 `OwnerGuard`;配置 `fleet.require_owner`,`MOSHIRO_ALLOW_NO_OWNER=1` 放行)。面板被攻破也改不了节点本地的属主。属主是节点敏感内容(extension 会话、加密数据)绑定的人——这些内容只有属主的 passkey 能授权、解密,没有属主的节点没有人能用它的敏感能力,也就不该存在。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。
|
||||||
|
|
||||||
### 8.1 添加节点时选属主
|
### 8.1 添加节点时选属主
|
||||||
|
|
||||||
- API:`POST /api/admin/join-tokens` 带可选的 `controllers: [credential_id…]`;缺省 = 操作员集合里的全部凭证。校验:每个 id 必须是操作员集合里的凭证。
|
- API:`POST /api/admin/join-tokens` 的 `controllers: [credential_id…]`:不给就是操作员集合里的全部凭证;给了就必须非空,且每个 id 都是操作员集合里的凭证。还没有建立信任根、或者一把属主都没有时,面板**提前拒绝**创建加入码(400,字段 `controllers`)——这只是尽早失败,免得装出一个起不来的节点;真正的强制在节点(上面)。`MoShiro__RequireOwner=false` 可以关掉这个提前检查(只给没有信任根的老部署)。
|
||||||
- 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表;只是选,不用触碰。选定的写进安装命令,节点装上时固定。
|
- **逆推出绑定关系**:节点握手时把本地固定的属主(passkey 凭证 id)报给面板(hello 的 `owners`),面板顺着凭证 id 反查出它绑定在**哪个账户的哪把 passkey** 上,显示在「系统 → 版本」的「属主」列(对不上任何已注册 passkey 的显示「未知凭证」)。上报只是自述,真正的授权由节点自己校验 passkey 签名;以后给节点更多的接口和操作(extension 会话、加密内容)时,据此决定谁有资格用,不会越权到别人的节点。
|
||||||
|
- 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表,**必选**(一把都不选时创建按钮不可用);只是选,不用触碰。选定的写进安装命令,节点装上时固定。
|
||||||
|
- 安装端也强制:`install.sh`、`install.ps1`、容器入口缺了 `MOSHIRO_GENESIS` 或 `MOSHIRO_CONTROLLERS` 就不装(只有没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`);`fleet-enroll --genesis` 不带 `--controllers` 也拒绝。
|
||||||
|
- 设计意图(还没做):属主的 passkey 是敏感内容的钥匙。节点和 Probe 这一侧要在宿主侧拿到属主 passkey 的证明,才可以解密、才可以开 extension 会话;Backend 在这里只是搬运,见第 8.2、8.4 节。
|
||||||
- 以后改属主要现有属主的 passkey 签名(`ControllerUpdate`,没有实现);管理员改不了。
|
- 以后改属主要现有属主的 passkey 签名(`ControllerUpdate`,没有实现);管理员改不了。
|
||||||
|
|
||||||
### 8.2 谁来强制:节点本地,不是 Backend
|
### 8.2 谁来强制:节点本地,不是 Backend
|
||||||
@@ -374,19 +379,19 @@ web ssh 这类会话要不要加密、要不要验证,**由 Agent(和 Probe
|
|||||||
| WebAuthn 断言、OpSet、Grant、Ticket、Binding、RL 的编码和验签(纯函数,只用 BCL 的 ECDSA) | `Fleet.Identity` |
|
| WebAuthn 断言、OpSet、Grant、Ticket、Binding、RL 的编码和验签(纯函数,只用 BCL 的 ECDSA) | `Fleet.Identity` |
|
||||||
| `trust` 状态类型(OpSet + RL)、成员条目带链、同步时验证、成员表持久化 | `Fleet.Link` 的 `StateHandler`(对等握手不变) |
|
| `trust` 状态类型(OpSet + RL)、成员条目带链、同步时验证、成员表持久化 | `Fleet.Link` 的 `StateHandler`(对等握手不变) |
|
||||||
| 能力常量、`Principal.Has`、端点 `.Requires`、对账测试 | `MoShiro.Protocol`、`Backend.Api` |
|
| 能力常量、`Principal.Has`、端点 `.Requires`、对账测试 | `MoShiro.Protocol`、`Backend.Api` |
|
||||||
| 账户、passkey 注册与登录、会话分级、提权窗口、审计、API key | `Backend.Application`、`Storage.Sqlite`、`Backend.Api` |
|
| 账户、passkey 注册与登录、审计、API key | `Backend.Application`、`Storage.Sqlite`、`Backend.Api` |
|
||||||
| 管理端:passkey 登录与注册、提权状态条、API key 创建界面(预设、全选)、审计页 | `web/admin` |
|
| 管理端:passkey 登录与注册、信任页(列出新增和移除的凭证)、API key 创建界面(预设、全选)、审计页 | `web/admin` |
|
||||||
| 安装命令带创世公钥和 `K_code`;`fleet-enroll` 签 Binding | `packaging/install`、`Fleet.Enrollment.Client` |
|
| 安装命令带创世公钥和 `K_code`;`fleet-enroll` 签 Binding | `packaging/install`、`Fleet.Enrollment.Client` |
|
||||||
|
|
||||||
## 12. 实施顺序(每步可独立验证和推送)
|
## 12. 实施顺序(每步可独立验证和推送)
|
||||||
|
|
||||||
1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。
|
1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。
|
||||||
2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。**已实现**:passkey 登录(第 3.1 节)、提权窗口(接入 15 分钟、吊销 5 分钟、凭证类 60 秒用一次;窗口属于会话,在内存里,API key 不受影响;创建带信任类能力的 key 和增删 passkey 要凭证窗口,第一个 passkey 例外)、审计日志(`GET /api/admin/audit`,写操作和登录自动记录,保留 365 天)、归属(加入代码的创建者、节点的添加者)。
|
2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、审计日志、归属列和详情页显示。**已实现**:passkey 登录(第 3.1 节)、审计日志(`GET /api/admin/audit`,写操作和登录自动记录,保留 365 天)、归属(加入代码的创建者、节点的添加者)。
|
||||||
3. **操作员集合与创世公钥固定**(落地切片见第 7.5 节):`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。
|
3. **操作员集合与创世公钥固定**(落地切片见第 7.5 节):`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。
|
||||||
4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。
|
4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。
|
||||||
5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。
|
5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。
|
||||||
|
|
||||||
实现状态:能力模型、passkey 登录、提权窗口、审计、操作员集合与创世固定、服务器身份和安全链路、静态安装脚本和哈希核对、属主固定都已实现(第 0 节);成员证明(第 7.1~7.3 节)没有采用;吊销表、任务和配置的操作员签名、节点自证构建、extension 属主的实际强制还没有做。
|
实现状态:能力模型、passkey 登录、审计、操作员集合与创世固定、服务器身份和安全链路、静态安装脚本和哈希核对、属主固定都已实现(第 0 节);成员证明(第 7.1~7.3 节)没有采用;吊销表、任务和配置的操作员签名、节点自证构建、extension 属主的实际强制还没有做。
|
||||||
|
|
||||||
## 13. 局限
|
## 13. 局限
|
||||||
|
|
||||||
@@ -455,21 +460,20 @@ curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo "<sha256> /
|
|||||||
|
|
||||||
**Backend 侧**
|
**Backend 侧**
|
||||||
|
|
||||||
- WebAuthn 验证只发生在登录、提权、创建 key;API 请求认证是一次哈希查表,主体缓存 30 秒并在撤销时主动失效。
|
- WebAuthn 验证只发生在登录;API 请求认证是一次哈希查表,主体缓存 30 秒并在撤销时主动失效。
|
||||||
- `enroll` key 签一张券是一次 HKDF 加一次 ECDSA 签名(不到 1 毫秒)。
|
- `enroll` key 签一张券是一次 HKDF 加一次 ECDSA 签名(不到 1 毫秒)。
|
||||||
- 审计日志的写入并进 SQLite 单写入者的批量提交,不拖慢摄取路径。
|
- 审计日志的写入并进 SQLite 单写入者的批量提交,不拖慢摄取路径。
|
||||||
- webhook 投递、数据拉取都在 Backend 里完成(见 5.4),对节点零开销。
|
- webhook 投递、数据拉取都在 Backend 里完成(见 5.4),对节点零开销。
|
||||||
|
|
||||||
**日常摩擦**
|
**日常摩擦**
|
||||||
|
|
||||||
- 进页面不要求 passkey,信任类操作发生时才提权;窗口内连续操作不再弹。
|
- 进页面不要求 passkey,只有给操作员集合签名时才触碰。
|
||||||
- 批量操作一次触控:批量吊销是一张吊销表一次签名,批量添加是窗口内连续签券;列表都支持全选。
|
- 批量操作一次触控:批量吊销是一张吊销表一次签名,批量添加是窗口内连续签券;列表都支持全选。
|
||||||
- 自动化走 `enroll` key,不需要人在场;其余自动化走普通 key。
|
- 自动化走 `enroll` key,不需要人在场;其余自动化走普通 key。
|
||||||
- 离线恢复密钥(见第 3 节)避免因为 passkey 丢失而重装节点。
|
- 离线恢复密钥(见第 3 节)避免因为 passkey 丢失而重装节点。
|
||||||
|
|
||||||
## 17. 待确认
|
## 17. 待确认
|
||||||
|
|
||||||
1. 窗口默认值:接入 15 分钟、吊销 5 分钟、凭证类不设窗口。
|
|
||||||
2. API key 的 IP 白名单要不要做(倾向做,放在第 1 步)。
|
2. API key 的 IP 白名单要不要做(倾向做,放在第 1 步)。
|
||||||
3. 迁移后,现有 admin 令牌不能生成加入代码,需要触控创建 `enroll` key 来替换。
|
3. 迁移后,现有 admin 令牌不能生成加入代码,需要触控创建 `enroll` key 来替换。
|
||||||
4. `owner` 与 `admin` 之外要不要加只读的认证角色(目前用 `read` 能力的 API key 满足)。
|
4. `owner` 与 `admin` 之外要不要加只读的认证角色(目前用 `read` 能力的 API key 满足)。
|
||||||
@@ -481,3 +485,26 @@ curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo "<sha256> /
|
|||||||
10. 安装脚本的哈希在哪里公布(Gitea 发布页之外还要不要放 DNS TXT 或另一个独立渠道)。
|
10. 安装脚本的哈希在哪里公布(Gitea 发布页之外还要不要放 DNS TXT 或另一个独立渠道)。
|
||||||
11. 节点预算的数字(常驻内存 ≤ 1 MB、二进制 ≤ 150 KB、每个成员验证 ≤ 100 毫秒、成员上限 256)是否合适;超出预算时优先砍什么(比如先去掉操作员集合的 64 个凭证上限换成更小的)。
|
11. 节点预算的数字(常驻内存 ≤ 1 MB、二进制 ≤ 150 KB、每个成员验证 ≤ 100 毫秒、成员上限 256)是否合适;超出预算时优先砍什么(比如先去掉操作员集合的 64 个凭证上限换成更小的)。
|
||||||
12. webhook 和数据拉取的配额默认值(每分钟请求数、每天行数)和事件清单。
|
12. webhook 和数据拉取的配额默认值(每分钟请求数、每天行数)和事件清单。
|
||||||
|
|
||||||
|
## 16. 节点二进制绑定
|
||||||
|
|
||||||
|
目的:被换掉的、版本被改的、降级的二进制,不能继续当这个节点运行。
|
||||||
|
|
||||||
|
```text
|
||||||
|
CI 构建:算出镜像里节点二进制的 SHA-256,写进签名的清单(binaries)
|
||||||
|
宿主机更新器:把验过的清单和签名写进节点只读挂载的 update-status/(installed.manifest、installed.sig)
|
||||||
|
节点握手(auth 里):binary = 正在运行的二进制的哈希(Linux 读 /proc/self/exe,是进程启动时映射的那个映像)
|
||||||
|
release、release_sig = 上面那份清单和签名
|
||||||
|
Backend:
|
||||||
|
① 第一次握手成功 → 绑定 (节点, 哈希, 清单 seq)
|
||||||
|
② 以后每次握手:哈希和绑定一致 → 放行
|
||||||
|
③ 不一致 → 必须是一份 CI 签名清单(用内嵌的发布公钥验)里列出的官方二进制,并且清单 seq 不低于绑定时的(合法升级,改绑);
|
||||||
|
否则拒绝连接,节点页标红「不符」。不许降级。
|
||||||
|
```
|
||||||
|
|
||||||
|
- **清单是节点带来的**,Backend 自己验签,所以不依赖面板有没有收到过这次发布;被攻破的 Backend 也加不进「官方」哈希(没有 CI 私钥)。
|
||||||
|
- **旧节点**(不上报哈希):没绑定过就放行(显示未绑定);绑定过的不许退回不上报。
|
||||||
|
- **没有更新器的节点**(二进制安装)没有清单,首次握手按上报的哈希绑定(显示「首次绑定」);手工升级之后哈希变了,会被拒绝,管理员在「系统 → 版本」点「接受当前二进制」清除绑定,下一次连接重新绑定。
|
||||||
|
- **策略** `MoShiro__BinaryPolicy`:`enforce`(默认)拒绝;`warn` 只记录不拒绝;`off` 不检查。
|
||||||
|
- **边界**:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希,Backend 分辨不出——上报者就是被怀疑的对象。要堵这一点,必须让容器之外的东西背书:宿主机更新器用安装时绑定的密钥对「当前运行的镜像 digest + 挑战」签名,由 agent 转发;容器里的进程被换掉也伪造不了。这一步还没做。
|
||||||
|
|
||||||
+3
-1
@@ -146,9 +146,11 @@
|
|||||||
| GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) |
|
| GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) |
|
||||||
| GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 |
|
| GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 |
|
||||||
| GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) |
|
| GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) |
|
||||||
| GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,面板不验签、原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 |
|
| GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,需要 `release.publish`,原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 |
|
||||||
|
| POST | `/api/release/offer` | CI 登记发布清单的入口,**不需要令牌**:`{manifest, signature}` 的签名必须能用 Backend 内嵌的发布公钥验过(ECDSA P-256 / SHA-256,DER),否则 403;`seq` 必须比已登记的新,否则 409。登记不依赖面板里的任何凭证,清库、重装、还没有 passkey 时照常工作 |
|
||||||
| GET | `/api/admin/links` | 每一对(Probe,Agent)的线路开关:`linked`(最终结果)、`override`(手动设置,`null` 跟随自动连接)、`connected`,以及 `auto_link` |
|
| GET | `/api/admin/links` | 每一对(Probe,Agent)的线路开关:`linked`(最终结果)、`override`(手动设置,`null` 跟随自动连接)、`connected`,以及 `auto_link` |
|
||||||
| PUT | `/api/admin/links/{probe_id}/{agent_id}` | `{enabled: true|false|null}`:手动强制连或不连,`null` 取消手动设置;改完立即重新下发 |
|
| PUT | `/api/admin/links/{probe_id}/{agent_id}` | `{enabled: true|false|null}`:手动强制连或不连,`null` 取消手动设置;改完立即重新下发 |
|
||||||
|
| DELETE | `/api/admin/nodes/{id}/binary` | 接受节点当前的二进制:清除它的二进制绑定,下一次握手重新绑定(需要 `fleet.write`;没有绑定也返回 204)。`GET /api/admin/build-info` 的每个节点带 `binary`(`sha256`、`signed`、`seq`、`bound_at`、`violation`) |
|
||||||
| POST | `/api/admin/nodes/{id}/update` | 请求一个在线的 Agent 或 Probe 更新到它收到的清单(节点不在线返回 409) |
|
| POST | `/api/admin/nodes/{id}/update` | 请求一个在线的 Agent 或 Probe 更新到它收到的清单(节点不在线返回 409) |
|
||||||
| GET/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 |
|
| GET/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 |
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -40,12 +40,20 @@ macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。
|
|||||||
|
|
||||||
Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/moshiro/moshiro-backend:<version>` 分发,支持 `linux/amd64` 和 `linux/arm64`。容器使用 glibc NativeAOT 单文件程序(动态链接,运行时要加载 libssl;musl 的静态产物做不到),基底为 `mcr.microsoft.com/dotnet/runtime-deps:10.0`(Debian),以非 root 用户运行,在 `8080/tcp` 提供 HTTP 服务;数据库和原始数据统一写入 `/data`。程序启用 InvariantGlobalization,无需 .NET 运行时或 ICU;时间查询使用 UTC。SQLite 从固定版本的官方源码编译并静态链接,不再随镜像携带 `libe_sqlite3.so`。将 `/data` 挂载为持久卷,并在反向代理处配置 HTTPS。
|
Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/moshiro/moshiro-backend:<version>` 分发,支持 `linux/amd64` 和 `linux/arm64`。容器使用 glibc NativeAOT 单文件程序(动态链接,运行时要加载 libssl;musl 的静态产物做不到),基底为 `mcr.microsoft.com/dotnet/runtime-deps:10.0`(Debian),以非 root 用户运行,在 `8080/tcp` 提供 HTTP 服务;数据库和原始数据统一写入 `/data`。程序启用 InvariantGlobalization,无需 .NET 运行时或 ICU;时间查询使用 UTC。SQLite 从固定版本的官方源码编译并静态链接,不再随镜像携带 `libe_sqlite3.so`。将 `/data` 挂载为持久卷,并在反向代理处配置 HTTPS。
|
||||||
|
|
||||||
在部署目录保存此仓库的 `compose.yaml`,先通过部署平台的环境变量或密钥管理设置 `MOSHIRO_ADMIN_PASSWORD`(可选设置 `MOSHIRO_ADMIN_USER`、`MOSHIRO_VERSION`),然后启动:
|
**前端内嵌在 Backend 里**:CI 构建 Backend 前先构建 `web/display` 和 `web/admin`,`Backend.Api` 把两个 `dist` 嵌进程序,展示端在 `/`,管理端在 `/admin/`,所以面板的域名(如 `https://mo.oeo.one/admin/`)打开就是管理端,passkey 才能绑定在这个域名上(浏览器只允许页面的来源与 RP 域名一致,本机 `localhost` 上的页面注册不了绑定 `mo.oeo.one` 的 passkey)。本地没有构建前端时不嵌入,Backend 只提供 API。文件是启动时读进内存的白名单,请求时不访问文件系统;带 `..`、反斜杠、`%`、控制字符或空段的路径一律 404,缺失的文件(带扩展名)404,只有无扩展名的路径回退到入口页;`/api`、`/openapi`、`/install`、`/healthz` 下没有匹配到的路径仍是后端的 JSON 404。页面带 CSP(脚本只许同源)、`nosniff`、`X-Frame-Options: DENY`,入口页 `no-cache`,哈希文件名的资源 `immutable`,不嵌入 source map。部署时设置 `MoShiro__PublicUrl=https://<面板域名>`(`deploy-node.sh backend --public-url …`),WebAuthn 的域名和允许的来源由它派生。
|
||||||
|
|
||||||
|
在部署目录保存此仓库的 `compose.yaml`,可选设置 `MOSHIRO_CLAIM_CODE`(认领码,不设置就随机生成并写进后端日志)和 `MOSHIRO_VERSION`,然后启动:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**首次认领(没有默认管理员)**:面板里还没有任何用户时,打开 `/admin/` 会进入认领向导:
|
||||||
|
1. 填认领码(首次启动时随机生成并写进后端日志,`journalctl -u moshiro-backend` 或 `docker logs`;也可以用 `MoShiro__ClaimCode` / `MOSHIRO_CLAIM_CODE` 指定)、管理员的用户名和密码;
|
||||||
|
2. 注册你的第一把 passkey;
|
||||||
|
3. 用它给操作员集合签名,建立信任根。
|
||||||
|
谁先打开页面谁就能当管理员,所以认领码只有能看到后端日志的人拿得到;创建完第一个管理员,码作废、入口关闭(再提交返回 409)。认领码比较不分大小写、忽略分隔符;失败按来源地址限流。向导可以中途离开,passkey 和信任根之后随时在「用户 → 我的 Passkey」和「系统 → 信任」里补;没有 passkey 也能用密码登录管理。
|
||||||
|
|
||||||
Compose 将容器端口只绑定到宿主机 `127.0.0.1:8080`,适合由同机 HTTPS 反向代理转发;数据保存在命名卷 `moshiro-backend-data`(Docker 和 Podman 通用,`podman compose` 也可以用同一份文件)。首次启动成功创建管理员后,改密应在管理端完成。
|
Compose 将容器端口只绑定到宿主机 `127.0.0.1:8080`,适合由同机 HTTPS 反向代理转发;数据保存在命名卷 `moshiro-backend-data`(Docker 和 Podman 通用,`podman compose` 也可以用同一份文件)。首次启动成功创建管理员后,改密应在管理端完成。
|
||||||
|
|
||||||
Backend 的 NativeAOT 发布默认启用 `SqliteStaticLink`,支持 Linux glibc/musl、macOS、Windows 的 x64/arm64 RID;跨平台构建使用 `-p:UseAotAnywhere=true`。SQLite 源码版本、下载年份和 SHA-256 统一配置在根目录 `Directory.Packages.props`,构建会校验下载文件并按 RID 缓存原生对象。普通 JIT 开发构建仍使用 NuGet 原生库。发布 Backend 时设置 `-p:OpenApiGenerateDocuments=false`,API 文档在普通开发构建中生成。
|
Backend 的 NativeAOT 发布默认启用 `SqliteStaticLink`,支持 Linux glibc/musl、macOS、Windows 的 x64/arm64 RID;跨平台构建使用 `-p:UseAotAnywhere=true`。SQLite 源码版本、下载年份和 SHA-256 统一配置在根目录 `Directory.Packages.props`,构建会校验下载文件并按 RID 缓存原生对象。普通 JIT 开发构建仍使用 NuGet 原生库。发布 Backend 时设置 `-p:OpenApiGenerateDocuments=false`,API 文档在普通开发构建中生成。
|
||||||
@@ -305,7 +313,7 @@ sh packaging/update/install-updater.sh agent --user \
|
|||||||
|
|
||||||
更新器依赖 Linux、systemd、OpenSSL、util-linux/flock 和所选引擎。用户部署把配置、公钥和镜像引用放在 `~/.config/moshiro/`、版本记录放在 `~/.local/state/moshiro-update/`;系统部署使用 `/etc/moshiro-update/` 和 `/var/lib/moshiro-update/`。私有仓库的 `--authfile` 对 Podman 使用其认证文件,对 Docker 使用标准 `config.json`,不改写全局 Docker 配置。旧 `--data-dir` 安装方式保留兼容,但新部署统一使用 `--volume`。
|
更新器依赖 Linux、systemd、OpenSSL、util-linux/flock 和所选引擎。用户部署把配置、公钥和镜像引用放在 `~/.config/moshiro/`、版本记录放在 `~/.local/state/moshiro-update/`;系统部署使用 `/etc/moshiro-update/` 和 `/var/lib/moshiro-update/`。私有仓库的 `--authfile` 对 Podman 使用其认证文件,对 Docker 使用标准 `config.json`,不改写全局 Docker 配置。旧 `--data-dir` 安装方式保留兼容,但新部署统一使用 `--volume`。
|
||||||
|
|
||||||
发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。
|
发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。`RELEASE_API_URL` 指向面板,CI 把签名清单 POST 到 `/api/release/offer`:这个入口不需要令牌,面板用内嵌的发布公钥验签,签名无效就拒绝,所以 CI 不需要在面板里持有任何凭证(清库或重装后照常登记,也没有长期令牌可偷)。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。
|
||||||
|
|
||||||
**换发布密钥**(泄露或定期轮换,不用重装节点):
|
**换发布密钥**(泄露或定期轮换,不用重装节点):
|
||||||
|
|
||||||
@@ -316,6 +324,26 @@ sh packaging/update/install-updater.sh agent --user \
|
|||||||
|
|
||||||
一行安装:页面给的命令先下载静态安装脚本(`/install`,所有人拿到的字节都一样)并核对它的 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹(`MOSHIRO_GENESIS`)和属主(`MOSHIRO_CONTROLLERS`)走环境变量,都是公开值,没有私钥。脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`);签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 和 `MOSHIRO_IMAGE=镜像` 才会安装。CI 把脚本的 SHA-256 写进签名清单和发布页,面板的「系统 → 信任」页显示它和清单是否一致。详见 [access.md](access.md) 第 0 节。
|
一行安装:页面给的命令先下载静态安装脚本(`/install`,所有人拿到的字节都一样)并核对它的 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹(`MOSHIRO_GENESIS`)和属主(`MOSHIRO_CONTROLLERS`)走环境变量,都是公开值,没有私钥。脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`);签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 和 `MOSHIRO_IMAGE=镜像` 才会安装。CI 把脚本的 SHA-256 写进签名清单和发布页,面板的「系统 → 信任」页显示它和清单是否一致。详见 [access.md](access.md) 第 0 节。
|
||||||
|
|
||||||
|
## 签名清单放在镜像仓库里(更新和首次部署都不依赖面板)
|
||||||
|
|
||||||
|
CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给面板,**一定会把签名清单推到镜像仓库**:`<镜像仓库>-manifest:<通道>`,例如 `registry.oeo.one/moshiro-dev/moshiro-backend-manifest:dev`、`registry.oeo.one/moshiro/moshiro-backend-manifest:release`。它是一个没有任何文件层的小镜像,清单和签名放在标签 `org.moshiro.manifest`(base64)和 `org.moshiro.signature` 里。`dev` 通道每次 `[CI]` 构建都会更新,`release` 通道只在打 tag 时更新,所以不发 Release 的构建照样能被更新到。
|
||||||
|
|
||||||
|
```text
|
||||||
|
信任锚点:仓库里的发布公钥 packaging/update/release-key.pub.pem(随安装脚本和 deploy-node.sh 分发)
|
||||||
|
取清单的人:宿主机更新器(定时)、deploy-node.sh(首次部署):pull 清单镜像 → 读标签 → 本机公钥验签 → 按清单里的 digest 拉真正的镜像
|
||||||
|
仓库、网络、面板都不需要可信:被改只会让验签失败;面板不在线、清库、没有 CI 都不影响
|
||||||
|
```
|
||||||
|
|
||||||
|
- **更新器**:配置里 `FEED=auto`(默认)时先看仓库里的清单,验签通过才用;取不到或验不过就用面板放进信箱的那份。`MANIFEST_REF` 可以指定清单镜像,`FEED=off` 关掉;通道取配置里的 `CHANNEL`,没有就按仓库路径推断(`moshiro-dev` → dev,`moshiro` → release)。仓库里的清单验签失败而信箱里又没有东西时,状态会报「无效清单」而不是悄悄当作没有更新。
|
||||||
|
- **面板自己取**:安装脚本向面板要某个组件的签名清单时,面板里没有(清过库、CI 登记那一步网络失败)或者已经放了十分钟以上,面板就按 OCI 分发协议**匿名**读一次仓库里的清单镜像(只读清单和它的配置两个小 JSON,不拉镜像层),用内嵌的 CI 发布公钥验过签才登记;验不过(仓库或路上被改)就当没有。所以清库之后不用手工补登记,也不会被一份伪造的清单骗到。仓库要匿名可读(`moshiro/**`、`moshiro-dev/**`)。
|
||||||
|
- **没有宿主机更新器的节点**(一键容器安装、二进制安装)握手时带不来清单:面板用自己手里(登记时验过 CI 签名)的那份把节点的二进制标成「已签名」;不在清单里的只是不标「已签名」,不会因此被拒绝。
|
||||||
|
- **发版**:打 `vMAJOR.MINOR.PATCH` tag 时,节点镜像(Agent、Probe 的多架构镜像)也和 Backend 一样签清单,放到 `moshiro/…-manifest:release`。清单里的 `binaries` 是**所有平台、所有架构**的发布压缩包里可执行文件的 SHA-256(Linux glibc 和 musl、macOS、Windows,x64 和 arm64,每个组件 8 个,`ci/release-binary-hashes.sh`),不只是容器镜像里的那两个:节点握手时上报正在运行的可执行文件的哈希,Backend 凭 CI 签名的清单认「官方二进制」,所以容器和直接下载压缩包安装的节点是一回事。
|
||||||
|
- **压缩包的校验和也签名**:Release 里除了每个压缩包的 `.sha256`,还有汇总的 `SHA256SUMS` 和 `SHA256SUMS.sig`(用发布私钥签,base64 的 DER ECDSA P-256,和清单同一把密钥)。验证:`base64 -d SHA256SUMS.sig > s.bin && openssl dgst -sha256 -verify packaging/update/release-key.pub.pem -signature s.bin SHA256SUMS`,通过之后再 `sha256sum -c SHA256SUMS`。macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要苹果和微软的证书,目前没有做。
|
||||||
|
- 测试:`bash ci/tests/binary-hashes-test.sh`(本地的假包仓库,8 种平台的 tar.gz 和 zip,缺任何一个压缩包都会失败,不会悄悄少签)。
|
||||||
|
- **首次部署**:`deploy-node.sh` 默认就走这条路,不需要面板,也不用手动指定镜像(Backend 第一次部署时本来就没有面板)。`--tag` 指定标签时不验签并有警告;`--image` 直接用你给的引用。
|
||||||
|
- **别人自己部署后端,什么都不用改**:仓库对匿名读开放(`moshiro/*` 和各 `*-manifest` 仓库),然后 `sudo sh deploy-node.sh backend --channel release --public-url https://<自己的域名>`,只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(`--image-repo`)并重新构建,这是 fork 的正常代价。
|
||||||
|
- 测试:`bash packaging/update/tests/feed-test.sh`(假的 podman + 真的 openssl,覆盖篡改、别的密钥、旧 seq、换钥、仓库不可用回退、首次部署的验签)。
|
||||||
|
|
||||||
## Backend 签名更新
|
## Backend 签名更新
|
||||||
|
|
||||||
Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。
|
Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。
|
||||||
|
|||||||
+3
-3
@@ -73,18 +73,18 @@ Agent、Backend、Probe 之间互相不信任。这份文档只回答三个问
|
|||||||
|
|
||||||
## 5. 入网信任链
|
## 5. 入网信任链
|
||||||
|
|
||||||
下文和其他章节里的「名册」指节点本地验过链的成员表。**详细设计见 [access.md](access.md)**(账户、角色、能力、API key、提权窗口、签名链、流程和实施顺序)。这里只留结论:
|
下文和其他章节里的「名册」指节点本地验过链的成员表。**详细设计见 [access.md](access.md)**(账户、角色、能力、API key、签名链、流程和实施顺序)。这里只留结论:
|
||||||
|
|
||||||
- 名册不再是「每次变更都要操作员签一整张表」,而是一条可离线验证的链:节点固定创世 owner 公钥 → 操作员集合(带版本,owner 签)→ 委托(passkey 签,限定范围和时间)→ 入网券(委托的密钥签)→ 节点绑定(节点注册时用安装命令里的 `K_code` 签)。加上 passkey 当场签的吊销表。
|
- 名册不再是「每次变更都要操作员签一整张表」,而是一条可离线验证的链:节点固定创世 owner 公钥 → 操作员集合(带版本,owner 签)→ 委托(passkey 签,限定范围和时间)→ 入网券(委托的密钥签)→ 节点绑定(节点注册时用安装命令里的 `K_code` 签)。加上 passkey 当场签的吊销表。
|
||||||
- Agent 只接纳链验得过的 Probe,Probe 只拨链验得过的 Agent,并且只拨公网地址(缺口 G3、G6 因此消失,不需要另外的拨号白名单)。
|
- Agent 只接纳链验得过的 Probe,Probe 只拨链验得过的 Agent,并且只拨公网地址(缺口 G3、G6 因此消失,不需要另外的拨号白名单)。
|
||||||
- **不可回放**:节点持久化见过的最大操作员集合版本和吊销表版本,Backend 无法拿旧文档复活已吊销的节点。
|
- **不可回放**:节点持久化见过的最大操作员集合版本和吊销表版本,Backend 无法拿旧文档复活已吊销的节点。
|
||||||
- 人在场时一次 passkey 触控换一个有时限的提权窗口,窗口内加节点不再确认;自动化用创建时经 passkey 签名的 `enroll` API key。被攻破的 Backend 在窗口和 key 过期后,一个节点都加不进来。
|
- 加节点不需要 passkey:创建加入码是普通操作,命令里只有公开的值(创世指纹、属主、脚本哈希);自动化用带 `fleet.enroll` 的 API key。信任来自节点注册时固定的创世指纹和属主。
|
||||||
|
|
||||||
## 6. 操作员凭证
|
## 6. 操作员凭证
|
||||||
|
|
||||||
操作员凭证就是 passkey(WebAuthn,ES256,attestation 选 `none`)。节点本地固定创世 owner 公钥,其余凭证靠 owner 签名的操作员集合同步,不用重装节点;每条凭证带角色(`owner`、`admin`)和限制(例如单张入网券的最长有效期),由节点自己执行。
|
操作员凭证就是 passkey(WebAuthn,ES256,attestation 选 `none`)。节点本地固定创世 owner 公钥,其余凭证靠 owner 签名的操作员集合同步,不用重装节点;每条凭证带角色(`owner`、`admin`)和限制(例如单张入网券的最长有效期),由节点自己执行。
|
||||||
|
|
||||||
- 登录可以用密码、passkey 或 OIDC,但**只有 passkey 能提权**:节点验证不了 IdP 的断言,也不该让密码和 IdP 进入信任域。OIDC 只是账户的另一种登录方式(绑定 `iss + sub`)。
|
- 登录可以用密码、passkey 或 OIDC,会话能力相同;但**只有 passkey 能给操作员集合签名**:节点验证不了 IdP 的断言,也不该让密码和 IdP 进入信任域。OIDC 只是账户的另一种登录方式(绑定 `iss + sub`)。
|
||||||
- 自动化凭证:`enroll` API key 创建时由 passkey 签一份委托;CI 和测试用软件 ECDSA 凭证,写进测试的操作员集合。
|
- 自动化凭证:`enroll` API key 创建时由 passkey 签一份委托;CI 和测试用软件 ECDSA 凭证,写进测试的操作员集合。
|
||||||
- 恢复:passkey 全部丢失时,重装节点并换创世公钥;所以每个账户至少注册两个 passkey。
|
- 恢复:passkey 全部丢失时,重装节点并换创世公钥;所以每个账户至少注册两个 passkey。
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
#
|
#
|
||||||
# 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1
|
# 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1
|
||||||
# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --server https://panel --join jt_xxx --name home-1
|
# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --server https://panel --join jt_xxx --name home-1
|
||||||
# 首次部署 Backend:sudo sh deploy-node.sh backend --channel dev --admin-password '…' [--public-url https://panel] [--port 29181] [--env KEY=VALUE]...
|
# 首次部署 Backend:sudo sh deploy-node.sh backend --channel dev [--public-url https://panel] [--port 29181] [--env KEY=VALUE]...
|
||||||
|
# 没有默认管理员:部署完打开面板,按页面引导自己创建管理员、注册 passkey、建立信任根;页面要的认领码在后端日志里(journalctl -u moshiro-backend)。
|
||||||
# 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置)
|
# 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置)
|
||||||
# 旧版 Probe(probe.json 里没有 fleet 段):加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old
|
# 旧版 Probe(probe.json 里没有 fleet 段):加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old
|
||||||
#
|
#
|
||||||
@@ -14,15 +15,15 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "usage: deploy-node.sh <agent|probe|backend> [--engine podman|docker] [--image-repo R] [--tag T] [--image REF]" >&2
|
echo "usage: deploy-node.sh <agent|probe|backend> [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2
|
||||||
echo " [--server URL --join TOKEN --name NAME] [--policy auto|manual] [--authfile F]" >&2
|
echo " [--server URL --join TOKEN --name NAME] [--policy auto|manual] [--authfile F]" >&2
|
||||||
echo " backend: [--channel dev|release] [--port N] [--admin-user U] [--admin-password P] [--public-url URL] [--env KEY=VALUE]..." >&2
|
echo " backend: [--channel dev|release] [--port N] [--admin-user U --admin-password P:只给自动化用,正常不要] [--public-url URL] [--env KEY=VALUE]..." >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
ROLE="${1:-}"; shift || true
|
ROLE="${1:-}"; shift || true
|
||||||
case "$ROLE" in agent|probe|backend) ;; *) usage ;; esac
|
case "$ROLE" in agent|probe|backend) ;; *) usage ;; esac
|
||||||
ENGINE=""; REPO=""; TAG=latest; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""; PORT=""; ADMIN_USER=""; ADMIN_PASSWORD=""; PUBLIC_URL=""; EXTRA_ENV=""
|
ENGINE=""; REPO=""; TAG=""; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""; PORT=""; ADMIN_USER=""; ADMIN_PASSWORD=""; PUBLIC_URL=""; EXTRA_ENV=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--engine) ENGINE="$2"; shift 2 ;;
|
--engine) ENGINE="$2"; shift 2 ;;
|
||||||
@@ -82,13 +83,50 @@ for f in "$PACKAGING/systemd/$UNIT_SRC" "$PACKAGING/update/install-updater.sh" "
|
|||||||
[ -r "$f" ] || { echo "missing $f (keep deploy-node.sh next to the packaging/ directory)" >&2; exit 1; }
|
[ -r "$f" ] || { echo "missing $f (keep deploy-node.sh next to the packaging/ directory)" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
# 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用
|
# 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用。
|
||||||
if [ -z "$IMAGE" ]; then
|
# 默认从仓库里的签名清单取(<仓库>-manifest:<通道>,CI 发布的),用随脚本带的发布公钥验签,按清单里的 digest 固定——
|
||||||
|
# 仓库和网络都不需要可信,也不需要面板在线(Backend 第一次部署时还没有面板)。
|
||||||
|
# --tag 指定标签就不验签(拉那个标签再固定 digest);--image 直接用你给的引用。
|
||||||
if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi
|
if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi
|
||||||
if [ "$ENGINE" = podman ] && [ -n "$AUTHFILE" ]; then "$ENGINE" pull -q --authfile "$AUTHFILE" "$REPO:$TAG" >/dev/null
|
pull_quiet() {
|
||||||
else "$ENGINE" pull -q "$REPO:$TAG" >/dev/null; fi
|
if [ "$ENGINE" = podman ] && [ -n "$AUTHFILE" ]; then "$ENGINE" pull -q --authfile "$AUTHFILE" "$1" >/dev/null
|
||||||
|
else "$ENGINE" pull -q "$1" >/dev/null; fi
|
||||||
|
}
|
||||||
|
resolve_verified() {
|
||||||
|
channel="$CHANNEL"
|
||||||
|
if [ -z "$channel" ]; then
|
||||||
|
case "$REPO" in */moshiro-dev/*) channel=dev ;; */moshiro/*) channel=release ;; esac
|
||||||
|
fi
|
||||||
|
[ -n "$channel" ] || { echo "cannot tell the release channel from $REPO: pass --channel dev|release, or --tag to skip verification" >&2; return 1; }
|
||||||
|
command -v openssl >/dev/null 2>&1 || { echo "openssl is required to verify the release signature (or pass --tag to skip verification)" >&2; return 1; }
|
||||||
|
ref="$REPO-manifest:$channel"
|
||||||
|
pull_quiet "$ref" || { echo "cannot pull the signed manifest $ref: is the repository readable (--authfile)? Or pass --tag to skip verification" >&2; return 1; }
|
||||||
|
labels="$("$ENGINE" image inspect --format '{{index .Config.Labels "org.moshiro.manifest"}}|{{index .Config.Labels "org.moshiro.signature"}}' "$ref")" || return 1
|
||||||
|
vtmp="$(mktemp -d)"
|
||||||
|
printf '%s' "${labels%%|*}" | base64 -d > "$vtmp/manifest" 2>/dev/null && printf '%s' "${labels#*|}" | base64 -d > "$vtmp/sig" 2>/dev/null \
|
||||||
|
|| { rm -rf "$vtmp"; echo "the manifest in $ref is not readable" >&2; return 1; }
|
||||||
|
if ! openssl dgst -sha256 -verify "$PACKAGING/update/release-key.pub.pem" -signature "$vtmp/sig" "$vtmp/manifest" >/dev/null 2>&1; then
|
||||||
|
rm -rf "$vtmp"; echo "the signature of the manifest in $ref does not match the bundled release key; refusing to install" >&2; return 1
|
||||||
|
fi
|
||||||
|
mfield() { sed -n 's/.*"'"$1"'":"\([^"]*\)".*/\1/p' "$vtmp/manifest" | head -n 1; }
|
||||||
|
vdigest="$(mfield digest)"; vcomponent="$(mfield component)"; vimage="$(mfield image)"; vtag="$(mfield tag)"
|
||||||
|
rm -rf "$vtmp"
|
||||||
|
[ "$vcomponent" = "$ROLE" ] && [ "$vimage" = "$REPO" ] || { echo "the manifest is for $vcomponent / $vimage, not $ROLE / $REPO; refusing to install" >&2; return 1; }
|
||||||
|
case "$vdigest" in sha256:*) ;; *) echo "the manifest has no usable digest" >&2; return 1 ;; esac
|
||||||
|
[ "${#vdigest}" -eq 71 ] && [ -z "$(printf '%s' "${vdigest#sha256:}" | tr -d '0-9a-f')" ] || { echo "the manifest digest is malformed" >&2; return 1; }
|
||||||
|
IMAGE="$REPO@$vdigest"
|
||||||
|
pull_quiet "$IMAGE" || { echo "cannot pull $IMAGE" >&2; return 1; }
|
||||||
|
echo "verified release $vtag (CI-signed manifest, $ref)"
|
||||||
|
}
|
||||||
|
if [ -z "$IMAGE" ]; then
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
resolve_verified || exit 1
|
||||||
|
else
|
||||||
|
pull_quiet "$REPO:$TAG"
|
||||||
if [ "$ENGINE" = podman ]; then DIGEST="$("$ENGINE" image inspect --format '{{.Digest}}' "$REPO:$TAG")"; IMAGE="$REPO@$DIGEST"
|
if [ "$ENGINE" = podman ]; then DIGEST="$("$ENGINE" image inspect --format '{{.Digest}}' "$REPO:$TAG")"; IMAGE="$REPO@$DIGEST"
|
||||||
else IMAGE="$("$ENGINE" image inspect --format '{{index .RepoDigests 0}}' "$REPO:$TAG")"; fi
|
else IMAGE="$("$ENGINE" image inspect --format '{{index .RepoDigests 0}}' "$REPO:$TAG")"; fi
|
||||||
|
echo "warning: $REPO:$TAG was not signature-checked (--tag)" >&2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "image: $IMAGE"
|
echo "image: $IMAGE"
|
||||||
|
|
||||||
@@ -106,18 +144,17 @@ if [ "$ROLE" = backend ]; then
|
|||||||
# Backend 的环境文件放在宿主机上(单元用 --env-file 读取);已有的不会被覆盖
|
# Backend 的环境文件放在宿主机上(单元用 --env-file 读取);已有的不会被覆盖
|
||||||
install -d -m 755 /etc/moshiro
|
install -d -m 755 /etc/moshiro
|
||||||
if [ ! -f /etc/moshiro/backend.env ]; then
|
if [ ! -f /etc/moshiro/backend.env ]; then
|
||||||
if [ -z "$ADMIN_PASSWORD" ]; then
|
|
||||||
ADMIN_PASSWORD="$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 20)"
|
|
||||||
echo "generated admin password (shown once): $ADMIN_PASSWORD"
|
|
||||||
fi
|
|
||||||
(
|
(
|
||||||
umask 077
|
umask 077
|
||||||
{
|
{
|
||||||
echo "MoShiro__DataDirectory=/data"
|
echo "MoShiro__DataDirectory=/data"
|
||||||
echo "MoShiro__UpdateChannel=$CHANNEL"
|
echo "MoShiro__UpdateChannel=$CHANNEL"
|
||||||
echo "MoShiro__ImageNamespace=$NAMESPACE"
|
echo "MoShiro__ImageNamespace=$NAMESPACE"
|
||||||
|
# 没有默认管理员:第一个管理员由部署的人在首次打开的页面上自己填,认领码在后端日志里(--admin-password 只给自动化用)
|
||||||
|
if [ -n "$ADMIN_PASSWORD" ]; then
|
||||||
echo "MoShiro__InitialAdminUser=${ADMIN_USER:-admin}"
|
echo "MoShiro__InitialAdminUser=${ADMIN_USER:-admin}"
|
||||||
echo "MoShiro__InitialAdminPassword=$ADMIN_PASSWORD"
|
echo "MoShiro__InitialAdminPassword=$ADMIN_PASSWORD"
|
||||||
|
fi
|
||||||
[ -z "$PUBLIC_URL" ] || echo "MoShiro__PublicUrl=$PUBLIC_URL"
|
[ -z "$PUBLIC_URL" ] || echo "MoShiro__PublicUrl=$PUBLIC_URL"
|
||||||
# 其他设置原样写入,例如 --env MoShiro__CorsOrigins=http://localhost:5173 --env MoShiro__SecureCookies=false
|
# 其他设置原样写入,例如 --env MoShiro__CorsOrigins=http://localhost:5173 --env MoShiro__SecureCookies=false
|
||||||
[ -z "$EXTRA_ENV" ] || printf '%s' "$EXTRA_ENV"
|
[ -z "$EXTRA_ENV" ] || printf '%s' "$EXTRA_ENV"
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ if [ ! -f "$identity/node.json" ]; then
|
|||||||
echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2
|
echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# 每个节点都必须有属主(passkey):信任根指纹和属主缺一个就不注册
|
||||||
|
if [ -z "${MOSHIRO_GENESIS:-}" ] || [ -z "${MOSHIRO_CONTROLLERS:-}" ]; then
|
||||||
|
if [ "${MOSHIRO_ALLOW_NO_OWNER:-}" != 1 ]; then
|
||||||
|
echo "this node has no owner: set MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS (the panel's command has them; set up the trust root first)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
if [ -n "${MOSHIRO_GENESIS:-}" ]; then
|
if [ -n "${MOSHIRO_GENESIS:-}" ]; then
|
||||||
# 命令里带着信任根指纹和属主(公开值):先验证面板的操作员集合,并把它们固定在本地
|
# 命令里带着信任根指纹和属主(公开值):先验证面板的操作员集合,并把它们固定在本地
|
||||||
/usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" \
|
/usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" \
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA==
|
|||||||
|
|
||||||
$container = "moshiro-$role"
|
$container = "moshiro-$role"
|
||||||
$volume = "moshiro-$role-config"
|
$volume = "moshiro-$role-config"
|
||||||
docker inspect $container *> $null
|
docker container inspect $container *> $null
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
docker start $container | Out-Null
|
docker start $container | Out-Null
|
||||||
Write-Host "$container is already installed and running."
|
Write-Host "$container is already installed and running."
|
||||||
@@ -113,6 +113,11 @@ YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA==
|
|||||||
# Docker Desktop 没有宿主网络:Agent 把端口发布出来(面板里的地址需要指向这台机器的公网地址)
|
# Docker Desktop 没有宿主网络:Agent 把端口发布出来(面板里的地址需要指向这台机器的公网地址)
|
||||||
if ($role -eq 'agent') { $arguments += @('-p', '7443:7443/tcp', '-p', '7443:7443/udp', '-p', '7444:7444/tcp') }
|
if ($role -eq 'agent') { $arguments += @('-p', '7443:7443/tcp', '-p', '7443:7443/udp', '-p', '7444:7444/tcp') }
|
||||||
$arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name")
|
$arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name")
|
||||||
|
# 每个节点都必须有属主(passkey):命令里的信任根指纹和属主缺一个就不装(面板的命令总是带着;只有没有信任根的老面板才需要 MOSHIRO_ALLOW_NO_OWNER=1)
|
||||||
|
if ((-not $env:MOSHIRO_GENESIS -or -not $env:MOSHIRO_CONTROLLERS) -and $env:MOSHIRO_ALLOW_NO_OWNER -ne '1') {
|
||||||
|
Write-Error 'this node has no owner: MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS are required (copy the complete command from the panel; set up the trust root first)'
|
||||||
|
return
|
||||||
|
}
|
||||||
# 命令里带着信任根指纹和属主(公开值):节点在注册时验证面板的操作员集合并把它们固定在本地
|
# 命令里带着信任根指纹和属主(公开值):节点在注册时验证面板的操作员集合并把它们固定在本地
|
||||||
if ($env:MOSHIRO_GENESIS) {
|
if ($env:MOSHIRO_GENESIS) {
|
||||||
if (($env:MOSHIRO_GENESIS + $env:MOSHIRO_CONTROLLERS) -notmatch '^[A-Za-z0-9_,-]+$') { Write-Error 'MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters'; return }
|
if (($env:MOSHIRO_GENESIS + $env:MOSHIRO_CONTROLLERS) -notmatch '^[A-Za-z0-9_,-]+$') { Write-Error 'MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters'; return }
|
||||||
@@ -129,7 +134,7 @@ YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA==
|
|||||||
|
|
||||||
# 等注册完成:第一次启动会用上面的令牌注册,成功之后才算装好
|
# 等注册完成:第一次启动会用上面的令牌注册,成功之后才算装好
|
||||||
for ($i = 0; $i -lt 30; $i++) {
|
for ($i = 0; $i -lt 30; $i++) {
|
||||||
$running = docker inspect -f '{{.State.Running}}' $container 2>$null
|
$running = docker container inspect -f '{{.State.Running}}' $container 2>$null
|
||||||
$logs = (docker logs $container 2>&1) -join "`n"
|
$logs = (docker logs $container 2>&1) -join "`n"
|
||||||
if ($logs -match 'link up') {
|
if ($logs -match 'link up') {
|
||||||
Write-Host "done: $role '$name' is online. It shows up in the panel now."
|
Write-Host "done: $role '$name' is online. It shows up in the panel now."
|
||||||
|
|||||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user