|
| 1 | +--- |
| 2 | +description: "How to connect the Python VGI client to workers through Tailscale over HTTPS, direct TCP, or userspace SOCKS5h." |
| 3 | +--- |
| 4 | + |
| 5 | +# Run VGI through a Tailnet |
| 6 | + |
| 7 | +The VGI client uses ordinary HTTPS and TCP endpoints. It does not join a |
| 8 | +Tailnet, manage Tailscale credentials, or introduce a `tailscale://` URL |
| 9 | +scheme. Run Tailscale on the client host or in a sidecar and pass the worker's |
| 10 | +MagicDNS name to the normal client factory. |
| 11 | + |
| 12 | +Connectivity and authentication are separate. A client can reach a worker |
| 13 | +through a Tailnet without the worker using Tailnet identity. To authorize from |
| 14 | +the caller's verified Tailscale user, node, tags, or application capabilities, |
| 15 | +configure a peer-identity provider and an authentication policy on the worker. |
| 16 | + |
| 17 | +## Authenticate an HTTP worker |
| 18 | + |
| 19 | +When Tailscale Serve terminates HTTPS, trust its identity headers only from the |
| 20 | +exact local proxy addresses that can reach the application. `require_peer_identity` |
| 21 | +requires verified evidence but deliberately does not invent a stable principal |
| 22 | +for capability-only or login-scoped Serve requests: |
| 23 | + |
| 24 | +```python test="lint" |
| 25 | +from vgi_rpc.http import tailscale_serve_header_provider |
| 26 | +from vgi_rpc.rpc import require_peer_identity |
| 27 | + |
| 28 | +from my_worker import MyWorker |
| 29 | +from vgi.serve import create_app |
| 30 | + |
| 31 | +tailscale = tailscale_serve_header_provider( |
| 32 | + issuer="tailnet:example", |
| 33 | + trusted_proxy_addresses=("127.0.0.1", "::1"), |
| 34 | +) |
| 35 | + |
| 36 | +app = create_app( |
| 37 | + MyWorker, |
| 38 | + peer_identity_providers=(tailscale,), |
| 39 | + peer_authentication_policy=require_peer_identity("tailscale"), |
| 40 | +) |
| 41 | +``` |
| 42 | + |
| 43 | +Serve login names have `subject_stability="login"`; tagged clients may be |
| 44 | +capability-only and have no subject at all. Consequently, the built-in |
| 45 | +`peer_identity_primary("tailscale")` policy is intentionally unsuitable for |
| 46 | +Serve. If a deployment elects to authenticate from a Serve login, it must use |
| 47 | +an explicit application policy that accepts that weaker identity and scopes it |
| 48 | +to the configured issuer and trusted proxy boundary. |
| 49 | + |
| 50 | +For direct HTTP over a Tailnet, LocalAPI WhoIs can produce a stable user or |
| 51 | +node subject and therefore can be the primary authenticator: |
| 52 | + |
| 53 | +```python test="lint" |
| 54 | +from vgi_rpc.http import tailscale_localapi_provider |
| 55 | +from vgi_rpc.rpc import peer_identity_primary |
| 56 | + |
| 57 | +from my_worker import MyWorker |
| 58 | +from vgi.serve import create_app |
| 59 | + |
| 60 | +tailscale = tailscale_localapi_provider(issuer="tailnet:example") |
| 61 | +app = create_app( |
| 62 | + MyWorker, |
| 63 | + peer_identity_providers=(tailscale,), |
| 64 | + peer_authentication_policy=peer_identity_primary("tailscale"), |
| 65 | +) |
| 66 | +``` |
| 67 | + |
| 68 | +`create_app()` remains a standard WSGI application, so the same configuration |
| 69 | +works behind Waitress, Granian, gunicorn, Envoy, nginx, or a cloud HTTP load |
| 70 | +balancer. The provider determines which proxy evidence is trusted; merely |
| 71 | +placing the worker behind a proxy never makes forwarded identity trustworthy. |
| 72 | + |
| 73 | +Trusted-proxy validation must see the physical socket peer. Do not install |
| 74 | +`ProxyFix`, forwarded-address middleware, or an equivalent rewrite ahead of |
| 75 | +VGI's identity middleware: attacker-controlled `Forwarded` or |
| 76 | +`X-Forwarded-For` values must never become `REMOTE_ADDR` before the exact proxy |
| 77 | +allowlist is checked. Keep the backend unreachable except through those |
| 78 | +allowlisted proxies. |
| 79 | + |
| 80 | +## HTTPS through Tailscale Serve |
| 81 | + |
| 82 | +On a host using Tailscale's normal network interface, connect to a worker |
| 83 | +published with Tailscale Serve exactly like any other HTTPS worker: |
| 84 | + |
| 85 | +```python test="lint" |
| 86 | +from vgi.client import Client |
| 87 | + |
| 88 | +with Client.from_http("https://worker.example-tailnet.ts.net") as client: |
| 89 | + catalogs = client.catalogs() |
| 90 | +``` |
| 91 | + |
| 92 | +Tailscale terminates HTTPS and adds its verified Serve identity and application |
| 93 | +capability headers. The VGI server validates those headers against its trusted |
| 94 | +proxy configuration; the client neither constructs nor trusts identity |
| 95 | +headers. Do not send `Tailscale-*` headers from application code. |
| 96 | + |
| 97 | +For userspace networking, inject an HTTP client configured for Tailscale's |
| 98 | +SOCKS5 server. Install SOCKS support with `pip install 'httpx2[socks]'`: |
| 99 | + |
| 100 | +```python test="lint" |
| 101 | +import httpx2 |
| 102 | + |
| 103 | +from vgi.client import Client |
| 104 | + |
| 105 | +url = "https://worker.example-tailnet.ts.net" |
| 106 | +with httpx2.Client( |
| 107 | + base_url=url, |
| 108 | + proxy="socks5h://127.0.0.1:1055", |
| 109 | + trust_env=False, |
| 110 | + timeout=httpx2.Timeout(60.0, connect=15.0), |
| 111 | +) as http_client, Client.from_http(url, httpx_client=http_client) as client: |
| 112 | + catalogs = client.catalogs() |
| 113 | +``` |
| 114 | + |
| 115 | +Using `socks5h` keeps MagicDNS resolution inside the Tailscale sidecar. A proxy |
| 116 | +failure is an error; this configuration does not fall back to a direct route. |
| 117 | + |
| 118 | +## Raw TCP through a Tailnet |
| 119 | + |
| 120 | +Start a high-level Worker with LocalAPI identity using `Worker.serve_tcp()`: |
| 121 | + |
| 122 | +```python test="lint" |
| 123 | +from vgi_rpc.http import tailscale_localapi_provider |
| 124 | +from vgi_rpc.rpc import peer_identity_primary |
| 125 | + |
| 126 | +from my_worker import MyWorker |
| 127 | + |
| 128 | +tailscale = tailscale_localapi_provider(issuer="tailnet:example") |
| 129 | +MyWorker().serve_tcp( |
| 130 | + "0.0.0.0", |
| 131 | + 9400, |
| 132 | + threaded=True, |
| 133 | + max_connections=64, |
| 134 | + peer_identity_providers=(tailscale,), |
| 135 | + peer_authentication_policy=peer_identity_primary("tailscale"), |
| 136 | +) |
| 137 | +``` |
| 138 | + |
| 139 | +The connection's verified stable Tailnet subject becomes `CallContext.auth` and |
| 140 | +is available to function parameters annotated with `Auth()`. Identity is |
| 141 | +snapshotted once for the stateful TCP connection. |
| 142 | + |
| 143 | +When `threaded=True`, all connections share the same Worker instance. Protect |
| 144 | +mutable subclass state with appropriate synchronization and never store |
| 145 | +per-caller authentication in shared instance attributes. Leave the default |
| 146 | +serial mode in place if the implementation is not safe for concurrent calls. |
| 147 | + |
| 148 | +Behind a Tailscale Service configured to emit PROXY protocol v2, require the |
| 149 | +preamble and name every immediate proxy address explicitly: |
| 150 | + |
| 151 | +```python test="lint" |
| 152 | +MyWorker().serve_tcp( |
| 153 | + "127.0.0.1", |
| 154 | + 9400, |
| 155 | + proxy_protocol="required", |
| 156 | + trusted_proxy_addresses=("127.0.0.1", "::1"), |
| 157 | + service_name="svc:vgi-worker", |
| 158 | + peer_identity_providers=(tailscale,), |
| 159 | + peer_authentication_policy=peer_identity_primary("tailscale"), |
| 160 | +) |
| 161 | +``` |
| 162 | + |
| 163 | +Keep that backend unreachable except through the trusted proxy. The asserted |
| 164 | +source is accepted only after immediate-peer validation, and `service_name` |
| 165 | +selects destination-scoped application capabilities in LocalAPI WhoIs. |
| 166 | + |
| 167 | +Direct TCP works with a MagicDNS name when the client host has Tailscale's |
| 168 | +normal network interface: |
| 169 | + |
| 170 | +```python test="lint" |
| 171 | +from vgi.client import Client |
| 172 | + |
| 173 | +with Client.from_tcp("worker.example-tailnet.ts.net", 9400) as client: |
| 174 | + catalogs = client.catalogs() |
| 175 | +``` |
| 176 | + |
| 177 | +For a userspace Tailscale sidecar, use the explicit SOCKS5h option: |
| 178 | + |
| 179 | +```python test="lint" |
| 180 | +with Client.from_tcp( |
| 181 | + "worker.example-tailnet.ts.net", |
| 182 | + 9400, |
| 183 | + proxy="socks5h://127.0.0.1:1055", |
| 184 | +) as client: |
| 185 | + catalogs = client.catalogs() |
| 186 | +``` |
| 187 | + |
| 188 | +The proxy applies to ordinary function calls, additional worker connections, |
| 189 | +and catalog discovery. Target hostnames are passed untouched to SOCKS5h and |
| 190 | +are never resolved locally by VGI. |
| 191 | + |
| 192 | +Raw VGI TCP has no application-layer encryption. Tailscale encrypts the |
| 193 | +Tailnet path, but the worker port should remain reachable only through the |
| 194 | +Tailnet and should not be exposed directly to an untrusted network. Prefer |
| 195 | +HTTPS through Tailscale Serve at public or mixed-trust boundaries. |
0 commit comments