ci/woodpecker/push/main-amd64-build Pipeline was successful
Agent, Probe and Backend now start from one static compose.yaml plus a local
.env: the panel (or the release assets, for the very first Backend) provides
the file, the panel's command writes the public values into .env, checks the
file's SHA-256 and runs compose up. Updating is compose pull && compose up -d.
The image is chosen by the CI channel (:latest, or MOSHIRO_VERSION in .env),
not by the panel; the previous one-line script stays as a fallback.
- packaging/compose/{agent,probe,backend}/compose.yaml are the single source,
embedded in the Backend and served at /install/compose/{file}; the root
compose.yaml moved there. Releases also carry them and list them in the
signed SHA256SUMS.
- The container entrypoint defaults the node name to the host name.
- Join token creation returns a compose block (.env, url, hash, per-platform
commands); the admin dialog leads with it.
- Remove code with no references (HotLayer members, LiveHub.SubscriberCount,
QueryService.ParseParams, ReleaseService.Forget, a few constants) and reduce
the install-code lookup to the role it is for.
188 lines
10 KiB
C#
188 lines
10 KiB
C#
using Fleet.Core;
|
||
using Fleet.Identity;
|
||
using MoShiro.Protocol;
|
||
|
||
namespace MoShiro.Backend.Application;
|
||
|
||
/// <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, string? CreatedBy = null);
|
||
|
||
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
|
||
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
||
|
||
/// <summary>
|
||
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
|
||
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
|
||
/// </summary>
|
||
/// <param name="ComposeSha256">面板提供的这个角色的 compose 文件的 SHA-256(命令下载之后先核对)。</param>
|
||
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "");
|
||
|
||
/// <summary>
|
||
/// compose 安装:<c>Env</c> 是要存成 <c>.env</c> 的内容(只有公开的值),<c>Url</c> 和 <c>Sha256</c> 是 compose 文件的下载地址和哈希,
|
||
/// 三条命令(Linux 用 Podman,macOS、Windows 用 Docker Desktop)把 .env、下载、核对哈希和启动合在一起;Agent 的 compose 需要 Linux 宿主网络,所以它的 macOS、Windows 命令是空的。
|
||
/// </summary>
|
||
public sealed record ComposeInstall(string Env, string Url, string Sha256, string Linux, string Macos, string Windows);
|
||
|
||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands, ComposeInstall Compose);
|
||
|
||
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
||
public sealed class JoinTokenService
|
||
{
|
||
public const string AgentRole = "agent";
|
||
public const string ProbeRole = "probe";
|
||
|
||
/// <summary>令牌里存的「种类」:空表示 Agent,其余就是角色名。</summary>
|
||
public static string KindOf(string role) => role == ProbeRole ? ProbeRole : "";
|
||
|
||
public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind;
|
||
|
||
private readonly IJoinTokenStore _store;
|
||
private readonly BackendOptions _options;
|
||
private readonly TimeProvider _time;
|
||
|
||
public JoinTokenService(IJoinTokenStore store, BackendOptions options, TimeProvider time)
|
||
{
|
||
_store = store;
|
||
_options = options;
|
||
_time = time;
|
||
}
|
||
|
||
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct)
|
||
{
|
||
var errors = new Dictionary<string, string>();
|
||
if (request.Role is not (AgentRole or ProbeRole))
|
||
{
|
||
errors["role"] = "must be agent or probe";
|
||
}
|
||
|
||
if (request.MaxUses is < 1 or > 100_000)
|
||
{
|
||
errors["max_uses"] = "must be between 1 and 100000";
|
||
}
|
||
|
||
if (request.ExpiresInHours is < 1 or > 24 * 365)
|
||
{
|
||
errors["expires_in_hours"] = "must be between 1 and 8760";
|
||
}
|
||
|
||
if (request.Labels is { } labels && (labels.Count > 16 || labels.Any(l => l.Length is 0 or > 32)))
|
||
{
|
||
errors["labels"] = "at most 16 labels of 1-32 characters";
|
||
}
|
||
|
||
if (request.Note is { Length: > 200 })
|
||
{
|
||
errors["note"] = "must be at most 200 characters";
|
||
}
|
||
|
||
if (errors.Count > 0)
|
||
{
|
||
return ValidationError.Fail<CreatedJoinToken>(errors);
|
||
}
|
||
|
||
string plaintext = TokenHash.NewToken("jt");
|
||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||
JoinTokenInfo info = await _store.CreateAsync(
|
||
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, request.CreatedBy, ct);
|
||
|
||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Compose(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
|
||
}
|
||
|
||
/// <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)");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把一次性码和公开的值写进本地的 <c>.env</c>,下载静态的 compose 文件、核对哈希、<c>compose up -d</c>:以后更新就是
|
||
/// <c>compose pull && compose up -d</c>。镜像来自 CI 渠道(<c>:latest</c>,也可以在 .env 里固定版本),不由面板决定。
|
||
/// </summary>
|
||
public ComposeInstall Compose(string baseUrl, string role, string code, InstallPlan plan)
|
||
{
|
||
string url = $"{baseUrl}/install/compose/{role}.yaml";
|
||
string dir = $"moshiro-{role}";
|
||
string Env(bool desktop)
|
||
{
|
||
var lines = new List<string> { $"MOSHIRO_SERVER={baseUrl}", $"MOSHIRO_JOIN={code}" };
|
||
if (plan.Genesis is null)
|
||
{
|
||
lines.Add("MOSHIRO_ALLOW_NO_OWNER=1");
|
||
}
|
||
else
|
||
{
|
||
lines.Add($"MOSHIRO_GENESIS={plan.Genesis}");
|
||
lines.Add($"MOSHIRO_CONTROLLERS={string.Join(',', plan.Controllers)}");
|
||
}
|
||
|
||
lines.Add($"MOSHIRO_IMAGE={ImageRepository(role)}");
|
||
if (desktop)
|
||
{
|
||
// Docker Desktop 没有宿主网络
|
||
lines.Add("MOSHIRO_NETWORK_MODE=bridge");
|
||
lines.Add("MOSHIRO_NETWORK_SCOPE=container");
|
||
}
|
||
|
||
lines.Add("# MOSHIRO_NAME=节点名字(不写就用主机名;名字被占用时在这里改)");
|
||
return string.Join('\n', lines);
|
||
}
|
||
|
||
string linux = $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(false)}\nEOF\n"
|
||
+ $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | sha256sum -c - && podman compose up -d";
|
||
string macos = role == ProbeRole
|
||
? $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(true)}\nEOF\n"
|
||
+ $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | shasum -a 256 -c - && docker compose up -d"
|
||
: "";
|
||
string windows = role == ProbeRole
|
||
? $"New-Item -ItemType Directory -Force {dir} | Out-Null; Set-Location {dir}\n@'\n{Env(true)}\n'@ | Set-Content -Encoding ascii .env\n"
|
||
+ $"irm '{url}' -OutFile compose.yaml; if ((Get-FileHash compose.yaml -Algorithm SHA256).Hash -ne '{plan.ComposeSha256}') {{ throw 'compose.yaml does not match its published hash' }}\ndocker compose up -d"
|
||
: "";
|
||
return new ComposeInstall(Env(false), url, plan.ComposeSha256, linux, macos, windows);
|
||
}
|
||
|
||
/// <summary>这个角色的镜像仓库地址(不带 tag):节点镜像仓库加上命名空间。</summary>
|
||
private string ImageRepository(string role)
|
||
{
|
||
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
|
||
string ns = _options.ImageNamespace.Trim('/');
|
||
return $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}";
|
||
}
|
||
|
||
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||
|
||
/// <summary>
|
||
/// 这个一次性码是给哪个角色(agent 或 probe)的:安装脚本据此向面板取该角色已签名的发布清单。
|
||
/// 代码不存在、已吊销、已过期或用完了返回 null(不消耗它)。
|
||
/// </summary>
|
||
public async ValueTask<string?> RoleOfCodeAsync(string code, CancellationToken ct)
|
||
{
|
||
if (code.Length is 0 or > 200)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct);
|
||
return token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses
|
||
? null
|
||
: RoleOf(token.Kind);
|
||
}
|
||
|
||
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);
|
||
|
||
/// <summary>彻底删除这条令牌记录(任何状态都可以);还没用完的令牌删除后立即不能再用。</summary>
|
||
public ValueTask<bool> DeleteAsync(string id, CancellationToken ct) => _store.DeleteAsync(id, ct);
|
||
}
|