Wren Linux

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