Skip to content

Commit b257eda

Browse files
committed
feat: add optional public funnel exposure
1 parent 31f008e commit b257eda

3 files changed

Lines changed: 139 additions & 36 deletions

File tree

README.md

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,18 @@ Use it when your dev server is running on a remote machine and you want to open
2929
- Supports `--tailscale-port` when you want the MagicDNS URL to include a specific HTTPS port.
3030
- 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.
3131
- Uses a stable alternate Tailscale HTTPS port by default: first free port from `8443` upward.
32-
- Cleans up the Tailscale Serve mappings it created when the child command exits or you press `Ctrl+C`.
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`.
3335

3436
## Requirements
3537

3638
- Node.js 20 or newer.
3739
- Tailscale installed and available as `tailscale` on `PATH`.
3840
- The device must be logged into Tailscale.
3941
- Tailscale Serve must be available for the device/tailnet.
40-
- Your user must be allowed to update Tailscale Serve config. If `tailscale serve` says access is denied, run this once:
42+
- For `--public`, Tailscale Funnel must be enabled for the device/tailnet.
43+
- Your user must be allowed to update Tailscale Serve/Funnel config. If `tailscale serve` or `tailscale funnel` says access is denied, run this once:
4144

4245
```bash
4346
sudo tailscale set --operator=$USER
@@ -48,9 +51,11 @@ Check Tailscale before using `lizardtail`:
4851
```bash
4952
tailscale status
5053
tailscale serve --help
54+
# Optional, only for --public:
55+
tailscale funnel --help
5156
```
5257

53-
`lizardtail` exposes services to your private tailnet via Tailscale Serve. It does **not** use Tailscale Funnel and does not publish your server to the public internet.
58+
`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`.
5459

5560
## Installation
5661

@@ -107,6 +112,7 @@ lizardtail -- npm run dev -- --host 0.0.0.0
107112
| `--timeout <ms>` | `30000` | How long to wait for a port to appear in command output. |
108113
| `--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`. |
109114
| `--vite-tailscale-port <port>` | first free `8443+` | Expose a detected Laravel Vite asset server on this Tailscale HTTPS port. Alias: `--vite-https-port`. |
115+
| `--public`, `--funnel` | disabled | Use Tailscale Funnel for public internet access instead of private tailnet-only Serve. |
110116
| `--no-open-check` | enabled | Skip waiting for the local port to accept connections before calling Tailscale. |
111117
| `-h`, `--help` | | Show help. |
112118

@@ -183,6 +189,28 @@ If your app server lands on a known port and you only want to expose that server
183189
lizardtail --port 8001 composer run dev
184190
```
185191

192+
### Public internet sharing
193+
194+
By default, URLs are only reachable from devices in your tailnet. To intentionally publish through Tailscale Funnel:
195+
196+
```bash
197+
lizardtail --public pnpm dev
198+
```
199+
200+
or:
201+
202+
```bash
203+
lizardtail --funnel pnpm dev
204+
```
205+
206+
This prints a public HTTPS URL such as:
207+
208+
```text
209+
https://my-host.tailabc.ts.net:8443
210+
```
211+
212+
Use this only for apps you are comfortable exposing publicly. Stop `lizardtail` with `Ctrl+C` to remove the Funnel mapping it created.
213+
186214
### Longer startup timeout
187215

188216
```bash
@@ -196,18 +224,20 @@ lizardtail --timeout 60000 pnpm dev
196224
3. It scans recent output for a local port.
197225
4. Once it finds a port, it waits for `127.0.0.1:<port>` or the configured `--host` to accept connections.
198226
5. It chooses the first free Tailscale HTTPS port from `8443` upward, unless `--tailscale-port` was provided.
199-
6. It runs:
227+
6. It runs Tailscale Serve for private tailnet-only access:
200228

201229
```bash
202230
tailscale serve --bg --https <tailscale-port> http://<host>:<port>
203231
```
204232

205-
On older Tailscale versions, if that form fails for `127.0.0.1`/`localhost`, it falls back to:
233+
With `--public` / `--funnel`, it runs Tailscale Funnel for public internet access:
206234

