wildburrow 0.1.0__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 (100) hide show
  1. wildburrow-0.1.0/.gitignore +13 -0
  2. wildburrow-0.1.0/LICENSE +21 -0
  3. wildburrow-0.1.0/PKG-INFO +231 -0
  4. wildburrow-0.1.0/README.md +196 -0
  5. wildburrow-0.1.0/pyproject.toml +129 -0
  6. wildburrow-0.1.0/src/wildburrow/__init__.py +0 -0
  7. wildburrow-0.1.0/src/wildburrow/__main__.py +6 -0
  8. wildburrow-0.1.0/src/wildburrow/auth/__init__.py +0 -0
  9. wildburrow-0.1.0/src/wildburrow/auth/base.py +24 -0
  10. wildburrow-0.1.0/src/wildburrow/auth/keys.py +60 -0
  11. wildburrow-0.1.0/src/wildburrow/auth/password.py +66 -0
  12. wildburrow-0.1.0/src/wildburrow/auth/sshsig.py +68 -0
  13. wildburrow-0.1.0/src/wildburrow/auth/token.py +46 -0
  14. wildburrow-0.1.0/src/wildburrow/certs/__init__.py +0 -0
  15. wildburrow-0.1.0/src/wildburrow/certs/acme.py +118 -0
  16. wildburrow-0.1.0/src/wildburrow/certs/manager.py +119 -0
  17. wildburrow-0.1.0/src/wildburrow/certs/store.py +56 -0
  18. wildburrow-0.1.0/src/wildburrow/cli/__init__.py +54 -0
  19. wildburrow-0.1.0/src/wildburrow/cli/ask.py +59 -0
  20. wildburrow-0.1.0/src/wildburrow/cli/client.py +181 -0
  21. wildburrow-0.1.0/src/wildburrow/cli/errors.py +12 -0
  22. wildburrow-0.1.0/src/wildburrow/cli/server.py +182 -0
  23. wildburrow-0.1.0/src/wildburrow/cli/service.py +310 -0
  24. wildburrow-0.1.0/src/wildburrow/client/__init__.py +0 -0
  25. wildburrow-0.1.0/src/wildburrow/client/config.py +60 -0
  26. wildburrow-0.1.0/src/wildburrow/client/retry.py +19 -0
  27. wildburrow-0.1.0/src/wildburrow/client/ssh.py +75 -0
  28. wildburrow-0.1.0/src/wildburrow/client/websocket.py +173 -0
  29. wildburrow-0.1.0/src/wildburrow/config.py +100 -0
  30. wildburrow-0.1.0/src/wildburrow/core/__init__.py +0 -0
  31. wildburrow-0.1.0/src/wildburrow/core/context.py +50 -0
  32. wildburrow-0.1.0/src/wildburrow/core/errors.py +6 -0
  33. wildburrow-0.1.0/src/wildburrow/core/names.py +55 -0
  34. wildburrow-0.1.0/src/wildburrow/core/registry.py +32 -0
  35. wildburrow-0.1.0/src/wildburrow/core/types.py +68 -0
  36. wildburrow-0.1.0/src/wildburrow/dns/__init__.py +0 -0
  37. wildburrow-0.1.0/src/wildburrow/dns/base.py +12 -0
  38. wildburrow-0.1.0/src/wildburrow/dns/cloudflare.py +72 -0
  39. wildburrow-0.1.0/src/wildburrow/edge/__init__.py +0 -0
  40. wildburrow-0.1.0/src/wildburrow/edge/http.py +179 -0
  41. wildburrow-0.1.0/src/wildburrow/edge/proxy.py +88 -0
  42. wildburrow-0.1.0/src/wildburrow/envfile.py +63 -0
  43. wildburrow-0.1.0/src/wildburrow/plugins.py +32 -0
  44. wildburrow-0.1.0/src/wildburrow/protocol.py +74 -0
  45. wildburrow-0.1.0/src/wildburrow/server.py +126 -0
  46. wildburrow-0.1.0/src/wildburrow/service/__init__.py +140 -0
  47. wildburrow-0.1.0/src/wildburrow/service/launchd.py +81 -0
  48. wildburrow-0.1.0/src/wildburrow/service/systemd.py +80 -0
  49. wildburrow-0.1.0/src/wildburrow/streams.py +28 -0
  50. wildburrow-0.1.0/src/wildburrow/transports/__init__.py +0 -0
  51. wildburrow-0.1.0/src/wildburrow/transports/ssh.py +197 -0
  52. wildburrow-0.1.0/src/wildburrow/transports/websocket.py +226 -0
  53. wildburrow-0.1.0/tests/__init__.py +0 -0
  54. wildburrow-0.1.0/tests/acme_env.py +101 -0
  55. wildburrow-0.1.0/tests/conftest.py +278 -0
  56. wildburrow-0.1.0/tests/e2e/__init__.py +0 -0
  57. wildburrow-0.1.0/tests/e2e/test_passthrough.py +180 -0
  58. wildburrow-0.1.0/tests/e2e/test_reconnect.py +113 -0
  59. wildburrow-0.1.0/tests/e2e/test_tls.py +258 -0
  60. wildburrow-0.1.0/tests/e2e/test_tunnels.py +405 -0
  61. wildburrow-0.1.0/tests/e2e/test_websocket.py +599 -0
  62. wildburrow-0.1.0/tests/plugins/__init__.py +0 -0
  63. wildburrow-0.1.0/tests/plugins/challtestsrv.py +37 -0
  64. wildburrow-0.1.0/tests/system/__init__.py +0 -0
  65. wildburrow-0.1.0/tests/system/test_service.py +89 -0
  66. wildburrow-0.1.0/tests/unit/__init__.py +0 -0
  67. wildburrow-0.1.0/tests/unit/conftest.py +78 -0
  68. wildburrow-0.1.0/tests/unit/help/connect.txt +18 -0
  69. wildburrow-0.1.0/tests/unit/help/hash-password.txt +6 -0
  70. wildburrow-0.1.0/tests/unit/help/login.txt +17 -0
  71. wildburrow-0.1.0/tests/unit/help/logout.txt +7 -0
  72. wildburrow-0.1.0/tests/unit/help/serve.txt +81 -0
  73. wildburrow-0.1.0/tests/unit/help/service-edit.txt +78 -0
  74. wildburrow-0.1.0/tests/unit/help/service-install.txt +79 -0
  75. wildburrow-0.1.0/tests/unit/help/service-status.txt +6 -0
  76. wildburrow-0.1.0/tests/unit/help/service-uninstall.txt +8 -0
  77. wildburrow-0.1.0/tests/unit/help/service.txt +12 -0
  78. wildburrow-0.1.0/tests/unit/help/token.txt +6 -0
  79. wildburrow-0.1.0/tests/unit/help/wildburrow.txt +16 -0
  80. wildburrow-0.1.0/tests/unit/test_ask.py +85 -0
  81. wildburrow-0.1.0/tests/unit/test_auth.py +188 -0
  82. wildburrow-0.1.0/tests/unit/test_certs.py +403 -0
  83. wildburrow-0.1.0/tests/unit/test_cli.py +590 -0
  84. wildburrow-0.1.0/tests/unit/test_cli_service.py +618 -0
  85. wildburrow-0.1.0/tests/unit/test_client_ssh.py +112 -0
  86. wildburrow-0.1.0/tests/unit/test_client_websocket.py +475 -0
  87. wildburrow-0.1.0/tests/unit/test_cloudflare.py +135 -0
  88. wildburrow-0.1.0/tests/unit/test_config.py +107 -0
  89. wildburrow-0.1.0/tests/unit/test_edge.py +132 -0
  90. wildburrow-0.1.0/tests/unit/test_envfile.py +102 -0
  91. wildburrow-0.1.0/tests/unit/test_http.py +144 -0
  92. wildburrow-0.1.0/tests/unit/test_mutation.py +434 -0
  93. wildburrow-0.1.0/tests/unit/test_names.py +90 -0
  94. wildburrow-0.1.0/tests/unit/test_plugins.py +39 -0
  95. wildburrow-0.1.0/tests/unit/test_proxy.py +98 -0
  96. wildburrow-0.1.0/tests/unit/test_registry.py +97 -0
  97. wildburrow-0.1.0/tests/unit/test_server.py +213 -0
  98. wildburrow-0.1.0/tests/unit/test_service.py +400 -0
  99. wildburrow-0.1.0/tests/unit/test_sshsig.py +144 -0
  100. wildburrow-0.1.0/tests/unit/test_token.py +44 -0
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .coverage
5
+ .coverage.*
6
+ coverage.xml
7
+ htmlcov/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .hypothesis/
11
+ dist/
12
+ /data/
13
+ mutants/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Işık Kaplan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.5
2
+ Name: wildburrow
3
+ Version: 0.1.0
4
+ Summary: Self-hosted HTTP tunnels with on-demand wildcard certificates at any depth
5
+ Project-URL: Repository, https://github.com/isik-kaplan/wildburrow
6
+ Project-URL: Issues, https://github.com/isik-kaplan/wildburrow/issues
7
+ Author: Işık Kaplan
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: acme,letsencrypt,ngrok,reverse-proxy,ssh,tunnel,websocket,wildcard
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: 3.15
21
+ Classifier: Topic :: Internet :: Proxy Servers
22
+ Classifier: Topic :: System :: Networking
23
+ Requires-Python: >=3.14.4
24
+ Requires-Dist: click==8.5.0
25
+ Requires-Dist: questionary==2.1.1
26
+ Requires-Dist: websockets==17.2
27
+ Provides-Extra: server
28
+ Requires-Dist: acme==5.8.0; extra == 'server'
29
+ Requires-Dist: argon2-cffi==25.1.0; extra == 'server'
30
+ Requires-Dist: asyncssh==2.24.1; extra == 'server'
31
+ Requires-Dist: cryptography==50.0.2; extra == 'server'
32
+ Requires-Dist: httpx==0.28.1; extra == 'server'
33
+ Requires-Dist: josepy==2.2.0; extra == 'server'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # wildburrow
37
+
38
+ Self-hosted HTTPS tunnels on your own domain, with wildcard names at any depth and certificates issued on demand.
39
+ It runs on Linux and macOS.
40
+
41
+ ```sh
42
+ wildburrow 3000 myapp # https://myapp.tunnel.example.com
43
+ wildburrow 3000 '*.foo' # https://<anything>.foo.tunnel.example.com
44
+ wildburrow 3000 '*.bar.foo' # https://<anything>.bar.foo.tunnel.example.com
45
+ ```
46
+
47
+ A wildcard covers exactly one label, like a TLS wildcard certificate: `*.foo` serves `api.foo` but not `x.api.foo`.
48
+ An exact name wins over a wildcard. Quote `*` names so your shell doesn't expand them.
49
+
50
+ Requests reach your local app unchanged, so websockets, server-sent events and streaming responses work as they do
51
+ locally. With HTTPS, visitors use `wss://` and your app sees plain `ws://`.
52
+
53
+ When a name needs a certificate that doesn't exist yet, the server gets `*.<parent>` from an ACME CA through a DNS-01
54
+ challenge while your client waits, then confirms the bind. Later binds under the same parent are instant.
55
+
56
+ ## Client
57
+
58
+ ```sh
59
+ uv tool install wildburrow # or: pipx install wildburrow
60
+ wildburrow login # asks for the server and how to connect to it
61
+ wildburrow 3000 '*.foo'
62
+ wildburrow logout # forgets the saved server
63
+ ```
64
+
65
+ `login` keeps its answers in `~/.config/wildburrow/config.toml` until you log in again or out; nothing in it expires,
66
+ and every tunnel logs in afresh. Given options, `wildburrow login tunnel.example.com --transport websocket`, it asks
67
+ nothing and saves what you passed.
68
+
69
+ Two transports are available:
70
+
71
+ | Transport | How it connects | Auth methods |
72
+ |---|---|---|
73
+ | `ssh` (default) | your `ssh` binary, port 2222, one `-R` per name | `key`, `password` |
74
+ | `websocket` | `wss://tunnel.example.com` on 443 | `key` (signed with `ssh-keygen -Y sign`), `password`, `token` |
75
+
76
+ ```sh
77
+ wildburrow login tunnel.example.com --transport websocket --auth key --identity ~/.ssh/id_ed25519
78
+ wildburrow login tunnel.example.com --transport websocket --auth token --token wb_...
79
+ WILDBURROW_TOKEN=wb_... wildburrow 3000 app # token from the environment
80
+ ```
81
+
82
+ The client keeps the tunnel up: when the connection drops, it reconnects and binds its names again, waiting
83
+ `--retry-delay` seconds (default 1) and doubling up to `--retry-max` (default 30). Over websocket it also waits for a
84
+ server that isn't reachable yet. A refused login or a rejected name ends the client instead, and `--no-reconnect` makes
85
+ any disconnect end it. `login` saves these flags like the others and `connect` overrides them. Binding a name you
86
+ already hold moves it to the new connection; other identities are refused.
87
+
88
+ The websocket transport goes through an HTTP proxy from `HTTPS_PROXY` (or `HTTP_PROXY`) unless `NO_PROXY` excludes the
89
+ server. The ssh transport follows your SSH config, so a `ProxyJump` or `ProxyCommand` there applies.
90
+
91
+ Plain `ssh` works without the client:
92
+
93
+ ```sh
94
+ ssh -T -p 2222 -R '*.foo:80:localhost:3000' tunnel.example.com
95
+ ```
96
+
97
+ ## Server
98
+
99
+ ```sh
100
+ uv tool install 'wildburrow[server]'
101
+ wildburrow service install
102
+ ```
103
+
104
+ `service install` asks for the domain, whether to serve HTTPS and through which DNS provider, and which keys may open
105
+ tunnels, then installs the server as a system service and starts it: systemd on Linux, launchd on macOS. It asks for
106
+ sudo itself. Point `tunnel.example.com` and `*.tunnel.example.com` at the server; DNS wildcards match any depth, so
107
+ deeper names resolve too.
108
+
109
+ ```sh
110
+ wildburrow service status # running or not, and where the logs are
111
+ wildburrow service edit # the same questions, answered with the current settings
112
+ wildburrow service uninstall # stop and remove it, asking whether to keep the certificates
113
+ ```
114
+
115
+ Each of them takes the settings as options instead, and then asks nothing:
116
+
117
+ ```sh
118
+ wildburrow service install --domain=tunnel.example.com --acme-email=you@example.com \
119
+ --dns=cloudflare --dns-cloudflare-api-token-file=/root/cloudflare-token
120
+ wildburrow service edit --http-port=8080
121
+ wildburrow service uninstall --yes --purge
122
+ ```
123
+
124
+ Without a DNS provider the server serves plain HTTP on port 80. With one, it gets wildcard certificates by proving it
125
+ owns the domain through DNS: port 80 redirects bound names to HTTPS, and port 443 terminates TLS, picking the
126
+ certificate by SNI. A request whose `Host` isn't covered by the certificate of its connection gets `421`. Try
127
+ `--acme-directory=https://acme-staging-v02.api.letsencrypt.org/directory` until the first certificate comes through, so
128
+ a mistake doesn't run into Let's Encrypt's rate limits.
129
+
130
+ Unless told otherwise, the service lets in the keys in your `~/.ssh/authorized_keys`, copied when you install or edit.
131
+ Its settings live in `/etc/wildburrow`, readable only by root and the service.
132
+
133
+ On Linux, an install under a home directory, which is where `uv tool install` puts it, runs as its owner with that
134
+ home read-only; one outside any home, like `/opt`, runs as a throwaway user that systemd makes up. Either way it can
135
+ bind ports 80 and 443 and nothing more. On macOS it runs as the install's owner, which macOS lets bind those ports.
136
+
137
+ ### Foreground
138
+
139
+ `wildburrow serve` runs the server in the foreground with the same settings, read from options, an `--env-file` of
140
+ `KEY=VALUE` lines, or the environment:
141
+
142
+ ```sh
143
+ sudo wildburrow serve --domain=tunnel.example.com
144
+ ```
145
+
146
+ ### Docker
147
+
148
+ ```sh
149
+ docker run -d --restart unless-stopped -p 80:80 -p 443:443 -p 2222:2222 -v wildburrow-data:/data \
150
+ -e WILDBURROW_DOMAIN=tunnel.example.com \
151
+ -e WILDBURROW_AUTH_KEY_AUTHORIZED_KEYS="$(cat ~/.ssh/id_ed25519.pub)" \
152
+ -e WILDBURROW_DNS=cloudflare -e WILDBURROW_DNS_CLOUDFLARE_API_TOKEN=... \
153
+ -e WILDBURROW_ACME_EMAIL=you@example.com \
154
+ ghcr.io/isik-kaplan/wildburrow
155
+ ```
156
+
157
+ The image comes in versions matching the package, for amd64 and arm64, and runs on Docker Desktop for macOS too.
158
+ `examples/docker-compose.traefik.yml` runs it behind Traefik with TLS passthrough and PROXY protocol, for hosts where
159
+ another proxy already owns ports 80 and 443.
160
+
161
+ ### Settings
162
+
163
+ Every setting is an option of `serve`, `service install` and `service edit`, and an environment variable for `serve`:
164
+ `--http-port 8080` is `WILDBURROW_HTTP_PORT=8080`, and an option wins over the variable. Adding `-file` to an option,
165
+ or `_FILE` to a variable, reads the value from a file, for secrets and multi-line values:
166
+ `--auth-key-authorized-keys-file=~/keys`. `wildburrow serve --help` lists them all, plugins included. A service has
167
+ no `--data-dir`; its service manager decides where its data goes.
168
+
169
+ | Variable | Default | |
170
+ |---|---|---|
171
+ | `WILDBURROW_DOMAIN` | required | Base domain tunnels live under |
172
+ | `WILDBURROW_TRANSPORTS` | `ssh` | Transport plugins: `ssh`, `websocket` |
173
+ | `WILDBURROW_AUTH` | `key` | Auth plugins, tried in order: `key`, `password`, `token` |
174
+ | `WILDBURROW_DATA_DIR` | `~/.local/state/wildburrow` | Host key, certificates, ACME account; `/data` in the image |
175
+ | `WILDBURROW_HTTP_HOST` / `_HTTP_PORT` | `0.0.0.0` / `80` | Plain HTTP listener |
176
+ | `WILDBURROW_HTTPS_HOST` / `_HTTPS_PORT` | `0.0.0.0` / `443` | TLS listener, when `WILDBURROW_DNS` is set |
177
+ | `WILDBURROW_HTTP_PROXY_PROTOCOL` / `_HTTPS_PROXY_PROTOCOL` | `false` | Require a PROXY v1/v2 header |
178
+ | `WILDBURROW_PROXY_TRUSTED` | private ranges | CIDRs allowed to send PROXY headers |
179
+ | `WILDBURROW_MAX_DEPTH` | `5` | Maximum labels in a name, `*` included |
180
+ | `WILDBURROW_MAX_HEADER_BYTES` | `65536` | Largest accepted request head |
181
+ | `WILDBURROW_HEADER_TIMEOUT` | `30` | Seconds for the TLS handshake and request head |
182
+ | `WILDBURROW_DNS` | | DNS plugin for DNS-01: `cloudflare` |
183
+ | `WILDBURROW_DNS_MANAGE_RECORDS` | `false` | Create a CNAME for nested wildcards |
184
+ | `WILDBURROW_ACME_DIRECTORY` | Let's Encrypt | ACME directory URL (staging, ZeroSSL, ...) |
185
+ | `WILDBURROW_ACME_EMAIL` | | ACME account contact |
186
+ | `WILDBURROW_ACME_PROPAGATION_SECONDS` | `30` | Wait after publishing a challenge record |
187
+ | `WILDBURROW_RENEW_BEFORE_DAYS` | `30` | Renew certificates in use this close to expiry |
188
+ | `WILDBURROW_SSH_PORT` | `2222` | SSH transport port (`_HOST`, `_LOGIN_TIMEOUT` too) |
189
+ | `WILDBURROW_WEBSOCKET_OPEN_TIMEOUT` | `10` | Seconds a client has to open a visitor stream |
190
+ | `WILDBURROW_AUTH_KEY_AUTHORIZED_KEYS` | `~/.ssh/authorized_keys` | OpenSSH public keys, one per line |
191
+ | `WILDBURROW_AUTH_PASSWORD_HASH` | | argon2 hash from `wildburrow hash-password` |
192
+ | `WILDBURROW_AUTH_PASSWORD_MAX_FAILURES` | `5` | Failed passwords per address before blocking |
193
+ | `WILDBURROW_AUTH_PASSWORD_BLOCK_SECONDS` | `900` | How long failures count against an address |
194
+ | `WILDBURROW_AUTH_TOKEN_HASHES` | | `name:sha256` lines from `wildburrow token NAME` |
195
+ | `WILDBURROW_DNS_CLOUDFLARE_API_TOKEN` | | Token with DNS Edit and Zone Read |
196
+
197
+ ## Plugins
198
+
199
+ Transports, auth methods and DNS providers are plugins registered through entry points: `wildburrow.transports`,
200
+ `wildburrow.auth` and `wildburrow.dns`. A plugin class has a `Settings` dataclass, filled from
201
+ `WILDBURROW_<NAME>_*` (transports), `WILDBURROW_AUTH_<NAME>_*` or `WILDBURROW_DNS_<NAME>_*`, and is constructed with
202
+ it. Its fields become `serve` options the same way. A DNS plugin implements `create_txt`, `delete_txt`, `ensure_cname`
203
+ and `aclose`; see `wildburrow/dns/base.py`.
204
+ Install a package that registers an entry point and select it by name.
205
+
206
+ ## Development
207
+
208
+ ```sh
209
+ uv sync --all-extras
210
+ uv run pytest -n auto # needs Docker for the Pebble ACME tests
211
+ uv run pytest -n auto -m "not acme and not system" # without Docker
212
+ uv run pytest -m system --no-cov tests/system # installs the real service; CI runs it on Linux and macOS
213
+ uv run ruff check . && uv run ruff format --check .
214
+ ```
215
+
216
+ Coverage stays at 100%, and every mutant has to be killed. The mutation run re-tests only what changed since the last
217
+ run in `mutants/`, both source and tests; CI keeps that directory cached between runs.
218
+
219
+ ```sh
220
+ uv run python -m scripts.mutation run # re-test what changed
221
+ uv run python -m scripts.mutation run --full # re-test everything
222
+ uv run python -m scripts.mutation run --shard commands # one shard: commands, service, client, ...
223
+ ```
224
+
225
+ CI runs each shard as a job of its own with its own cache, so a cold run takes minutes and a red shard says where
226
+ to look. There is one pass: each mutant runs against the tests traced to it, the test that killed it last time and
227
+ those that killed its siblings first, and a survivor is a test to write. mutmut leaves decorated functions alone,
228
+ so a decorated function only hands off to a plain one.
229
+
230
+ Every push to `master` runs the tests and the mutation run, then publishes to PyPI and `ghcr.io` when the version in
231
+ `pyproject.toml` isn't on PyPI yet.
@@ -0,0 +1,196 @@
1
+ # wildburrow
2
+
3
+ Self-hosted HTTPS tunnels on your own domain, with wildcard names at any depth and certificates issued on demand.
4
+ It runs on Linux and macOS.
5
+
6
+ ```sh
7
+ wildburrow 3000 myapp # https://myapp.tunnel.example.com
8
+ wildburrow 3000 '*.foo' # https://<anything>.foo.tunnel.example.com
9
+ wildburrow 3000 '*.bar.foo' # https://<anything>.bar.foo.tunnel.example.com
10
+ ```
11
+
12
+ A wildcard covers exactly one label, like a TLS wildcard certificate: `*.foo` serves `api.foo` but not `x.api.foo`.
13
+ An exact name wins over a wildcard. Quote `*` names so your shell doesn't expand them.
14
+
15
+ Requests reach your local app unchanged, so websockets, server-sent events and streaming responses work as they do
16
+ locally. With HTTPS, visitors use `wss://` and your app sees plain `ws://`.
17
+
18
+ When a name needs a certificate that doesn't exist yet, the server gets `*.<parent>` from an ACME CA through a DNS-01
19
+ challenge while your client waits, then confirms the bind. Later binds under the same parent are instant.
20
+
21
+ ## Client
22
+
23
+ ```sh
24
+ uv tool install wildburrow # or: pipx install wildburrow
25
+ wildburrow login # asks for the server and how to connect to it
26
+ wildburrow 3000 '*.foo'
27
+ wildburrow logout # forgets the saved server
28
+ ```
29
+
30
+ `login` keeps its answers in `~/.config/wildburrow/config.toml` until you log in again or out; nothing in it expires,
31
+ and every tunnel logs in afresh. Given options, `wildburrow login tunnel.example.com --transport websocket`, it asks
32
+ nothing and saves what you passed.
33
+
34
+ Two transports are available:
35
+
36
+ | Transport | How it connects | Auth methods |
37
+ |---|---|---|
38
+ | `ssh` (default) | your `ssh` binary, port 2222, one `-R` per name | `key`, `password` |
39
+ | `websocket` | `wss://tunnel.example.com` on 443 | `key` (signed with `ssh-keygen -Y sign`), `password`, `token` |
40
+
41
+ ```sh
42
+ wildburrow login tunnel.example.com --transport websocket --auth key --identity ~/.ssh/id_ed25519
43
+ wildburrow login tunnel.example.com --transport websocket --auth token --token wb_...
44
+ WILDBURROW_TOKEN=wb_... wildburrow 3000 app # token from the environment
45
+ ```
46
+
47
+ The client keeps the tunnel up: when the connection drops, it reconnects and binds its names again, waiting
48
+ `--retry-delay` seconds (default 1) and doubling up to `--retry-max` (default 30). Over websocket it also waits for a
49
+ server that isn't reachable yet. A refused login or a rejected name ends the client instead, and `--no-reconnect` makes
50
+ any disconnect end it. `login` saves these flags like the others and `connect` overrides them. Binding a name you
51
+ already hold moves it to the new connection; other identities are refused.
52
+
53
+ The websocket transport goes through an HTTP proxy from `HTTPS_PROXY` (or `HTTP_PROXY`) unless `NO_PROXY` excludes the
54
+ server. The ssh transport follows your SSH config, so a `ProxyJump` or `ProxyCommand` there applies.
55
+
56
+ Plain `ssh` works without the client:
57
+
58
+ ```sh
59
+ ssh -T -p 2222 -R '*.foo:80:localhost:3000' tunnel.example.com
60
+ ```
61
+
62
+ ## Server
63
+
64
+ ```sh
65
+ uv tool install 'wildburrow[server]'
66
+ wildburrow service install
67
+ ```
68
+
69
+ `service install` asks for the domain, whether to serve HTTPS and through which DNS provider, and which keys may open
70
+ tunnels, then installs the server as a system service and starts it: systemd on Linux, launchd on macOS. It asks for
71
+ sudo itself. Point `tunnel.example.com` and `*.tunnel.example.com` at the server; DNS wildcards match any depth, so
72
+ deeper names resolve too.
73
+
74
+ ```sh
75
+ wildburrow service status # running or not, and where the logs are
76
+ wildburrow service edit # the same questions, answered with the current settings
77
+ wildburrow service uninstall # stop and remove it, asking whether to keep the certificates
78
+ ```
79
+
80
+ Each of them takes the settings as options instead, and then asks nothing:
81
+
82
+ ```sh
83
+ wildburrow service install --domain=tunnel.example.com --acme-email=you@example.com \
84
+ --dns=cloudflare --dns-cloudflare-api-token-file=/root/cloudflare-token
85
+ wildburrow service edit --http-port=8080
86
+ wildburrow service uninstall --yes --purge
87
+ ```
88
+
89
+ Without a DNS provider the server serves plain HTTP on port 80. With one, it gets wildcard certificates by proving it
90
+ owns the domain through DNS: port 80 redirects bound names to HTTPS, and port 443 terminates TLS, picking the
91
+ certificate by SNI. A request whose `Host` isn't covered by the certificate of its connection gets `421`. Try
92
+ `--acme-directory=https://acme-staging-v02.api.letsencrypt.org/directory` until the first certificate comes through, so
93
+ a mistake doesn't run into Let's Encrypt's rate limits.
94
+
95
+ Unless told otherwise, the service lets in the keys in your `~/.ssh/authorized_keys`, copied when you install or edit.
96
+ Its settings live in `/etc/wildburrow`, readable only by root and the service.
97
+
98
+ On Linux, an install under a home directory, which is where `uv tool install` puts it, runs as its owner with that
99
+ home read-only; one outside any home, like `/opt`, runs as a throwaway user that systemd makes up. Either way it can
100
+ bind ports 80 and 443 and nothing more. On macOS it runs as the install's owner, which macOS lets bind those ports.
101
+
102
+ ### Foreground
103
+
104
+ `wildburrow serve` runs the server in the foreground with the same settings, read from options, an `--env-file` of
105
+ `KEY=VALUE` lines, or the environment:
106
+
107
+ ```sh
108
+ sudo wildburrow serve --domain=tunnel.example.com
109
+ ```
110
+
111
+ ### Docker
112
+
113
+ ```sh
114
+ docker run -d --restart unless-stopped -p 80:80 -p 443:443 -p 2222:2222 -v wildburrow-data:/data \
115
+ -e WILDBURROW_DOMAIN=tunnel.example.com \
116
+ -e WILDBURROW_AUTH_KEY_AUTHORIZED_KEYS="$(cat ~/.ssh/id_ed25519.pub)" \
117
+ -e WILDBURROW_DNS=cloudflare -e WILDBURROW_DNS_CLOUDFLARE_API_TOKEN=... \
118
+ -e WILDBURROW_ACME_EMAIL=you@example.com \
119
+ ghcr.io/isik-kaplan/wildburrow
120
+ ```
121
+
122
+ The image comes in versions matching the package, for amd64 and arm64, and runs on Docker Desktop for macOS too.
123
+ `examples/docker-compose.traefik.yml` runs it behind Traefik with TLS passthrough and PROXY protocol, for hosts where
124
+ another proxy already owns ports 80 and 443.
125
+
126
+ ### Settings
127
+
128
+ Every setting is an option of `serve`, `service install` and `service edit`, and an environment variable for `serve`:
129
+ `--http-port 8080` is `WILDBURROW_HTTP_PORT=8080`, and an option wins over the variable. Adding `-file` to an option,
130
+ or `_FILE` to a variable, reads the value from a file, for secrets and multi-line values:
131
+ `--auth-key-authorized-keys-file=~/keys`. `wildburrow serve --help` lists them all, plugins included. A service has
132
+ no `--data-dir`; its service manager decides where its data goes.
133
+
134
+ | Variable | Default | |
135
+ |---|---|---|
136
+ | `WILDBURROW_DOMAIN` | required | Base domain tunnels live under |
137
+ | `WILDBURROW_TRANSPORTS` | `ssh` | Transport plugins: `ssh`, `websocket` |
138
+ | `WILDBURROW_AUTH` | `key` | Auth plugins, tried in order: `key`, `password`, `token` |
139
+ | `WILDBURROW_DATA_DIR` | `~/.local/state/wildburrow` | Host key, certificates, ACME account; `/data` in the image |
140
+ | `WILDBURROW_HTTP_HOST` / `_HTTP_PORT` | `0.0.0.0` / `80` | Plain HTTP listener |
141
+ | `WILDBURROW_HTTPS_HOST` / `_HTTPS_PORT` | `0.0.0.0` / `443` | TLS listener, when `WILDBURROW_DNS` is set |
142
+ | `WILDBURROW_HTTP_PROXY_PROTOCOL` / `_HTTPS_PROXY_PROTOCOL` | `false` | Require a PROXY v1/v2 header |
143
+ | `WILDBURROW_PROXY_TRUSTED` | private ranges | CIDRs allowed to send PROXY headers |
144
+ | `WILDBURROW_MAX_DEPTH` | `5` | Maximum labels in a name, `*` included |
145
+ | `WILDBURROW_MAX_HEADER_BYTES` | `65536` | Largest accepted request head |
146
+ | `WILDBURROW_HEADER_TIMEOUT` | `30` | Seconds for the TLS handshake and request head |
147
+ | `WILDBURROW_DNS` | | DNS plugin for DNS-01: `cloudflare` |
148
+ | `WILDBURROW_DNS_MANAGE_RECORDS` | `false` | Create a CNAME for nested wildcards |
149
+ | `WILDBURROW_ACME_DIRECTORY` | Let's Encrypt | ACME directory URL (staging, ZeroSSL, ...) |
150
+ | `WILDBURROW_ACME_EMAIL` | | ACME account contact |
151
+ | `WILDBURROW_ACME_PROPAGATION_SECONDS` | `30` | Wait after publishing a challenge record |
152
+ | `WILDBURROW_RENEW_BEFORE_DAYS` | `30` | Renew certificates in use this close to expiry |
153
+ | `WILDBURROW_SSH_PORT` | `2222` | SSH transport port (`_HOST`, `_LOGIN_TIMEOUT` too) |
154
+ | `WILDBURROW_WEBSOCKET_OPEN_TIMEOUT` | `10` | Seconds a client has to open a visitor stream |
155
+ | `WILDBURROW_AUTH_KEY_AUTHORIZED_KEYS` | `~/.ssh/authorized_keys` | OpenSSH public keys, one per line |
156
+ | `WILDBURROW_AUTH_PASSWORD_HASH` | | argon2 hash from `wildburrow hash-password` |
157
+ | `WILDBURROW_AUTH_PASSWORD_MAX_FAILURES` | `5` | Failed passwords per address before blocking |
158
+ | `WILDBURROW_AUTH_PASSWORD_BLOCK_SECONDS` | `900` | How long failures count against an address |
159
+ | `WILDBURROW_AUTH_TOKEN_HASHES` | | `name:sha256` lines from `wildburrow token NAME` |
160
+ | `WILDBURROW_DNS_CLOUDFLARE_API_TOKEN` | | Token with DNS Edit and Zone Read |
161
+
162
+ ## Plugins
163
+
164
+ Transports, auth methods and DNS providers are plugins registered through entry points: `wildburrow.transports`,
165
+ `wildburrow.auth` and `wildburrow.dns`. A plugin class has a `Settings` dataclass, filled from
166
+ `WILDBURROW_<NAME>_*` (transports), `WILDBURROW_AUTH_<NAME>_*` or `WILDBURROW_DNS_<NAME>_*`, and is constructed with
167
+ it. Its fields become `serve` options the same way. A DNS plugin implements `create_txt`, `delete_txt`, `ensure_cname`
168
+ and `aclose`; see `wildburrow/dns/base.py`.
169
+ Install a package that registers an entry point and select it by name.
170
+
171
+ ## Development
172
+
173
+ ```sh
174
+ uv sync --all-extras
175
+ uv run pytest -n auto # needs Docker for the Pebble ACME tests
176
+ uv run pytest -n auto -m "not acme and not system" # without Docker
177
+ uv run pytest -m system --no-cov tests/system # installs the real service; CI runs it on Linux and macOS
178
+ uv run ruff check . && uv run ruff format --check .
179
+ ```
180
+
181
+ Coverage stays at 100%, and every mutant has to be killed. The mutation run re-tests only what changed since the last
182
+ run in `mutants/`, both source and tests; CI keeps that directory cached between runs.
183
+
184
+ ```sh
185
+ uv run python -m scripts.mutation run # re-test what changed
186
+ uv run python -m scripts.mutation run --full # re-test everything
187
+ uv run python -m scripts.mutation run --shard commands # one shard: commands, service, client, ...
188
+ ```
189
+
190
+ CI runs each shard as a job of its own with its own cache, so a cold run takes minutes and a red shard says where
191
+ to look. There is one pass: each mutant runs against the tests traced to it, the test that killed it last time and
192
+ those that killed its siblings first, and a survivor is a test to write. mutmut leaves decorated functions alone,
193
+ so a decorated function only hands off to a plain one.
194
+
195
+ Every push to `master` runs the tests and the mutation run, then publishes to PyPI and `ghcr.io` when the version in
196
+ `pyproject.toml` isn't on PyPI yet.
@@ -0,0 +1,129 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.32.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wildburrow"
7
+ version = "0.1.0"
8
+ description = "Self-hosted HTTP tunnels with on-demand wildcard certificates at any depth"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Işık Kaplan" }]
13
+ keywords = ["tunnel", "ngrok", "reverse-proxy", "ssh", "websocket", "acme", "letsencrypt", "wildcard"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Framework :: AsyncIO",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: System Administrators",
20
+ "Operating System :: MacOS",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Programming Language :: Python :: 3.15",
25
+ "Topic :: Internet :: Proxy Servers",
26
+ "Topic :: System :: Networking",
27
+ ]
28
+ requires-python = ">=3.14.4"
29
+ dependencies = [
30
+ "click==8.5.0",
31
+ "questionary==2.1.1",
32
+ "websockets==17.2",
33
+ ]
34
+
35
+ [project.urls]
36
+ Repository = "https://github.com/isik-kaplan/wildburrow"
37
+ Issues = "https://github.com/isik-kaplan/wildburrow/issues"
38
+
39
+ [project.optional-dependencies]
40
+ server = [
41
+ "acme==5.8.0",
42
+ "argon2-cffi==25.1.0",
43
+ "asyncssh==2.24.1",
44
+ "cryptography==50.0.2",
45
+ "httpx==0.28.1",
46
+ "josepy==2.2.0",
47
+ ]
48
+
49
+ [project.scripts]
50
+ wildburrow = "wildburrow.cli:main"
51
+
52
+ [project.entry-points."wildburrow.transports"]
53
+ ssh = "wildburrow.transports.ssh:SSHTransport"
54
+ websocket = "wildburrow.transports.websocket:WebSocketTransport"
55
+
56
+ [project.entry-points."wildburrow.auth"]
57
+ key = "wildburrow.auth.keys:KeyAuth"
58
+ password = "wildburrow.auth.password:PasswordAuth"
59
+ token = "wildburrow.auth.token:TokenAuth"
60
+
61
+ [project.entry-points."wildburrow.dns"]
62
+ cloudflare = "wildburrow.dns.cloudflare:CloudflareDNS"
63
+
64
+ [dependency-groups]
65
+ dev = [
66
+ "hypothesis==6.169.0",
67
+ "mutmut==3.8.0",
68
+ "pytest==9.1.1",
69
+ "pytest-asyncio==1.4.0",
70
+ "pytest-cov==7.1.0",
71
+ "pytest-timeout==2.4.0",
72
+ "pytest-xdist==3.8.0",
73
+ "ruff==0.17.0",
74
+ ]
75
+
76
+ [tool.hatch.build.targets.sdist]
77
+ include = ["src", "tests", "README.md", "LICENSE"]
78
+
79
+ [tool.hatch.build.targets.wheel]
80
+ packages = ["src/wildburrow"]
81
+
82
+ [tool.uv]
83
+ default-groups = ["dev"]
84
+
85
+ [tool.ruff]
86
+ line-length = 120
87
+ target-version = "py314"
88
+
89
+ [tool.ruff.lint]
90
+ select = ["E", "F", "W", "I", "B", "UP", "S", "ASYNC", "RUF", "SIM", "PT"]
91
+
92
+ [tool.ruff.lint.per-file-ignores]
93
+ "tests/**" = ["S101", "S105", "S106", "S603", "S607", "ASYNC110"]
94
+
95
+ [tool.pytest.ini_options]
96
+ testpaths = ["tests"]
97
+ asyncio_mode = "auto"
98
+ asyncio_default_fixture_loop_scope = "function"
99
+ addopts = "--strict-markers --cov --cov-branch --cov-report=term-missing -m 'not system'"
100
+ markers = [
101
+ "e2e: drives real clients against a running server",
102
+ "acme: needs Docker for a local Pebble ACME server",
103
+ "system: installs the real service, so it needs root through sudo without a password",
104
+ ]
105
+ filterwarnings = ["error"]
106
+ timeout = 120
107
+ # Ends the run outright: raised inside the code instead, a timeout can be caught and the hang carries on.
108
+ timeout_method = "thread"
109
+
110
+ [tool.coverage.run]
111
+ source = ["wildburrow", "scripts"]
112
+ branch = true
113
+
114
+ [tool.coverage.report]
115
+ fail_under = 100
116
+ show_missing = true
117
+ exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError", "\\.\\.\\.$"]
118
+
119
+ [tool.mutmut]
120
+ source_paths = ["src/wildburrow"]
121
+ also_copy = ["scripts"]
122
+ # The trampolines add statements no coverage gate can account for, and a fixed seed keeps a verdict repeatable.
123
+ # The slowest test takes seconds, so a test still going at 30 is a mutant hanging it, not a slow test.
124
+ pytest_add_cli_args = ["--no-cov", "--hypothesis-seed=0", "--timeout=30", "-p", "scripts.mutation_killers"]
125
+ # mutmut stops a run at (estimated + constant) * 15 seconds; this keeps that past the 30 a hung test gets, so the test
126
+ # timeout fails it the way it would fail a run without mutmut.
127
+ timeout_constant = 3
128
+ # Starting a flag at False or an optional at None leaves one mutant, the other falsy value, which nothing can tell apart.
129
+ do_not_mutate_patterns = ['^\s*[\w.]+(: [^=]+)? = (False|None)$']
File without changes
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from wildburrow.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
File without changes
@@ -0,0 +1,24 @@
1
+ from collections.abc import Sequence
2
+ from typing import ClassVar, Protocol
3
+
4
+ from wildburrow.core.types import Credential, Identity, Peer
5
+
6
+
7
+ class AuthProvider(Protocol):
8
+ handles: ClassVar[tuple[type, ...]]
9
+
10
+ async def authenticate(self, credential: Credential, peer: Peer) -> Identity | None: ...
11
+
12
+
13
+ class Authenticator:
14
+ def __init__(self, providers: Sequence[AuthProvider]) -> None:
15
+ self._providers = tuple(providers)
16
+
17
+ def accepts(self, kind: type) -> bool:
18
+ return any(kind in provider.handles for provider in self._providers)
19
+
20
+ async def authenticate(self, credential: Credential, peer: Peer) -> Identity | None:
21
+ for provider in self._providers:
22
+ if type(credential) in provider.handles and (identity := await provider.authenticate(credential, peer)):
23
+ return identity
24
+ return None