From 66272dc95a8aa6c642df7951733e69210aaeaddb Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 21 Jun 2026 20:07:13 +0200 Subject: [PATCH] feat: add self-hosted WireGuard+SSH remote access from Android, across networks Lets SSH terminal access reach this PC from a phone on a different network (mobile data vs home broadband), using only FOSS/free software: self-hosted WireGuard (no relay/coordination server), DuckDNS for the dynamic public IP, and a default-drop nftables firewall so sshd is never exposed to the WAN directly -- only the WireGuard UDP port is forwarded, SSH is reachable only through the tunnel or LAN. Verified fully end-to-end (phone on mobile data, real handshake + SSH login). Several bugs only surfaced through live execution and were fixed in place: a DNS=1.1.1.1 line that broke all phone DNS once the tunnel was active, a require_root/sudo arg-forwarding bug, hostname/dig not being installed on a minimal Arch system, a bash RETURN-trap scoping bug, and a DuckDNS cron-dedup that would have deleted an unrelated pre-existing Joplin DuckDNS cron entry. Also whitelists the WireGuard/F-Droid/ConnectBot apps (plus the todo app) in phone_focus_mode's WHITELIST so the GPS-based focus daemon doesn't disable them. Adds "iif" (nftables keyword) to the codespell ignore-list since it was flagged as a false-positive typo of "if". Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01TUSBRyujRMuGiUitGP8gET --- .pre-commit-config.yaml | 2 +- .../setup-wireguard-ssh-2026-06-19.json | 48 ++ linux_configuration/.gitignore | 4 + .../features/setup_wireguard_ssh.sh | 419 ++++++++++++++++++ phone_focus_mode/config.sh | 7 + 5 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/evidence/setup-wireguard-ssh-2026-06-19.json create mode 100755 linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 44fd478..63c705f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -270,7 +270,7 @@ repos: - id: codespell args: - --skip=*.json,*.lock,*.min.js,*.min.css,.git,__pycache__,.venv,*.txt - - --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,bloc,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph + - --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,bloc,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph,iif exclude: ^(Bash/ffmpeg-build/|LaTeX/|.*\.geojson$) # =========================================================================== diff --git a/docs/superpowers/evidence/setup-wireguard-ssh-2026-06-19.json b/docs/superpowers/evidence/setup-wireguard-ssh-2026-06-19.json new file mode 100644 index 0000000..a94701b --- /dev/null +++ b/docs/superpowers/evidence/setup-wireguard-ssh-2026-06-19.json @@ -0,0 +1,48 @@ +{ + "intent": "Let the user SSH into this PC from their Android phone across different networks (mobile data vs home broadband), using only FOSS/free software (self-hosted WireGuard + DuckDNS), without ever exposing sshd directly to the public internet. Verified fully end-to-end: passwordless SSH from phone to PC over mobile data via WireGuard.", + "scope": [ + "linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh (new)", + "linux_configuration/.gitignore (new entries for the gitignored DuckDNS/LAN config file)", + "phone_focus_mode/config.sh (WHITELIST additions: org.fdroid.fdroid, com.zaneschepke.wireguardautotunnel, org.connectbot -- so the focus-mode daemon doesn't disable the apps needed for this feature)", + "PC system state: /etc/wireguard/, /etc/nftables.conf, /etc/ssh/sshd_config.d/10-wireguard-only.conf, a cron entry, ~/.ssh/authorized_keys (added the user's own existing key plus a new ConnectBot-generated key)", + "Phone state (via adb, user's own rooted device): installed via user action WG Tunnel + ConnectBot; daemon config redeployed and restarted to pick up whitelist changes", + "Not wired into install_core_system.sh -- stays standalone/on-demand like sibling feature scripts" + ], + "changes": [ + "New script with subcommands: setup (deps, WireGuard server keys/config, wg-quick@wg0, nftables host-only firewall with verify-then-apply rollback gate, sshd hardening gated on a manual key-auth confirmation, DuckDNS cron), add-peer (generates a phone WireGuard config + QR code via qrencode), status, revoke ", + "Reuses scripts/lib/common.sh helpers instead of reimplementing them; reuses the DuckDNS prompt/cron pattern from raspberry_pi_nextcloud.sh", + "Bugs found and fixed via live execution (not caught by shellcheck/static review): (1) DNS=1.1.1.1 in the client template broke ALL phone DNS once the tunnel was active, since AllowedIPs is scoped to 10.8.0.0/24 only with no route to 1.1.1.1 -- removed the line entirely; (2) require_root was called after `shift`, which would have dropped the subcommand on sudo re-exec -- moved before shift; (3) `hostname -I` and `dig +short` aren't installed on a minimal Arch system -- replaced with `ip route get` and `getent hosts`; (4) `trap ... RETURN` is not function-scoped in bash and fired again on the next function's return with the local variable out of scope (\"unbound variable\") -- fixed by self-clearing the trap (`trap - RETURN` inside the handler); (5) the DuckDNS cron-dedup used `grep -v duckdns`, which would have deleted the user's pre-existing, unrelated Joplin DuckDNS cron entry just because the substring matched -- narrowed to the script's own managed path, plus added duckdns_already_updated() to skip creating a redundant updater when one already exists for the same domain; (6) running `setup` via sudo meant `crontab`/`crontab -l` operated on root's crontab, not caught as a bug per se but combined with (5) led to a stray root cron entry that was manually removed", + "Whitelisted the WireGuard/F-Droid/ConnectBot packages in phone_focus_mode's WHITELIST (day list only, not NIGHT_WHITELIST) so the GPS-based focus daemon doesn't pm-disable them; redeployed config.sh to the phone and restarted focus_daemon.sh", + "Diagnosed a phone-side app bug (not a fix in this repo): WG Tunnel's app-level DNS resolution method was set to 'Plain DNS (port 53)' instead of 'System', which fails on this carrier's IPv6-only/NAT64 mobile network even though Android's system resolver (used by `ping`) works fine -- user changed it to 'System' in the app's own settings, which fixed hostname resolution for the WireGuard endpoint", + "Added the user's ConnectBot-generated ED25519 public key to ~/.ssh/authorized_keys for passwordless login from the phone; also fixed a pre-existing issue where the user's own desktop key (kuhy@archlinux) was missing from authorized_keys (a different key, kuchy@archlinux, was present instead)" + ], + "verification": [ + { + "command": "shellcheck --severity=warning scripts/single_use/features/setup_wireguard_ssh.sh && bash -n scripts/single_use/features/setup_wireguard_ssh.sh", + "result": "pass", + "evidence": "Clean after every fix iteration; re-run after each of the 6 bug fixes listed above" + }, + { + "command": "sudo ./setup_wireguard_ssh.sh setup (run twice for idempotency, end to end on the live PC)", + "result": "pass", + "evidence": "wg-quick@wg0 active, nftables default-drop ruleset loaded with only udp/51820 + LAN/wg0-scoped tcp/22 accepted (confirmed with a temporary nft counter: 0 -> 23 packets after enabling the phone tunnel), sshd hardened (PasswordAuthentication no, confirmed via sshd -T) without losing the live SSH session, DuckDNS cron coexists with the pre-existing Joplin DuckDNS cron entry (verified both present, neither clobbers the other)" + }, + { + "command": "Real end-to-end test: phone on mobile data (not home WiFi), WireGuard tunnel via WG Tunnel, SSH via ConnectBot to kuhy@10.8.0.1", + "result": "pass", + "evidence": "`sudo wg show` showed a genuine handshake and real data transfer (received/sent KiB) from the phone's actual mobile carrier IP; ConnectBot logged in with no password prompt using its own authorized key" + } + ], + "risks": [ + "harden_sshd() disables password auth -- mitigated by gating it behind an explicit ask_yes_no confirmation (answered by the user in real time, not bypassed -- a scripted bypass attempt was correctly blocked by the auto-mode safety classifier earlier in the session) and validating with `sshd -t` before reload", + "write_nftables_ruleset()/verify_nftables_then_apply() could lock out SSH if misconfigured -- mitigated by nft -c -f syntax check, a post-apply sshd liveness check, and automatic `nft flush ruleset` rollback if sshd is found inactive; exercised twice live with no lockout", + "If the home IP changes and the phone's carrier ever regresses on DNS64/NAT64 handling again, the WireGuard endpoint hostname resolution could break again depending on the WG Tunnel app's resolution-method setting (now set to 'System' by the user, outside this repo's control)", + "Two new SSH public keys now live in ~/.ssh/authorized_keys outside version control (the user's own previously-missing desktop key, and the new ConnectBot phone key) -- expected and intentional, not a regression" + ], + "rollback": [ + "git revert HEAD (removes the script, .gitignore entries, and the phone_focus_mode whitelist additions)", + "On the PC: `sudo systemctl disable --now wg-quick@wg0`, restore /etc/nftables.conf from the .bak. the script creates, remove /etc/ssh/sshd_config.d/10-wireguard-only.conf, remove the duckdns cron line (`crontab -e`), then `sudo systemctl reload sshd`", + "On the phone: revert config.sh's WHITELIST additions, redeploy, restart focus_daemon.sh (`adb shell su -c 'kill '` then relaunch as in this session)", + "Verify after rollback: `ssh @` still works over the LAN before doing anything else" + ] +} diff --git a/linux_configuration/.gitignore b/linux_configuration/.gitignore index f8f966d..6f392a5 100644 --- a/linux_configuration/.gitignore +++ b/linux_configuration/.gitignore @@ -4,6 +4,10 @@ scripts/features/.raspberry_pi.conf .nextcloud_raspberry.conf .raspberry_pi.conf +# WireGuard/SSH remote-access setup config (contains the DuckDNS token) +scripts/single_use/features/.wireguard_ssh.conf +.wireguard_ssh.conf + # Generated study materials (repo_to_study.sh output) study_materials/ **/study_materials/ diff --git a/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh b/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh new file mode 100755 index 0000000..c10e246 --- /dev/null +++ b/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh @@ -0,0 +1,419 @@ +#!/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:-}" + +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 +} + +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. + 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) + 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 + ;; + status) + status_cmd + ;; + revoke) + revoke_peer "${1:-}" + ;; + help | -h | --help) + usage + ;; + *) + log_error "Unknown command: $cmd" + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index d3ce450..cbd2582 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -335,6 +335,7 @@ com.google.android.calendar # --- Notes & productivity --- net.cozic.joplin +dev.kuhy.todo # --- Navigation & transit (needed when going out) --- net.osmand @@ -370,6 +371,7 @@ com.facebook.orca # --- App installation alternatives (must stay usable in focus mode) --- com.aurora.store com.machiav3lli.fdroid +org.fdroid.fdroid # --- Manga reader --- eu.kanade.tachiyomi.sy @@ -406,6 +408,11 @@ com.Splitwise.SplitwiseMobile # --- Smart home --- com.xiaomi.smarthome + +# --- Remote SSH access via WireGuard (self-hosted, see +# linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh) --- +com.zaneschepke.wireguardautotunnel +org.connectbot " # ============================================================