Skip to content

Commit 2bb6879

Browse files
committed
test(tailnet): add cross-language live qualification
1 parent a5689b0 commit 2bb6879

9 files changed

Lines changed: 562 additions & 6 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Cross-language real Tailnet integration
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
tailscale_channel:
7+
description: Official Tailscale container channel
8+
required: true
9+
default: stable
10+
type: choice
11+
options:
12+
- stable
13+
- unstable
14+
15+
permissions:
16+
contents: read
17+
18+
concurrency:
19+
group: vgi-rpc-cross-language-real-tailnet
20+
cancel-in-progress: false
21+
22+
jobs:
23+
go-rust:
24+
name: Go and Rust bidirectional qualification
25+
runs-on: ubuntu-24.04
26+
timeout-minutes: 45
27+
environment: tailnet-integration
28+
env:
29+
TAILSCALE_IMAGE: tailscale/tailscale:${{ inputs.tailscale_channel }}
30+
TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }}
31+
TS_OAUTH_SECRET: ${{ secrets.TS_OAUTH_SECRET }}
32+
TAILNET_ISSUER: ${{ vars.TAILNET_ISSUER }}
33+
TAILNET_EXPECTED_CAPABILITY: ${{ vars.TAILNET_EXPECTED_CAPABILITY }}
34+
TAILNET_EXPECTED_CLIENT_TAG: ${{ vars.TAILNET_EXPECTED_CLIENT_TAG }}
35+
steps:
36+
- uses: actions/checkout@v7
37+
with:
38+
path: vgi-rpc-python
39+
40+
- name: Read pinned implementation revisions
41+
id: revisions
42+
working-directory: vgi-rpc-python
43+
shell: bash
44+
run: |
45+
go_revision="$(jq -r .go tests/tailnet/revisions.json)"
46+
rust_revision="$(jq -r .rust tests/tailnet/revisions.json)"
47+
[[ "$go_revision" =~ ^[0-9a-f]{40}$ ]]
48+
[[ "$rust_revision" =~ ^[0-9a-f]{40}$ ]]
49+
echo "go=$go_revision" >> "$GITHUB_OUTPUT"
50+
echo "rust=$rust_revision" >> "$GITHUB_OUTPUT"
51+
52+
- uses: actions/checkout@v7
53+
with:
54+
repository: Query-farm/vgi-rpc-go
55+
path: vgi-rpc-go
56+
ref: ${{ steps.revisions.outputs.go }}
57+
58+
- uses: actions/checkout@v7
59+
with:
60+
repository: Query-farm/vgi-rpc-rust
61+
path: vgi-rpc-rust
62+
ref: ${{ steps.revisions.outputs.rust }}
63+
64+
- name: Report resolved implementation revisions
65+
shell: bash
66+
run: |
67+
go_revision="$(git -C vgi-rpc-go rev-parse HEAD)"
68+
rust_revision="$(git -C vgi-rpc-rust rev-parse HEAD)"
69+
test "$go_revision" = "${{ steps.revisions.outputs.go }}"
70+
test "$rust_revision" = "${{ steps.revisions.outputs.rust }}"
71+
{
72+
echo "### Cross-language implementation revisions"
73+
echo
74+
echo "| Implementation | Revision |"
75+
echo "| --- | --- |"
76+
echo "| Go | \`$go_revision\` |"
77+
echo "| Rust | \`$rust_revision\` |"
78+
} >> "$GITHUB_STEP_SUMMARY"
79+
80+
- name: Check kernel networking support
81+
run: |
82+
test -c /dev/net/tun
83+
docker compose version
84+
85+
- name: Run bidirectional real-Tailnet matrix
86+
working-directory: vgi-rpc-python
87+
env:
88+
TAILNET_GO_SOURCE: ${{ github.workspace }}/vgi-rpc-go
89+
TAILNET_RUST_SOURCE: ${{ github.workspace }}/vgi-rpc-rust
90+
run: tests/tailnet/run_cross_language.sh
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
services:
2+
server-tool:
3+
image: ${VGI_TAILNET_IMAGE:-vgi-rpc-tailnet:local}
4+
profiles: ["interop"]
5+
network_mode: service:tailscale-server
6+
depends_on:
7+
tailscale-server:
8+
condition: service_healthy
9+
10+
go-client-direct:
11+
image: ${VGI_TAILNET_GO_IMAGE:-vgi-rpc-tailnet-go:local}
12+
profiles: ["interop"]
13+
network_mode: service:tailscale-client
14+
depends_on:
15+
tailscale-client:
16+
condition: service_healthy
17+
18+
go-client-socks:
19+
image: ${VGI_TAILNET_GO_IMAGE:-vgi-rpc-tailnet-go:local}
20+
profiles: ["interop"]
21+
depends_on:
22+
tailscale-socks:
23+
condition: service_healthy
24+
25+
go-worker-direct:
26+
image: ${VGI_TAILNET_GO_IMAGE:-vgi-rpc-tailnet-go:local}
27+
profiles: ["interop"]
28+
network_mode: service:tailscale-server
29+
depends_on:
30+
tailscale-server:
31+
condition: service_healthy
32+
volumes:
33+
- server-socket:/var/run/tailscale:ro
34+
command:
35+
- server-tcp
36+
- --host
37+
- 0.0.0.0
38+
- --port
39+
- "19400"
40+
- --issuer
41+
- ${TAILNET_ISSUER:-tailnet:vgi-ci}
42+
- --localapi-socket
43+
- /var/run/tailscale/tailscaled.sock
44+
- --expected-capability
45+
- ${TAILNET_EXPECTED_CAPABILITY:-}
46+
- --expected-tag
47+
- ${TAILNET_EXPECTED_CLIENT_TAG:-tag:vgi-ci-client}
48+
49+
go-worker-http:
50+
image: ${VGI_TAILNET_GO_IMAGE:-vgi-rpc-tailnet-go:local}
51+
profiles: ["interop"]
52+
network_mode: service:tailscale-server
53+
depends_on:
54+
tailscale-server:
55+
condition: service_healthy
56+
command:
57+
- server-http
58+
- --host
59+
- 127.0.0.1
60+
- --port
61+
- "18080"
62+
- --issuer
63+
- ${TAILNET_ISSUER:-tailnet:vgi-ci}
64+
- --expected-capability
65+
- ${TAILNET_EXPECTED_CAPABILITY:-}
66+
67+
rust-client-direct:
68+
image: ${VGI_TAILNET_RUST_IMAGE:-vgi-rpc-tailnet-rust:local}
69+
profiles: ["interop"]
70+
network_mode: service:tailscale-client
71+
depends_on:
72+
tailscale-client:
73+
condition: service_healthy
74+
75+
rust-client-socks:
76+
image: ${VGI_TAILNET_RUST_IMAGE:-vgi-rpc-tailnet-rust:local}
77+
profiles: ["interop"]
78+
depends_on:
79+
tailscale-socks:
80+
condition: service_healthy
81+
82+
rust-worker-direct:
83+
image: ${VGI_TAILNET_RUST_IMAGE:-vgi-rpc-tailnet-rust:local}
84+
profiles: ["interop"]
85+
network_mode: service:tailscale-server
86+
depends_on:
87+
tailscale-server:
88+
condition: service_healthy
89+
volumes:
90+
- server-socket:/var/run/tailscale:ro
91+
command:
92+
- server-tcp
93+
- --host
94+
- 0.0.0.0
95+
- --port
96+
- "19400"
97+
- --issuer
98+
- ${TAILNET_ISSUER:-tailnet:vgi-ci}
99+
- --localapi-socket
100+
- /var/run/tailscale/tailscaled.sock
101+
- --expected-capability
102+
- ${TAILNET_EXPECTED_CAPABILITY:-}
103+
- --expected-tag
104+
- ${TAILNET_EXPECTED_CLIENT_TAG:-tag:vgi-ci-client}
105+
106+
rust-worker-http:
107+
image: ${VGI_TAILNET_RUST_IMAGE:-vgi-rpc-tailnet-rust:local}
108+
profiles: ["interop"]
109+
network_mode: service:tailscale-server
110+
depends_on:
111+
tailscale-server:
112+
condition: service_healthy
113+
command:
114+
- server-http
115+
- --host
116+
- 127.0.0.1
117+
- --port
118+
- "18080"
119+
- --issuer
120+
- ${TAILNET_ISSUER:-tailnet:vgi-ci}
121+
- --expected-capability
122+
- ${TAILNET_EXPECTED_CAPABILITY:-}

