Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,18 @@ dvm log cloudflared -f
The cloudflared token is staged through a mode `0600` guest temp file during `sync`
instead of being passed as a `limactl shell env` argument.

Tailscale Funnel VM (publish a local service at a public `*.ts.net` URL on demand):
Tailscale supports two patterns:

**Private dev access** — reach app VMs by hostname from your Mac without
juggling host ports. Add `use tailscale` to each app VM and sync:

```bash
TAILSCALE_AUTH_KEY="tskey-..." dvm sync fida
# then from the host browser: http://fida:5173
```

**Public Funnel URLs** — publish a local service at a public `*.ts.net` URL
on demand:

```bash
# One-time: create the VM and join your tailnet
Expand All @@ -147,7 +158,8 @@ dvm sync tailscale
Funnel is OFF by default; the recipe resets it on every sync, so leaving the
target unset turns it off. Auth keys get the same mode `0600` guest temp-file
handling as Cloudflare tokens. See [docs/services.md](docs/services.md#tailscale)
for the Funnel ACL prerequisite and full walkthrough.
for both patterns, the Funnel ACL prerequisite, and auth-key sourcing options
(global config, env var, or macOS Keychain).

## Recipes

Expand Down
11 changes: 7 additions & 4 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,13 @@ them from per-VM configs. Service recipes (`llama`, `cloudflared`) are not in
- `DVM_CLOUDFLARED_SERVICE`, `DVM_CLOUDFLARED_TOKEN`: cloudflared service settings.
The bundled cloudflared recipe receives the token through a mode `0600` guest temp
file during `sync`, so it is not passed as a `limactl shell env` argument.
- `DVM_TAILSCALE_AUTH_KEY`: tailscale auth key (`tskey-...`). Passed in at sync time
via `TAILSCALE_AUTH_KEY=...` and staged through a mode `0600` guest temp file, the
same way as the cloudflared token. Required on first sync; subsequent syncs reuse
the persistent tailscaled state unless a new key is provided.
- `DVM_TAILSCALE_AUTH_KEY`: tailscale auth key (`tskey-...`). Either set in
`~/.config/dvm/config.sh` or passed at sync time via `TAILSCALE_AUTH_KEY=...`
(config wins when both are set). Staged through a mode `0600` guest temp file
during `sync`, the same way as the cloudflared token. Required on first sync;
subsequent syncs reuse the persistent tailscaled state unless a new key is
provided. See [Services → Auth key sourcing](services.md#auth-key-sourcing)
for global-config, env-var, and macOS Keychain patterns.
- `DVM_TAILSCALE_HOSTNAME`: override the device name shown in the Tailscale admin
console. Defaults to `$DVM_NAME`.
- `DVM_TAILSCALE_FUNNEL_TARGET`: backend URL for `tailscale funnel`, e.g.
Expand Down
10 changes: 6 additions & 4 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,12 @@ CLOUDFLARED_TOKEN="$(security find-generic-password -a dvm -s cloudflared -w)" \
DVM does not provide a secret store command.

`tailscale` joins the tailnet (private mesh) and optionally publishes a single
backend service via Tailscale Funnel. Configure either a dedicated proxy VM or
add `use tailscale` to any VM that should be reachable from your tailnet. See
[Services](services.md#tailscale) for tunnel-VM setup, the auth-key flow, and the
Funnel ACL prerequisite. The auth key is passed in at sync time through the same
backend service via Tailscale Funnel. Two patterns: add `use tailscale` to any
app VM for private hostname-based dev access from your tailnet, or use the
dedicated `tailscale` VM template for on-demand public Funnel URLs. See
[Services](services.md#tailscale) for both walkthroughs, the Funnel ACL
prerequisite, and the three auth-key sourcing options (global config, env var,
or macOS Keychain). The auth key is passed in at sync time through the same
secret-staging path as `CLOUDFLARED_TOKEN`:

```bash
Expand Down
166 changes: 148 additions & 18 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,93 @@ compromised.

## Tailscale

