Compare commits
123
Commits
b99b95c738
..
dev
| 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 | ||
|
|
a2f88a344b | ||
|
|
9bb6841dce | ||
|
|
fc2bbdcc6d | ||
|
|
321cacac32 | ||
|
|
36f9816f40 | ||
|
|
310b2b5b19 | ||
|
|
a19e6548d3 | ||
|
|
ff45ad086e | ||
|
|
1379b8c7cb | ||
|
|
359313a57b | ||
|
|
6385c1b9a3 | ||
|
|
ddb6608e32 | ||
|
|
55b80f0a97 | ||
|
|
f01bb8fd63 | ||
|
|
af2962fc58 | ||
|
|
5528753d45 | ||
|
|
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
@@ -3,3 +3,7 @@ ci/index-backend-image.sh text eol=lf
|
|||||||
ci/publish-release-offer.sh text eol=lf
|
ci/publish-release-offer.sh text eol=lf
|
||||||
ci/release-linux.sh text eol=lf
|
ci/release-linux.sh text eol=lf
|
||||||
packaging/update/*.sh text eol=lf
|
packaging/update/*.sh text eol=lf
|
||||||
|
packaging/container/*.sh text eol=lf
|
||||||
|
ci/release-node-images.sh text eol=lf
|
||||||
|
packaging/install/*.sh text eol=lf
|
||||||
|
packaging/install/*.ps1 text eol=lf
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
|
|||||||
## 信任关系
|
## 信任关系
|
||||||
|
|
||||||
```text
|
```text
|
||||||
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend
|
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend
|
||||||
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
|
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
|
||||||
|
|
||||||
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
|
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
|
||||||
@@ -97,7 +97,8 @@ Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent
|
|||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
|
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
|
||||||
| [`docs/security.md`](docs/security.md) | 零信任设计稿(尚未实现):威胁模型、名册和操作员凭证、监听隐身、extension 授权 |
|
| [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 |
|
||||||
|
| [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 |
|
||||||
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
|
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
|
||||||
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
|
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
|
||||||
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
|
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
|
||||||
|
|||||||
@@ -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.1.1</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)。
|
||||||
@@ -18,6 +18,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
private readonly AgentConfig _config;
|
private readonly AgentConfig _config;
|
||||||
private readonly bool _ipv6Supported;
|
private readonly bool _ipv6Supported;
|
||||||
private readonly KnockGate _knock;
|
private readonly KnockGate _knock;
|
||||||
|
private readonly TrustAnchor _trust;
|
||||||
private readonly RestartController _restart;
|
private readonly RestartController _restart;
|
||||||
private readonly RestartController? _ownedRestart;
|
private readonly RestartController? _ownedRestart;
|
||||||
private readonly LoadedNodeIdentity _identity;
|
private readonly LoadedNodeIdentity _identity;
|
||||||
@@ -46,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;
|
||||||
@@ -63,6 +65,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
_admission = new AgentAdmission(_streams);
|
_admission = new AgentAdmission(_streams);
|
||||||
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
||||||
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
|
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
|
||||||
|
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
|
||||||
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
||||||
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
|
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
|
||||||
|
|
||||||
@@ -81,7 +84,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
};
|
};
|
||||||
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
||||||
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
|
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
|
||||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts);
|
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
|
||||||
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
|
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
|
||||||
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
|
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
|
||||||
|
|
||||||
@@ -123,6 +126,9 @@ public sealed class AgentApp : IDisposable
|
|||||||
|
|
||||||
public int ControlPort => _peers.Port;
|
public int ControlPort => _peers.Port;
|
||||||
|
|
||||||
|
/// <summary>节点当前采用的操作员集合版本(没有固定信任根时是 0)。</summary>
|
||||||
|
public int TrustVersion => _trust.Version;
|
||||||
|
|
||||||
public AgentCounters Counters => _counters;
|
public AgentCounters Counters => _counters;
|
||||||
|
|
||||||
public FleetLink Link => _link;
|
public FleetLink Link => _link;
|
||||||
@@ -270,6 +276,8 @@ 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,
|
||||||
|
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)],
|
||||||
@@ -296,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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Fleet.Hosting;
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
using Fleet.Rpc;
|
using Fleet.Rpc;
|
||||||
using MoShiro.Measurement;
|
using MoShiro.Measurement;
|
||||||
@@ -25,14 +26,16 @@ public sealed class TaskManager
|
|||||||
private readonly TargetRunners _targets;
|
private readonly TargetRunners _targets;
|
||||||
private readonly Func<IReadOnlyList<int>> _udpPorts;
|
private readonly Func<IReadOnlyList<int>> _udpPorts;
|
||||||
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
||||||
|
private readonly TrustAnchor _trust;
|
||||||
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
||||||
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
|
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
|
||||||
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
|
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
|
||||||
private long _version;
|
private long _version;
|
||||||
private BudgetGuard? _guard;
|
private BudgetGuard? _guard;
|
||||||
|
|
||||||
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts)
|
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
|
||||||
{
|
{
|
||||||
|
_trust = trust ?? TrustAnchor.None;
|
||||||
_guardFactory = guardFactory;
|
_guardFactory = guardFactory;
|
||||||
_streams = streams;
|
_streams = streams;
|
||||||
_env = env;
|
_env = env;
|
||||||
@@ -98,6 +101,8 @@ public sealed class TaskManager
|
|||||||
_ = ct;
|
_ = ct;
|
||||||
var result = new StateSyncResult { AppliedVersion = version };
|
var result = new StateSyncResult { AppliedVersion = version };
|
||||||
|
|
||||||
|
// 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
|
||||||
|
_trust.Update(set.TrustChain);
|
||||||
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||||
foreach (PeerSpec peer in set.Peers)
|
foreach (PeerSpec peer in set.Peers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Fleet.Core;
|
||||||
|
|
||||||
|
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
|
||||||
|
public sealed class SignedStatementDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("statement")]
|
||||||
|
public string Statement { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("credential_id")]
|
||||||
|
public string CredentialId { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("authenticator_data")]
|
||||||
|
public string AuthenticatorData { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("client_data_json")]
|
||||||
|
public string ClientDataJson { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("signature")]
|
||||||
|
public string Signature { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。</summary>
|
||||||
|
public sealed class TrustBundle
|
||||||
|
{
|
||||||
|
[JsonPropertyName("chain")]
|
||||||
|
public List<SignedStatementDto> Chain { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -2,13 +2,17 @@ using System.Security.Cryptography;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
using Fleet.Hosting;
|
using Fleet.Hosting;
|
||||||
using Fleet.Identity;
|
using Fleet.Identity;
|
||||||
|
|
||||||
namespace Fleet.Enrollment.Client;
|
namespace Fleet.Enrollment.Client;
|
||||||
|
|
||||||
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory);
|
[JsonSerializable(typeof(TrustBundle))]
|
||||||
|
internal sealed partial class TrustBundleContext : JsonSerializerContext;
|
||||||
|
|
||||||
|
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
|
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
|
||||||
@@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident
|
|||||||
public static class EnrollmentClient
|
public static class EnrollmentClient
|
||||||
{
|
{
|
||||||
public static async Task<Result<EnrollmentResult>> EnrollAsync(
|
public static async Task<Result<EnrollmentResult>> EnrollAsync(
|
||||||
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct)
|
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct,
|
||||||
|
string? genesis = null, IReadOnlyList<string>? controllers = null)
|
||||||
{
|
{
|
||||||
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
|
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
|
||||||
if (File.Exists(nodeFile))
|
if (File.Exists(nodeFile))
|
||||||
@@ -36,6 +41,24 @@ public static class EnrollmentClient
|
|||||||
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
|
||||||
|
// 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
|
||||||
|
TrustBundle? bundle = 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);
|
||||||
|
if (!trusted.TryGet(out bundle, out FleetError? trustError))
|
||||||
|
{
|
||||||
|
return Result<EnrollmentResult>.Fail(trustError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
|
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
|
||||||
string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)));
|
string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)));
|
||||||
var body = new JsonObject
|
var body = new JsonObject
|
||||||
@@ -76,10 +99,46 @@ public static class EnrollmentClient
|
|||||||
var store = new ConfigStore(identityDirectory);
|
var store = new ConfigStore(identityDirectory);
|
||||||
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
|
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
|
||||||
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
|
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
|
||||||
|
if (bundle is not null)
|
||||||
|
{
|
||||||
|
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
// node.json 最后写:它存在就表示注册完整完成
|
// node.json 最后写:它存在就表示注册完整完成
|
||||||
store.Write(
|
store.Write(
|
||||||
LoadedNodeIdentity.NodeFileName,
|
LoadedNodeIdentity.NodeFileName,
|
||||||
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
|
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
|
||||||
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory));
|
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory, bundle is not null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Result<TrustBundle>> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList<string> controllers, CancellationToken ct)
|
||||||
|
{
|
||||||
|
static Result<TrustBundle> Refuse(string reason) => Result<TrustBundle>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
|
||||||
|
|
||||||
|
using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json");
|
||||||
|
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain");
|
||||||
|
}
|
||||||
|
|
||||||
|
TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle);
|
||||||
|
if (bundle is null || bundle.Chain.Count == 0)
|
||||||
|
{
|
||||||
|
return Refuse("the panel has no operator trust root set up");
|
||||||
|
}
|
||||||
|
|
||||||
|
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
|
||||||
|
{
|
||||||
|
return Refuse("an owner passkey in the install command is not in the operator set");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<TrustBundle>.Ok(bundle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Fleet.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
|
|
||||||
|
namespace Fleet.Hosting;
|
||||||
|
|
||||||
|
/// <summary><c>trust.json</c>:安装时固定的创世指纹和属主,加见过的最新操作员集合链。</summary>
|
||||||
|
public sealed class TrustFile
|
||||||
|
{
|
||||||
|
[JsonPropertyName("genesis")]
|
||||||
|
public string Genesis { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("chain")]
|
||||||
|
public List<SignedStatementDto> Chain { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。</summary>
|
||||||
|
[JsonPropertyName("controllers")]
|
||||||
|
public List<string> Controllers { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSerializable(typeof(TrustFile))]
|
||||||
|
public sealed partial class TrustFileContext : JsonSerializerContext;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
|
||||||
|
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
|
||||||
|
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TrustAnchor : IServerVerifier
|
||||||
|
{
|
||||||
|
public const string FileName = "trust.json";
|
||||||
|
|
||||||
|
private readonly ConfigStore? _store;
|
||||||
|
private readonly INodeLog _log;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly TrustFile? _file;
|
||||||
|
private OperatorSet? _current;
|
||||||
|
|
||||||
|
private TrustAnchor(ConfigStore? store, TrustFile? file, OperatorSet? current, INodeLog log)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_file = file;
|
||||||
|
_current = current;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>没有固定的创世(旧节点,或没有指纹的安装)。</summary>
|
||||||
|
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
|
||||||
|
|
||||||
|
public bool Pinned => _file is not null;
|
||||||
|
|
||||||
|
public string? Genesis => _file?.Genesis;
|
||||||
|
|
||||||
|
/// <summary>当前生效的操作员集合版本;没有固定时为 0。</summary>
|
||||||
|
public int Version
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _current?.Version ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。</summary>
|
||||||
|
public bool Required
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _current?.Servers is { Count: > 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。</summary>
|
||||||
|
public bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
|
||||||
|
{
|
||||||
|
IReadOnlyList<byte[]> servers;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
servers = _current?.Servers ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (byte[] server in servers)
|
||||||
|
{
|
||||||
|
if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。</summary>
|
||||||
|
public IReadOnlyList<string> Controllers
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TrustAnchor Load(string identityDirectory, INodeLog log)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(identityDirectory, FileName);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return new TrustAnchor(new ConfigStore(identityDirectory), null, null, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrustFile file = NodeConfig.Load(path, TrustFileContext.Default.TrustFile);
|
||||||
|
OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
// 固定的创世和存下来的链对不上:不能当成「没有固定」放过,直接让节点起不来比悄悄失去保护好
|
||||||
|
throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>注册时写下:固定的创世、验证过的链、属主。</summary>
|
||||||
|
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, IReadOnlyList<string> controllers) =>
|
||||||
|
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile));
|
||||||
|
|
||||||
|
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。</summary>
|
||||||
|
public int Update(IReadOnlyList<SignedStatementDto>? chain)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_file is null || chain is not { Count: > 0 })
|
||||||
|
{
|
||||||
|
return _current?.Version ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperatorSet? candidate = OperatorTrust.VerifyChain(_file.Genesis, [.. chain.Select(c => c.ToSigned())]);
|
||||||
|
if (candidate is null)
|
||||||
|
{
|
||||||
|
_log.Warn("the operator set from the panel does not verify against the pinned genesis; ignoring it");
|
||||||
|
return _current!.Version;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.Version > _current!.Version)
|
||||||
|
{
|
||||||
|
_file.Chain = [.. chain];
|
||||||
|
_current = candidate;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_store?.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(_file, TrustFileContext.Default.TrustFile));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
_log.Warn("could not save trust.json", ("err", ex.Message));
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.Info("operator set updated", ("version", candidate.Version));
|
||||||
|
}
|
||||||
|
|
||||||
|
return _current.Version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ namespace Fleet.Identity;
|
|||||||
public static class SignaturePurpose
|
public static class SignaturePurpose
|
||||||
{
|
{
|
||||||
public const string Auth = "auth";
|
public const string Auth = "auth";
|
||||||
|
|
||||||
|
/// <summary>安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。</summary>
|
||||||
|
public const string AuthSecure = "auth2";
|
||||||
public const string Peer = "peer";
|
public const string Peer = "peer";
|
||||||
public const string Enroll = "enroll";
|
public const string Enroll = "enroll";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Buffers.Text;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Fleet.Identity;
|
||||||
|
|
||||||
|
/// <summary>passkey(WebAuthn)对一段文本签出的断言:<c>challenge = SHA-256(Statement 的 UTF-8)</c>。四个字段都是 base64url。</summary>
|
||||||
|
public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
||||||
|
|
||||||
|
/// <summary>一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。</summary>
|
||||||
|
public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
|
||||||
|
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Servers">服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
|
||||||
|
/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。</param>
|
||||||
|
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
|
||||||
|
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
|
||||||
|
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
|
||||||
|
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
|
||||||
|
/// </summary>
|
||||||
|
public static class OperatorTrust
|
||||||
|
{
|
||||||
|
public const string SetHeader = "moshiro-opset-v1";
|
||||||
|
public const string Genesis = "0";
|
||||||
|
|
||||||
|
public static string Encode(OperatorSet set)
|
||||||
|
{
|
||||||
|
var text = new StringBuilder();
|
||||||
|
text.Append(SetHeader).Append('\n')
|
||||||
|
.Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n')
|
||||||
|
.Append("prev=").Append(set.Previous).Append('\n')
|
||||||
|
.Append("rp=").Append(set.RpId).Append('\n')
|
||||||
|
.Append("origins=").Append(string.Join(',', set.Origins));
|
||||||
|
foreach (OperatorKey key in set.Keys)
|
||||||
|
{
|
||||||
|
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (byte[] server in set.Servers ?? [])
|
||||||
|
{
|
||||||
|
text.Append("\nserver=").Append(Convert.ToBase64String(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>严格解析:多一行、少一行、顺序不对都算坏。</summary>
|
||||||
|
public static OperatorSet? ParseSet(string text)
|
||||||
|
{
|
||||||
|
string[] lines = text.Split('\n');
|
||||||
|
if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1
|
||||||
|
|| !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = new List<OperatorKey>();
|
||||||
|
var servers = new List<byte[]>();
|
||||||
|
foreach (string line in lines.Skip(5))
|
||||||
|
{
|
||||||
|
if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki))
|
||||||
|
{
|
||||||
|
keys.Add(new OperatorKey(value[..split], spki));
|
||||||
|
}
|
||||||
|
else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey))
|
||||||
|
{
|
||||||
|
servers.Add(serverKey);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
|
||||||
|
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证操作员集合的链:第一项是创世版本(指纹等于 <paramref name="pinnedGenesis"/>,自签),其后每一项由上一版里的凭证签名、版本加一、<c>prev</c> 指向上一版的指纹、域名不变。
|
||||||
|
/// 返回最后一版;不对返回 null。
|
||||||
|
/// </summary>
|
||||||
|
public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList<SignedStatement> chain)
|
||||||
|
{
|
||||||
|
if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperatorSet? current = null;
|
||||||
|
string? previousFingerprint = null;
|
||||||
|
foreach (SignedStatement link in chain)
|
||||||
|
{
|
||||||
|
OperatorSet? next = ParseSet(link.Statement);
|
||||||
|
if (next is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
if (next.Version != 1 || !VerifyOperator(link, next))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = next;
|
||||||
|
previousFingerprint = Fingerprint(link.Statement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>断言是不是这个集合里某把凭证对 <c>Statement</c> 签的(要求用户验证;挑战是语句的 SHA-256)。</summary>
|
||||||
|
public static bool VerifyOperator(SignedStatement signed, OperatorSet set)
|
||||||
|
{
|
||||||
|
OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId);
|
||||||
|
if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryValue(string line, string key, out string value)
|
||||||
|
{
|
||||||
|
value = "";
|
||||||
|
if (!line.StartsWith(key + "=", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = line[(key.Length + 1)..];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryBase64(string text, out byte[] data)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
data = Convert.FromBase64String(text);
|
||||||
|
return data.Length > 0;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
data = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDecode(string text, out byte[] data)
|
||||||
|
{
|
||||||
|
data = [];
|
||||||
|
return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Fleet.Identity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:<c>Required</c> 为真(当前操作员集合里登记了服务器密钥)
|
||||||
|
/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。
|
||||||
|
/// </summary>
|
||||||
|
public interface IServerVerifier
|
||||||
|
{
|
||||||
|
bool Required { get; }
|
||||||
|
|
||||||
|
/// <summary>服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。</summary>
|
||||||
|
bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。</summary>
|
||||||
|
public static class ServerHello
|
||||||
|
{
|
||||||
|
/// <summary>被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。</summary>
|
||||||
|
public static byte[] Payload(string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
|
||||||
|
Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki));
|
||||||
|
|
||||||
|
public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
|
||||||
|
serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||||
|
|
||||||
|
public static bool Verify(ReadOnlySpan<byte> serverKeySpki, string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using ECDsa key = ECDsa.Create();
|
||||||
|
key.ImportSubjectPublicKeyInfo(serverKeySpki, out _);
|
||||||
|
return key.VerifyData(Payload(nonce, serverEphemeralSpki), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。
|
||||||
|
/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。
|
||||||
|
/// 帧格式:<c>计数器(8,大端) ‖ 密文 ‖ 标签(16)</c>;明文是 <c>帧类型(1) ‖ 内容</c>;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LinkCipher : IDisposable
|
||||||
|
{
|
||||||
|
public const int Overhead = 8 + 16;
|
||||||
|
|
||||||
|
private const byte NodeToServer = 0;
|
||||||
|
private const byte ServerToNode = 1;
|
||||||
|
|
||||||
|
private readonly AesGcm _send;
|
||||||
|
private readonly AesGcm _receive;
|
||||||
|
private readonly byte _sendDirection;
|
||||||
|
private readonly byte _receiveDirection;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private long _sendCounter;
|
||||||
|
private long _receiveCounter;
|
||||||
|
|
||||||
|
private LinkCipher(byte[] sendKey, byte[] receiveKey, byte sendDirection, byte receiveDirection)
|
||||||
|
{
|
||||||
|
_send = new AesGcm(sendKey, 16);
|
||||||
|
_receive = new AesGcm(receiveKey, 16);
|
||||||
|
_sendDirection = sendDirection;
|
||||||
|
_receiveDirection = receiveDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LinkCipher ForNode(EphemeralKey own, ReadOnlySpan<byte> serverEphemeralSpki, string nonce)
|
||||||
|
{
|
||||||
|
(byte[] n2s, byte[] s2n) = Derive(own, serverEphemeralSpki, own.PublicKeySpki, serverEphemeralSpki, nonce);
|
||||||
|
return new LinkCipher(n2s, s2n, NodeToServer, ServerToNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LinkCipher ForServer(EphemeralKey own, ReadOnlySpan<byte> nodeEphemeralSpki, string nonce)
|
||||||
|
{
|
||||||
|
(byte[] n2s, byte[] s2n) = Derive(own, nodeEphemeralSpki, nodeEphemeralSpki, own.PublicKeySpki, nonce);
|
||||||
|
return new LinkCipher(s2n, n2s, ServerToNode, NodeToServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (byte[] NodeToServerKey, byte[] ServerToNodeKey) Derive(EphemeralKey own, ReadOnlySpan<byte> remote, ReadOnlySpan<byte> nodeEphemeral, ReadOnlySpan<byte> serverEphemeral, string nonce)
|
||||||
|
{
|
||||||
|
byte[] secret = own.AgreeRaw(remote);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|link|"), .. SHA256.HashData(serverEphemeral), .. SHA256.HashData(nodeEphemeral)];
|
||||||
|
byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 64, Encoding.UTF8.GetBytes(nonce), info);
|
||||||
|
return (material[..32], material[32..]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>加密一帧。</summary>
|
||||||
|
public byte[] Seal(byte kind, ReadOnlySpan<byte> data)
|
||||||
|
{
|
||||||
|
long counter;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
counter = _sendCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] output = new byte[Overhead + 1 + data.Length];
|
||||||
|
BinaryPrimitives.WriteInt64BigEndian(output, counter);
|
||||||
|
byte[] plaintext = new byte[1 + data.Length];
|
||||||
|
plaintext[0] = kind;
|
||||||
|
data.CopyTo(plaintext.AsSpan(1));
|
||||||
|
_send.Encrypt(Nonce(counter), plaintext, output.AsSpan(8, plaintext.Length), output.AsSpan(8 + plaintext.Length, 16), Aad(_sendDirection, counter));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。</summary>
|
||||||
|
public bool TryOpen(ReadOnlySpan<byte> frame, out byte kind, out byte[] data)
|
||||||
|
{
|
||||||
|
kind = 0;
|
||||||
|
data = [];
|
||||||
|
if (frame.Length < Overhead + 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long counter = BinaryPrimitives.ReadInt64BigEndian(frame);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (counter != _receiveCounter)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int length = frame.Length - Overhead;
|
||||||
|
byte[] plaintext = new byte[length];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_receive.Decrypt(Nonce(counter), frame.Slice(8, length), frame.Slice(8 + length, 16), plaintext, Aad(_receiveDirection, counter));
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_receiveCounter++;
|
||||||
|
kind = plaintext[0];
|
||||||
|
data = plaintext[1..];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Nonce(long counter)
|
||||||
|
{
|
||||||
|
byte[] nonce = new byte[12];
|
||||||
|
BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(4), counter);
|
||||||
|
return nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Aad(byte direction, long counter)
|
||||||
|
{
|
||||||
|
byte[] aad = new byte[9];
|
||||||
|
aad[0] = direction;
|
||||||
|
BinaryPrimitives.WriteInt64BigEndian(aad.AsSpan(1), counter);
|
||||||
|
return aad;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_send.Dispose();
|
||||||
|
_receive.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,14 @@ public sealed class EphemeralKey : IDisposable
|
|||||||
|
|
||||||
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
|
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
|
||||||
|
|
||||||
|
/// <summary>与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。</summary>
|
||||||
|
public byte[] AgreeRaw(ReadOnlySpan<byte> remotePublicKeySpki)
|
||||||
|
{
|
||||||
|
using ECDiffieHellman remote = ECDiffieHellman.Create();
|
||||||
|
remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
|
||||||
|
return _key.DeriveRawSecretAgreement(remote.PublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
|
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
|
||||||
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
|
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Fleet.Core;
|
||||||
|
|
||||||
|
namespace Fleet.Identity;
|
||||||
|
|
||||||
|
public static class TrustMapping
|
||||||
|
{
|
||||||
|
public static SignedStatement ToSigned(this SignedStatementDto dto) => new(dto.Statement, dto.CredentialId, dto.AuthenticatorData, dto.ClientDataJson, dto.Signature);
|
||||||
|
|
||||||
|
public static SignedStatementDto ToDto(this SignedStatement signed) =>
|
||||||
|
new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature };
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Buffers.Text;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Fleet.Identity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
|
||||||
|
/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
|
||||||
|
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
|
||||||
|
/// </summary>
|
||||||
|
public static class WebAuthnAssertion
|
||||||
|
{
|
||||||
|
public const byte FlagUserPresent = 0x01;
|
||||||
|
public const byte FlagUserVerified = 0x04;
|
||||||
|
|
||||||
|
/// <param name="publicKeySpki">注册时保存的凭证公钥(SPKI)。</param>
|
||||||
|
/// <param name="expectedChallenge">这次请求里服务端或签名人规定的挑战原文(不是 base64url)。</param>
|
||||||
|
/// <param name="originAllowed">校验 <c>clientDataJSON.origin</c>。</param>
|
||||||
|
/// <param name="signCount">authenticatorData 里的签名计数器。</param>
|
||||||
|
public static bool Verify(
|
||||||
|
ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> authenticatorData, ReadOnlySpan<byte> clientDataJson, ReadOnlySpan<byte> signature,
|
||||||
|
string rpId, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed, bool requireUserVerification, out uint signCount)
|
||||||
|
{
|
||||||
|
signCount = 0;
|
||||||
|
if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId))))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte flags = authenticatorData[32];
|
||||||
|
if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using ECDsa key = ECDsa.Create();
|
||||||
|
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
|
||||||
|
byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)];
|
||||||
|
if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>clientDataJSON</c> 的 type、challenge、origin 都要对得上。注册和登录共用。</summary>
|
||||||
|
public static bool ClientDataValid(ReadOnlySpan<byte> clientDataJson, string expectedType, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray());
|
||||||
|
JsonElement root = document.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object
|
||||||
|
|| root.GetProperty("type").GetString() != expectedType
|
||||||
|
|| root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin)
|
||||||
|
|| root.GetProperty("challenge").GetString() is not { } challenge)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] decoded = Base64Url.DecodeFromChars(challenge);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,15 @@ public sealed class FleetLinkOptions
|
|||||||
public long MaxInflightBytes { get; init; } = 4 << 20;
|
public long MaxInflightBytes { get; init; } = 4 << 20;
|
||||||
|
|
||||||
public Backoff Backoff { get; init; } = new();
|
public Backoff Backoff { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 校验服务器身份:<see cref="IServerVerifier.Required"/> 为真(节点固定的操作员集合里登记了服务器密钥)时,
|
||||||
|
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
|
||||||
|
/// </summary>
|
||||||
|
public IServerVerifier? ServerVerifier { get; init; }
|
||||||
|
|
||||||
|
/// <summary>握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。</summary>
|
||||||
|
public Func<BinaryAttestation?>? Attestation { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -77,6 +86,7 @@ public sealed class FleetLink
|
|||||||
private const string RpcPath = "/api/agent/v1/rpc";
|
private const string RpcPath = "/api/agent/v1/rpc";
|
||||||
|
|
||||||
private readonly FleetLinkOptions _options;
|
private readonly FleetLinkOptions _options;
|
||||||
|
private volatile bool _secure;
|
||||||
private readonly RpcRouter<LinkContext> _router;
|
private readonly RpcRouter<LinkContext> _router;
|
||||||
private readonly Dictionary<string, IStateHandler> _handlers;
|
private readonly Dictionary<string, IStateHandler> _handlers;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
@@ -153,6 +163,9 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
|
||||||
|
public bool IsSecure => _secure && IsConnected;
|
||||||
|
|
||||||
private async Task<string> RunOnceAsync(CancellationToken ct)
|
private async Task<string> RunOnceAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var socket = new ClientWebSocket();
|
using var socket = new ClientWebSocket();
|
||||||
@@ -160,7 +173,7 @@ public sealed class FleetLink
|
|||||||
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
|
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
|
||||||
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
|
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
|
||||||
await socket.ConnectAsync(builder.Uri, ct);
|
await socket.ConnectAsync(builder.Uri, ct);
|
||||||
await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
|
await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes));
|
||||||
|
|
||||||
string? failure = await AuthenticateAsync(transport, ct);
|
string? failure = await AuthenticateAsync(transport, ct);
|
||||||
if (failure is not null)
|
if (failure is not null)
|
||||||
@@ -168,6 +181,8 @@ public sealed class FleetLink
|
|||||||
return failure;
|
return failure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_secure = transport.Secure;
|
||||||
|
|
||||||
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
||||||
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
Task run = session.RunAsync(sessionCts.Token);
|
Task run = session.RunAsync(sessionCts.Token);
|
||||||
@@ -211,7 +226,17 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
||||||
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
|
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)
|
||||||
{
|
{
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
||||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||||
@@ -222,9 +247,46 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
|
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
|
||||||
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
|
|
||||||
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
|
// 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西
|
||||||
|
EphemeralKey? ephemeral = null;
|
||||||
|
AuthParams authParams;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_options.ServerVerifier is { Required: true } verifier)
|
||||||
|
{
|
||||||
|
if (!TryDecodeBase64(nonce.ServerEphemeral, out byte[] serverEphemeral) || !TryDecodeBase64(nonce.ServerSignature, out byte[] serverSignature)
|
||||||
|
|| !verifier.Verify(nonce.Nonce, serverEphemeral, serverSignature))
|
||||||
|
{
|
||||||
|
return "the server could not prove its identity (it is not the server your operator set authorizes)";
|
||||||
|
}
|
||||||
|
|
||||||
|
ephemeral = new EphemeralKey();
|
||||||
|
string serverEphemeralText = Convert.ToBase64String(serverEphemeral);
|
||||||
|
string nodeEphemeralText = Convert.ToBase64String(ephemeral.PublicKeySpki);
|
||||||
|
authParams = new AuthParams
|
||||||
|
{
|
||||||
|
NodeId = _options.NodeId.Value,
|
||||||
|
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
|
||||||
|
NodeEphemeral = nodeEphemeralText,
|
||||||
|
};
|
||||||
|
Attest(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);
|
||||||
|
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
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);
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ephemeral?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
@@ -259,7 +321,26 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
|
private static bool TryDecodeBase64(string? text, out byte[] data)
|
||||||
|
{
|
||||||
|
data = [];
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
data = Convert.FromBase64String(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<RpcMessage?> ReceiveMessageAsync(SecureTransport transport, CancellationToken ct)
|
||||||
{
|
{
|
||||||
RpcFrame? frame = await transport.ReceiveAsync(ct);
|
RpcFrame? frame = await transport.ReceiveAsync(ct);
|
||||||
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Fleet.Identity;
|
||||||
|
using Fleet.Rpc;
|
||||||
|
|
||||||
|
namespace Fleet.Link;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 认证阶段是明文,认证通过之后换成应用层加密(<see cref="LinkCipher"/>):此后每帧都是加密的二进制帧,明文帧一律拒绝。
|
||||||
|
/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SecureTransport : IRpcTransport
|
||||||
|
{
|
||||||
|
private readonly IRpcTransport _inner;
|
||||||
|
private LinkCipher? _cipher;
|
||||||
|
private bool _allowPlaintext = true;
|
||||||
|
|
||||||
|
public SecureTransport(IRpcTransport inner)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Secure => _cipher is not null;
|
||||||
|
|
||||||
|
public void Enable(LinkCipher cipher) => _cipher = cipher;
|
||||||
|
|
||||||
|
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct) =>
|
||||||
|
_cipher is null ? _inner.SendAsync(frame, ct) : _inner.SendAsync(new RpcFrame(FrameKind.Binary, _cipher.Seal((byte)frame.Kind, frame.Data.Span)), ct);
|
||||||
|
|
||||||
|
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
RpcFrame? frame = await _inner.ReceiveAsync(ct);
|
||||||
|
if (frame is not { } received || _cipher is null)
|
||||||
|
{
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (received.Kind == FrameKind.Binary && _cipher.TryOpen(received.Data.Span, out byte kind, out byte[] data))
|
||||||
|
{
|
||||||
|
_allowPlaintext = false;
|
||||||
|
return new RpcFrame((FrameKind)kind, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_allowPlaintext && received.Kind == FrameKind.Text)
|
||||||
|
{
|
||||||
|
return received; // 认证被拒绝时服务器还没有协商好密钥,回复是明文
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IOException("a frame on the secure link failed to authenticate");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask CloseAsync(string reason, CancellationToken ct) => _inner.CloseAsync(reason, ct);
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_cipher?.Dispose();
|
||||||
|
await _inner.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,14 @@ public sealed class ChallengeParams
|
|||||||
|
|
||||||
[JsonPropertyName("expires_at_ms")]
|
[JsonPropertyName("expires_at_ms")]
|
||||||
public long ExpiresAtMs { get; set; }
|
public long ExpiresAtMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。</summary>
|
||||||
|
[JsonPropertyName("server_eph")]
|
||||||
|
public string? ServerEphemeral { get; set; }
|
||||||
|
|
||||||
|
/// <summary>服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。</summary>
|
||||||
|
[JsonPropertyName("server_sig")]
|
||||||
|
public string? ServerSignature { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AuthParams
|
public sealed class AuthParams
|
||||||
@@ -63,6 +71,21 @@ public sealed class AuthParams
|
|||||||
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
|
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
|
||||||
[JsonPropertyName("signature")]
|
[JsonPropertyName("signature")]
|
||||||
public string Signature { get; set; } = "";
|
public string Signature { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
|
||||||
|
[JsonPropertyName("node_eph")]
|
||||||
|
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
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ public sealed record NewNode(
|
|||||||
string? NodeVersion,
|
string? NodeVersion,
|
||||||
long NowMs,
|
long NowMs,
|
||||||
string Kind = "",
|
string Kind = "",
|
||||||
byte[]? TlsPublicKey = null);
|
byte[]? TlsPublicKey = null,
|
||||||
|
string? AddedBy = null);
|
||||||
|
|
||||||
public sealed record JoinTokenRecord(
|
public sealed record JoinTokenRecord(
|
||||||
string Id,
|
string Id,
|
||||||
@@ -46,6 +47,7 @@ public sealed record JoinTokenRecord(
|
|||||||
long ExpiresAtMs,
|
long ExpiresAtMs,
|
||||||
long? RevokedAtMs,
|
long? RevokedAtMs,
|
||||||
long CreatedAtMs,
|
long CreatedAtMs,
|
||||||
string Kind = "");
|
string Kind = "",
|
||||||
|
string? CreatedBy = null);
|
||||||
|
|
||||||
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
|
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
|
||||||
@@ -6,6 +6,39 @@ namespace Fleet.Server;
|
|||||||
|
|
||||||
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
|
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
|
||||||
|
/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
|
||||||
|
/// </summary>
|
||||||
|
public interface IServerIdentity
|
||||||
|
{
|
||||||
|
byte[] PublicKeySpki { get; }
|
||||||
|
|
||||||
|
/// <summary>对一段内容签名(<c>Fleet.Identity.ServerHello.Payload</c> 给出的)。</summary>
|
||||||
|
byte[] Sign(ReadOnlySpan<byte> payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
|
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
|
||||||
public interface IEnrollmentHook
|
public interface IEnrollmentHook
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ public sealed class EnrollRequest
|
|||||||
public JsonElement? Meta { get; set; }
|
public JsonElement? Meta { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class TrustRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("join_token")]
|
||||||
|
public string JoinToken { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class EnrollResponse
|
public sealed class EnrollResponse
|
||||||
{
|
{
|
||||||
[JsonPropertyName("node_id")]
|
[JsonPropertyName("node_id")]
|
||||||
@@ -69,5 +75,7 @@ public sealed class FleetProblem
|
|||||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
[JsonSerializable(typeof(EnrollRequest))]
|
[JsonSerializable(typeof(EnrollRequest))]
|
||||||
[JsonSerializable(typeof(EnrollResponse))]
|
[JsonSerializable(typeof(EnrollResponse))]
|
||||||
|
[JsonSerializable(typeof(TrustRequest))]
|
||||||
|
[JsonSerializable(typeof(Fleet.Core.TrustBundle))]
|
||||||
[JsonSerializable(typeof(FleetProblem))]
|
[JsonSerializable(typeof(FleetProblem))]
|
||||||
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
|
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
|
||||||
@@ -20,9 +20,42 @@ public static class FleetEnrollmentEndpoints
|
|||||||
.WithTags("agent")
|
.WithTags("agent")
|
||||||
.WithSummary("Enroll a node with a join token")
|
.WithSummary("Enroll a node with a join token")
|
||||||
.DisableAntiforgery();
|
.DisableAntiforgery();
|
||||||
|
// 注册之前取信任链:节点先确认这个面板的操作员集合和委托确实是它固定的创世签出来的,再把自己的密钥交出去
|
||||||
|
app.MapPost(path + "/trust", HandleTrustAsync)
|
||||||
|
.WithName("fleet_enroll_trust")
|
||||||
|
.WithTags("agent")
|
||||||
|
.WithSummary("The operator set chain and the grant of a join token, for the node to verify before it enrolls")
|
||||||
|
.DisableAntiforgery();
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleTrustAsync(HttpContext http, FleetEnrollmentService service)
|
||||||
|
{
|
||||||
|
TrustRequest? request;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
request = await JsonSerializer.DeserializeAsync(http.Request.Body, EnrollmentJsonContext.Default.TrustRequest, http.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
request = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request is null || string.IsNullOrEmpty(request.JoinToken))
|
||||||
|
{
|
||||||
|
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "join_token is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<TrustBundle> result = await service.GetTrustAsync(request.JoinToken, http.RequestAborted);
|
||||||
|
if (!result.TryGet(out TrustBundle? bundle, out FleetError? error))
|
||||||
|
{
|
||||||
|
return Problem(error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone : StatusCodes.Status401Unauthorized, error.Code, error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
|
return Results.Json(bundle, EnrollmentJsonContext.Default.TrustBundle);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
|
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
|
||||||
{
|
{
|
||||||
EnrollRequest? request;
|
EnrollRequest? request;
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService
|
|||||||
{
|
{
|
||||||
private readonly IFleetStore _store;
|
private readonly IFleetStore _store;
|
||||||
private readonly IEnumerable<IEnrollmentHook> _hooks;
|
private readonly IEnumerable<IEnrollmentHook> _hooks;
|
||||||
|
private readonly IEnumerable<IOperatorTrustSource> _trust;
|
||||||
private readonly FleetServerOptions _options;
|
private readonly FleetServerOptions _options;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
|
|
||||||
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, FleetServerOptions options, TimeProvider time)
|
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, IEnumerable<IOperatorTrustSource> trust, FleetServerOptions options, TimeProvider time)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_hooks = hooks;
|
_hooks = hooks;
|
||||||
|
_trust = trust;
|
||||||
_options = options;
|
_options = options;
|
||||||
_time = time;
|
_time = time;
|
||||||
}
|
}
|
||||||
@@ -80,7 +82,8 @@ public sealed partial class FleetEnrollmentService
|
|||||||
request.NodeVersion,
|
request.NodeVersion,
|
||||||
nowMs,
|
nowMs,
|
||||||
token.Kind,
|
token.Kind,
|
||||||
tlsPublicKey);
|
tlsPublicKey,
|
||||||
|
token.CreatedBy);
|
||||||
|
|
||||||
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
||||||
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
|
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
|
||||||
@@ -101,6 +104,30 @@ public sealed partial class FleetEnrollmentService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。</summary>
|
||||||
|
public async ValueTask<Result<TrustBundle>> GetTrustAsync(string joinToken, CancellationToken ct)
|
||||||
|
{
|
||||||
|
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
|
||||||
|
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
|
if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses)
|
||||||
|
{
|
||||||
|
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.ExpiresAtMs <= nowMs)
|
||||||
|
{
|
||||||
|
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
var bundle = new TrustBundle();
|
||||||
|
foreach (IOperatorTrustSource source in _trust)
|
||||||
|
{
|
||||||
|
bundle.Chain.AddRange(await source.ChainAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<TrustBundle>.Ok(bundle);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryDecode(string text, out byte[] data)
|
private static bool TryDecode(string text, out byte[] data)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
|||||||
IEnumerable<IStateProvider> stateProviders,
|
IEnumerable<IStateProvider> stateProviders,
|
||||||
IEnumerable<IInbox> inboxes,
|
IEnumerable<IInbox> inboxes,
|
||||||
IEnumerable<INodeEventSink> sinks,
|
IEnumerable<INodeEventSink> sinks,
|
||||||
FleetRouter router)
|
FleetRouter router,
|
||||||
|
IServerIdentity? serverIdentity = null,
|
||||||
|
IBinaryAttestor? binaryAttestor = null)
|
||||||
{
|
{
|
||||||
|
ServerIdentity = serverIdentity;
|
||||||
|
BinaryAttestor = binaryAttestor;
|
||||||
Store = store;
|
Store = store;
|
||||||
Options = options;
|
Options = options;
|
||||||
Time = time;
|
Time = time;
|
||||||
@@ -38,6 +42,12 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
|||||||
|
|
||||||
internal IFleetStore Store { get; }
|
internal IFleetStore Store { get; }
|
||||||
|
|
||||||
|
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
|
||||||
|
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; }
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ public static class FleetServerExtensions
|
|||||||
provider.GetServices<IStateProvider>(),
|
provider.GetServices<IStateProvider>(),
|
||||||
provider.GetServices<IInbox>(),
|
provider.GetServices<IInbox>(),
|
||||||
provider.GetServices<INodeEventSink>(),
|
provider.GetServices<INodeEventSink>(),
|
||||||
provider.GetRequiredService<FleetRouter>()));
|
provider.GetRequiredService<FleetRouter>(),
|
||||||
|
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;
|
||||||
|
|||||||
@@ -14,9 +14,19 @@ internal sealed partial class NodeConnection
|
|||||||
{
|
{
|
||||||
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
|
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
|
||||||
await SendAsync(
|
|
||||||
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
|
// 有服务器身份密钥:挑战里带上这次连接的临时公钥和服务器的签名,节点据此确认服务器、协商加密链路
|
||||||
ct);
|
using var serverEphemeral = _host.ServerIdentity is null ? null : new EphemeralKey();
|
||||||
|
var challenge = new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs };
|
||||||
|
string? serverEphemeralText = null;
|
||||||
|
if (_host.ServerIdentity is { } identity && serverEphemeral is not null)
|
||||||
|
{
|
||||||
|
serverEphemeralText = Convert.ToBase64String(serverEphemeral.PublicKeySpki);
|
||||||
|
challenge.ServerEphemeral = serverEphemeralText;
|
||||||
|
challenge.ServerSignature = Convert.ToBase64String(identity.Sign(ServerHello.Payload(nonce, serverEphemeral.PublicKeySpki)));
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendAsync(RpcMessage.Notification(FleetMethods.Challenge, challenge, FleetRpcContext.Default.ChallengeParams), ct);
|
||||||
|
|
||||||
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
|
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
|
||||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||||
@@ -48,10 +58,21 @@ internal sealed partial class NodeConnection
|
|||||||
}
|
}
|
||||||
|
|
||||||
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
|
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
|
||||||
|
|
||||||
|
// 安全链路:节点的签名覆盖双方的临时公钥(AuthSecure),验过之后这条连接上的所有帧都是加密的
|
||||||
|
byte[] nodeEphemeral = [];
|
||||||
|
bool secure = auth?.NodeEphemeral is not null && serverEphemeral is not null && serverEphemeralText is not null && TryDecode(auth.NodeEphemeral, out nodeEphemeral);
|
||||||
bool signatureOk = node is not null
|
bool signatureOk = node is not null
|
||||||
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
|
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
|
||||||
&& TryDecode(auth!.Signature, out byte[] signature)
|
&& TryDecode(auth!.Signature, out byte[] signature)
|
||||||
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
|
&& (secure
|
||||||
|
? NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.AuthSecure, auth.NodeId, nonce, serverEphemeralText!, auth.NodeEphemeral!)
|
||||||
|
: auth.NodeEphemeral is null && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce));
|
||||||
|
if (signatureOk && secure)
|
||||||
|
{
|
||||||
|
_cipher = LinkCipher.ForServer(serverEphemeral!, nodeEphemeral, nonce);
|
||||||
|
}
|
||||||
|
|
||||||
if (!signatureOk)
|
if (!signatureOk)
|
||||||
{
|
{
|
||||||
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
|
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
|
||||||
@@ -67,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),
|
||||||
|
|||||||
@@ -75,7 +75,15 @@ internal sealed partial class NodeConnection
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_cipher is { } cipher)
|
||||||
|
{
|
||||||
|
await _socket.SendAsync(cipher.Seal(0, bytes), WebSocketMessageType.Binary, endOfMessage: true, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (WebSocketException)
|
catch (WebSocketException)
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ internal sealed partial class NodeConnection
|
|||||||
|
|
||||||
if (result.EndOfMessage)
|
if (result.EndOfMessage)
|
||||||
{
|
{
|
||||||
|
if (_cipher is { } cipher)
|
||||||
|
{
|
||||||
|
// 加密链路上只接受通过认证的二进制帧;明文、被改过的、重放的、乱序的一律断开
|
||||||
|
if (result.MessageType != WebSocketMessageType.Binary || !cipher.TryOpen(writer.WrittenSpan, out byte kind, out byte[] plain))
|
||||||
|
{
|
||||||
|
_closeReason ??= "frame failed authentication";
|
||||||
|
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "frame failed authentication", CancellationToken.None);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Frame(kind == 0, plain);
|
||||||
|
}
|
||||||
|
|
||||||
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
|
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
|||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
using Fleet.Rpc;
|
using Fleet.Rpc;
|
||||||
|
|
||||||
namespace Fleet.Server;
|
namespace Fleet.Server;
|
||||||
@@ -24,6 +25,7 @@ internal sealed partial class NodeConnection : IDisposable
|
|||||||
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
||||||
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
|
||||||
private readonly CancellationTokenSource _lifetime = new();
|
private readonly CancellationTokenSource _lifetime = new();
|
||||||
|
private LinkCipher? _cipher;
|
||||||
private long _nextRequestId;
|
private long _nextRequestId;
|
||||||
private long _lastPresenceTicks;
|
private long _lastPresenceTicks;
|
||||||
private NodeRecord? _node;
|
private NodeRecord? _node;
|
||||||
@@ -134,6 +136,7 @@ internal sealed partial class NodeConnection : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_cipher?.Dispose();
|
||||||
_lifetime.Dispose();
|
_lifetime.Dispose();
|
||||||
_sendLock.Dispose();
|
_sendLock.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System.Buffers.Text;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Fleet.Identity;
|
||||||
|
|
||||||
|
namespace Fleet.Tests;
|
||||||
|
|
||||||
|
/// <summary>软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。</summary>
|
||||||
|
internal sealed class TestAuthenticator : IDisposable
|
||||||
|
{
|
||||||
|
public const string RpId = "panel.example.com";
|
||||||
|
public const string Origin = "https://panel.example.com";
|
||||||
|
|
||||||
|
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
private uint _counter;
|
||||||
|
|
||||||
|
public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo());
|
||||||
|
|
||||||
|
public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin)
|
||||||
|
{
|
||||||
|
byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement));
|
||||||
|
_counter++;
|
||||||
|
byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter];
|
||||||
|
byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}");
|
||||||
|
byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||||
|
return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _key.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OperatorTrustTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly TestAuthenticator _alice = new();
|
||||||
|
private readonly TestAuthenticator _bob = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_alice.Dispose();
|
||||||
|
_bob.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private OperatorSet Genesis(params TestAuthenticator[] keys) =>
|
||||||
|
new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]);
|
||||||
|
|
||||||
|
private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned()
|
||||||
|
{
|
||||||
|
OperatorSet set = Genesis(_alice);
|
||||||
|
SignedStatement genesis = SignedGenesis(set, _alice);
|
||||||
|
string pin = OperatorTrust.Fingerprint(genesis.Statement);
|
||||||
|
|
||||||
|
Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis]));
|
||||||
|
Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世
|
||||||
|
Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹)
|
||||||
|
Assert.Null(OperatorTrust.VerifyChain(pin, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts()
|
||||||
|
{
|
||||||
|
SignedStatement real = SignedGenesis(Genesis(_alice), _alice);
|
||||||
|
string pin = OperatorTrust.Fingerprint(real.Statement);
|
||||||
|
|
||||||
|
using var attacker = new TestAuthenticator();
|
||||||
|
SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同
|
||||||
|
Assert.Null(OperatorTrust.VerifyChain(pin, [forged]));
|
||||||
|
|
||||||
|
// 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证
|
||||||
|
var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]);
|
||||||
|
Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain()
|
||||||
|
{
|
||||||
|
SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice);
|
||||||
|
string pin = OperatorTrust.Fingerprint(genesis.Statement);
|
||||||
|
var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]);
|
||||||
|
SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second));
|
||||||
|
|
||||||
|
OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]);
|
||||||
|
Assert.Equal(2, current?.Version);
|
||||||
|
Assert.Equal(2, current?.Keys.Count);
|
||||||
|
|
||||||
|
// 新加的 bob 可以签下一版(上一版里有他)
|
||||||
|
var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]);
|
||||||
|
Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version);
|
||||||
|
|
||||||
|
// 版本号跳了、prev 不对、域名换了:都不行
|
||||||
|
var skipped = second with { Version = 3 };
|
||||||
|
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))]));
|
||||||
|
var wrongPrev = second with { Previous = new string('a', 64) };
|
||||||
|
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))]));
|
||||||
|
var otherRp = second with { RpId = "evil.example" };
|
||||||
|
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement()
|
||||||
|
{
|
||||||
|
OperatorSet set = Genesis(_alice);
|
||||||
|
Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set));
|
||||||
|
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证
|
||||||
|
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set));
|
||||||
|
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改
|
||||||
|
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parsing_IsStrict()
|
||||||
|
{
|
||||||
|
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
|
||||||
|
OperatorSet set = Genesis(_alice);
|
||||||
|
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
|
||||||
|
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
|
||||||
|
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Fleet.Identity;
|
||||||
|
|
||||||
|
namespace Fleet.Tests;
|
||||||
|
|
||||||
|
public sealed class SecureChannelTests
|
||||||
|
{
|
||||||
|
private const string Nonce = "n0nce-from-the-server";
|
||||||
|
|
||||||
|
private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce)
|
||||||
|
{
|
||||||
|
using var node = new EphemeralKey();
|
||||||
|
using var server = new EphemeralKey();
|
||||||
|
return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey()
|
||||||
|
{
|
||||||
|
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
using var eph = new EphemeralKey();
|
||||||
|
using var otherEph = new EphemeralKey();
|
||||||
|
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
|
||||||
|
byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki);
|
||||||
|
|
||||||
|
Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof));
|
||||||
|
Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥
|
||||||
|
Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上
|
||||||
|
Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥
|
||||||
|
Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1)));
|
||||||
|
Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BothDirections_RoundTrip_AndKeepTheFrameKind()
|
||||||
|
{
|
||||||
|
(LinkCipher node, LinkCipher server) = Pair();
|
||||||
|
using LinkCipher n = node;
|
||||||
|
using LinkCipher s = server;
|
||||||
|
|
||||||
|
byte[] toServer = n.Seal(0, "hello server"u8);
|
||||||
|
Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data));
|
||||||
|
Assert.Equal(0, kind);
|
||||||
|
Assert.Equal("hello server"u8.ToArray(), data);
|
||||||
|
|
||||||
|
byte[] big = RandomNumberGenerator.GetBytes(300_000);
|
||||||
|
byte[] toNode = s.Seal(1, big);
|
||||||
|
Assert.True(n.TryOpen(toNode, out kind, out data));
|
||||||
|
Assert.Equal(1, kind);
|
||||||
|
Assert.Equal(big, data);
|
||||||
|
Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused()
|
||||||
|
{
|
||||||
|
(LinkCipher node, LinkCipher server) = Pair();
|
||||||
|
using LinkCipher n = node;
|
||||||
|
using LinkCipher s = server;
|
||||||
|
|
||||||
|
byte[] first = n.Seal(0, "one"u8);
|
||||||
|
byte[] second = n.Seal(0, "two"u8);
|
||||||
|
Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧)
|
||||||
|
Assert.True(s.TryOpen(first, out _, out _));
|
||||||
|
Assert.False(s.TryOpen(first, out _, out _)); // 重放
|
||||||
|
Assert.True(s.TryOpen(second, out _, out _));
|
||||||
|
|
||||||
|
byte[] tampered = n.Seal(0, "three"u8);
|
||||||
|
tampered[12] ^= 0x01;
|
||||||
|
Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位
|
||||||
|
|
||||||
|
byte[] fromNode = n.Seal(0, "four"u8);
|
||||||
|
(LinkCipher otherNode, LinkCipher otherServer) = Pair();
|
||||||
|
using LinkCipher on = otherNode;
|
||||||
|
using LinkCipher os = otherServer;
|
||||||
|
Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开
|
||||||
|
|
||||||
|
byte[] fromServer = s.Seal(0, "reflected"u8);
|
||||||
|
Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器
|
||||||
|
Assert.False(n.TryOpen([1, 2, 3], out _, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADifferentNonce_GivesDifferentKeys()
|
||||||
|
{
|
||||||
|
(LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西
|
||||||
|
using LinkCipher n = node;
|
||||||
|
using LinkCipher s = server;
|
||||||
|
Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain()
|
||||||
|
{
|
||||||
|
using var alice = new TestAuthenticator();
|
||||||
|
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
|
||||||
|
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]);
|
||||||
|
string text = OperatorTrust.Encode(set);
|
||||||
|
|
||||||
|
OperatorSet parsed = OperatorTrust.ParseSet(text)!;
|
||||||
|
Assert.Equal(spki, Assert.Single(parsed.Servers!));
|
||||||
|
Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定
|
||||||
|
|
||||||
|
SignedStatement genesis = alice.Sign(text);
|
||||||
|
OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!;
|
||||||
|
Assert.Equal(spki, Assert.Single(verified.Servers!));
|
||||||
|
|
||||||
|
// 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏
|
||||||
|
Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA"));
|
||||||
|
Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ string? name = Arg("--name");
|
|||||||
string? directory = Arg("--identity-dir");
|
string? directory = Arg("--identity-dir");
|
||||||
if (server is null || join is null || name is null || directory is null)
|
if (server is null || join is null || name is null || directory is null)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>]");
|
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>] [--genesis <trust root fingerprint> --controllers <passkey credential ids, comma separated>]");
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,14 +30,23 @@ if (Arg("--meta") is { } metaText)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string? genesis = Arg("--genesis");
|
||||||
|
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);
|
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
|
||||||
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
|
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
|
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}");
|
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}" + (enrolled.Pinned ? "; operator trust pinned" : "; operator trust NOT pinned (no --genesis)"));
|
||||||
return 0;
|
return 0;
|
||||||
+1622
-59
File diff suppressed because it is too large.
Load diff
@@ -16,11 +16,13 @@ public sealed class AgentDirectory
|
|||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly ConfigStore _store;
|
private readonly ConfigStore _store;
|
||||||
private readonly INodeLog _log;
|
private readonly INodeLog _log;
|
||||||
|
private readonly TrustAnchor _trust;
|
||||||
private List<KnownAgent> _agents = [];
|
private List<KnownAgent> _agents = [];
|
||||||
private long _version;
|
private long _version;
|
||||||
|
|
||||||
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log)
|
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
|
||||||
{
|
{
|
||||||
|
_trust = trust ?? TrustAnchor.None;
|
||||||
_store = store;
|
_store = store;
|
||||||
_log = log;
|
_log = log;
|
||||||
string cache = Path.Combine(configDirectory, FileName);
|
string cache = Path.Combine(configDirectory, FileName);
|
||||||
@@ -68,6 +70,7 @@ public sealed class AgentDirectory
|
|||||||
public void Apply(ProbeAgentList result)
|
public void Apply(ProbeAgentList result)
|
||||||
{
|
{
|
||||||
var next = new List<KnownAgent>();
|
var next = new List<KnownAgent>();
|
||||||
|
_trust.Update(result.TrustChain);
|
||||||
foreach (AgentEndpoint agent in result.Agents)
|
foreach (AgentEndpoint agent in result.Agents)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(agent.Host))
|
if (!string.IsNullOrWhiteSpace(agent.Host))
|
||||||
|
|||||||
@@ -27,20 +27,24 @@ public sealed class ProbeApp : IDisposable
|
|||||||
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
||||||
{
|
{
|
||||||
_log = log;
|
_log = log;
|
||||||
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log);
|
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
|
||||||
|
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
|
||||||
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
|
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
|
||||||
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,
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -164,6 +164,9 @@ public sealed class ProbeAgentList
|
|||||||
|
|
||||||
[JsonPropertyName("agents")]
|
[JsonPropertyName("agents")]
|
||||||
public List<AgentEndpoint> Agents { get; set; } = [];
|
public List<AgentEndpoint> Agents { get; set; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("trust_chain")]
|
||||||
|
public List<SignedStatementDto>? TrustChain { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
|
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
|
||||||
@@ -303,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; }
|
||||||
|
|
||||||
@@ -313,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; }
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ public sealed class TaskSet
|
|||||||
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
|
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
|
||||||
[JsonPropertyName("peers")]
|
[JsonPropertyName("peers")]
|
||||||
public List<PeerSpec> Peers { get; set; } = [];
|
public List<PeerSpec> Peers { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>操作员集合的链(passkey 签的,创世在前):节点从固定的创世指纹验过才采用。</summary>
|
||||||
|
[JsonPropertyName("trust_chain")]
|
||||||
|
public List<SignedStatementDto>? TrustChain { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TaskSchedule
|
public static class TaskSchedule
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
@@ -17,50 +18,67 @@ public static class AgentEndpoints
|
|||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/agents", ListAsync)
|
group.MapGet("/agents", ListAsync)
|
||||||
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403);
|
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.FleetRead);
|
||||||
group.MapGet("/agents/{id}", GetAsync)
|
group.MapGet("/agents/{id}", GetAsync)
|
||||||
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404)
|
||||||
|
.Requires(Capability.FleetRead);
|
||||||
group.MapPatch("/agents/{id}", PatchAsync)
|
group.MapPatch("/agents/{id}", PatchAsync)
|
||||||
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
|
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
|
||||||
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
|
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
|
||||||
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412);
|
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412)
|
||||||
|
.Requires(Capability.FleetWrite);
|
||||||
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
|
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
|
||||||
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
|
||||||
|
.Requires(Capability.FleetEnroll);
|
||||||
group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct)))
|
group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct)))
|
||||||
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
|
||||||
|
.Requires(Capability.FleetWrite);
|
||||||
group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct)))
|
group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct)))
|
||||||
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
|
||||||
|
.Requires(Capability.FleetWrite);
|
||||||
group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct)))
|
group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct)))
|
||||||
.Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag)
|
.Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag)
|
||||||
.Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
.Returns<AgentDto>().ProducesProblems(401, 403, 404)
|
||||||
|
.Requires(Capability.FleetEnroll);
|
||||||
group.MapDelete("/agents/{id}", DeleteAsync)
|
group.MapDelete("/agents/{id}", DeleteAsync)
|
||||||
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
|
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
|
||||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.FleetRevoke);
|
||||||
|
|
||||||
group.MapGet("/join-tokens", ListTokensAsync)
|
group.MapGet("/join-tokens", ListTokensAsync)
|
||||||
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403);
|
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.FleetRead);
|
||||||
group.MapPost("/join-tokens", CreateTokenAsync)
|
group.MapPost("/join-tokens", CreateTokenAsync)
|
||||||
.Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag)
|
.Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag)
|
||||||
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
|
||||||
|
.Requires(Capability.FleetEnroll);
|
||||||
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
||||||
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
|
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
|
||||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.FleetEnroll);
|
||||||
|
|
||||||
group.MapGet("/probes", ListProbesAsync)
|
group.MapGet("/probes", ListProbesAsync)
|
||||||
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
|
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.FleetRead);
|
||||||
group.MapGet("/probes/{id}", GetProbeAsync)
|
group.MapGet("/probes/{id}", GetProbeAsync)
|
||||||
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404)
|
||||||
|
.Requires(Capability.FleetRead);
|
||||||
group.MapPatch("/probes/{id}", PatchProbeAsync)
|
group.MapPatch("/probes/{id}", PatchProbeAsync)
|
||||||
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
|
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409)
|
||||||
|
.Requires(Capability.FleetWrite);
|
||||||
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
|
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
|
||||||
.Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
|
.Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
|
||||||
.Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
.Returns<ProbeDto>().ProducesProblems(401, 403, 404)
|
||||||
|
.Requires(Capability.FleetRevoke);
|
||||||
group.MapDelete("/probes/{id}", DeleteProbeAsync)
|
group.MapDelete("/probes/{id}", DeleteProbeAsync)
|
||||||
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
|
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
|
||||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.FleetRevoke);
|
||||||
group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync)
|
group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync)
|
||||||
.Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag)
|
.Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag)
|
||||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409);
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409)
|
||||||
|
.Requires(Capability.FleetWrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListAsync(AgentService agents, CancellationToken ct) =>
|
private static async Task<IResult> ListAsync(AgentService agents, CancellationToken ct) =>
|
||||||
@@ -112,11 +130,23 @@ public static class AgentEndpoints
|
|||||||
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
|
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
|
||||||
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
|
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
|
||||||
|
|
||||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options)
|
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, TrustService trust, CreateJoinTokenRequest request, BackendOptions options)
|
||||||
{
|
{
|
||||||
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||||
|
|
||||||
|
// 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把
|
||||||
|
OperatorSet? set = await trust.CurrentAsync(http.RequestAborted);
|
||||||
|
string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis;
|
||||||
|
Result<string[]> owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers);
|
||||||
|
if (!owner.TryGet(out string[]? controllers, out FleetError? ownerError))
|
||||||
|
{
|
||||||
|
return Problems.From(http, ownerError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
|
||||||
|
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
|
||||||
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
||||||
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole), baseUrl, http.RequestAborted);
|
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted);
|
||||||
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
|
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
|
||||||
{
|
{
|
||||||
return Problems.From(http, error);
|
return Problems.From(http, error);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using MoShiro.Backend.Application;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
|
/// <summary>审计日志:管理接口里每个写操作、登录都会记一行,这里只读。</summary>
|
||||||
|
public static class AuditEndpoints
|
||||||
|
{
|
||||||
|
private const string Tag = "admin-audit";
|
||||||
|
|
||||||
|
/// <summary>handler 往这里放一句「这次具体做了什么」(例如新建令牌的名字),守卫记审计时一起写进去。</summary>
|
||||||
|
public const string DetailItem = "moshiro.audit.detail";
|
||||||
|
|
||||||
|
public static void Map(RouteGroupBuilder guarded) =>
|
||||||
|
guarded.MapGet("/audit", ListAsync)
|
||||||
|
.Api("audit_list", "Audit log of admin actions, newest first", Tag).Returns<PageDto<AuditDto>>().ProducesProblems(400, 401, 403)
|
||||||
|
.Requires(Capability.AuditRead);
|
||||||
|
|
||||||
|
public static void SetDetail(this HttpContext http, string detail) => http.Items[DetailItem] = detail;
|
||||||
|
|
||||||
|
private static async Task<IResult> ListAsync(HttpContext http, AuditService audit, string? actor, string? action, int? limit, string? cursor)
|
||||||
|
{
|
||||||
|
long? before = null;
|
||||||
|
if (cursor is not null)
|
||||||
|
{
|
||||||
|
if (!long.TryParse(cursor, out long parsed))
|
||||||
|
{
|
||||||
|
return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor"));
|
||||||
|
}
|
||||||
|
|
||||||
|
before = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
int size = Math.Clamp(limit ?? 50, 1, 500);
|
||||||
|
IReadOnlyList<AuditRecord> rows = await audit.ListAsync(new AuditQuery(before, size + 1, string.IsNullOrEmpty(actor) ? null : actor, string.IsNullOrEmpty(action) ? null : action), http.RequestAborted);
|
||||||
|
bool more = rows.Count > size;
|
||||||
|
IReadOnlyList<AuditRecord> page = [.. rows.Take(size)];
|
||||||
|
string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
|
||||||
|
return TypedResults.Json(new PageDto<AuditDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoAuditDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,36 +16,104 @@ public static class AuthEndpoints
|
|||||||
public static void MapLogin(RouteGroupBuilder open)
|
public static void MapLogin(RouteGroupBuilder open)
|
||||||
{
|
{
|
||||||
open.MapPost("/auth/login", LoginAsync)
|
open.MapPost("/auth/login", LoginAsync)
|
||||||
|
.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)
|
||||||
|
.AllowsAnonymous()
|
||||||
|
.Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag)
|
||||||
|
.Returns<PasskeyRequestOptionsDto>().ProducesProblems(429);
|
||||||
|
open.MapPost("/auth/passkey/login", PasskeyLoginAsync)
|
||||||
|
.AllowsAnonymous()
|
||||||
|
.Api("auth_passkey_login", "Sign in with a passkey assertion; same response as the password sign-in", Tag)
|
||||||
|
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void MapSession(RouteGroupBuilder guarded)
|
public static void MapSession(RouteGroupBuilder guarded)
|
||||||
{
|
{
|
||||||
guarded.MapPost("/auth/logout", LogoutAsync)
|
guarded.MapPost("/auth/logout", LogoutAsync)
|
||||||
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
|
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
|
||||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401);
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
|
||||||
|
.Requires(Capability.Authenticated);
|
||||||
|
guarded.MapGet("/auth/passkeys", ListPasskeysAsync)
|
||||||
|
.Api("auth_passkeys_list", "The signed-in user's passkeys", Tag)
|
||||||
|
.Returns<PageDto<PasskeyDto>>().ProducesProblems(401)
|
||||||
|
.Requires(Capability.Authenticated);
|
||||||
|
guarded.MapPost("/auth/passkeys/options", PasskeyCreationOptionsAsync)
|
||||||
|
.Api("auth_passkeys_options", "A challenge for registering a new passkey (the user must be signed in)", Tag)
|
||||||
|
.Returns<PasskeyCreationOptionsDto>().ProducesProblems(400, 401, 403)
|
||||||
|
.Requires(Capability.Authenticated);
|
||||||
|
guarded.MapPost("/auth/passkeys", RegisterPasskeyAsync)
|
||||||
|
.Api("auth_passkeys_register", "Register a passkey from a WebAuthn attestation (ES256, user verification required)", Tag)
|
||||||
|
.Returns<PasskeyDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||||
|
.Requires(Capability.Authenticated);
|
||||||
|
guarded.MapDelete("/auth/passkeys/{id}", DeletePasskeyAsync)
|
||||||
|
.Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
|
||||||
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
|
||||||
|
.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)
|
||||||
|
.Requires(Capability.Authenticated);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options)
|
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||||
{
|
{
|
||||||
string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
string source = http.ClientAddress();
|
||||||
if (!limiter.TryAcquire(source, out int retryAfter))
|
if (!limiter.TryAcquire(source, out int retryAfter))
|
||||||
{
|
{
|
||||||
return Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
|
return Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
|
||||||
}
|
}
|
||||||
|
|
||||||
Result<LoginResult> result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
Result<LoginResult> result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||||
|
string remote = source;
|
||||||
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||||
{
|
{
|
||||||
|
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "auth.login", null, null, remote, false, http.RequestAborted);
|
||||||
return Problems.From(http, error);
|
return Problems.From(http, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
|
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "auth.login", null, null, remote, true, http.RequestAborted);
|
||||||
bool returnSession = request.ReturnSession == true;
|
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>
|
||||||
|
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options)
|
||||||
|
{
|
||||||
if (!returnSession)
|
if (!returnSession)
|
||||||
{
|
{
|
||||||
http.Response.Cookies.Append(
|
http.Response.Cookies.Append(
|
||||||
@@ -65,6 +133,98 @@ public static class AuthEndpoints
|
|||||||
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse);
|
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。</summary>
|
||||||
|
internal static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options)
|
||||||
|
{
|
||||||
|
string own = $"{http.Request.Scheme}://{http.Request.Host}";
|
||||||
|
Uri? configured = Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out Uri? uri) ? uri : null;
|
||||||
|
string rpId = !string.IsNullOrWhiteSpace(options.WebAuthnRpId) ? options.WebAuthnRpId.Trim() : configured?.Host ?? http.Request.Host.Host;
|
||||||
|
IEnumerable<string> origins = !string.IsNullOrWhiteSpace(options.WebAuthnOrigins)
|
||||||
|
? options.WebAuthnOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(o => o.TrimEnd('/'))
|
||||||
|
: [configured is null ? own : configured.GetLeftPart(UriPartial.Authority)];
|
||||||
|
return new PasskeyContext(rpId, new HashSet<string>(origins, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>限流。<paramref name="scope"/> 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。</summary>
|
||||||
|
private static IResult Limited(HttpContext http, LoginRateLimiter limiter, out bool ok, string scope = "", int? limit = null)
|
||||||
|
{
|
||||||
|
string source = scope + (http.ClientAddress());
|
||||||
|
ok = limiter.TryAcquire(source, out int retryAfter, limit);
|
||||||
|
return ok ? TypedResults.NoContent() : Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>取挑战只是页面加载和点按钮,不是猜密码:额度比登录失败宽松,挑战总量另有上限(淘汰最旧的)。</summary>
|
||||||
|
private const int PasskeyOptionsPerMinute = 60;
|
||||||
|
|
||||||
|
private static IResult PasskeyLoginOptions(HttpContext http, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
|
||||||
|
{
|
||||||
|
IResult limit = Limited(http, limiter, out bool ok, "passkey-options:", limit: PasskeyOptionsPerMinute);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
RequestOptions o = passkeys.BeginLogin(PasskeyContextOf(http, options));
|
||||||
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
|
return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs), ApiJsonContext.Default.PasskeyRequestOptionsDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||||
|
{
|
||||||
|
IResult limit = Limited(http, limiter, out bool ok);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<LoginResult> result = await passkeys.LoginAsync(
|
||||||
|
new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
|
||||||
|
string? remote = http.ClientAddress();
|
||||||
|
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||||
|
{
|
||||||
|
await audit.RecordAsync(null, "?", AuthMethods.Passkey, "auth.login", null, null, remote, false, http.RequestAborted);
|
||||||
|
return Problems.From(http, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Passkey, "auth.login", null, null, remote, true, http.RequestAborted);
|
||||||
|
return IssueSession(http, login, request.ReturnSession == true, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListPasskeysAsync(HttpContext http, PasskeyService passkeys)
|
||||||
|
{
|
||||||
|
string userId = http.GetPrincipal().UserId ?? "";
|
||||||
|
IReadOnlyList<PasskeyRecord> list = userId.Length == 0 ? [] : await passkeys.ListAsync(userId, http.RequestAborted);
|
||||||
|
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
|
||||||
|
{
|
||||||
|
Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
|
||||||
|
if (!result.TryGet(out CreationOptions? o, out FleetError? error))
|
||||||
|
{
|
||||||
|
return Problems.From(http, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
|
return TypedResults.Json(new PasskeyCreationOptionsDto(o.Challenge, o.RpId, o.RpName, o.UserId, o.UserName, o.ExcludeCredentials, o.TimeoutMs), ApiJsonContext.Default.PasskeyCreationOptionsDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
return result.TryGet(out PasskeyRecord? record, out FleetError? error)
|
||||||
|
? TypedResults.Json(Mappers.ToDto(record), ApiJsonContext.Default.PasskeyDto, statusCode: StatusCodes.Status201Created)
|
||||||
|
: Problems.From(http, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id)
|
||||||
|
{
|
||||||
|
return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
|
||||||
|
? TypedResults.NoContent()
|
||||||
|
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey");
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||||
{
|
{
|
||||||
string? bearer = PrincipalResolver.BearerOf(http);
|
string? bearer = PrincipalResolver.BearerOf(http);
|
||||||
@@ -85,7 +245,7 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
Principal principal = http.GetPrincipal();
|
Principal principal = http.GetPrincipal();
|
||||||
return TypedResults.Json(
|
return TypedResults.Json(
|
||||||
new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken),
|
new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken, principal.AuthMethod.Length > 0 ? principal.AuthMethod : null),
|
||||||
ApiJsonContext.Default.MeDto);
|
ApiJsonContext.Default.MeDto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,17 +17,26 @@ public static class BuildEndpoints
|
|||||||
{
|
{
|
||||||
group.MapGet("/build-info", GetAsync)
|
group.MapGet("/build-info", GetAsync)
|
||||||
.Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag)
|
.Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag)
|
||||||
.Returns<BuildInfoDto>().ProducesProblems(401, 403);
|
.Returns<BuildInfoDto>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.UpdateRead);
|
||||||
group.MapGet("/releases", ListReleasesAsync)
|
group.MapGet("/releases", ListReleasesAsync)
|
||||||
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403);
|
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.UpdateRead);
|
||||||
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);
|
||||||
|
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);
|
||||||
group.MapPost("/backend/update", ConfirmBackendUpdateAsync)
|
group.MapPost("/backend/update", ConfirmBackendUpdateAsync)
|
||||||
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412);
|
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412)
|
||||||
|
.Requires(Capability.UpdateConfirm);
|
||||||
group.MapGet("/backend/update/live", LiveBackendUpdateAsync)
|
group.MapGet("/backend/update/live", LiveBackendUpdateAsync)
|
||||||
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403);
|
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.UpdateRead);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
|
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
|
||||||
@@ -35,15 +44,43 @@ public static class BuildEndpoints
|
|||||||
{
|
{
|
||||||
group.MapPost("/releases", PublishReleaseAsync)
|
group.MapPost("/releases", PublishReleaseAsync)
|
||||||
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag)
|
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag)
|
||||||
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||||
|
.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);
|
||||||
@@ -101,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);
|
||||||
}
|
}
|
||||||
@@ -16,14 +16,18 @@ public static class DataEndpoints
|
|||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/raw/chunks", ListChunksAsync)
|
group.MapGet("/raw/chunks", ListChunksAsync)
|
||||||
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403);
|
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403)
|
||||||
|
.Requires(Capability.DataRead);
|
||||||
group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync)
|
group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync)
|
||||||
.Api("raw_chunks_get", "Download a raw chunk (application/vnd.moshiro.rawchunk)", Tag)
|
.Api("raw_chunks_get", "Download a raw chunk (application/vnd.moshiro.rawchunk)", Tag)
|
||||||
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410);
|
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410)
|
||||||
|
.Requires(Capability.DataRead);
|
||||||
group.MapGet("/events", ListEventsAsync)
|
group.MapGet("/events", ListEventsAsync)
|
||||||
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403);
|
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403)
|
||||||
|
.Requires(Capability.EventsRead);
|
||||||
group.MapGet("/analysis/recompute", RecomputeAsync)
|
group.MapGet("/analysis/recompute", RecomputeAsync)
|
||||||
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403);
|
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.DataWrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListChunksAsync(
|
private static async Task<IResult> ListChunksAsync(
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ public static class LinkEndpoints
|
|||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/links", ListAsync)
|
group.MapGet("/links", ListAsync)
|
||||||
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403);
|
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.FleetRead);
|
||||||
group.MapPut("/links/{probe_id}/{agent_id}", SetAsync)
|
group.MapPut("/links/{probe_id}/{agent_id}", SetAsync)
|
||||||
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404);
|
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404)
|
||||||
|
.Requires(Capability.FleetWrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct)
|
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct)
|
||||||
|
|||||||
@@ -18,33 +18,54 @@ public static class SystemEndpoints
|
|||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/api-tokens", ListTokensAsync)
|
group.MapGet("/api-tokens", ListTokensAsync)
|
||||||
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403);
|
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.KeysRead);
|
||||||
group.MapPost("/api-tokens", CreateTokenAsync)
|
group.MapPost("/api-tokens", CreateTokenAsync)
|
||||||
.Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag)
|
.Api("api_tokens_create", "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once", TokensTag)
|
||||||
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
|
||||||
|
.Requires(Capability.KeysManage);
|
||||||
group.MapDelete("/api-tokens/{id}", RevokeTokenAsync)
|
group.MapDelete("/api-tokens/{id}", RevokeTokenAsync)
|
||||||
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.KeysManage);
|
||||||
|
|
||||||
|
group.MapGet("/capabilities", CapabilitiesAsync)
|
||||||
|
.Api("capabilities_list", "The capability catalog: groups, which ones change trust, which are reserved, and the presets", TokensTag)
|
||||||
|
.Returns<CapabilityCatalogDto>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.KeysRead);
|
||||||
|
|
||||||
group.MapGet("/settings", ListSettingsAsync)
|
group.MapGet("/settings", ListSettingsAsync)
|
||||||
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403);
|
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.SettingsRead);
|
||||||
group.MapPatch("/settings", PatchSettingsAsync)
|
group.MapPatch("/settings", PatchSettingsAsync)
|
||||||
.Accepts<Dictionary<string, JsonElement>>("application/merge-patch+json", "application/json")
|
.Accepts<Dictionary<string, JsonElement>>("application/merge-patch+json", "application/json")
|
||||||
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403);
|
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403)
|
||||||
|
.Requires(Capability.SettingsWrite);
|
||||||
|
|
||||||
group.MapGet("/users", ListUsersAsync)
|
group.MapGet("/users", ListUsersAsync)
|
||||||
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403);
|
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.AccountsRead);
|
||||||
group.MapPost("/users", CreateUserAsync)
|
group.MapPost("/users", CreateUserAsync)
|
||||||
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||||
|
.Requires(Capability.AccountsManage);
|
||||||
group.MapPatch("/users/{id}", ChangePasswordAsync)
|
group.MapPatch("/users/{id}", ChangePasswordAsync)
|
||||||
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403);
|
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403)
|
||||||
|
.Requires(Capability.AccountsManage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<CapabilityCatalogDto> CapabilitiesAsync() =>
|
||||||
|
TypedResults.Json(
|
||||||
|
new CapabilityCatalogDto(
|
||||||
|
[.. CapabilityCatalog.Groups.Select(g => new CapabilityGroupDto(g, [.. CapabilityCatalog.All.Where(c => c.Group == g).Select(c => new CapabilityDto(c.Id, c.Trust, c.Reserved, c.Implies))]))],
|
||||||
|
[.. CapabilityCatalog.Presets.Select(p => new CapabilityPresetDto(p.Id, p.Capabilities))]),
|
||||||
|
ApiJsonContext.Default.CapabilityCatalogDto);
|
||||||
|
|
||||||
private static async Task<IResult> ListTokensAsync(AuthService auth, CancellationToken ct) =>
|
private static async Task<IResult> ListTokensAsync(AuthService auth, CancellationToken ct) =>
|
||||||
TypedResults.Json(new PageDto<ApiTokenDto>([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto);
|
TypedResults.Json(new PageDto<ApiTokenDto>([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto);
|
||||||
|
|
||||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
||||||
{
|
{
|
||||||
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted);
|
http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
|
||||||
|
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))
|
||||||
{
|
{
|
||||||
return Problems.From(http, error);
|
return Problems.From(http, error);
|
||||||
|
|||||||
@@ -16,26 +16,34 @@ public static class TaskEndpoints
|
|||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/tasks", ListAsync)
|
group.MapGet("/tasks", ListAsync)
|
||||||
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403);
|
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.TasksRead);
|
||||||
group.MapPost("/tasks", CreateAsync)
|
group.MapPost("/tasks", CreateAsync)
|
||||||
.Api("tasks_create", "Create a task; params are validated against the kind", TasksTag)
|
.Api("tasks_create", "Create a task; params are validated against the kind", TasksTag)
|
||||||
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404);
|
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404)
|
||||||
|
.Requires(Capability.TasksWrite);
|
||||||
group.MapGet("/tasks/{id}", GetAsync)
|
group.MapGet("/tasks/{id}", GetAsync)
|
||||||
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404);
|
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404)
|
||||||
|
.Requires(Capability.TasksRead);
|
||||||
group.MapPatch("/tasks/{id}", PatchAsync)
|
group.MapPatch("/tasks/{id}", PatchAsync)
|
||||||
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
|
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
|
||||||
.Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag)
|
.Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag)
|
||||||
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412);
|
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412)
|
||||||
|
.Requires(Capability.TasksWrite);
|
||||||
group.MapDelete("/tasks/{id}", DeleteAsync)
|
group.MapDelete("/tasks/{id}", DeleteAsync)
|
||||||
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.TasksWrite);
|
||||||
|
|
||||||
group.MapPost("/lines/{line_id}/tests", StartTestAsync)
|
group.MapPost("/lines/{line_id}/tests", StartTestAsync)
|
||||||
.Api("tests_start", "Start an on-demand test on a line", TestsTag)
|
.Api("tests_start", "Start an on-demand test on a line", TestsTag)
|
||||||
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409);
|
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409)
|
||||||
|
.Requires(Capability.TasksWrite);
|
||||||
group.MapGet("/lines/{line_id}/tests", ListTestsAsync)
|
group.MapGet("/lines/{line_id}/tests", ListTestsAsync)
|
||||||
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403);
|
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.TasksRead);
|
||||||
group.MapPost("/tests/{id}/cancel", CancelAsync)
|
group.MapPost("/tests/{id}/cancel", CancelAsync)
|
||||||
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404);
|
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404)
|
||||||
|
.Requires(Capability.TasksWrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct)
|
private static async Task<IResult> ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Fleet.Core;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using MoShiro.Backend.Application;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
|
||||||
|
/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。
|
||||||
|
/// </summary>
|
||||||
|
public static class TrustEndpoints
|
||||||
|
{
|
||||||
|
private const string Tag = "admin-trust";
|
||||||
|
|
||||||
|
public static void Map(RouteGroupBuilder guarded)
|
||||||
|
{
|
||||||
|
guarded.MapGet("/trust", StateAsync)
|
||||||
|
.Api("trust_get", "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", Tag)
|
||||||
|
.Returns<TrustStateDto>().ProducesProblems(401, 403)
|
||||||
|
.Requires(Capability.FleetRead);
|
||||||
|
guarded.MapPost("/trust", CommitAsync)
|
||||||
|
.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)
|
||||||
|
.Requires(Capability.AccountsManage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
|
||||||
|
{
|
||||||
|
TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted);
|
||||||
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
|
string sh = InstallScripts.Sha256(InstallScripts.Sh);
|
||||||
|
string ps1 = InstallScripts.Sha256(InstallScripts.Ps1);
|
||||||
|
return TypedResults.Json(Mappers.ToDto(state, options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}", sh, ps1, await MatchesReleaseAsync(releases, sh, ps1, http.RequestAborted)), ApiJsonContext.Default.TrustStateDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。</summary>
|
||||||
|
private static async Task<bool?> MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole })
|
||||||
|
{
|
||||||
|
if (await releases.GetAsync(component, ct) is not { } offer)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(offer.Manifest);
|
||||||
|
if (document.RootElement.TryGetProperty("install_sh", out System.Text.Json.JsonElement recordedSh) && document.RootElement.TryGetProperty("install_ps1", out System.Text.Json.JsonElement recordedPs1))
|
||||||
|
{
|
||||||
|
return string.Equals(recordedSh.GetString(), sh, StringComparison.Ordinal) && string.Equals(recordedPs1.GetString(), ps1, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
||||||
|
{
|
||||||
|
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options);
|
||||||
|
Result<TrustState> result = await trust.CommitAsync(signed, context, http.RequestAborted);
|
||||||
|
if (!result.TryGet(out TrustState? state, out FleetError? error))
|
||||||
|
{
|
||||||
|
return Problems.From(http, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 新的操作员集合要随名册下发给节点
|
||||||
|
http.SetDetail($"operator set v{state.Version}");
|
||||||
|
return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ 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.Configure<JsonOptions>(options =>
|
services.Configure<JsonOptions>(options =>
|
||||||
{
|
{
|
||||||
JsonSerializerOptions json = options.SerializerOptions;
|
JsonSerializerOptions json = options.SerializerOptions;
|
||||||
@@ -103,19 +105,23 @@ public static class ApiModule
|
|||||||
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
||||||
AuthEndpoints.MapLogin(admin);
|
AuthEndpoints.MapLogin(admin);
|
||||||
|
|
||||||
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync));
|
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync));
|
||||||
|
|
||||||
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync);
|
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
|
||||||
AuthEndpoints.MapSession(guarded);
|
AuthEndpoints.MapSession(guarded);
|
||||||
AgentEndpoints.Map(guarded);
|
AgentEndpoints.Map(guarded);
|
||||||
TaskEndpoints.Map(guarded);
|
TaskEndpoints.Map(guarded);
|
||||||
DataEndpoints.Map(guarded);
|
DataEndpoints.Map(guarded);
|
||||||
|
AuditEndpoints.Map(guarded);
|
||||||
|
TrustEndpoints.Map(guarded);
|
||||||
SystemEndpoints.Map(guarded);
|
SystemEndpoints.Map(guarded);
|
||||||
BuildEndpoints.Map(guarded);
|
BuildEndpoints.Map(guarded);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 请求的真实来源地址。后端前面通常有反向代理(Cloudflare、frp、Caddy),套接字上看到的是最近一跳;
|
||||||
|
/// 只有配置了 <see cref="BackendOptions.TrustedProxies"/>,并且连进来的正是其中之一,才相信转发头,
|
||||||
|
/// 且从右往左跳过所有可信代理,取第一个不可信的地址(客户端自己伪造的在最左边,不会被采信)。
|
||||||
|
/// 没配置就一律用套接字地址:宁可限流和审计里是代理的地址,也不让客户端自己报地址。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClientAddressResolver
|
||||||
|
{
|
||||||
|
private readonly IPNetwork[] _trusted;
|
||||||
|
private readonly string _header;
|
||||||
|
|
||||||
|
public ClientAddressResolver(BackendOptions options)
|
||||||
|
{
|
||||||
|
_header = string.IsNullOrWhiteSpace(options.TrustedProxyHeader) ? "X-Forwarded-For" : options.TrustedProxyHeader.Trim();
|
||||||
|
_trusted = [.. (options.TrustedProxies ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(ParseNetwork)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPNetwork ParseNetwork(string text)
|
||||||
|
{
|
||||||
|
if (IPNetwork.TryParse(text, out IPNetwork network))
|
||||||
|
{
|
||||||
|
return network;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IPAddress.TryParse(text, out IPAddress? single))
|
||||||
|
{
|
||||||
|
return new IPNetwork(single, single.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"TrustedProxies: '{text}' is not an IP address or CIDR range");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsTrusted(IPAddress address)
|
||||||
|
{
|
||||||
|
IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
|
||||||
|
return _trusted.Any(n => n.Contains(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Resolve(HttpContext http)
|
||||||
|
{
|
||||||
|
IPAddress? remote = http.Connection.RemoteIpAddress;
|
||||||
|
if (remote is null)
|
||||||
|
{
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_trusted.Length == 0 || !IsTrusted(remote))
|
||||||
|
{
|
||||||
|
return remote.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] chain = [.. http.Request.Headers[_header].SelectMany(v => (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))];
|
||||||
|
for (int i = chain.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (!IPAddress.TryParse(chain[i], out IPAddress? hop))
|
||||||
|
{
|
||||||
|
break; // 不是地址就不信这条头
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsTrusted(hop))
|
||||||
|
{
|
||||||
|
return hop.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remote.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ClientAddressExtensions
|
||||||
|
{
|
||||||
|
public static string ClientAddress(this HttpContext http) => http.RequestServices.GetRequiredService<ClientAddressResolver>().Resolve(http);
|
||||||
|
}
|
||||||
@@ -9,6 +9,19 @@ public static class EndpointConventions
|
|||||||
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
|
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
|
||||||
builder.WithName(operationId).WithSummary(summary).WithTags(tag);
|
builder.WithName(operationId).WithSummary(summary).WithTags(tag);
|
||||||
|
|
||||||
|
/// <summary>声明端点需要的能力(授权的唯一依据);OpenAPI 里带上 <c>x-moshiro-capability</c>。</summary>
|
||||||
|
public static RouteHandlerBuilder Requires(this RouteHandlerBuilder builder, string capability) =>
|
||||||
|
builder.WithMetadata(new CapabilityRequirement(capability)).AddOpenApiOperationTransformer((operation, _, _) =>
|
||||||
|
{
|
||||||
|
operation.Extensions ??= new Dictionary<string, Microsoft.OpenApi.IOpenApiExtension>();
|
||||||
|
operation.Extensions["x-moshiro-capability"] = new Microsoft.OpenApi.JsonNodeExtension(System.Text.Json.Nodes.JsonValue.Create(capability));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
|
||||||
|
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
|
||||||
|
builder.WithMetadata(new CapabilityRequirement(null));
|
||||||
|
|
||||||
public static RouteHandlerBuilder Returns<T>(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) =>
|
public static RouteHandlerBuilder Returns<T>(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) =>
|
||||||
builder.Produces<T>(status);
|
builder.Produces<T>(status);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using MoShiro.Backend.Application;
|
using MoShiro.Backend.Application;
|
||||||
using MoShiro.Protocol;
|
using MoShiro.Protocol;
|
||||||
@@ -53,17 +54,29 @@ public static class PrincipalResolver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
/// <summary>
|
||||||
public static class AdminGuard
|
/// 端点声明自己需要的能力(<see cref="EndpointConventions.Requires"/>)。<c>Capability == null</c> 表示匿名可访问(登录)。
|
||||||
|
/// 没有声明的 <c>/api/admin</c> 端点一律拒绝(并有对账测试保证不存在)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CapabilityRequirement(string? Capability);
|
||||||
|
|
||||||
|
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||||
|
public static class CapabilityGuard
|
||||||
{
|
{
|
||||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||||
{
|
{
|
||||||
HttpContext http = context.HttpContext;
|
HttpContext http = context.HttpContext;
|
||||||
Principal principal = http.GetPrincipal();
|
CapabilityRequirement? requirement = http.GetEndpoint()?.Metadata.GetMetadata<CapabilityRequirement>();
|
||||||
if (!principal.IsAdmin)
|
if (requirement is null)
|
||||||
{
|
{
|
||||||
return principal.Scope != ApiScope.None
|
return Problems.Create(http, MoShiroErrors.Forbidden, "this endpoint has no capability declaration");
|
||||||
? Problems.Create(http, MoShiroErrors.Forbidden, "the token scope does not allow this operation")
|
}
|
||||||
|
|
||||||
|
Principal principal = http.GetPrincipal();
|
||||||
|
if (requirement.Capability is { } capability && !principal.Has(capability))
|
||||||
|
{
|
||||||
|
return principal.Kind != PrincipalKind.Anonymous
|
||||||
|
? Problems.Create(http, MoShiroErrors.Forbidden, "the credential lacks the capability: " + capability)
|
||||||
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
|
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,15 +89,26 @@ public static class AdminGuard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await next(context);
|
if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
|
||||||
}
|
{
|
||||||
|
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
|
||||||
|
string action = http.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? http.Request.Path.Value ?? "";
|
||||||
|
if (action.EndsWith("_options", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return await next(context); // 取挑战不是操作
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。</summary>
|
string? target = http.Request.RouteValues.Count == 0 ? null : string.Join(' ', http.Request.RouteValues.Where(v => v.Value is not null).Select(v => v.Key + "=" + v.Value));
|
||||||
public static class ReleaseGuard
|
http.Response.OnCompleted(() =>
|
||||||
{
|
{
|
||||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
|
http.Items.TryGetValue(AuditEndpoints.DetailItem, out object? detail);
|
||||||
context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next);
|
return http.RequestServices.GetRequiredService<AuditService>()
|
||||||
|
.RecordAsync(principal, action, target, detail as string, http.ClientAddress(), http.Response.StatusCode < 400).AsTask();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next(context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
||||||
@@ -93,7 +117,7 @@ public static class PublicGuard
|
|||||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||||
{
|
{
|
||||||
HttpContext http = context.HttpContext;
|
HttpContext http = context.HttpContext;
|
||||||
if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
|
if (!http.GetPrincipal().Has(Capability.PublicRead) && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
|
||||||
{
|
{
|
||||||
return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token");
|
return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token");
|
||||||
}
|
}
|
||||||
@@ -102,5 +126,5 @@ public static class PublicGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>被隐藏(public_visible = false)的线路只对已认证的访问者可见。</summary>
|
/// <summary>被隐藏(public_visible = false)的线路只对已认证的访问者可见。</summary>
|
||||||
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead;
|
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().Has(Capability.PublicRead);
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ public static class Mappers
|
|||||||
public static ProbeDto ToDto(ProbeView v) =>
|
public static ProbeDto ToDto(ProbeView v) =>
|
||||||
new(
|
new(
|
||||||
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online,
|
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online,
|
||||||
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe));
|
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe), v.Probe.AddedBy);
|
||||||
|
|
||||||
public static SeriesDto ToDto(SeriesResult r, bool live) =>
|
public static SeriesDto ToDto(SeriesResult r, bool live) =>
|
||||||
new(
|
new(
|
||||||
@@ -103,7 +103,7 @@ public static class Mappers
|
|||||||
: null;
|
: null;
|
||||||
return new AgentDto(
|
return new AgentDto(
|
||||||
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
|
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
|
||||||
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual);
|
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
|
public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
|
||||||
@@ -154,9 +154,16 @@ public static class Mappers
|
|||||||
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
|
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
|
||||||
|
|
||||||
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));
|
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 ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs));
|
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 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 PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports);
|
||||||
|
|
||||||
|
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs));
|
||||||
|
|
||||||
public static RawChunkMetaDto ToDto(RawChunkMeta m) =>
|
public static RawChunkMetaDto ToDto(RawChunkMeta m) =>
|
||||||
new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length);
|
new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Fleet.Core;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace MoShiro.Backend.Api;
|
namespace MoShiro.Backend.Api;
|
||||||
@@ -5,9 +6,24 @@ namespace MoShiro.Backend.Api;
|
|||||||
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
|
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
|
||||||
public sealed record LoginRequest(string Username, string Password, bool? ReturnSession);
|
public sealed record LoginRequest(string Username, string Password, bool? ReturnSession);
|
||||||
|
|
||||||
|
/// <summary>浏览器 <c>navigator.credentials.create</c> 要的参数;二进制字段都是 base64url。</summary>
|
||||||
|
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>
|
||||||
|
/// <param name="AllowCredentials">列出时限定可选的 passkey(凭证 id);登录的挑战不带。</param>
|
||||||
|
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>
|
||||||
|
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 PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, IReadOnlyList<string> Transports);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken);
|
/// <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 LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
||||||
|
|
||||||
@@ -42,7 +58,8 @@ public sealed record AgentDto(
|
|||||||
bool Ipv6Supported = false,
|
bool Ipv6Supported = false,
|
||||||
Ipv6DiagnosticDto? Ipv6 = null,
|
Ipv6DiagnosticDto? Ipv6 = null,
|
||||||
bool PublicHostAuto = true,
|
bool PublicHostAuto = true,
|
||||||
bool Ipv6HostAuto = true);
|
bool Ipv6HostAuto = true,
|
||||||
|
string? AddedBy = null);
|
||||||
|
|
||||||
public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State,
|
public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State,
|
||||||
string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null);
|
string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null);
|
||||||
@@ -52,13 +69,37 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo
|
|||||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
|
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
|
||||||
|
|
||||||
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent");
|
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null);
|
||||||
|
|
||||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null);
|
/// <param name="Controllers">属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。</param>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <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="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
||||||
|
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
|
||||||
|
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
|
||||||
|
/// <param name="OperatorDetails">每个操作员是哪个账户的哪把 passkey(对不上已注册的 passkey 时名称和账户为空)。</param>
|
||||||
|
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</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, IReadOnlyList<NodeOwnerDto>? OperatorDetails = null);
|
||||||
|
|
||||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||||
|
|
||||||
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||||
@@ -67,7 +108,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string?
|
|||||||
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
|
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
|
||||||
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
||||||
|
|
||||||
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null);
|
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null);
|
||||||
|
|
||||||
/// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary>
|
/// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary>
|
||||||
public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected);
|
public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected);
|
||||||
@@ -117,9 +158,19 @@ public sealed record RawChunkMetaDto(
|
|||||||
bool Partial,
|
bool Partial,
|
||||||
int Bytes);
|
int Bytes);
|
||||||
|
|
||||||
public sealed record ApiTokenDto(string Id, string Name, string Scope, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? RevokedAt);
|
public sealed record ApiTokenDto(string Id, string Name, IReadOnlyList<string> Capabilities, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? ExpiresAt, DateTimeOffset? RevokedAt);
|
||||||
|
|
||||||
public sealed record CreateApiTokenRequest(string Name, string Scope);
|
/// <param name="Capabilities">能力列表(见 /api/admin/capabilities);写蕴含读,只能是创建者自己拥有能力的子集。</param>
|
||||||
|
/// <param name="ExpiresInDays">有效天数;缺省不过期。</param>
|
||||||
|
public sealed record CreateApiTokenRequest(string Name, IReadOnlyList<string> Capabilities, int? ExpiresInDays = null);
|
||||||
|
|
||||||
|
public sealed record CapabilityDto(string Id, bool Trust, bool Reserved, IReadOnlyList<string> Implies);
|
||||||
|
|
||||||
|
public sealed record CapabilityGroupDto(string Id, IReadOnlyList<CapabilityDto> Capabilities);
|
||||||
|
|
||||||
|
public sealed record CapabilityPresetDto(string Id, IReadOnlyList<string> Capabilities);
|
||||||
|
|
||||||
|
public sealed record CapabilityCatalogDto(IReadOnlyList<CapabilityGroupDto> Groups, IReadOnlyList<CapabilityPresetDto> Presets);
|
||||||
|
|
||||||
public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext);
|
public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext);
|
||||||
|
|
||||||
@@ -134,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))]
|
||||||
@@ -70,9 +72,22 @@ namespace MoShiro.Backend.Api;
|
|||||||
[JsonSerializable(typeof(RawChunkMetaDto))]
|
[JsonSerializable(typeof(RawChunkMetaDto))]
|
||||||
[JsonSerializable(typeof(PageDto<RawChunkMetaDto>))]
|
[JsonSerializable(typeof(PageDto<RawChunkMetaDto>))]
|
||||||
[JsonSerializable(typeof(PageDto<EventDto>))]
|
[JsonSerializable(typeof(PageDto<EventDto>))]
|
||||||
|
[JsonSerializable(typeof(PageDto<AuditDto>))]
|
||||||
[JsonSerializable(typeof(ApiTokenDto))]
|
[JsonSerializable(typeof(ApiTokenDto))]
|
||||||
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
|
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
|
||||||
[JsonSerializable(typeof(CreateApiTokenRequest))]
|
[JsonSerializable(typeof(CreateApiTokenRequest))]
|
||||||
|
[JsonSerializable(typeof(CapabilityCatalogDto))]
|
||||||
|
[JsonSerializable(typeof(PasskeyCreationOptionsDto))]
|
||||||
|
[JsonSerializable(typeof(PasskeyRequestOptionsDto))]
|
||||||
|
[JsonSerializable(typeof(RegisterPasskeyRequest))]
|
||||||
|
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||||
|
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
||||||
|
[JsonSerializable(typeof(TrustStateDto))]
|
||||||
|
[JsonSerializable(typeof(SetupStatusDto))]
|
||||||
|
[JsonSerializable(typeof(SetupRequest))]
|
||||||
|
[JsonSerializable(typeof(ProposedCredentialDto))]
|
||||||
|
[JsonSerializable(typeof(PasskeyDto))]
|
||||||
|
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||||
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
||||||
[JsonSerializable(typeof(PageDto<SettingDto>))]
|
[JsonSerializable(typeof(PageDto<SettingDto>))]
|
||||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "SettingsPatch")]
|
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "SettingsPatch")]
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public sealed record EdgePartialDto(bool First, bool Last);
|
|||||||
|
|
||||||
public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason);
|
public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason);
|
||||||
|
|
||||||
|
public sealed record AuditDto(long Id, DateTimeOffset At, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success);
|
||||||
|
|
||||||
public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail);
|
public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail);
|
||||||
|
|
||||||
public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt);
|
public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。
|
||||||
|
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/access.md 第 15 节)。
|
||||||
|
/// 角色、面板地址、代码都走命令里的环境变量。
|
||||||
|
/// </summary>
|
||||||
|
public static class InstallScripts
|
||||||
|
{
|
||||||
|
public const string Sh = "install.sh";
|
||||||
|
public const string Ps1 = "install.ps1";
|
||||||
|
|
||||||
|
/// <summary>CI 的发布公钥(<c>packaging/update/release-key.pub.pem</c>):脚本里内置的就是这一把,测试核对两边一致。</summary>
|
||||||
|
public static string ReleaseKey() => Read("release-key.pub.pem").Trim();
|
||||||
|
|
||||||
|
public static string Script(string resource) => Read(resource);
|
||||||
|
|
||||||
|
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
|
||||||
|
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
|
||||||
|
|
||||||
|
private static string Read(string resource)
|
||||||
|
{
|
||||||
|
using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource)
|
||||||
|
?? throw new InvalidOperationException($"the embedded resource {resource} is missing");
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
return reader.ReadToEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,15 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
|
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
|
||||||
|
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
|
||||||
|
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
||||||
|
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
|
||||||
|
</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>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
|
|||||||
|
|
||||||
namespace MoShiro.Backend.Api;
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、安装脚本。</summary>
|
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、一行安装脚本(<c>/install</c>、<c>/install.ps1</c>)。</summary>
|
||||||
public static class OpsEndpoints
|
public static class OpsEndpoints
|
||||||
{
|
{
|
||||||
private const string Tag = "ops";
|
private const string Tag = "ops";
|
||||||
@@ -17,12 +17,45 @@ public static class OpsEndpoints
|
|||||||
.Api("health_get", "SQLite and the raw data directory are both writable", Tag)
|
.Api("health_get", "SQLite and the raw data directory are both writable", Tag)
|
||||||
.Returns<HealthDto>().ProducesProblems(503);
|
.Returns<HealthDto>().ProducesProblems(503);
|
||||||
app.MapGet("/metrics", MetricsAsync)
|
app.MapGet("/metrics", MetricsAsync)
|
||||||
.Api("metrics_get", "Prometheus metrics (admin token)", Tag)
|
.Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag)
|
||||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
||||||
app.MapGet("/install.sh", (HttpContext http) => NotShipped(http))
|
// 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。
|
||||||
.Api("install_sh", "Installer for Linux nodes", Tag).ProducesProblems(501);
|
// 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。
|
||||||
app.MapGet("/install.ps1", (HttpContext http) => NotShipped(http))
|
app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||||
.Api("install_ps1", "Installer for Windows nodes", Tag).ProducesProblems(501);
|
.Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||||||
|
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript");
|
||||||
|
app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
// 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。
|
||||||
|
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false))
|
||||||
|
.Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag)
|
||||||
|
.Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404);
|
||||||
|
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true))
|
||||||
|
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
|
||||||
|
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
|
||||||
|
app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain"))
|
||||||
|
.Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||||||
|
.Produces(StatusCodes.Status200OK, contentType: "text/plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
|
||||||
|
{
|
||||||
|
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||||
|
InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted);
|
||||||
|
ReleaseOffer? offer = target is null ? null : await releases.GetOrFetchAsync(target.Role, http.RequestAborted);
|
||||||
|
if (offer is null)
|
||||||
|
{
|
||||||
|
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
|
return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult Static(HttpContext http, string resource, string contentType)
|
||||||
|
{
|
||||||
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
|
return TypedResults.Text(InstallScripts.Script(resource), contentType + "; charset=utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
|
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
|
||||||
@@ -37,17 +70,13 @@ public static class OpsEndpoints
|
|||||||
private static async Task<IResult> MetricsAsync(HttpContext http, OpsService ops)
|
private static async Task<IResult> MetricsAsync(HttpContext http, OpsService ops)
|
||||||
{
|
{
|
||||||
Principal principal = http.GetPrincipal();
|
Principal principal = http.GetPrincipal();
|
||||||
if (!principal.IsAdmin)
|
if (!principal.Has(Capability.OpsMetrics))
|
||||||
{
|
{
|
||||||
return principal.CanRead
|
return principal.Kind != PrincipalKind.Anonymous
|
||||||
? Problems.Create(http, MoShiroErrors.Forbidden, "an admin token is required")
|
? Problems.Create(http, MoShiroErrors.Forbidden, "the ops.metrics capability is required")
|
||||||
: Problems.Create(http, MoShiroErrors.Unauthorized, "an admin token is required");
|
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4");
|
return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>安装脚本需要下载节点程序的发布物,那属于打包与部署(M6),目前还没有。</summary>
|
|
||||||
private static IResult NotShipped(HttpContext http) =>
|
|
||||||
Problems.Create(http, StatusCodes.Status501NotImplemented, ErrorCodeHttpMap.NotImplemented.Value, "the node installer ships with packaging (M6)");
|
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ public static class LiveEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
string client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
string client = http.ClientAddress();
|
||||||
if (!limiter.TryAcquire(client))
|
if (!limiter.TryAcquire(client))
|
||||||
{
|
{
|
||||||
return Problems.Create(http, MoShiroErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client");
|
return Problems.Create(http, MoShiroErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
namespace MoShiro.Backend.Application;
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
/// <param name="Role"><c>agent</c>(默认)或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
/// <param name="Role"><c>agent</c>(默认)或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole);
|
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null);
|
||||||
|
|
||||||
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
|
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
|
||||||
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
|
||||||
|
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256);
|
||||||
|
|
||||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
||||||
|
|
||||||
|
/// <summary>安装脚本里要填的四个值。</summary>
|
||||||
|
public sealed record InstallTarget(string Role, string Server, string Code, string Image);
|
||||||
|
|
||||||
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
||||||
public sealed class JoinTokenService
|
public sealed class JoinTokenService
|
||||||
{
|
{
|
||||||
@@ -32,7 +43,7 @@ public sealed class JoinTokenService
|
|||||||
_time = time;
|
_time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
|
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var errors = new Dictionary<string, string>();
|
var errors = new Dictionary<string, string>();
|
||||||
if (request.Role is not (AgentRole or ProbeRole))
|
if (request.Role is not (AgentRole or ProbeRole))
|
||||||
@@ -40,9 +51,9 @@ public sealed class JoinTokenService
|
|||||||
errors["role"] = "must be agent or probe";
|
errors["role"] = "must be agent or probe";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.MaxUses is < 1 or > 1000)
|
if (request.MaxUses is < 1 or > 100_000)
|
||||||
{
|
{
|
||||||
errors["max_uses"] = "must be between 1 and 1000";
|
errors["max_uses"] = "must be between 1 and 100000";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.ExpiresInHours is < 1 or > 24 * 365)
|
if (request.ExpiresInHours is < 1 or > 24 * 365)
|
||||||
@@ -69,20 +80,57 @@ public sealed class JoinTokenService
|
|||||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
JoinTokenInfo info = await _store.CreateAsync(
|
JoinTokenInfo info = await _store.CreateAsync(
|
||||||
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
|
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
|
||||||
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct);
|
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
|
||||||
|
|
||||||
string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
|
||||||
// Probe 和 Agent 一样用加入令牌注册;容器节点用部署脚本,Linux 二进制用 install.sh
|
|
||||||
string shell = request.Role == ProbeRole
|
|
||||||
? $"sudo sh deploy-node.sh probe --server {baseUrl} --join {plaintext} --name <node-name>"
|
|
||||||
: $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
|
||||||
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
|
|
||||||
string powershell = request.Role == ProbeRole
|
|
||||||
? $"fleet-enroll --server {baseUrl} --join {plaintext} --name <node-name> --identity-dir <dir>"
|
|
||||||
: $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
|
|
||||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一行安装命令:先下载静态脚本、核对 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹和属主都走环境变量。
|
||||||
|
/// 里面没有任何私钥,码泄露最多让别人注册出一个节点。
|
||||||
|
/// </summary>
|
||||||
|
public static InstallCommands Commands(string baseUrl, string role, string code, InstallPlan plan)
|
||||||
|
{
|
||||||
|
string pin = plan.Genesis is null ? "" : $" MOSHIRO_GENESIS='{plan.Genesis}' MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'";
|
||||||
|
string env = $"MOSHIRO_SERVER='{baseUrl}' MOSHIRO_ROLE='{role}' MOSHIRO_CODE='{code}'{pin}";
|
||||||
|
string psPin = plan.Genesis is null ? "" : $"$env:MOSHIRO_GENESIS='{plan.Genesis}'; $env:MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'; ";
|
||||||
|
return new InstallCommands(
|
||||||
|
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | sha256sum -c - >/dev/null && {env} sh \"$f\"",
|
||||||
|
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"",
|
||||||
|
$"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; "
|
||||||
|
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。
|
||||||
|
/// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。
|
||||||
|
/// </summary>
|
||||||
|
public async ValueTask<InstallTarget?> ResolveInstallAsync(string code, string? requestBaseUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (code.Length is 0 or > 200)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct);
|
||||||
|
if (token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string role = RoleOf(token.Kind);
|
||||||
|
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
|
||||||
|
string ns = _options.ImageNamespace.Trim('/');
|
||||||
|
string image = $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}:latest";
|
||||||
|
string server = BaseUrl(requestBaseUrl);
|
||||||
|
return IsSafe(server) && IsSafe(image) && IsSafe(code) ? new InstallTarget(role, server, code, image) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>值会被放进脚本的单引号里:只允许 URL 和镜像名里正常出现的字符。</summary>
|
||||||
|
private static bool IsSafe(string value) => value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' or ':' or '/' or '[' or ']' or '~' or '%');
|
||||||
|
|
||||||
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||||
|
|
||||||
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,9 +24,16 @@ public sealed partial class AgentService
|
|||||||
|
|
||||||
private readonly NodeInfoService _nodes;
|
private readonly NodeInfoService _nodes;
|
||||||
private readonly LinkService _links;
|
private readonly LinkService _links;
|
||||||
|
private readonly TrustService _trust;
|
||||||
|
|
||||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links)
|
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;
|
||||||
_links = links;
|
_links = links;
|
||||||
_nodes = nodes;
|
_nodes = nodes;
|
||||||
_agents = agents;
|
_agents = agents;
|
||||||
@@ -145,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);
|
||||||
@@ -172,6 +181,9 @@ public sealed partial class AgentService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<Fleet.Core.SignedStatementDto> chain = await _trust.ChainAsync(ct);
|
||||||
|
result.TrustChain = chain.Count == 0 ? null : [.. chain];
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
services.AddSingleton<IngestService>();
|
services.AddSingleton<IngestService>();
|
||||||
services.AddSingleton<WindowComputer>();
|
services.AddSingleton<WindowComputer>();
|
||||||
services.AddSingleton<AuthService>();
|
services.AddSingleton<AuthService>();
|
||||||
|
services.AddSingleton<PasskeyService>();
|
||||||
|
services.AddSingleton<AuditService>();
|
||||||
|
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<TrustService>();
|
||||||
|
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
||||||
services.AddSingleton<SettingsService>();
|
services.AddSingleton<SettingsService>();
|
||||||
services.AddSingleton<ReleaseService>();
|
services.AddSingleton<ReleaseService>();
|
||||||
services.AddSingleton<ScheduleService>();
|
services.AddSingleton<ScheduleService>();
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 审计日志里的一行:谁(<see cref="Actor"/>,用户名或 <c>key:令牌名</c>)、怎么登录的(<see cref="Method"/>)、做了什么(<see cref="Action"/>,
|
||||||
|
/// 管理接口的 operationId,或 <c>auth.login</c> 这类登录事件)、对谁(<see cref="Target"/>)、结果如何。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AuditRecord(long Id, long AtMs, string? UserId, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success);
|
||||||
|
|
||||||
|
public sealed record AuditQuery(long? BeforeId, int Limit, string? Actor, string? Action);
|
||||||
|
|
||||||
|
/// <summary>审计日志只追加:没有修改和单条删除,只按保留期整体清理。</summary>
|
||||||
|
public sealed partial class AuditService
|
||||||
|
{
|
||||||
|
/// <summary>保留一年。审计记录的是谁动过信任相关的东西,比监测数据留得久。</summary>
|
||||||
|
public const int RetentionDays = 365;
|
||||||
|
|
||||||
|
private readonly IAuditStore _store;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
private readonly ILogger<AuditService> _logger;
|
||||||
|
|
||||||
|
public AuditService(IAuditStore store, TimeProvider time, ILogger<AuditService> logger)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_time = time;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>记一条。写不进去只记日志,不能因为审计失败而让已经完成的操作报错。</summary>
|
||||||
|
public async ValueTask RecordAsync(Principal principal, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default) =>
|
||||||
|
await RecordAsync(principal.UserId, principal.Label, MethodOf(principal), action, target, detail, remote, success, ct);
|
||||||
|
|
||||||
|
public async ValueTask RecordAsync(string? userId, string actor, string method, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _store.AddAsync(new AuditRecord(0, _time.GetUtcNow().ToUnixTimeMilliseconds(), userId, actor, method, action, Clip(target, 200), Clip(detail, 500), remote, success), ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
LogFailed(ex, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<IReadOnlyList<AuditRecord>> ListAsync(AuditQuery query, CancellationToken ct) => _store.ListAsync(query, ct);
|
||||||
|
|
||||||
|
public ValueTask<int> PruneAsync(long nowMs, CancellationToken ct) => _store.DeleteOlderThanAsync(nowMs - (RetentionDays * 86_400_000L), ct);
|
||||||
|
|
||||||
|
/// <summary>登录方式:密码、passkey,或 API key。</summary>
|
||||||
|
public static string MethodOf(Principal principal) =>
|
||||||
|
principal.Kind == PrincipalKind.Anonymous ? "anonymous"
|
||||||
|
: principal.Kind == PrincipalKind.Token && principal.UserId is null ? "key"
|
||||||
|
: principal.AuthMethod.Length > 0 ? principal.AuthMethod : AuthMethods.Password;
|
||||||
|
|
||||||
|
private static string? Clip(string? text, int max) => text is { } t && t.Length > max ? t[..max] : text;
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "could not write the audit record for {Action}")]
|
||||||
|
private partial void LogFailed(Exception exception, string action);
|
||||||
|
}
|
||||||
@@ -7,24 +7,25 @@ public enum PrincipalKind
|
|||||||
Token,
|
Token,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。</summary>
|
/// <summary>
|
||||||
public enum ApiScope
|
/// 已认证的主体:会话或 API key,带着它拥有的能力集合。授权只看 <see cref="Has"/>。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
||||||
{
|
{
|
||||||
None = 0,
|
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):登录方式只用于审计和显示;敏感操作靠提权窗口(触碰 passkey)。API key 为空。</summary>
|
||||||
Release = 1,
|
public string AuthMethod { get; init; } = "";
|
||||||
Read = 2,
|
|
||||||
Admin = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId)
|
/// <summary>会话的标识(令牌哈希的十六进制):API key 为空。</summary>
|
||||||
{
|
public string? SessionId { get; init; }
|
||||||
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, ApiScope.None, null, null, null, null);
|
|
||||||
|
|
||||||
public bool CanRead => Scope >= ApiScope.Read;
|
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
|
||||||
|
public string Label => Kind == PrincipalKind.Token && UserId is null ? "key:" + (Username ?? TokenId) : Username ?? "anonymous";
|
||||||
|
|
||||||
public bool IsAdmin => Scope >= ApiScope.Admin;
|
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet<string>(), null, null, null, null);
|
||||||
|
|
||||||
public bool CanPublishRelease => Scope is ApiScope.Release or ApiScope.Admin;
|
public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability);
|
||||||
|
|
||||||
|
public static IReadOnlySet<string> SetOf(IEnumerable<string> capabilities) => new HashSet<string>(capabilities, StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username);
|
public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username);
|
||||||
|
|||||||
@@ -37,11 +37,22 @@ public sealed partial class AuthService
|
|||||||
return Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials");
|
return Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await CreateSessionAsync(user, AuthMethods.Password, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>已经确认了用户身份(例如 passkey 断言通过)之后建立会话。</summary>
|
||||||
|
public async ValueTask<Result<LoginResult>> CreateSessionForAsync(string userId, string method, CancellationToken ct) =>
|
||||||
|
await _store.GetUserAsync(userId, ct) is { } user
|
||||||
|
? await CreateSessionAsync(user, method, ct)
|
||||||
|
: Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials");
|
||||||
|
|
||||||
|
private async ValueTask<Result<LoginResult>> CreateSessionAsync(UserRecord user, string method, CancellationToken ct)
|
||||||
|
{
|
||||||
byte[] token = RandomNumberGenerator.GetBytes(32);
|
byte[] token = RandomNumberGenerator.GetBytes(32);
|
||||||
byte[] csrf = RandomNumberGenerator.GetBytes(32);
|
byte[] csrf = RandomNumberGenerator.GetBytes(32);
|
||||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
long expires = now + (long)_options.SessionLifetime.TotalMilliseconds;
|
long expires = now + (long)_options.SessionLifetime.TotalMilliseconds;
|
||||||
await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires), ct);
|
await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires, method), ct);
|
||||||
await _store.DeleteExpiredSessionsAsync(now, ct);
|
await _store.DeleteExpiredSessionsAsync(now, ct);
|
||||||
return Result<LoginResult>.Ok(new LoginResult(Base64Url.Encode(token), Base64Url.Encode(csrf), expires, user.Id, user.Username));
|
return Result<LoginResult>.Ok(new LoginResult(Base64Url.Encode(token), Base64Url.Encode(csrf), expires, user.Id, user.Username));
|
||||||
}
|
}
|
||||||
@@ -60,25 +71,24 @@ 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, ApiScope.Admin, user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null);
|
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)
|
||||||
{
|
{
|
||||||
ApiTokenRecord? token = await _store.FindApiTokenAsync(TokenHash.Sha256(bearer), ct);
|
ApiTokenRecord? token = await _store.FindApiTokenAsync(TokenHash.Sha256(bearer), ct);
|
||||||
if (token is null || token.RevokedAtMs is not null)
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
|
if (token is null || token.RevokedAtMs is not null || (token.ExpiresAtMs is { } expires && expires <= now))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
||||||
if (token.LastUsedAtMs is not { } last || now - last >= TokenTouchIntervalMs)
|
if (token.LastUsedAtMs is not { } last || now - last >= TokenTouchIntervalMs)
|
||||||
{
|
{
|
||||||
await _store.TouchApiTokenAsync(token.Id, now, ct);
|
await _store.TouchApiTokenAsync(token.Id, now, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiScope scope = token.Scope switch { "admin" => ApiScope.Admin, "release" => ApiScope.Release, _ => ApiScope.Read };
|
return new Principal(PrincipalKind.Token, Principal.SetOf(token.Capabilities), null, token.Name, null, token.Id);
|
||||||
return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask LogoutAsync(string sessionToken, CancellationToken ct)
|
public async ValueTask LogoutAsync(string sessionToken, CancellationToken ct)
|
||||||
@@ -128,20 +138,48 @@ public sealed partial class AuthService
|
|||||||
|
|
||||||
public ValueTask<IReadOnlyList<UserRecord>> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct);
|
public ValueTask<IReadOnlyList<UserRecord>> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct);
|
||||||
|
|
||||||
public async ValueTask<Result<CreatedApiToken>> CreateApiTokenAsync(string name, string scope, CancellationToken ct)
|
/// <summary>
|
||||||
|
/// 创建 API key:能力必须都在目录里,并且是创建者自己拥有能力的子集(不能越权);写蕴含读,结果稳定排序。
|
||||||
|
/// <paramref name="expiresInDays"/> 为空表示不过期。
|
||||||
|
/// </summary>
|
||||||
|
public async ValueTask<Result<CreatedApiToken>> CreateApiTokenAsync(string name, IReadOnlyList<string> capabilities, int? expiresInDays, Principal creator, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var errors = new Dictionary<string, string>();
|
||||||
if (name.Length is 0 or > 64)
|
if (name.Length is 0 or > 64)
|
||||||
{
|
{
|
||||||
return ValidationError.Fail<CreatedApiToken>("name", "must be 1-64 characters");
|
errors["name"] = "must be 1-64 characters";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope is not ("read" or "admin" or "release"))
|
string[] unknown = [.. capabilities.Where(c => !CapabilityCatalog.IsKnown(c))];
|
||||||
|
if (capabilities.Count == 0)
|
||||||
{
|
{
|
||||||
return ValidationError.Fail<CreatedApiToken>("scope", "must be read, admin or release");
|
errors["capabilities"] = "choose at least one capability";
|
||||||
|
}
|
||||||
|
else if (unknown.Length > 0)
|
||||||
|
{
|
||||||
|
errors["capabilities"] = "unknown capability: " + string.Join(", ", unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresInDays is < 1 or > 3650)
|
||||||
|
{
|
||||||
|
errors["expires_in_days"] = "must be between 1 and 3650";
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<string> effective = CapabilityCatalog.Expand(capabilities.Where(CapabilityCatalog.IsKnown));
|
||||||
|
string[] beyond = [.. effective.Where(c => !creator.Has(c))];
|
||||||
|
if (beyond.Length > 0)
|
||||||
|
{
|
||||||
|
errors["capabilities"] = "you cannot grant what you do not have: " + string.Join(", ", beyond);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
{
|
||||||
|
return ValidationError.Fail<CreatedApiToken>(errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
string plaintext = TokenHash.NewToken("mos");
|
string plaintext = TokenHash.NewToken("mos");
|
||||||
var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, scope, TokenHash.Sha256(plaintext), _time.GetUtcNow().ToUnixTimeMilliseconds(), null, null);
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
|
var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, effective, TokenHash.Sha256(plaintext), now, null, expiresInDays is { } days ? now + (days * 86_400_000L) : null, null);
|
||||||
await _store.CreateApiTokenAsync(record, ct);
|
await _store.CreateApiTokenAsync(record, ct);
|
||||||
return Result<CreatedApiToken>.Ok(new CreatedApiToken(record, plaintext));
|
return Result<CreatedApiToken>.Ok(new CreatedApiToken(record, plaintext));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 能力(capability):一项具体的权限。端点声明自己需要哪一项,<see cref="Principal.Has"/> 是唯一的判断入口,
|
||||||
|
/// 会话、API key 都走它。设计见 docs/access.md 第 2 节。
|
||||||
|
/// </summary>
|
||||||
|
public static class Capability
|
||||||
|
{
|
||||||
|
/// <summary>任何已认证的主体(会话或 API key);只有登出、查看自己这类端点用它。</summary>
|
||||||
|
public const string Authenticated = "authenticated";
|
||||||
|
|
||||||
|
public const string PublicRead = "public.read";
|
||||||
|
|
||||||
|
public const string FleetRead = "fleet.read";
|
||||||
|
public const string FleetWrite = "fleet.write";
|
||||||
|
public const string FleetEnroll = "fleet.enroll";
|
||||||
|
public const string FleetRevoke = "fleet.revoke";
|
||||||
|
|
||||||
|
public const string TasksRead = "tasks.read";
|
||||||
|
public const string TasksWrite = "tasks.write";
|
||||||
|
|
||||||
|
public const string DataRead = "data.read";
|
||||||
|
public const string DataWrite = "data.write";
|
||||||
|
|
||||||
|
public const string SettingsRead = "settings.read";
|
||||||
|
public const string SettingsWrite = "settings.write";
|
||||||
|
|
||||||
|
public const string UpdateRead = "update.read";
|
||||||
|
public const string UpdateConfirm = "update.confirm";
|
||||||
|
public const string ReleasePublish = "release.publish";
|
||||||
|
|
||||||
|
public const string KeysRead = "keys.read";
|
||||||
|
public const string KeysManage = "keys.manage";
|
||||||
|
|
||||||
|
public const string AccountsRead = "accounts.read";
|
||||||
|
public const string AccountsManage = "accounts.manage";
|
||||||
|
|
||||||
|
public const string AuditRead = "audit.read";
|
||||||
|
public const string EventsRead = "events.read";
|
||||||
|
public const string OpsMetrics = "ops.metrics";
|
||||||
|
|
||||||
|
// 预留:外部 webhook 回调和数据拉取(docs/access.md 5.4),目录里先存在,实现放后面
|
||||||
|
public const string DataExport = "data.export";
|
||||||
|
public const string WebhooksRead = "webhooks.read";
|
||||||
|
public const string WebhooksManage = "webhooks.manage";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 CapabilityPreset(string Id, IReadOnlyList<string> Capabilities);
|
||||||
|
|
||||||
|
/// <summary>能力目录:分组、依赖(写蕴含读)、预设,以及创建 API key 时的校验。</summary>
|
||||||
|
public static class CapabilityCatalog
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<CapabilityInfo> All { get; } =
|
||||||
|
[
|
||||||
|
new(Capability.PublicRead, "public"),
|
||||||
|
|
||||||
|
new(Capability.FleetRead, "fleet"),
|
||||||
|
new(Capability.FleetWrite, "fleet", false, false, Capability.FleetRead),
|
||||||
|
new(Capability.FleetEnroll, "fleet", false, false, Capability.FleetRead),
|
||||||
|
new(Capability.FleetRevoke, "fleet", true, false, Capability.FleetRead),
|
||||||
|
|
||||||
|
new(Capability.TasksRead, "tasks"),
|
||||||
|
new(Capability.TasksWrite, "tasks", false, false, Capability.TasksRead),
|
||||||
|
|
||||||
|
new(Capability.DataRead, "data"),
|
||||||
|
new(Capability.DataWrite, "data", false, false, Capability.DataRead),
|
||||||
|
new(Capability.DataExport, "data", false, true, Capability.DataRead),
|
||||||
|
|
||||||
|
new(Capability.SettingsRead, "settings"),
|
||||||
|
new(Capability.SettingsWrite, "settings", false, false, Capability.SettingsRead),
|
||||||
|
|
||||||
|
new(Capability.UpdateRead, "update"),
|
||||||
|
new(Capability.UpdateConfirm, "update", false, false, Capability.UpdateRead),
|
||||||
|
new(Capability.ReleasePublish, "update"),
|
||||||
|
|
||||||
|
new(Capability.KeysRead, "keys"),
|
||||||
|
new(Capability.KeysManage, "keys", false, false, Capability.KeysRead),
|
||||||
|
|
||||||
|
new(Capability.AccountsRead, "accounts"),
|
||||||
|
new(Capability.AccountsManage, "accounts", true, false, Capability.AccountsRead),
|
||||||
|
|
||||||
|
new(Capability.EventsRead, "ops"),
|
||||||
|
new(Capability.AuditRead, "ops"),
|
||||||
|
new(Capability.OpsMetrics, "ops"),
|
||||||
|
|
||||||
|
new(Capability.WebhooksRead, "integrations", false, true),
|
||||||
|
new(Capability.WebhooksManage, "integrations", false, true, Capability.WebhooksRead),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, CapabilityInfo> s_byId = All.ToDictionary(c => c.Id, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> Groups { get; } = [.. All.Select(c => c.Group).Distinct()];
|
||||||
|
|
||||||
|
public static IReadOnlyList<CapabilityPreset> Presets { get; } =
|
||||||
|
[
|
||||||
|
new("read-only", [.. All.Where(c => !c.Reserved && c.Id.EndsWith(".read", StringComparison.Ordinal)).Select(c => c.Id)]),
|
||||||
|
new("operations", Expand([Capability.PublicRead, Capability.FleetWrite, Capability.TasksWrite, Capability.DataWrite, Capability.SettingsWrite, Capability.UpdateConfirm, Capability.EventsRead, Capability.AuditRead])),
|
||||||
|
new("enroll", Expand([Capability.FleetEnroll])),
|
||||||
|
new("ci-release", [Capability.ReleasePublish]),
|
||||||
|
new("all", [.. All.Select(c => c.Id)]),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static bool IsKnown(string id) => s_byId.ContainsKey(id);
|
||||||
|
|
||||||
|
public static CapabilityInfo? Find(string id) => s_byId.GetValueOrDefault(id);
|
||||||
|
|
||||||
|
/// <summary>补上蕴含的能力(写蕴含读),去重并排序,结果稳定。</summary>
|
||||||
|
public static IReadOnlyList<string> Expand(IEnumerable<string> ids)
|
||||||
|
{
|
||||||
|
var result = new SortedSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (string id in ids)
|
||||||
|
{
|
||||||
|
Add(result, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. result];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Add(SortedSet<string> set, string id)
|
||||||
|
{
|
||||||
|
if (id is Capability.Authenticated || !set.Add(id) || !s_byId.TryGetValue(id, out CapabilityInfo? info))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string implied in info.Implies)
|
||||||
|
{
|
||||||
|
Add(set, implied);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 登录会话的能力:全部,包括信任类,密码登录和 passkey 登录一样。区别在于敏感操作:信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>),
|
||||||
|
/// 窗口要当场触碰 passkey 才能开,所以密码泄露的人做不了这些,日常管理却不需要每次触碰。
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
|
||||||
|
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ public sealed class LoginRateLimiter
|
|||||||
_time = time;
|
_time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>登记一次尝试;超限时返回 false 与建议的重试等待秒数。</summary>
|
/// <summary>登记一次尝试;超限时返回 false 与建议的重试等待秒数。<paramref name="limit"/> 缺省是登录失败的额度(每分钟 <see cref="BackendOptions.LoginAttemptsPerMinute"/> 次)。</summary>
|
||||||
public bool TryAcquire(string source, out int retryAfterSeconds)
|
public bool TryAcquire(string source, out int retryAfterSeconds, int? limit = null)
|
||||||
{
|
{
|
||||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
Queue<long> queue = _attempts.GetOrAdd(source, _ => new Queue<long>());
|
Queue<long> queue = _attempts.GetOrAdd(source, _ => new Queue<long>());
|
||||||
@@ -27,7 +27,7 @@ public sealed class LoginRateLimiter
|
|||||||
queue.Dequeue();
|
queue.Dequeue();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queue.Count >= _options.LoginAttemptsPerMinute)
|
if (queue.Count >= (limit ?? _options.LoginAttemptsPerMinute))
|
||||||
{
|
{
|
||||||
retryAfterSeconds = (int)Math.Max(1, (60_000 - (now - queue.Peek()) + 999) / 1000);
|
retryAfterSeconds = (int)Math.Max(1, (60_000 - (now - queue.Peek()) + 999) / 1000);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Fleet.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
public sealed record PasskeyRecord(string Id, string UserId, string Name, byte[] PublicKeySpki, long SignCount, IReadOnlyList<string> Transports, long CreatedAtMs, long? LastUsedAtMs);
|
||||||
|
|
||||||
|
/// <summary>这次请求的 WebAuthn 依赖方:<c>RpId</c> 是域名,<c>Origins</c> 是浏览器会填进 <c>clientDataJSON.origin</c> 的来源。</summary>
|
||||||
|
public sealed record PasskeyContext(string RpId, IReadOnlySet<string> Origins);
|
||||||
|
|
||||||
|
public sealed record CreationOptions(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
|
||||||
|
|
||||||
|
public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs, IReadOnlyList<string>? AllowCredentials = null);
|
||||||
|
|
||||||
|
public sealed record PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
|
||||||
|
|
||||||
|
public sealed record PasskeyAssertion(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// passkey 的注册与登录(WebAuthn,ES256,attestation 当 none)。挑战只在内存里,一次有效、5 分钟过期;
|
||||||
|
/// 登录是「可发现凭证」:不需要先输用户名,凭证 id 找到用户。验证断言的纯函数在 <see cref="WebAuthnAssertion"/>(节点以后也用)。
|
||||||
|
/// 设计见 docs/access.md 第 3 节。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PasskeyService
|
||||||
|
{
|
||||||
|
private const int ChallengeBytes = 32;
|
||||||
|
private const int MaxPasskeysPerUser = 10;
|
||||||
|
private const int MaxPendingChallenges = 4096;
|
||||||
|
private static readonly TimeSpan s_challengeLifetime = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private readonly IAuthStore _store;
|
||||||
|
private readonly AuthService _auth;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<string, PendingChallenge> _challenges = [];
|
||||||
|
|
||||||
|
private sealed record PendingChallenge(string Purpose, string? UserId, long ExpiresAtMs);
|
||||||
|
|
||||||
|
private const string RegisterPurpose = "register";
|
||||||
|
private const string LoginPurpose = "login";
|
||||||
|
|
||||||
|
public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_auth = auth;
|
||||||
|
_time = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<CreationOptions>> BeginRegistrationAsync(Principal principal, PasskeyContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (principal.UserId is not { } userId || principal.Username is not { } username)
|
||||||
|
{
|
||||||
|
return Result<CreationOptions>.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token");
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<PasskeyRecord> existing = await _store.ListPasskeysAsync(userId, ct);
|
||||||
|
if (existing.Count >= MaxPasskeysPerUser)
|
||||||
|
{
|
||||||
|
return ValidationError.Fail<CreationOptions>("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user");
|
||||||
|
}
|
||||||
|
|
||||||
|
string challenge = Issue(RegisterPurpose, userId);
|
||||||
|
return Result<CreationOptions>.Ok(new CreationOptions(challenge, context.RpId, "MoShiro", Base64Url.Encode(Encoding.UTF8.GetBytes(userId)), username, [.. existing.Select(p => p.Id)], 60_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<PasskeyRecord>> RegisterAsync(Principal principal, PasskeyRegistration request, PasskeyContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (principal.UserId is not { } userId)
|
||||||
|
{
|
||||||
|
return Result<PasskeyRecord>.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token");
|
||||||
|
}
|
||||||
|
|
||||||
|
string name = request.Name.Trim();
|
||||||
|
if (name.Length is 0 or > 64)
|
||||||
|
{
|
||||||
|
return ValidationError.Fail<PasskeyRecord>("name", "must be 1-64 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryDecode(request.AttestationObject, out byte[] attestation) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.CredentialId, out byte[] id))
|
||||||
|
{
|
||||||
|
return ValidationError.Fail<PasskeyRecord>("credential", "malformed registration");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挑战在 clientDataJSON 里:先取出来,一次有效
|
||||||
|
if (!TryTakeChallenge(clientData, RegisterPurpose, userId, out byte[] challenge))
|
||||||
|
{
|
||||||
|
return Result<PasskeyRecord>.Fail(MoShiroErrors.ValidationFailed, "the registration challenge is unknown, expired or already used");
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisteredCredential? credential = WebAuthnRegistration.Verify(attestation, clientData, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true);
|
||||||
|
if (credential is null || !credential.CredentialId.AsSpan().SequenceEqual(id))
|
||||||
|
{
|
||||||
|
return Result<PasskeyRecord>.Fail(MoShiroErrors.ValidationFailed, "the registration could not be verified (ES256 with user verification is required)");
|
||||||
|
}
|
||||||
|
|
||||||
|
string credentialId = Base64Url.Encode(credential.CredentialId);
|
||||||
|
var record = new PasskeyRecord(credentialId, userId, name, credential.PublicKeySpki, credential.SignCount, request.Transports?.Take(8).ToArray() ?? [], _time.GetUtcNow().ToUnixTimeMilliseconds(), null);
|
||||||
|
return await _store.AddPasskeyAsync(record, ct)
|
||||||
|
? Result<PasskeyRecord>.Ok(record)
|
||||||
|
: Result<PasskeyRecord>.Fail(MoShiroErrors.NameConflict, "this passkey is already registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(LoginPurpose, null), context.RpId, 60_000);
|
||||||
|
|
||||||
|
/// <summary>验证断言并建立会话。失败一律 unauthorized,不区分原因。</summary>
|
||||||
|
public async ValueTask<Result<LoginResult>> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
PasskeyRecord? passkey = await VerifyAsync(request, context, LoginPurpose, null, ct);
|
||||||
|
return passkey is null
|
||||||
|
? Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials")
|
||||||
|
: await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。</summary>
|
||||||
|
private async ValueTask<PasskeyRecord?> VerifyAsync(PasskeyAssertion request, PasskeyContext context, string purpose, string? userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryTakeChallenge(clientData, purpose, userId, out byte[] challenge))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasskeyRecord? passkey = await _store.FindPasskeyAsync(request.CredentialId, ct);
|
||||||
|
if (passkey is null || !WebAuthnAssertion.Verify(passkey.PublicKeySpki, authData, clientData, signature, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true, out uint signCount))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计数器只增不减;很多同步的 passkey 一直报 0,两边都是 0 时不检查
|
||||||
|
if ((signCount != 0 || passkey.SignCount != 0) && signCount <= passkey.SignCount)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _store.TouchPasskeyAsync(passkey.Id, signCount, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||||
|
return passkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<IReadOnlyList<PasskeyRecord>> ListAsync(string userId, CancellationToken ct) => _store.ListPasskeysAsync(userId, ct);
|
||||||
|
|
||||||
|
public ValueTask<bool> DeleteAsync(string userId, string passkeyId, CancellationToken ct) => _store.DeletePasskeyAsync(userId, passkeyId, ct);
|
||||||
|
|
||||||
|
private string Issue(string purpose, string? userId)
|
||||||
|
{
|
||||||
|
byte[] challenge = RandomNumberGenerator.GetBytes(ChallengeBytes);
|
||||||
|
string key = Base64Url.Encode(challenge);
|
||||||
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_challenges.Count >= MaxPendingChallenges)
|
||||||
|
{
|
||||||
|
foreach (string stale in _challenges.Where(p => p.Value.ExpiresAtMs <= now).Select(p => p.Key).ToList())
|
||||||
|
{
|
||||||
|
_challenges.Remove(stale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_challenges.Count >= MaxPendingChallenges)
|
||||||
|
{
|
||||||
|
_challenges.Remove(_challenges.OrderBy(p => p.Value.ExpiresAtMs).First().Key); // 淘汰最旧的
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_challenges[key] = new PendingChallenge(purpose, userId, now + (long)s_challengeLifetime.TotalMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。</summary>
|
||||||
|
private bool TryTakeChallenge(byte[] clientDataJson, string purpose, string? userId, out byte[] challenge)
|
||||||
|
{
|
||||||
|
challenge = [];
|
||||||
|
string? text;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = System.Text.Json.JsonDocument.Parse(clientDataJson);
|
||||||
|
text = document.RootElement.GetProperty("challenge").GetString();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is System.Text.Json.JsonException or KeyNotFoundException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text is null || !TryDecode(text, out byte[] raw))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_challenges.Remove(Base64Url.Encode(raw), out PendingChallenge? pending) || pending.ExpiresAtMs <= now || pending.Purpose != purpose || pending.UserId != userId)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge = raw;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDecode(string text, out byte[] data) => Base64Url.TryDecode(text, out data) && data.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AuthMethods
|
||||||
|
{
|
||||||
|
public const string Password = "password";
|
||||||
|
public const string Passkey = "passkey";
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Fleet.Identity;
|
||||||
|
using Fleet.Server;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务器的身份密钥(P-256):操作员用 passkey 把公钥签进操作员集合,之后节点只认带这把密钥签名的服务器,
|
||||||
|
/// 并在这条链路上用应用层加密(TLS 在 Cloudflare 或被冒充的证书那里终止时,中间人拿不出这把密钥)。
|
||||||
|
/// 私钥存在 Backend 的设置里:它保护的是「面板被冒充」,不是「面板被攻破」;面板被攻破时它也会泄露,那时要由 passkey 签一个换掉它的新集合版本。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerIdentityService : IServerIdentity, IDisposable
|
||||||
|
{
|
||||||
|
private const string Key = "server.identity_key";
|
||||||
|
|
||||||
|
private readonly ISettingsStore _settings;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private ECDsa? _key;
|
||||||
|
private byte[]? _spki;
|
||||||
|
|
||||||
|
public ServerIdentityService(ISettingsStore settings)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] PublicKeySpki
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _spki!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>公钥的指纹(SHA-256,小写十六进制):页面上给人核对。</summary>
|
||||||
|
public string Fingerprint => Convert.ToHexStringLower(SHA256.HashData(PublicKeySpki));
|
||||||
|
|
||||||
|
public byte[] Sign(ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _key!.SignData(payload, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>启动时调用:读出或生成密钥。节点可能在启动完成之前就连上来,所以 <see cref="Sign"/> 也会按需加载。</summary>
|
||||||
|
public ValueTask EnsureAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureLoaded(ct);
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureLoaded(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_key is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_key is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? stored = _settings.GetAsync(Key, ct).AsTask().GetAwaiter().GetResult();
|
||||||
|
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
if (stored is not null)
|
||||||
|
{
|
||||||
|
key.ImportPkcs8PrivateKey(Convert.FromBase64String(stored), out _);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_settings.SetAsync(Key, Convert.ToBase64String(key.ExportPkcs8PrivateKey()), ct).AsTask().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
_spki = key.ExportSubjectPublicKeyInfo();
|
||||||
|
_key = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _key?.Dispose();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Fleet.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
|
using Fleet.Server;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>要操作员用 passkey 签的下一版操作员集合:<c>Challenge</c> 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。</summary>
|
||||||
|
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>
|
||||||
|
/// <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, IReadOnlyList<NodeOwner>? OperatorDetails = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
|
||||||
|
/// Backend <b>不持有任何签名的能力</b>:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链,
|
||||||
|
/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/access.md 第 7 节。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TrustService : IOperatorTrustSource, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IOperatorSetStore _store;
|
||||||
|
private readonly IAuthStore _auth;
|
||||||
|
private readonly ServerIdentityService _server;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
|
public TrustService(IOperatorSetStore store, IAuthStore auth, ServerIdentityService server, TimeProvider time)
|
||||||
|
{
|
||||||
|
_server = server;
|
||||||
|
_store = store;
|
||||||
|
_auth = auth;
|
||||||
|
_time = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _gate.Dispose();
|
||||||
|
|
||||||
|
public ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||||
|
|
||||||
|
/// <summary>当前生效的操作员集合(从创世验过的链);还没有建立信任根返回 null。</summary>
|
||||||
|
public async ValueTask<OperatorSet?> CurrentAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyList<SignedStatementDto> chain = await _store.ListAsync(ct);
|
||||||
|
return chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<TrustState> StateAsync(PasskeyContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyList<SignedStatementDto> chain = await _store.ListAsync(ct);
|
||||||
|
OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]);
|
||||||
|
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
|
||||||
|
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
|
||||||
|
bool inSync = current is not null && proposed is not null && Same(current, proposed);
|
||||||
|
// 每个操作员是哪个账户的哪把 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)
|
||||||
|
{
|
||||||
|
IReadOnlyList<PasskeyRecord> passkeys = await _auth.ListAllPasskeysAsync(ct);
|
||||||
|
if (passkeys.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = new OperatorSet(
|
||||||
|
(current?.Version ?? 0) + 1,
|
||||||
|
chain.Count == 0 ? OperatorTrust.Genesis : OperatorTrust.Fingerprint(chain[^1].Statement),
|
||||||
|
context.RpId,
|
||||||
|
[.. context.Origins.Order(StringComparer.Ordinal)],
|
||||||
|
[.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))],
|
||||||
|
[_server.PublicKeySpki]);
|
||||||
|
string statement = OperatorTrust.Encode(next);
|
||||||
|
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) =>
|
||||||
|
a.RpId == b.RpId && a.Origins.Order(StringComparer.Ordinal).SequenceEqual(b.Origins.Order(StringComparer.Ordinal))
|
||||||
|
&& a.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal).SequenceEqual(b.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal))
|
||||||
|
&& (a.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((b.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
|
||||||
|
/// 谁能调用由端点的能力把关;这里不看调用者,信任来自节点自己验证的 passkey 签名。
|
||||||
|
/// </summary>
|
||||||
|
public async ValueTask<Result<TrustState>> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _gate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IReadOnlyList<SignedStatementDto> chain = await _store.ListAsync(ct);
|
||||||
|
OperatorSet? set = OperatorTrust.ParseSet(signed.Statement);
|
||||||
|
if (set is null)
|
||||||
|
{
|
||||||
|
return ValidationError.Fail<TrustState>("statement", "is not an operator set");
|
||||||
|
}
|
||||||
|
|
||||||
|
OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]);
|
||||||
|
if (set.Version != (current?.Version ?? 0) + 1)
|
||||||
|
{
|
||||||
|
return Result<TrustState>.Fail(MoShiroErrors.NameConflict, $"the operator set is at version {current?.Version ?? 0}; sign version {(current?.Version ?? 0) + 1}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (set.RpId != context.RpId || set.Origins.Any(o => !context.Origins.Contains(o)))
|
||||||
|
{
|
||||||
|
return ValidationError.Fail<TrustState>("statement", "the relying party or origins do not match this panel");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (set.Servers is not { Count: > 0 } || !set.Servers.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)))
|
||||||
|
{
|
||||||
|
// 集合里没有这个面板的服务器密钥:签下去之后固定了它的节点就认不出这个面板了
|
||||||
|
return ValidationError.Fail<TrustState>("statement", "must list this panel's server key");
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<PasskeyRecord> registered = await _auth.ListAllPasskeysAsync(ct);
|
||||||
|
if (set.Keys.Any(k => !registered.Any(p => p.Id == k.CredentialId && p.PublicKeySpki.AsSpan().SequenceEqual(k.PublicKeySpki))))
|
||||||
|
{
|
||||||
|
return ValidationError.Fail<TrustState>("statement", "lists a passkey that is not registered on this panel");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SignedStatement> extended = [.. chain.Select(c => c.ToSigned()), signed.ToSigned()];
|
||||||
|
string genesis = OperatorTrust.Fingerprint(extended[0].Statement);
|
||||||
|
if (OperatorTrust.VerifyChain(genesis, extended) is not { } verified || verified.Version != set.Version)
|
||||||
|
{
|
||||||
|
return Result<TrustState>.Fail(MoShiroErrors.Forbidden, "the operator set is not signed by a passkey of the previous version");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _store.AddAsync(set.Version, signed, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct))
|
||||||
|
{
|
||||||
|
return Result<TrustState>.Fail(MoShiroErrors.NameConflict, "this operator set version already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<TrustState>.Ok(await StateAsync(context, ct));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Fleet.Identity;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>注册成功后要保存的凭证。</summary>
|
||||||
|
public sealed record RegisteredCredential(byte[] CredentialId, byte[] PublicKeySpki, uint SignCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析并校验 WebAuthn 注册(<c>navigator.credentials.create</c> 的结果)。只接受 ES256;attestation 一律当 <c>none</c> 处理:
|
||||||
|
/// 不验设备证明,信任浏览器的实现(docs/access.md)。CBOR 解析只在 Backend 里,节点不带。
|
||||||
|
/// </summary>
|
||||||
|
public static class WebAuthnRegistration
|
||||||
|
{
|
||||||
|
public static RegisteredCredential? Verify(
|
||||||
|
ReadOnlySpan<byte> attestationObject, ReadOnlySpan<byte> clientDataJson, string rpId, ReadOnlySpan<byte> expectedChallenge,
|
||||||
|
Func<string, bool> originAllowed, bool requireUserVerification)
|
||||||
|
{
|
||||||
|
if (!WebAuthnAssertion.ClientDataValid(clientDataJson, "webauthn.create", expectedChallenge, originAllowed))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (new MiniCbor(attestationObject.ToArray()).ReadValue() is not Dictionary<object, object> attestation
|
||||||
|
|| attestation.GetValueOrDefault("authData") is not byte[] authData || authData.Length < 55)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CryptographicOperations.FixedTimeEquals(authData.AsSpan(0, 32), SHA256.HashData(Encoding.UTF8.GetBytes(rpId))))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte flags = authData[32];
|
||||||
|
const byte attestedData = 0x40;
|
||||||
|
if ((flags & WebAuthnAssertion.FlagUserPresent) == 0 || (flags & attestedData) == 0 || (requireUserVerification && (flags & WebAuthnAssertion.FlagUserVerified) == 0))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint signCount = (uint)((authData[33] << 24) | (authData[34] << 16) | (authData[35] << 8) | authData[36]);
|
||||||
|
int idLength = (authData[53] << 8) | authData[54];
|
||||||
|
if (idLength is 0 or > 1023 || authData.Length < 55 + idLength)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] credentialId = authData[55..(55 + idLength)];
|
||||||
|
var reader = new MiniCbor(authData[(55 + idLength)..]);
|
||||||
|
if (reader.ReadValue() is not Dictionary<object, object> cose
|
||||||
|
|| cose.GetValueOrDefault(1L) is not long kty || kty != 2
|
||||||
|
|| cose.GetValueOrDefault(3L) is not long alg || alg != -7
|
||||||
|
|| cose.GetValueOrDefault(-1L) is not long curve || curve != 1
|
||||||
|
|| cose.GetValueOrDefault(-2L) is not byte[] { Length: 32 } x
|
||||||
|
|| cose.GetValueOrDefault(-3L) is not byte[] { Length: 32 } y)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using ECDsa key = ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = x, Y = y } });
|
||||||
|
return new RegisteredCredential(credentialId, key.ExportSubjectPublicKeyInfo(), signCount);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is InvalidDataException or CryptographicException or ArgumentException or IndexOutOfRangeException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>够用的 CBOR 读取器(RFC 8949):整数、字节串、文本串、数组、映射、布尔和 null。输入有上限,嵌套有深度限制。</summary>
|
||||||
|
internal sealed class MiniCbor
|
||||||
|
{
|
||||||
|
private const int MaxDepth = 8;
|
||||||
|
private const int MaxItems = 64;
|
||||||
|
|
||||||
|
private readonly byte[] _data;
|
||||||
|
private int _position;
|
||||||
|
|
||||||
|
public MiniCbor(byte[] data)
|
||||||
|
{
|
||||||
|
_data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ReadValue(int depth = 0)
|
||||||
|
{
|
||||||
|
if (depth > MaxDepth || _position >= _data.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("cbor too deep or truncated");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte initial = _data[_position++];
|
||||||
|
int major = initial >> 5;
|
||||||
|
long argument = ReadArgument(initial & 0x1F);
|
||||||
|
switch (major)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
return argument;
|
||||||
|
case 1:
|
||||||
|
return -1 - argument;
|
||||||
|
case 2:
|
||||||
|
return Take(argument);
|
||||||
|
case 3:
|
||||||
|
return Encoding.UTF8.GetString(Take(argument));
|
||||||
|
case 4:
|
||||||
|
{
|
||||||
|
CheckCount(argument);
|
||||||
|
var list = new List<object?>();
|
||||||
|
for (long i = 0; i < argument; i++)
|
||||||
|
{
|
||||||
|
list.Add(ReadValue(depth + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 5:
|
||||||
|
{
|
||||||
|
CheckCount(argument);
|
||||||
|
var map = new Dictionary<object, object>();
|
||||||
|
for (long i = 0; i < argument; i++)
|
||||||
|
{
|
||||||
|
object key = ReadValue(depth + 1) ?? throw new InvalidDataException("null map key");
|
||||||
|
map[key] = ReadValue(depth + 1)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 7:
|
||||||
|
return initial switch { 0xF4 => false, 0xF5 => true, 0xF6 => null, _ => throw new InvalidDataException("unsupported simple value") };
|
||||||
|
default:
|
||||||
|
throw new InvalidDataException("unsupported cbor type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long ReadArgument(int info)
|
||||||
|
{
|
||||||
|
if (info < 24)
|
||||||
|
{
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
int length = info switch { 24 => 1, 25 => 2, 26 => 4, 27 => 8, _ => throw new InvalidDataException("indefinite lengths are not supported") };
|
||||||
|
byte[] bytes = Take(length);
|
||||||
|
long value = 0;
|
||||||
|
foreach (byte b in bytes)
|
||||||
|
{
|
||||||
|
value = (value << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value < 0 ? throw new InvalidDataException("argument too large") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] Take(long length)
|
||||||
|
{
|
||||||
|
if (length < 0 || length > _data.Length - _position)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("cbor truncated");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] result = _data[_position..(_position + (int)length)];
|
||||||
|
_position += (int)length;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CheckCount(long count)
|
||||||
|
{
|
||||||
|
if (count > MaxItems)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("cbor collection too large");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,17 +12,37 @@ public sealed class BackendOptions
|
|||||||
/// <summary>容器使用的 OCI repository namespace;Compose 默认 moshiro,开发镜像使用 moshiro-dev。</summary>
|
/// <summary>容器使用的 OCI repository namespace;Compose 默认 moshiro,开发镜像使用 moshiro-dev。</summary>
|
||||||
public string ImageNamespace { get; set; } = "";
|
public string ImageNamespace { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>节点镜像所在的仓库地址(管理端「添加 Agent / Probe」生成的容器命令用它拼镜像名)。</summary>
|
||||||
|
public string NodeImageRegistry { get; set; } = "registry.oeo.one";
|
||||||
|
|
||||||
/// <summary>容器内更新请求目录;留空使用数据目录下的 update/。</summary>
|
/// <summary>容器内更新请求目录;留空使用数据目录下的 update/。</summary>
|
||||||
public string? BackendUpdateDirectory { get; set; }
|
public string? BackendUpdateDirectory { get; set; }
|
||||||
|
|
||||||
/// <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; }
|
||||||
|
|
||||||
public int Pbkdf2Iterations { get; set; } = 600_000;
|
public int Pbkdf2Iterations { get; set; } = 600_000;
|
||||||
|
|
||||||
|
/// <summary>WebAuthn 的依赖方域名;缺省取 <see cref="PublicUrl"/> 的主机,没有就取请求的主机。passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。</summary>
|
||||||
|
public string? WebAuthnRpId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>允许的来源(逗号分隔,如 <c>https://panel.example.com</c>);缺省是 <see cref="PublicUrl"/> 的来源,没有就取请求自己的来源。</summary>
|
||||||
|
public string? WebAuthnOrigins { get; set; }
|
||||||
|
|
||||||
/// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
|
/// <summary>会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。</summary>
|
||||||
public bool SecureCookies { get; set; } = true;
|
public bool SecureCookies { get; set; } = true;
|
||||||
|
|
||||||
@@ -32,6 +52,15 @@ public sealed class BackendOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CorsOrigins { get; set; }
|
public string? CorsOrigins { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 可信的反向代理(逗号分隔的 IP 或 CIDR,如 <c>127.0.0.1,172.16.0.0/12</c>)。只有从这些地址连进来的请求,才采信
|
||||||
|
/// <see cref="TrustedProxyHeader"/> 里的客户端地址(用于登录限流、审计和 SSE 限制);空 = 不信任任何转发头,用套接字地址。
|
||||||
|
/// </summary>
|
||||||
|
public string? TrustedProxies { get; set; }
|
||||||
|
|
||||||
|
/// <summary>携带客户端地址的头,缺省 <c>X-Forwarded-For</c>(Cloudflare 单独的 <c>CF-Connecting-IP</c> 也可以填这里)。</summary>
|
||||||
|
public string? TrustedProxyHeader { get; set; }
|
||||||
|
|
||||||
public TimeSpan SessionLifetime { get; set; } = TimeSpan.FromHours(12);
|
public TimeSpan SessionLifetime { get; set; } = TimeSpan.FromHours(12);
|
||||||
|
|
||||||
public int LoginAttemptsPerMinute { get; set; } = 10;
|
public int LoginAttemptsPerMinute { get; set; } = 10;
|
||||||
@@ -79,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; }
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ public sealed record AgentRecord(
|
|||||||
bool PublicHostManual = false,
|
bool PublicHostManual = false,
|
||||||
bool Ipv6HostManual = false,
|
bool Ipv6HostManual = false,
|
||||||
IReadOnlyList<string>? ReportedAddresses = null,
|
IReadOnlyList<string>? ReportedAddresses = null,
|
||||||
byte[]? TlsPublicKey = null);
|
byte[]? TlsPublicKey = null,
|
||||||
|
string? AddedBy = null);
|
||||||
|
|
||||||
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
|
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
|
||||||
public sealed class AgentPatch
|
public sealed class AgentPatch
|
||||||
@@ -76,10 +77,11 @@ public sealed record JoinTokenInfo(
|
|||||||
long ExpiresAtMs,
|
long ExpiresAtMs,
|
||||||
long? RevokedAtMs,
|
long? RevokedAtMs,
|
||||||
long CreatedAtMs,
|
long CreatedAtMs,
|
||||||
string Kind = "");
|
string Kind = "",
|
||||||
|
string? CreatedBy = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
|
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
|
||||||
/// Agent 的 <c>peers</c> 里放的是后者。
|
/// Agent 的 <c>peers</c> 里放的是后者。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null);
|
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null);
|
||||||
@@ -2,9 +2,9 @@ namespace MoShiro.Backend.Application;
|
|||||||
|
|
||||||
public sealed record UserRecord(string Id, string Username, byte[] PasswordHash, byte[] PasswordSalt, int Iterations, long CreatedAtMs);
|
public sealed record UserRecord(string Id, string Username, byte[] PasswordHash, byte[] PasswordSalt, int Iterations, long CreatedAtMs);
|
||||||
|
|
||||||
public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs);
|
public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs, string AuthMethod = AuthMethods.Password);
|
||||||
|
|
||||||
public sealed record ApiTokenRecord(string Id, string Name, string Scope, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? RevokedAtMs);
|
public sealed record ApiTokenRecord(string Id, string Name, IReadOnlyList<string> Capabilities, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? ExpiresAtMs, long? RevokedAtMs);
|
||||||
|
|
||||||
public interface IAuthStore
|
public interface IAuthStore
|
||||||
{
|
{
|
||||||
@@ -31,6 +31,21 @@ public interface IAuthStore
|
|||||||
|
|
||||||
ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct);
|
ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>凭证 id 已经存在时返回 false。</summary>
|
||||||
|
ValueTask<bool> AddPasskeyAsync(PasskeyRecord passkey, CancellationToken ct);
|
||||||
|
|
||||||
|
ValueTask<PasskeyRecord?> FindPasskeyAsync(string credentialId, CancellationToken ct);
|
||||||
|
|
||||||
|
ValueTask<IReadOnlyList<PasskeyRecord>> ListPasskeysAsync(string userId, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>所有用户的 passkey(操作员集合从这里取)。</summary>
|
||||||
|
ValueTask<IReadOnlyList<PasskeyRecord>> ListAllPasskeysAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>只能删自己的。</summary>
|
||||||
|
ValueTask<bool> DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct);
|
||||||
|
|
||||||
|
ValueTask TouchPasskeyAsync(string credentialId, long signCount, long nowMs, CancellationToken ct);
|
||||||
|
|
||||||
ValueTask<ApiTokenRecord?> FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct);
|
ValueTask<ApiTokenRecord?> FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct);
|
||||||
|
|
||||||
ValueTask<IReadOnlyList<ApiTokenRecord>> ListApiTokensAsync(CancellationToken ct);
|
ValueTask<IReadOnlyList<ApiTokenRecord>> ListApiTokensAsync(CancellationToken ct);
|
||||||
|
|||||||
@@ -54,10 +54,13 @@ public interface IGeoLookup
|
|||||||
|
|
||||||
public interface IJoinTokenStore
|
public interface IJoinTokenStore
|
||||||
{
|
{
|
||||||
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
|
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct);
|
||||||
|
|
||||||
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
|
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>按令牌的 SHA-256 查找(安装脚本用它确认令牌能不能用,不消耗)。</summary>
|
||||||
|
ValueTask<JoinTokenInfo?> FindAsync(byte[] tokenSha256, CancellationToken ct);
|
||||||
|
|
||||||
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
|
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
|
||||||
|
|
||||||
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
|
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
|
||||||
@@ -141,6 +144,38 @@ 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>
|
||||||
|
public interface IOperatorSetStore
|
||||||
|
{
|
||||||
|
ValueTask<IReadOnlyList<SignedStatementDto>> ListAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>追加一个版本;版本号已经有了返回 false。</summary>
|
||||||
|
ValueTask<bool> AddAsync(int version, SignedStatementDto signed, long nowMs, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAuditStore
|
||||||
|
{
|
||||||
|
ValueTask AddAsync(AuditRecord record, CancellationToken ct);
|
||||||
|
|
||||||
|
ValueTask<IReadOnlyList<AuditRecord>> ListAsync(AuditQuery query, CancellationToken ct);
|
||||||
|
|
||||||
|
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>主机资源占用的历史:每个 Agent 每分钟一个样本。</summary>
|
/// <summary>主机资源占用的历史:每个 Agent 每分钟一个样本。</summary>
|
||||||
public interface IHostStore
|
public interface IHostStore
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,19 +12,21 @@ public sealed partial class RetentionService : BackgroundService
|
|||||||
private readonly IMetricStore _metrics;
|
private readonly IMetricStore _metrics;
|
||||||
private readonly IEventStore _events;
|
private readonly IEventStore _events;
|
||||||
private readonly IAuthStore _auth;
|
private readonly IAuthStore _auth;
|
||||||
|
private readonly AuditService _audit;
|
||||||
private readonly IHostStore _hosts;
|
private readonly IHostStore _hosts;
|
||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
private readonly LoginRateLimiter _limiter;
|
private readonly LoginRateLimiter _limiter;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
private readonly ILogger<RetentionService> _logger;
|
private readonly ILogger<RetentionService> _logger;
|
||||||
|
|
||||||
public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger<RetentionService> logger)
|
public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, AuditService audit, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger<RetentionService> logger)
|
||||||
{
|
{
|
||||||
_raw = raw;
|
_raw = raw;
|
||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
_events = events;
|
_events = events;
|
||||||
_hosts = hosts;
|
_hosts = hosts;
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
|
_audit = audit;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_limiter = limiter;
|
_limiter = limiter;
|
||||||
_time = time;
|
_time = time;
|
||||||
@@ -69,6 +71,7 @@ public sealed partial class RetentionService : BackgroundService
|
|||||||
await _events.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.EventDays, ct) * Steps.Day), ct);
|
await _events.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.EventDays, ct) * Steps.Day), ct);
|
||||||
await _hosts.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.HostDays, ct) * Steps.Day), ct);
|
await _hosts.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.HostDays, ct) * Steps.Day), ct);
|
||||||
await _auth.DeleteExpiredSessionsAsync(nowMs, ct);
|
await _auth.DeleteExpiredSessionsAsync(nowMs, ct);
|
||||||
|
await _audit.PruneAsync(nowMs, ct);
|
||||||
_limiter.Prune();
|
_limiter.Prune();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,9 +11,13 @@ public sealed class StartupTasks : IHostedService
|
|||||||
private readonly IRawStore _raw;
|
private readonly IRawStore _raw;
|
||||||
private readonly AuthService _auth;
|
private readonly AuthService _auth;
|
||||||
private readonly TaskService _tasks;
|
private readonly TaskService _tasks;
|
||||||
|
private readonly ServerIdentityService _identity;
|
||||||
|
private readonly SetupService _setup;
|
||||||
|
|
||||||
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks)
|
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity, SetupService setup)
|
||||||
{
|
{
|
||||||
|
_setup = setup;
|
||||||
|
_identity = identity;
|
||||||
_raw = raw;
|
_raw = raw;
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
_tasks = tasks;
|
_tasks = tasks;
|
||||||
@@ -23,7 +27,9 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ public sealed class TaskPlanner : IDisposable
|
|||||||
private readonly IProbeStore _probes;
|
private readonly IProbeStore _probes;
|
||||||
private readonly ISettingsStore _settings;
|
private readonly ISettingsStore _settings;
|
||||||
private readonly LinkService _links;
|
private readonly LinkService _links;
|
||||||
|
private readonly TrustService _trust;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links)
|
public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links, TrustService trust)
|
||||||
{
|
{
|
||||||
|
_trust = trust;
|
||||||
_links = links;
|
_links = links;
|
||||||
_tasks = tasks;
|
_tasks = tasks;
|
||||||
_agents = agents;
|
_agents = agents;
|
||||||
@@ -65,6 +67,9 @@ public sealed class TaskPlanner : IDisposable
|
|||||||
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) });
|
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<Fleet.Core.SignedStatementDto> chain = await _trust.ChainAsync(ct);
|
||||||
|
document.TrustChain = chain.Count == 0 ? null : [.. chain];
|
||||||
|
|
||||||
string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.Default.TaskSet)));
|
string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.Default.TaskSet)));
|
||||||
|
|
||||||
await _gate.WaitAsync(ct);
|
await _gate.WaitAsync(ct);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user