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
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
5528753d45
commit
af2962fc58
29 files changed
+558
-124
No files matched your search
@@ -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
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
@@ -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 的本地配置:主机状态上报
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user