tailkitty 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {tailkitty-0.2.2 → tailkitty-0.2.3}/AGENTS.md +1 -0
  2. {tailkitty-0.2.2 → tailkitty-0.2.3}/BUILDING.md +2 -1
  3. {tailkitty-0.2.2 → tailkitty-0.2.3}/CHANGELOG.md +9 -0
  4. {tailkitty-0.2.2 → tailkitty-0.2.3}/COMPARISON.md +3 -2
  5. {tailkitty-0.2.2 → tailkitty-0.2.3}/PKG-INFO +34 -5
  6. {tailkitty-0.2.2 → tailkitty-0.2.3}/README.md +33 -4
  7. {tailkitty-0.2.2 → tailkitty-0.2.3}/RELEASING.md +8 -4
  8. {tailkitty-0.2.2 → tailkitty-0.2.3}/SECURITY.md +5 -0
  9. {tailkitty-0.2.2 → tailkitty-0.2.3}/docs/architecture.md +12 -0
  10. {tailkitty-0.2.2 → tailkitty-0.2.3}/docs/python-api.md +61 -0
  11. {tailkitty-0.2.2 → tailkitty-0.2.3}/docs/recipes.md +27 -0
  12. {tailkitty-0.2.2 → tailkitty-0.2.3}/docs/troubleshooting.md +13 -0
  13. tailkitty-0.2.3/patches/0001-cli-udp-serving.patch +118 -0
  14. {tailkitty-0.2.2 → tailkitty-0.2.3}/pyproject.toml +1 -1
  15. {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/smoke_wheel.py +42 -0
  16. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/__init__.py +6 -0
  17. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/client.py +28 -1
  18. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/constants.py +1 -1
  19. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/process.py +19 -0
  20. tailkitty-0.2.3/src/tailkitty/udp.py +417 -0
  21. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_cli.py +1 -1
  22. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_module.py +1 -1
  23. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_process.py +7 -0
  24. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_project.py +16 -0
  25. tailkitty-0.2.3/tests/test_udp.py +125 -0
  26. {tailkitty-0.2.2 → tailkitty-0.2.3}/uv.lock +1 -1
  27. {tailkitty-0.2.2 → tailkitty-0.2.3}/.gitignore +0 -0
  28. {tailkitty-0.2.2 → tailkitty-0.2.3}/.mise.toml +0 -0
  29. {tailkitty-0.2.2 → tailkitty-0.2.3}/.python-version +0 -0
  30. {tailkitty-0.2.2 → tailkitty-0.2.3}/CONTRIBUTING.md +0 -0
  31. {tailkitty-0.2.2 → tailkitty-0.2.3}/ITERATIONS.md +0 -0
  32. {tailkitty-0.2.2 → tailkitty-0.2.3}/LICENSE +0 -0
  33. {tailkitty-0.2.2 → tailkitty-0.2.3}/THIRD_PARTY_NOTICES.md +0 -0
  34. {tailkitty-0.2.2 → tailkitty-0.2.3}/hatch_build.py +0 -0
  35. {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/__init__.py +0 -0
  36. {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/build_binary.py +0 -0
  37. {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/build_wheels.py +0 -0
  38. {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/check_upstream.py +0 -0
  39. {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/targets.py +0 -0
  40. {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/verify_wheel.py +0 -0
  41. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/__main__.py +0 -0
  42. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/backend.py +0 -0
  43. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/bundle.py +0 -0
  44. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/cli.py +0 -0
  45. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/derp.py +0 -0
  46. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/destination.py +0 -0
  47. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/diagnostics.py +0 -0
  48. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/py.typed +0 -0
  49. {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/token.py +0 -0
  50. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_backend.py +0 -0
  51. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_bundle.py +0 -0
  52. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_derp.py +0 -0
  53. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_destination.py +0 -0
  54. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_targets.py +0 -0
  55. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_token.py +0 -0
  56. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_token_validation.py +0 -0
  57. {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_upstream.py +0 -0
@@ -46,6 +46,7 @@ compatibility and fail-closed bundle discovery are core requirements.
46
46
  | `src/tailkitty/backend.py` | Backend precedence and command execution |
47
47
  | `src/tailkitty/bundle.py` | Runtime manifest and executable integrity checks |
48
48
  | `src/tailkitty/client.py` | Sync and asyncio client APIs |
49
+ | `src/tailkitty/udp.py` | Typed SOCKS5 UDP framing, connections, and lifecycle |
49
50
  | `src/tailkitty/process.py` | Managed server processes and low-level async execution |
50
51
  | `src/tailkitty/cli.py` | Python-native commands and upstream pass-through |
51
52
  | `src/tailkitty/constants.py` | Package, Go, module, and upstream Tailcat pins |
@@ -154,7 +154,8 @@ The smoke test:
154
154
  3. Runs `tailkitty doctor --json` and confirms the bundled backend is selected.
155
155
  4. Starts an isolated local DERP/STUN relay through Tailcat's test mode.
156
156
  5. Starts a peer and performs an actual encrypted ping.
157
- 6. Applies five-second startup and client bounds, followed by bounded termination.
157
+ 6. Sends and receives a real application UDP datagram through the same isolated relay.
158
+ 7. Applies five-second startup and client bounds, followed by bounded termination.
158
159
 
159
160
  It does not depend on public relay availability and does not touch saved developer keys.
160
161
 
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3 - 2026-09-08
4
+
5
+ - Add typed synchronous and asyncio UDP connections that preserve datagram boundaries through
6
+ Tailcat's SOCKS5 UDP relay.
7
+ - Add opt-in `ServerProcess(udp=...)` forwarding from Tailcat UDP ports to matching loopback UDP
8
+ ports, with a real encrypted UDP wheel smoke test.
9
+ - Create GitHub Releases automatically after successful Trusted Publishing, attaching all wheels,
10
+ the source distribution, and SHA-256 checksums.
11
+
3
12
  ## 0.2.2 - 2026-09-08
4
13
 
5
14
  - Use Tailcat's declarative `serve` subcommand in managed Python server processes.
@@ -5,7 +5,7 @@ independent Python projects built around upstream
5
5
  [Tailscale Tailcat](https://github.com/tailscale/tailcat). They are not rename-compatible packages:
6
6
  install and import the one you intend.
7
7
 
8
- This comparison was checked on 2026-09-08 against `pytailcat` 0.1.4 and Tailkitty 0.2.2.
8
+ This comparison was checked on 2026-09-08 against `pytailcat` 0.1.4 and Tailkitty 0.2.3.
9
9
 
10
10
  ## Summary
11
11
 
@@ -16,7 +16,7 @@ This comparison was checked on 2026-09-08 against `pytailcat` 0.1.4 and Tailkitt
16
16
 
17
17
  ## Feature comparison
18
18
 
19
- | Capability | `pytailcat` 0.1.4 | Tailkitty 0.2.2 |
19
+ | Capability | `pytailcat` 0.1.4 | Tailkitty 0.2.3 |
20
20
  | --- | --- | --- |
21
21
  | Distribution/import | `pytailcat` | `tailkitty` |
22
22
  | Upstream data plane | Bundled Tailcat executable | Pinned Tailcat v0.6.0 executable |
@@ -28,6 +28,7 @@ This comparison was checked on 2026-09-08 against `pytailcat` 0.1.4 and Tailkitt
28
28
  | DERP expansion | Delegated to executable | Pure Python with bounded ETag cache and stale fallback |
29
29
  | DNS destinations | Delegated to executable | Pure-Python sync and asyncio resolution |
30
30
  | Client API | Thin synchronous process wrapper | Typed finite, streaming, sync, and asyncio clients |
31
+ | Application UDP | Depends on older bundled executable | Typed sync/async datagrams and opt-in localhost serving |
31
32
  | Server API | Synchronous `ServerProcess` | Managed sync/async servers with typed v0.6 options |
32
33
  | Lifecycle cleanup | Basic process management | Bounded startup, terminate/kill escalation, reaping |
33
34
  | Bundle verification | Package transport integrity | Runtime target, schema, size, and SHA-256 verification |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tailkitty
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Python tooling and a verified distribution for Tailscale Tailcat
5
5
  Project-URL: Homepage, https://github.com/kornpow/tailkitty
6
6
  Project-URL: Source, https://github.com/kornpow/tailkitty
@@ -53,6 +53,7 @@ control server.
53
53
  | --- | --- |
54
54
  | Send bytes between two computers | [One-shot pipe](#one-shot-pipe) |
55
55
  | Reach a remote web app or database | [Forward a port](#forward-a-port) |
56
+ | Exchange UDP datagrams | [Typed UDP](#typed-udp) |
56
57
  | Use Tailcat from Python | [Python quickstart](#python-quickstart) |
57
58
  | Transfer files or use SSH | [Recipes](https://github.com/kornpow/tailkitty/blob/main/docs/recipes.md) |
58
59
  | Diagnose an installation | [Troubleshooting](https://github.com/kornpow/tailkitty/blob/main/docs/troubleshooting.md) |
@@ -188,6 +189,33 @@ exit. See the [Python API guide](https://github.com/kornpow/tailkitty/blob/main/
188
189
  for asyncio, streaming, token handling, server
189
190
  options, exceptions, and low-level execution.
190
191
 
192
+ ### Typed UDP
193
+
194
+ Tailkitty preserves application datagram boundaries instead of treating UDP as a byte stream. On
195
+ the server, bind your UDP application to localhost and opt in to the same Tailcat port:
196
+
197
+ ```python
198
+ from tailkitty import ServerProcess
199
+
200
+ with ServerProcess(udp=5353, key="new") as server:
201
+ print(server.token)
202
+ input("Press Enter to stop the UDP tunnel... ")
203
+ ```
204
+
205
+ On the client:
206
+
207
+ ```python
208
+ from tailkitty import Client
209
+
210
+ with Client("tc...").connect_udp(5353, timeout=10) as connection:
211
+ reply = connection.request(b"one datagram", timeout=3)
212
+ print(reply.data)
213
+ ```
214
+
215
+ `send()` and `receive()` each handle exactly one datagram. Payloads are limited to
216
+ `MAX_UDP_PAYLOAD` (1232 bytes), the safe size for Tailcat's tunnel MTU. Asyncio equivalents are
217
+ available through `AsyncClient.connect_udp()`.
218
+
191
219
  ## CLI model
192
220
 
193
221
  Tailkitty owns a small Python-native command surface:
@@ -214,9 +242,9 @@ tailkitty socks 'tc...' curl http://server.tailcat:8080/
214
242
  tailkitty genkey --client --key=client-default
215
243
  ```
216
244
 
217
- Run `tailkitty readme` for the documentation embedded in the bundled Tailcat version. Tailcat v0.6
218
- also supports application-layer UDP through its Go API and SOCKS5 UDP association; Tailkitty does
219
- not currently expose a separate typed Python UDP API.
245
+ Run `tailkitty readme` for the documentation embedded in the bundled Tailcat version. Tailkitty's
246
+ typed UDP API uses Tailcat v0.6's SOCKS5 UDP association and an auditable bundled patch that adds
247
+ opt-in `127.0.0.1` UDP serving to the CLI.
220
248
 
221
249
  ## Addresses and DNS
222
250
 
@@ -262,8 +290,9 @@ tailkitty resolve 'tc...' > full-address.txt
262
290
  | `Client.request(...)` | Yes | Finite request/response exchange |
263
291
  | `Client.run(...)` | Yes | Finite exchange with exit status and stderr |
264
292
  | `Client.connect(...)` | Yes | Full-duplex `subprocess.Popen` connection |
293
+ | `Client.connect_udp(...)` | Yes | Datagram-preserving UDP connection |
265
294
  | `ServerProcess` | Yes | Managed synchronous server |
266
- | `AsyncClient` / `AsyncServerProcess` | Yes | Asyncio equivalents |
295
+ | `AsyncClient` / `AsyncServerProcess` | Yes | Asyncio equivalents, including UDP |
267
296
  | `run(...)` / `run_async(...)` | Yes | Low-level Tailcat command execution |
268
297
  | `diagnostics()` | Yes | Structured backend and environment report |
269
298
 
@@ -28,6 +28,7 @@ control server.
28
28
  | --- | --- |
29
29
  | Send bytes between two computers | [One-shot pipe](#one-shot-pipe) |
30
30
  | Reach a remote web app or database | [Forward a port](#forward-a-port) |
31
+ | Exchange UDP datagrams | [Typed UDP](#typed-udp) |
31
32
  | Use Tailcat from Python | [Python quickstart](#python-quickstart) |
32
33
  | Transfer files or use SSH | [Recipes](https://github.com/kornpow/tailkitty/blob/main/docs/recipes.md) |
33
34
  | Diagnose an installation | [Troubleshooting](https://github.com/kornpow/tailkitty/blob/main/docs/troubleshooting.md) |
@@ -163,6 +164,33 @@ exit. See the [Python API guide](https://github.com/kornpow/tailkitty/blob/main/
163
164
  for asyncio, streaming, token handling, server
164
165
  options, exceptions, and low-level execution.
165
166
 
167
+ ### Typed UDP
168
+
169
+ Tailkitty preserves application datagram boundaries instead of treating UDP as a byte stream. On
170
+ the server, bind your UDP application to localhost and opt in to the same Tailcat port:
171
+
172
+ ```python
173
+ from tailkitty import ServerProcess
174
+
175
+ with ServerProcess(udp=5353, key="new") as server:
176
+ print(server.token)
177
+ input("Press Enter to stop the UDP tunnel... ")
178
+ ```
179
+
180
+ On the client:
181
+
182
+ ```python
183
+ from tailkitty import Client
184
+
185
+ with Client("tc...").connect_udp(5353, timeout=10) as connection:
186
+ reply = connection.request(b"one datagram", timeout=3)
187
+ print(reply.data)
188
+ ```
189
+
190
+ `send()` and `receive()` each handle exactly one datagram. Payloads are limited to
191
+ `MAX_UDP_PAYLOAD` (1232 bytes), the safe size for Tailcat's tunnel MTU. Asyncio equivalents are
192
+ available through `AsyncClient.connect_udp()`.
193
+
166
194
  ## CLI model
167
195
 
168
196
  Tailkitty owns a small Python-native command surface:
@@ -189,9 +217,9 @@ tailkitty socks 'tc...' curl http://server.tailcat:8080/
189
217
  tailkitty genkey --client --key=client-default
190
218
  ```
191
219
 
192
- Run `tailkitty readme` for the documentation embedded in the bundled Tailcat version. Tailcat v0.6
193
- also supports application-layer UDP through its Go API and SOCKS5 UDP association; Tailkitty does
194
- not currently expose a separate typed Python UDP API.
220
+ Run `tailkitty readme` for the documentation embedded in the bundled Tailcat version. Tailkitty's
221
+ typed UDP API uses Tailcat v0.6's SOCKS5 UDP association and an auditable bundled patch that adds
222
+ opt-in `127.0.0.1` UDP serving to the CLI.
195
223
 
196
224
  ## Addresses and DNS
197
225
 
@@ -237,8 +265,9 @@ tailkitty resolve 'tc...' > full-address.txt
237
265
  | `Client.request(...)` | Yes | Finite request/response exchange |
238
266
  | `Client.run(...)` | Yes | Finite exchange with exit status and stderr |
239
267
  | `Client.connect(...)` | Yes | Full-duplex `subprocess.Popen` connection |
268
+ | `Client.connect_udp(...)` | Yes | Datagram-preserving UDP connection |
240
269
  | `ServerProcess` | Yes | Managed synchronous server |
241
- | `AsyncClient` / `AsyncServerProcess` | Yes | Asyncio equivalents |
270
+ | `AsyncClient` / `AsyncServerProcess` | Yes | Asyncio equivalents, including UDP |
242
271
  | `run(...)` / `run_async(...)` | Yes | Low-level Tailcat command execution |
243
272
  | `diagnostics()` | Yes | Structured backend and environment report |
244
273
 
@@ -122,16 +122,20 @@ command line, in shell history, or in repository files.
122
122
  If the automated release is still running, do not race it with a local upload. Cancel or disable
123
123
  the redundant publisher first so one path owns publication.
124
124
 
125
- ## 7. Create or verify the GitHub release
125
+ ## 7. Verify the GitHub release
126
126
 
127
- The release should contain the same seven artifacts published to PyPI, plus checksums when the
128
- automated workflow produced them. For a controlled local fallback:
127
+ After Trusted Publishing succeeds, the workflow creates the GitHub Release and attaches the same
128
+ seven artifacts plus `SHA256SUMS`. It verifies that the tag already exists before creating the
129
+ release. Confirm those eight assets are present.
130
+
131
+ For a controlled local fallback when automation was not used:
129
132
 
130
133
  ```console
131
134
  gh release create vX.Y.Z \
132
135
  --title "Tailkitty vX.Y.Z" \
133
136
  --generate-notes \
134
- dist/release/*
137
+ dist/release/* \
138
+ checksums/SHA256SUMS
135
139
  ```
136
140
 
137
141
  ## 8. Verify from the public index
@@ -113,6 +113,11 @@ These services have broader consequences than a one-shot byte stream:
113
113
 
114
114
  Tailkitty does not add authentication to the application behind a served port.
115
115
 
116
+ UDP serving is separately opt-in through `ServerProcess(udp=...)`. Selected Tailcat UDP ports are
117
+ forwarded only to matching `127.0.0.1` UDP ports; the local application still needs its own
118
+ authentication where appropriate. UDP source addresses seen by the local application belong to
119
+ the proxy path and are not a substitute for Tailcat client identity or `--allow`.
120
+
116
121
  ## Bundle trust model
117
122
 
118
123
  Platform wheels contain an executable built from the immutable Tailcat pin. The build manifest
@@ -35,6 +35,7 @@ Tailcat interoperability depends on Tailscale's implementations of:
35
35
  - WireGuard session establishment and encryption.
36
36
  - Disco-key peer discovery and endpoint exchange.
37
37
  - STUN-based NAT traversal and UDP hole punching.
38
+ - Application UDP flows with preserved datagram boundaries.
38
39
  - DERP relay transport and path selection.
39
40
  - A userspace TCP/IP stack based on gVisor netstack.
40
41
 
@@ -63,6 +64,17 @@ directly to Tailcat rather than an unnecessary wrapper process.
63
64
  This small ownership surface prevents Tailkitty's parser from lagging or subtly changing upstream
64
65
  commands.
65
66
 
67
+ ## Typed UDP flow
68
+
69
+ Each `UDPConnection` owns a private loopback SOCKS5 UDP association. Python encodes and validates
70
+ the SOCKS5 datagram envelope; the upstream process carries each payload over Tailcat's WireGuard
71
+ tunnel without merging datagrams. A small recorded source patch adds opt-in `serve --udp`
72
+ forwarding from selected tunnel ports to matching `127.0.0.1` UDP ports.
73
+
74
+ This keeps the cryptographic and network stack native while exposing typed synchronous and asyncio
75
+ lifecycle APIs. Closing a connection closes its sockets and terminates and reaps the owned Tailcat
76
+ process. Payloads larger than Tailcat's 1232-byte safe tunnel size are rejected before sending.
77
+
66
78
  ## Address flow
67
79
 
68
80
  ```text
@@ -18,6 +18,7 @@ uv add tailkitty
18
18
  | Send one finite request | `Client.request()` |
19
19
  | Keep exit status and stderr | `Client.run()` |
20
20
  | Stream in both directions | `Client.connect()` |
21
+ | Exchange UDP datagrams | `Client.connect_udp()` |
21
22
  | Send bytes with a one-line helper | `send()` |
22
23
  | Run a managed server | `ServerProcess` |
23
24
  | Do the same work with asyncio | `AsyncClient`, `AsyncServerProcess` |
@@ -185,6 +186,64 @@ termination, and reaping.
185
186
  For a minimal one-shot exchange, `send(destination, data, port=0, timeout=None)` is a convenience
186
187
  wrapper around `Client(destination).request(...)`.
187
188
 
189
+ ## UDP datagrams
190
+
191
+ `connect_udp()` returns a managed connection that keeps datagram boundaries intact:
192
+
193
+ ```python
194
+ from tailkitty import Client, MAX_UDP_PAYLOAD
195
+
196
+ with Client("tc...").connect_udp(5353, timeout=10) as connection:
197
+ connection.send(b"first datagram")
198
+ response = connection.receive(timeout=3)
199
+ print(response.data, response.host, response.port)
200
+
201
+ second = connection.request(b"second datagram", timeout=3)
202
+ assert len(second.data) <= MAX_UDP_PAYLOAD
203
+ ```
204
+
205
+ Each `send()` transmits one datagram and each `receive()` returns one immutable `Datagram` with
206
+ `data`, `host`, and `port` fields. `request()` is a send followed by one receive; UDP itself does
207
+ not guarantee that the response corresponds to the request. A receive timeout raises
208
+ `TimeoutError` (`socket.timeout` in synchronous code).
209
+
210
+ The default target host, `server.tailcat`, means the named Tailcat server. Set `host` to an IP or
211
+ hostname only when the server is intentionally configured as an exit node:
212
+
213
+ ```python
214
+ connection = Client("tc...").connect_udp(53, host="192.0.2.53")
215
+ ```
216
+
217
+ Tailcat's IPv6 tunnel MTU makes 1232 bytes the largest safe UDP payload. Tailkitty rejects larger
218
+ writes rather than relying on fragmentation. SOCKS5 fragmentation is not supported. Each
219
+ connection owns a private loopback-only SOCKS5 proxy and native Tailcat process; use a context
220
+ manager or call `close()`.
221
+
222
+ The asyncio API has matching semantics:
223
+
224
+ ```python
225
+ from tailkitty import AsyncClient
226
+
227
+ async with await AsyncClient("tc...").connect_udp(5353, timeout=10) as connection:
228
+ response = await connection.request(b"datagram", timeout=3)
229
+ print(response.data)
230
+ ```
231
+
232
+ On the server, start the UDP application on localhost first, then expose the matching port:
233
+
234
+ ```python
235
+ from tailkitty import ServerProcess
236
+
237
+ with ServerProcess(udp=[5353, 9000], key="new", allow=["nodekey:..."]) as server:
238
+ print(server.token)
239
+ input("Press Enter to stop the UDP tunnel... ")
240
+ ```
241
+
242
+ `udp` accepts one integer or an iterable of integers. It is opt-in and independent of TCP `serve`;
243
+ the bundled backend forwards only the selected Tailcat UDP ports to the same ports on `127.0.0.1`.
244
+ This server feature is a Tailkitty patch to the pinned executable. An unpatched external backend
245
+ selected through `TAILKITTY_BACKEND` may reject `--udp`.
246
+
188
247
  ## Managed synchronous server
189
248
 
190
249
  ```python
@@ -230,6 +289,7 @@ equivalent.
230
289
  | `use_preshared_key` | Explicitly enable or disable address PSKs; `None` uses upstream default |
231
290
  | `files` | File-service root and optional mode suffix |
232
291
  | `ssh_authorized_keys` | One source or a sequence of files, literal keys, or `user@github` values |
292
+ | `udp` | UDP port or iterable forwarded to matching localhost UDP ports |
233
293
  | `extra_args` | Escape hatch for upstream flags not modeled yet |
234
294
  | `stdin`, `stdout`, `stderr` | Synchronous child-process stream targets |
235
295
  | `env` | Environment additions for the child process |
@@ -340,6 +400,7 @@ target, wheel tag, Tailcat version, compiler version, size, digest, and verified
340
400
  | `BackendNotFound` | Backend discovery and integrity translation |
341
401
  | `BundleError` | Direct bundle validation |
342
402
  | `ServerStartError` | Managed server exits before advertising an address |
403
+ | `UDPError` | SOCKS5 negotiation, framing, subprocess, or closed-connection failure |
343
404
  | `TimeoutError` | Managed startup or async operation timeout |
344
405
  | `subprocess.TimeoutExpired` | Synchronous finite client timeout |
345
406
  | `subprocess.CalledProcessError` | Checked client or low-level command fails |
@@ -239,6 +239,33 @@ The SOCKS proxy can also route ordinary destinations through an exit node:
239
239
  tailkitty socks 'tc...' curl https://example.com/
240
240
  ```
241
241
 
242
+ ## Exchange UDP datagrams from Python
243
+
244
+ Start a UDP service on `127.0.0.1:5353` on the server, then expose that same UDP port:
245
+
246
+ ```python
247
+ from tailkitty import ServerProcess
248
+
249
+ with ServerProcess(udp=5353, key="new", allow=["nodekey:..."]) as server:
250
+ print(server.token)
251
+ input("Press Enter to stop the UDP tunnel... ")
252
+ ```
253
+
254
+ On the client:
255
+
256
+ ```python
257
+ from tailkitty import Client
258
+
259
+ with Client("tc...").connect_udp(5353, timeout=10) as connection:
260
+ connection.send(b"one datagram")
261
+ response = connection.receive(timeout=3)
262
+ print(response.data)
263
+ ```
264
+
265
+ Use `AsyncClient.connect_udp()` for asyncio applications. Keep payloads at or below 1232 bytes.
266
+ UDP is unreliable and unordered by design, so applications must define their own retry and
267
+ request-correlation behavior when needed.
268
+
242
269
  ## Check relay and direct connectivity
243
270
 
244
271
  One ping reports the path used:
@@ -177,6 +177,19 @@ Failure to become direct usually points to NAT, firewall, captive-network, or UD
177
177
  connection can still work through DERP. Compare both peers from another network before assuming a
178
178
  packaging defect.
179
179
 
180
+ ## UDP request times out
181
+
182
+ - Confirm the local UDP application is bound to `127.0.0.1:<port>` on the server before starting
183
+ `ServerProcess(udp=<port>)`.
184
+ - `serve=<port>` exposes TCP; `udp=<port>` exposes UDP. Configure the protocol you actually use.
185
+ - Keep each datagram at or below `MAX_UDP_PAYLOAD` (1232 bytes).
186
+ - Set a finite receive timeout while diagnosing; UDP has no delivery or response guarantee.
187
+ - Confirm the client satisfies the server's `allow` policy.
188
+ - An external `TAILKITTY_BACKEND` needs Tailkitty's `serve --udp` patch for server-side forwarding.
189
+
190
+ Always close `UDPConnection`, preferably with a context manager, so its private SOCKS5 proxy and
191
+ Tailcat subprocess are reaped.
192
+
180
193
  ## DNS destination fails
181
194
 
182
195
  Inspect the TXT record with a DNS tool:
@@ -0,0 +1,118 @@
1
+ diff --git a/cmd/tailcat/tailcat.go b/cmd/tailcat/tailcat.go
2
+ index fbd6372..b2b8d74 100644
3
+ --- a/cmd/tailcat/tailcat.go
4
+ +++ b/cmd/tailcat/tailcat.go
5
+ @@ -57,6 +57,7 @@ var (
6
+ flagFiles *string
7
+ flagSSHAuthorizedKeys *string
8
+ flagPSK *bool
9
+ + flagUDP *string
10
+ flagVerbose *bool
11
+ flagFullAddress *bool
12
+ flagJSON *bool
13
+ @@ -103,6 +104,7 @@ func newRootCommand() *ff.Command {
14
+ flagFiles = serveFS.StringLong("files", "", "directory to serve to SFTP clients (scp, sftp) with the 'files' service, with an optional :ro (read-only, the default), :rw (read-write), :wo (flat write-only drop box), or :wo+ (recursive write-only drop box) suffix. If empty, the current directory is served read-only. Giving --files implies the 'files' service.")
15
+ flagSSHAuthorizedKeys = serveFS.StringLong("ssh-authorized-keys", "", "comma-separated SSH public key sources for the 'ssh' service: authorized_keys file paths, literal OpenSSH public key lines, or names like 'alice@github' (fetched from https://github.com/alice.keys). All sources are loaded and validated at startup.")
16
+ flagPSK = serveFS.BoolLongDefault("psk", true, "include a WireGuard pre-shared key in the tailcat address (recommended). Set false only for shorter addresses and compatibility with tailcat clients v0.5.0 and earlier; this weakens security.")
17
+ + flagUDP = serveFS.StringLong("udp", "", "comma-separated UDP ports or ranges to proxy to the same ports on localhost")
18
+
19
+ recvFS := ff.NewFlagSet("recv").SetParent(serveFS)
20
+ flagRecvAcceptDirs := recvFS.BoolLong("accept-dirs", "accept directory trees (tailcat cp -r), keeping requested file names when available. The trade-off: senders can then make and stat directories and learn whether some names already exist in the drop box. The default flat mode reveals nothing about existing files, but accepts only single files, each saved under a server-chosen unique name.")
21
+ @@ -1174,6 +1176,13 @@ func server(logf logger.Logf, serveSpec string) {
22
+ if err != nil {
23
+ log.Fatalf("invalid port or service to serve: %v", err)
24
+ }
25
+ + udpPortSet, udpServices, err := parsePortSet(*flagUDP)
26
+ + if err != nil {
27
+ + log.Fatalf("invalid UDP port to serve: %v", err)
28
+ + }
29
+ + if len(udpServices) != 0 {
30
+ + log.Fatalf("--udp accepts only port numbers and ranges")
31
+ + }
32
+ if *flagFiles != "" {
33
+ if !tailCatSSHEnabled {
34
+ log.Fatalf("--files requires SSH support, not included in binary per build tags")
35
+ @@ -1212,7 +1221,7 @@ func server(logf logger.Logf, serveSpec string) {
36
+ }
37
+ // A server running only named services isn't the empty-port-list
38
+ // accept-one-connection stdout mode.
39
+ - oneShotStdout := len(portSet) == 0 && len(services) == 0
40
+ + oneShotStdout := len(portSet) == 0 && len(services) == 0 && len(udpPortSet) == 0
41
+
42
+ var reg *tailcfg.DERPRegion
43
+ var devDERP *derpserver.Server
44
+ @@ -1322,6 +1331,9 @@ func server(logf logger.Logf, serveSpec string) {
45
+ }
46
+ s.ServedTCPPorts = portRanges(ports)
47
+ }
48
+ + if len(udpPortSet) != 0 {
49
+ + s.ServedUDPPorts = portRanges(slices.Sorted(maps.Keys(udpPortSet)))
50
+ + }
51
+ if *flagAllow != "" {
52
+ for _, ks := range strings.Split(*flagAllow, ",") {
53
+ if ks == "none" {
54
+ @@ -1347,6 +1360,24 @@ func server(logf logger.Logf, serveSpec string) {
55
+ }
56
+ }
57
+
58
+ + udpForwardTo := func(ipPortStr string) func(tailcat.ConnPacketConn) {
59
+ + return func(c tailcat.ConnPacketConn) {
60
+ + addr, err := net.ResolveUDPAddr("udp", ipPortStr)
61
+ + if err != nil {
62
+ + logf("error resolving UDP target %v: %v", ipPortStr, err)
63
+ + c.Close()
64
+ + return
65
+ + }
66
+ + localConn, err := net.DialUDP("udp", nil, addr)
67
+ + if err != nil {
68
+ + logf("error proxying UDP to %v: %v", ipPortStr, err)
69
+ + c.Close()
70
+ + return
71
+ + }
72
+ + tailcat.ProxyPacketConns(c, localConn)
73
+ + }
74
+ + }
75
+ +
76
+ if services.Contains("exit-node") {
77
+ s.OnTCPForward = func(dst netip.AddrPort) (handler func(net.Conn)) {
78
+ return tcpForwardTo(dst.String())
79
+ @@ -1387,6 +1418,15 @@ func server(logf logger.Logf, serveSpec string) {
80
+ return tcpForwardTo(fmt.Sprintf("localhost:%v", port))
81
+ }
82
+
83
+ + if len(udpPortSet) != 0 {
84
+ + s.OnUDP = func(port uint16) (handler func(tailcat.ConnPacketConn)) {
85
+ + if !udpPortSet.Contains(port) {
86
+ + return nil
87
+ + }
88
+ + return udpForwardTo(fmt.Sprintf("127.0.0.1:%v", port))
89
+ + }
90
+ + }
91
+ +
92
+ if err := s.Start(); err != nil {
93
+ log.Fatalf("Server.Start: %v", err)
94
+ }
95
+ diff --git a/cmd/tailcat/tailcat.go b/cmd/tailcat/tailcat.go
96
+ --- a/cmd/tailcat/tailcat.go
97
+ +++ b/cmd/tailcat/tailcat.go
98
+ @@ -416,6 +416,9 @@ The arguments are port numbers, port ranges, and service names,
99
+ either as separate arguments or comma-separated. Ports are proxied
100
+ to the same port on localhost. Service names are:
101
+
102
+ +The --udp flag separately selects UDP ports or ranges to proxy to the
103
+ +same ports on localhost. UDP is opt-in and does not change TCP serving.
104
+ +
105
+ all serve all ports
106
+ exit-node run an exit node for all addresses
107
+ ssh SSH server requiring a public key listed by
108
+ @@ -439,6 +442,10 @@ Serve some ports:
109
+
110
+ tailcat serve 22,80,443,8000-8999
111
+
112
+ +Serve UDP port 53 while also serving TCP port 8080:
113
+ +
114
+ + tailcat serve --udp=53 8080
115
+ +
116
+ Serve all ports:
117
+
118
+ tailcat serve all
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tailkitty"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Python tooling and a verified distribution for Tailscale Tailcat"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -15,6 +15,41 @@ from pathlib import Path
15
15
 
16
16
  from .targets import host_target
17
17
 
18
+ UDP_SMOKE = r"""import socket
19
+ import threading
20
+
21
+ from tailkitty import Client, ServerProcess
22
+
23
+ sock = socket.socket(type=socket.SOCK_DGRAM)
24
+ sock.settimeout(5)
25
+ sock.bind(("127.0.0.1", 0))
26
+ port = sock.getsockname()[1]
27
+
28
+ def echo():
29
+ data, source = sock.recvfrom(2048)
30
+ sock.sendto(data, source)
31
+
32
+ thread = threading.Thread(target=echo, daemon=True)
33
+ thread.start()
34
+ try:
35
+ server = ServerProcess(
36
+ key="new",
37
+ udp=port,
38
+ env={"TS_DEBUG_TAILCAT_LOCAL_DERP": "1"},
39
+ )
40
+ try:
41
+ server.start(timeout=5)
42
+ with Client(server.token).connect_udp(port, timeout=5) as connection:
43
+ response = connection.request(b"tailkitty-udp-smoke", timeout=3)
44
+ assert response.data == b"tailkitty-udp-smoke"
45
+ finally:
46
+ server.stop(grace_period=2)
47
+ thread.join(timeout=1)
48
+ assert not thread.is_alive()
49
+ finally:
50
+ sock.close()
51
+ """
52
+
18
53
 
19
54
  def smoke_data_plane(backend: Path) -> None:
20
55
  """Prove the bundled helper can complete a real encrypted peer handshake."""
@@ -86,12 +121,19 @@ def smoke_wheel(wheel: Path, *, uv: str = "uv") -> dict[str, object]:
86
121
  raise RuntimeError("installed wheel did not discover its verified host bundle")
87
122
  backend = Path(report["backend"]["path"])
88
123
  smoke_data_plane(backend)
124
+ subprocess.run(
125
+ [str(interpreter), "-c", UDP_SMOKE],
126
+ cwd=root,
127
+ check=True,
128
+ timeout=15,
129
+ )
89
130
  return {
90
131
  "wheel": wheel.name,
91
132
  "target": target.name,
92
133
  "backend": report["backend"]["path"],
93
134
  "tailcat_version": bundle["tailcat_version"],
94
135
  "data_plane": "verified",
136
+ "udp_data_plane": "verified",
95
137
  "verified": bundle["verified"],
96
138
  }
97
139
 
@@ -12,16 +12,20 @@ from .destination import DestinationError, resolve_destination, resolve_destinat
12
12
  from .diagnostics import diagnostics
13
13
  from .process import AsyncServerProcess, ServerProcess, ServerStartError, run_async, send
14
14
  from .token import ConnInfo, DerpNode, DerpRegion, TokenError, parse_token, resolve_token
15
+ from .udp import MAX_UDP_PAYLOAD, AsyncUDPConnection, Datagram, UDPConnection, UDPError
15
16
 
16
17
  __all__ = [
18
+ "MAX_UDP_PAYLOAD",
17
19
  "AsyncClient",
18
20
  "AsyncServerProcess",
21
+ "AsyncUDPConnection",
19
22
  "BackendInfo",
20
23
  "BackendNotFound",
21
24
  "BundleError",
22
25
  "BundleManifest",
23
26
  "Client",
24
27
  "ConnInfo",
28
+ "Datagram",
25
29
  "DerpMapCache",
26
30
  "DerpMapError",
27
31
  "DerpNode",
@@ -30,6 +34,8 @@ __all__ = [
30
34
  "ServerProcess",
31
35
  "ServerStartError",
32
36
  "TokenError",
37
+ "UDPConnection",
38
+ "UDPError",
33
39
  "diagnostics",
34
40
  "inspect_backend",
35
41
  "parse_token",