Skip to content

Commit 454e181

Browse files
committed
feat: support explicit tailscale https port
1 parent 617fabf commit 454e181

3 files changed

Lines changed: 123 additions & 10 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Use it when your dev server is running on a remote machine and you want to open
2424
- Runs `tailscale serve --bg http://127.0.0.1:<port>`.
2525
- Prints the HTTPS MagicDNS URL for the current Tailscale device.
2626
- Supports an explicit `--port` when automatic detection is not possible.
27+
- Supports `--tailscale-port` when you want the MagicDNS URL to include a specific HTTPS port.
2728

2829
## Requirements
2930

@@ -99,6 +100,7 @@ lizardtail -- npm run dev -- --host 0.0.0.0
99100
| `--port <port>` | auto-detect | Expose this port instead of reading one from command output. |
100101
| `--host <host>` | `127.0.0.1` | Local host to pass to Tailscale Serve. |
101102
| `--timeout <ms>` | `30000` | How long to wait for a port to appear in command output. |
103+
| `--tailscale-port <port>` | `443` | Expose on this Tailscale HTTPS port and print it in the MagicDNS URL. Alias: `--https-port`. |
102104
| `--no-open-check` | enabled | Skip waiting for the local port to accept connections before calling Tailscale. |
103105
| `-h`, `--help` | | Show help. |
104106

@@ -128,6 +130,26 @@ lizardtail -- npm run dev -- --host 0.0.0.0
128130
lizardtail --port 3000 npm run dev
129131
```
130132

133+
### MagicDNS URL with an explicit port
134+
135+
By default, Tailscale Serve uses HTTPS port 443, so the URL has no port:
136+
137+
```text
138+
https://my-host.tailabc.ts.net
139+
```
140+
141+
If you want a URL with a port, choose the Tailscale HTTPS port separately:
142+
143+
```bash
144+
lizardtail --tailscale-port 8450 pnpm dev
145+
```
146+
147+
That prints a URL like:
148+
149+
```text
150+
https://my-host.tailabc.ts.net:8450
151+
```
152+
131153
### Longer startup timeout
132154

133155
```bash
@@ -146,6 +168,12 @@ lizardtail --timeout 60000 pnpm dev
146168
tailscale serve --bg http://<host>:<port>
147169
```
148170

171+
If `--tailscale-port <port>` is set, it runs:
172+
173+
```bash
174+
tailscale serve --bg --https <tailscale-port> http://<host>:<port>
175+
```
176+
149177
On older Tailscale versions, if that form fails for `127.0.0.1`/`localhost`, it falls back to:
150178

151179
```bash

src/index.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface Options {
1111
command: string[];
1212
host: string;
1313
port?: number;
14+
tailscalePort?: number;
1415
timeoutMs: number;
1516
openCheck: boolean;
1617
}
@@ -25,12 +26,15 @@ Options:
2526
--port <port> Expose this port instead of detecting one from output.
2627
--host <host> Local host to expose. Default: 127.0.0.1
2728
--timeout <ms> Port-detection timeout. Default: ${DEFAULT_TIMEOUT_MS}
29+
--tailscale-port <port>
30+
Expose on this Tailscale HTTPS port instead of 443.
2831
--no-open-check Skip waiting for the local port to accept connections.
2932
-h, --help Show this help.
3033
3134
Examples:
3235
lizardtail pnpm dev
3336
lizardtail --port 3000 npm run dev
37+
lizardtail --tailscale-port 8450 pnpm dev
3438
`);
3539
}
3640

@@ -89,6 +93,23 @@ export function parseArgs(argv: string[]): Options {
8993
continue;
9094
}
9195

96+
if (arg === "--tailscale-port" || arg === "--https-port") {
97+
const value = argv[++i];
98+
if (!value) usage();
99+
options.tailscalePort = parsePort(value);
100+
continue;
101+
}
102+
103+
if (arg.startsWith("--tailscale-port=")) {
104+
options.tailscalePort = parsePort(arg.slice("--tailscale-port=".length));
105+
continue;
106+
}
107+
108+
if (arg.startsWith("--https-port=")) {
109+
options.tailscalePort = parsePort(arg.slice("--https-port=".length));
110+
continue;
111+
}
112+
92113
if (arg === "--timeout") {
93114
const value = argv[++i];
94115
if (!value) usage();
@@ -166,7 +187,18 @@ function isTailscaleServePermissionError(error: unknown): boolean {
166187
return message.includes("access denied") && (message.includes("sudo tailscale serve") || message.includes("operator"));
167188
}
168189

169-
function tailscaleServePermissionHelp(target: string): string {
190+
function tailscaleServeCommand(target: string, tailscalePort?: number): string[] {
191+
const args = ["serve", "--bg"];
192+
if (tailscalePort !== undefined) args.push("--https", String(tailscalePort));
193+
args.push(target);
194+
return args;
195+
}
196+
197+
function tailscaleUrl(dnsName: string, tailscalePort?: number): string {
198+
return tailscalePort === undefined ? `https://${dnsName}` : `https://${dnsName}:${tailscalePort}`;
199+
}
200+
201+
function tailscaleServePermissionHelp(target: string, tailscalePort?: number): string {
170202
return `Tailscale refused to update Serve config without elevated privileges.
171203
172204
Run this once to let your user manage Tailscale Serve:
@@ -177,7 +209,7 @@ Then rerun lizardtail.
177209
178210
Or expose this server manually with:
179211
180-
sudo tailscale serve --bg ${target}`;
212+
sudo tailscale ${tailscaleServeCommand(target, tailscalePort).join(" ")}`;
181213
}
182214

183215
async function exec(command: string, args: string[], opts: { input?: string } = {}): Promise<{ stdout: string; stderr: string }> {
@@ -239,24 +271,24 @@ export async function waitForOpenPort(host: string, port: number, timeoutMs: num
239271
throw new Error(`timed out waiting for ${host}:${port} to accept connections`);
240272
}
241273

242-
export async function exposeWithTailscale(host: string, port: number): Promise<string> {
274+
export async function exposeWithTailscale(host: string, port: number, tailscalePort?: number): Promise<string> {
243275
await exec("tailscale", ["status"]);
244276

245277
const target = `http://${host}:${port}`;
246278
try {
247-
await exec("tailscale", ["serve", "--bg", target]);
279+
await exec("tailscale", tailscaleServeCommand(target, tailscalePort));
248280
} catch (firstError) {
249281
if (isTailscaleServePermissionError(firstError)) {
250-
throw new Error(tailscaleServePermissionHelp(target));
282+
throw new Error(tailscaleServePermissionHelp(target, tailscalePort));
251283
}
252284

253285
if (host !== "127.0.0.1" && host !== "localhost") throw firstError;
254286

255287
try {
256-
await exec("tailscale", ["serve", "--bg", String(port)]);
288+
await exec("tailscale", tailscaleServeCommand(String(port), tailscalePort));
257289
} catch (fallbackError) {
258290
if (isTailscaleServePermissionError(fallbackError)) {
259-
throw new Error(tailscaleServePermissionHelp(target));
291+
throw new Error(tailscaleServePermissionHelp(target, tailscalePort));
260292
}
261293
throw fallbackError;
262294
}
@@ -266,10 +298,10 @@ export async function exposeWithTailscale(host: string, port: number): Promise<s
266298
const status = JSON.parse(stdout) as { Self?: { DNSName?: string; TailscaleIPs?: string[] } };
267299
const dnsName = status.Self?.DNSName?.replace(/\.$/, "");
268300

269-
if (dnsName) return `https://${dnsName}`;
301+
if (dnsName) return tailscaleUrl(dnsName, tailscalePort);
270302

271303
const ip = status.Self?.TailscaleIPs?.find((value) => /^\d+\.\d+\.\d+\.\d+$/.test(value));
272-
if (ip) return `https://${ip}`;
304+
if (ip) return tailscaleUrl(ip, tailscalePort);
273305

274306
throw new Error("could not determine this device's Tailscale DNS name or IP");
275307
}
@@ -298,7 +330,7 @@ export async function main(): Promise<void> {
298330
const localUrl = `http://${options.host}:${port}`;
299331
console.error(`\nlizardtail: detected local server on ${localUrl}`);
300332
if (options.openCheck) await waitForOpenPort(options.host, port, 10_000);
301-
const tailscaleUrl = await exposeWithTailscale(options.host, port);
333+
const tailscaleUrl = await exposeWithTailscale(options.host, port, options.tailscalePort);
302334
console.error(`lizardtail: serving via Tailscale: ${tailscaleUrl}\n`);
303335
})().catch((error: unknown) => {
304336
console.error(`lizardtail: failed to expose server: ${errorMessage(error)}`);

tests/index.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,59 @@ test("parseArgs uses documented defaults", () => {
5858
});
5959
});
6060

