using Fleet.Core; using Fleet.Identity; using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// agent(默认)或 probe:用这个令牌注册出来的是哪种节点。 public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null); /// 各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。 public sealed record InstallCommands(string Linux, string Macos, string Windows); /// /// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、 /// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 Genesis 为空,节点不固定信任根。 /// /// 面板提供的这个角色的 compose 文件的 SHA-256(命令下载之后先核对)。 public sealed record InstallPlan(string? Genesis, IReadOnlyList Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = ""); /// /// compose 安装:Env 是要存成 .env 的内容(只有公开的值),Url 和 Sha256 是 compose 文件的下载地址和哈希, /// 三条命令(Linux 用 Podman,macOS、Windows 用 Docker Desktop)把 .env、下载、核对哈希和启动合在一起;Agent 的 compose 需要 Linux 宿主网络,所以它的 macOS、Windows 命令是空的。 /// 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); /// 加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。 public sealed class JoinTokenService { public const string AgentRole = "agent"; public const string ProbeRole = "probe"; /// 令牌里存的「种类」:空表示 Agent,其余就是角色名。 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> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct) { var errors = new Dictionary(); 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(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.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Compose(BaseUrl(requestBaseUrl), request.Role, plaintext, plan))); } /// /// 一行安装命令:先下载静态脚本、核对 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹和属主都走环境变量。 /// 里面没有任何私钥,码泄露最多让别人注册出一个节点。 /// 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)"); } /// /// 把一次性码和公开的值写进本地的 .env,下载静态的 compose 文件、核对哈希、compose up -d:以后更新就是 /// compose pull && compose up -d。镜像来自 CI 渠道(:latest,也可以在 .env 里固定版本),不由面板决定。 /// 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 { $"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); } /// 这个角色的镜像仓库地址(不带 tag):节点镜像仓库加上命名空间。 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('/'); /// /// 这个一次性码是给哪个角色(agent 或 probe)的:安装脚本据此向面板取该角色已签名的发布清单。 /// 代码不存在、已吊销、已过期或用完了返回 null(不消耗它)。 /// public async ValueTask 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> ListAsync(CancellationToken ct) => _store.ListAsync(ct); public ValueTask RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); /// 彻底删除这条令牌记录(任何状态都可以);还没用完的令牌删除后立即不能再用。 public ValueTask DeleteAsync(string id, CancellationToken ct) => _store.DeleteAsync(id, ct); }