tests/tailnet/implementations.json

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"schema_version": 2,
3+
"implementations": {
4+
"go": {
5+
"implemented": {
6+
"client": {
7+
"http": true,
8+
"http_socks5h": true,
9+
"tcp": false,
10+
"tcp_socks5h": false
11+
},
12+
"server": {
13+
"http_serve_identity": true,
14+
"tcp_localapi_identity": true,
15+
"tcp_proxy_v2_identity": true
16+
},
17+
"iroh": false
18+
},
19+
"live_qualified": {
20+
"gate": "go-rust",
21+
"client": {
22+
"http": true,
23+
"http_socks5h": true,
24+
"tcp": false,
25+
"tcp_socks5h": false
26+
},
27+
"server": {
28+
"http_serve_identity": true,
29+
"tcp_localapi_identity": true,
30+
"tcp_proxy_v2_identity": false
31+
},
32+
"iroh": false
33+
}
34+
},
35+
"rust": {
36+
"implemented": {
37+
"client": {
38+
"http": true,
39+
"http_socks5h": false,
40+
"tcp": true,
41+
"tcp_socks5h": true
42+
},
43+
"server": {
44+
"http_serve_identity": true,
45+
"tcp_localapi_identity": true,
46+
"tcp_proxy_v2_identity": true
47+
},
48+
"iroh": true
49+
},
50+
"live_qualified": {
51+
"gate": "go-rust",
52+
"client": {
53+
"http": true,
54+
"http_socks5h": false,
55+
"tcp": true,
56+
"tcp_socks5h": true
57+
},
58+
"server": {
59+
"http_serve_identity": true,
60+
"tcp_localapi_identity": true,
61+
"tcp_proxy_v2_identity": false
62+
},
63+
"iroh": false
64+
}
65+
},
66+
"cpp": {"implemented": {}, "live_qualified": null},
67+
"csharp": {"implemented": {}, "live_qualified": null},
68+
"java": {"implemented": {}, "live_qualified": null},
69+
"typescript": {"implemented": {}, "live_qualified": null}
70+
}
71+
}