61+
test("parseArgs supports an explicit Tailscale HTTPS port", () => {
62+
assert.deepEqual(parseArgs(["--tailscale-port", "8450", "pnpm", "dev"]), {
63+
command: ["pnpm", "dev"],
64+
host: "127.0.0.1",
65+
tailscalePort: 8450,
66+
timeoutMs: DEFAULT_TIMEOUT_MS,
67+
openCheck: true,
68+
});
69+
});
70+
71+
test("exposeWithTailscale can expose on an explicit Tailscale HTTPS port", async () => {
72+
const tempDir = await mkdtemp(path.join(tmpdir(), "lizardtail-test-"));
73+
const tailscalePath = path.join(tempDir, "tailscale");
74+
const tailscaleLog = path.join(tempDir, "tailscale.log");
75+
const originalPath = process.env.PATH;
76+
77+
await writeFile(
78+
tailscalePath,
79+
`#!/usr/bin/env bash
80+
printf '%s\n' "$*" >> "$TAILSCALE_LOG"
81+
if [ "$1" = "status" ] && [ "$2" = "--json" ]; then
82+
echo '{"Self":{"DNSName":"test-host.tailnet.ts.net."}}'
83+
exit 0
84+
fi
85+
if [ "$1" = "status" ]; then
86+
echo 'ok'
87+
exit 0
88+
fi
89+
if [ "$1" = "serve" ]; then
90+
echo 'serve ok'
91+
exit 0
92+
fi
93+
exit 1
94+
`,
95+
{ mode: 0o755 },
96+
);
97+
98+
try {
99+
process.env.PATH = `${tempDir}${path.delimiter}${originalPath ?? ""}`;
100+
process.env.TAILSCALE_LOG = tailscaleLog;
101+
102+
const url = await exposeWithTailscale("127.0.0.1", 3001, 8450);
103+
104+
assert.equal(url, "https://test-host.tailnet.ts.net:8450");
105+
const calls = await readFile(tailscaleLog, "utf8");
106+
assert.match(calls, /serve --bg --https 8450 http:\/\/127\.0\.0\.1:3001/);
107+
} finally {
108+
process.env.PATH = originalPath;
109+
delete process.env.TAILSCALE_LOG;
110+
await rm(tempDir, { recursive: true, force: true });
111+
}
112+
});
113+
61114
test("exposeWithTailscale explains how to fix Tailscale Serve permission errors", async () => {
62115
const tempDir = await mkdtemp(path.join(tmpdir(), "lizardtail-test-"));
63116
const tailscalePath = path.join(tempDir, "tailscale");

0 commit comments

Comments
 (0)