207235
```bash
208-
tailscale serve --bg --https <tailscale-port> <port>
236+
tailscale funnel --bg --https <tailscale-port> http://<host>:<port>
209237
```
210238

239+
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.
240+
211241
7. It reads `tailscale status --json`, extracts the current device's MagicDNS name, and prints:
212242

213243
```text
@@ -216,10 +246,12 @@ lizardtail --timeout 60000 pnpm dev
216246

217247
## Shutdown behavior
218248

219-
When the child command exits, or when you press `Ctrl+C`, `lizardtail` removes the Tailscale Serve mappings it created for that run:
249+
When the child command exits, or when you press `Ctrl+C`, `lizardtail` removes the Tailscale mappings it created for that run:
220250

221251
```bash
222252
tailscale serve --https=<port> off
253+
# or, with --public:
254+
tailscale funnel --https=<port> off
223255
```
224256

225257
It only tracks ports created by the current `lizardtail` process.

src/index.ts

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface Options {
1818
viteTailscalePort?: number;
1919
timeoutMs: number;
2020
openCheck: boolean;
21+
public: boolean;
2122
}
2223

2324
export const DEFAULT_TIMEOUT_MS = 30_000;
@@ -36,13 +37,15 @@ Options:
3637
Expose the main app on this Tailscale HTTPS port. Default: first free ${DEFAULT_TAILSCALE_HTTPS_PORT}+ port.
3738
--vite-tailscale-port <port>
3839
Expose a detected Laravel Vite server on this Tailscale HTTPS port.
40+
--public, --funnel Expose publicly on the internet with Tailscale Funnel instead of private tailnet-only Serve.
3941
--no-open-check Skip waiting for the local port to accept connections.
4042
-h, --help Show this help.
4143
4244
Examples:
4345
lizardtail pnpm dev
4446
lizardtail --port 3000 npm run dev
4547
lizardtail --tailscale-port 8450 pnpm dev
48+
lizardtail --public pnpm dev
4649
`);
4750
}
4851

@@ -57,6 +60,7 @@ export function parseArgs(argv: string[]): Options {
5760
host: "127.0.0.1",
5861
timeoutMs: DEFAULT_TIMEOUT_MS,
5962
openCheck: true,
63+
public: false,
6064
};
6165

