diff --git a/.gitattributes b/.gitattributes index 627e9f4..a074163 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,6 @@ ci/index-backend-image.sh text eol=lf ci/publish-release-offer.sh text eol=lf ci/release-linux.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 diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index dcf4fa9..a4801fb 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -4139,16 +4139,28 @@ } } }, - "/install.sh": { + "/install": { "get": { "tags": [ "ops" ], - "summary": "Installer for Linux nodes", + "summary": "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)", "operationId": "install_sh", + "parameters": [ + { + "name": "code", + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { - "501": { - "description": "Not Implemented", + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { @@ -4165,11 +4177,23 @@ "tags": [ "ops" ], - "summary": "Installer for Windows nodes", + "summary": "One-line installer for Windows nodes (agent or probe, decided by the code)", "operationId": "install_ps1", + "parameters": [ + { + "name": "code", + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { - "501": { - "description": "Not Implemented", + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { diff --git a/backend/src/MoShiro.Backend.Api/InstallScripts.cs b/backend/src/MoShiro.Backend.Api/InstallScripts.cs new file mode 100644 index 0000000..35d427f --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/InstallScripts.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Api; + +/// +/// 一行安装脚本:packaging/install/ 下的 install.sh 和 install.ps1 作为嵌入资源打进 Backend, +/// 这里把角色、网关、代码和镜像填进去。Agent 和 Probe 共用同一份,不分开维护。 +/// +public static class InstallScripts +{ + public static string Render(string resource, InstallTarget target) + { + 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() + .Replace("@@ROLE@@", target.Role, StringComparison.Ordinal) + .Replace("@@SERVER@@", target.Server, StringComparison.Ordinal) + .Replace("@@CODE@@", target.Code, StringComparison.Ordinal) + .Replace("@@IMAGE@@", target.Image, StringComparison.Ordinal); + } +} diff --git a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj index 0111063..fdf3d8b 100644 --- a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj +++ b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj @@ -7,5 +7,7 @@ + + diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index 75779be..9c8d766 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -6,7 +6,7 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Api; -/// 运维端点:/healthz、/metrics(admin 令牌)、安装脚本。 +/// 运维端点:/healthz、/metrics(admin 令牌)、一行安装脚本(/install、/install.ps1)。 public static class OpsEndpoints { private const string Tag = "ops"; @@ -19,10 +19,30 @@ public static class OpsEndpoints app.MapGet("/metrics", MetricsAsync) .Api("metrics_get", "Prometheus metrics (admin token)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403); - app.MapGet("/install.sh", (HttpContext http) => NotShipped(http)) - .Api("install_sh", "Installer for Linux nodes", Tag).ProducesProblems(501); - app.MapGet("/install.ps1", (HttpContext http) => NotShipped(http)) - .Api("install_ps1", "Installer for Windows nodes", Tag).ProducesProblems(501); + // 一行安装:curl -fsSL 'https://panel/install?code=…' | bash(macOS 用 zsh),irm 'https://panel/install.ps1?code=…' | iex(Windows)。 + // Agent 和 Probe 是同一份脚本,角色、网关和镜像由一次性代码决定;代码不能用就是 404。 + app.MapGet("/install", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript")) + .Api("install_sh", "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript").ProducesProblems(404); + app.MapGet("/install.sh", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript")) + .ExcludeFromDescription(); + app.MapGet("/install.ps1", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.ps1", "text/plain")) + .Api("install_ps1", "One-line installer for Windows nodes (agent or probe, decided by the code)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); + } + + private static async Task InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType) + { + string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; + InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted); + if (target is null) + { + return Problems.Create(http, MoShiroErrors.NotFound, "the install code is invalid, expired or already used"); + } + + string script = InstallScripts.Render(resource, target); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Text(script, contentType + "; charset=utf-8"); } private static async Task HealthAsync(HttpContext http, OpsService ops) @@ -46,8 +66,4 @@ public static class OpsEndpoints return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4"); } - - /// 安装脚本需要下载节点程序的发布物,那属于打包与部署(M6),目前还没有。 - private static IResult NotShipped(HttpContext http) => - Problems.Create(http, StatusCodes.Status501NotImplemented, ErrorCodeHttpMap.NotImplemented.Value, "the node installer ships with packaging (M6)"); } diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index 73a7158..bf60a76 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -5,11 +5,14 @@ namespace MoShiro.Backend.Application; /// agent(默认)或 probe:用这个令牌注册出来的是哪种节点。 public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole); -/// 各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。 +/// 各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。 public sealed record InstallCommands(string Linux, string Macos, string Windows); public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands); +/// 安装脚本里要填的四个值。 +public sealed record InstallTarget(string Role, string Server, string Code, string Image); + /// 加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。 public sealed class JoinTokenService { @@ -71,18 +74,44 @@ public sealed class JoinTokenService 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); - string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); - // Probe 和 Agent 一样用加入令牌注册;容器节点用部署脚本,Linux 二进制用 install.sh - string shell = request.Role == ProbeRole - ? $"sudo sh deploy-node.sh probe --server {baseUrl} --join {plaintext} --name " - : $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name "; - // PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴 - string powershell = request.Role == ProbeRole - ? $"fleet-enroll --server {baseUrl} --join {plaintext} --name --identity-dir " - : $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name \""; - return Result.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell))); + string baseUrl = BaseUrl(requestBaseUrl); + string query = $"{baseUrl}/install?code={plaintext}"; + return Result.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands( + $"curl -fsSL '{query}' | bash", + $"curl -fsSL '{query}' | zsh", + $"irm '{baseUrl}/install.ps1?code={plaintext}' | iex"))); } + private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); + + /// + /// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。 + /// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。 + /// + public async ValueTask 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; + } + + /// 值会被放进脚本的单引号里:只允许 URL 和镜像名里正常出现的字符。 + private static bool IsSafe(string value) => value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' or ':' or '/' or '[' or ']' or '~' or '%'); + public ValueTask> ListAsync(CancellationToken ct) => _store.ListAsync(ct); public ValueTask RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 9e6f0d7..27e8cf0 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -12,6 +12,9 @@ public sealed class BackendOptions /// 容器使用的 OCI repository namespace;Compose 默认 moshiro,开发镜像使用 moshiro-dev。 public string ImageNamespace { get; set; } = ""; + /// 节点镜像所在的仓库地址(管理端「添加 Agent / Probe」生成的容器命令用它拼镜像名)。 + public string NodeImageRegistry { get; set; } = "registry.oeo.one"; + /// 容器内更新请求目录;留空使用数据目录下的 update/。 public string? BackendUpdateDirectory { get; set; } diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index a4a4ba4..ce18a42 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -58,6 +58,9 @@ public interface IJoinTokenStore ValueTask> ListAsync(CancellationToken ct); + /// 按令牌的 SHA-256 查找(安装脚本用它确认令牌能不能用,不消耗)。 + ValueTask FindAsync(byte[] tokenSha256, CancellationToken ct); + ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct); ValueTask DeleteAsync(string id, CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs index 63a8bda..40cf6b3 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -32,6 +32,14 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9))), ct); + public ValueTask FindAsync(byte[] tokenSha256, CancellationToken ct) => + _db.ReadAsync( + c => c.QueryOne( + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9)), + ("@h", tokenSha256)), + ct); + public ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct) => _db.WriteAsync(c => c.Exec("UPDATE join_tokens SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct); diff --git a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs index 44f273c..d6bbc84 100644 --- a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs @@ -222,11 +222,12 @@ public sealed class ApiBasicsTests } [Fact] - public async Task InstallScripts_AreNotShippedYet() + public async Task InstallScripts_RequireAValidCode() { + // 一行安装脚本凭一次性代码获取;没有代码或代码不能用是 404(完整行为见 InstallTests) using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install.sh"); - Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode); - Assert.Equal("not_implemented", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue()); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("not_found", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue()); } } diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs new file mode 100644 index 0000000..e7c21c6 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Text.Json.Nodes; + +namespace MoShiro.Backend.Tests; + +/// 一行安装:Agent 和 Probe 共用同一份脚本,角色、网关、镜像由一次性代码决定,节点名字默认取主机名。 +[Collection("probe-e2e")] +public sealed class InstallTests +{ + private readonly BackendFixture _backend; + + public InstallTests(BackendFixture backend) + { + _backend = backend; + } + + private async Task CreateAsync(string role) + { + using HttpResponseMessage response = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = role })); + return JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + } + + [Theory] + [InlineData("agent")] + [InlineData("probe")] + public async Task Commands_AreOneLiners_WithoutAPlaceholder_AndTheScriptIsFilledInForTheRole(string role) + { + JsonNode created = await CreateAsync(role); + string code = created["plaintext"]!.GetValue(); + JsonNode commands = created["install_commands"]!; + string linux = commands["linux"]!.GetValue(); + string macos = commands["macos"]!.GetValue(); + string windows = commands["windows"]!.GetValue(); + Assert.StartsWith("curl -fsSL '", linux, StringComparison.Ordinal); + Assert.EndsWith("/install?code=" + code + "' | bash", linux, StringComparison.Ordinal); + Assert.EndsWith("/install?code=" + code + "' | zsh", macos, StringComparison.Ordinal); + Assert.StartsWith("irm '", windows, StringComparison.Ordinal); + Assert.EndsWith("/install.ps1?code=" + code + "' | iex", windows, StringComparison.Ordinal); + Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符 + + // 脚本不需要登录(拿代码就是授权),不带缓存 + using HttpResponseMessage sh = await _backend.Anonymous.GetAsync("/install?code=" + code); + string script = await sh.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, sh.StatusCode); + Assert.Equal("no-store", sh.Headers.CacheControl?.ToString()); + Assert.Contains($"ROLE='{role}'", script, StringComparison.Ordinal); + Assert.Contains($"CODE='{code}'", script, StringComparison.Ordinal); + Assert.Contains($"/moshiro-{role}:latest'", script, StringComparison.Ordinal); + Assert.Contains("SERVER='http", script, StringComparison.Ordinal); + Assert.DoesNotContain("@@", script, StringComparison.Ordinal); + + using HttpResponseMessage ps = await _backend.Anonymous.GetAsync("/install.ps1?code=" + code); + string powershell = await ps.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, ps.StatusCode); + Assert.Contains($"$role = '{role}'", powershell, StringComparison.Ordinal); + Assert.DoesNotContain("@@", powershell, StringComparison.Ordinal); + } + + [Fact] + public async Task Script_IsNotServedForUnknownRevokedOrUsedUpCodes() + { + foreach (string path in new[] { "/install", "/install?code=", "/install?code=jt_nope", "/install.ps1?code=jt_nope", "/install.sh?code=jt_nope" }) + { + using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + JsonNode revoked = await CreateAsync("agent"); + using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue()); + Assert.True(delete.IsSuccessStatusCode); + using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install?code=" + revoked["plaintext"]!.GetValue()); + Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode); + + // 用完了(一次性令牌已经注册过一个节点) + JsonNode used = await CreateAsync("probe"); + string code = used["plaintext"]!.GetValue(); + using HttpResponseMessage before = await _backend.Anonymous.GetAsync("/install?code=" + code); + Assert.Equal(HttpStatusCode.OK, before.StatusCode); + await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { } + using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install?code=" + code); + Assert.Equal(HttpStatusCode.NotFound, after.StatusCode); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs index 7d9fdc0..d976acc 100644 --- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs @@ -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.sh", "/install.ps1"]; + string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1"]; 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)) { diff --git a/ci/release-node-images.sh b/ci/release-node-images.sh index 0280285..debb513 100644 --- a/ci/release-node-images.sh +++ b/ci/release-node-images.sh @@ -70,6 +70,7 @@ build_arch_image() { install -m 0755 "$work/fleet-enroll" "$context/app/fleet-enroll" install -m 0755 "$work/moshiro-$component" "$context/app/moshiro-$component" : > "$context/config/.keep" + install -m 0755 "$root/packaging/container/entrypoint.sh" "$context/entrypoint.sh" buildah bud --tls-verify=true --arch "$arch" \ --build-arg "APP_VERSION=$version" \ diff --git a/ci/stage-node-image.py b/ci/stage-node-image.py index ab19e03..c27aa30 100644 --- a/ci/stage-node-image.py +++ b/ci/stage-node-image.py @@ -35,6 +35,7 @@ def stage(component: str, destination: Path) -> None: restore.mkdir(parents=True) source.mkdir(parents=True) shutil.copy2(CONTAINERFILES[component], destination / "Containerfile") + shutil.copy2(ROOT / "packaging/container/entrypoint.sh", destination / "entrypoint.sh") for path in BACKEND.rglob("*"): if not path.is_file() or IGNORED_PARTS.intersection(path.parts): diff --git a/docs/api.md b/docs/api.md index 3be38ba..11e0909 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,7 +17,7 @@ public_visible = false 的 Agent 对匿名访客隐藏 /api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或 admin 作用域的 API 令牌(`POST /api/admin/releases` 也接受只能登记发布清单的 release 令牌) /api/agent/v1/* 节点专用:注册(使用加入令牌)和 RPC(使用签名认证) -/healthz /metrics /openapi/v1.json /install.sh /install.ps1 +/healthz /metrics /openapi/v1.json /install /install.ps1 ``` 资源总览: @@ -170,7 +170,7 @@ | `GET /healthz` | 无 | SQLite 与原始数据目录都可写 | | `GET /metrics` | admin 令牌 | Prometheus 格式:在线数、块入库速率、分析队列长度、重算进度、磁盘占用、SSE 连接数 | | `GET /openapi/v1.json` | 无 | | -| `GET /install.sh` `/install.ps1` | 无 | 安装脚本(只下载二进制并写入配置) | +| `GET /install?code=…`(也接受 `/install.sh`)、`GET /install.ps1?code=…` | 无(凭一次性代码) | 一行安装脚本:Agent 和 Probe 共用同一份,角色、面板地址和镜像由代码决定,用 Docker 或 Podman 起容器;代码不能用就是 404 | ## 6. 错误码(节选,完整列表见共享的 `ErrorCode`) diff --git a/docs/deployment.md b/docs/deployment.md index 893a5b3..0347125 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -207,6 +207,21 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 镜像和 Compose 配置共用。普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `moshiro-agent-config`,Probe 保存在 `moshiro-probe-config`,更新状态使用独立的 `moshiro--status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要使用 `compose down -v`,该参数会删除数据。 +**一行安装(Docker 或 Podman,Agent 和 Probe 共用)**:管理端「添加 Agent」「添加 Probe」生成加入令牌之后,给出一行命令,面板地址和一次性代码已经填好,没有要自己替换的占位符;节点名字默认取主机名: + +```sh +curl -fsSL 'https://panel.example.com/install?code=jt_…' | bash # Linux +curl -fsSL 'https://panel.example.com/install?code=jt_…' | zsh # macOS +irm 'https://panel.example.com/install.ps1?code=jt_…' | iex # Windows(PowerShell) +``` + +- 脚本是同一份(`packaging/install/install.sh` 和 `install.ps1`,作为嵌入资源打进 Backend),由 Backend 在 `/install` 和 `/install.ps1` 按代码填好角色、面板地址和镜像;Agent 还是 Probe 由代码决定。代码不存在、已吊销、已过期或用完了都是 404。 +- 脚本检测 Docker 或 Podman(连不上就自动用 sudo),拉镜像,起容器 `moshiro-`:只读根文件系统、丢掉全部能力、Linux 上用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址),macOS 和 Windows 上 Agent 发布 7443、7444 端口;注册成功(日志出现 `link up`)才算装好。 +- 身份和配置在命名卷 `moshiro--config` 里(持久化):镜像入口脚本(`packaging/container/entrypoint.sh`)第一次启动用 `MOSHIRO_SERVER`、`MOSHIRO_JOIN`、`MOSHIRO_NAME` 注册并写配置,之后重启、换新镜像重建容器都不需要再给参数。卷名和部署脚本一致,以后可以直接切到脚本方式。 +- 环境变量:`MOSHIRO_NAME=名字`(想自己定,或者提示名字被占用)、`MOSHIRO_ENGINE=docker|podman`、`MOSHIRO_DRY_RUN=1`(只打印 run 命令)。Podman 会顺便启用 `podman-restart`,容器开机自启。 +- 镜像地址由 Backend 的 `MoShiro__NodeImageRegistry`(默认 `registry.oeo.one`)和 `MoShiro__ImageNamespace`(默认 `moshiro`,开发镜像 `moshiro-dev`)拼出;私有仓库先 `docker login` 或 `podman login`。 +- 更新:拉新镜像、删掉旧容器、重新执行安装命令(卷保留);要自动更新(签名清单)用下面的部署脚本。 + **用脚本部署和更新(rootful Podman 或 Docker,推荐)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。 ```sh diff --git a/docs/enrollment.md b/docs/enrollment.md index 2786ca2..614e3d3 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -16,10 +16,12 @@ ```text 管理员 ── 创建加入令牌 {标签, max_uses, 过期时间, auto_approve} ──> 面板(Fleet.Server.Enrollment) - <── 安装命令: - curl -fsSL https://panel/install.sh | sh -s -- --join jt_xxx --name jp-1 + <── 一行安装命令(Agent 和 Probe 共用,没有占位符,节点名字默认取主机名): + curl -fsSL 'https://panel/install?code=jt_xxx' | bash (macOS 用 zsh) + irm 'https://panel/install.ps1?code=jt_xxx' | iex (Windows) -install.sh(只做安装,不包含任何远程控制功能;容器节点的做法见 deployment.md) +面板按代码生成脚本(packaging/install/,嵌入 Backend):用 Docker 或 Podman 起容器,镜像入口用代码注册,身份和配置在命名卷里。 +下面是发布包里的 install.sh(二进制安装,不包含任何远程控制功能;容器节点的做法见 deployment.md): ├─ 下载 moshiro-agent、fleet-enroll,并校验 SHA-256 ├─ 创建系统用户 moshiro(运行 Agent) ├─ fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/moshiro @@ -92,7 +94,7 @@ Probe 启动 | GET/PATCH/DELETE | `/api/admin/probes`、`/probes/{id}` | Probe 的列表、改名、删除 | | POST | `/api/admin/probes/{id}/revoke` | 吊销(换密钥就是吊销之后重新注册,没有轮换) | | POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让 Agent 断开某个 Probe | -| GET | `/install.sh`、`/install.ps1` | 安装脚本 | +| GET | `/install?code=…`、`/install.ps1?code=…` | 一行安装脚本(凭一次性代码,Agent 和 Probe 共用) | ## Agent 的本地配置:主机状态上报 diff --git a/packaging/container/Agent.Containerfile b/packaging/container/Agent.Containerfile index 15156ec..880220d 100644 --- a/packaging/container/Agent.Containerfile +++ b/packaging/container/Agent.Containerfile @@ -9,9 +9,11 @@ LABEL org.opencontainers.image.title="MoShiro Agent" \ COPY --chown=1654:1654 app/moshiro-agent /usr/local/bin/moshiro-agent COPY --chown=1654:1654 app/fleet-enroll /usr/local/bin/fleet-enroll COPY --chown=1654:1654 config/ /etc/moshiro/ +COPY --chown=1654:1654 --chmod=0755 entrypoint.sh /usr/local/bin/moshiro-entrypoint +ENV MOSHIRO_ROLE=agent USER 1654:1654 WORKDIR /etc/moshiro VOLUME ["/etc/moshiro"] -ENTRYPOINT ["/usr/local/bin/moshiro-agent"] +ENTRYPOINT ["/usr/local/bin/moshiro-entrypoint"] CMD ["--config", "/etc/moshiro/agent.json"] diff --git a/packaging/container/Agent.NativeBuild.Containerfile b/packaging/container/Agent.NativeBuild.Containerfile index ef818a6..6b691ae 100644 --- a/packaging/container/Agent.NativeBuild.Containerfile +++ b/packaging/container/Agent.NativeBuild.Containerfile @@ -35,9 +35,11 @@ LABEL org.opencontainers.image.title="MoShiro Agent" \ COPY --chown=1654:1654 --from=build /out/agent/moshiro-agent /usr/local/bin/moshiro-agent COPY --chown=1654:1654 --from=build /out/enroll/fleet-enroll /usr/local/bin/fleet-enroll COPY --chown=1654:1654 config/ /etc/moshiro/ +COPY --chown=1654:1654 --chmod=0755 entrypoint.sh /usr/local/bin/moshiro-entrypoint +ENV MOSHIRO_ROLE=agent USER 1654:1654 WORKDIR /etc/moshiro VOLUME ["/etc/moshiro"] -ENTRYPOINT ["/usr/local/bin/moshiro-agent"] +ENTRYPOINT ["/usr/local/bin/moshiro-entrypoint"] CMD ["--config", "/etc/moshiro/agent.json"] diff --git a/packaging/container/Probe.Containerfile b/packaging/container/Probe.Containerfile index 8ade640..e5579f1 100644 --- a/packaging/container/Probe.Containerfile +++ b/packaging/container/Probe.Containerfile @@ -9,9 +9,11 @@ LABEL org.opencontainers.image.title="MoShiro Probe" \ COPY --chown=1654:1654 app/moshiro-probe /usr/local/bin/moshiro-probe COPY --chown=1654:1654 app/fleet-enroll /usr/local/bin/fleet-enroll COPY --chown=1654:1654 config/ /etc/moshiro/ +COPY --chown=1654:1654 --chmod=0755 entrypoint.sh /usr/local/bin/moshiro-entrypoint +ENV MOSHIRO_ROLE=probe USER 1654:1654 WORKDIR /etc/moshiro VOLUME ["/etc/moshiro"] -ENTRYPOINT ["/usr/local/bin/moshiro-probe"] +ENTRYPOINT ["/usr/local/bin/moshiro-entrypoint"] CMD ["--config", "/etc/moshiro/probe.json"] diff --git a/packaging/container/Probe.NativeBuild.Containerfile b/packaging/container/Probe.NativeBuild.Containerfile index e68781b..1982032 100644 --- a/packaging/container/Probe.NativeBuild.Containerfile +++ b/packaging/container/Probe.NativeBuild.Containerfile @@ -35,9 +35,11 @@ LABEL org.opencontainers.image.title="MoShiro Probe" \ COPY --chown=1654:1654 --from=build /out/probe/moshiro-probe /usr/local/bin/moshiro-probe COPY --chown=1654:1654 --from=build /out/enroll/fleet-enroll /usr/local/bin/fleet-enroll COPY --chown=1654:1654 config/ /etc/moshiro/ +COPY --chown=1654:1654 --chmod=0755 entrypoint.sh /usr/local/bin/moshiro-entrypoint +ENV MOSHIRO_ROLE=probe USER 1654:1654 WORKDIR /etc/moshiro VOLUME ["/etc/moshiro"] -ENTRYPOINT ["/usr/local/bin/moshiro-probe"] +ENTRYPOINT ["/usr/local/bin/moshiro-entrypoint"] CMD ["--config", "/etc/moshiro/probe.json"] diff --git a/packaging/container/entrypoint.sh b/packaging/container/entrypoint.sh new file mode 100644 index 0000000..e701be1 --- /dev/null +++ b/packaging/container/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# 容器入口(Agent 和 Probe 共用):第一次启动用 MOSHIRO_SERVER / MOSHIRO_JOIN / MOSHIRO_NAME 自己注册并写配置, +# 身份和配置都在命名卷 /etc/moshiro 里,之后重启(或换新镜像重建容器)直接启动,不需要再给参数。 +set -eu + +role="${MOSHIRO_ROLE:?MOSHIRO_ROLE is set by the image}" +dir=/etc/moshiro +identity="$dir/$role-id" +config="$dir/$role.json" + +if [ ! -f "$identity/node.json" ]; then + if [ -z "${MOSHIRO_SERVER:-}" ] || [ -z "${MOSHIRO_JOIN:-}" ] || [ -z "${MOSHIRO_NAME:-}" ]; then + echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2 + exit 1 + fi + /usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" +fi + +if [ ! -f "$config" ]; then + if [ -z "${MOSHIRO_SERVER:-}" ]; then + echo "$config is missing: set MOSHIRO_SERVER to create it" >&2 + exit 1 + fi + if [ "$role" = agent ]; then + printf '{"fleet":{"server":"%s","identity_dir":"%s"},"listen":{"bind":"::"}}\n' "$MOSHIRO_SERVER" "$identity" > "$config" + else + printf '{"fleet":{"server":"%s","identity_dir":"%s"}}\n' "$MOSHIRO_SERVER" "$identity" > "$config" + fi +fi + +exec "/usr/local/bin/moshiro-$role" "$@" diff --git a/packaging/install/install.ps1 b/packaging/install/install.ps1 new file mode 100644 index 0000000..5c43dea --- /dev/null +++ b/packaging/install/install.ps1 @@ -0,0 +1,77 @@ +# MoShiro 节点一键安装(Windows,Agent 和 Probe 共用同一份)。面板在 /install.ps1?code=… 生成这份脚本,把下面四个值填好: +# irm 'https://panel/install.ps1?code=jt_…' | iex +# 做的事:用 Docker Desktop 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取计算机名。 +# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)。 +function Install-MoShiro { + $ErrorActionPreference = 'Stop' + $role = '@@ROLE@@' + $server = '@@SERVER@@' + $code = '@@CODE@@' + $image = '@@IMAGE@@' + + $name = if ($env:MOSHIRO_NAME) { $env:MOSHIRO_NAME } else { $env:COMPUTERNAME } + $name = $name -replace '[^A-Za-z0-9._-]', '-' + if ($name.Length -gt 64) { $name = $name.Substring(0, 64) } + if (-not $name) { $name = 'node' } + + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + Write-Error 'Docker Desktop is required: https://docs.docker.com/desktop/setup/install/windows-install/' + return + } + docker info *> $null + if ($LASTEXITCODE -ne 0) { + Write-Error 'Docker is installed but not running: start Docker Desktop and run this again' + return + } + + $container = "moshiro-$role" + $volume = "moshiro-$role-config" + docker inspect $container *> $null + if ($LASTEXITCODE -eq 0) { + docker start $container | Out-Null + Write-Host "$container is already installed and running." + Write-Host "reinstall: docker rm -f $container; docker volume rm $volume (then run the install command again)" + return + } + + Write-Host "pulling $image" + docker pull $image | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "could not pull $image (private registry? run: docker login $($image.Split('/')[0]))" + return + } + + $arguments = @('run', '-d', '--name', $container, '--restart', 'unless-stopped', + '--read-only', '--tmpfs', '/tmp:rw,noexec,nosuid,size=64m', '--cap-drop', 'all', '--security-opt', 'no-new-privileges', + '-v', "${volume}:/etc/moshiro") + # Docker Desktop 没有宿主网络:Agent 把端口发布出来(面板里的地址需要指向这台机器的公网地址) + if ($role -eq 'agent') { $arguments += @('-p', '7443:7443/tcp', '-p', '7443:7443/udp', '-p', '7444:7444/tcp') } + $arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name", $image) + + Write-Host "starting $container as '$name'" + docker @arguments | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error 'docker run failed' + return + } + + # 等注册完成:第一次启动会用上面的令牌注册,成功之后才算装好 + for ($i = 0; $i -lt 30; $i++) { + $running = docker inspect -f '{{.State.Running}}' $container 2>$null + $logs = (docker logs $container 2>&1) -join "`n" + if ($logs -match 'link up') { + Write-Host "done: $role '$name' is online. It shows up in the panel now." + Write-Host "logs: docker logs -f $container" + return + } + if ($running -ne 'true') { + ($logs -split "`n" | Select-Object -Last 8) -join "`n" | Write-Host + Write-Error "$container stopped. If the name '$name' is taken, run again with `$env:MOSHIRO_NAME=''. To start over: docker rm -f $container; docker volume rm $volume" + return + } + Start-Sleep -Seconds 1 + } + Write-Host "$container is running, but has not reported online yet. Check: docker logs $container" +} + +Install-MoShiro diff --git a/packaging/install/install.sh b/packaging/install/install.sh new file mode 100644 index 0000000..1a012ac --- /dev/null +++ b/packaging/install/install.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# MoShiro 节点一键安装(Agent 和 Probe 共用同一份)。面板在 /install?code=… 生成这份脚本,把下面四个值填好: +# curl -fsSL 'https://panel/install?code=jt_…' | bash (macOS 用 zsh) +# 做的事:用 Docker 或 Podman 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取主机名。 +# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=1(只打印命令)。 +set -eu + +ROLE='@@ROLE@@' +SERVER='@@SERVER@@' +CODE='@@CODE@@' +IMAGE='@@IMAGE@@' + +say() { printf '%s\n' "$*"; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +name="${MOSHIRO_NAME:-$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo node)}" +name="$(printf '%s' "$name" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-64)" +[ -n "$name" ] || name=node + +engine="${MOSHIRO_ENGINE:-}" +if [ -z "$engine" ]; then + if command -v docker >/dev/null 2>&1; then engine=docker + elif command -v podman >/dev/null 2>&1; then engine=podman + else die "Docker or Podman is required (https://docs.docker.com/engine/install/)"; fi +fi +command -v "$engine" >/dev/null 2>&1 || die "$engine is not installed" + +# 当前用户连不上引擎(不在 docker 组、rootful podman)就用 sudo +use_sudo=0 +if ! "$engine" info >/dev/null 2>&1 /dev/null 2>&1 || die "$engine needs root: run this as root or install sudo" + use_sudo=1 +fi +eng() { if [ "$use_sudo" -eq 1 ]; then sudo "$engine" "$@"; else "$engine" "$@"; fi; } + +container="moshiro-$ROLE" +volume="moshiro-$ROLE-config" +linux=0 +[ "$(uname -s)" = Linux ] && linux=1 + +restart=unless-stopped +[ "$engine" = podman ] && restart=always +set -- run -d --name "$container" --restart "$restart" \ + --read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --cap-drop all --security-opt no-new-privileges \ + -v "$volume:/etc/moshiro" +[ "$engine" = podman ] && set -- "$@" --security-opt label=disable +if [ "$linux" -eq 1 ]; then + # 宿主网络:Agent 对外暴露端口,也靠它识别本机的公网 IPv4 和 IPv6 地址 + set -- "$@" --network host + if [ "$ROLE" = agent ]; then + # 主机资源占用(只读),管理端的 Agent 详情页显示 CPU、内存、磁盘、网络 + set -- "$@" -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro \ + -e MOSHIRO_NETWORK_SCOPE=host -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root + fi +elif [ "$ROLE" = agent ]; then + # macOS 上的 Docker 没有宿主网络:把 Agent 的端口发布出来(面板里的地址需要指向这台机器的公网地址) + set -- "$@" -p 7443:7443/tcp -p 7443:7443/udp -p 7444:7444/tcp +fi +set -- "$@" -e "MOSHIRO_SERVER=$SERVER" -e "MOSHIRO_JOIN=$CODE" -e "MOSHIRO_NAME=$name" "$IMAGE" + +if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then + say "$engine $*" + exit 0 +fi + +if eng inspect "$container" >/dev/null 2>&1 /dev/null /dev/null /dev/null /dev/null 2>&1; then + if [ "$(id -u)" -eq 0 ]; then systemctl enable --now podman-restart >/dev/null 2>&1 || true + elif [ "$use_sudo" -eq 1 ]; then sudo systemctl enable --now podman-restart >/dev/null 2>&1 || true; fi +fi + +# 等注册完成:第一次启动会用上面的令牌注册,成功之后才算装好 +i=0 +while [ "$i" -lt 30 ]; do + state="$(eng inspect -f '{{.State.Running}}' "$container" 2>/dev/null &1 &2 + die "$container stopped. If the name '$name' is taken, run again with MOSHIRO_NAME=. To start over: $engine rm -f $container && $engine volume rm $volume" + fi + i=$((i + 1)) + sleep 1 +done +say "$container is running, but has not reported online yet. Check: $engine logs $container" diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index 115a8d5..b8b7673 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -6,7 +6,8 @@ import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/ import { fmtAgo, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { NodeTag, useNodeInfos } from '@shared/nodes' -import { Chip, CommandTabs, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, guessPlatform, Segmented, Spinner, useAction } from '@shared/ui' +import { Chip, Confirm, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' +import { JoinTokenCreated } from './JoinTokenCreated' export const STATUS: Record = { active: { label: '已启用', tone: 'ok' }, @@ -33,43 +34,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated const [hours, setHours] = useState(24) const [created, setCreated] = useState() - if (created) { - return ( - - 完成 - - } - > -
-
令牌明文只显示这一次,请现在就复制。
- - - - -
{created.plaintext}
-
- -
-
-

- {created.token.auto_approve ? '使用这个令牌注册的 Agent 会自动启用。' : '使用这个令牌注册的 Agent 需要你在列表里审批之后才会启用。'} 可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。 -

-
-
- ) - } + if (created) return return ( void }) { + const commands = created.install_commands + return ( + + 完成 + + } + > +
+
命令里的代码只显示这一次,请现在就复制。
+ + + + +
{created.plaintext}
+
+ +
+
+

+ {kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''} + 可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。 +

+
+
+ ) +} diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index a8d617f..eb31244 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -7,7 +7,8 @@ import { fmtAgo, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { NodeTag, useNodeInfos } from '@shared/nodes' import { IPV6_STATES } from '@shared/ipVersions' -import { Chip, CommandTabs, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, guessPlatform, Spinner, useAction } from '@shared/ui' +import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' +import { JoinTokenCreated } from './JoinTokenCreated' /** * 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的; @@ -96,41 +97,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated const [hours, setHours] = useState(24) const [created, setCreated] = useState() - if (created) { - return ( - - 完成 - - } - > -
-
令牌明文只显示这一次,请现在就复制。
- - - - -
{created.plaintext}
-
- -
-
-

一次性令牌,{fmtTime(created.token.expires_at)} 过期。

-
-
- ) - } + if (created) return return (