Skip to content

Latest commit

 

History

History
407 lines (282 loc) · 12.6 KB

File metadata and controls

407 lines (282 loc) · 12.6 KB

lizardtail

Lizardtail is a Tailscale Serve wrapper around other commands. It runs a command, watches its output for a localhost server port, exposes that port with Tailscale Serve, and prints the private tailnet URL. Pass --public to use Tailscale Funnel intentionally.

lizardtail pnpm dev
Local: http://localhost:5173

lizardtail: detected local server on http://127.0.0.1:5173
lizardtail: serving via Tailscale: https://my-host.tailabc.ts.net:8443

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.

Features

  • Runs any command you pass it, such as pnpm dev, npm run dev, bun run dev, or python -m http.server.
  • Streams the child command's stdout/stderr normally.
  • 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.
  • Ignores timing output like ready in 500 ms so it does not accidentally expose port 500.
  • Waits briefly after the first candidate port so multi-process commands, such as Laravel plus Vite, can print the better app-server URL.
  • Waits for the detected port to accept local connections before exposing it.
  • Runs tailscale serve --bg --https <tailscale-port> http://127.0.0.1:<port>.
  • Prints the HTTPS MagicDNS URL for the current Tailscale device.
  • Supports an explicit --port when automatic detection is not possible.
  • Supports --tailscale-port when you want the MagicDNS URL to include a specific HTTPS port.
  • 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.
  • Uses a stable alternate Tailscale HTTPS port by default: first free port from 8443 upward.
  • Stays private to your tailnet by default.
  • Supports --public / --funnel for intentional public internet sharing through Tailscale Funnel.
  • Cleans up the Tailscale Serve/Funnel mappings it created when the child command exits or you press Ctrl+C.
  • Has editable blocked-port guardrails with default entries for common HTTP/HTTPS ingress ports.

Requirements

  • Node.js 20 or newer.

  • Tailscale installed and available as tailscale on PATH.

  • The device must be logged into Tailscale.

  • Tailscale Serve must be available for the device/tailnet.

  • For --public, Tailscale Funnel must be enabled for the device/tailnet.

  • Your user must be allowed to update Tailscale Serve/Funnel config. If tailscale serve or tailscale funnel says access is denied, run this once:

    sudo tailscale set --operator=$USER

Check Tailscale before using lizardtail:

tailscale status
tailscale serve --help
# Optional, only for --public:
tailscale funnel --help

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.

Installation

Install from npm:

npm install -g lizardtail

Then run:

lizardtail help

Development from source

Use git clone only if you want to work on lizardtail itself:

git clone https://github.com/dasomji/lizardtail.git
cd lizardtail
npm install
npm run build
npm link

You can run the TypeScript source directly:

npm run dev -- pnpm dev

Or build and run the compiled CLI:

npm run build
node dist/index.js pnpm dev

Usage

lizardtail [options] -- <command> [args...]
lizardtail [options] <command> [args...]
lizardtail help [topic]
lizardtail config init

Use -- when the command itself has flags that could be confused for lizardtail options:

lizardtail -- npm run dev -- --host 0.0.0.0

Help

lizardtail help
lizardtail help config

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.

Options

Option Default Description
--port <port> auto-detect Expose this port instead of reading one from command output.
--host <host> 127.0.0.1 Local host to pass to Tailscale Serve/Funnel.
--timeout <ms> 30000 How long to wait for a port to appear in command output.
--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.
--vite-tailscale-port <port> first free 8443+ Expose a detected Laravel Vite asset server on this Tailscale HTTPS port. Alias: --vite-https-port.
--public, --funnel disabled Use Tailscale Funnel for public internet access instead of private tailnet-only Serve.
--no-open-check enabled Skip waiting for the local port to accept connections before calling Tailscale.
-h, --help Show help.

Examples

Vite / frontend dev server

lizardtail pnpm dev

If the frontend dev server proxies API requests to a backend, expose the frontend port so the proxied backend remains reachable from the remote browser:

lizardtail --port 5173 npm run dev

If Vite is configured to bind to another host:

lizardtail --host localhost pnpm dev

npm script with extra flags

lizardtail -- npm run dev -- --host 0.0.0.0

Known port

lizardtail --port 3000 npm run dev

MagicDNS URL with an explicit port

By default, lizardtail uses the first free Tailscale HTTPS port from 8443 upward, so multiple projects can be served at the same time:

https://my-host.tailabc.ts.net:8443

You can also choose the Tailscale HTTPS port explicitly:

lizardtail --tailscale-port 8450 pnpm dev

That prints a URL like:

https://my-host.tailabc.ts.net:8450

Laravel / composer run dev

