wren-linux / bin / browser-session
#!/bin/bash
# browser-session: written by Wren Linux lib/browser-sandbox.sh
# Runs as the user tor-browser, tor-browser-unsafe or mullvad-browser (the browser-sandbox command starts it
# through sudo; it is the only thing your user may run as them).
# start SHARE_DIR WAYLAND_DISPLAY AUDIO [URL...] the browser, inside cage, a tiny
# Wayland compositor shown as one window in your sway. These browsers run on X11,
# where any program can read every window and key, so cage gives this one a private
# X server of its own, with nothing else on it.
# clip-in standard input becomes the clipboard inside its window
# clip-out prints the clipboard inside its window
# The clipboard is never shared by itself: only these two, one at a time, when you ask
# (browser-sandbox clip, which checks and shows the text).
set -euo pipefail
# 077: also makes XWayland's socket in /tmp/.X11-unix this user's alone
umask 077
export PATH=/usr/local/bin:/usr/bin:/bin
# sudo keeps the folder it was started from, which this user may not enter (find says so, and fails)
cd /
die() { printf 'browser-session: %s\n' "$*" >&2; exit 1; }
me=$(id -un)
case $me in
# Tor Browser's AppArmor profile starts Tor under a profile of its own, which
# no_new_privs would refuse: see bin/no-abstract-sockets. Its profile only attaches
# to a browser under /home, so that is where this user's home is.
tor-browser) wrap=(--userns) put_back="sudo systemctl restart wren-tor-browser-firewall" ;;
tor-browser-unsafe) wrap=(--userns) put_back="sudo systemctl restart wren-tor-browser-unsafe-firewall" ;;
mullvad-browser) wrap=() put_back="sudo systemctl restart wren-mullvad-browser-firewall" ;;
*) die "this runs as the user tor-browser, tor-browser-unsafe or mullvad-browser; use their commands." ;;
esac
noabs=/usr/local/libexec/wren-no-abstract-sockets
netcheck=/usr/local/libexec/wren-sandbox-net-check
[ -x "$noabs" ] || die "$noabs is missing. Run: wrenctl install $me"
[ -x "$netcheck" ] || die "$netcheck is missing. Run: wrenctl install $me"
X11_DIR=/tmp/.X11-unix
session_base() { find /dev/shm -maxdepth 1 -uid "$(id -u)" -name "$me.*" -print -quit; }
# x11_dir_in: Tor Browser's X11 socket folder is in namespaces of its own (bin/no-abstract-sockets
# --userns), whose number its session wrote down. It is opened from outside them, by
# /proc/PID/root, as file 3, before the Landlock scope below (which refuses looking into another
# sandbox's programs). Nothing ever runs inside those namespaces: a hacked browser can name one it
# made, but not swap the programs or libraries the clipboard commands run.
x11_dir_in() {
local base pid
base=$(session_base)
[ -n "$base" ] || die "it isn't open."
pid=$(head -c 12 "$base/ns-pid" 2>/dev/null) || true
[[ $pid =~ ^[0-9]+$ ]] || die "its namespaces aren't ready yet."
# (only the shell's own error for this goes: die's say why)
{ exec 3<"/proc/$pid/root$X11_DIR"; } 2>/dev/null || die "its window isn't ready yet."
}
# Everything runs under it, from here on: XWayland's abstract socket is open to any user,
# and Landlock is what keeps this browser from yours, and others from its own. The clipboard
# commands too, but without --userns: they need no namespaces of their own.
# (sudo clears the environment, so your user can't set this and skip it.)
if [ "${WREN_BROWSER_WRAPPED:-}" != 1 ]; then
if [[ ${1:-} == clip-* ]]; then
if [ ${#wrap[@]} -gt 0 ]; then x11_dir_in; fi
WREN_BROWSER_WRAPPED=1 exec "$noabs" "$0" "$@"
fi
WREN_BROWSER_WRAPPED=1 exec "$noabs" "${wrap[@]}" "$0" "$@"
fi
home=$(getent passwd "$me" | cut -d: -f6) || true
[ "$home" = "/home/$me" ] || die "unexpected home folder for $me."
# browser_x: its private X server, which its session wrote down, as xdisplay (the socket's file,
# not ":N": that tries the abstract socket first, which the Landlock scope above refuses for a
# program outside the browser's own: xclip said "Can't open display", tried for real), in the
# folder opened as file 3 (Tor Browser's, above) or the usual one. A hacked browser can change
# those files, but this runs as the same user, under Landlock, so all it could do is lose the text,
# or point at a socket that isn't this user's, which is refused.
browser_x() {
local base d dir=$X11_DIR
base=$(session_base)
[ -n "$base" ] || die "it isn't open."
d=$(head -c 16 "$base/run/display" 2>/dev/null) || die "its window isn't ready yet."
[[ $d =~ ^:[0-9]+$ ]] || die "its window isn't ready yet."
if [ ${#wrap[@]} -gt 0 ]; then
[ -d /proc/self/fd/3 ] || die "its window isn't ready yet."
dir=/proc/self/fd/3
fi
xdisplay=$dir/X${d#:}
[ "$(stat -c '%F %u' -- "$xdisplay" 2>/dev/null)" = "socket $(id -u)" ] || die "its window isn't ready yet."
}
cmd=${1:-}
[ $# -eq 0 ] || shift
case $cmd in
clip-in)
# xclip stays in the background holding the text, until the browser copies
# something else or its X server closes
browser_x
DISPLAY=$xdisplay xclip -selection clipboard -in >/dev/null 2>&1
exit ;;
clip-out)
browser_x
DISPLAY=$xdisplay timeout 5 xclip -selection clipboard -out
exit ;;
start) ;;
*) die "usage: browser-session start SHARE_DIR WAYLAND_DISPLAY AUDIO [URL...] | clip-in | clip-out" ;;
esac
"$netcheck" || die "the firewall that keeps it off your network isn't active, so it wasn't started. Run: $put_back"
[ $# -ge 3 ] || die "usage: browser-session start SHARE_DIR WAYLAND_DISPLAY AUDIO [URL...]"
host_dir=$1 host_wl=$2 audio=$3
shift 3
# These come from the user who called us, so nothing else is accepted
[[ $host_dir =~ ^/dev/shm/wren-$me\.[A-Za-z0-9]+$ ]] || die "unexpected socket folder."
[[ $host_wl =~ ^[A-Za-z0-9._-]+$ ]] || die "unexpected Wayland display name."
[ -S "$host_dir/$host_wl" ] || die "can't reach the Wayland socket."
[[ $audio == - || $audio == "$me-audio.fifo" ]] || die "unexpected sound pipe name."
for a in "$@"; do
[[ $a =~ ^https?://[^[:space:]]+$ ]] || die "only http:// and https:// addresses can be opened."
done
if [ "$audio" != - ]; then
[ -p "$host_dir/$audio" ] || audio=-
for c in pipewire wireplumber pipewire-pulse; do
command -v "$c" >/dev/null || audio=-
done
fi
# Where it saves: the folder you share with it (~/Browsers/NAME on your side). Each browser looks
# in its own Downloads, which is a link to that.
files=/var/lib/wren-browsers/$me
case $me in
tor-browser | tor-browser-unsafe)
browser_dir=$home/.local/share/torbrowser/tbb/$(uname -m)/tor-browser/Browser
start_app=$browser_dir/start-tor-browser
[ -x "$start_app" ] || die "Tor Browser isn't here. Run: wrenctl install $me"
downloads=$browser_dir/Downloads ;;
mullvad-browser)
start_app=/usr/lib/mullvad-browser/start-mullvad-browser
[ -x "$start_app" ] || die "Mullvad Browser isn't here. Run: wrenctl install mullvad-browser-sandbox"
downloads=$home/Downloads ;;
esac
[ -d "$files" ] || die "$files is missing. Run: wrenctl install $me"
# (a real folder is left as it is if it has anything in it: those are its files)
if [ ! -L "$downloads" ] && [ -d "$downloads" ]; then rmdir -- "$downloads" 2>/dev/null || true; fi
if [ -L "$downloads" ] || [ ! -e "$downloads" ]; then ln -sfn "$files" "$downloads"; fi
# share_files: what it saved, readable by you and nobody else. You are named in the folder's
# default ACL, but a named user's entry counts only as far as the file's mask allows, and a
# download saved as private (600) gets an empty mask. Only files this user owns, straight in
# that folder. (As firefox-session's share_downloads.)
share_files() {
find "$files" -maxdepth 1 -type f -user "$me" \( ! -perm -g=r -o -perm /g=x,o=rwx \) \
-exec setfacl -m g::-,o::-,m::rw {} + 2>/dev/null || true
}
[ -d /dev/shm ] || die "/dev/shm (memory) isn't available."
# Left behind by a crash or a power cut (the browser-sandbox command allows one at a time)
find /dev/shm -maxdepth 1 -name "$me.*" -uid "$(id -u)" -exec rm -rf {} + 2>/dev/null || true
base=$(mktemp -d "/dev/shm/$me.XXXXXXXX")
mkdir "$base/run" "$base/config"
# (whose X11 socket folder clip-in and clip-out open; this is in the namespaces already, wrapped above)
if [ ${#wrap[@]} -gt 0 ]; then echo "$$" >"$base/ns-pid"; fi
pid='' sharer=''
cleanup() {
trap - EXIT INT TERM HUP
[ -z "$sharer" ] || kill "$sharer" 2>/dev/null || true
share_files
if [ -n "$pid" ]; then
kill -TERM -- "-$pid" 2>/dev/null || true
sleep 2
kill -KILL -- "-$pid" 2>/dev/null || true
fi
rm -rf "$base"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
# Its own sound server, whose only output is a pipe into your user's player: no sound
# cards and no microphone to reach (as in incognito)
if [ "$audio" != - ]; then
mkdir -p "$base/config/pipewire/pipewire.conf.d"
cat >"$base/config/pipewire/pipewire.conf.d/10-wren.conf" <<EOT
context.modules = [
{ name = libpipewire-module-pipe-tunnel
args = {
tunnel.mode = sink
pipe.filename = "$host_dir/$audio"
audio.format = S16LE
audio.rate = 48000
audio.channels = 2
audio.position = [ FL FR ]
node.name = $me-out
node.description = "$me"
}
}
]
EOT
fi
# One session: the sound server (if any), then cage, with the browser in it. Closing it
# all is one signal to the group. The browser connects only to cage's X server.
# shellcheck disable=SC2016 # expanded by the inner bash, on purpose
start='
waitfor() { for _ in $(seq 50); do [ -S "$1" ] && return 0; sleep 0.1; done; return 1; }
if [ -n "$BROWSER_AUDIO" ]; then
XDG_CONFIG_HOME=$BROWSER_CONFIG pipewire &
if waitfor "$XDG_RUNTIME_DIR/pipewire-0"; then
XDG_CONFIG_HOME=$BROWSER_CONFIG wireplumber -p policy &
XDG_CONFIG_HOME=$BROWSER_CONFIG pipewire-pulse &
waitfor "$XDG_RUNTIME_DIR/pulse/native" || true
fi
fi
exec cage -d -- bash -c "printf %s \"\$DISPLAY\" >\"\$XDG_RUNTIME_DIR/display\"; exec \"\$@\"" browser-start "$@"'
cd "$home"
setsid env -i \
PATH="$PATH" LANG=C.UTF-8 WREN_BROWSER_WRAPPED=1 \
HOME="$home" XDG_RUNTIME_DIR="$base/run" BROWSER_CONFIG="$base/config" \
BROWSER_AUDIO="$([ "$audio" != - ] && echo 1)" PULSE_SERVER="unix:$base/run/pulse/native" \
WAYLAND_DISPLAY="$host_dir/$host_wl" WLR_BACKENDS=wayland WLR_RENDERER=pixman \
GDK_BACKEND=x11 MOZ_ENABLE_WAYLAND=0 LIBGL_ALWAYS_SOFTWARE=1 GTK_USE_PORTAL=0 \
DBUS_SESSION_BUS_ADDRESS=disabled: \
bash -c "$start" browser-session "$start_app" "$@" &
pid=$!
"$netcheck" --watch "$pid" &
# What it saves becomes readable for you as it lands, and what it saved earlier too
share_files
while kill -0 "$pid" 2>/dev/null; do sleep 2; share_files; done &
sharer=$!
wait "$pid" || true