253 lines
15 KiB
C#
253 lines
15 KiB
C#
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace MoShiro.Backend.Tests;
|
|
|
|
/// <summary>
|
|
/// 一行安装:Agent 和 Probe 共用同一份<b>静态</b>脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行),
|
|
/// 面板地址、角色、一次性代码都走命令里的环境变量;脚本再向面板取 CI 签名的发布清单来验证镜像。
|
|
/// </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_VerifyTheScriptHash_ThenRunItWithThePublicValues(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("f=$(mktemp) && curl -fsSL '", linux, StringComparison.Ordinal);
|
|
Assert.Contains("/install' -o \"$f\" && echo \"", linux, StringComparison.Ordinal);
|
|
Assert.Contains("sha256sum -c -", linux, StringComparison.Ordinal);
|
|
Assert.Contains($"MOSHIRO_ROLE='{role}'", linux, StringComparison.Ordinal);
|
|
Assert.Contains($"MOSHIRO_CODE='{code}'", linux, StringComparison.Ordinal);
|
|
Assert.EndsWith("sh \"$f\"", linux, StringComparison.Ordinal);
|
|
Assert.Contains("shasum -a 256 -c -", macos, StringComparison.Ordinal);
|
|
Assert.StartsWith("$f = Join-Path $env:TEMP", windows, StringComparison.Ordinal);
|
|
Assert.Contains($"$env:MOSHIRO_ROLE='{role}'", windows, StringComparison.Ordinal);
|
|
Assert.EndsWith("iex (Get-Content -Raw $f)", windows, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("agent")]
|
|
[InlineData("probe")]
|
|
public async Task ComposeInstall_WritesThePublicValuesToAnEnvFile_AndChecksTheComposeHash(string role)
|
|
{
|
|
JsonNode created = await CreateAsync(role);
|
|
string code = created["plaintext"]!.GetValue<string>();
|
|
JsonNode compose = created["compose"]!;
|
|
string env = compose["env"]!.GetValue<string>();
|
|
string url = compose["url"]!.GetValue<string>();
|
|
string linux = compose["linux"]!.GetValue<string>();
|
|
|
|
Assert.EndsWith($"/install/compose/{role}.yaml", url, StringComparison.Ordinal);
|
|
Assert.Contains($"MOSHIRO_JOIN={code}", env, StringComparison.Ordinal);
|
|
Assert.Contains("MOSHIRO_SERVER=", env, StringComparison.Ordinal);
|
|
Assert.Contains($"/moshiro-{role}", env, StringComparison.Ordinal); // MOSHIRO_IMAGE:仓库和命名空间来自后端的配置,不是写死的
|
|
Assert.StartsWith($"mkdir -p moshiro-{role} && cd moshiro-{role} && cat > .env <<'EOF'\n", linux, StringComparison.Ordinal);
|
|
Assert.Contains(env, linux, StringComparison.Ordinal);
|
|
Assert.Contains("chmod 600 .env", linux, StringComparison.Ordinal);
|
|
Assert.Contains($"echo \"{compose["sha256"]!.GetValue<string>()} compose.yaml\" | sha256sum -c -", linux, StringComparison.Ordinal);
|
|
Assert.EndsWith("podman compose up -d", linux, StringComparison.Ordinal);
|
|
|
|
// 面板提供的就是命令里核对的那份
|
|
using HttpResponseMessage served = await _backend.Anonymous.GetAsync($"/install/compose/{role}.yaml");
|
|
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
|
|
Assert.Equal("text/yaml", served.Content.Headers.ContentType?.MediaType);
|
|
byte[] body = await served.Content.ReadAsByteArrayAsync();
|
|
Assert.Equal(compose["sha256"]!.GetValue<string>(), Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(body)));
|
|
|
|
if (role == "probe")
|
|
{
|
|
// Docker Desktop 没有宿主网络:macOS 和 Windows 的 .env 里改成 bridge
|
|
string macos = compose["macos"]!.GetValue<string>();
|
|
string windows = compose["windows"]!.GetValue<string>();
|
|
Assert.Contains("MOSHIRO_NETWORK_MODE=bridge", macos + windows, StringComparison.Ordinal);
|
|
Assert.Contains("docker compose up -d", macos, StringComparison.Ordinal);
|
|
Assert.Contains("Get-FileHash compose.yaml", windows, StringComparison.Ordinal);
|
|
}
|
|
else
|
|
{
|
|
// Agent 需要 Linux 的宿主网络
|
|
Assert.Equal(string.Empty, compose["macos"]!.GetValue<string>());
|
|
Assert.Equal(string.Empty, compose["windows"]!.GetValue<string>());
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("agent")]
|
|
[InlineData("probe")]
|
|
public async Task QuadletInstall_ChecksTheUnitHash_PointsTheImageAtTheConfiguredRepository_AndLeavesAutoUpdateOn(string role)
|
|
{
|
|
JsonNode created = await CreateAsync(role);
|
|
JsonNode quadlet = created["quadlet"]!;
|
|
string env = quadlet["env"]!.GetValue<string>();
|
|
string command = quadlet["command"]!.GetValue<string>();
|
|
string url = quadlet["url"]!.GetValue<string>();
|
|
|
|
Assert.EndsWith($"/install/quadlet/{role}.container", url, StringComparison.Ordinal);
|
|
Assert.Contains($"MOSHIRO_JOIN={created["plaintext"]!.GetValue<string>()}", env, StringComparison.Ordinal);
|
|
Assert.Contains("# MOSHIRO_UPDATE_POLICY=manual", env, StringComparison.Ordinal); // 默认自动更新,env 里只留一行注释说明怎么改成手动
|
|
Assert.DoesNotContain("\nMOSHIRO_UPDATE_POLICY=", env, StringComparison.Ordinal);
|
|
Assert.Contains($"~/.config/moshiro/{role}.env", command, StringComparison.Ordinal);
|
|
Assert.Contains("chmod 600", command, StringComparison.Ordinal);
|
|
Assert.Contains($"echo \"{quadlet["sha256"]!.GetValue<string>()} $HOME/.config/containers/systemd/moshiro-{role}.container\" | sha256sum -c -", command, StringComparison.Ordinal);
|
|
Assert.Contains($"Image=", command, StringComparison.Ordinal);
|
|
Assert.Contains($"/moshiro-{role}:latest", command, StringComparison.Ordinal);
|
|
Assert.EndsWith($"systemctl --user daemon-reload && systemctl --user start moshiro-{role}", command, StringComparison.Ordinal);
|
|
|
|
// 面板提供的就是命令里核对的那份:Pull=always(退出再被拉起就是更新),并告诉节点外面会重新拉取
|
|
using HttpResponseMessage served = await _backend.Anonymous.GetAsync($"/install/quadlet/{role}.container");
|
|
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
|
|
byte[] body = await served.Content.ReadAsByteArrayAsync();
|
|
Assert.Equal(quadlet["sha256"]!.GetValue<string>(), Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(body)));
|
|
string unit = System.Text.Encoding.UTF8.GetString(body);
|
|
Assert.Contains("\nPull=always", unit, StringComparison.Ordinal);
|
|
Assert.Contains("Environment=MOSHIRO_UPDATE=restart", unit, StringComparison.Ordinal);
|
|
Assert.Contains("ReadOnly=true", unit, StringComparison.Ordinal);
|
|
Assert.Contains("DropCapability=ALL", unit, StringComparison.Ordinal);
|
|
Assert.Contains("NoNewPrivileges=true", unit, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("jt_", unit, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("evil.container")]
|
|
[InlineData("backend.container")]
|
|
[InlineData("agent")]
|
|
public async Task OnlyTheTwoQuadletUnits_AreServed(string file)
|
|
{
|
|
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install/quadlet/" + file);
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("agent.yaml")]
|
|
[InlineData("probe.yaml")]
|
|
[InlineData("backend.yaml")]
|
|
public async Task TheComposeFiles_AreStatic_PinNothingToABuild_AndKeepTheNodeHardening(string file)
|
|
{
|
|
string body = await _backend.Anonymous.GetStringAsync("/install/compose/" + file);
|
|
|
|
Assert.Contains("${MOSHIRO_VERSION:-latest}", body, StringComparison.Ordinal); // 更新就是 pull 加 up,想固定版本在 .env 里写
|
|
Assert.Contains("read_only: true", body, StringComparison.Ordinal);
|
|
Assert.Contains("no-new-privileges:true", body, StringComparison.Ordinal);
|
|
Assert.Contains("- ALL", body, StringComparison.Ordinal); // cap_drop
|
|
Assert.DoesNotContain("jt_", body, StringComparison.Ordinal); // 没有任何一次性的值
|
|
// 参数只从 .env 原样传进容器:podman-compose 不替换环境变量里的默认值,写成 ${变量:-默认值} 会把字面量传给节点
|
|
Assert.Contains("env_file:", body, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(":-}", body, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("evil.yaml")]
|
|
[InlineData("agent")]
|
|
[InlineData("..%2Finstall.sh")]
|
|
public async Task OnlyTheThreeComposeFiles_AreServed(string file)
|
|
{
|
|
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install/compose/" + file);
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode()
|
|
{
|
|
foreach (string path in new[] { "/install", "/install.sh", "/install.ps1" })
|
|
{
|
|
using HttpResponseMessage first = await _backend.Anonymous.GetAsync(path);
|
|
using HttpResponseMessage second = await _backend.Anonymous.GetAsync(path + "?code=jt_whatever");
|
|
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
|
Assert.Equal("no-store", first.Headers.CacheControl?.ToString());
|
|
string body = await first.Content.ReadAsStringAsync();
|
|
Assert.Equal(body, await second.Content.ReadAsStringAsync()); // 带不带码、带什么码,内容都一样
|
|
Assert.DoesNotContain("@@", body, StringComparison.Ordinal);
|
|
Assert.Contains("MOSHIRO_SERVER", body, StringComparison.Ordinal);
|
|
Assert.Contains("MOSHIRO_ROLE", body, StringComparison.Ordinal);
|
|
Assert.Contains("MOSHIRO_CODE", body, StringComparison.Ordinal);
|
|
}
|
|
|
|
// 同一份字节:sh 的两个路径一致
|
|
Assert.Equal(await _backend.Anonymous.GetStringAsync("/install"), await _backend.Anonymous.GetStringAsync("/install.sh"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheScripts_CarryTheReleaseKey_ThatTheBackendShips()
|
|
{
|
|
string key = MoShiro.Backend.Api.InstallScripts.ReleaseKey();
|
|
Assert.StartsWith("-----BEGIN PUBLIC KEY-----", key, StringComparison.Ordinal);
|
|
foreach (string path in new[] { "/install", "/install.ps1" })
|
|
{
|
|
string script = (await _backend.Anonymous.GetStringAsync(path)).Replace("\r\n", "\n", StringComparison.Ordinal);
|
|
Assert.Contains(key.Replace("\r\n", "\n", StringComparison.Ordinal), script, StringComparison.Ordinal); // 脚本里内置的和面板发布的是同一把
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte()
|
|
{
|
|
string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}""";
|
|
using HttpResponseMessage published = await _backend.Anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) }));
|
|
Assert.True(published.StatusCode is HttpStatusCode.Created or HttpStatusCode.Conflict, await published.Content.ReadAsStringAsync());
|
|
|
|
string code = (await CreateAsync("probe"))["plaintext"]!.GetValue<string>();
|
|
using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
|
|
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
|
|
Assert.Equal("no-store", served.Headers.CacheControl?.ToString());
|
|
Assert.Contains("\"component\":\"probe\"", await served.Content.ReadAsStringAsync(), StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改
|
|
using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code);
|
|
Assert.Equal(HttpStatusCode.OK, signature.StatusCode);
|
|
Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheManifest_IsNotServedForUnknownRevokedOrUsedUpCodes()
|
|
{
|
|
foreach (string path in new[] { "/install/manifest", "/install/manifest?code=", "/install/manifest?code=jt_nope", "/install/manifest.sig?code=jt_nope" })
|
|
{
|
|
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
}
|
|
|
|
JsonNode revoked = await CreateAsync("probe");
|
|
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/manifest?code=" + revoked["plaintext"]!.GetValue<string>());
|
|
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
|
|
|
|
// 用完了(一次性令牌已经注册过一个节点):脚本在动手之前就能从这里知道,给出明确的提示
|
|
JsonNode used = await CreateAsync("probe");
|
|
string code = used["plaintext"]!.GetValue<string>();
|
|
await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { }
|
|
using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
|
|
Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Codes_RecordWhoCreatedThem()
|
|
{
|
|
JsonNode created = await CreateAsync("agent");
|
|
Assert.StartsWith("key:", created["token"]!["created_by"]!.GetValue<string>(), StringComparison.Ordinal); // Admin 客户端是 API key
|
|
}
|
|
}
|