diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5ba46..4b8b6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 2637bf9..c4ed42c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 7dd4166..aacc6dd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/docs/commands.md b/docs/commands.md index 06dd86b..3d15262 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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 diff --git a/docs/config.md b/docs/config.md index 9d9e22b..81dd810 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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`. diff --git a/docs/recipes.md b/docs/recipes.md index 5d95f38..c7d0453 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -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 diff --git a/docs/services.md b/docs/services.md index dd889f2..5ed305f 100644 --- a/docs/services.md +++ b/docs/services.md @@ -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=` 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://..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 + `..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: @@ -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. diff --git a/share/dvm/config.sh b/share/dvm/config.sh index 022c620..a02dc6a 100644 --- a/share/dvm/config.sh +++ b/share/dvm/config.sh @@ -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 `; override only if you use custom key names. diff --git a/share/dvm/lib/apply.sh b/share/dvm/lib/apply.sh index 4d63ba9..d39d0aa 100644 --- a/share/dvm/lib/apply.sh +++ b/share/dvm/lib/apply.sh @@ -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) @@ -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 @@ -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 diff --git a/share/dvm/lib/core.sh b/share/dvm/lib/core.sh index 0de68b9..cacfebd 100644 --- a/share/dvm/lib/core.sh +++ b/share/dvm/lib/core.sh @@ -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 diff --git a/share/dvm/lib/guest.sh b/share/dvm/lib/guest.sh index 93ee7bd..f514f08 100644 --- a/share/dvm/lib/guest.sh +++ b/share/dvm/lib/guest.sh @@ -288,6 +288,10 @@ default_log_unit() { unit="$DVM_LLAMA_SERVICE" count=$((count + 1)) ;; + tailscale) + unit="tailscaled.service" + count=$((count + 1)) + ;; esac done fi diff --git a/share/dvm/recipes/tailscale.sh b/share/dvm/recipes/tailscale.sh new file mode 100644 index 0000000..bbc6445 --- /dev/null +++ b/share/dvm/recipes/tailscale.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Description: Tailscale mesh + optional Funnel public ingress +set -euo pipefail + +tailscale_die() { + printf 'dvm recipe: error: tailscale: %s\n' "$*" >&2 + exit 1 +} + +if ! command -v tailscale >/dev/null 2>&1; then + sudo dnf5 config-manager addrepo --from-repofile=https://pkgs.tailscale.com/stable/fedora/tailscale.repo + sudo dnf5 install -y tailscale +fi + +sudo systemctl enable --now tailscaled + +hostname="${DVM_TAILSCALE_HOSTNAME:-${DVM_NAME:-}}" +[ -n "$hostname" ] || tailscale_die "DVM_TAILSCALE_HOSTNAME is required when DVM_NAME is unset" + +auth_key="" +auth_key_file="${DVM_TAILSCALE_AUTH_KEY_FILE:-}" +if [ -n "$auth_key_file" ] && [ -f "$auth_key_file" ]; then + auth_key="$(cat "$auth_key_file")" + rm -f "$auth_key_file" +fi + +current_state="" +if status_json="$(tailscale status --json 2>/dev/null)"; then + current_state="$(printf '%s\n' "$status_json" | sed -n 's/.*"BackendState"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" +fi + +case "$current_state" in +Running) + if [ -n "$auth_key" ]; then + printf 'tailscale: re-running tailscale up with new auth key\n' >&2 + sudo tailscale up --auth-key="$auth_key" --hostname="$hostname" --accept-dns --reset + fi + ;; +*) + [ -n "$auth_key" ] || tailscale_die "tailscale is not authenticated; pass TAILSCALE_AUTH_KEY=tskey-... when running dvm sync" + sudo tailscale up --auth-key="$auth_key" --hostname="$hostname" --accept-dns + ;; +esac + +sudo tailscale funnel reset >/dev/null 2>&1 || true +if [ -n "${DVM_TAILSCALE_FUNNEL_TARGET:-}" ]; then + target="$DVM_TAILSCALE_FUNNEL_TARGET" + case "$target" in + http://* | https://*) ;; + *) tailscale_die "DVM_TAILSCALE_FUNNEL_TARGET must be a http:// or https:// URL: $target" ;; + esac + case "$target" in + *' '* | *$'\n'* | *$'\r'* | *'"'* | *'`'*) tailscale_die "invalid characters in DVM_TAILSCALE_FUNNEL_TARGET: $target" ;; + esac + sudo tailscale funnel --bg --https=443 "$target" + printf 'tailscale funnel: ON, target=%s\n' "$target" +else + printf 'tailscale funnel: OFF (set DVM_TAILSCALE_FUNNEL_TARGET=URL to enable)\n' +fi + +ipv4="$(tailscale ip --4 2>/dev/null | head -1 || true)" +[ -n "$ipv4" ] && printf 'tailscale ipv4: %s\n' "$ipv4" +public_url="$(tailscale serve status 2>/dev/null | sed -n 's|^\(https://[^[:space:]]*\).*|\1|p' | head -1 || true)" +[ -n "$public_url" ] && printf 'tailscale public url: %s\n' "$public_url" diff --git a/share/dvm/vms/tailscale.sh b/share/dvm/vms/tailscale.sh new file mode 100644 index 0000000..c610aa8 --- /dev/null +++ b/share/dvm/vms/tailscale.sh @@ -0,0 +1,21 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034,SC2088 +# Dedicated Tailscale Funnel VM. Joins the tailnet so it can publish public +# `*.ts.net` URLs for other DVM VMs on demand. +# +# Funnel is OFF by default. Toggle it per-sync: +# ON: DVM_TAILSCALE_FUNNEL_TARGET="http://lima-dvm-app.internal:3000" dvm sync tailscale +# OFF: dvm sync tailscale +# +# Pin a permanent target by uncommenting the line below. + +DVM_NO_BASELINE=1 +DVM_TAILSCALE_AUTH_KEY="${TAILSCALE_AUTH_KEY:-}" + +# DVM_TAILSCALE_FUNNEL_TARGET="http://lima-dvm-app.internal:3000" + +# Optional: override the device name shown in the Tailscale admin console. +# Defaults to $DVM_NAME. +# DVM_TAILSCALE_HOSTNAME="dvm-funnel" + +use tailscale diff --git a/tests/smoke.sh b/tests/smoke.sh index d9e99ed..e842afe 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -77,6 +77,18 @@ DVM_CLOUDFLARED_TOKEN="${CLOUDFLARED_TOKEN:-}" use cloudflared VM +cat >"$TMP/config/vms/tailscale.sh" <<'VM' +DVM_CPUS=2 +DVM_MEMORY=2GiB +DVM_DISK=20GiB +DVM_CODE_DIR="~/code/tailscale" +DVM_NO_BASELINE=1 +DVM_TAILSCALE_AUTH_KEY="${TAILSCALE_AUTH_KEY:-}" +DVM_TAILSCALE_FUNNEL_TARGET="http://lima-dvm-app.internal:3000" + +use tailscale +VM + cat >"$TMP/bin/limactl" <<'FAKE' #!/usr/bin/env bash set -euo pipefail @@ -286,6 +298,30 @@ if grep -Fq 'DVM_CLOUDFLARED_TOKEN=smoke.Token_123=-' "$TMP/state/log"; then exit 1 fi +: >"$TMP/state/log" +TAILSCALE_AUTH_KEY="tskey-auth-smoke-Test_123" "$ROOT/bin/dvm" sync tailscale +grep -Fq 'dvm recipe: tailscale' "$TMP/state/guest.sh" +grep -Fq 'dvm-tailscale-auth-key.' "$TMP/state/guest.sh" +grep -Fq 'auth_key_file="${DVM_TAILSCALE_AUTH_KEY_FILE:-}"' "$TMP/state/guest.sh" +grep -Fq 'tailscale up --auth-key=' "$TMP/state/guest.sh" +grep -Fq 'tailscale funnel --bg --https=443' "$TMP/state/guest.sh" +grep -Fq 'DVM_TAILSCALE_FUNNEL_TARGET=http://lima-dvm-app.internal:3000' "$TMP/state/log" +if grep -Fq 'TAILSCALE_AUTH_KEY=tskey-auth-smoke-Test_123' "$TMP/state/log"; then + printf 'tailscale auth key leaked into limactl argv log\n' >&2 + exit 1 +fi +if grep -Fq 'DVM_TAILSCALE_AUTH_KEY=tskey-auth-smoke-Test_123' "$TMP/state/log"; then + printf 'DVM tailscale auth key leaked into limactl argv log\n' >&2 + exit 1 +fi + +set +e +TAILSCALE_AUTH_KEY="not-a-tskey" "$ROOT/bin/dvm" sync tailscale 2>"$TMP/tailscale-bad.err" +status="$?" +set -e +[ "$status" -ne 0 ] +grep -Fq 'must start with tskey-' "$TMP/tailscale-bad.err" + perl -0pi -e 's/DVM_PORTS="3000:3000"/DVM_PORTS="3000:3000 9000:9000"/' "$TMP/config/vms/app.sh" "$ROOT/bin/dvm" sync app grep -Fq 'edit --tty=false --set .portForwards' "$TMP/state/log" @@ -408,6 +444,9 @@ grep -Fq 'shell dvm-cloudflared sudo journalctl -u dvm-cloudflared.service --no- "$ROOT/bin/dvm" log cloudflared -f grep -Fq 'shell dvm-cloudflared sudo journalctl -u dvm-cloudflared.service -f' "$TMP/state/log" +"$ROOT/bin/dvm" log tailscale +grep -Fq 'shell dvm-tailscale sudo journalctl -u tailscaled.service --no-pager -n 100' "$TMP/state/log" + cat >"$TMP/config/vms/invalid.sh" <<'VM' DVM_USER="root:bad" DVM_CPUS=2