feat: one-line install for agents and probes from one shared script (curl | bash, zsh, PowerShell) [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-07 20:02:38 +08:00
1 parent 5528753d45
commit af2962fc58
29 files changed
+558 -124

No files matched your search

+3
View File
@@ -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
+31 -7
View File
@@ -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": {
@@ -0,0 +1,23 @@
using System.Reflection;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
/// <summary>
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,
/// 这里把角色、网关、代码和镜像填进去。Agent 和 Probe 共用同一份,不分开维护。
/// </summary>
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);
}
}
@@ -7,5 +7,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
</ItemGroup>
</Project>
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、安装脚本。</summary>
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、一行安装脚本(<c>/install</c>、<c>/install.ps1</c>)。</summary>
public static class OpsEndpoints
{
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<IResult> 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<IResult> 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");
}
/// <summary>安装脚本需要下载节点程序的发布物,那属于打包与部署(M6),目前还没有。</summary>
private static IResult NotShipped(HttpContext http) =>
Problems.Create(http, StatusCodes.Status501NotImplemented, ErrorCodeHttpMap.NotImplemented.Value, "the node installer ships with packaging (M6)");
}
@@ -5,11 +5,14 @@ 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);
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
public sealed record InstallCommands(string Linux, string Macos, string Windows);
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
/// <summary>安装脚本里要填的四个值。</summary>
public sealed record InstallTarget(string Role, string Server, string Code, string Image);
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
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 <node-name>"
: $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
string powershell = request.Role == ProbeRole
? $"fleet-enroll --server {baseUrl} --join {plaintext} --name <node-name> --identity-dir <dir>"
: $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
string baseUrl = BaseUrl(requestBaseUrl);
string query = $"{baseUrl}/install?code={plaintext}";
return Result<CreatedJoinToken>.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('/');
/// <summary>
/// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。
/// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。
/// </summary>
public async ValueTask<InstallTarget?> ResolveInstallAsync(string code, string? requestBaseUrl, CancellationToken ct)
{
if (code.Length is 0 or > 200)
{
return null;
}
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct);
if (token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses)
{
return null;
}
string role = RoleOf(token.Kind);
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
string ns = _options.ImageNamespace.Trim('/');
string image = $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}:latest";
string server = BaseUrl(requestBaseUrl);
return IsSafe(server) && IsSafe(image) && IsSafe(code) ? new InstallTarget(role, server, code, image) : null;
}
/// <summary>值会被放进脚本的单引号里:只允许 URL 和镜像名里正常出现的字符。</summary>
private static bool IsSafe(string value) => value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' or ':' or '/' or '[' or ']' or '~' or '%');
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
@@ -12,6 +12,9 @@ public sealed class BackendOptions
/// <summary>容器使用的 OCI repository namespace;Compose 默认 moshiro,开发镜像使用 moshiro-dev。</summary>
public string ImageNamespace { get; set; } = "";
/// <summary>节点镜像所在的仓库地址(管理端「添加 Agent / Probe」生成的容器命令用它拼镜像名)。</summary>
public string NodeImageRegistry { get; set; } = "registry.oeo.one";
/// <summary>容器内更新请求目录;留空使用数据目录下的 update/。</summary>
public string? BackendUpdateDirectory { get; set; }
@@ -58,6 +58,9 @@ public interface IJoinTokenStore
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
/// <summary>按令牌的 SHA-256 查找(安装脚本用它确认令牌能不能用,不消耗)。</summary>
ValueTask<JoinTokenInfo?> FindAsync(byte[] tokenSha256, CancellationToken ct);
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
@@ -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<JoinTokenInfo?> 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<bool> 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);
@@ -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<string>());
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("not_found", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
}
}
@@ -0,0 +1,83 @@
using System.Net;
using System.Text.Json.Nodes;
namespace MoShiro.Backend.Tests;
/// <summary>一行安装:Agent 和 Probe 共用同一份脚本,角色、网关、镜像由一次性代码决定,节点名字默认取主机名。</summary>
[Collection("probe-e2e")]
public sealed class InstallTests
{
private readonly BackendFixture _backend;
public InstallTests(BackendFixture backend)
{
_backend = backend;
}
private async Task<JsonNode> 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<string>();
JsonNode commands = created["install_commands"]!;
string linux = commands["linux"]!.GetValue<string>();
string macos = commands["macos"]!.GetValue<string>();
string windows = commands["windows"]!.GetValue<string>();
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<string>());
Assert.True(delete.IsSuccessStatusCode);
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install?code=" + revoked["plaintext"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
// 用完了(一次性令牌已经注册过一个节点)
JsonNode used = await CreateAsync("probe");
string code = used["plaintext"]!.GetValue<string>();
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);
}
}
@@ -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))
{
+1
View File
@@ -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" \
+1
View File
@@ -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):
+2 -2
View File
@@ -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`)
+15
View File
@@ -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-<role>-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-<role>`:只读根文件系统、丢掉全部能力、Linux 上用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址),macOS 和 Windows 上 Agent 发布 7443、7444 端口;注册成功(日志出现 `link up`)才算装好。
- 身份和配置在命名卷 `moshiro-<role>-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
+6 -4
View File
@@ -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 的本地配置:主机状态上报
+3 -1
View File
@@ -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"]
@@ -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"]
+3 -1
View File
@@ -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"]
@@ -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"]
+31
View File
@@ -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" "$@"
+77
View File
@@ -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='<other-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
+103
View File
@@ -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; then
[ "$(id -u)" -ne 0 ] || die "$engine is installed but not running"
command -v sudo >/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; then
eng start "$container" >/dev/null </dev/null
say "$container is already installed and running."
say "reinstall: $engine rm -f $container && $engine volume rm $volume (then run the install command again)"
exit 0
fi
say "pulling $IMAGE"
if ! eng pull "$IMAGE" >/dev/null </dev/null; then
die "could not pull $IMAGE (private registry? run: $engine login ${IMAGE%%/*})"
fi
say "starting $container as '$name'"
eng "$@" >/dev/null </dev/null
# Podman 的容器开机自启要靠 podman-restart.service
if [ "$engine" = podman ] && command -v systemctl >/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 </dev/null || echo false)"
logs="$(eng logs "$container" 2>&1 </dev/null || true)"
case "$logs" in
*"link up"*) say "done: $ROLE '$name' is online. It shows up in the panel now."; say "logs: $engine logs -f $container"; exit 0 ;;
esac
if [ "$state" != true ]; then
printf '%s\n' "$logs" | tail -n 8 >&2
die "$container stopped. If the name '$name' is taken, run again with MOSHIRO_NAME=<other-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"
+3 -38
View File
@@ -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<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
active: { label: '已启用', tone: 'ok' },
@@ -33,43 +34,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
const [hours, setHours] = useState(24)
const [created, setCreated] = useState<CreatedJoinTokenDto>()
if (created) {
return (
<Dialog
title="加入令牌已创建"
onClose={onClose}
actions={
<button type="button" className="btn primary" onClick={onClose}>
完成
</button>
}
>
<div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<Field label="在要加入的机器上运行">
<CommandTabs
storageKey="moshiro.install_tab"
initial={guessPlatform()}
tabs={[
{ id: 'linux', label: 'Linux', command: created.install_commands.linux },
{ id: 'macos', label: 'macOS', command: created.install_commands.macos },
{ id: 'windows', label: 'Windows', command: created.install_commands.windows },
]}
/>
</Field>
<Field label="令牌">
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} label="复制令牌" />
</div>
</Field>
<p className="muted">
{created.token.auto_approve ? '使用这个令牌注册的 Agent 会自动启用。' : '使用这个令牌注册的 Agent 需要你在列表里审批之后才会启用。'} 可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
</p>
</div>
</Dialog>
)
}
if (created) return <JoinTokenCreated created={created} kind="Agent" onClose={onClose} />
return (
<Dialog
+50
View File
@@ -0,0 +1,50 @@
import type { CreatedJoinTokenDto } from '@shared/api/types'
import { fmtTime } from '@shared/format'
import { CommandTabs, CopyButton, Dialog, Field, guessPlatform } from '@shared/ui'
/**
* 加入令牌创建之后的对话框:Agent 和 Probe 共用同一份,一行命令装好。
* 命令里只有一次性代码,角色由代码决定;节点名字默认取主机名。
*/
export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJoinTokenDto; kind: 'Agent' | 'Probe'; onClose: () => void }) {
const commands = created.install_commands
return (
<Dialog
title="加入令牌已创建"
onClose={onClose}
actions={
<button type="button" className="btn primary" onClick={onClose}>
完成
</button>
}
>
<div className="stack">
<div className="notice warn">命令里的代码只显示这一次,请现在就复制。</div>
<Field
label={`在要加入的机器上运行(${kind})`}
hint="需要 Docker 或 Podman。命令里已经填好面板地址和一次性代码,节点名字默认取主机名;身份和配置存在命名卷里,重启、换新镜像重建容器都不用再给参数。名字想自己定,或者提示名字被占用,在命令里加上 MOSHIRO_NAME=名字(Linux 和 macOS 写在 bash 或 zsh 前面,Windows 先执行 $env:MOSHIRO_NAME='名字')。"
>
<CommandTabs
storageKey="moshiro.install_tab"
initial={guessPlatform()}
tabs={[
{ id: 'linux', label: 'Linux', command: commands.linux },
{ id: 'macos', label: 'macOS', command: commands.macos },
{ id: 'windows', label: 'Windows', command: commands.windows },
]}
/>
</Field>
<Field label="代码">
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} label="复制代码" />
</div>
</Field>
<p className="muted">
{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
</p>
</div>
</Dialog>
)
}
+3 -36
View File
@@ -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<CreatedJoinTokenDto>()
if (created) {
return (
<Dialog
title="加入令牌已创建"
onClose={onClose}
actions={
<button type="button" className="btn primary" onClick={onClose}>
完成
</button>
}
>
<div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<Field label="在家宽机器上运行" hint="把 <node-name> 换成这台 Probe 的名字,例如 home。Probe 会自己生成密钥并注册;Agent 列表和更新都由面板下发。">
<CommandTabs
storageKey="moshiro.install_tab"
initial={guessPlatform()}
tabs={[
{ id: 'linux', label: 'Linux', command: created.install_commands.linux },
{ id: 'macos', label: 'macOS', command: created.install_commands.macos },
{ id: 'windows', label: 'Windows', command: created.install_commands.windows },
]}
/>
</Field>
<Field label="令牌">
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} label="复制令牌" />
</div>
</Field>
<p className="muted">一次性令牌,{fmtTime(created.token.expires_at)} 过期。</p>
</div>
</Dialog>
)
}
if (created) return <JoinTokenCreated created={created} kind="Probe" onClose={onClose} />
return (
<Dialog
+27 -9
View File
@@ -953,14 +953,14 @@ export interface paths {
patch?: never;
trace?: never;
};
"/install.sh": {
"/install": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Installer for Linux nodes */
/** One-line installer for Linux and macOS nodes (agent or probe, decided by the code) */
get: operations["install_sh"];
put?: never;
post?: never;
@@ -977,7 +977,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Installer for Windows nodes */
/** One-line installer for Windows nodes (agent or probe, decided by the code) */
get: operations["install_ps1"];
put?: never;
post?: never;
@@ -5115,15 +5115,24 @@ export interface operations {
};
install_sh: {
parameters: {
query?: never;
query?: {
code?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Not Implemented */
501: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
@@ -5135,15 +5144,24 @@ export interface operations {
};
install_ps1: {
parameters: {
query?: never;
query?: {
code?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Not Implemented */
501: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
+1
View File
@@ -765,6 +765,7 @@ pre.code {
font-size: 12.5px;
line-height: 1.6;
word-break: break-all;
white-space: pre-wrap; /* 容器命令是多行的(行尾反斜杠续行) */
user-select: all;
}