tests/tailnet/interop_probe.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Call a foreign worker whose handler enforces the live identity contract."""
5+
6+
from __future__ import annotations
7+
8+
import argparse
9+
from collections.abc import Sequence
10+
11+
import httpx2
12+
13+
from vgi_rpc.conformance import ConformanceService
14+
from vgi_rpc.http import http_connect
15+
from vgi_rpc.rpc import tcp_connect
16+
17+
18+
def _parser() -> argparse.ArgumentParser:
19+
parser = argparse.ArgumentParser(description=__doc__)
20+
subparsers = parser.add_subparsers(dest="transport", required=True)
21+
tcp = subparsers.add_parser("tcp")
22+
tcp.add_argument("--host", required=True)
23+
tcp.add_argument("--port", required=True, type=int)
24+
http = subparsers.add_parser("http")
25+
http.add_argument("--url", required=True)
26+
http.add_argument("--spoof-login")
27+
return parser
28+
29+
30+
def main(argv: Sequence[str] | None = None) -> None:
31+
"""Prove two authenticated calls reach the foreign-language handler."""
32+
args = _parser().parse_args(argv)
33+
if args.transport == "tcp":
34+
with tcp_connect(ConformanceService, args.host, args.port, connect_timeout=20) as client:
35+
assert client.echo_string(value="python-to-foreign-1") == "python-to-foreign-1"
36+
assert client.echo_string(value="python-to-foreign-2") == "python-to-foreign-2"
37+
else:
38+
headers = {"Tailscale-User-Login": args.spoof_login} if args.spoof_login else None
39+
with (
40+
httpx2.Client(
41+
base_url=args.url,
42+
headers=headers,
43+
follow_redirects=True,
44+
timeout=20,
45+
trust_env=False,
46+
) as http,
47+
http_connect(ConformanceService, client=http) as client,
48+
):
49+
assert client.echo_string(value="python-to-foreign-1") == "python-to-foreign-1"
50+
assert client.echo_string(value="python-to-foreign-2") == "python-to-foreign-2"
51+
print(f"Python -> foreign {args.transport} Tailnet probe passed", flush=True)
52+
53+
54+
if __name__ == "__main__":
55+
main()

tests/tailnet/probe.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def _parser() -> argparse.ArgumentParser:
3434
for child in (tcp, http):
3535
child.add_argument("--expected-evidence-source", required=True)
3636
child.add_argument("--expected-assurance", required=True)
37+
child.add_argument("--expected-issuer", required=True)
3738
child.add_argument("--expected-subject-kind", required=True)
3839
child.add_argument("--expected-subject-stability", required=True)
3940
child.add_argument("--expected-capability", required=True)
@@ -42,6 +43,8 @@ def _parser() -> argparse.ArgumentParser:
4243
child.add_argument("--expected-target-value")
4344
child.add_argument("--expect-proxy", action="store_true")
4445
child.add_argument("--expect-authenticated", action="store_true")
46+
child.add_argument("--expect-principal-match", action="store_true")
47+
child.add_argument("--expect-evidence-binding", action="store_true")
4548
return parser
4649

4750

@@ -53,6 +56,7 @@ def _assert_snapshot(raw: str, args: argparse.Namespace) -> dict[str, Any]:
5356
assert identity["provider"] == "tailscale", identity
5457
assert identity["evidence_source"] == args.expected_evidence_source, identity
5558
assert identity["assurance"] == args.expected_assurance, identity
59+
assert identity["issuer"] == args.expected_issuer, identity
5660
assert identity["subject_kind"] == args.expected_subject_kind, identity
5761
assert identity["subject_stability"] == args.expected_subject_stability, identity
5862
assert identity["subject_verified"] is (args.expected_subject_stability != "none"), identity
@@ -71,6 +75,9 @@ def _assert_snapshot(raw: str, args: argparse.Namespace) -> dict[str, Any]:
7175
spoofed = hashlib.sha256(f"login:{args.spoof_login}".encode()).hexdigest()
7276
assert identity["subject_fingerprint"] != spoofed, "Serve trusted a client-supplied identity header"
7377
assert payload["auth"]["authenticated"] is args.expect_authenticated, payload["auth"]
78+
assert payload["auth"]["principal_matches_identity"] is args.expect_principal_match, payload["auth"]
79+
assert payload["auth"]["peer_evidence_binding_present"] is args.expect_evidence_binding, payload["auth"]
80+
assert payload["auth"]["domain"] == ("tailscale" if args.expect_authenticated else None), payload["auth"]
7481
return payload
7582

7683

tests/tailnet/revisions.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"schema_version": 1,
3+
"go": "e13bed390c93514d7615d9ebe3f647ca71825e9d",
4+
"rust": "67ff24f0268f0c381edd7618bf9001aa2f040c1f"
5+
}

0 commit comments

Comments
 (0)