lizardtail 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +390 -0
- package/dist/index.js +786 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lizardtail contributors
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# lizardtail
|
|
2
|
+
|
|
3
|
+
`lizardtail` runs a command, watches its output for a localhost server port, exposes that port with [Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve), and prints the private tailnet URL. Pass `--public` to use Tailscale Funnel intentionally.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
lizardtail pnpm dev
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
Local: http://localhost:5173
|
|
11
|
+
|
|
12
|
+
lizardtail: detected local server on http://127.0.0.1:5173
|
|
13
|
+
lizardtail: serving via Tailscale: https://my-host.tailabc.ts.net:8443
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use it when your dev server is running on a remote machine and you want to open it from another device on your tailnet without manually copying ports or reconfiguring Tailscale Serve.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- Runs any command you pass it, such as `pnpm dev`, `npm run dev`, `bun run dev`, or `python -m http.server`.
|
|
21
|
+
- Streams the child command's stdout/stderr normally.
|
|
22
|
+
- Detects common dev-server output formats, including `http://localhost:5173`, `http://127.0.0.1:3000`, `started server on 0.0.0.0:8080`, and `PORT=4321`.
|
|
23
|
+
- Ignores timing output like `ready in 500 ms` so it does not accidentally expose port `500`.
|
|
24
|
+
- Waits briefly after the first candidate port so multi-process commands, such as Laravel plus Vite, can print the better app-server URL.
|
|
25
|
+
- Waits for the detected port to accept local connections before exposing it.
|
|
26
|
+
- Runs `tailscale serve --bg --https <tailscale-port> http://127.0.0.1:<port>`.
|
|
27
|
+
- Prints the HTTPS MagicDNS URL for the current Tailscale device.
|
|
28
|
+
- Supports an explicit `--port` when automatic detection is not possible.
|
|
29
|
+
- Supports `--tailscale-port` when you want the MagicDNS URL to include a specific HTTPS port.
|
|
30
|
+
- Detects Laravel + Vite dev output, exposes both servers, rewrites Laravel's `public/hot` file to the Tailscale Vite URL, and proxies Vite assets with CORS headers so module scripts can load cross-origin.
|
|
31
|
+
- Uses a stable alternate Tailscale HTTPS port by default: first free port from `8443` upward.
|
|
32
|
+
- Stays private to your tailnet by default.
|
|
33
|
+
- Supports `--public` / `--funnel` for intentional public internet sharing through Tailscale Funnel.
|
|
34
|
+
- Cleans up the Tailscale Serve/Funnel mappings it created when the child command exits or you press `Ctrl+C`.
|
|
35
|
+
- Has editable blocked-port guardrails with default entries for common HTTP/HTTPS ingress ports.
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- Node.js 20 or newer.
|
|
40
|
+
- Tailscale installed and available as `tailscale` on `PATH`.
|
|
41
|
+
- The device must be logged into Tailscale.
|
|
42
|
+
- Tailscale Serve must be available for the device/tailnet.
|
|
43
|
+
- For `--public`, Tailscale Funnel must be enabled for the device/tailnet.
|
|
44
|
+
- Your user must be allowed to update Tailscale Serve/Funnel config. If `tailscale serve` or `tailscale funnel` says access is denied, run this once:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
sudo tailscale set --operator=$USER
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Check Tailscale before using `lizardtail`:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
tailscale status
|
|
54
|
+
tailscale serve --help
|
|
55
|
+
# Optional, only for --public:
|
|
56
|
+
tailscale funnel --help
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`lizardtail` exposes services to your private tailnet via Tailscale Serve by default. It only uses Tailscale Funnel, which publishes to the public internet, when you explicitly pass `--public` or `--funnel`.
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
### From source
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/dasomji/lizardtail.git
|
|
67
|
+
cd lizardtail
|
|
68
|
+
npm install
|
|
69
|
+
npm run build
|
|
70
|
+
npm link
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then run:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
lizardtail --help
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### During development
|
|
80
|
+
|
|
81
|
+
You can run the TypeScript source directly:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm run dev -- pnpm dev
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Or build and run the compiled CLI:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm run build
|
|
91
|
+
node dist/index.js pnpm dev
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
lizardtail [options] -- <command> [args...]
|
|
98
|
+
lizardtail [options] <command> [args...]
|
|
99
|
+
lizardtail help [topic]
|
|
100
|
+
lizardtail config init
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Use `--` when the command itself has flags that could be confused for `lizardtail` options:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
lizardtail -- npm run dev -- --host 0.0.0.0
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Help
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
lizardtail help
|
|
113
|
+
lizardtail help config
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
These commands are meant for both humans and coding agents: they describe usage, safety behavior, public/private exposure, and config file shape without needing to open the README.
|
|
117
|
+
|
|
118
|
+
### Options
|
|
119
|
+
|
|
120
|
+
| Option | Default | Description |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `--port <port>` | auto-detect | Expose this port instead of reading one from command output. |
|
|
123
|
+
| `--host <host>` | `127.0.0.1` | Local host to pass to Tailscale Serve/Funnel. |
|
|
124
|
+
| `--timeout <ms>` | `30000` | How long to wait for a port to appear in command output. |
|
|
125
|
+
| `--tailscale-port <port>` | first free `8443+` | Expose the main app on this Tailscale HTTPS port and print it in the MagicDNS URL. Alias: `--https-port`. |
|
|
126
|
+
| `--vite-tailscale-port <port>` | first free `8443+` | Expose a detected Laravel Vite asset server on this Tailscale HTTPS port. Alias: `--vite-https-port`. |
|
|
127
|
+
| `--public`, `--funnel` | disabled | Use Tailscale Funnel for public internet access instead of private tailnet-only Serve. |
|
|
128
|
+
| `--no-open-check` | enabled | Skip waiting for the local port to accept connections before calling Tailscale. |
|
|
129
|
+
| `-h`, `--help` | | Show help. |
|
|
130
|
+
|
|
131
|
+
## Examples
|
|
132
|
+
|
|
133
|
+
### Vite / frontend dev server
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
lizardtail pnpm dev
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
If Vite is configured to bind to another host:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
lizardtail --host localhost pnpm dev
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### npm script with extra flags
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
lizardtail -- npm run dev -- --host 0.0.0.0
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Known port
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
lizardtail --port 3000 npm run dev
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### MagicDNS URL with an explicit port
|
|
158
|
+
|
|
159
|
+
By default, `lizardtail` uses the first free Tailscale HTTPS port from `8443` upward, so multiple projects can be served at the same time:
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
https://my-host.tailabc.ts.net:8443
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
You can also choose the Tailscale HTTPS port explicitly:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
lizardtail --tailscale-port 8450 pnpm dev
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
That prints a URL like:
|
|
172
|
+
|
|
173
|
+
```text
|
|
174
|
+
https://my-host.tailabc.ts.net:8450
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Laravel / `composer run dev`
|
|
178
|
+
|
|
179
|
+
Laravel development commands often start both the PHP app server and the Vite asset server. When `lizardtail` sees both, it:
|
|
180
|
+
|
|
181
|
+
1. exposes the Laravel app server;
|
|
182
|
+
2. starts a small local proxy in front of Vite that adds CORS headers;
|
|
183
|
+
3. exposes that Vite proxy on a separate Tailscale HTTPS port;
|
|
184
|
+
4. writes `public/hot` to the Tailscale Vite URL so Laravel renders assets from the reachable Vite server.
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
lizardtail composer run dev
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
You can choose the Vite Tailscale port explicitly:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
lizardtail --vite-tailscale-port 8453 composer run dev
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`lizardtail` also sets `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` for the child command when it can read your Tailscale MagicDNS name. The local proxy handles CORS for module scripts loaded from the Vite Tailscale URL.
|
|
197
|
+
|
|
198
|
+
If your app server lands on a known port and you only want to expose that server, you can force it:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
lizardtail --port 8001 composer run dev
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Public internet sharing
|
|
205
|
+
|
|
206
|
+
By default, URLs are only reachable from devices in your tailnet. To intentionally publish through Tailscale Funnel:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
lizardtail --public pnpm dev
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
or:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
lizardtail --funnel pnpm dev
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
This prints a public HTTPS URL such as:
|
|
219
|
+
|
|
220
|
+
```text
|
|
221
|
+
https://my-host.tailabc.ts.net:8443
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Use this only for apps you are comfortable exposing publicly. Stop `lizardtail` with `Ctrl+C` to remove the Funnel mapping it created.
|
|
225
|
+
|
|
226
|
+
### Editable blocked ports
|
|
227
|
+
|
|
228
|
+
Lizard Tail ships with a small default blocked-port list for common HTTP/HTTPS ingress ports:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"blockedPorts": [
|
|
233
|
+
{
|
|
234
|
+
"port": 80,
|
|
235
|
+
"scope": "both",
|
|
236
|
+
"reason": "Common HTTP ingress/proxy port. Blocking prevents dev exposure from replacing a production web route."
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"port": 443,
|
|
240
|
+
"scope": "both",
|
|
241
|
+
"reason": "Common HTTPS ingress/proxy port. Lizard Tail defaults to high explicit Tailscale HTTPS ports instead."
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Create an editable config file:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
lizardtail config init
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Lizard Tail searches the current working directory for:
|
|
254
|
+
|
|
255
|
+
1. `lizardtail.config.json`
|
|
256
|
+
2. `.lizardtail.json`
|
|
257
|
+
|
|
258
|
+
You can also set `LIZARDTAIL_CONFIG=/path/to/config.json`.
|
|
259
|
+
|
|
260
|
+
Each blocked-port entry has:
|
|
261
|
+
|
|
262
|
+
- `port`: number from `1` to `65535`
|
|
263
|
+
- `scope`: `"local"`, `"tailscale"`, or `"both"` — defaults to `"both"`
|
|
264
|
+
- `reason`: explanation shown when the rule blocks an action
|
|
265
|
+
|
|
266
|
+
If a config file exists, its `blockedPorts` list replaces the built-in default list. Keep, edit, or remove entries based on your own host.
|
|
267
|
+
|
|
268
|
+
### Longer startup timeout
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
lizardtail --timeout 60000 pnpm dev
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## How it works
|
|
275
|
+
|
|
276
|
+
1. `lizardtail` starts the command you provide.
|
|
277
|
+
2. It streams the command output to your terminal.
|
|
278
|
+
3. It scans recent output for a local port.
|
|
279
|
+
4. Once it finds a port, it waits for `127.0.0.1:<port>` or the configured `--host` to accept connections.
|
|
280
|
+
5. It chooses the first free Tailscale HTTPS port from `8443` upward, unless `--tailscale-port` was provided. Ports in the configured blocked-port list are refused or skipped.
|
|
281
|
+
6. It runs Tailscale Serve for private tailnet-only access:
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
tailscale serve --bg --https <tailscale-port> http://<host>:<port>
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
With `--public` / `--funnel`, it runs Tailscale Funnel for public internet access:
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
tailscale funnel --bg --https <tailscale-port> http://<host>:<port>
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
On older Tailscale versions, if that form fails for `127.0.0.1`/`localhost`, it falls back to the same command with just `<port>` as the target.
|
|
294
|
+
|
|
295
|
+
7. It reads `tailscale status --json`, extracts the current device's MagicDNS name, and prints:
|
|
296
|
+
|
|
297
|
+
```text
|
|
298
|
+
https://<device-name>.<tailnet>.ts.net:<tailscale-port>
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Shutdown behavior
|
|
302
|
+
|
|
303
|
+
When the child command exits, or when you press `Ctrl+C`, `lizardtail` removes the Tailscale mappings it created for that run:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
tailscale serve --https=<port> off
|
|
307
|
+
# or, with --public:
|
|
308
|
+
tailscale funnel --https=<port> off
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
It only tracks ports created by the current `lizardtail` process.
|
|
312
|
+
|
|
313
|
+
## Troubleshooting
|
|
314
|
+
|
|
315
|
+
### No port detected
|
|
316
|
+
|
|
317
|
+
If the server does not print a recognizable port, pass it explicitly:
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
lizardtail --port 5173 pnpm dev
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Tailscale command fails
|
|
324
|
+
|
|
325
|
+
Verify Tailscale is running and logged in:
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
tailscale status
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Then check Serve support and current mappings:
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
tailscale serve --help
|
|
335
|
+
tailscale serve status
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### `Access denied: serve config denied`
|
|
339
|
+
|
|
340
|
+
Some Tailscale installs only allow root, or the configured Tailscale operator, to change Serve config. If you see:
|
|
341
|
+
|
|
342
|
+
```text
|
|
343
|
+
Access denied: serve config denied
|
|
344
|
+
Use 'sudo tailscale serve ...'
|
|
345
|
+
To not require root, use 'sudo tailscale set --operator=$USER' once.
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
run:
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
sudo tailscale set --operator=$USER
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Then rerun `lizardtail`. This is a one-time local machine setup step.
|
|
355
|
+
|
|
356
|
+
### Browser cannot load assets
|
|
357
|
+
|
|
358
|
+
Some frameworks, especially full-stack apps with separate backend and Vite dev servers, need more than one port exposed. `lizardtail` detects Laravel + Vite and exposes both automatically. For other multi-port setups, run a second `lizardtail --port <port> ...` command or configure Tailscale Serve manually with explicit high ports.
|
|
359
|
+
|
|
360
|
+
### Host checks or CORS failures
|
|
361
|
+
|
|
362
|
+
Some dev servers reject requests from the Tailscale hostname. Configure your dev server to allow the Tailscale MagicDNS host or to bind with the right host/CORS options. For example, Vite may need `--host 0.0.0.0` and framework-specific allowed-host settings.
|
|
363
|
+
|
|
364
|
+
## Development
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
npm install
|
|
368
|
+
npm run typecheck
|
|
369
|
+
npm test
|
|
370
|
+
npm run build
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
The test suite uses Node's built-in test runner through `tsx` and includes:
|
|
374
|
+
|
|
375
|
+
- unit tests for argument parsing and port detection;
|
|
376
|
+
- an integration-style CLI test with a fake `tailscale` executable and a real temporary HTTP server.
|
|
377
|
+
|
|
378
|
+
## Contributing
|
|
379
|
+
|
|
380
|
+
Issues and pull requests are welcome. Please include tests for behavior changes and run:
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
npm test
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
before opening a pull request.
|
|
387
|
+
|
|
388
|
+
## License
|
|
389
|
+
|
|
390
|
+
MIT. See [LICENSE](./LICENSE).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
5
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
12
|
+
export const DEFAULT_TAILSCALE_HTTPS_PORT = 8443;
|
|
13
|
+
const MAX_AUTO_TAILSCALE_HTTPS_PORT = 8999;
|
|
14
|
+
const CONFIG_FILENAMES = ["lizardtail.config.json", ".lizardtail.json"];
|
|
15
|
+
const DETECTION_SETTLE_MS = 1_500;
|
|
16
|
+
const DEFAULT_CONFIG = {
|
|
17
|
+
blockedPorts: [
|
|
18
|
+
{
|
|
19
|
+
port: 80,
|
|
20
|
+
scope: "both",
|
|
21
|
+
reason: "Common HTTP ingress/proxy port. Blocking prevents dev exposure from replacing a production web route.",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
port: 443,
|
|
25
|
+
scope: "both",
|
|
26
|
+
reason: "Common HTTPS ingress/proxy port. Lizard Tail defaults to high explicit Tailscale HTTPS ports instead.",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
export function printUsage() {
|
|
31
|
+
console.error(`Usage: lizardtail [options] -- <command> [args...]
|
|
32
|
+
lizardtail [options] <command> [args...]
|
|
33
|
+
lizardtail help [topic]
|
|
34
|
+
lizardtail config init
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
--port <port> Expose this port instead of detecting one from output.
|
|
38
|
+
--host <host> Local host to expose. Default: 127.0.0.1
|
|
39
|
+
--timeout <ms> Port-detection timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
|
40
|
+
--tailscale-port <port>
|
|
41
|
+
Expose the main app on this Tailscale HTTPS port. Default: first free ${DEFAULT_TAILSCALE_HTTPS_PORT}+ port.
|
|
42
|
+
--vite-tailscale-port <port>
|
|
43
|
+
Expose a detected Laravel Vite server on this Tailscale HTTPS port.
|
|
44
|
+
--public, --funnel Expose publicly on the internet with Tailscale Funnel instead of private tailnet-only Serve.
|
|
45
|
+
--no-open-check Skip waiting for the local port to accept connections.
|
|
46
|
+
-h, --help Show this help.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
lizardtail pnpm dev
|
|
50
|
+
lizardtail --port 3000 npm run dev
|
|
51
|
+
lizardtail --tailscale-port 8450 pnpm dev
|
|
52
|
+
lizardtail --public pnpm dev
|
|
53
|
+
lizardtail help config
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
56
|
+
function printDetailedHelp(topic) {
|
|
57
|
+
if (topic === "config") {
|
|
58
|
+
console.log(`Lizard Tail configuration
|
|
59
|
+
|
|
60
|
+
Config files, searched from the current working directory:
|
|
61
|
+
1. lizardtail.config.json
|
|
62
|
+
2. .lizardtail.json
|
|
63
|
+
|
|
64
|
+
You can also set LIZARDTAIL_CONFIG=/path/to/config.json.
|
|
65
|
+
|
|
66
|
+
Create a starter config:
|
|
67
|
+
lizardtail config init
|
|
68
|
+
|
|
69
|
+
Config shape:
|
|
70
|
+
${JSON.stringify(DEFAULT_CONFIG, null, 2)}
|
|
71
|
+
|
|
72
|
+
blockedPorts entries:
|
|
73
|
+
port Number from 1 to 65535.
|
|
74
|
+
scope "local", "tailscale", or "both". Defaults to "both".
|
|
75
|
+
reason Human-readable explanation shown when the rule blocks an action.
|
|
76
|
+
|
|
77
|
+
If a config file exists, its blockedPorts list replaces the built-in default list. Keep the default entries if they matter for your host, or edit/remove them if your environment is different.`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
console.log(`Lizard Tail
|
|
81
|
+
|
|
82
|
+
Run a dev server command, detect its local port, and expose it through Tailscale.
|
|
83
|
+
|
|
84
|
+
Common usage:
|
|
85
|
+
lizardtail pnpm dev
|
|
86
|
+
lizardtail --port 3000 npm run dev
|
|
87
|
+
lizardtail --tailscale-port 8450 pnpm dev
|
|
88
|
+
lizardtail --public pnpm dev
|
|
89
|
+
|
|
90
|
+
Private vs public:
|
|
91
|
+
Default: private tailnet-only Tailscale Serve.
|
|
92
|
+
--public / --funnel: public internet exposure through Tailscale Funnel.
|
|
93
|
+
|
|
94
|
+
Safety:
|
|
95
|
+
Lizard Tail always uses explicit high Tailscale HTTPS ports by default (${DEFAULT_TAILSCALE_HTTPS_PORT}+).
|
|
96
|
+
It never runs bare tailscale serve/funnel target commands.
|
|
97
|
+
It only removes mappings it created in the current process.
|
|
98
|
+
Ports can be blocked with lizardtail.config.json or .lizardtail.json.
|
|
99
|
+
|
|
100
|
+
Help topics:
|
|
101
|
+
lizardtail help config
|
|
102
|
+
|
|
103
|
+
Other commands:
|
|
104
|
+
lizardtail config init Write a starter config file.
|
|
105
|
+
`);
|
|
106
|
+
}
|
|
107
|
+
function usage() {
|
|
108
|
+
printUsage();
|
|
109
|
+
process.exit(2);
|
|
110
|
+
}
|
|
111
|
+
async function handleMetaCommand(argv) {
|
|
112
|
+
if (argv[0] === "help") {
|
|
113
|
+
printDetailedHelp(argv[1]);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (argv[0] === "config" && argv[1] === "init") {
|
|
117
|
+
await writeDefaultConfigFile();
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (argv[0] === "init-config") {
|
|
121
|
+
await writeDefaultConfigFile();
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
async function writeDefaultConfigFile() {
|
|
127
|
+
const configPath = path.join(process.cwd(), "lizardtail.config.json");
|
|
128
|
+
if (existsSync(configPath))
|
|
129
|
+
throw new Error(`${configPath} already exists`);
|
|
130
|
+
await writeFile(configPath, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
|
|
131
|
+
console.log(`wrote ${configPath}`);
|
|
132
|
+
}
|
|
133
|
+
export function parseArgs(argv) {
|
|
134
|
+
const options = {
|
|
135
|
+
command: [],
|
|
136
|
+
host: "127.0.0.1",
|
|
137
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
138
|
+
openCheck: true,
|
|
139
|
+
public: false,
|
|
140
|
+
};
|
|
141
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
142
|
+
const arg = argv[i];
|
|
143
|
+
if (arg === "--") {
|
|
144
|
+
options.command = argv.slice(i + 1);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
if (arg === "-h" || arg === "--help") {
|
|
148
|
+
printUsage();
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
if (arg === "--no-open-check") {
|
|
152
|
+
options.openCheck = false;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (arg === "--public" || arg === "--funnel") {
|
|
156
|
+
options.public = true;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (arg === "--port") {
|
|
160
|
+
const value = argv[++i];
|
|
161
|
+
if (!value)
|
|
162
|
+
usage();
|
|
163
|
+
options.port = parsePort(value);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (arg.startsWith("--port=")) {
|
|
167
|
+
options.port = parsePort(arg.slice("--port=".length));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (arg === "--host") {
|
|
171
|
+
const value = argv[++i];
|
|
172
|
+
if (!value)
|
|
173
|
+
usage();
|
|
174
|
+
options.host = value;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (arg.startsWith("--host=")) {
|
|
178
|
+
options.host = arg.slice("--host=".length);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (arg === "--tailscale-port" || arg === "--https-port") {
|
|
182
|
+
const value = argv[++i];
|
|
183
|
+
if (!value)
|
|
184
|
+
usage();
|
|
185
|
+
options.tailscalePort = parsePort(value);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (arg.startsWith("--tailscale-port=")) {
|
|
189
|
+
options.tailscalePort = parsePort(arg.slice("--tailscale-port=".length));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (arg.startsWith("--https-port=")) {
|
|
193
|
+
options.tailscalePort = parsePort(arg.slice("--https-port=".length));
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (arg === "--vite-tailscale-port" || arg === "--vite-https-port") {
|
|
197
|
+
const value = argv[++i];
|
|
198
|
+
if (!value)
|
|
199
|
+
usage();
|
|
200
|
+
options.viteTailscalePort = parsePort(value);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (arg.startsWith("--vite-tailscale-port=")) {
|
|
204
|
+
options.viteTailscalePort = parsePort(arg.slice("--vite-tailscale-port=".length));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (arg.startsWith("--vite-https-port=")) {
|
|
208
|
+
options.viteTailscalePort = parsePort(arg.slice("--vite-https-port=".length));
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (arg === "--timeout") {
|
|
212
|
+
const value = argv[++i];
|
|
213
|
+
if (!value)
|
|
214
|
+
usage();
|
|
215
|
+
options.timeoutMs = parseTimeout(value);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (arg.startsWith("--timeout=")) {
|
|
219
|
+
options.timeoutMs = parseTimeout(arg.slice("--timeout=".length));
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (arg.startsWith("-")) {
|
|
223
|
+
console.error(`lizardtail: unknown option ${arg}`);
|
|
224
|
+
usage();
|
|
225
|
+
}
|
|
226
|
+
options.command = argv.slice(i);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
if (options.command.length === 0)
|
|
230
|
+
usage();
|
|
231
|
+
return options;
|
|
232
|
+
}
|
|
233
|
+
function parsePort(value) {
|
|
234
|
+
const port = Number(value);
|
|
235
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
236
|
+
console.error(`lizardtail: invalid port: ${value}`);
|
|
237
|
+
process.exit(2);
|
|
238
|
+
}
|
|
239
|
+
return port;
|
|
240
|
+
}
|
|
241
|
+
function parseTimeout(value) {
|
|
242
|
+
const timeout = Number(value);
|
|
243
|
+
if (!Number.isInteger(timeout) || timeout < 1) {
|
|
244
|
+
console.error(`lizardtail: invalid timeout: ${value}`);
|
|
245
|
+
process.exit(2);
|
|
246
|
+
}
|
|
247
|
+
return timeout;
|
|
248
|
+
}
|
|
249
|
+
async function loadConfig() {
|
|
250
|
+
const configPath = findConfigPath();
|
|
251
|
+
if (!configPath)
|
|
252
|
+
return DEFAULT_CONFIG;
|
|
253
|
+
try {
|
|
254
|
+
const raw = await readFile(configPath, "utf8");
|
|
255
|
+
const parsed = JSON.parse(raw);
|
|
256
|
+
return normalizeConfig(parsed, configPath);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (error instanceof SyntaxError)
|
|
260
|
+
throw new Error(`failed to parse ${configPath}: ${error.message}`);
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function findConfigPath() {
|
|
265
|
+
if (process.env.LIZARDTAIL_CONFIG)
|
|
266
|
+
return process.env.LIZARDTAIL_CONFIG;
|
|
267
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
268
|
+
const candidate = path.join(process.cwd(), filename);
|
|
269
|
+
if (existsSync(candidate))
|
|
270
|
+
return candidate;
|
|
271
|
+
}
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
function normalizeConfig(config, source) {
|
|
275
|
+
if (!Array.isArray(config.blockedPorts)) {
|
|
276
|
+
throw new Error(`${source} must contain a blockedPorts array`);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
blockedPorts: config.blockedPorts.map((rule, index) => normalizeBlockedPortRule(rule, `${source}:blockedPorts[${index}]`)),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function normalizeBlockedPortRule(rule, label) {
|
|
283
|
+
const port = Number(rule.port);
|
|
284
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
285
|
+
throw new Error(`${label}.port must be an integer from 1 to 65535`);
|
|
286
|
+
}
|
|
287
|
+
const scope = rule.scope ?? "both";
|
|
288
|
+
if (scope !== "local" && scope !== "tailscale" && scope !== "both") {
|
|
289
|
+
throw new Error(`${label}.scope must be "local", "tailscale", or "both"`);
|
|
290
|
+
}
|
|
291
|
+
const reason = typeof rule.reason === "string" && rule.reason.trim() ? rule.reason.trim() : "Blocked by lizardtail configuration.";
|
|
292
|
+
return { port, scope, reason };
|
|
293
|
+
}
|
|
294
|
+
function findBlockedPortRule(config, port, scope) {
|
|
295
|
+
return config.blockedPorts.find((rule) => rule.port === port && (rule.scope === "both" || rule.scope === scope || rule.scope === undefined));
|
|
296
|
+
}
|
|
297
|
+
function assertPortAllowed(config, port, scope) {
|
|
298
|
+
const rule = findBlockedPortRule(config, port, scope);
|
|
299
|
+
if (!rule)
|
|
300
|
+
return;
|
|
301
|
+
const label = scope === "tailscale" ? "Tailscale HTTPS" : "local";
|
|
302
|
+
throw new Error(`refusing to use ${label} port ${port}: ${rule.reason}`);
|
|
303
|
+
}
|
|
304
|
+
export function stripAnsi(input) {
|
|
305
|
+
return input.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
|
|
306
|
+
}
|
|
307
|
+
export function detectPortFromText(text) {
|
|
308
|
+
const candidates = detectPortCandidates(text);
|
|
309
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
310
|
+
return candidates[0]?.port;
|
|
311
|
+
}
|
|
312
|
+
function detectPortCandidates(text) {
|
|
313
|
+
const clean = stripAnsi(text);
|
|
314
|
+
const candidates = [];
|
|
315
|
+
for (const line of clean.split(/\r?\n/)) {
|
|
316
|
+
candidates.push(...detectPortCandidatesFromLine(line));
|
|
317
|
+
}
|
|
318
|
+
return candidates;
|
|
319
|
+
}
|
|
320
|
+
function detectPortCandidatesFromLine(line) {
|
|
321
|
+
const candidates = [];
|
|
322
|
+
const lowerLine = line.toLowerCase();
|
|
323
|
+
const lineLooksLikeDuration = /\b\d{1,5}\s*ms\b/i.test(line);
|
|
324
|
+
const lineLooksLikeServer = /\b(server|listening|started|running)\b/i.test(line) || /\[server\]/i.test(line);
|
|
325
|
+
const lineLooksLikeVite = /\[vite\]|\bvite\b/i.test(line);
|
|
326
|
+
const localUrlPattern = /https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{1,5})/gi;
|
|
327
|
+
for (const match of line.matchAll(localUrlPattern)) {
|
|
328
|
+
const port = validDetectedPort(match[2]);
|
|
329
|
+
if (!port)
|
|
330
|
+
continue;
|
|
331
|
+
let score = 70;
|
|
332
|
+
if (lineLooksLikeServer)
|
|
333
|
+
score += 30;
|
|
334
|
+
if (lineLooksLikeVite)
|
|
335
|
+
score -= 15;
|
|
336
|
+
if (match[1] === "0.0.0.0")
|
|
337
|
+
score -= 10;
|
|
338
|
+
candidates.push({ port, score });
|
|
339
|
+
}
|
|
340
|
+
if (lineLooksLikeDuration)
|
|
341
|
+
return candidates;
|
|
342
|
+
const portPhrase = line.match(/\b(?:port|PORT)\s*(?:=|:|is|on)?\s*(\d{2,5})\b/);
|
|
343
|
+
if (portPhrase?.[1]) {
|
|
344
|
+
const port = validDetectedPort(portPhrase[1]);
|
|
345
|
+
if (port)
|
|
346
|
+
candidates.push({ port, score: lowerLine.includes("in use") ? 20 : 45 });
|
|
347
|
+
}
|
|
348
|
+
const serverPort = line.match(/\b(?:listening|started|running|server)\b[^\n\r]*:(\d{2,5})\b/i);
|
|
349
|
+
if (serverPort?.[1]) {
|
|
350
|
+
const port = validDetectedPort(serverPort[1]);
|
|
351
|
+
if (port)
|
|
352
|
+
candidates.push({ port, score: lineLooksLikeServer ? 60 : 40 });
|
|
353
|
+
}
|
|
354
|
+
return candidates;
|
|
355
|
+
}
|
|
356
|
+
function validDetectedPort(value) {
|
|
357
|
+
const port = Number(value);
|
|
358
|
+
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : undefined;
|
|
359
|
+
}
|
|
360
|
+
export function detectLaravelViteServers(text) {
|
|
361
|
+
const clean = stripAnsi(text);
|
|
362
|
+
const detection = {};
|
|
363
|
+
const appMatch = clean.match(/\[server\][^\n\r]*Server running on \[http:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\]|::1):(\d{1,5})\]/i);
|
|
364
|
+
const appPort = appMatch?.[1] ? validDetectedPort(appMatch[1]) : undefined;
|
|
365
|
+
if (appPort)
|
|
366
|
+
detection.appPort = appPort;
|
|
367
|
+
const viteMatch = [...clean.matchAll(/\[vite\][^\n\r]*(?:Local:)\s*http:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{1,5})\/?/gi)].at(-1);
|
|
368
|
+
const vitePort = viteMatch?.[2] ? validDetectedPort(viteMatch[2]) : undefined;
|
|
369
|
+
if (vitePort) {
|
|
370
|
+
detection.vitePort = vitePort;
|
|
371
|
+
detection.viteHost = normalizeLocalHost(viteMatch?.[1] ?? "localhost");
|
|
372
|
+
}
|
|
373
|
+
return detection;
|
|
374
|
+
}
|
|
375
|
+
function normalizeLocalHost(host) {
|
|
376
|
+
return host === "[::1]" || host === "::1" ? "localhost" : host;
|
|
377
|
+
}
|
|
378
|
+
function errorMessage(error) {
|
|
379
|
+
return error instanceof Error ? error.message : String(error);
|
|
380
|
+
}
|
|
381
|
+
function isTailscaleServePermissionError(error) {
|
|
382
|
+
const message = errorMessage(error).toLowerCase();
|
|
383
|
+
return message.includes("access denied") && (message.includes("sudo tailscale serve") || message.includes("sudo tailscale funnel") || message.includes("operator"));
|
|
384
|
+
}
|
|
385
|
+
function tailscaleExposeCommand(target, tailscalePort, mode) {
|
|
386
|
+
return [mode, "--bg", "--https", String(tailscalePort), target];
|
|
387
|
+
}
|
|
388
|
+
function tailscaleUrl(dnsName, tailscalePort) {
|
|
389
|
+
return tailscalePort === undefined ? `https://${dnsName}` : `https://${dnsName}:${tailscalePort}`;
|
|
390
|
+
}
|
|
391
|
+
function tailscaleServePermissionHelp(target, tailscalePort, mode) {
|
|
392
|
+
const label = mode === "funnel" ? "Funnel" : "Serve";
|
|
393
|
+
return `Tailscale refused to update ${label} config without elevated privileges.
|
|
394
|
+
|
|
395
|
+
Run this once to let your user manage Tailscale ${label}:
|
|
396
|
+
|
|
397
|
+
sudo tailscale set --operator=$USER
|
|
398
|
+
|
|
399
|
+
Then rerun lizardtail.
|
|
400
|
+
|
|
401
|
+
Or expose this server manually with:
|
|
402
|
+
|
|
403
|
+
sudo tailscale ${tailscaleExposeCommand(target, tailscalePort, mode).join(" ")}`;
|
|
404
|
+
}
|
|
405
|
+
async function exec(command, args, opts = {}) {
|
|
406
|
+
const childEnv = opts.env ?? process.env;
|
|
407
|
+
const child = spawn(command, args, {
|
|
408
|
+
stdio: [opts.input ? "pipe" : "ignore", "pipe", "pipe"],
|
|
409
|
+
env: childEnv,
|
|
410
|
+
});
|
|
411
|
+
let stdout = "";
|
|
412
|
+
let stderr = "";
|
|
413
|
+
if (!child.stdout || !child.stderr) {
|
|
414
|
+
throw new Error(`failed to capture output for ${command}`);
|
|
415
|
+
}
|
|
416
|
+
child.stdout.setEncoding("utf8");
|
|
417
|
+
child.stderr.setEncoding("utf8");
|
|
418
|
+
child.stdout.on("data", (chunk) => {
|
|
419
|
+
stdout += chunk;
|
|
420
|
+
});
|
|
421
|
+
child.stderr.on("data", (chunk) => {
|
|
422
|
+
stderr += chunk;
|
|
423
|
+
});
|
|
424
|
+
if (opts.input) {
|
|
425
|
+
if (!child.stdin)
|
|
426
|
+
throw new Error(`failed to write input for ${command}`);
|
|
427
|
+
child.stdin.end(opts.input);
|
|
428
|
+
}
|
|
429
|
+
const [code, signal] = (await Promise.race([
|
|
430
|
+
once(child, "exit"),
|
|
431
|
+
once(child, "error").then(([error]) => {
|
|
432
|
+
throw error;
|
|
433
|
+
}),
|
|
434
|
+
]));
|
|
435
|
+
if (code !== 0) {
|
|
436
|
+
const reason = signal ? `signal ${signal}` : `exit code ${code}`;
|
|
437
|
+
throw new Error(`${command} ${args.join(" ")} failed with ${reason}\n${stderr || stdout}`.trim());
|
|
438
|
+
}
|
|
439
|
+
return { stdout, stderr };
|
|
440
|
+
}
|
|
441
|
+
export async function waitForOpenPort(host, port, timeoutMs) {
|
|
442
|
+
const deadline = Date.now() + timeoutMs;
|
|
443
|
+
while (Date.now() < deadline) {
|
|
444
|
+
const isOpen = await new Promise((resolve) => {
|
|
445
|
+
const socket = net.connect({ host, port });
|
|
446
|
+
const done = (result) => {
|
|
447
|
+
socket.destroy();
|
|
448
|
+
resolve(result);
|
|
449
|
+
};
|
|
450
|
+
socket.setTimeout(500);
|
|
451
|
+
socket.once("connect", () => done(true));
|
|
452
|
+
socket.once("timeout", () => done(false));
|
|
453
|
+
socket.once("error", () => done(false));
|
|
454
|
+
});
|
|
455
|
+
if (isOpen)
|
|
456
|
+
return;
|
|
457
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
458
|
+
}
|
|
459
|
+
throw new Error(`timed out waiting for ${host}:${port} to accept connections`);
|
|
460
|
+
}
|
|
461
|
+
async function getTailscaleDnsName() {
|
|
462
|
+
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
|
463
|
+
const status = JSON.parse(stdout);
|
|
464
|
+
return status.Self?.DNSName?.replace(/\.$/, "");
|
|
465
|
+
}
|
|
466
|
+
async function getTailscaleExposureStatus(mode) {
|
|
467
|
+
try {
|
|
468
|
+
const { stdout } = await exec("tailscale", [mode, "status", "--json"]);
|
|
469
|
+
return JSON.parse(stdout);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function collectTailscaleStatusPorts(status, usedPorts) {
|
|
476
|
+
if (!status?.Web)
|
|
477
|
+
return;
|
|
478
|
+
for (const key of Object.keys(status.Web)) {
|
|
479
|
+
const match = key.match(/:(\d{2,5})$/);
|
|
480
|
+
const port = match?.[1] ? validDetectedPort(match[1]) : undefined;
|
|
481
|
+
if (port)
|
|
482
|
+
usedPorts.add(port);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async function resolveTailscaleHttpsPort(_target, requestedPort, config = DEFAULT_CONFIG) {
|
|
486
|
+
if (requestedPort !== undefined)
|
|
487
|
+
return requestedPort;
|
|
488
|
+
return chooseTailscaleHttpsPort(undefined, config);
|
|
489
|
+
}
|
|
490
|
+
async function chooseTailscaleHttpsPort(excludedPort, config = DEFAULT_CONFIG) {
|
|
491
|
+
const usedPorts = new Set();
|
|
492
|
+
collectTailscaleStatusPorts(await getTailscaleExposureStatus("serve"), usedPorts);
|
|
493
|
+
collectTailscaleStatusPorts(await getTailscaleExposureStatus("funnel"), usedPorts);
|
|
494
|
+
for (let port = DEFAULT_TAILSCALE_HTTPS_PORT; port <= MAX_AUTO_TAILSCALE_HTTPS_PORT; port += 1) {
|
|
495
|
+
if (port === excludedPort || usedPorts.has(port) || findBlockedPortRule(config, port, "tailscale"))
|
|
496
|
+
continue;
|
|
497
|
+
return port;
|
|
498
|
+
}
|
|
499
|
+
throw new Error("could not find an available Tailscale HTTPS port");
|
|
500
|
+
}
|
|
501
|
+
async function writeLaravelHotFile(viteUrl) {
|
|
502
|
+
const publicDir = path.join(process.cwd(), "public");
|
|
503
|
+
if (!existsSync(publicDir))
|
|
504
|
+
return undefined;
|
|
505
|
+
const hotPath = path.join(publicDir, "hot");
|
|
506
|
+
await writeFile(hotPath, viteUrl);
|
|
507
|
+
return hotPath;
|
|
508
|
+
}
|
|
509
|
+
async function startCorsProxy(targetHost, targetPort) {
|
|
510
|
+
const server = createServer((incoming, response) => {
|
|
511
|
+
if (incoming.method === "OPTIONS") {
|
|
512
|
+
response.writeHead(204, corsHeaders());
|
|
513
|
+
response.end();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const upstream = httpRequest({
|
|
517
|
+
host: targetHost,
|
|
518
|
+
port: targetPort,
|
|
519
|
+
method: incoming.method,
|
|
520
|
+
path: incoming.url,
|
|
521
|
+
headers: { ...incoming.headers, host: `${targetHost}:${targetPort}` },
|
|
522
|
+
}, (upstreamResponse) => {
|
|
523
|
+
response.writeHead(upstreamResponse.statusCode ?? 502, {
|
|
524
|
+
...upstreamResponse.headers,
|
|
525
|
+
...corsHeaders(),
|
|
526
|
+
});
|
|
527
|
+
upstreamResponse.pipe(response);
|
|
528
|
+
});
|
|
529
|
+
upstream.on("error", (error) => {
|
|
530
|
+
response.writeHead(502, corsHeaders());
|
|
531
|
+
response.end(`lizardtail Vite proxy error: ${error.message}`);
|
|
532
|
+
});
|
|
533
|
+
incoming.pipe(upstream);
|
|
534
|
+
});
|
|
535
|
+
server.on("upgrade", (request, socket, head) => {
|
|
536
|
+
const upstream = net.connect(targetPort, targetHost, () => {
|
|
537
|
+
upstream.write(`${request.method ?? "GET"} ${request.url ?? "/"} HTTP/${request.httpVersion}\r\n`);
|
|
538
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
539
|
+
if (value === undefined)
|
|
540
|
+
continue;
|
|
541
|
+
upstream.write(`${name}: ${Array.isArray(value) ? value.join(",") : value}\r\n`);
|
|
542
|
+
}
|
|
543
|
+
upstream.write(`\r\n`);
|
|
544
|
+
if (head.length > 0)
|
|
545
|
+
upstream.write(head);
|
|
546
|
+
socket.pipe(upstream).pipe(socket);
|
|
547
|
+
});
|
|
548
|
+
upstream.on("error", () => socket.destroy());
|
|
549
|
+
});
|
|
550
|
+
await new Promise((resolve, reject) => {
|
|
551
|
+
server.once("error", reject);
|
|
552
|
+
server.listen(0, "127.0.0.1", () => {
|
|
553
|
+
server.off("error", reject);
|
|
554
|
+
resolve();
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
const address = server.address();
|
|
558
|
+
if (!address || typeof address === "string")
|
|
559
|
+
throw new Error("failed to start Vite CORS proxy");
|
|
560
|
+
return { host: "127.0.0.1", port: address.port };
|
|
561
|
+
}
|
|
562
|
+
function corsHeaders() {
|
|
563
|
+
return {
|
|
564
|
+
"Access-Control-Allow-Origin": "*",
|
|
565
|
+
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
566
|
+
"Access-Control-Allow-Headers": "*",
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
async function exposeWithTailscaleDetailed(host, port, tailscalePort, publicExposure = false, config) {
|
|
570
|
+
const resolvedConfig = config ?? (await loadConfig());
|
|
571
|
+
assertPortAllowed(resolvedConfig, port, "local");
|
|
572
|
+
await exec("tailscale", ["status"]);
|
|
573
|
+
const target = `http://${host}:${port}`;
|
|
574
|
+
const resolvedTailscalePort = await resolveTailscaleHttpsPort(target, tailscalePort, resolvedConfig);
|
|
575
|
+
assertPortAllowed(resolvedConfig, resolvedTailscalePort, "tailscale");
|
|
576
|
+
const mode = publicExposure ? "funnel" : "serve";
|
|
577
|
+
try {
|
|
578
|
+
await exec("tailscale", tailscaleExposeCommand(target, resolvedTailscalePort, mode));
|
|
579
|
+
}
|
|
580
|
+
catch (firstError) {
|
|
581
|
+
if (isTailscaleServePermissionError(firstError)) {
|
|
582
|
+
throw new Error(tailscaleServePermissionHelp(target, resolvedTailscalePort, mode));
|
|
583
|
+
}
|
|
584
|
+
if (host !== "127.0.0.1" && host !== "localhost")
|
|
585
|
+
throw firstError;
|
|
586
|
+
try {
|
|
587
|
+
await exec("tailscale", tailscaleExposeCommand(String(port), resolvedTailscalePort, mode));
|
|
588
|
+
}
|
|
589
|
+
catch (fallbackError) {
|
|
590
|
+
if (isTailscaleServePermissionError(fallbackError)) {
|
|
591
|
+
throw new Error(tailscaleServePermissionHelp(target, resolvedTailscalePort, mode));
|
|
592
|
+
}
|
|
593
|
+
throw fallbackError;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
|
597
|
+
const status = JSON.parse(stdout);
|
|
598
|
+
const dnsName = status.Self?.DNSName?.replace(/\.$/, "");
|
|
599
|
+
if (dnsName)
|
|
600
|
+
return { url: tailscaleUrl(dnsName, resolvedTailscalePort), httpsPort: resolvedTailscalePort, mode };
|
|
601
|
+
const ip = status.Self?.TailscaleIPs?.find((value) => /^\d+\.\d+\.\d+\.\d+$/.test(value));
|
|
602
|
+
if (ip)
|
|
603
|
+
return { url: tailscaleUrl(ip, resolvedTailscalePort), httpsPort: resolvedTailscalePort, mode };
|
|
604
|
+
throw new Error("could not determine this device's Tailscale DNS name or IP");
|
|
605
|
+
}
|
|
606
|
+
export async function exposeWithTailscale(host, port, tailscalePort, publicExposure = false) {
|
|
607
|
+
return (await exposeWithTailscaleDetailed(host, port, tailscalePort, publicExposure)).url;
|
|
608
|
+
}
|
|
609
|
+
async function removeTailscaleExposurePort(port, mode) {
|
|
610
|
+
await exec("tailscale", [mode, `--https=${port}`, "off"]);
|
|
611
|
+
}
|
|
612
|
+
export async function main() {
|
|
613
|
+
const argv = process.argv.slice(2);
|
|
614
|
+
if (await handleMetaCommand(argv))
|
|
615
|
+
return;
|
|
616
|
+
const options = parseArgs(argv);
|
|
617
|
+
const config = await loadConfig();
|
|
618
|
+
const [command, ...args] = options.command;
|
|
619
|
+
const tailscaleDnsName = await getTailscaleDnsName().catch(() => undefined);
|
|
620
|
+
const childEnv = { ...process.env, FORCE_COLOR: process.env.FORCE_COLOR ?? "1" };
|
|
621
|
+
if (tailscaleDnsName) {
|
|
622
|
+
childEnv.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS = childEnv.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS
|
|
623
|
+
? `${childEnv.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS},${tailscaleDnsName}`
|
|
624
|
+
: tailscaleDnsName;
|
|
625
|
+
}
|
|
626
|
+
const child = spawn(command, args, {
|
|
627
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
628
|
+
env: childEnv,
|
|
629
|
+
});
|
|
630
|
+
let exposed = false;
|
|
631
|
+
let exposing;
|
|
632
|
+
let recentOutput = "";
|
|
633
|
+
let detectionTimer;
|
|
634
|
+
const createdTailscalePorts = new Map();
|
|
635
|
+
const cleanupTailscaleServe = async () => {
|
|
636
|
+
for (const [port, mode] of createdTailscalePorts) {
|
|
637
|
+
try {
|
|
638
|
+
await removeTailscaleExposurePort(port, mode);
|
|
639
|
+
console.error(`lizardtail: removed Tailscale ${mode === "funnel" ? "Funnel" : "Serve"} mapping on HTTPS port ${port}`);
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
console.error(`lizardtail: failed to remove Tailscale ${mode === "funnel" ? "Funnel" : "Serve"} mapping on HTTPS port ${port}: ${errorMessage(error)}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
createdTailscalePorts.clear();
|
|
646
|
+
};
|
|
647
|
+
const stopChild = () => {
|
|
648
|
+
if (!child.killed)
|
|
649
|
+
child.kill("SIGTERM");
|
|
650
|
+
};
|
|
651
|
+
const expose = (port) => {
|
|
652
|
+
if (exposed || exposing)
|
|
653
|
+
return;
|
|
654
|
+
exposed = true;
|
|
655
|
+
exposing = (async () => {
|
|
656
|
+
const localUrl = `http://${options.host}:${port}`;
|
|
657
|
+
console.error(`\nlizardtail: detected local server on ${localUrl}`);
|
|
658
|
+
assertPortAllowed(config, port, "local");
|
|
659
|
+
if (options.openCheck)
|
|
660
|
+
await waitForOpenPort(options.host, port, 10_000);
|
|
661
|
+
const exposure = await exposeWithTailscaleDetailed(options.host, port, options.tailscalePort, options.public, config);
|
|
662
|
+
createdTailscalePorts.set(exposure.httpsPort, exposure.mode);
|
|
663
|
+
console.error(`lizardtail: serving via Tailscale: ${exposure.url}`);
|
|
664
|
+
console.error(`lizardtail: cleanup command: tailscale ${exposure.mode} --https=${exposure.httpsPort} off`);
|
|
665
|
+
console.error("");
|
|
666
|
+
})().catch((error) => {
|
|
667
|
+
console.error(`lizardtail: failed to expose server: ${errorMessage(error)}`);
|
|
668
|
+
stopChild();
|
|
669
|
+
process.exitCode = 1;
|
|
670
|
+
});
|
|
671
|
+
};
|
|
672
|
+
const exposeLaravelVite = (appPort, vitePort, viteHost) => {
|
|
673
|
+
if (exposed || exposing)
|
|
674
|
+
return;
|
|
675
|
+
exposed = true;
|
|
676
|
+
exposing = (async () => {
|
|
677
|
+
const appLocalUrl = `http://${options.host}:${appPort}`;
|
|
678
|
+
const viteLocalUrl = `http://${viteHost}:${vitePort}`;
|
|
679
|
+
console.error(`\nlizardtail: detected Laravel app server on ${appLocalUrl}`);
|
|
680
|
+
console.error(`lizardtail: detected Vite asset server on ${viteLocalUrl}`);
|
|
681
|
+
assertPortAllowed(config, appPort, "local");
|
|
682
|
+
assertPortAllowed(config, vitePort, "local");
|
|
683
|
+
if (options.openCheck) {
|
|
684
|
+
await waitForOpenPort(options.host, appPort, 10_000);
|
|
685
|
+
await waitForOpenPort(viteHost, vitePort, 10_000);
|
|
686
|
+
}
|
|
687
|
+
const appExposure = await exposeWithTailscaleDetailed(options.host, appPort, options.tailscalePort, options.public, config);
|
|
688
|
+
createdTailscalePorts.set(appExposure.httpsPort, appExposure.mode);
|
|
689
|
+
const viteProxy = await startCorsProxy(viteHost, vitePort);
|
|
690
|
+
const viteTailscalePort = options.viteTailscalePort ?? (await chooseTailscaleHttpsPort(appExposure.httpsPort, config));
|
|
691
|
+
const viteExposure = await exposeWithTailscaleDetailed(viteProxy.host, viteProxy.port, viteTailscalePort, options.public, config);
|
|
692
|
+
createdTailscalePorts.set(viteExposure.httpsPort, viteExposure.mode);
|
|
693
|
+
const hotPath = await writeLaravelHotFile(viteExposure.url);
|
|
694
|
+
console.error(`lizardtail: serving Laravel via Tailscale: ${appExposure.url}`);
|
|
695
|
+
console.error(`lizardtail: serving Vite assets via Tailscale: ${viteExposure.url}`);
|
|
696
|
+
console.error(`lizardtail: proxying Vite through local CORS proxy: http://${viteProxy.host}:${viteProxy.port} -> ${viteLocalUrl}`);
|
|
697
|
+
if (hotPath)
|
|
698
|
+
console.error(`lizardtail: wrote Laravel Vite hot file: ${hotPath}`);
|
|
699
|
+
console.error(`lizardtail: cleanup command: tailscale ${appExposure.mode} --https=${appExposure.httpsPort} off`);
|
|
700
|
+
console.error(`lizardtail: cleanup command: tailscale ${viteExposure.mode} --https=${viteExposure.httpsPort} off`);
|
|
701
|
+
console.error("");
|
|
702
|
+
})().catch((error) => {
|
|
703
|
+
console.error(`lizardtail: failed to expose Laravel/Vite servers: ${errorMessage(error)}`);
|
|
704
|
+
stopChild();
|
|
705
|
+
process.exitCode = 1;
|
|
706
|
+
});
|
|
707
|
+
};
|
|
708
|
+
child.stdout.setEncoding("utf8");
|
|
709
|
+
child.stderr.setEncoding("utf8");
|
|
710
|
+
const inspectChunk = (chunk) => {
|
|
711
|
+
recentOutput = (recentOutput + chunk).slice(-8_000);
|
|
712
|
+
const detectedPort = detectPortFromText(recentOutput);
|
|
713
|
+
if (!detectedPort || exposed || exposing)
|
|
714
|
+
return;
|
|
715
|
+
if (detectionTimer)
|
|
716
|
+
clearTimeout(detectionTimer);
|
|
717
|
+
detectionTimer = setTimeout(() => {
|
|
718
|
+
detectionTimer = undefined;
|
|
719
|
+
const laravelVite = detectLaravelViteServers(recentOutput);
|
|
720
|
+
if (laravelVite.appPort && laravelVite.vitePort) {
|
|
721
|
+
exposeLaravelVite(laravelVite.appPort, laravelVite.vitePort, laravelVite.viteHost ?? "localhost");
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const settledPort = detectPortFromText(recentOutput);
|
|
725
|
+
if (settledPort)
|
|
726
|
+
expose(settledPort);
|
|
727
|
+
}, DETECTION_SETTLE_MS);
|
|
728
|
+
};
|
|
729
|
+
child.stdout.on("data", (chunk) => {
|
|
730
|
+
process.stdout.write(chunk);
|
|
731
|
+
if (!options.port)
|
|
732
|
+
inspectChunk(chunk);
|
|
733
|
+
});
|
|
734
|
+
child.stderr.on("data", (chunk) => {
|
|
735
|
+
process.stderr.write(chunk);
|
|
736
|
+
if (!options.port)
|
|
737
|
+
inspectChunk(chunk);
|
|
738
|
+
});
|
|
739
|
+
child.on("error", (error) => {
|
|
740
|
+
console.error(`lizardtail: failed to start ${command}: ${error.message}`);
|
|
741
|
+
process.exit(1);
|
|
742
|
+
});
|
|
743
|
+
if (options.port)
|
|
744
|
+
expose(options.port);
|
|
745
|
+
const timeout = options.port
|
|
746
|
+
? undefined
|
|
747
|
+
: setTimeout(() => {
|
|
748
|
+
if (!exposed) {
|
|
749
|
+
console.error(`lizardtail: no server port detected within ${options.timeoutMs}ms`);
|
|
750
|
+
stopChild();
|
|
751
|
+
process.exitCode = 1;
|
|
752
|
+
}
|
|
753
|
+
}, options.timeoutMs);
|
|
754
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
755
|
+
process.once(signal, () => {
|
|
756
|
+
child.kill(signal);
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
const [code, signal] = (await once(child, "exit"));
|
|
760
|
+
if (timeout)
|
|
761
|
+
clearTimeout(timeout);
|
|
762
|
+
if (detectionTimer)
|
|
763
|
+
clearTimeout(detectionTimer);
|
|
764
|
+
if (exposing)
|
|
765
|
+
await exposing;
|
|
766
|
+
await cleanupTailscaleServe();
|
|
767
|
+
if (signal)
|
|
768
|
+
process.kill(process.pid, signal);
|
|
769
|
+
process.exit(code ?? process.exitCode ?? 0);
|
|
770
|
+
}
|
|
771
|
+
function isCliEntrypoint() {
|
|
772
|
+
if (!process.argv[1])
|
|
773
|
+
return false;
|
|
774
|
+
try {
|
|
775
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (isCliEntrypoint()) {
|
|
782
|
+
main().catch((error) => {
|
|
783
|
+
console.error(`lizardtail: ${errorMessage(error)}`);
|
|
784
|
+
process.exit(1);
|
|
785
|
+
});
|
|
786
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lizardtail",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run a dev server command, detect its port, expose it with Tailscale Serve or Funnel, and print the URL.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lizardtail": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"dev": "tsx src/index.ts",
|
|
17
|
+
"test": "npm run build && tsx --test tests/**/*.test.ts",
|
|
18
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
19
|
+
"prepare": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"tailscale",
|
|
23
|
+
"serve",
|
|
24
|
+
"dev-server",
|
|
25
|
+
"cli",
|
|
26
|
+
"tailnet",
|
|
27
|
+
"localhost"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.10.2",
|
|
35
|
+
"tsx": "^4.19.2",
|
|
36
|
+
"typescript": "^5.7.2"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/dasomji/lizardtail#readme",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/dasomji/lizardtail.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/dasomji/lizardtail/issues"
|
|
45
|
+
}
|
|
46
|
+
}
|