Skip to content

Commit 19cdbaa

Browse files
committed
test(tailnet): add live qualification adapter
1 parent 2303883 commit 19cdbaa

10 files changed

Lines changed: 567 additions & 0 deletions

File tree

.dockerignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.git
2+
.github
3+
**/bin
4+
**/obj
5+
TestResults
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
using System.Text.Json;
2+
using Microsoft.AspNetCore.Builder;
3+
using Microsoft.AspNetCore.Hosting;
4+
using QueryFarm.VgiRpc.Client;
5+
using QueryFarm.VgiRpc.Client.Http;
6+
using QueryFarm.VgiRpc.Http;
7+
using QueryFarm.VgiRpc.Identity;
8+
using QueryFarm.VgiRpc.Server;
9+
using QueryFarm.VgiRpc.Tailnet;
10+
11+
try
12+
{
13+
var arguments = CliArguments.Parse(args);
14+
switch (arguments.Mode)
15+
{
16+
case "client-tcp":
17+
await RunTcpClientAsync(arguments);
18+
break;
19+
case "client-http":
20+
await RunHttpClientAsync(arguments);
21+
break;
22+
case "server-http":
23+
await RunHttpServerAsync(arguments);
24+
break;
25+
default:
26+
throw new ArgumentException("mode must be client-tcp, client-http, or server-http");
27+
}
28+
}
29+
catch (Exception exception)
30+
{
31+
Console.Error.WriteLine(JsonSerializer.Serialize(new { ok = false, error = exception.Message }));
32+
Environment.ExitCode = 1;
33+
}
34+
35+
static async Task RunTcpClientAsync(CliArguments arguments)
36+
{
37+
var host = arguments.Required("host");
38+
var port = arguments.RequiredInt("port");
39+
await using var client = arguments.Optional("proxy") is { } proxy
40+
? await RpcClient.ConnectTcpAsync(host, port, proxy, arguments.Timeout)
41+
: await RpcClient.ConnectTcpAsync(host, port);
42+
await ValidateTwiceAsync(client.CreateProxy<ITailnetEvidenceService>(), arguments);
43+
}
44+
45+
static async Task RunHttpClientAsync(CliArguments arguments)
46+
{
47+
var headers = arguments.Optional("spoof-login") is { } spoofLogin
48+
? new Dictionary<string, string> { ["Tailscale-User-Login"] = spoofLogin }
49+
: null;
50+
await using var client = new HttpRpcClient(new Uri(arguments.Required("url")), new HttpRpcClientOptions
51+
{
52+
TcpProxy = arguments.Optional("proxy"),
53+
ConnectTimeout = arguments.Timeout,
54+
DefaultHeaders = headers,
55+
});
56+
await ValidateTwiceAsync(client.CreateProxy<ITailnetEvidenceService>(), arguments);
57+
}
58+
59+
static async Task ValidateTwiceAsync(ITailnetEvidenceService service, CliArguments arguments)
60+
{
61+
var expected = new TailnetSnapshotExpectations(
62+
arguments.Required("expected-issuer"),
63+
arguments.Required("expected-evidence-source"),
64+
arguments.Required("expected-assurance"),
65+
arguments.Required("expected-subject-kind"),
66+
arguments.Required("expected-subject-stability"),
67+
arguments.Required("expected-capability"),
68+
arguments.Optional("expected-tag"),
69+
arguments.Optional("expected-target-kind"),
70+
arguments.Flag("expect-authenticated"),
71+
arguments.Flag("expect-proxy"));
72+
var first = await service.SnapshotAsync();
73+
var second = await service.SnapshotAsync();
74+
TailnetEvidenceValidator.ValidateSnapshot(first, expected);
75+
TailnetEvidenceValidator.ValidateSnapshot(second, expected);
76+
if (!StringComparer.Ordinal.Equals(first, second))
77+
throw new InvalidDataException("Tailnet evidence changed between qualification calls");
78+
Console.WriteLine(JsonSerializer.Serialize(new { ok = true, mode = arguments.Mode }));
79+
}
80+
81+
static async Task RunHttpServerAsync(CliArguments arguments)
82+
{
83+
var issuer = arguments.Required("issuer");
84+
var capability = arguments.Required("expected-capability");
85+
var trusted = new[]
86+
{
87+
arguments.Optional("trusted-proxy-ipv4") ?? "127.0.0.1",
88+
arguments.Optional("trusted-proxy-ipv6") ?? "::1",
89+
};
90+
var builder = WebApplication.CreateSlimBuilder();
91+
builder.WebHost.UseUrls($"http://{arguments.Required("host")}:{arguments.RequiredInt("port")}");
92+
var app = builder.Build();
93+
app.UseVgiRpcPhysicalPeerSnapshot();
94+
var provider = TailscalePeerIdentityProviders.Serve(issuer, trusted);
95+
var authenticate = PeerIdentityAuthentication.Compose(
96+
null,
97+
[provider],
98+
PeerAuthenticationPolicies.Require("tailscale"),
99+
timeout: arguments.Timeout);
100+
var implementation = new TailnetConformanceService(new TailnetServerExpectations(issuer, capability));
101+
app.MapVgiRpc(new RpcServer(typeof(IConformanceService), implementation), authenticate: authenticate);
102+
await app.RunAsync();
103+
}
104+
105+
internal sealed class CliArguments
106+
{
107+
private readonly Dictionary<string, List<string>> _values;
108+
109+
private CliArguments(string mode, Dictionary<string, List<string>> values)
110+
{
111+
Mode = mode;
112+
_values = values;
113+
}
114+
115+
public string Mode { get; }
116+
public TimeSpan Timeout => TimeSpan.FromSeconds(OptionalInt("timeout-seconds") ?? 10);
117+
118+
public static CliArguments Parse(string[] arguments)
119+
{
120+
if (arguments.Length == 0) throw new ArgumentException("a mode is required");
121+
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
122+
for (var index = 1; index < arguments.Length;)
123+
{
124+
var option = arguments[index];
125+
if (!option.StartsWith("--", StringComparison.Ordinal))
126+
throw new ArgumentException("options must start with --");
127+
var name = option[2..];
128+
var isFlag = name is "expect-authenticated" or "expect-proxy";
129+
if (!isFlag && index + 1 >= arguments.Length)
130+
throw new ArgumentException($"--{name} requires a value");
131+
var value = isFlag ? "true" : arguments[index + 1];
132+
if (!values.TryGetValue(name, out var entries)) values[name] = entries = [];
133+
entries.Add(value);
134+
index += isFlag ? 1 : 2;
135+
}
136+
return new CliArguments(arguments[0], values);
137+
}
138+
139+
public string Required(string name) => Optional(name)
140+
?? throw new ArgumentException($"--{name} is required");
141+
142+
public string? Optional(string name) => _values.TryGetValue(name, out var values) switch
143+
{
144+
true when values.Count == 1 => values[0],
145+
true => throw new ArgumentException($"--{name} may be supplied only once"),
146+
false => null,
147+
};
148+
149+
public int RequiredInt(string name) => ParseInt(Required(name), name);
150+
public int? OptionalInt(string name) => Optional(name) is { } value ? ParseInt(value, name) : null;
151+
public bool Flag(string name) => _values.TryGetValue(name, out var values) && values switch
152+
{
153+
["true"] => true,
154+
_ => throw new ArgumentException($"--{name} may be supplied only once"),
155+
};
156+
157+
private static int ParseInt(string value, string name) =>
158+
int.TryParse(value, out var parsed) && parsed > 0
159+
? parsed
160+
: throw new ArgumentException($"--{name} must be a positive integer");
161+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<RootNamespace>QueryFarm.VgiRpc.Tailnet</RootNamespace>
6+
<Description>Live-Tailnet qualification adapter for the C# vgi-rpc client and HTTP Serve worker.</Description>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
11+
<ProjectReference Include="..\..\src\QueryFarm.VgiRpc.Client\QueryFarm.VgiRpc.Client.csproj" />
12+
<ProjectReference Include="..\..\src\QueryFarm.VgiRpc.Client.Http\QueryFarm.VgiRpc.Client.Http.csproj" />
13+
<ProjectReference Include="..\..\src\QueryFarm.VgiRpc.Http\QueryFarm.VgiRpc.Http.csproj" />
14+
</ItemGroup>
15+
16+
</Project>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# C# live-Tailnet qualification adapter
2+
3+
This executable exercises only transport and identity surfaces the C# port currently implements:
4+
5+
- `client-tcp`: direct TCP or explicit credential-free `socks5h://` dialing.
6+
- `client-http`: direct HTTP or explicit credential-free `socks5h://` dialing. `--spoof-login <login>`
7+
deliberately injects a `Tailscale-User-Login` request header for the reverse-Serve spoof test.
8+
- `server-http`: a capability-only worker behind Tailscale Serve. It accepts `--host`, `--port`,
9+
`--issuer`, and `--expected-capability`; `--trusted-proxy-ipv4`/`--trusted-proxy-ipv6` default
10+
to the exact loopback addresses. The physical-peer snapshot
11+
middleware runs before any address-rewriting middleware, Serve headers are trusted only from
12+
exact configured proxy IP literals, and each RPC call requires verified capability evidence.
13+
14+
Client qualification validates the provider status, issuer, evidence source, assurance, subject
15+
kind and stability, capability, optional tag and capability-target kind, proxy topology signal,
16+
authentication state, principal-to-identity match, and a non-empty evidence binding. It calls the
17+
snapshot method twice and requires byte-identical evidence to exercise stable connection reuse.
18+
19+
The HTTP worker accepts capability-only evidence and therefore intentionally remains anonymous;
20+
its service method rejects a user subject, including a spoofed login that reaches the worker
21+
without being stripped and replaced by the trusted Serve proxy.
22+
23+
Raw-TCP server qualification is intentionally absent. `SocketTransport.ServeTcpAsync` supplies a
24+
plain `IRpcTransport`, while the persistent `RpcServer` call contexts currently expose anonymous
25+
authentication and empty peer evidence. `TailscaleLocalApiProvider` exists for ASP.NET request
26+
composition, but attaching it outside that request pipeline would require a new connection-level
27+
identity snapshot seam in core. Advertising raw LocalAPI coverage here would therefore be false.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using QueryFarm.VgiRpc.Server;
2+
3+
namespace QueryFarm.VgiRpc.Tailnet;
4+
5+
public interface ITailnetEvidenceService
6+
{
7+
Task<string> SnapshotAsync();
8+
}
9+
10+
public interface IConformanceService
11+
{
12+
Task<string> EchoStringAsync(string value, ICallContext? context = null);
13+
}
14+
15+
public sealed class TailnetConformanceService(TailnetServerExpectations expectations) : IConformanceService
16+
{
17+
public Task<string> EchoStringAsync(string value, ICallContext? context = null)
18+
{
19+
TailnetEvidenceValidator.ValidateServerContext(
20+
context ?? throw new InvalidOperationException("call context is required"), expectations);
21+
return Task.FromResult(value);
22+
}
23+
}
24+
25+
public sealed record TailnetServerExpectations(
26+
string Issuer,
27+
string Capability,
28+
string EvidenceSource = "serve_proxy",
29+
string Assurance = "configured_proxy");
30+
31+
public sealed record TailnetSnapshotExpectations(
32+
string Issuer,
33+
string EvidenceSource,
34+
string Assurance,
35+
string SubjectKind,
36+
string SubjectStability,
37+
string Capability,
38+
string? Tag,
39+
string? TargetKind,
40+
bool Authenticated,
41+
bool ExpectProxy);

0 commit comments

Comments
 (0)