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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@
deletion is refused unless `--force` is passed. Previously only dirty Git repos
blocked deletion; loose data files (databases, notes, downloads) could be lost
silently.
- Added a `tailscale` recipe and a dedicated `tailscale` VM template aimed at
on-demand sharing of local VM services via Tailscale Funnel. The recipe
installs Tailscale on Fedora, joins the tailnet via an auth key, and toggles
Funnel based on `DVM_TAILSCALE_FUNNEL_TARGET`: every sync runs `tailscale
funnel reset` first, so passing the env var enables Funnel for that target
and omitting it turns Funnel off. The auth key is staged through a mode
`0600` guest temp file, the same way as the cloudflared token, and is never
passed as a `limactl shell env` argument. `dvm log tailscale` defaults to
`tailscaled.service`. See [docs/services.md](docs/services.md#tailscale).
- Added a `bat` recipe that installs bat from Fedora and runs `bat cache --build`.
- Added VM config validation before Lima template rendering for VM names, users, sizing,
code directories, host IPs, and port forwards.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ 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):

```bash
# One-time: create the VM and join your tailnet
dvm init tailscale tailscale
TAILSCALE_AUTH_KEY="tskey-..." dvm sync tailscale

# Turn Funnel ON, pointing at another VM's service
DVM_TAILSCALE_FUNNEL_TARGET="http://lima-dvm-app.internal:3000" \
dvm sync tailscale

# Turn Funnel OFF
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.

## Recipes

Bundled recipes live in `share/dvm/recipes` and can be copied or overridden in
Expand All @@ -151,6 +171,7 @@ First-pass recipes include:
- `chezmoi`: public HTTPS dotfiles
- `llama`: dedicated llama service VM
- `cloudflared`: dedicated Cloudflare Tunnel VM
- `tailscale`: tailnet membership and optional Funnel public ingress
- `node`, `python`: language basics

Codex and Claude default to unattended mode inside the `dvm-agent` Bubblewrap sandbox
Expand Down
8 changes: 6 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@ Defaults:
- public dotfiles use HTTPS by default
- Cloudflare tokens are passed to sync explicitly, staged through a mode `0600` guest
temp file, and written inside the VM
- Tailscale auth keys are handled the same way as Cloudflare tokens: passed to sync
explicitly, staged through a mode `0600` guest temp file, never passed as a
`limactl shell env` argument
- forwarded ports bind to `127.0.0.1` unless config says otherwise
- `dvm rm --yes` checks nested Git repos before deleting unless `--force` is used

Most sync-time DVM environment values are visible to host process listings while
`limactl` runs. Do not put secrets in general `DVM_*` config. The bundled cloudflared
token handoff is special-cased so `CLOUDFLARED_TOKEN` and `DVM_CLOUDFLARED_TOKEN` are
not passed as `limactl shell env` arguments.
and tailscale handoffs are special-cased so `CLOUDFLARED_TOKEN`,
`DVM_CLOUDFLARED_TOKEN`, `TAILSCALE_AUTH_KEY`, and `DVM_TAILSCALE_AUTH_KEY` are not
passed as `limactl shell env` arguments.

The `dvm-agent` recipe uses Unix ACLs to grant access to project code and restrict
common main-user secret paths, including SSH/GPG directories, token files, shell
Expand Down
1 change: 1 addition & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ service recipe, DVM picks the unit automatically:

- `use cloudflared`: `dvm-cloudflared.service`
- `use llama`: `dvm-llama.service`
- `use tailscale`: `tailscaled.service`

Otherwise pass the unit explicitly. With no journal arguments DVM uses
`--no-pager -n 100`; when DVM can infer the unit, journal arguments can follow the VM
Expand Down
9 changes: 9 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ 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_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.
`http://lima-dvm-app.internal:3000`. When set, the VM publishes the target on a
public `*.ts.net` URL; when unset the VM stays tailnet-private.
- `DVM_NO_BASELINE=1`: skip the implicit `baseline` recipe. Service VMs use this to
avoid dev-tool setup; recipes selected by that VM must install their own dependencies
such as `git`, `curl`, `jq`, `tar`, or `unzip`.
Expand Down
13 changes: 12 additions & 1 deletion docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,19 @@ 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
secret-staging path as `CLOUDFLARED_TOKEN`:

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

