wren-linux / bin / whonix
#!/bin/sh
# whonix: written by Wren Linux extras/whonix.sh
# Installed under several names (symlinks to this file); the name it is run as picks
# which Workstation(s) it acts on. The Gateway is shared by all of them.
# whonix / whonix-noscript the Safest, no-JavaScript Workstation (the default)
# whonix-javascript the Safer, JavaScript Workstation (its frame is red)
# whonix-full both Workstations at once
# With no word it starts the Gateway and that command's Workstation(s), each on an
# empty workspace: the Gateway on this one if it is empty, else the next empty one to
# the right (or left), each Workstation on the next empty one after it, where you are
# left. The javascript Workstation never starts unless you name it (whonix-javascript
# or whonix-full), so the habitual "whonix" can't bring it up by accident.
# ... gateway start the Gateway and open it (Tor settings)
# ... stop shut down every Whonix VM that is running, cleanly: it presses each
# VM's power button (extras/whonix.sh makes that shut it down). Save
# your work inside first. One still running after WHONIX_STOP_WAIT
# seconds (60) is left running, and it says so.
# ... stop --force the same, but one still running then is switched off, as if its
# power button were held
# ... live the same as a plain start, but nothing done in the Workstation(s) is
# kept: each disk is put back when it shuts down. The Gateway runs as
# usual, since a Gateway that forgot would pick new Tor guards each
# time, which is worse.
# ... update update both VMs, and Tor Browser, over Tor, with both shut down: the
# Gateway first, then the Workstation. You see it in their windows, and
# they power off when done. Your Tor Browser bookmarks are kept.
# After WHONIX_UPDATE_DAYS (7) without an update that worked, the bar says so
# (bin/sway-status), and whonix stop asks whether to update first.
v="virsh -c qemu:///session"
# The Gateway, and the Workstations this command knows about. stop/close act on all of
# them (skipping any not running); start/live act on the ones the run-as name picks.
GW=Whonix-Gateway
WS_NOSCRIPT=Whonix-Workstation
WS_JAVASCRIPT=Whonix-Workstation-javascript
WS_ALL="$WS_NOSCRIPT $WS_JAVASCRIPT"
case ${0##*/} in
whonix-javascript) WS_START=$WS_JAVASCRIPT ;;
whonix-full) WS_START="$WS_NOSCRIPT $WS_JAVASCRIPT" ;;
*) WS_START=$WS_NOSCRIPT ;; # whonix, whonix-noscript
esac
# Started from Super+d there is no terminal: what went wrong is also shown as a
# notification, so it doesn't fail without a word
fail() {
echo "whonix: $*" >&2
[ -t 2 ] || notify-send -u critical Whonix "$*" 2>/dev/null || true
exit 1
}
running() { [ "$($v domstate "$1" 2>/dev/null)" = running ]; }
# any_running VM...: true if any of these VMs is running
any_running() { for vm in "$@"; do running "$vm" && return 0; done; return 1; }
# whonix_running: true if the Gateway or any Workstation is running
whonix_running() {
# shellcheck disable=SC2086 # the lists split into separate VM names on purpose
any_running $WS_ALL "$GW"
}
# The Gateway's outside network (extras/whonix.sh gives it a card of Wren Linux's):
# passt with Whonix's fixed addresses, IPv4 only, no DHCP, nothing of this computer
# mapped in, and 10.0.2.3 where only DNS goes on, to the resolver this computer
# uses. It ends when the Gateway does (--one-off). A Gateway set up before this
# has libvirt's own and is left as it is.
GW_NET=$HOME/.local/state/wren
# When the last update worked (or Whonix was installed). No file: installed before
# there was a whonix update, so nothing in its VMs would answer one.
STAMP=$GW_NET/whonix-updated
# Held while an update runs (flock lets go if that process dies)
LOCK=$GW_NET/whonix-update.lock
TTY=${WHONIX_TTY:-/dev/tty}
gateway_net() {
$v dumpxml --inactive "$GW" 2>/dev/null | grep -q 'wren-gw-net' || return 0
running "$GW" && return 0
command -v passt >/dev/null || fail "passt is missing. Run: wrenctl install whonix"
mkdir -p "$GW_NET"
chmod 700 "$GW_NET"
rm -f "$GW_NET/whonix-gateway-net.sock"
passt --quiet --one-off --socket "$GW_NET/whonix-gateway-net.sock" --pid "$GW_NET/whonix-gateway-net.pid" \
--ipv4-only --address 10.0.2.15 --netmask 24 --gateway 10.0.2.2 \
--no-map-gw --dns-forward 10.0.2.3 --no-dhcp ${WHONIX_NET_PCAP:+--pcap "$WHONIX_NET_PCAP"} ||
fail "couldn't start the Gateway's network (passt)."
}
up() {
running "$1" && return 0
# (its error message, not its output)
# shellcheck disable=SC2069 # this order on purpose: keeps the error, drops the output
err=$($v start "$1" 2>&1 >/dev/null) || fail "couldn't start $1: $err"
}
# What a live Workstation changed goes to a file next to its disk, which libvirt deletes when it
# shuts down, but which a power cut leaves behind: gone before any start, not to linger.
IMAGES=$HOME/.local/share/libvirt/images
# (matched by the domain at the end of the name, so one Workstation's leftover is never
# taken for a sibling whose name it is a prefix of, e.g. Whonix-Workstation-javascript)
live_leftovers() { for ws in "$@"; do running "$ws" || rm -f "$IMAGES"/*TRANSIENT-"$ws"; done; }
# live_xml VM OUT: VM's settings, its disk marked to forget. virsh create starts it
# from this copy, so the saved settings stay as they were, as with an update. (Whonix names its
# disk through libvirt's storage pool, and libvirt only forgets on a disk named as a plain file.)
live_xml() {
$v dumpxml --inactive "$1" | python3 -I -c '
import re, sys
from xml.sax.saxutils import quoteattr
out, images = sys.argv[1:3]
def forget(m):
d = re.sub(r"(<disk\b[^>]*)type=.volume.", lambda t: t.group(1) + "type=\"file\"", m.group(0), count=1)
d = re.sub(r"<source\b[^>]*volume=.([^\x27\"]+).[^>]*/>", lambda s: "<source file=%s/>" % quoteattr(images + "/" + s.group(1)), d, count=1)
return d.replace("</disk>", "<transient/></disk>")
x = sys.stdin.read()
x, n = re.subn(r"<disk\b[^>]*device=.disk.[^>]*>.*?</disk>", forget, x, count=1, flags=re.S)
if not n:
sys.exit("no disk in its settings")
open(out, "w").write(x)' "$2" "$IMAGES"
}
# live WS...: start each of these Workstations from a copy whose disk is marked to forget
live() {
for ws in "$@"; do
# (running as usual, what it has done would be kept, whatever this said)
if running "$ws"; then fail "$ws is already running, and keeps what is done in it. Run whonix stop first."; fi
xml=$GW_NET/live-$ws.xml
live_xml "$ws" "$xml" || fail "couldn't read the settings of $ws."
# shellcheck disable=SC2069 # this order on purpose: keeps the error, drops the output
err=$($v create "$xml" 2>&1 >/dev/null) || { rm -f "$xml"; fail "couldn't start $ws: $err"; }
rm -f "$xml"
done
echo "Live: nothing done in the Workstation is kept when it shuts down. It does go to this computer's disk until then."
}
# console VM: its window, and back once that is open. virt-manager can stay in
# front until its window closes, so it runs on its own, with what it says in a
# file (mostly noise about audio). A second one hands its window to the first,
# which has to be up by then: outside sway, where nothing shows that, it gets a moment.
console() {
command -v virt-manager >/dev/null || fail "virt-manager is missing. Run: wrenctl install whonix"
mkdir -p "$GW_NET"
log=$GW_NET/$1-console.log
setsid virt-manager -c qemu:///session --show-domain-console "$1" >"$log" 2>&1 </dev/null &
if ! in_sway; then
sleep 3
return 0
fi
end=$(($(date +%s) + ${WHONIX_WINDOW_WAIT:-60}))
until sway_ask open "$1"; do
[ "$(date +%s)" -lt "$end" ] || fail "couldn't open the window of $1. What virt-manager said: $log"
sleep 0.25
done
}
in_sway() { [ -n "${SWAYSOCK:-}" ] && command -v swaymsg >/dev/null; }
# In sway, with workspaces (not extras/one-workspace.sh: there the two share
# the one screen), each window gets an empty workspace of its own
spread() {
in_sway || return 1
[ ! -e "${XDG_CONFIG_HOME:-$HOME/.config}/sway/config.d/one-workspace.conf" ]
}
# sway_ask empty [AFTER]: the number of an empty workspace (1-10). Without
# AFTER, the one you are on if it is empty; else, and with AFTER, the next one
# to the right of it (or of AFTER), or else to the left. None free: nothing.
# sway_ask open TITLE: is a window "TITLE on ..." (virt-manager's) open?
sway_ask() {
swaymsg -t get_tree 2>/dev/null | python3 -I -c '
import json, sys
used, here, titles = set(), None, []
def walk(n, ws=None):
global here
if n.get("type") == "workspace":
ws = n.get("num", -1)
if n.get("focused") and ws is not None:
here = ws
if n.get("pid"):
titles.append(n.get("name") or "")
if ws is not None:
used.add(ws)
for c in n.get("nodes", []) + n.get("floating_nodes", []):
walk(c, ws)
walk(json.load(sys.stdin))
if sys.argv[1] == "open":
sys.exit(0 if any(sys.argv[2] + " on " in t for t in titles) else 1)
after = int(sys.argv[2]) if len(sys.argv) > 2 else None
start = after if after is not None else (here if here and here > 0 else 1)
order = ([] if after is not None else [start]) + list(range(start + 1, 11)) + list(range(start - 1, 0, -1))
print(next((w for w in order if w not in used), ""))
' "$@"
}
# window_to TITLE WORKSPACE: once the window is there (virt-manager takes a
# moment), it goes to that workspace, and so do you
window_to() {
i=0
while [ "$i" -lt 40 ]; do
swaymsg "[title=\"$1 on \"] move container to workspace number $2, workspace number $2" >/dev/null 2>&1 && return 0
sleep 0.25
i=$((i + 1))
done
}
# open_vms VM...: open each VM's window; in sway, each on the next empty workspace after
# the last, leaving you on the final one (with one-workspace, all share the one screen)
open_vms() {
if ! spread; then
for vm in "$@"; do console "$vm"; done
return
fi
prev=
for vm in "$@"; do
if ! sway_ask open "$vm" && w=$(sway_ask empty ${prev:+"$prev"}) && [ -n "$w" ]; then
console "$vm" && window_to "$vm" "$w"
prev=$w
else
console "$vm"
fi
done
}
# close_windows: the window of each VM that is off now, closed (virt-manager keeps it open,
# empty, after its VM powers off); one still running keeps its window
close_windows() {
in_sway || return 0
for vm in $WS_ALL $GW; do
running "$vm" && continue
swaymsg -q "[app_id=\"^virt-manager\$\" title=\"^$vm on \"] kill" >/dev/null 2>&1 || true
done
}
stop() {
if ! whonix_running; then
echo "whonix machines aren't running right now so there is nothing to stop."
close_windows
return 0
fi
i=0
while [ "$i" -lt "${WHONIX_STOP_WAIT:-60}" ] && whonix_running; do
# Again every few seconds: a VM still starting doesn't hear its power button yet
# (on a real Workstation, for about its first 70 seconds), and that press is lost
if [ $((i % 5)) = 0 ]; then
for vm in $WS_ALL $GW; do
if running "$vm"; then $v shutdown "$vm" >/dev/null 2>&1 || true; fi
done
fi
sleep 1
i=$((i + 1))
done
if [ "${1:-}" != --force ]; then
left=0
for vm in $WS_ALL $GW; do
running "$vm" || continue
echo "whonix: $vm is still running. Installed before whonix stop could shut it down, it shows a dialog: choose Shutdown there. Stuck? whonix stop --force switches it off (it can leave its disk needing repair)." >&2
left=1
done
close_windows
return "$left"
fi
# The Workstations first: the Gateway is their only way out
for vm in $WS_ALL $GW; do
running "$vm" || continue
echo "whonix: $vm didn't shut down by itself; switching it off." >&2
# --graceful: QEMU is asked to end and closes its disk cleanly
$v destroy --graceful "$vm" >/dev/null 2>&1 || $v destroy "$vm" >/dev/null 2>&1 ||
echo "whonix: couldn't switch $vm off." >&2
done
close_windows
true
}
# ---- whonix update ----
# Runs data/whonix-update in each VM (extras/whonix.sh writes it into the images).
# have_tty: there is a terminal to ask on, whatever stdin is
# (in a subshell: in plain sh, a redirect that fails on ":" ends the whole script, silently)
have_tty() { (: <"$TTY") 2>/dev/null; }
# update_running: an update holds the lock now
update_running() {
[ -e "$LOCK" ] || return 1
! flock -n "$LOCK" true
}
# update_due: installed with whonix update, and none has worked for a week
update_due() {
[ -e "$STAMP" ] &&
[ -n "$(find "$STAMP" -mmin +$((${WHONIX_UPDATE_DAYS:-7} * 1440)) 2>/dev/null)" ]
}
# note_xml VM OUT LOG: VM's saved settings, as they are, plus two things for this one run:
# the note in its firmware that makes it update, and its serial port written to LOG, which
# is how it says how that went. virsh create starts it from this copy, so the saved
# settings stay as they were: its next ordinary start is ordinary, even after a crash here.
note_xml() {
$v dumpxml --inactive "$1" | python3 -I -c '
import re, sys
from xml.sax.saxutils import quoteattr
out, log = sys.argv[1:3]
x = sys.stdin.read()
x, serial = re.subn(r"(<serial type=.pty.>)", lambda m: m.group(1) + "<log file=%s append=\"off\"/>" % quoteattr(log), x, count=1)
if not serial:
sys.exit("no serial port in its settings")
if "xmlns:qemu=" not in x:
x = re.sub(r"<domain\b", "<domain xmlns:qemu=\"http://libvirt.org/schemas/domain/qemu/1.0\"", x, count=1)
args = "<qemu:arg value=\"-smbios\"/><qemu:arg value=\"type=11,value=io.systemd.credential:wren.update=1\"/>"
if "</qemu:commandline>" in x:
x = x.replace("</qemu:commandline>", args + "</qemu:commandline>", 1)
else:
x = re.sub(r"</domain>\s*$", "<qemu:commandline>" + args + "</qemu:commandline></domain>", x)
open(out, "w").write(x)' "$2" "$3"
}
# update_vm VM WORDS: starts VM to update itself, shows it, and waits for it to power off.
# Succeeds only for its exact "all went well" line. That comes from inside the VM, so it is
# only ever compared, or shown cut down to plain characters. A VM that writes without end
# or never ends is switched off: it can't fill this disk or hold this up for ever.
update_vm() {
log=$GW_NET/$1-update.log
xml=$GW_NET/$1-update.xml
rm -f "$log"
if ! note_xml "$1" "$xml" "$log"; then
echo "whonix: couldn't read the settings of $1." >&2
return 1
fi
echo "Updating $2. Its window shows how it goes, and it powers off when done. Please leave it running."
# shellcheck disable=SC2069 # this order on purpose: keeps the error, drops the output
if ! err=$($v create "$xml" 2>&1 >/dev/null); then
rm -f "$xml"
echo "whonix: couldn't start $1: $err" >&2
return 1
fi
rm -f "$xml"
# (no window is no reason to stop an update)
if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then (console "$1") || true; fi
end=$(($(date +%s) + ${WHONIX_UPDATE_LIMIT:-11400}))
while :; do
case $($v domstate "$1" 2>/dev/null) in
"shut off" | crashed) break ;;
paused)
$v destroy "$1" >/dev/null 2>&1 || true
echo "whonix: $1 was paused, which usually means the disk here is full. Free some, then run this again." >&2
return 1 ;;
esac
if [ "$(date +%s)" -ge "$end" ] ||
[ "$({ wc -c <"$log"; } 2>/dev/null || echo 0)" -gt "${WHONIX_UPDATE_LOG_MAX:-67108864}" ]; then
$v destroy "$1" >/dev/null 2>&1 || true
echo "whonix: $1's update didn't end as it should, so it was switched off. What it said: $log" >&2
return 1
fi
sleep "${WHONIX_UPDATE_POLL:-2}"
done
if in_sway; then swaymsg -q "[title=\"$1 on \"] kill" >/dev/null 2>&1 || true; fi
said=$({ tr -d '\r' <"$log"; } 2>/dev/null | grep -a '^WREN-RESULT: ' | tail -n 1)
if [ "$said" = "WREN-RESULT: ok" ]; then
rm -f "$log"
return 0
fi
echo "whonix: updating $2 didn't work: $(printf '%s' "${said#WREN-RESULT: }" | LC_ALL=C tr -cd 'A-Za-z0-9 :.,_-' | cut -c1-200). What it said: $log" >&2
return 1
}
update_failed() {
echo "whonix: the update isn't counted as done, so the reminder stays. What was there before still works." >&2
[ -t 2 ] || notify-send -u critical Whonix "The update didn't work: see the terminal it ran in." 2>/dev/null || true
exit 1
}
# update: one at a time. flock -o: the lock stays with flock, not with what the update
# starts (a libvirt daemon that outlived it would hold it for ever). Signals ignored
# here stay ignored: the window it runs in may be closed, and the update goes on.
update() {
[ -e "$STAMP" ] || fail "this Whonix was installed before whonix update, so nothing inside it can answer one. Update inside the VMs, or back up what you keep in them, then: wrenctl remove whonix, and wrenctl install whonix."
mkdir -p "$GW_NET"
chmod 700 "$GW_NET"
trap '' HUP PIPE
rc=0
flock -n -o -E 75 "$LOCK" sh "$0" __update || rc=$?
if [ "$rc" = 75 ]; then fail "an update is running already. Leave it running."; fi
exit "$rc"
}
update_locked() {
if whonix_running; then
fail "Whonix is running. Save your work inside it, then: whonix stop, and whonix update."
fi
gateway_net
update_vm "$GW" "the Gateway (1 of 2)" || update_failed
# The Workstation's only way out is the Gateway, up as usual
gateway_net
up "$GW"
ok=true
update_vm "$WS_NOSCRIPT" "the Workstation and Tor Browser (2 of 2)" || ok=false
$v shutdown "$GW" >/dev/null 2>&1 || true
i=0
while [ "$i" -lt "${WHONIX_STOP_WAIT:-30}" ] && running "$GW"; do
sleep 1
i=$((i + 1))
done
$ok || update_failed
touch "$STAMP"
echo "Whonix is up to date. Start it with: whonix"
}
# wait_for_update WORD: what starting Whonix does while it updates: asks whether to open
# it when that is done. No only cancels opening it: the update goes on.
wait_for_update() {
update_running || return 0
if ! have_tty; then
# (from Super+d: a window to ask in)
if in_sway && command -v foot >/dev/null; then exec foot --title=Whonix "$0" "$1"; fi
fail "Whonix is updating. Open it when that is done: it powers off by itself."
fi
printf 'Whonix is updating. Open it when it is done? [Y/n] ' >>"$TTY"
read -r answer <"$TTY" || answer=
case $answer in
[Nn]*) echo "Not opening it. The update goes on."; exit 0 ;;
esac
flock "$LOCK" true
}
# stop_asking: whonix stop. Once an update is due, and with someone to answer, it asks.
stop_asking() {
want=false
if update_due && have_tty && whonix_running; then
printf "Whonix hasn't been updated in over %s days. Would you like to update before shutting down? [y/N] " "${WHONIX_UPDATE_DAYS:-7}" >>"$TTY"
read -r answer <"$TTY" || answer=
case $answer in [Yy]*) want=true ;; esac
fi
stop || exit $?
if $want; then update; fi
}
# webtunnel's program in the Gateway (extras/whonix.sh copies it in): Whonix doesn't
# update that copy, so this says when the one here, updated by apt, is newer
lyrebird_check() {
f=$GW_NET/whonix-lyrebird-version
[ -s "$f" ] || return 0
have=$(dpkg-query -W -f='${Version}' lyrebird 2>/dev/null) || return 0
dpkg --compare-versions "$have" gt "$(cat "$f")" || return 0
msg="lyrebird $have is out; the Gateway has $(cat "$f"). Copy it in: whonix stop, then wrenctl install whonix --bridges"
echo "whonix: $msg" >&2
[ -t 2 ] || notify-send Whonix "$msg" 2>/dev/null || true
}
# do_start / do_live: start the Gateway and the Workstation(s) this command picked, then
# open their windows (do_live from a copy whose disk is forgotten on shutdown). WS_START is
# split into the positional parameters once, so the rest stays quoted.
do_start() {
# shellcheck disable=SC2086
set -- $WS_START
live_leftovers "$@"
gateway_net && up "$GW" || return
for ws in "$@"; do up "$ws"; done
open_vms "$GW" "$@"
}
do_live() {
# shellcheck disable=SC2086
set -- $WS_START
live_leftovers "$@"
gateway_net && up "$GW" || return
live "$@" || return
open_vms "$GW" "$@"
}
case "${1:-start}" in
start) wait_for_update start; lyrebird_check; do_start ;;
live) wait_for_update start; lyrebird_check; do_live ;;
gateway) wait_for_update gateway; lyrebird_check; gateway_net && up "$GW" && console "$GW" ;;
stop)
case ${2:-} in
"")
# (its power button would cut off what apt is doing)
if update_running; then fail "Whonix is updating, and powers off by itself when done. Leave it running."; fi
stop_asking ;;
--force) stop --force ;;
*) echo "usage: whonix stop [--force]" >&2; exit 2 ;;
esac ;;
update) update ;;
__update) update_locked ;;
*) echo "usage: whonix [start|live|gateway|stop [--force]|update]" >&2; exit 2 ;;
esac