Files
MoShiro/backend/tests/MoShiro.Backend.Tests/InstallTests.cs
T
justme ad5efc62ff agent: unify listening and dialing with directed mutual measurements
Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
2026-10-21 11:55:36 +08:00

250 lines
15 KiB
C#

using System.Net;
using System.Text.Json.Nodes;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 一行安装:监听侧和拨号侧 Agent 共用同一份<b>静态</b>脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行),
/// 面板地址、角色、一次性代码都走命令里的环境变量;脚本再向面板取 CI 签名的发布清单来验证镜像。
/// </summary>
public sealed class InstallTests : IClassFixture<BackendFixture>
{
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())!;
}
[Fact]
public async Task OpenWrtScript_IsStaticAndContainsThePinnedReleasePublicKey()
{
string script = await _backend.Anonymous.GetStringAsync("/install/openwrt.sh");
string key = await _backend.Anonymous.GetStringAsync("/install");
Assert.Contains("BEGIN PUBLIC KEY", script, StringComparison.Ordinal);
Assert.Contains(MoShiro.Backend.Api.InstallScripts.ReleaseKey(), script, StringComparison.Ordinal);
Assert.Contains("--genesis", script, StringComparison.Ordinal);
Assert.Contains("--controllers", script, StringComparison.Ordinal);
Assert.Contains("openssl dgst -sha256 -verify", script, StringComparison.Ordinal);
Assert.DoesNotContain("BEGIN PRIVATE KEY", script + key, StringComparison.Ordinal);
JsonNode agent = await CreateAsync("agent");
Assert.Equal("", agent["install_commands"]!["openwrt"]!.GetValue<string>());
}
[Fact]
public async Task Commands_VerifyTheScriptHash_ThenRunItWithThePublicValues()
{
const string role = "agent";
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); // 没有要用户自己替换的占位符
}
[Fact]
public async Task ComposeInstall_WritesThePublicValuesToAnEnvFile_AndChecksTheComposeHash()
{
const string role = "agent";
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)));
Assert.Equal(string.Empty, compose["macos"]!.GetValue<string>());
Assert.Equal(string.Empty, compose["windows"]!.GetValue<string>());
}
[Fact]
public async Task QuadletInstall_ChecksTheUnitHash_PointsTheImageAtTheConfiguredRepository_AndLeavesAutoUpdateOn()
{
const string role = "agent";
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 UnknownQuadletUnits_AreRejected(string file)
{
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install/quadlet/" + file);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Theory]
[InlineData("agent.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 UnknownComposeFiles_AreRejected(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":"agent","channel":"dev","image":"registry.example/moshiro-dev/moshiro-agent","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("agent"))["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\":\"agent\"", 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("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/manifest?code=" + revoked["plaintext"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
// 用完了(一次性令牌已经注册过一个节点):脚本在动手之前就能从这里知道,给出明确的提示
JsonNode used = await CreateAsync("agent");
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
}
}