`dvm log llama` and `dvm log cloudflared` show the default service units for those
dedicated VMs.
dedicated VMs. For the tailscale VM use `dvm log tailscale tailscaled.service`.

## Project Hook

Expand Down
110 changes: 110 additions & 0 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,115 @@ CLOUDFLARED_TOKEN="$(security find-generic-password -a dvm -s cloudflared -w)" \
DVM does not have a secret command. Rotate the token in Cloudflare if the VM is
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
`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.

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

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
tunnel device — for example:

```hujson
"nodeAttrs": [
{ "target": ["tag:dvm"], "attr": ["funnel"] }
]
```

Without this, `tailscale funnel` will report a permissions error inside the
VM. (Tailnets that allow Funnel everywhere can use a broader target like
`["*"]`, but tag-scoped is preferred.)

3. **Create the proxy VM.**

```bash
dvm init tailscale tailscale
TAILSCALE_AUTH_KEY="tskey-..." dvm sync tailscale
```

The VM joins the tailnet. Funnel stays off — no public URL yet.

The bundled template sets `DVM_NO_BASELINE=1` and maps `TAILSCALE_AUTH_KEY` to
`DVM_TAILSCALE_AUTH_KEY`. DVM stages the key through a mode `0600` guest temp
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

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

```bash
# Turn ON, pointing at the app VM's local port
DVM_TAILSCALE_FUNNEL_TARGET="http://lima-dvm-app.internal:3000" \
dvm sync tailscale

# DVM prints the public URL, e.g.:
# tailscale funnel: ON, target=http://lima-dvm-app.internal:3000
# tailscale public url: https://<machine>.<tailnet>.ts.net
```

Share the URL. When you're done:

```bash
# Turn OFF
dvm sync tailscale
# tailscale funnel: OFF (set DVM_TAILSCALE_FUNNEL_TARGET=URL to enable)
```

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

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
itself instead of passing it at the command line:

```bash
# in ~/.config/dvm/vms/tailscale.sh
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

- **One Funnel target per node.** To expose multiple services publicly at the
same time, run multiple Tailscale VMs (`dvm init demo-a tailscale`,
`dvm init demo-b tailscale`) and point each at a different backend.
- **Funnel listens only on ports 443, 8443, 10000.** The recipe uses 443. The
*backend* (the URL you point at) can run on any port — Funnel terminates
TLS and proxies to whatever you specify.
- **`tailscaled` must keep running for the tunnel to stay up.** `dvm stop
tailscale` or shutting down the host kills the public URL until the VM
starts again.
- **The URL is your tailnet hostname**, like
`<machine>.<tailnet>.ts.net` — no custom domain. If you need a custom
domain, use the `cloudflared` recipe instead.

### 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`.

## Logs

