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.
- {tailkitty-0.2.2 → tailkitty-0.2.3}/AGENTS.md +1 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/BUILDING.md +2 -1
- {tailkitty-0.2.2 → tailkitty-0.2.3}/CHANGELOG.md +9 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/COMPARISON.md +3 -2
- {tailkitty-0.2.2 → tailkitty-0.2.3}/PKG-INFO +34 -5
- {tailkitty-0.2.2 → tailkitty-0.2.3}/README.md +33 -4
- {tailkitty-0.2.2 → tailkitty-0.2.3}/RELEASING.md +8 -4
- {tailkitty-0.2.2 → tailkitty-0.2.3}/SECURITY.md +5 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/docs/architecture.md +12 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/docs/python-api.md +61 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/docs/recipes.md +27 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/docs/troubleshooting.md +13 -0
- tailkitty-0.2.3/patches/0001-cli-udp-serving.patch +118 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/pyproject.toml +1 -1
- {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/smoke_wheel.py +42 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/__init__.py +6 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/client.py +28 -1
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/constants.py +1 -1
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/process.py +19 -0
- tailkitty-0.2.3/src/tailkitty/udp.py +417 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_cli.py +1 -1
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_module.py +1 -1
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_process.py +7 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_project.py +16 -0
- tailkitty-0.2.3/tests/test_udp.py +125 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/uv.lock +1 -1
- {tailkitty-0.2.2 → tailkitty-0.2.3}/.gitignore +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/.mise.toml +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/.python-version +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/CONTRIBUTING.md +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/ITERATIONS.md +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/LICENSE +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/THIRD_PARTY_NOTICES.md +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/hatch_build.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/__init__.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/build_binary.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/build_wheels.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/check_upstream.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/targets.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/scripts/verify_wheel.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/__main__.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/backend.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/bundle.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/cli.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/derp.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/destination.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/diagnostics.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/py.typed +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/src/tailkitty/token.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_backend.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_bundle.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_derp.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_destination.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_targets.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_token.py +0 -0
- {tailkitty-0.2.2 → tailkitty-0.2.3}/tests/test_token_validation.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
193
|
-
|
|
194
|
-
|
|
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.
|
|
125
|
+
## 7. Verify the GitHub release
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
@@ -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",
|