#!/bin/bash # Self-hosted WireGuard VPN + hardened SSH for remote terminal access from # Android, working across different networks (no relay, no third-party # coordination server -- point-to-point WireGuard via a port-forwarded UDP # port and DuckDNS for the dynamic public IP). # # Usage: # sudo ./setup_wireguard_ssh.sh setup - full first-time setup # sudo ./setup_wireguard_ssh.sh add-peer - provision a new phone/laptop # ./setup_wireguard_ssh.sh status - show current state # sudo ./setup_wireguard_ssh.sh revoke - remove a peer # ./setup_wireguard_ssh.sh help set -euo pipefail SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" # shellcheck source=../../lib/common.sh source "$SCRIPT_DIR/../../lib/common.sh" readonly WG_IFACE="wg0" readonly WG_PORT="51820" readonly WG_SUBNET="10.8.0.0/24" readonly WG_SERVER_IP="10.8.0.1" readonly WG_DIR="/etc/wireguard" readonly WG_CONF="${WG_DIR}/${WG_IFACE}.conf" readonly WG_CLIENTS_DIR="${WG_DIR}/clients" readonly NFT_CONF="/etc/nftables.conf" readonly SSHD_DROPIN="/etc/ssh/sshd_config.d/10-wireguard-only.conf" readonly DUCKDNS_DIR="/opt/duckdns" readonly CONFIG_FILE="${SCRIPT_DIR}/.wireguard_ssh.conf" # Load saved config (DuckDNS domain/token, LAN subnet override) if present. if [[ -f $CONFIG_FILE ]]; then # shellcheck source=/dev/null source "$CONFIG_FILE" fi DUCKDNS_DOMAIN="${DUCKDNS_DOMAIN:-}" DUCKDNS_TOKEN="${DUCKDNS_TOKEN:-}" LAN_SUBNET="${LAN_SUBNET:-}" ALLOW_WEB="${ALLOW_WEB:-false}" die() { log_error "$1" exit 1 } save_config() { cat >"$CONFIG_FILE" </dev/null | awk '{print $7; exit}') if [[ -z $lan_ip ]]; then die "Could not auto-detect LAN IP. Set LAN_SUBNET=192.168.x.0/24 and re-run." fi LAN_SUBNET="${lan_ip%.*}.0/24" log_info "Detected LAN subnet: $LAN_SUBNET" save_config } install_dependencies() { log_info "Installing dependencies (wireguard-tools, qrencode, nftables, openssh)..." install_missing_pacman_packages wireguard-tools qrencode nftables openssh } generate_server_keys() { ensure_dir "$WG_DIR" chmod 700 "$WG_DIR" if [[ -f "${WG_DIR}/server_private.key" ]]; then log_info "Server keypair already exists -- not rotating (would break existing peer configs)." return 0 fi umask 077 wg genkey | tee "${WG_DIR}/server_private.key" | wg pubkey >"${WG_DIR}/server_public.key" log_ok "Generated server keypair." } write_wg0_conf() { if [[ -f $WG_CONF ]]; then log_info "${WG_CONF} already exists -- leaving peers intact, not regenerating." return 0 fi local server_private_key server_private_key=$(<"${WG_DIR}/server_private.key") umask 077 cat >"$WG_CONF" <"${NFT_CONF}.new" <"$SSHD_DROPIN" <<'EOF' # Managed by setup_wireguard_ssh.sh -- drop-in, does not touch sshd_config. PasswordAuthentication no PubkeyAuthentication yes PermitRootLogin no EOF sshd -t || { rm -f "$SSHD_DROPIN" die "sshd config invalid after adding drop-in -- removed it." } systemctl reload sshd log_ok "sshd hardened: key-only auth, no root login." } duckdns_already_updated() { local domain="$1" actual_user line script actual_user=$(get_actual_user) while IFS= read -r line; do [[ $line =~ ^[[:space:]]*(#|$) ]] && continue for script in $line; do [[ -f $script ]] || continue if grep -q "duckdns.org/update?domains=${domain}" "$script" 2>/dev/null; then return 0 fi done done < <(crontab -u "$actual_user" -l 2>/dev/null || true) return 1 } setup_duckdns() { if [[ -z $DUCKDNS_DOMAIN || -z $DUCKDNS_TOKEN ]]; then read -r -p "DuckDNS subdomain (without .duckdns.org): " DUCKDNS_DOMAIN read -r -p "DuckDNS token: " DUCKDNS_TOKEN [[ -n $DUCKDNS_DOMAIN && -n $DUCKDNS_TOKEN ]] || die "Both fields are required." save_config fi if duckdns_already_updated "$DUCKDNS_DOMAIN"; then log_info "An existing cron job already keeps ${DUCKDNS_DOMAIN}.duckdns.org updated -- not adding a duplicate." return 0 fi ensure_dir "$DUCKDNS_DIR" cat >"${DUCKDNS_DIR}/duck.sh" </dev/null || true ) | grep -vF "${DUCKDNS_DIR}/duck.sh" | { cat echo "*/5 * * * * ${DUCKDNS_DIR}/duck.sh >/dev/null 2>&1" } | crontab - log_ok "DuckDNS configured: ${DUCKDNS_DOMAIN}.duckdns.org (refreshed every 5 min via cron)." } next_free_wg_ip() { local used octet used=$(grep -oP 'AllowedIPs\s*=\s*10\.8\.0\.\K[0-9]+' "$WG_CONF" 2>/dev/null || true) for ((octet = 2; octet <= 254; octet++)); do if ! grep -qx "$octet" <<<"$used"; then echo "10.8.0.${octet}" return 0 fi done die "No free IPs left in ${WG_SUBNET}." } add_phone_peer() { local name="${1:?usage: add-peer }" [[ -f $WG_CONF ]] || die "Run 'setup' first -- ${WG_CONF} does not exist yet." [[ -n $DUCKDNS_DOMAIN ]] || die "DuckDNS domain not configured -- run 'setup' first." if grep -q "# peer:${name}\$" "$WG_CONF"; then die "A peer named '${name}' already exists in ${WG_CONF}. Use 'revoke ${name}' first to replace it." fi ensure_dir "$WG_CLIENTS_DIR" chmod 700 "$WG_CLIENTS_DIR" local tmpdir tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"; trap - RETURN' RETURN umask 077 wg genkey | tee "${tmpdir}/priv" | wg pubkey >"${tmpdir}/pub" local client_priv client_pub server_pub peer_ip client_priv=$(<"${tmpdir}/priv") client_pub=$(<"${tmpdir}/pub") server_pub=$(<"${WG_DIR}/server_public.key") peer_ip=$(next_free_wg_ip) cat >>"$WG_CONF" <"$client_conf" <"$tmp" cat "$tmp" >"$WG_CONF" chmod 600 "$WG_CONF" if is_service_active "wg-quick@${WG_IFACE}"; then wg syncconf "$WG_IFACE" <(wg-quick strip "$WG_IFACE") fi rm -f "${WG_CLIENTS_DIR}/${name}.conf" log_ok "Revoked peer '${name}'." } print_router_instructions() { local lan_ip lan_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}') cat < ${lan_ip} (UDP only -- do NOT forward TCP/22). 4. Save (and reboot the router if it requires it). 5. Confirm your ISP gives you a real public IPv4 (not CGNAT): curl -s https://api.ipify.org getent hosts ${DUCKDNS_DOMAIN:-}.duckdns.org These two must match and must NOT be in 100.64.0.0/10 (CGNAT range). EOF } print_android_instructions() { cat < "+" -> "Scan from QR code" -> scan the code printed above. 5. Toggle the tunnel on. 6. From Termux/ConnectBot: ssh $(get_actual_user)@${WG_SERVER_IP} EOF } allow_web() { ALLOW_WEB=true save_config write_nftables_ruleset verify_nftables_then_apply log_ok "Opened tcp/80 and tcp/443 in the input chain (persisted -- future 'setup' re-runs keep this rule)." } status_cmd() { echo "=== WireGuard ===" wg show 2>/dev/null || echo "(interface not up)" echo echo "=== wg-quick@${WG_IFACE} service ===" systemctl status "wg-quick@${WG_IFACE}" --no-pager 2>/dev/null || echo "(not installed)" echo echo "=== nftables (input chain) ===" nft list ruleset 2>/dev/null | sed -n '/chain input/,/}/p' || echo "(nftables not active)" echo echo "=== sshd password auth ===" sshd -T 2>/dev/null | grep -i passwordauthentication || echo "(could not query sshd)" } usage() { cat < [args] Commands: setup Full first-time setup (WireGuard, firewall, sshd, DuckDNS). add-peer Provision a new phone/laptop and print its QR code. allow-web Open tcp/80 and tcp/443 in the firewall (for a web server on this host). status Show WireGuard/firewall/sshd status. revoke Remove a peer. help Show this message. EOF } main() { local cmd="${1:-help}" # Forward the FULL original argv to require_root before shifting anything # off -- exec sudo "$0" "$@" inside require_root must re-launch with the # subcommand still present, or sudo would silently run with no args. case "$cmd" in setup | add-peer | revoke | allow-web) require_root "$@" ;; esac shift || true case "$cmd" in setup) install_dependencies generate_server_keys write_wg0_conf enable_wg_service write_nftables_ruleset verify_nftables_then_apply harden_sshd setup_duckdns print_router_instructions print_android_instructions log_ok "Setup complete. Run 'add-peer ' to provision your phone." ;; add-peer) add_phone_peer "${1:-}" print_android_instructions ;; allow-web) allow_web ;; status) status_cmd ;; revoke) revoke_peer "${1:-}" ;; help | -h | --help) usage ;; *) log_error "Unknown command: $cmd" usage exit 1 ;; esac } main "$@"