Compare commits
88
Commits
v0.2.0
...
0dc8c6fe34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dc8c6fe34 | ||
|
|
f704c66dd9 | ||
|
|
1f70c8d31e | ||
|
|
fbda9487d5 | ||
|
|
4a3631f82b | ||
|
|
3b88a3419d | ||
|
|
0e0b925c8b | ||
|
|
f01bb8fd63 | ||
|
|
96990c6f4e | ||
|
|
bd35a1a966 | ||
|
|
b47e01d944 | ||
|
|
7ee860cb27 | ||
|
|
699ed8c411 | ||
|
|
0012df7b91 | ||
|
|
d5967b6110 | ||
|
|
450748f3bc | ||
|
|
5bd53f8880 | ||
|
|
643f9e1ea3 | ||
|
|
ac0bcd8e1f | ||
|
|
f4aa3f2482 | ||
|
|
180e54cb3c | ||
|
|
a19517a57d | ||
|
|
83e5120377 | ||
|
|
17b35223aa | ||
|
|
4d329f5e16 | ||
|
|
ad17170664 | ||
|
|
710aa9e99f | ||
|
|
71d2866b9c | ||
|
|
48664c0ada | ||
|
|
198ee556d6 | ||
|
|
da465866d5 | ||
|
|
3a751a0f89 | ||
|
|
d69581dcb5 | ||
|
|
8f44e924a4 | ||
|
|
0c39485a78 | ||
|
|
7a71bf57f9 | ||
|
|
22a98f2eb0 | ||
|
|
0a66f5e07a | ||
|
|
48072737c9 | ||
|
|
604cbe3123 | ||
|
|
b242701e9e | ||
|
|
83f54978d2 | ||
|
|
1e5c660d05 | ||
|
|
2e470607fa | ||
|
|
a70b89b95c | ||
|
|
a9538eae4a | ||
|
|
91c63d0cf2 | ||
|
|
193fc3a95c | ||
|
|
134c06d605 | ||
|
|
d1be70cda2 | ||
|
|
672f6e3dcb | ||
|
|
9a375ffd9e | ||
|
|
407c09f6a5 | ||
|
|
fffee8937c | ||
|
|
91485db500 | ||
|
|
fa34869f18 | ||
|
|
deb5a97802 | ||
|
|
479f8c25b0 | ||
|
|
2b80bb66d1 | ||
|
|
078eacf1cb | ||
|
|
3db6d5b159 | ||
|
|
a7a8212c08 | ||
|
|
68406843de | ||
|
|
fae8c45c90 | ||
|
|
3bf12ec73d | ||
|
|
a9ee4ec34c | ||
|
|
6b12049df0 | ||
|
|
d4b4869501 | ||
|
|
92b72d4443 | ||
|
|
67b428991f | ||
|
|
55756f7695 | ||
|
|
17a69c1c7a | ||
|
|
50d8c07939 | ||
|
|
2e549b230d | ||
|
|
ace42e5da7 | ||
|
|
ad32afd038 | ||
|
|
286c9ff7cd | ||
|
|
8aaa574485 | ||
|
|
ce30828ed3 | ||
|
|
a46f45cce3 | ||
|
|
ce3ba51c02 | ||
|
|
faf1203f0a | ||
|
|
ee422ca366 | ||
|
|
c33003c279 | ||
|
|
31af31b96f | ||
|
|
9b6c4e0fd8 | ||
|
|
ceef543854 | ||
|
|
299f86dbee |
No files matched your search
@@ -50,8 +50,6 @@ steps:
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
commands:
|
||||
- bash ci/index-backend-image.sh
|
||||
depends_on:
|
||||
|
||||
@@ -27,8 +27,6 @@ steps:
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh backend
|
||||
depends_on: []
|
||||
@@ -44,8 +42,6 @@ steps:
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh agent
|
||||
depends_on: []
|
||||
@@ -61,8 +57,6 @@ steps:
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
RELEASE_API_TOKEN:
|
||||
from_secret: release_api_token
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh probe
|
||||
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.
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
## Project layout
|
||||
|
||||
@@ -5020,6 +5020,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/release/offer": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"release"
|
||||
],
|
||||
"summary": "Register a release manifest that carries a valid CI signature (no token needed)",
|
||||
"operationId": "release_offer",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PublishReleaseRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReleaseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/v1/enroll": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -9384,6 +9445,9 @@
|
||||
{
|
||||
"name": "ops"
|
||||
},
|
||||
{
|
||||
"name": "release"
|
||||
},
|
||||
{
|
||||
"name": "agent"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,31 @@ public static class BuildEndpoints
|
||||
.Requires(Capability.ReleasePublish);
|
||||
}
|
||||
|
||||
/// <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, CancellationToken ct)
|
||||
{
|
||||
var items = (await nodes.ListAsync(ct))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 发布清单的签名校验:用 Backend 内嵌的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)验 ECDSA P-256 / SHA-256(DER)签名,签名对象是清单的原始字节。
|
||||
/// 登记清单因此不需要任何令牌:能被登记的只有 CI 签过的清单,节点的更新器之后还会用本机的公钥再验一遍。
|
||||
/// </summary>
|
||||
public static class ReleaseSignature
|
||||
{
|
||||
public static bool Verify(string publicKeyPem, string manifest, string signatureBase64)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = ECDsa.Create();
|
||||
key.ImportFromPem(publicKeyPem);
|
||||
return key.VerifyData(Encoding.UTF8.GetBytes(manifest), Convert.FromBase64String(signatureBase64), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,9 @@ public static class ApiModule
|
||||
LinkEndpoints.Map(guarded);
|
||||
|
||||
OpsEndpoints.Map(app);
|
||||
BuildEndpoints.MapOffer(app);
|
||||
app.MapOpenApi("/openapi/v1.json");
|
||||
app.MapStaticSite();
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,4 +11,11 @@
|
||||
<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>
|
||||
</Project>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public sealed record ReleaseOffer(string Component, long Seq, string Tag, string
|
||||
|
||||
/// <summary>
|
||||
/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。
|
||||
/// <b>面板不验签、也不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
|
||||
/// 登记入口(<c>/api/release/offer</c>)只收带有效 CI 签名的清单;面板<b>不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
|
||||
/// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。
|
||||
/// </summary>
|
||||
public sealed partial class ReleaseService : IDisposable
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed partial class OpenApiConformanceTests
|
||||
foreach ((string path, _) in doc["paths"]!.AsObject())
|
||||
{
|
||||
string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"];
|
||||
string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig"];
|
||||
string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/api/release/offer"];
|
||||
Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers");
|
||||
foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Backend.Api;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CI 登记清单不需要令牌,但清单必须带 CI 发布密钥的有效签名:令牌存在面板的数据库里,清库、重装或还没有 passkey 的时候就拿不到,
|
||||
/// 而清单本来就是签名的,面板直接验签更简单,也没有长期令牌可偷。
|
||||
/// </summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class ReleaseOfferTests
|
||||
{
|
||||
// openssl 生成的测试密钥对和签名(`openssl dgst -sha256 -sign`,DER):证明验签接受 CI 脚本实际产出的格式
|
||||
private const string TestKey = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQwlE2KjYWszeaTsng6CfPxhOqeIV\n+7dm940f7XfHJr9A6/OTiZ2YnhFWWipZ4Z4poPtb6hMW04tIARO8+Km34w==\n-----END PUBLIC KEY-----";
|
||||
private const string Manifest = "{\"schema\":1,\"component\":\"agent\",\"seq\":7}";
|
||||
private const string OpenSslSignature = "MEUCIDrOloi3otWBiCFcXEsPg6kHCS7G09PrO3hW+3AKXAiZAiEAvXgt9K+iZXnWiEgKdanBKbEoL0LyT/xuaEW6dAG0jDY=";
|
||||
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public ReleaseOfferTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignatureFromOpenSsl_Verifies_AndAnyChangeDoesNot()
|
||||
{
|
||||
Assert.True(ReleaseSignature.Verify(TestKey, Manifest, OpenSslSignature));
|
||||
|
||||
Assert.False(ReleaseSignature.Verify(TestKey, Manifest.Replace("7", "8", StringComparison.Ordinal), OpenSslSignature));
|
||||
Assert.False(ReleaseSignature.Verify(TestKey, Manifest + " ", OpenSslSignature));
|
||||
|
||||
byte[] tampered = Convert.FromBase64String(OpenSslSignature);
|
||||
tampered[10] ^= 1;
|
||||
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, Convert.ToBase64String(tampered)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnotherKey_GarbageAndEmptyInputs_AreRejected()
|
||||
{
|
||||
using var other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
string signature = Convert.ToBase64String(other.SignData(Encoding.UTF8.GetBytes(Manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
|
||||
|
||||
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, signature));
|
||||
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, "not base64!!"));
|
||||
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, ""));
|
||||
Assert.False(ReleaseSignature.Verify("not a key", Manifest, OpenSslSignature));
|
||||
// P1363(节点和 WebAuthn 用的格式)不是 CI 签名的格式
|
||||
Assert.False(ReleaseSignature.Verify(TestKey, Manifest, Convert.ToBase64String(new byte[64])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheEmbeddedReleaseKey_IsAUsableP256PublicKey()
|
||||
{
|
||||
using var key = ECDsa.Create();
|
||||
key.ImportFromPem(InstallScripts.ReleaseKey());
|
||||
|
||||
Assert.Equal(256, key.KeySize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"component\":\"agent\",\"channel\":\"dev\",\"image\":\"r/a\",\"digest\":\"sha256:0000000000000000000000000000000000000000000000000000000000000000\",\"seq\":99999999999}", "AAAA")]
|
||||
[InlineData("", "")]
|
||||
[InlineData("not json", "MEUCIDrOloi3otWBiCFcXEsPg6kHCS7G09PrO3hW+3AKXAiZAiEAvXgt9K+iZXnWiEgKdanBKbEoL0LyT/xuaEW6dAG0jDY=")]
|
||||
public async Task OfferWithoutAValidCiSignature_IsForbidden_EvenWithoutAnyToken(string manifest, string signature)
|
||||
{
|
||||
using HttpClient anonymous = _backend.NewClient();
|
||||
using HttpResponseMessage response = await anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheAdminEndpoint_StillNeedsTheCapability()
|
||||
{
|
||||
using HttpClient anonymous = _backend.NewClient();
|
||||
using HttpResponseMessage response = await anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest, ["signature"] = OpenSslSignature }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using MoShiro.Backend.Api;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 内嵌前端:白名单、不碰文件系统,路径穿越的各种写法全部 404;SPA 回退只给没有扩展名的路径,不吞 API 的 404;
|
||||
/// 页面带 CSP 等响应头,哈希文件名的资源长缓存、入口页不缓存。
|
||||
/// </summary>
|
||||
public sealed class StaticSiteTests
|
||||
{
|
||||
private static StaticSite Site() => new(
|
||||
[
|
||||
("/index.html", Encoding.UTF8.GetBytes("<html>display</html>")),
|
||||
("/assets/app-1a2b.js", Encoding.UTF8.GetBytes("console.log(1)")),
|
||||
("/flags/cn.svg", Encoding.UTF8.GetBytes("<svg/>")),
|
||||
("/admin/index.html", Encoding.UTF8.GetBytes("<html>admin</html>")),
|
||||
("/admin/assets/app-3c4d.css", Encoding.UTF8.GetBytes("body{}")),
|
||||
("/../secret.txt", Encoding.UTF8.GetBytes("must never be registered")),
|
||||
]);
|
||||
|
||||
private static string Text(SiteAsset? asset) => Encoding.UTF8.GetString(asset!.Content);
|
||||
|
||||
[Fact]
|
||||
public void KnownFiles_AreServed_WithTypeAndCaching()
|
||||
{
|
||||
StaticSite site = Site();
|
||||
|
||||
Assert.Equal("<html>display</html>", Text(site.Resolve("/").Asset));
|
||||
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/").Asset));
|
||||
|
||||
SiteAsset? js = site.Resolve("/assets/app-1a2b.js").Asset;
|
||||
Assert.StartsWith("text/javascript", js!.ContentType, StringComparison.Ordinal);
|
||||
Assert.Contains("immutable", js.CacheControl, StringComparison.Ordinal);
|
||||
Assert.False(js.Html);
|
||||
|
||||
SiteAsset? index = site.Resolve("/index.html").Asset;
|
||||
Assert.Equal("no-cache", index!.CacheControl);
|
||||
Assert.True(index.Html);
|
||||
Assert.Equal("image/svg+xml", site.Resolve("/flags/cn.svg").Asset!.ContentType);
|
||||
Assert.StartsWith("text/css", site.Resolve("/admin/assets/app-3c4d.css").Asset!.ContentType, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdminWithoutSlash_RedirectsToAFixedPath()
|
||||
{
|
||||
SiteResolution resolution = Site().Resolve("/admin");
|
||||
|
||||
Assert.Null(resolution.Asset);
|
||||
Assert.Equal("/admin/", resolution.Redirect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/../secret.txt")]
|
||||
[InlineData("/admin/../../etc/passwd")]
|
||||
[InlineData("/assets/../index.html")]
|
||||
[InlineData("/./index.html")]
|
||||
[InlineData("/%2e%2e/secret.txt")]
|
||||
[InlineData("/%2E%2E%2Fsecret.txt")]
|
||||
[InlineData("/..%2fsecret.txt")]
|
||||
[InlineData("/..%5csecret.txt")]
|
||||
[InlineData("/%252e%252e/secret.txt")]
|
||||
[InlineData("/..\\secret.txt")]
|
||||
[InlineData("/assets\\app-1a2b.js")]
|
||||
[InlineData("/assets//app-1a2b.js")]
|
||||
[InlineData("//assets/app-1a2b.js")]
|
||||
[InlineData("/index.html\0.png")]
|
||||
[InlineData("/index.html\r\nSet-Cookie: a=b")]
|
||||
[InlineData("/文件")]
|
||||
[InlineData("assets/app-1a2b.js")]
|
||||
[InlineData("")]
|
||||
public void TraversalAndMalformedPaths_AreNotFound_NeverNormalized(string path)
|
||||
{
|
||||
SiteResolution resolution = Site().Resolve(path);
|
||||
|
||||
Assert.Null(resolution.Asset);
|
||||
Assert.Null(resolution.Redirect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlongPath_IsNotFound()
|
||||
{
|
||||
Assert.Null(Site().Resolve("/" + new string('a', 5000)).Asset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpaFallback_OnlyForPathsWithoutAnExtension()
|
||||
{
|
||||
StaticSite site = Site();
|
||||
|
||||
Assert.Equal("<html>display</html>", Text(site.Resolve("/lines/abc").Asset));
|
||||
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/agents").Asset));
|
||||
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/system/trust").Asset));
|
||||
|
||||
// 缺失的文件不是页面:脚本、样式、图片 404 要保持 404,不能变成 HTML
|
||||
Assert.Null(site.Resolve("/assets/missing.js").Asset);
|
||||
Assert.Null(site.Resolve("/admin/assets/missing.css").Asset);
|
||||
Assert.Null(site.Resolve("/favicon.ico").Asset);
|
||||
Assert.Null(site.Resolve("/robots.txt").Asset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdminAndDisplay_AreNotMixed()
|
||||
{
|
||||
StaticSite site = Site();
|
||||
|
||||
// 展示端的路径永远不会得到管理端的页面,反过来管理端的前缀之外也一样
|
||||
Assert.Equal("<html>display</html>", Text(site.Resolve("/administrator").Asset));
|
||||
Assert.Equal("<html>display</html>", Text(site.Resolve("/adminx/agents").Asset));
|
||||
Assert.Null(site.Resolve("/admin/index.htm").Asset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySite_ServesNothing()
|
||||
{
|
||||
var site = new StaticSite([]);
|
||||
|
||||
Assert.Equal(0, site.Count);
|
||||
Assert.Null(site.Resolve("/").Asset);
|
||||
Assert.Null(site.Resolve("/admin/agents").Asset);
|
||||
Assert.Null(site.Resolve("/admin").Redirect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UncleanPathsAreRejectedWhenRegistering()
|
||||
{
|
||||
StaticSite site = Site();
|
||||
|
||||
Assert.Equal(5, site.Count - 2); // 五个干净的文件;两个别名 "/" 和 "/admin/" 不算;"/../secret.txt" 没登记
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>HTTP 层:后端自己的路径不会被前端的回退吞掉;响应头对。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class StaticSiteHttpTests
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public StaticSiteHttpTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/admin/does-not-exist")]
|
||||
[InlineData("/api/public/does-not-exist")]
|
||||
[InlineData("/api/agent/v1/nope")]
|
||||
[InlineData("/api")]
|
||||
[InlineData("/openapi/missing.json")]
|
||||
[InlineData("/install/missing")]
|
||||
[InlineData("/healthz/x")]
|
||||
public async Task BackendPaths_KeepTheirOwnNotFound_NotTheFrontend(string path)
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage response = await client.GetAsync(path);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("<html", body, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/admin/agents")]
|
||||
[InlineData("/healthz")]
|
||||
public async Task ExistingEndpoints_AreNotShadowedByTheCatchAll(string path)
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage response = await client.GetAsync(path);
|
||||
|
||||
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.DoesNotContain("<html", await response.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/../etc/passwd")]
|
||||
[InlineData("/%2e%2e/etc/passwd")]
|
||||
[InlineData("/admin/..%2f..%2fetc/passwd")]
|
||||
[InlineData("/..%5c..%5cwindows/win.ini")]
|
||||
public async Task Traversal_NeverReturnsAFileFromTheHost(string path)
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage response = await client.GetAsync(path);
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.DoesNotContain("root:", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("[fonts]", body, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WritesToTheSite_AreRejected()
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
foreach (HttpMethod method in new[] { HttpMethod.Post, HttpMethod.Put, HttpMethod.Delete, HttpMethod.Patch })
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, "/admin/index.html");
|
||||
using HttpResponseMessage response = await client.SendAsync(request);
|
||||
|
||||
Assert.True(response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotFound, $"{method}: {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pages_CarryTheSecurityHeaders_WhenTheFrontendIsEmbedded()
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage response = await client.GetAsync("/admin/");
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return; // 本地没构建前端时不嵌入(CI 的 Backend 测试同样没有);响应头的逻辑由上面的单元测试和发布构建覆盖
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single());
|
||||
Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single());
|
||||
string csp = response.Headers.GetValues("Content-Security-Policy").Single();
|
||||
Assert.Contains("script-src 'self'", csp, StringComparison.Ordinal);
|
||||
Assert.Contains("frame-ancestors 'none'", csp, StringComparison.Ordinal);
|
||||
Assert.Equal("no-cache", response.Headers.CacheControl!.ToString());
|
||||
|
||||
string etag = response.Headers.ETag!.ToString();
|
||||
using var conditional = new HttpRequestMessage(HttpMethod.Get, "/admin/");
|
||||
conditional.Headers.TryAddWithoutValidation("If-None-Match", etag);
|
||||
using HttpResponseMessage notModified = await client.SendAsync(conditional);
|
||||
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,8 @@ if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then
|
||||
digest="$(cat "$work/$component.digest")"
|
||||
release_dir="$work/$component-release"
|
||||
bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$dev_tag" dev "$release_dir"
|
||||
if [[ -n "${RELEASE_API_URL:-}" && -n "${RELEASE_API_TOKEN:-}" ]]; then
|
||||
python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' "$release_dir/manifest.json" "$release_dir/manifest.sig" | curl --fail-with-body --silent --show-error --retry 3 -X POST "${RELEASE_API_URL%/}/api/admin/releases" -H "Authorization: Bearer $RELEASE_API_TOKEN" -H 'Content-Type: application/json' --data-binary @- >/dev/null
|
||||
if [[ -n "${RELEASE_API_URL:-}" ]]; then
|
||||
python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' "$release_dir/manifest.json" "$release_dir/manifest.sig" | curl --fail-with-body --silent --show-error --retry 3 -X POST "${RELEASE_API_URL%/}/api/release/offer" -H 'Content-Type: application/json' --data-binary @- >/dev/null
|
||||
echo "registered the signed $component release with the panel"
|
||||
fi
|
||||
fi
|
||||
@@ -1,16 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI 发布 webhook:镜像已推送成功后,签名并登记清单。面板不会因此自动重启 Backend。
|
||||
# CI 发布 webhook:镜像已推送成功后,签名并登记清单(面板验 CI 签名,不需要令牌)。面板不会因此自动重启 Backend。
|
||||
set -euo pipefail
|
||||
component="${1:?component}"; repository="${2:?repository}"; digest="${3:?digest}"; tag="${4:?tag}"; channel="${5:?channel}"; outdir="${6:?outdir}"
|
||||
[[ -n "${RELEASE_SIGNING_KEY:-}" ]] || { echo "release signing is not configured; no update offer published"; exit 0; }
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$tag" "$channel" "$outdir"
|
||||
if [[ -n "${RELEASE_API_URL:-}" && -n "${RELEASE_API_TOKEN:-}" ]]; then
|
||||
if [[ -n "${RELEASE_API_URL:-}" ]]; then
|
||||
python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' \
|
||||
"$outdir/manifest.json" "$outdir/manifest.sig" > "$outdir/payload.json"
|
||||
response="$outdir/response.json"
|
||||
status="$(curl --silent --show-error --retry 3 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/admin/releases" \
|
||||
-H "Authorization: Bearer $RELEASE_API_TOKEN" -H 'Content-Type: application/json' --data-binary "@$outdir/payload.json")"
|
||||
status="$(curl --silent --show-error --retry 3 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/release/offer" \ -H 'Content-Type: application/json' --data-binary "@$outdir/payload.json")"
|
||||
if [[ "$status" != 2* ]]; then
|
||||
if [[ "$component" == backend && "$status" == 400 ]] && grep -Fq 'manifest needs a known component' "$response"; then
|
||||
echo "the panel does not support Backend update offers yet; bootstrap it once before future Backend offers"
|
||||
|
||||
@@ -52,6 +52,11 @@ export MOSHIRO_TAG="${MOSHIRO_TAG:-${tag:-main-${CI_COMMIT_SHA:0:10}}}"
|
||||
export MOSHIRO_COMMIT="${MOSHIRO_COMMIT:-${CI_COMMIT_SHA:0:10}}"
|
||||
export MOSHIRO_BUILT_AT="${MOSHIRO_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
|
||||
|
||||
# 前端先构建:Backend.Api 在 web/*/dist 存在时把它们内嵌进程序(展示端 /,管理端 /admin/),这样 passkey 能绑定在面板自己的域名上
|
||||
if [[ -z "${NPM_CONFIG_CACHE:-}" && -d /home/justme/.npm ]]; then export NPM_CONFIG_CACHE=/home/justme/.npm; fi
|
||||
(cd "$root" && npm ci --prefix web --prefer-offline && npm run build --prefix web)
|
||||
[[ -f "$root/web/display/dist/index.html" && -f "$root/web/admin/dist/index.html" ]] || { echo "the web build did not produce both apps" >&2; exit 1; }
|
||||
|
||||
(cd "$root/backend" && dotnet publish src/MoShiro.Backend \
|
||||
-m:3 -c Release -r "$rid" --self-contained true \
|
||||
-p:DebugType=none -p:DebugSymbols=false \
|
||||
|
||||
+2
-1
@@ -146,7 +146,8 @@
|
||||
| GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) |
|
||||
| GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 |
|
||||
| GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) |
|
||||
| GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,面板不验签、原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 |
|
||||
| GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,需要 `release.publish`,原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 |
|
||||
| POST | `/api/release/offer` | CI 登记发布清单的入口,**不需要令牌**:`{manifest, signature}` 的签名必须能用 Backend 内嵌的发布公钥验过(ECDSA P-256 / SHA-256,DER),否则 403;`seq` 必须比已登记的新,否则 409。登记不依赖面板里的任何凭证,清库、重装、还没有 passkey 时照常工作 |
|
||||
| GET | `/api/admin/links` | 每一对(Probe,Agent)的线路开关:`linked`(最终结果)、`override`(手动设置,`null` 跟随自动连接)、`connected`,以及 `auto_link` |
|
||||
| PUT | `/api/admin/links/{probe_id}/{agent_id}` | `{enabled: true|false|null}`:手动强制连或不连,`null` 取消手动设置;改完立即重新下发 |
|
||||
| POST | `/api/admin/nodes/{id}/update` | 请求一个在线的 Agent 或 Probe 更新到它收到的清单(节点不在线返回 409) |
|
||||
|
||||
+3
-1
@@ -40,6 +40,8 @@ macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。
|
||||
|
||||
Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/moshiro/moshiro-backend:<version>` 分发,支持 `linux/amd64` 和 `linux/arm64`。容器使用 glibc NativeAOT 单文件程序(动态链接,运行时要加载 libssl;musl 的静态产物做不到),基底为 `mcr.microsoft.com/dotnet/runtime-deps:10.0`(Debian),以非 root 用户运行,在 `8080/tcp` 提供 HTTP 服务;数据库和原始数据统一写入 `/data`。程序启用 InvariantGlobalization,无需 .NET 运行时或 ICU;时间查询使用 UTC。SQLite 从固定版本的官方源码编译并静态链接,不再随镜像携带 `libe_sqlite3.so`。将 `/data` 挂载为持久卷,并在反向代理处配置 HTTPS。
|
||||
|
||||
**前端内嵌在 Backend 里**:CI 构建 Backend 前先构建 `web/display` 和 `web/admin`,`Backend.Api` 把两个 `dist` 嵌进程序,展示端在 `/`,管理端在 `/admin/`,所以面板的域名(如 `https://mo.oeo.one/admin/`)打开就是管理端,passkey 才能绑定在这个域名上(浏览器只允许页面的来源与 RP 域名一致,本机 `localhost` 上的页面注册不了绑定 `mo.oeo.one` 的 passkey)。本地没有构建前端时不嵌入,Backend 只提供 API。文件是启动时读进内存的白名单,请求时不访问文件系统;带 `..`、反斜杠、`%`、控制字符或空段的路径一律 404,缺失的文件(带扩展名)404,只有无扩展名的路径回退到入口页;`/api`、`/openapi`、`/install`、`/healthz` 下没有匹配到的路径仍是后端的 JSON 404。页面带 CSP(脚本只许同源)、`nosniff`、`X-Frame-Options: DENY`,入口页 `no-cache`,哈希文件名的资源 `immutable`,不嵌入 source map。部署时设置 `MoShiro__PublicUrl=https://<面板域名>`(`deploy-node.sh backend --public-url …`),WebAuthn 的域名和允许的来源由它派生。
|
||||
|
||||
在部署目录保存此仓库的 `compose.yaml`,先通过部署平台的环境变量或密钥管理设置 `MOSHIRO_ADMIN_PASSWORD`(可选设置 `MOSHIRO_ADMIN_USER`、`MOSHIRO_VERSION`),然后启动:
|
||||
|
||||
```sh
|
||||
@@ -305,7 +307,7 @@ sh packaging/update/install-updater.sh agent --user \
|
||||
|
||||
更新器依赖 Linux、systemd、OpenSSL、util-linux/flock 和所选引擎。用户部署把配置、公钥和镜像引用放在 `~/.config/moshiro/`、版本记录放在 `~/.local/state/moshiro-update/`;系统部署使用 `/etc/moshiro-update/` 和 `/var/lib/moshiro-update/`。私有仓库的 `--authfile` 对 Podman 使用其认证文件,对 Docker 使用标准 `config.json`,不改写全局 Docker 配置。旧 `--data-dir` 安装方式保留兼容,但新部署统一使用 `--volume`。
|
||||
|
||||
发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。
|
||||
发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。`RELEASE_API_URL` 指向面板,CI 把签名清单 POST 到 `/api/release/offer`:这个入口不需要令牌,面板用内嵌的发布公钥验签,签名无效就拒绝,所以 CI 不需要在面板里持有任何凭证(清库或重装后照常登记,也没有长期令牌可偷)。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。
|
||||
|
||||
**换发布密钥**(泄露或定期轮换,不用重装节点):
|
||||
|
||||
|
||||
@@ -244,7 +244,8 @@ export function App() {
|
||||
}
|
||||
if (!me) return <Login />
|
||||
// 密码登录的会话没有任何能力:只给它添加 Passkey 的页面
|
||||
if (me.kind === 'session' && me.auth_method === 'password') return <PasskeyOnly />
|
||||
// 以数据源方式(会话令牌)连别的后端时 kind 是 token,所以只看登录方式
|
||||
if (me.auth_method === 'password') return <PasskeyOnly />
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
@@ -271,7 +272,7 @@ export function App() {
|
||||
<span className="grow">
|
||||
<div style={{ fontWeight: 600 }}>{me.username ?? 'API 令牌'}</div>
|
||||
<div className="faint" style={{ fontSize: 12 }}>
|
||||
{me.kind === 'token' ? `API 令牌 · ${me.capabilities.length} 项能力` : '管理员'}
|
||||
{me.kind === 'token' && !me.auth_method ? `API 令牌 · ${me.capabilities.length} 项能力` : '管理员'}
|
||||
</div>
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { KeyRound, LogOut } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import { getBase } from '@shared/api/source'
|
||||
import type { Page, PasskeyDto } from '@shared/api/types'
|
||||
import { passkeySupported } from '@shared/api/webauthn'
|
||||
import { useAuth } from './auth'
|
||||
@@ -23,7 +22,6 @@ export function PasskeyOnly() {
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
const remote = !!getBase()
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="card stack" style={{ maxWidth: 520 }}>
|
||||
@@ -34,9 +32,7 @@ export function PasskeyOnly() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="notice">用密码登录的会话不能管理任何东西,只能添加 Passkey。添加之后退出,用 Passkey 重新登录。</div>
|
||||
{remote ? (
|
||||
<div className="notice warn">这是另一个后端:Passkey 绑定在后端自己的域名上,请直接打开那个后端的管理端地址再登录。</div>
|
||||
) : !passkeySupported() ? (
|
||||
{!passkeySupported() ? (
|
||||
<div className="notice warn">这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。</div>
|
||||
) : count === 0 || count === null ? (
|
||||
<div className="stack">
|
||||
|
||||
@@ -1195,6 +1195,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/release/offer": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Register a release manifest that carries a valid CI signature (no token needed) */
|
||||
post: operations["release_offer"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/agent/v1/enroll": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -6194,6 +6211,57 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
release_offer: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublishReleaseRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReleaseDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
fleet_enroll: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Reference in new issue
Block a user