DVM has a log helper for service VMs:
Expand All @@ -93,6 +202,7 @@ dvm log cloudflared
dvm log cloudflared -f
dvm log cloudflared dvm-cloudflared.service -f
dvm log llama
dvm log tailscale tailscaled.service -f
```

If a VM has no known service recipe or more than one, pass the systemd unit explicitly.
Expand Down
4 changes: 4 additions & 0 deletions share/dvm/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
# Set to 0 to enable Claude permission prompts.
# DVM_CLAUDE_BYPASS=1

# Tailscale auth key for VMs that use the `tailscale` recipe. Pass at sync time
# instead so it does not sit in a config file: `TAILSCALE_AUTH_KEY=tskey-... dvm sync ...`.
# DVM_TAILSCALE_AUTH_KEY="tskey-..."

# Settings for the chezmoi recipe. DVM_CHEZMOI_REPO is required when any VM uses
# `use chezmoi`. The signing/deploy key paths default to those created by
# `dvm ssh-key <name>`; override only if you use custom key names.
Expand Down
28 changes: 27 additions & 1 deletion share/dvm/lib/apply.sh
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
# shellcheck shell=bash

run_guest_apply() {
local cloudflared_token helper recipe path var
local cloudflared_token tailscale_auth_key helper recipe path var
local -a args
args=()
cloudflared_token=""
tailscale_auth_key=""
if uses_recipe cloudflared; then
cloudflared_token="${DVM_CLOUDFLARED_TOKEN:-${CLOUDFLARED_TOKEN:-}}"
[ -z "$cloudflared_token" ] || validate_cloudflared_token "$cloudflared_token"
fi
if uses_recipe tailscale; then
tailscale_auth_key="${DVM_TAILSCALE_AUTH_KEY:-${TAILSCALE_AUTH_KEY:-}}"
[ -z "$tailscale_auth_key" ] || validate_tailscale_auth_key "$tailscale_auth_key"
fi
while IFS= read -r var; do
case "$var" in
DVM_ROOT | DVM_SHARE | DVM_CONFIG | DVM_FAKE_STATE | DVM_LIMA_NAME | DVM_RECIPES | DVM_NO_BASELINE | DVM_PORT_FORWARDS_YAML) continue ;;
DVM_CLOUDFLARED_TOKEN | DVM_CLOUDFLARED_TOKEN_FILE) continue ;;
DVM_TAILSCALE_AUTH_KEY | DVM_TAILSCALE_AUTH_KEY_FILE) continue ;;
DVM_*) args+=("$var=${!var}") ;;
esac
done < <(compgen -A variable | sort)
Expand Down Expand Up @@ -53,6 +59,9 @@ DVM_HOSTNAME
if [ "$recipe" = "cloudflared" ]; then
emit_cloudflared_token_file "$cloudflared_token"
fi
if [ "$recipe" = "tailscale" ]; then
emit_tailscale_auth_key_file "$tailscale_auth_key"
fi
printf '\n# dvm recipe: %s\n' "$recipe"
cat "$path"
done
Expand Down Expand Up @@ -89,6 +98,23 @@ export DVM_CLOUDFLARED_TOKEN_FILE="$dvm_cloudflared_token_file"
DVM_CLOUDFLARED_TOKEN_SETUP
}

emit_tailscale_auth_key_file() {
local key="$1"
[ -n "$key" ] || return 0
cat <<'DVM_TAILSCALE_AUTH_KEY_SETUP'
# dvm tailscale auth key file
dvm_tailscale_auth_key_file="$(mktemp "${TMPDIR:-/tmp}/dvm-tailscale-auth-key.XXXXXX")"
chmod 600 "$dvm_tailscale_auth_key_file"
cat >"$dvm_tailscale_auth_key_file" <<'DVM_TAILSCALE_AUTH_KEY'
DVM_TAILSCALE_AUTH_KEY_SETUP
printf '%s\n' "$key"
cat <<'DVM_TAILSCALE_AUTH_KEY_SETUP'
DVM_TAILSCALE_AUTH_KEY
export DVM_TAILSCALE_AUTH_KEY_FILE="$dvm_tailscale_auth_key_file"

DVM_TAILSCALE_AUTH_KEY_SETUP
}

apply_one() {
load_vm "$1"
ensure_vm
Expand Down
10 changes: 10 additions & 0 deletions share/dvm/lib/core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ validate_cloudflared_token() {
esac
}

validate_tailscale_auth_key() {
case "$1" in
tskey-*) ;;
*) die "invalid tailscale auth key: must start with tskey-" ;;
esac
case "$1" in
*[!A-Za-z0-9._=-]*) die "invalid tailscale auth key characters" ;;
esac
}

dvm_endpoint_name() {
local name="$1"
case "$name" in
Expand Down
4 changes: 4 additions & 0 deletions share/dvm/lib/guest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ default_log_unit() {
unit="$DVM_LLAMA_SERVICE"
count=$((count + 1))
;;
tailscale)
unit="tailscaled.service"
count=$((count + 1))
;;
esac
done
fi
Expand Down
Loading