A systemd service that monitors WireGuard VPN connectivity, manages NAT-PMP port mappings, and automatically keeps qBittorrent's listening port synchronized.
Designed for use with ProtonVPN's port forwarding feature, but should work with any VPN provider that supports NAT-PMP.
- Monitors WireGuard interface health (interface state, IP assignment, gateway reachability)
- Requests NAT-PMP port mappings via
natpmpc - Automatically updates qBittorrent's listening port when the mapped port changes
- Ensures qBittorrent is bound to the VPN interface
- Optional iptables/ip6tables killswitch (both IPv4 and IPv6) to prevent leaks
- State machine with automatic recovery from failures
- systemd integration with watchdog support
- State persistence across restarts
- Python 3.10+
natpmpc(from libnatpmp)wireguard-tools(forwgcommand)- A running WireGuard VPN with NAT-PMP support
- qBittorrent with Web UI enabled
The installer will guide you through the setup interactively:
curl -fsSL https://raw.githubusercontent.com/vegardx/qbouncer/main/scripts/install.sh | bashSupply chain note:
curl | bashtrusts whateverraw.githubusercontent.comserves at request time. For stronger guarantees, download first and verify the published checksum before executing:curl -fsSLo install.sh https://raw.githubusercontent.com/vegardx/qbouncer/main/scripts/install.sh # Check against the checksum published on the release page before running: sha256sum install.sh sudo bash install.sh
Or with options for non-interactive install. Pass credentials via environment
variables, not CLI flags, so they don't show up in ps:
QBT_PASSWORD="..." curl -fsSL https://raw.githubusercontent.com/vegardx/qbouncer/main/scripts/install.sh | \
sudo -E bash -s -- \
--non-interactive \
--force-config \
--wg-interface wg0 \
--gateway 10.2.0.1 \
--qbt-host localhost \
--qbt-port 8080 \
--qbt-username admin \
--killswitch \
--killswitch-user qbittorrentThe installer is idempotent and can be run multiple times safely to upgrade or reconfigure.
# Install system dependencies (Debian/Ubuntu)
apt install python3 python3-venv libnatpmp1 wireguard-tools iptables git
# Clone the repository
git clone https://github.com/vegardx/qbouncer.git
cd qbouncer
# Create service user and matching group
useradd -r -U -s /usr/sbin/nologin qbouncer
# Create installation directory and virtual environment
mkdir -p /opt/qbouncer
python3 -m venv /opt/qbouncer/venv
# Install qbouncer into the virtual environment
/opt/qbouncer/venv/bin/pip install .
# Setup configuration (readable by the service user's group)
mkdir -p /etc/qbouncer
cp config/qbouncer.toml.example /etc/qbouncer/qbouncer.toml
chown root:qbouncer /etc/qbouncer /etc/qbouncer/qbouncer.toml
chmod 750 /etc/qbouncer
chmod 640 /etc/qbouncer/qbouncer.toml
$EDITOR /etc/qbouncer/qbouncer.toml
# Install and enable systemd service
cp systemd/qbouncer.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable qbouncer
systemctl start qbouncersudo bash scripts/install.sh --uninstallAdd --purge to remove the configuration directory, state directory, and
service user without prompting.
Configuration file: /etc/qbouncer/qbouncer.toml
See config/qbouncer.toml.example for the
full list of options with inline documentation. Minimal working config:
[wireguard]
interface = "wg0"
health_check_host = "10.2.0.1"
[natpmp]
gateway = "10.2.0.1"
[qbittorrent]
host = "localhost"
port = 8080
username = "admin"
# password = "" # See "Credentials" below for the safer options
interface_binding = "wg0"
[killswitch]
enabled = false # Set true + user = "..." to enable the killswitchNotable options documented in the example but worth calling out:
qbt_use_https + qbt_ca_bundle for a CA-pinned HTTPS connection to
qBittorrent, availability_poll_interval / failure_retry_delay to tune
cadence, and [killswitch] to enable the iptables/ip6tables killswitch.
Every Config field has a QBOUNCER_<FIELD_NAME_UPPER> env var that
overrides the TOML value. Examples:
QBOUNCER_WG_INTERFACE=wg0
QBOUNCER_NATPMP_GATEWAY=10.2.0.1
QBOUNCER_QBT_PORT=8080
QBOUNCER_LOG_LEVEL=DEBUGThree ways to supply the qBittorrent password, in increasing preference:
- In the TOML file — convenient, but the secret sits on disk in a config directory readable by the service group.
QBOUNCER_QBT_PASSWORDenv var — useful for non-interactive installs. Be careful not to leak it into shell history.QBOUNCER_QBT_PASSWORD_FILEenv var (recommended) — points at a file whose first line is the password. The systemd unit ships with a commented-outLoadCredential=qbt_password:/etc/qbouncer/credentials/qbt_passwordline + matchingEnvironment=QBOUNCER_QBT_PASSWORD_FILE=%d/qbt_password. Uncomment both, write the password to that file (mode0600), and the daemon reads it through systemd's per-service credentials tmpfs. This path beats both of the above when set.
# Start the service
systemctl start qbouncer
# Check status
systemctl status qbouncer
# View logs
journalctl -u qbouncer -f
# Stop the service
systemctl stop qbouncer# Run with default config
/opt/qbouncer/venv/bin/qbouncer
# Run with custom config file
/opt/qbouncer/venv/bin/qbouncer --config /path/to/config.toml
# Override the log level (DEBUG, INFO, WARNING, ERROR)
/opt/qbouncer/venv/bin/qbouncer --log-level DEBUG
# Shortcut for --log-level DEBUG
/opt/qbouncer/venv/bin/qbouncer -v
# Print version and exit
/opt/qbouncer/venv/bin/qbouncer --version- VPN Monitoring: Checks that the WireGuard interface is UP, has an IP address, and can reach the gateway
- Port Mapping: Requests TCP and UDP port mappings from the NAT-PMP gateway every 60 seconds
- qBittorrent Sync: If the mapped port changes, updates qBittorrent's listening port via its Web API
- Interface Binding: Verifies qBittorrent is bound to the VPN interface to prevent IP leaks
sequenceDiagram
participant S as qbouncer
participant KS as iptables/ip6tables
participant WG as WireGuard
participant NAT as NAT-PMP Gateway
participant QB as qBittorrent
Note over S,KS: On startup (if killswitch enabled)
S->>KS: Install chain + jump (fail-closed)
loop Wait for VPN
S->>WG: Check interface UP?
S->>WG: Ping gateway via iface
end
S->>QB: Check API available?
QB-->>S: OK (version)
loop Steady state
S->>NAT: Request TCP + UDP mapping
NAT-->>S: Public port 12345
alt Port or interface drift
S->>QB: Set listen_port=12345, interface=wg0
QB-->>S: OK
end
S->>WG: Periodic health check
S->>QB: Verify reachable + still bound to iface
S->>KS: Verify chain + jump still present
end
Note over S,KS: On SIGTERM/SIGINT
S->>S: Save state
S->>QB: Logout (invalidate session)
S->>KS: Tear down chain + jump
# Verify interface is up
ip link show wg0
# Check connectivity
ping -I wg0 10.2.0.1
# Verify handshake
wg show wg0# Request a port mapping manually
natpmpc -a 1 0 tcp 60 -g 10.2.0.1# Log in (replace with your credentials) and keep the session cookie
curl -c /tmp/qbt.cookies -d 'username=admin&password=yourpass' \
http://localhost:8080/api/v2/auth/login
# Now query with the cookie
curl -b /tmp/qbt.cookies http://localhost:8080/api/v2/app/preferences \
| jq .listen_portIf the Web UI has authentication disabled (it does by default on localhost),
drop the first call and just hit /api/v2/app/preferences directly.
# Full logs
journalctl -u qbouncer
# Follow logs
journalctl -u qbouncer -f
# Logs since last boot
journalctl -u qbouncer -bMIT License - see LICENSE for details.