6266
for (let i = 0; i < argv.length; i += 1) {
@@ -77,6 +81,11 @@ export function parseArgs(argv: string[]): Options {
7781
continue;
7882
}
7983

84+
if (arg === "--public" || arg === "--funnel") {
85+
options.public = true;
86+
continue;
87+
}
88+
8089
if (arg === "--port") {
8190
const value = argv[++i];
8291
if (!value) usage();
@@ -283,18 +292,17 @@ function isTailscaleServePermissionError(error: unknown): boolean {
283292
return message.includes("access denied") && (message.includes("sudo tailscale serve") || message.includes("operator"));
284293
}
285294

286-
function tailscaleServeCommand(target: string, tailscalePort?: number): string[] {
287-
const args = ["serve", "--bg"];
288-
if (tailscalePort !== undefined) args.push("--https", String(tailscalePort));
289-
args.push(target);
290-
return args;
295+
type ExposureMode = "serve" | "funnel";
296+
297+
function tailscaleExposeCommand(target: string, tailscalePort: number, mode: ExposureMode): string[] {
298+
return [mode, "--bg", "--https", String(tailscalePort), target];
291299
}
292300

293301
function tailscaleUrl(dnsName: string, tailscalePort?: number): string {
294302
return tailscalePort === undefined ? `https://${dnsName}` : `https://${dnsName}:${tailscalePort}`;
295303
}
296304

297-
function tailscaleServePermissionHelp(target: string, tailscalePort?: number): string {
305+
function tailscaleServePermissionHelp(target: string, tailscalePort: number, mode: ExposureMode): string {
298306
return `Tailscale refused to update Serve config without elevated privileges.
299307
300308
Run this once to let your user manage Tailscale Serve:
@@ -305,7 +313,7 @@ Then rerun lizardtail.
305313
306314
Or expose this server manually with:
307315
308-
sudo tailscale ${tailscaleServeCommand(target, tailscalePort).join(" ")}`;
316+
sudo tailscale ${tailscaleExposeCommand(target, tailscalePort, mode).join(" ")}`;
309317
}
310318

311319
async function exec(command: string, args: string[], opts: { input?: string; env?: NodeJS.ProcessEnv } = {}): Promise<{ stdout: string; stderr: string }> {
@@ -508,28 +516,30 @@ function corsHeaders(): Record<string, string> {
508516
interface TailscaleExposure {
509517
url: string;
510518
httpsPort: number;
519+
mode: ExposureMode;
511520
}
512521

513-
async function exposeWithTailscaleDetailed(host: string, port: number, tailscalePort?: number): Promise<TailscaleExposure> {
522+
async function exposeWithTailscaleDetailed(host: string, port: number, tailscalePort?: number, publicExposure = false): Promise<TailscaleExposure> {
514523
await exec("tailscale", ["status"]);
515524

516525
const target = `http://${host}:${port}`;
517526
const resolvedTailscalePort = await resolveTailscaleHttpsPort(target, tailscalePort);
527+
const mode: ExposureMode = publicExposure ? "funnel" : "serve";
518528

519529
try {
520-
await exec("tailscale", tailscaleServeCommand(target, resolvedTailscalePort));
530+
await exec("tailscale", tailscaleExposeCommand(target, resolvedTailscalePort, mode));
521531
} catch (firstError) {
522532
if (isTailscaleServePermissionError(firstError)) {
523-
throw new Error(tailscaleServePermissionHelp(target, resolvedTailscalePort));
533+
throw new Error(tailscaleServePermissionHelp(target, resolvedTailscalePort, mode));
524534
}
525535

526536
if (host !== "127.0.0.1" && host !== "localhost") throw firstError;
527537

528538
try {
529-
await exec("tailscale", tailscaleServeCommand(String(port), resolvedTailscalePort));
539+
await exec("tailscale", tailscaleExposeCommand(String(port), resolvedTailscalePort, mode));
530540
} catch (fallbackError) {
531541
if (isTailscaleServePermissionError(fallbackError)) {
532-
throw new Error(tailscaleServePermissionHelp(target, resolvedTailscalePort));
542+
throw new Error(tailscaleServePermissionHelp(target, resolvedTailscalePort, mode));
533543
}
534544
throw fallbackError;
535545
}
@@ -539,20 +549,20 @@ async function exposeWithTailscaleDetailed(host: string, port: number, tailscale
539549
const status = JSON.parse(stdout) as { Self?: { DNSName?: string; TailscaleIPs?: string[] } };
540550
const dnsName = status.Self?.DNSName?.replace(/\.$/, "");
541551

542-
if (dnsName) return { url: tailscaleUrl(dnsName, resolvedTailscalePort), httpsPort: resolvedTailscalePort };
552+
if (dnsName) return { url: tailscaleUrl(dnsName, resolvedTailscalePort), httpsPort: resolvedTailscalePort, mode };
543553

544554
const ip = status.Self?.TailscaleIPs?.find((value) => /^\d+\.\d+\.\d+\.\d+$/.test(value));
545-
if (ip) return { url: tailscaleUrl(ip, resolvedTailscalePort), httpsPort: resolvedTailscalePort };
555+
if (ip) return { url: tailscaleUrl(ip, resolvedTailscalePort), httpsPort: resolvedTailscalePort, mode };
546556

547557
throw new Error("could not determine this device's Tailscale DNS name or IP");
548558
}
549559

550-
export async function exposeWithTailscale(host: string, port: number, tailscalePort?: number): Promise<string> {
551-
return (await exposeWithTailscaleDetailed(host, port, tailscalePort)).url;
560+
export async function exposeWithTailscale(host: string, port: number, tailscalePort?: number, publicExposure = false): Promise<string> {
561+
return (await exposeWithTailscaleDetailed(host, port, tailscalePort, publicExposure)).url;
552562
}
553563

554-
async function removeTailscaleServePort(port: number): Promise<void> {
555-
await exec("tailscale", ["serve", `--https=${port}`, "off"]);
564+
async function removeTailscaleExposurePort(port: number, mode: ExposureMode): Promise<void> {
565+
await exec("tailscale", [mode, `--https=${port}`, "off"]);
556566
}
557567

558568
export async function main(): Promise<void> {
@@ -576,15 +586,15 @@ export async function main(): Promise<void> {
576586
let exposing: Promise<void> | undefined;
577587
let recentOutput = "";
578588
let detectionTimer: NodeJS.Timeout | undefined;
579-
const createdTailscalePorts = new Set<number>();
589+
const createdTailscalePorts = new Map<number, ExposureMode>();
580590

581591
const cleanupTailscaleServe = async () => {
582-
for (const port of createdTailscalePorts) {
592+
for (const [port, mode] of createdTailscalePorts) {
583593
try {
584-
await removeTailscaleServePort(port);
585-
console.error(`lizardtail: removed Tailscale Serve mapping on HTTPS port ${port}`);
594+
await removeTailscaleExposurePort(port, mode);
595+
console.error(`lizardtail: removed Tailscale ${mode === "funnel" ? "Funnel" : "Serve"} mapping on HTTPS port ${port}`);
586596
} catch (error) {
587-
console.error(`lizardtail: failed to remove Tailscale Serve mapping on HTTPS port ${port}: ${errorMessage(error)}`);
597+
console.error(`lizardtail: failed to remove Tailscale ${mode === "funnel" ? "Funnel" : "Serve"} mapping on HTTPS port ${port}: ${errorMessage(error)}`);
588598
}
589599
}
590600
createdTailscalePorts.clear();
@@ -601,8 +611,8 @@ export async function main(): Promise<void> {
601611
const localUrl = `http://${options.host}:${port}`;
602612
console.error(`\nlizardtail: detected local server on ${localUrl}`);
603613
if (options.openCheck) await waitForOpenPort(options.host, port, 10_000);
604-
const exposure = await exposeWithTailscaleDetailed(options.host, port, options.tailscalePort);
605-
createdTailscalePorts.add(exposure.httpsPort);
614+
const exposure = await exposeWithTailscaleDetailed(options.host, port, options.tailscalePort, options.public);
615+
createdTailscalePorts.set(exposure.httpsPort, exposure.mode);
606616
console.error(`lizardtail: serving via Tailscale: ${exposure.url}\n`);
607617
})().catch((error: unknown) => {
608618
console.error(`lizardtail: failed to expose server: ${errorMessage(error)}`);
@@ -625,12 +635,12 @@ export async function main(): Promise<void> {
625635
await waitForOpenPort(viteHost, vitePort, 10_000);
626636
}
627637

628-
const appExposure = await exposeWithTailscaleDetailed(options.host, appPort, options.tailscalePort);
629-
createdTailscalePorts.add(appExposure.httpsPort);
638+
const appExposure = await exposeWithTailscaleDetailed(options.host, appPort, options.tailscalePort, options.public);
639+
createdTailscalePorts.set(appExposure.httpsPort, appExposure.mode);
630640
const viteProxy = await startCorsProxy(viteHost, vitePort);
631641
const viteTailscalePort = options.viteTailscalePort ?? (await chooseTailscaleHttpsPort(appExposure.httpsPort));
632-
const viteExposure = await exposeWithTailscaleDetailed(viteProxy.host, viteProxy.port, viteTailscalePort);
633-
createdTailscalePorts.add(viteExposure.httpsPort);
642+
const viteExposure = await exposeWithTailscaleDetailed(viteProxy.host, viteProxy.port, viteTailscalePort, options.public);
643+
createdTailscalePorts.set(viteExposure.httpsPort, viteExposure.mode);
634644
const hotPath = await writeLaravelHotFile(viteExposure.url);
635645

636646
console.error(`lizardtail: serving Laravel via Tailscale: ${appExposure.url}`);

tests/index.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ test("parseArgs parses options before the command", () => {
6161
port: 3000,
6262
timeoutMs: 5000,
6363
openCheck: false,
64+
public: false,
6465
});
6566
});
6667

@@ -70,6 +71,7 @@ test("parseArgs keeps command flags after -- delimiter", () => {
7071
host: "127.0.0.1",
7172
timeoutMs: 1000,
7273
openCheck: true,
74+
public: false,
7375
});
7476
});
7577

@@ -79,6 +81,7 @@ test("parseArgs uses documented defaults", () => {
7981
host: "127.0.0.1",
8082
timeoutMs: DEFAULT_TIMEOUT_MS,
8183
openCheck: true,
84+
public: false,
8285
});
8386
});
8487

@@ -90,6 +93,17 @@ test("parseArgs supports explicit Tailscale HTTPS ports", () => {
9093
viteTailscalePort: 8453,
9194
timeoutMs: DEFAULT_TIMEOUT_MS,
9295
openCheck: true,
96+
public: false,
97+
});
98+
});
99+
100+
test("parseArgs supports public Funnel exposure", () => {
101+
assert.deepEqual(parseArgs(["--public", "pnpm", "dev"]), {
102+
command: ["pnpm", "dev"],
103+
host: "127.0.0.1",
104+
timeoutMs: DEFAULT_TIMEOUT_MS,
105+
openCheck: true,
106+
public: true,
93107
});
94108
});
95109

@@ -136,6 +150,53 @@ exit 1
136150
}
137151
});
138152

153+
test("exposeWithTailscale can expose publicly with Tailscale Funnel", async () => {
154+
const tempDir = await mkdtemp(path.join(tmpdir(), "lizardtail-test-"));
155+
const tailscalePath = path.join(tempDir, "tailscale");
156+
const tailscaleLog = path.join(tempDir, "tailscale.log");
157+
const originalPath = process.env.PATH;
158+
159+
await writeFile(
160+
tailscalePath,
161+
`#!/usr/bin/env bash
162+
printf '%s\n' "$*" >> "$TAILSCALE_LOG"
163+
if [ "$1" = "status" ] && [ "$2" = "--json" ]; then
164+
echo '{"Self":{"DNSName":"test-host.tailnet.ts.net."}}'
165+
exit 0
166+
fi
167+
if [ "$1" = "status" ]; then
168+
echo 'ok'
169+
exit 0
170+
fi
171+
if [ "$1" = "serve" ] && [ "$2" = "status" ] && [ "$3" = "--json" ]; then
172+
echo '{}'
173+
exit 0
174+
fi
175+
if [ "$1" = "funnel" ]; then
176+
echo 'funnel ok'
177+
exit 0
178+
fi
179+
exit 1
180+
`,
181+
{ mode: 0o755 },
182+
);
183+
184+
try {
185+
process.env.PATH = `${tempDir}${path.delimiter}${originalPath ?? ""}`;
186+
process.env.TAILSCALE_LOG = tailscaleLog;
187+
188+
const url = await exposeWithTailscale("127.0.0.1", 3001, undefined, true);
189+
190+
assert.equal(url, "https://test-host.tailnet.ts.net:8443");
191+
const calls = await readFile(tailscaleLog, "utf8");
192+
assert.match(calls, /funnel --bg --https 8443 http:\/\/127\.0\.0\.1:3001/);
193+
} finally {
194+
process.env.PATH = originalPath;
195+
delete process.env.TAILSCALE_LOG;
196+
await rm(tempDir, { recursive: true, force: true });
197+
}
198+
});
199+
139200
test("exposeWithTailscale auto-selects a port when default HTTPS already serves another target", async () => {
140201
const tempDir = await mkdtemp(path.join(tmpdir(), "lizardtail-test-"));
141202
const tailscalePath = path.join(tempDir, "tailscale");

0 commit comments

Comments
 (0)