Laravel development commands often start both the PHP app server and the Vite asset server. When lizardtail sees both, it:

  1. exposes the Laravel app server;
  2. starts a small local proxy in front of Vite that adds CORS headers;
  3. exposes that Vite proxy on a separate Tailscale HTTPS port;
  4. writes public/hot to the Tailscale Vite URL so Laravel renders assets from the reachable Vite server.
lizardtail composer run dev

You can choose the Vite Tailscale port explicitly:

lizardtail --vite-tailscale-port 8453 composer run dev

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.

If your app server lands on a known port, pass it with --port. Lizard Tail treats that as the main app port, but still watches briefly for companion services such as Vite and exposes them when detected:

lizardtail --port 8001 composer run dev

If no companion service appears during the short settle window, it falls back to exposing only the explicit port.

Public internet sharing

By default, URLs are only reachable from devices in your tailnet. To intentionally publish through Tailscale Funnel:

lizardtail --public pnpm dev

or:

lizardtail --funnel pnpm dev

This prints a public HTTPS URL such as:

https://my-host.tailabc.ts.net:8443

Use this only for apps you are comfortable exposing publicly. Stop lizardtail with Ctrl+C to remove the Funnel mapping it created.

Editable blocked ports

Lizard Tail ships with a small default blocked-port list for common HTTP/HTTPS ingress ports:

{
  "blockedPorts": [
    {
      "port": 80,
      "scope": "both",
      "reason": "Common HTTP ingress/proxy port. Blocking prevents dev exposure from replacing a production web route."
    },
    {
      "port": 443,
      "scope": "both",
      "reason": "Common HTTPS ingress/proxy port. Lizard Tail defaults to high explicit Tailscale HTTPS ports instead."
    }
  ]
}

Create an editable config file:

lizardtail config init

Lizard Tail searches the current working directory for:

  1. lizardtail.config.json
  2. .lizardtail.json

You can also set LIZARDTAIL_CONFIG=/path/to/config.json.

Each blocked-port entry has:

  • port: number from 1 to 65535
  • scope: "local", "tailscale", or "both" — defaults to "both"
  • reason: explanation shown when the rule blocks an action

If a config file exists, its blockedPorts list replaces the built-in default list. Keep, edit, or remove entries based on your own host.

Longer startup timeout

lizardtail --timeout 60000 pnpm dev

How it works

  1. lizardtail starts the command you provide.

  2. It streams the command output to your terminal.

  3. It scans recent output for local ports and known multi-service setups.

  4. With --port, it treats that port as the known main app port but still waits briefly for companion-service output before falling back to a single-port exposure.

  5. Once it chooses what to expose, it waits for 127.0.0.1:<port> or the configured --host to accept connections.

  6. 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.

  7. It runs Tailscale Serve for private tailnet-only access:

    tailscale serve --bg --https <tailscale-port> http://<host>:<port>

    With --public / --funnel, it runs Tailscale Funnel for public internet access:

    tailscale funnel --bg --https <tailscale-port> http://<host>:<port>

    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.

  8. It reads tailscale status --json, extracts the current device's MagicDNS name, and prints:

    https://<device-name>.<tailnet>.ts.net:<tailscale-port>
    

Shutdown behavior

When the child command exits, or when you press Ctrl+C, lizardtail removes the Tailscale mappings it created for that run:

tailscale serve --https=<port> off
# or, with --public:
tailscale funnel --https=<port> off

For Laravel + Vite, it also closes the local Vite proxy and restores public/hot to the exact content seen before Lizard Tail rewrote it. If there was no previous public/hot, it removes the generated file. If another process changes the file after Lizard Tail writes it, Lizard Tail leaves that newer content in place and warns instead of clobbering it.

It only tracks ports and files created or modified by the current lizardtail process.

Troubleshooting

No port detected

If the server does not print a recognizable port, pass it explicitly:

lizardtail --port 5173 pnpm dev

Tailscale command fails

Verify Tailscale is running and logged in:

tailscale status

Then check Serve support and current mappings:

tailscale serve --help
tailscale serve status

Access denied: serve config denied

Some Tailscale installs only allow root, or the configured Tailscale operator, to change Serve config. If you see:

Access denied: serve config denied
Use 'sudo tailscale serve ...'
To not require root, use 'sudo tailscale set --operator=$USER' once.

run:

sudo tailscale set --operator=$USER

Then rerun lizardtail. This is a one-time local machine setup step.

Browser cannot load assets

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.

Host checks or CORS failures

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.

Development

npm install
npm run typecheck
npm test
npm run build

The test suite uses Node's built-in test runner through tsx and includes:

  • unit tests for argument parsing and port detection;
  • an integration-style CLI test with a fake tailscale executable and a real temporary HTTP server.

Contributing

Issues and pull requests are welcome. Please include tests for behavior changes and run:

npm test

before opening a pull request.

License

MIT. See LICENSE.