|
#!/bin/bash |
|
set -e |
|
|
|
# ExpressVPN installer for Bazzite Linux (immutable Fedora Atomic) |
|
# |
|
# WHY THIS SCRIPT EXISTS |
|
# ---------------------- |
|
# The official ExpressVPN .run installer fails on Bazzite for two reasons: |
|
# 1. It detects and rejects being run with sudo. |
|
# 2. Without sudo, it tries `dnf install` for dependencies, which Bazzite |
|
# blocks on the immutable host (rpm-ostree warning, then exits). |
|
# |
|
# Even if you work around those, the installer tries to write the app icon |
|
# and .desktop launcher to /usr/share/pixmaps/ and /usr/share/applications/, |
|
# which are part of Bazzite's read-only OSTree image. It hits a |
|
# "Read-only file system" error and aborts mid-install. |
|
# |
|
# This script bypasses all of that by doing each step explicitly, using sudo |
|
# only for the specific operations that need it, and redirecting the icon and |
|
# .desktop file to writable XDG user directories instead. |
|
# |
|
# WHY NOT OTHER APPROACHES |
|
# ------------------------ |
|
# - Flatpak: ExpressVPN does not publish one. |
|
# - AppImage: ExpressVPN does not publish one. |
|
# - rpm-ostree: ExpressVPN dropped separate .rpm packages in version 4.0. |
|
# Only a .run file is available, so there is nothing to layer. |
|
# - Distrobox: ExpressVPN's daemon runs --update-dns-config=static_resolv_conf |
|
# on connect, which tries to rewrite /etc/resolv.conf. Inside any |
|
# container, /etc/resolv.conf is a bind-mount and not writable in |
|
# the way the daemon expects. This causes HE_ERR_CALLBACK_FAILED |
|
# and an immediate disconnect. Confirmed on NixOS and Arch. |
|
# Zero documented success reports for ExpressVPN in any container |
|
# on Fedora Atomic. |
|
# |
|
# KNOWN CAVEATS |
|
# ------------- |
|
# - Kill switch unreliable: Bazzite runs firewalld, which manages nftables. |
|
# ExpressVPN's daemon injects raw iptables rules for its kill switch. |
|
# Firewalld can flush those rules on network events, silently breaking the |
|
# kill switch mid-session. The VPN tunnel itself works fine. |
|
# |
|
# - DNS fragility: On connect, the daemon replaces /etc/resolv.conf (which on |
|
# Bazzite is a symlink to /run/systemd/resolve/stub-resolv.conf) with a |
|
# static file. On a clean disconnect it restores it. On a crash or hard kill, |
|
# it does not. If you lose DNS after an unclean exit, restore it manually: |
|
# sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf |
|
# |
|
# - Future in-app updates: ExpressVPN's updater re-runs the original installer, |
|
# which will hit the same /usr/share read-only failure. When updating, re-run |
|
# this script instead (after re-extracting the new .run file). |
|
# |
|
# PREREQUISITES |
|
# ------------- |
|
# Download the official .run file from expressvpn.com/vpn-download/vpn-linux |
|
# then run this script pointing at it: |
|
# bash install-expressvpn.sh ~/Downloads/expressvpn-linux-universal-14.x.x.xxxxx_release.run |
|
|
|
INSTALL_DIR="/opt/expressvpn" |
|
SERVICE_NAME="expressvpn-service" |
|
BRAND="expressvpn" |
|
EXTRACT_DIR="/tmp/evpn_extracted" |
|
EXTRACTED="$EXTRACT_DIR/x64" |
|
|
|
echo "" |
|
echo "=================================" |
|
echo "ExpressVPN Installer (Bazzite)" |
|
echo "=================================" |
|
echo "" |
|
|
|
# Resolve the .run file |
|
if [ "$#" -ne 1 ]; then |
|
echo "Usage: bash install-expressvpn.sh /path/to/expressvpn-linux-universal-<version>.run" |
|
echo "Download the .run file from expressvpn.com/vpn-download/vpn-linux" |
|
exit 1 |
|
fi |
|
RUN_FILE="$1" |
|
if [ ! -f "$RUN_FILE" ]; then |
|
echo "ERROR: File not found: $RUN_FILE" |
|
exit 1 |
|
fi |
|
|
|
# If ExpressVPN is running, offer to stop it before proceeding |
|
if systemctl is-active --quiet expressvpn-service || pgrep -x expressvpn-client >/dev/null 2>&1; then |
|
echo "ExpressVPN is currently running." |
|
read -r -p "Stop it now and proceed with installation? [y/N] " confirm |
|
if [[ "$confirm" != [yY] ]]; then |
|
echo "Aborted." |
|
exit 1 |
|
fi |
|
sudo systemctl stop expressvpn-service |
|
echo "OK: ExpressVPN stopped" |
|
fi |
|
|
|
echo "Using installer: $RUN_FILE" |
|
|
|
# Extract the .run file |
|
echo ">>> Extracting installer..." |
|
rm -rf "$EXTRACT_DIR" |
|
sh "$RUN_FILE" --noexec --nox11 --target "$EXTRACT_DIR" |
|
echo "OK" |
|
|
|
# Sanity check |
|
if [ ! -d "$EXTRACTED/expressvpnfiles" ]; then |
|
echo "ERROR: Extraction succeeded but expected files not found at $EXTRACTED" |
|
exit 1 |
|
fi |
|
|
|
# Step 1: Copy files to /opt/expressvpn |
|
echo ">>> [1/6] Copying files to $INSTALL_DIR..." |
|
sudo mkdir -p "$INSTALL_DIR" |
|
sudo cp -rf "$EXTRACTED/expressvpnfiles/"* "$INSTALL_DIR/" |
|
sudo cp "$EXTRACTED/installfiles/"*.sh "$INSTALL_DIR/bin/" |
|
sudo chmod +x "$INSTALL_DIR/bin/"*.sh |
|
sudo find "$INSTALL_DIR" -type d -exec chmod 755 {} \; |
|
sudo find "$INSTALL_DIR/bin" "$INSTALL_DIR/lib" -maxdepth 1 -type f -exec chmod +x {} \; |
|
sudo mkdir -p "$INSTALL_DIR/var" |
|
echo "OK" |
|
|
|
# Step 2: Set capability on DNS helper binary |
|
echo ">>> [2/6] Setting capability on expressvpn-unbound..." |
|
sudo setcap 'cap_net_bind_service=+ep' "$INSTALL_DIR/bin/expressvpn-unbound" |
|
echo "OK" |
|
|
|
# Step 3: Create system groups |
|
echo ">>> [3/6] Creating system groups..." |
|
getent group expressvpn >/dev/null || sudo groupadd expressvpn |
|
getent group expressvpnhnsd >/dev/null || sudo groupadd expressvpnhnsd |
|
echo "OK" |
|
|
|
# Step 4: Install and start systemd service |
|
echo ">>> [4/6] Installing systemd service..." |
|
sudo cp "$EXTRACTED/installfiles/expressvpn-service.service" "/etc/systemd/system/${SERVICE_NAME}.service" |
|
sudo chmod 644 "/etc/systemd/system/${SERVICE_NAME}.service" |
|
sudo systemctl daemon-reload |
|
sudo systemctl enable "$SERVICE_NAME" |
|
sudo systemctl restart "$SERVICE_NAME" |
|
echo "OK" |
|
|
|
# Step 5: NetworkManager config (prevent NM from touching the WireGuard interface) |
|
echo ">>> [5/6] Configuring NetworkManager..." |
|
NM_CONF="/etc/NetworkManager/conf.d/wgexpressvpn.conf" |
|
if [ ! -f "$NM_CONF" ]; then |
|
printf '[keyfile]\nunmanaged-devices=interface-name:wgexpressvpn*\n' | sudo tee "$NM_CONF" > /dev/null |
|
fi |
|
echo "OK" |
|
|
|
# Step 6: Desktop entry, icon, and CLI symlink (no root needed) |
|
echo ">>> [6/6] Installing desktop entry, icon, and CLI symlink..." |
|
|
|
mkdir -p "$HOME/.local/share/pixmaps" |
|
cp "$EXTRACTED/installfiles/app-icon.png" "$HOME/.local/share/pixmaps/${BRAND}.png" |
|
|
|
mkdir -p "$HOME/.local/share/applications" |
|
# Patch Icon= to use full path since ~/.local/share/pixmaps isn't always searched by name |
|
sed "s|^Icon=.*|Icon=$HOME/.local/share/pixmaps/${BRAND}.png|" \ |
|
"$EXTRACTED/installfiles/${BRAND}.desktop" \ |
|
> "$HOME/.local/share/applications/${BRAND}.desktop" |
|
|
|
# CLI symlink in ~/.local/bin (already in PATH on modern Fedora) |
|
mkdir -p "$HOME/.local/bin" |
|
ln -sf "$INSTALL_DIR/bin/expressvpnctl" "$HOME/.local/bin/expressvpnctl" |
|
ln -sf "$INSTALL_DIR/bin/expressvpnctl" "$HOME/.local/bin/expressvpn" |
|
echo "OK" |
|
|
|
echo "" |
|
echo "=================================" |
|
echo "Installation complete!" |
|
echo "=================================" |
|
echo "" |
|
echo "Activate your subscription:" |
|
echo " expressvpn activate" |
|
echo "" |
|
echo "Or launch the GUI from your app grid." |
|
echo "" |
|
echo "If DNS breaks after an unclean VPN exit, run:" |
|
echo " sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf" |