Use case this recipe is designed for: **publish a local VM service at a public
`*.ts.net` URL on demand, share it with a teammate or external user, then turn
it off when you're done**. Funnel is OFF by default; you flip it on by passing
The `tailscale` recipe supports two patterns:

1. **Private dev access.** Add `use tailscale` to any app VM so you can reach
its dev servers from your host (and other tailnet devices) by hostname,
without juggling `DVM_PORTS` across many VMs.
2. **Public Funnel URLs.** Use the dedicated `tailscale` VM template to publish
one HTTP backend (running in any other DVM VM) at a public `*.ts.net` URL on
demand.

The two are independent — you can use either, both, or neither. Both share the
same auth-key flow; see [Auth key sourcing](#auth-key-sourcing).

### Pattern 1: Private dev access (per app VM)

Goal: reach `http://fida:5173`, `http://bar:3000`, etc. from your Mac without
ever picking unique host ports per VM.

1. **Install Tailscale on your host** and sign in to the same tailnet. The host
resolves VM hostnames via MagicDNS.

2. **Add the recipe to each app VM** that should be reachable:

```bash
# ~/.config/dvm/vms/fida.sh
use_tools
use tailscale
```

You can drop `DVM_PORTS` from these VMs entirely — Tailscale gives each VM
its own IP, so port collisions don't exist on the tailnet.

3. **Sync each VM**, providing the auth key (see
[Auth key sourcing](#auth-key-sourcing) for where to put it):

```bash
TAILSCALE_AUTH_KEY="tskey-..." dvm sync fida
```

The recipe registers the VM with hostname `$DVM_NAME` (override with
`DVM_TAILSCALE_HOSTNAME`).

4. **Run dev servers as usual** inside the VM, listening on `0.0.0.0`:

```bash
dvm ssh fida
npm run dev # vite on 0.0.0.0:5173
```

From the host browser:

```text
http://fida:5173
http://bar:3000
```

#### Optional: drop the port with `tailscale serve`

For a clean HTTPS URL without a port, run inside the VM:

```bash
sudo tailscale serve --bg --https=443 http://localhost:5173
```

This persists across reboots. `https://fida.<tailnet>.ts.net` now hits vite
directly. `tailscale serve status` shows current config; `tailscale serve
reset` clears it.

This is tailnet-private — only devices signed into your tailnet can reach it.
For a public URL, see Pattern 2.

### Pattern 2: Public Funnel URLs (dedicated VM)

Use case: **publish a local VM service at a public `*.ts.net` URL on demand,
share it with a teammate or external user, then turn it off when you're
done**. Funnel is OFF by default; you flip it on by passing
`DVM_TAILSCALE_FUNNEL_TARGET=<url>` at sync time and OFF by syncing again
without it.

DVM ships a `tailscale` recipe and a dedicated `tailscale` VM template that
joins the tailnet and proxies one HTTP backend (running in any other DVM VM
reachable via Lima's internal DNS) to a public Funnel URL.
DVM ships a dedicated `tailscale` VM template that joins the tailnet and
proxies one HTTP backend (running in any other DVM VM reachable via Lima's
internal DNS) to a public Funnel URL.

### One-time setup
#### One-time setup

1. **Get an auth key.** Sign in at `login.tailscale.com/admin` → **Settings →
Keys** → **Generate auth key**. For a long-lived proxy node, use a
**reusable** key. Recommended: tag the key (e.g. `tag:dvm`) so ACLs can
reason about it. Copy the `tskey-...` value.
1. **Get an auth key** (see [Auth key sourcing](#auth-key-sourcing)). For a
long-lived proxy node, use a **reusable** key. Recommended: tag the key
(e.g. `tag:dvm`) so ACLs can reason about it.

2. **Allow Funnel in your tailnet ACL.** Open **Access controls** in the admin
console and ensure your policy grants the `funnel` node attribute to the
Expand Down Expand Up @@ -131,7 +202,7 @@ The bundled template sets `DVM_NO_BASELINE=1` and maps `TAILSCALE_AUTH_KEY` to
file during `sync`, so the value is never passed as a `limactl shell env`
argument on the host.

### Day-to-day: turn Funnel on/off
#### Day-to-day: Funnel on/off

When a teammate needs to see your dev app for a few hours:

Expand All @@ -156,7 +227,7 @@ dvm sync tailscale
The recipe runs `tailscale funnel reset` on every sync, so leaving
`DVM_TAILSCALE_FUNNEL_TARGET` unset means OFF. No state to forget about.

### Pinning a permanent target
#### Pinning a permanent target

If a single VM should always be the funnel target (e.g. a dedicated demo VM
that's always sharing the same service), set the variable in the VM config
Expand All @@ -170,7 +241,7 @@ DVM_TAILSCALE_FUNNEL_TARGET="http://lima-dvm-demo.internal:8080"
Then every `dvm sync tailscale` keeps Funnel ON pointing there. Comment the
line out (or delete it) when you want to go back to the on-demand workflow.

### Limits to know
#### Limits to know

- **One Funnel target per node.** To expose multiple services publicly at the
same time, run multiple Tailscale VMs (`dvm init demo-a tailscale`,
Expand All @@ -185,13 +256,72 @@ line out (or delete it) when you want to go back to the on-demand workflow.
`<machine>.<tailnet>.ts.net` — no custom domain. If you need a custom
domain, use the `cloudflared` recipe instead.

### Auth key sourcing

A single **reusable** auth key works for every VM that uses the recipe.
Generate one in the admin console under **Settings → Keys → Generate auth
key**. DVM reads the key from `DVM_TAILSCALE_AUTH_KEY` first and falls back to
`TAILSCALE_AUTH_KEY` (apply.sh:14) — so a value in config wins over an env
var. Pick whichever sourcing fits your threat model.

#### A. Global config file (lowest friction, plaintext on disk)

Set the key once in `~/.config/dvm/config.sh`:

```bash
DVM_TAILSCALE_AUTH_KEY="tskey-auth-..."
```

Then `dvm sync <name>` works with no env var. The key sits unencrypted in your
home directory; reasonable if your laptop disk is encrypted and the key is a
personal-tailnet reusable key.

#### B. Shell env var (per-sync or per-shell)

```bash
TAILSCALE_AUTH_KEY="tskey-auth-..." dvm sync fida
```

Or export it in your shell rc so every sync in that shell picks it up:

```bash
# ~/.zshrc
export TAILSCALE_AUTH_KEY="tskey-auth-..."
```

Same disk-plaintext concern as (A) when stored in rc files; (A) is usually
simpler if you're going that route.

#### C. macOS Keychain (no plaintext on disk)

Store the key once:

```bash
security add-generic-password -a "$USER" -s dvm-tailscale-auth-key \
-w "tskey-auth-..."
```

Wrap `dvm sync` so it pulls the key from the Keychain on demand:

```bash
# ~/.zshrc
dvm-sync() {
TAILSCALE_AUTH_KEY="$(security find-generic-password \
-a "$USER" -s dvm-tailscale-auth-key -w)" \
dvm sync "$@"
}
```

Then `dvm-sync fida` reads the key at sync time only; it never lands on disk
in plaintext and never enters shell history.

### Auth key rotation and tear-down

To rotate the auth key, generate a new one in the admin console, run
`TAILSCALE_AUTH_KEY="tskey-new..." dvm sync tailscale`, then delete the old
key. To remove the VM from your tailnet, delete the device in the admin
console (or `dvm sh tailscale` then `sudo tailscale logout`) and then
`dvm rm tailscale --yes`.
`TAILSCALE_AUTH_KEY="tskey-new..." dvm sync <name>` (or update whichever
sourcing path you use), then delete the old key. To remove a VM from your
tailnet, delete the device in the admin console (or `dvm sh <name>` then
`sudo tailscale logout`) and then `dvm rm <name> --yes`.

## Logs

Expand Down