mirror of
https://github.com/bivlked/amneziawg-installer.git
synced 2026-10-19 05:03:18 +00:00
Reported by @humowns in discussion #38. The installer writes AllowedIPs and DNS as `a, b, c`. regenerate_client read those values out of the client .conf through tr -d '[:space:]', which strips the spaces along with the carriage return, and wrote back what it had read. The first regen collapsed the list to `a,b,c` and every later reissue kept it that way. The difference is invisible to the eye and covers 34 entries in the default routing mode. manage modify did the same from the other side: the value was validated element by element and then written verbatim. Reading now normalises to the canonical form through a shared awg_normalize_csv helper, which has one property worth calling out: a repeated regen also repairs configs that earlier versions had already collapsed, so nobody has to edit files by hand. The vpn:// builder deliberately keeps stripping spaces before it builds the structured allowed_ips array, and a test guards that now. The first pass of this branch normalised there too, which was wrong: the value feeds split(/,/), so the canonical form put a leading space inside every element but the first, and the Amnezia GUI client reads that array without trimming. Measured on a live server, 33 of 34 entries came out with a stray space. A collapsed link always traces back to a .conf that regen had already collapsed, because the embedded config is inlined from the file as it is. A review of the branch found that the first pass had traded two loud failures for quiet ones. Repeated AllowedIPs or DNS lines, which wg allows and adds up, were truncated to the first line, handing back a valid config with part of the networks gone; they are joined into one list now and the join itself is reported. And modify could blank a setting and delete the backup, because the post-write check accepted a bare `Key = ` prefix while awg_normalize_csv arrived in a patch release and _check_common_compat deliberately tolerates a patch-level drift between manage and the library. It verifies the helper is present and requires a non-empty value now. Verified on a live server rather than in unit tests alone: 5.27.0 collapsed a healthy 34-entry list, this branch restored that damaged config byte for byte, and the allowed_ips array inside the regenerated link stayed clean. Suite: 1319 passing, 0 failing, 0 skipped on Linux.
4334 lines
198 KiB
Bash
4334 lines
198 KiB
Bash
#!/bin/bash
|
||
|
||
# Minimum Bash version check
|
||
if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then
|
||
echo "ERROR: Bash >= 4.0 required (current: ${BASH_VERSION})" >&2; exit 1
|
||
fi
|
||
|
||
# ==============================================================================
|
||
# AmneziaWG 2.0 installation and configuration script for Ubuntu/Debian servers
|
||
# Author: @bivlked
|
||
# Version: 5.27.1
|
||
# Date: 2026-08-22
|
||
# Repository: https://github.com/bivlked/amneziawg-installer
|
||
# ==============================================================================
|
||
|
||
# --- Safe mode and Constants ---
|
||
set -o pipefail
|
||
SCRIPT_VERSION="5.27.1"
|
||
|
||
AWG_DIR="/root/awg"
|
||
CONFIG_FILE="$AWG_DIR/awgsetup_cfg.init"
|
||
STATE_FILE="$AWG_DIR/setup_state"
|
||
LOG_FILE="$AWG_DIR/install_amneziawg.log"
|
||
KEYS_DIR="$AWG_DIR/keys"
|
||
SERVER_CONF_FILE="/etc/amnezia/amneziawg/awg0.conf"
|
||
AWG_BRANCH="${AWG_BRANCH:-v${SCRIPT_VERSION}}"
|
||
COMMON_SCRIPT_URL="https://raw.githubusercontent.com/bivlked/amneziawg-installer/${AWG_BRANCH}/awg_common_en.sh"
|
||
COMMON_SCRIPT_PATH="$AWG_DIR/awg_common.sh"
|
||
MANAGE_SCRIPT_URL="https://raw.githubusercontent.com/bivlked/amneziawg-installer/${AWG_BRANCH}/manage_amneziawg_en.sh"
|
||
MANAGE_SCRIPT_PATH="$AWG_DIR/manage_amneziawg.sh"
|
||
|
||
# SHA256 checksums of downloaded scripts. Updated at each release.
|
||
# Verified in step5_download_scripts() after curl.
|
||
# Verification is skipped when AWG_BRANCH is overridden (test branch).
|
||
# Format: sha256sum output (hex, 64 chars).
|
||
COMMON_SCRIPT_SHA256="5df976a5388cba54af7a89bd743bcd07e1ccb5793b91c09fb0d11481ccff35f5"
|
||
MANAGE_SCRIPT_SHA256="251644ac15a2b1e9ffa0a1f5fd24a29e21bf3816ce97ca642be3e881c848d105"
|
||
|
||
# AmneziaWG 2.0 pin (H0, 31 jul 2026). Upstream merged AmneziaWG 3.0 into the
|
||
# amneziawg-linux-kernel-module default branch, and the PPA switched to it. Back
|
||
# then the PPA DKMS build failed on nla_put_uint on kernels older than 6.7
|
||
# (Debian 12 = 6.1). Upstream fixed that the same evening on 31 jul
|
||
# (v3.0.20260731-04; verified by us on Debian 12 / 6.1.0-51 on 1 aug), so the pin
|
||
# went from FORCED to DELIBERATE: on older kernels we keep the module we have
|
||
# tested until 3.0 is validated separately. AWG2_PIN_COMMIT is checked after clone
|
||
# (integrity: more robust than a fragile tarball SHA - a tag can be moved, an
|
||
# immutable commit cannot).
|
||
AWG2_PIN_TAG="v1.0.20260725"
|
||
AWG2_PIN_COMMIT="ae0924ca700520ca34c5bdbcfd05b2f683ea9353"
|
||
|
||
# CLI flags
|
||
UNINSTALL=0; HELP=0; HELP_EXIT_RC=0; DIAGNOSTIC=0; VERBOSE=0; NO_COLOR=0; AUTO_YES=0; NO_TWEAKS=0; NO_CPS=0; KEEP_PACKAGES=""
|
||
FORCE_REINSTALL=0
|
||
_APT_UPDATED=0
|
||
CLI_PORT=""; CLI_SUBNET=""; CLI_DISABLE_IPV6="default"; CLI_SSH_PORT=""
|
||
CLI_ROUTING_MODE="default"; CLI_CUSTOM_ROUTES=""; CLI_ENDPOINT=""; CLI_NO_TWEAKS=0; CLI_NO_CPS=0; CLI_KEEP_PACKAGES=0
|
||
CLI_ALLOW_IPV6_TUNNEL=0
|
||
CLI_ISOLATION="default"
|
||
CLI_SERVER_NAME=""
|
||
CLI_MOBILE=0
|
||
|
||
# --- Auto-cleanup of temporary files ---
|
||
_install_temp_files=()
|
||
_install_cleaned=0
|
||
_install_cleanup() {
|
||
# Idempotent: on INT/TERM it is called from the signal handler, then again on
|
||
# EXIT - the second call must be a no-op.
|
||
[[ "$_install_cleaned" -eq 1 ]] && return 0
|
||
_install_cleaned=1
|
||
local f
|
||
for f in "${_install_temp_files[@]}"; do [[ -f "$f" ]] && rm -f "$f"; done
|
||
# Clean up temporary files from awg_common.sh (if already sourced)
|
||
type _awg_cleanup &>/dev/null && _awg_cleanup
|
||
}
|
||
# On INT/TERM the cleanup used to run but the script did NOT exit - execution
|
||
# continued past the interrupted command (dangerous mid apt/dpkg/config edits)
|
||
# and cleanup ran again on EXIT. A signal now means cleanup + explicit 130/143.
|
||
_install_on_signal() {
|
||
_install_cleanup
|
||
exit "$1"
|
||
}
|
||
trap _install_cleanup EXIT
|
||
trap '_install_on_signal 130' INT
|
||
trap '_install_on_signal 143' TERM
|
||
|
||
# --- Argument processing ---
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
--uninstall) UNINSTALL=1 ;;
|
||
--help|-h) HELP=1 ;;
|
||
--diagnostic) DIAGNOSTIC=1 ;;
|
||
--verbose|-v) VERBOSE=1 ;;
|
||
--no-color) NO_COLOR=1 ;;
|
||
--port=*) CLI_PORT="${1#*=}" ;;
|
||
--ssh-port=*) CLI_SSH_PORT="${1#*=}" ;;
|
||
--subnet=*) CLI_SUBNET="${1#*=}" ;;
|
||
--allow-ipv6) CLI_DISABLE_IPV6=0 ;;
|
||
--disallow-ipv6) CLI_DISABLE_IPV6=1 ;;
|
||
--allow-ipv6-tunnel) CLI_ALLOW_IPV6_TUNNEL=1 ;;
|
||
--route-all) CLI_ROUTING_MODE=1 ;;
|
||
--route-amnezia) CLI_ROUTING_MODE=2 ;;
|
||
--route-custom=*) CLI_ROUTING_MODE=3; CLI_CUSTOM_ROUTES="${1#*=}" ;;
|
||
--isolation=*) CLI_ISOLATION="${1#*=}" ;;
|
||
--endpoint=*) CLI_ENDPOINT="${1#*=}" ;;
|
||
--server-name=*) CLI_SERVER_NAME="${1#*=}" ;;
|
||
--mobile) CLI_MOBILE=1 ;;
|
||
--yes|-y) AUTO_YES=1 ;;
|
||
--no-tweaks) NO_TWEAKS=1; CLI_NO_TWEAKS=1 ;;
|
||
--no-cps) NO_CPS=1; CLI_NO_CPS=1 ;;
|
||
--keep-packages) KEEP_PACKAGES=1; CLI_KEEP_PACKAGES=1 ;;
|
||
--force|-f) FORCE_REINSTALL=1 ;;
|
||
--preset=*) CLI_PRESET="${1#*=}" ;;
|
||
--jc=*) CLI_JC="${1#*=}" ;;
|
||
--jmin=*) CLI_JMIN="${1#*=}" ;;
|
||
--jmax=*) CLI_JMAX="${1#*=}" ;;
|
||
*) echo "Unknown argument: $1" >&2; HELP=1; HELP_EXIT_RC=1 ;;
|
||
esac
|
||
shift
|
||
done
|
||
|
||
# ==============================================================================
|
||
# Logging functions
|
||
# ==============================================================================
|
||
|
||
log_msg() {
|
||
local type="$1" msg="$2"
|
||
local ts
|
||
ts=$(date +'%F %T')
|
||
local entry="[$ts] $type: $msg"
|
||
local color_start="" color_end=""
|
||
|
||
if [[ "$NO_COLOR" -eq 0 ]]; then
|
||
color_end="\033[0m"
|
||
case "$type" in
|
||
INFO) color_start="\033[0;32m" ;;
|
||
WARN) color_start="\033[0;33m" ;;
|
||
ERROR) color_start="\033[1;31m" ;;
|
||
DEBUG) color_start="\033[0;36m" ;;
|
||
*) color_start=""; color_end="" ;;
|
||
esac
|
||
fi
|
||
|
||
if ! mkdir -p "$(dirname "$LOG_FILE")" || ! echo "$entry" >> "$LOG_FILE"; then
|
||
echo "[$ts] ERROR: Log write error $LOG_FILE" >&2
|
||
fi
|
||
|
||
if [[ "$type" == "ERROR" || "$type" == "WARN" ]]; then
|
||
printf "${color_start}%s${color_end}\n" "$entry" >&2
|
||
elif [[ "$type" == "DEBUG" && "$VERBOSE" -eq 1 ]]; then
|
||
printf "${color_start}%s${color_end}\n" "$entry" >&2
|
||
elif [[ "$type" == "INFO" ]]; then
|
||
printf "${color_start}%s${color_end}\n" "$entry"
|
||
elif [[ "$type" != "DEBUG" ]]; then
|
||
printf "${color_start}%s${color_end}\n" "$entry"
|
||
fi
|
||
}
|
||
|
||
log() { log_msg "INFO" "$1"; }
|
||
log_warn() { log_msg "WARN" "$1"; }
|
||
log_error() { log_msg "ERROR" "$1"; }
|
||
log_debug() { if [[ "$VERBOSE" -eq 1 ]]; then log_msg "DEBUG" "$1"; fi; }
|
||
die() { log_error "CRITICAL ERROR: $1"; log_error "Installation aborted. Log: $LOG_FILE"; exit 1; }
|
||
|
||
# ==============================================================================
|
||
# apt-get update wrapper that tolerates 404s only for source packages (deb-src).
|
||
# INLINE: needed in steps 1-2 before awg_common.sh is downloaded (Step 5).
|
||
# Some mirrors (Hetzner, AWS) do not serve source packages, but the default
|
||
# ubuntu.sources contains 'Types: deb deb-src'. We do not need source packages
|
||
# (kernel module is built via DKMS using binary headers), so such 404s are safe
|
||
# to ignore. Returns 0 if update succeeded OR if all errors are on source markers.
|
||
# Any other error (GPG, binary-package network, silent crash / OOM / SIGKILL) → non-zero.
|
||
# ==============================================================================
|
||
apt_update_tolerant() {
|
||
# --ppa-amnezia-tolerant: also ignore errors from the Amnezia PPA. Used
|
||
# in step 2 — apt_wait_for_ppa_package below already retries for the
|
||
# ppa.launchpadcontent.net outage scenario (issue #68). Without this
|
||
# flag we must fail fast on any non-source error, otherwise the script
|
||
# would continue installing on a stale apt-cache (PR #69 review finding).
|
||
local ppa_tolerant=0
|
||
if [[ "${1:-}" == "--ppa-amnezia-tolerant" ]]; then
|
||
ppa_tolerant=1
|
||
shift
|
||
fi
|
||
|
||
local err_output rc non_src_errors raw_had_non_src_errors=0
|
||
err_output=$(LANG=C LC_ALL=C apt-get update -y 2>&1)
|
||
rc=$?
|
||
echo "$err_output"
|
||
|
||
if [[ $rc -eq 0 ]]; then
|
||
return 0
|
||
fi
|
||
|
||
# Filter error lines. Ignore:
|
||
# 1. Lines about source packages (deb-src / /source/ / Sources)
|
||
# 2. Generic 'Some index files failed to download' — symptom, not cause
|
||
# Additionally exclude known informational W: lines that are never the
|
||
# CAUSE of rc!=0 but used to survive the filters and turn a tolerable
|
||
# failure (e.g. deb-src 404 with duplicated sources) into a false fatal:
|
||
# - "Target ... is configured multiple times" (duplicate sources entries)
|
||
# - "... stored in legacy trusted.gpg keyring" (old key format)
|
||
non_src_errors=$(printf '%s\n' "$err_output" \
|
||
| grep -E '^(E:|Err:|W:)' \
|
||
| grep -vE '(deb-src|/source/|Sources([^[:alpha:]]|$))' \
|
||
| grep -vE 'Some index files failed to download' \
|
||
| grep -vE '^W: (Target .* is configured multiple times|.* stored in legacy trusted\.gpg)' || true)
|
||
|
||
# Remember pre-PPA-filter state — we need to distinguish "real APT errors,
|
||
# but all on Amnezia PPA" (tolerant OK) from "no classifiable errors at all"
|
||
# (OOM / silent crash — NOT tolerant even if the output happens to mention
|
||
# a PPA URL elsewhere).
|
||
[[ -n "$non_src_errors" ]] && raw_had_non_src_errors=1
|
||
|
||
# Optional (step 2): drop errors that are only on the Amnezia PPA — they
|
||
# will be re-checked via apt_wait_for_ppa_package against apt-cache (issue #68).
|
||
if [[ $ppa_tolerant -eq 1 && -n "$non_src_errors" ]]; then
|
||
non_src_errors=$(printf '%s\n' "$non_src_errors" \
|
||
| grep -vE 'ppa\.launchpadcontent\.net.*amnezia' || true)
|
||
fi
|
||
|
||
if [[ -z "$non_src_errors" ]]; then
|
||
# Edge case: rc != 0 but no classifiable E:/Err:/W: lines found
|
||
# (OOM-killer SIGKILL, silent crash, unknown apt output format).
|
||
# Ignore ONLY if the output actually contains source-markers, or if
|
||
# ppa-tolerant + there were real APT lines and all of them were on the
|
||
# Amnezia PPA.
|
||
if printf '%s\n' "$err_output" | grep -qE '(deb-src|/source/|Sources([^[:alpha:]]|$))'; then
|
||
log_warn "apt update: source packages unavailable in mirror (expected, ignored)"
|
||
return 0
|
||
fi
|
||
if [[ $ppa_tolerant -eq 1 && $raw_had_non_src_errors -eq 1 ]] \
|
||
&& printf '%s\n' "$err_output" | grep -qE 'ppa\.launchpadcontent\.net.*amnezia'; then
|
||
log_warn "apt update: errors only on Amnezia PPA (issue #68), continuing with retry."
|
||
return 0
|
||
fi
|
||
log_error "apt update exited with rc=$rc without any classifiable APT lines — possible silent crash / OOM / SIGKILL"
|
||
return "$rc"
|
||
fi
|
||
|
||
log_error "apt update failed with non-source errors:"
|
||
printf '%s\n' "$non_src_errors" | while IFS= read -r line; do
|
||
log_error " $line"
|
||
done
|
||
return "$rc"
|
||
}
|
||
|
||
# ==============================================================================
|
||
# apt_wait_for_ppa_package <package> [max_attempts] [initial_delay_seconds]
|
||
# Waits until the given package becomes visible in apt-cache, with
|
||
# exponential backoff between attempts. Needed in step 2 after the
|
||
# Amnezia PPA is added: ppa.launchpadcontent.net sometimes briefly
|
||
# goes down (issue #68), and without retries the first cold install
|
||
# fails even though the PPA is back a minute later.
|
||
#
|
||
# IMPORTANT: this checks apt-cache show, not the rc of apt-get update.
|
||
# apt-get update returns 0 tolerantly even when an InRelease file did
|
||
# not download — so a plain rc-based retry does not catch a PPA outage.
|
||
# Package visibility in apt-cache is the only reliable signal that
|
||
# the PPA actually got indexed.
|
||
#
|
||
# With the defaults (3 attempts × initial=30s) the timeline is:
|
||
# attempt 1 → sleep 30s → apt update + attempt 2 → sleep 60s →
|
||
# apt update + attempt 3 (last). After the third fail we return 1.
|
||
# Total wait between attempts is about 1.5 minutes.
|
||
#
|
||
# The 1800s delay cap guards against arithmetic overflow if the helper
|
||
# is ever called with a very large max.
|
||
# ==============================================================================
|
||
apt_wait_for_ppa_package() {
|
||
local pkg="$1" max="${2:-3}" delay="${3:-30}" attempt
|
||
for ((attempt = 1; attempt <= max; attempt++)); do
|
||
if apt-cache show "$pkg" >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
if (( attempt == max )); then
|
||
return 1
|
||
fi
|
||
log_warn "Package '${pkg}' did not appear in apt-cache (attempt ${attempt}/${max}, PPA still unavailable), retrying in ${delay}s..."
|
||
sleep "$delay"
|
||
apt_update_tolerant >/dev/null 2>&1 || true
|
||
delay=$(( delay * 2 > 1800 ? 1800 : delay * 2 ))
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Help
|
||
# ==============================================================================
|
||
|
||
show_help() {
|
||
cat << 'EOF'
|
||
Usage: sudo bash install_amneziawg_en.sh [OPTIONS]
|
||
Script for installation and configuration of AmneziaWG 2.0 on Ubuntu (24.04 / 25.10 / 26.04) and Debian (12 / 13).
|
||
|
||
Options:
|
||
-h, --help Show this help and exit
|
||
--uninstall Uninstall AmneziaWG and all its configurations
|
||
--diagnostic Generate diagnostic report and exit
|
||
-v, --verbose Verbose output for debugging (including DEBUG)
|
||
--no-color Disable colored terminal output
|
||
--port=NUMBER Set UDP port (1-65535) non-interactively
|
||
--ssh-port=PORT SSH port for the UFW rule (auto-detected by default;
|
||
comma-separated list). Use if SSH runs on a non-standard
|
||
port and auto-detection is unavailable
|
||
--subnet=SUBNET Tunnel subnet, CIDR /16-/30 (e.g. 10.9.0.0/16) non-interactively
|
||
--allow-ipv6 Keep IPv6 enabled non-interactively
|
||
--disallow-ipv6 Force-disable IPv6 non-interactively
|
||
--allow-ipv6-tunnel Enable dual-stack IPv6 inside the tunnel (ULA, opt-in)
|
||
--route-all Use 'All traffic' mode non-interactively
|
||
--route-amnezia Use 'Amnezia' mode non-interactively
|
||
--route-custom=NETS Use 'Custom' mode non-interactively
|
||
--isolation=on|off Isolate VPN clients from each other (default on).
|
||
off: the tunnel subnet is added to client AllowedIPs
|
||
--endpoint=ADDR External server endpoint: FQDN, IPv4 or [IPv6] (NAT)
|
||
--server-name=NAME Server name shown in the Amnezia app on vpn:// import
|
||
(default 'AWG Server'; no quotes or control characters)
|
||
--mobile Mobile setup in one flag: --preset=mobile + port 443/udp
|
||
(mobile carriers often kill non-standard UDP ports).
|
||
An explicit --port=N wins over port 443
|
||
-y, --yes Auto-confirm (reboots, UFW, etc.)
|
||
-f, --force Force reinstall on top of an already-running AmneziaWG
|
||
(by default a run on a configured server aborts;
|
||
ENV: AWG_FORCE_REINSTALL=1 is equivalent to the flag)
|
||
--no-tweaks Skip the system cleanup, the optimization and the hardening
|
||
(UFW, Fail2Ban); the minimal forwarding sysctl is always applied
|
||
--keep-packages Do not remove system packages (snapd and others), but keep
|
||
the firewall, Fail2Ban and the optimization. Removing snapd
|
||
takes installed snaps and their data in /var/snap with it
|
||
--preset=TYPE Obfuscation parameter preset: default, mobile
|
||
mobile: Jc=3, narrow Jmax — for mobile carriers (Tele2, Yota, Megafon)
|
||
--jc=N Set Jc manually (1-128, overrides preset)
|
||
--jmin=N Set Jmin manually (0-1280, overrides preset)
|
||
--jmax=N Set Jmax manually (0-1280, overrides preset, must be >= Jmin)
|
||
--no-cps Disable CPS (the I1 parameter) - needed if the desktop
|
||
AmneziaVPN on macOS hangs on connect (issue #159)
|
||
|
||
Examples:
|
||
sudo bash install_amneziawg_en.sh # Interactive installation
|
||
sudo bash install_amneziawg_en.sh --port=51820 --route-all # Non-interactive
|
||
sudo bash install_amneziawg_en.sh --route-amnezia --yes # Fully automated
|
||
sudo bash install_amneziawg_en.sh --preset=mobile --yes # Optimized for mobile networks
|
||
sudo bash install_amneziawg_en.sh --uninstall # Uninstall
|
||
sudo bash install_amneziawg_en.sh --diagnostic # Diagnostics
|
||
|
||
Repository: https://github.com/bivlked/amneziawg-installer
|
||
EOF
|
||
# Explicit --help exits 0; an unknown argument exits 1 (false success in CI).
|
||
exit "${HELP_EXIT_RC:-0}"
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Utilities and validation
|
||
# ==============================================================================
|
||
|
||
update_state() {
|
||
local next_step=$1
|
||
mkdir -p "$(dirname "$STATE_FILE")"
|
||
# Atomic write: tmp-file + flock + mv. Protects against a truncated
|
||
# state file if the process is killed / power-lost between write and close.
|
||
(
|
||
flock -x 200
|
||
local tmp="${STATE_FILE}.tmp.$BASHPID"
|
||
if printf '%s\n' "$next_step" > "$tmp" && mv -f "$tmp" "$STATE_FILE"; then
|
||
exit 0
|
||
fi
|
||
rm -f "$tmp" 2>/dev/null
|
||
exit 1
|
||
) 200>"${STATE_FILE}.lock" || die "Failed to write state"
|
||
log "State: next step - $next_step"
|
||
}
|
||
|
||
request_reboot() {
|
||
local next_step=$1
|
||
update_state "$next_step"
|
||
|
||
# Capture boot_id before the 1→2 reboot gate. On step 2 entry we
|
||
# compare it with the current boot_id — if they match, the user did
|
||
# not reboot, which means apt full-upgrade staged a new kernel on
|
||
# disk but the running kernel is still the old one. DKMS would build
|
||
# the module against the old kernel and modprobe would fail after
|
||
# the next reboot. Fail fast instead.
|
||
if [[ "$next_step" == "2" ]] && [[ -r /proc/sys/kernel/random/boot_id ]]; then
|
||
if cat /proc/sys/kernel/random/boot_id > "$AWG_DIR/.boot_id_before_step2" 2>/dev/null; then
|
||
log_debug "boot_id captured before reboot"
|
||
fi
|
||
fi
|
||
|
||
echo "" >> "$LOG_FILE"
|
||
log_warn "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||
log_warn "!!! SYSTEM REBOOT REQUIRED !!!"
|
||
log_warn "!!! After reboot, run the script again: !!!"
|
||
log_warn "!!! sudo bash $0 [with the same parameters, if any] !!!"
|
||
log_warn "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||
echo "" >> "$LOG_FILE"
|
||
local confirm="y"
|
||
if [[ "$AUTO_YES" -eq 0 ]]; then
|
||
read -rp "Reboot now? [y/N]: " confirm < /dev/tty
|
||
else
|
||
log "Auto-confirming reboot (--yes)."
|
||
fi
|
||
if [[ "$confirm" =~ ^[[:space:]]*[Yy]([Ee][Ss])?[[:space:]]*$ ]]; then
|
||
log "Reboot initiated..."
|
||
sleep 5
|
||
if ! reboot; then die "Reboot command failed."; fi
|
||
exit 1
|
||
else
|
||
log "Reboot cancelled. Reboot manually and run the script again."
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# Early container detection (LXC/OpenVZ/Docker/WSL) - 4pda case: on a
|
||
# container VDS the install used to reach step 3 and die with a raw
|
||
# 'modprobe: FATAL: Module amneziawg not found' with no explanation. AmneziaWG
|
||
# installs a kernel module via DKMS, and containers share the host kernel and
|
||
# cannot load their own modules - it is more honest to stop right away.
|
||
# systemd-detect-virt exists on all supported Ubuntu/Debian; if it is somehow
|
||
# missing, the check is skipped (soft degradation, the install is not blocked).
|
||
check_container() {
|
||
command -v systemd-detect-virt &>/dev/null || return 0
|
||
local virt
|
||
virt=$(systemd-detect-virt --container 2>/dev/null) || true
|
||
[[ -z "$virt" || "$virt" == "none" ]] && return 0
|
||
log_error "Container detected: ${virt}."
|
||
log_error "AmneziaWG requires loading a kernel module (DKMS), and containers (LXC/OpenVZ/Docker/WSL) share the host kernel and cannot load their own modules."
|
||
die "Use a full VPS (KVM/QEMU) or bare-metal. The container option is userspace amneziawg-go: ADVANCED.en.md, section 'LXC / Docker via amneziawg-go'."
|
||
}
|
||
|
||
check_os_version() {
|
||
log "Checking OS..."
|
||
|
||
# Detection via /etc/os-release (universal for Ubuntu and Debian)
|
||
OS_ID=""
|
||
OS_VERSION=""
|
||
OS_CODENAME=""
|
||
if [[ -f /etc/os-release ]]; then
|
||
# shellcheck source=/dev/null
|
||
source /etc/os-release
|
||
OS_ID="$ID"
|
||
OS_VERSION="$VERSION_ID"
|
||
OS_CODENAME="$VERSION_CODENAME"
|
||
elif command -v lsb_release &>/dev/null; then
|
||
OS_ID=$(lsb_release -si | tr '[:upper:]' '[:lower:]')
|
||
OS_VERSION=$(lsb_release -sr)
|
||
OS_CODENAME=$(lsb_release -sc)
|
||
else
|
||
log_warn "Cannot detect OS (/etc/os-release and lsb_release not found)."
|
||
return 0
|
||
fi
|
||
export OS_ID OS_VERSION OS_CODENAME
|
||
|
||
# Supported OS
|
||
local supported=0
|
||
case "$OS_ID" in
|
||
ubuntu)
|
||
if [[ "$OS_VERSION" == "24.04" || "$OS_VERSION" == "25.10" || "$OS_VERSION" == "26.04" ]]; then
|
||
supported=1
|
||
fi
|
||
;;
|
||
debian)
|
||
if [[ "$OS_VERSION" == "12" || "$OS_VERSION" == "13" ]]; then
|
||
supported=1
|
||
fi
|
||
;;
|
||
esac
|
||
|
||
if [[ "$supported" -eq 1 ]]; then
|
||
log "OS: ${OS_ID^} $OS_VERSION ($OS_CODENAME) — supported"
|
||
else
|
||
log_warn "Detected $OS_ID $OS_VERSION ($OS_CODENAME). Script tested on Ubuntu 24.04/25.10/26.04 and Debian 12/13."
|
||
if [[ "$AUTO_YES" -eq 0 ]]; then
|
||
read -rp "Continue? [y/N]: " confirm < /dev/tty
|
||
if ! [[ "$confirm" =~ ^[[:space:]]*[Yy]([Ee][Ss])?[[:space:]]*$ ]]; then die "Cancelled."; fi
|
||
else
|
||
log "Continuing on $OS_ID $OS_VERSION (--yes)."
|
||
fi
|
||
fi
|
||
}
|
||
|
||
check_kernel_version() {
|
||
# The AmneziaWG 2.0 module is built via DKMS against the host kernel. On
|
||
# kernels older than 5.15 (Ubuntu < 22.04, e.g. 5.4 on 20.04) the build
|
||
# usually fails at step 2 with an opaque package-failure. Warn EXPLICITLY and
|
||
# early, before updates and reboots (issue #163). Not a die: on some older
|
||
# kernels the module still builds (HWE and such), so WARN + confirm.
|
||
local kver kmaj kmin
|
||
kver=$(uname -r)
|
||
if [[ "$kver" =~ ^([0-9]+)\.([0-9]+) ]]; then
|
||
kmaj=${BASH_REMATCH[1]}; kmin=${BASH_REMATCH[2]}
|
||
else
|
||
log_warn "Could not parse the kernel version ('$kver') - skipping the minimum-version check."
|
||
return 0
|
||
fi
|
||
if (( kmaj < 5 || (kmaj == 5 && kmin < 15) )); then
|
||
log_warn "Kernel $kver is older than 5.15 - usually too old for the AmneziaWG 2.0 module."
|
||
log_warn "The DKMS module build on such a kernel most often fails. Reinstall the VPS on Ubuntu 24.04 LTS or Debian 12 (or newer). Matrix: Ubuntu 24.04/25.10/26.04, Debian 12/13."
|
||
if [[ "$AUTO_YES" -eq 0 ]]; then
|
||
read -rp "Continue anyway? [y/N]: " confirm < /dev/tty
|
||
if ! [[ "$confirm" =~ ^[[:space:]]*[Yy]([Ee][Ss])?[[:space:]]*$ ]]; then die "Cancelled: kernel $kver is too old for the AmneziaWG 2.0 module."; fi
|
||
else
|
||
log "Continuing on kernel $kver (--yes)."
|
||
fi
|
||
else
|
||
log "Kernel $kver (OK for the AmneziaWG 2.0 module)."
|
||
fi
|
||
}
|
||
|
||
# shellcheck disable=SC2120 # called without args in the installer (uses uname -r); bats passes versions
|
||
_kernel_supports_awg3() {
|
||
# Returns 0 if the kernel version is >= 6.7 - there we take the module from the
|
||
# PPA as is. Returns 1 if the kernel is older than 6.7 - there we go the pinned
|
||
# 2.0 route.
|
||
# ⚠️ The name is historical, do not read it literally. The 6.7 threshold comes
|
||
# from 30-31 jul 2026: the 3.0 code called nla_put_uint, absent before mainline
|
||
# v6.7, and on 6.1 (Debian 12) the build died with 'implicit declaration of
|
||
# nla_put_uint'. Upstream fixed that on 31 jul (v3.0.20260731-04), and the 3.0
|
||
# module DOES build on 6.1 now - verified on a stand on 1 aug. The threshold is
|
||
# kept deliberately: within a day the 3.0 line managed to break and fix the
|
||
# build on old kernels specifically, so that is where it is least proven, while
|
||
# the pinned 2.0 is checked against an immutable commit. Drop the threshold
|
||
# after validating 3.0, not because the build passes again.
|
||
# Arg $1: kernel release (default uname -r). An unparseable version is treated
|
||
# as "NOT supported" -> pinned 2.0 (it builds on ANY of our kernels, so the
|
||
# conservative choice never breaks connectivity, it only withholds 3.0 features
|
||
# which H0 does not ship anyway).
|
||
# Pure function with no external deps (bats: extracted via sed-range + source).
|
||
local kver="${1:-$(uname -r)}" kmaj kmin
|
||
local min_maj=6 min_min=7
|
||
if [[ "$kver" =~ ^([0-9]+)\.([0-9]+) ]]; then
|
||
kmaj=${BASH_REMATCH[1]}; kmin=${BASH_REMATCH[2]}
|
||
else
|
||
return 1
|
||
fi
|
||
if (( kmaj > min_maj || (kmaj == min_maj && kmin >= min_min) )); then
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
check_free_space() {
|
||
log "Checking disk space..."
|
||
local req=2048
|
||
local avail
|
||
avail=$(df -m / | awk 'NR==2 {print $4}')
|
||
if [[ -z "$avail" ]]; then
|
||
log_warn "Failed to determine free space."
|
||
return 0
|
||
fi
|
||
if [ "$avail" -lt "$req" ]; then
|
||
log_warn "Available $avail MB. Recommended >= $req MB."
|
||
if [[ "$AUTO_YES" -eq 0 ]]; then
|
||
read -rp "Continue? [y/N]: " confirm < /dev/tty
|
||
if ! [[ "$confirm" =~ ^[[:space:]]*[Yy]([Ee][Ss])?[[:space:]]*$ ]]; then die "Cancelled."; fi
|
||
else
|
||
log "Continuing with $avail MB (--yes)."
|
||
fi
|
||
else
|
||
log "Free: $avail MB (OK)"
|
||
fi
|
||
}
|
||
|
||
check_port_availability() {
|
||
local port=$1
|
||
log "Checking port $port..."
|
||
local proc
|
||
proc=$(ss -lunp | grep ":${port} ")
|
||
if [[ -n "$proc" ]]; then
|
||
log_error "Port ${port}/udp already in use! Process: $proc"
|
||
return 1
|
||
else
|
||
log "Port $port/udp is free."
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
install_packages() {
|
||
local packages=("$@")
|
||
local to_install=()
|
||
local pkg
|
||
log "Checking packages: ${packages[*]}..."
|
||
for pkg in "${packages[@]}"; do
|
||
if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "ok installed"; then
|
||
to_install+=("$pkg")
|
||
fi
|
||
done
|
||
if [ ${#to_install[@]} -eq 0 ]; then
|
||
log "All packages already installed."
|
||
return 0
|
||
fi
|
||
log "Installing: ${to_install[*]}..."
|
||
if [[ "${_APT_UPDATED:-0}" -eq 0 ]]; then
|
||
# C4: a hard apt_update_tolerant failure (GPG / binary-repo network / OOM)
|
||
# is NOT source noise but a real error; continuing on a stale cache is not
|
||
# safe (contract line ~138, same as callers 1975/2108). die aborts the
|
||
# install, so _APT_UPDATED=1 is set only on success - otherwise a later
|
||
# install_packages call in this session would silently skip the update.
|
||
apt_update_tolerant || die "apt update error."
|
||
_APT_UPDATED=1
|
||
fi
|
||
if ! DEBIAN_FRONTEND=noninteractive apt install -y "${to_install[@]}"; then
|
||
# v5.13.0: typical failure on 25.10/26.04 after an in-place upgrade
|
||
# from 24.04 — the amneziawg-dkms postinst runs `dkms autoinstall`
|
||
# which iterates over ALL kernels in /lib/modules/. The leftover
|
||
# 6.8.x headers were compiled with gcc-13, but 25.10 ships only
|
||
# gcc-15 by default → autoinstall fails, dpkg leaves the dependent
|
||
# amneziawg-tools / amneziawg unconfigured. Force-build the module
|
||
# for the running kernel only and finish with dpkg --configure -a.
|
||
if printf '%s\n' "${to_install[@]}" | grep -qx "amneziawg-dkms"; then
|
||
log_warn "apt install did not complete — trying a DKMS build for the running kernel $(uname -r) only..."
|
||
local _mver
|
||
_mver="$(ls /var/lib/dkms/amneziawg/ 2>/dev/null | head -n1)"
|
||
if [[ -n "$_mver" ]] \
|
||
&& dkms install -m amneziawg -v "$_mver" -k "$(uname -r)" --force \
|
||
&& DEBIAN_FRONTEND=noninteractive dpkg --configure -a; then
|
||
log "DKMS module built for $(uname -r), dpkg configured."
|
||
log "Packages installed."
|
||
return 0
|
||
fi
|
||
fi
|
||
die "Package installation error."
|
||
fi
|
||
log "Packages installed."
|
||
}
|
||
|
||
cleanup_apt() {
|
||
log "Cleaning apt..."
|
||
apt-get clean || log_warn "apt-get clean error"
|
||
rm -rf /var/lib/apt/lists/* || log_warn "rm /var/lib/apt/lists/* error"
|
||
log "apt cache cleared."
|
||
}
|
||
|
||
configure_ipv6() {
|
||
if [[ "$CLI_DISABLE_IPV6" != "default" ]]; then
|
||
DISABLE_IPV6=$CLI_DISABLE_IPV6
|
||
log "IPv6 from CLI: $DISABLE_IPV6"
|
||
elif [[ "$AUTO_YES" -eq 1 ]]; then
|
||
DISABLE_IPV6=1
|
||
log "IPv6 disabled (--yes, default)."
|
||
else
|
||
read -rp "Disable IPv6 (recommended)? [Y/n]: " dis_ipv6 < /dev/tty
|
||
if [[ "$dis_ipv6" =~ ^[Nn]$ ]]; then
|
||
DISABLE_IPV6=0
|
||
else
|
||
DISABLE_IPV6=1
|
||
fi
|
||
fi
|
||
export DISABLE_IPV6
|
||
log "IPv6 disable: $(if [ "$DISABLE_IPV6" -eq 1 ]; then echo 'Yes'; else echo 'No'; fi)"
|
||
}
|
||
|
||
# Detect whether the VPS has native IPv6.
|
||
# Native IPv6 = a globally routable address (NOT ULA fc00::/7, NOT link-local
|
||
# fe80::) AND a default IPv6 route. Either condition alone is insufficient:
|
||
# - a global address without a default route -> no IPv6 internet egress (a client
|
||
# with ::/0 would black-hole);
|
||
# - a ULA (fddd::/...) has global scope to `ip` but is not internet-routable.
|
||
# Echo 1 only when both conditions hold, otherwise 0.
|
||
detect_native_ipv6() {
|
||
local have_addr=0 have_route=0
|
||
if ip -6 addr show scope global 2>/dev/null \
|
||
| grep -oP 'inet6\s+\K[0-9a-fA-F:]+' \
|
||
| grep -qviE '^(fc|fd)'; then
|
||
have_addr=1
|
||
fi
|
||
if ip -6 route show default 2>/dev/null | grep -q .; then
|
||
have_route=1
|
||
fi
|
||
if [[ "$have_addr" -eq 1 && "$have_route" -eq 1 ]]; then
|
||
echo 1
|
||
else
|
||
echo 0
|
||
fi
|
||
}
|
||
|
||
configure_ipv6_tunnel() {
|
||
if [[ "$CLI_ALLOW_IPV6_TUNNEL" -eq 1 ]]; then
|
||
ALLOW_IPV6_TUNNEL=1
|
||
elif [[ -z "${ALLOW_IPV6_TUNNEL:-}" ]]; then
|
||
ALLOW_IPV6_TUNNEL=0
|
||
fi
|
||
: "${IPV6_SUBNET:=fddd:2c4:2c4:2c4::/64}"
|
||
# The IPv6 tunnel requires host IPv6 enabled. Override --disallow-ipv6 AND
|
||
# actively re-enable IPv6 at runtime BEFORE detection/render: on an upgrade
|
||
# from a default past install (IPv6 was runtime-disabled), the kernel hides
|
||
# all IPv6 addresses, so detect_native_ipv6 would false-negative and a client
|
||
# would be rendered with an IPv6 Address while the kernel has IPv6 off
|
||
# (awg-quick restart can fail). weaq P1.
|
||
if [[ "$ALLOW_IPV6_TUNNEL" -eq 1 ]]; then
|
||
if [[ "$DISABLE_IPV6" -eq 1 ]]; then
|
||
log_warn "--allow-ipv6-tunnel requires host IPv6 forwarding; overriding --disallow-ipv6 (DISABLE_IPV6=0)"
|
||
DISABLE_IPV6=0
|
||
fi
|
||
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
|
||
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
|
||
sysctl -w net.ipv6.conf.lo.disable_ipv6=0 >/dev/null 2>&1 || true
|
||
fi
|
||
# Detect native IPv6 AFTER the runtime re-enable (cached in init for client render in Phase 4).
|
||
SERVER_HAS_NATIVE_IPV6=$(detect_native_ipv6)
|
||
if [[ "$ALLOW_IPV6_TUNNEL" -eq 1 && "$SERVER_HAS_NATIVE_IPV6" -eq 0 ]]; then
|
||
log_warn "Native IPv6 not detected on VPS - the IPv6 tunnel will work peer-to-peer only, without IPv6 internet egress."
|
||
fi
|
||
export ALLOW_IPV6_TUNNEL IPV6_SUBNET SERVER_HAS_NATIVE_IPV6 DISABLE_IPV6
|
||
}
|
||
|
||
# Safe configuration loader (whitelist parser, no source/eval)
|
||
safe_load_config() {
|
||
local config_file="${1:-$CONFIG_FILE}"
|
||
if [[ ! -f "$config_file" ]]; then return 1; fi
|
||
|
||
local line key value first_line=1
|
||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||
if [[ "$first_line" -eq 1 ]]; then
|
||
line="${line#$'\xEF\xBB\xBF'}"
|
||
first_line=0
|
||
fi
|
||
line="${line%$'\r'}"
|
||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||
[[ -z "${line// /}" ]] && continue
|
||
line="${line#export }"
|
||
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||
key="${BASH_REMATCH[1]}"
|
||
value="${BASH_REMATCH[2]}"
|
||
if [[ "$value" == \'*\' ]]; then
|
||
value="${value#\'}"
|
||
value="${value%\'}"
|
||
elif [[ "$value" == \"*\" ]]; then
|
||
value="${value#\"}"
|
||
value="${value%\"}"
|
||
fi
|
||
case "$key" in
|
||
OS_ID|OS_VERSION|OS_CODENAME|AWG_PORT|AWG_TUNNEL_SUBNET|\
|
||
DISABLE_IPV6|ALLOWED_IPS_MODE|ALLOWED_IPS|AWG_ENDPOINT|AWG_MTU|\
|
||
AWG_Jc|AWG_Jmin|AWG_Jmax|AWG_S1|AWG_S2|AWG_S3|AWG_S4|\
|
||
AWG_H1|AWG_H2|AWG_H3|AWG_H4|AWG_I1|AWG_I2|AWG_I3|AWG_I4|AWG_I5|AWG_PRESET|NO_TWEAKS|NO_CPS|KEEP_PACKAGES|\
|
||
AWG_APPLY_MODE|ALLOW_IPV6_TUNNEL|IPV6_SUBNET|SERVER_HAS_NATIVE_IPV6|PREV_AWG_PORT|CLIENT_ISOLATION|CLIENT_ISOLATION_NET|AWG_SERVER_NAME)
|
||
export "$key=$value"
|
||
;;
|
||
esac
|
||
fi
|
||
done < "$config_file"
|
||
}
|
||
|
||
# Read a single key from config (for point queries)
|
||
safe_read_config_key() {
|
||
local key="$1" config_file="${2:-$CONFIG_FILE}"
|
||
local line first_line=1
|
||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||
if [[ "$first_line" -eq 1 ]]; then
|
||
line="${line#$'\xEF\xBB\xBF'}"
|
||
first_line=0
|
||
fi
|
||
line="${line%$'\r'}"
|
||
line="${line#export }"
|
||
if [[ "$line" =~ ^${key}=(.*)$ ]]; then
|
||
local value="${BASH_REMATCH[1]}"
|
||
if [[ "$value" == \'*\' ]]; then
|
||
value="${value#\'}"
|
||
value="${value%\'}"
|
||
elif [[ "$value" == \"*\" ]]; then
|
||
value="${value#\"}"
|
||
value="${value%\"}"
|
||
fi
|
||
echo "$value"
|
||
return 0
|
||
fi
|
||
done < "$config_file"
|
||
return 1
|
||
}
|
||
|
||
validate_jc_value() {
|
||
local v="$1"
|
||
[[ "$v" =~ ^[0-9]+$ ]] && [[ "$v" -ge 1 ]] && [[ "$v" -le 128 ]]
|
||
}
|
||
|
||
validate_junk_size() {
|
||
local v="$1"
|
||
[[ "$v" =~ ^[0-9]+$ ]] && [[ "$v" -ge 0 ]] && [[ "$v" -le 1280 ]]
|
||
}
|
||
|
||
validate_port() {
|
||
local port="$1"
|
||
# ^[1-9][0-9]{0,4}$ forbids leading zeros ('0080' would otherwise be parsed as
|
||
# octal in arithmetic and slip past the range check) and bounds the length:
|
||
# without a limit 64-bit (( )) arithmetic wraps, so 2^64+51820 would pass the
|
||
# range check. Comparison uses plain decimal.
|
||
if ! [[ "$port" =~ ^[1-9][0-9]{0,4}$ ]] || (( port > 65535 )); then
|
||
die "Invalid port: '$port'. Allowed range: 1-65535."
|
||
fi
|
||
}
|
||
|
||
validate_subnet() {
|
||
local subnet="$1" o
|
||
# Self-contained (step 0, BEFORE awg_common.sh is downloaded): does not use
|
||
# _valid_ipv4/_cidr_bounds. Octets without leading zeros ('010...' would
|
||
# otherwise be parsed as octal).
|
||
if ! [[ "$subnet" =~ ^(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]{0,2})/([0-9]{1,2})$ ]]; then
|
||
die "Invalid subnet: '$subnet'. Expected CIDR /16-/30, e.g. 10.9.0.0/16."
|
||
fi
|
||
local a="${BASH_REMATCH[1]}" b="${BASH_REMATCH[2]}" c="${BASH_REMATCH[3]}" d="${BASH_REMATCH[4]}" prefix="${BASH_REMATCH[5]}"
|
||
for o in "$a" "$b" "$c" "$d"; do
|
||
(( 10#$o <= 255 )) || die "Invalid subnet: '$subnet'. Octet out of range 0-255."
|
||
done
|
||
(( 10#$prefix >= 16 && 10#$prefix <= 30 )) || die "Invalid subnet: '$subnet'. Only /16-/30 masks are supported."
|
||
# Inline arithmetic: the address must be network or network+1.
|
||
local ip=$(( (10#$a << 24) | (10#$b << 16) | (10#$c << 8) | 10#$d ))
|
||
local mask=$(( (0xFFFFFFFF << (32 - 10#$prefix)) & 0xFFFFFFFF ))
|
||
local network=$(( ip & mask ))
|
||
local n1=$(( network + 1 ))
|
||
local srv="$(( (n1 >> 24) & 255 )).$(( (n1 >> 16) & 255 )).$(( (n1 >> 8) & 255 )).$(( n1 & 255 ))"
|
||
if (( ip != network && ip != n1 )); then
|
||
die "Invalid subnet: '$subnet'. Server address must be ${srv} (network+1), or specify the network."
|
||
fi
|
||
# Normalize the global to <network+1>/<prefix> (server = network+1).
|
||
AWG_TUNNEL_SUBNET="${srv}/${prefix}"
|
||
}
|
||
|
||
# Tunnel network from a CIDR string (<network+1>/<prefix> -> <network>/<prefix>).
|
||
# Needed for client isolation (issue #178): with isolation disabled, it is the
|
||
# network address itself that goes into client AllowedIPs. Self-contained
|
||
# (step 0, BEFORE awg_common.sh is loaded): does not use _cidr_bounds/_int_to_ipv4.
|
||
tunnel_network_cidr() {
|
||
local subnet="${1:-$AWG_TUNNEL_SUBNET}"
|
||
if ! [[ "$subnet" =~ ^(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]{0,2})/([0-9]{1,2})$ ]]; then
|
||
return 1
|
||
fi
|
||
local a="${BASH_REMATCH[1]}" b="${BASH_REMATCH[2]}" c="${BASH_REMATCH[3]}" d="${BASH_REMATCH[4]}" prefix="${BASH_REMATCH[5]}"
|
||
(( 10#$prefix <= 32 )) || return 1
|
||
local o
|
||
for o in "$a" "$b" "$c" "$d"; do (( 10#$o <= 255 )) || return 1; done
|
||
local ip=$(( (10#$a << 24) | (10#$b << 16) | (10#$c << 8) | 10#$d ))
|
||
local mask
|
||
if (( 10#$prefix == 0 )); then mask=0; else mask=$(( (0xFFFFFFFF << (32 - 10#$prefix)) & 0xFFFFFFFF )); fi
|
||
local net=$(( ip & mask ))
|
||
echo "$(( (net >> 24) & 255 )).$(( (net >> 16) & 255 )).$(( (net >> 8) & 255 )).$(( net & 255 ))/${prefix}"
|
||
}
|
||
|
||
# Explicit client isolation choice (issue #178). Priority:
|
||
# CLI flag > saved config > interactive question (first run only, no --yes) >
|
||
# 1 (isolated). An old config without the key = 1: before this feature,
|
||
# split modes were isolated de facto, so the behaviour is preserved.
|
||
configure_client_isolation() {
|
||
case "$CLI_ISOLATION" in
|
||
on) CLIENT_ISOLATION=1; log "Client isolation from CLI: enabled." ;;
|
||
off) CLIENT_ISOLATION=0; log "Client isolation from CLI: disabled." ;;
|
||
default)
|
||
if [[ -n "${CLIENT_ISOLATION:-}" ]]; then
|
||
log "Client isolation (from config): $( [[ "$CLIENT_ISOLATION" -eq 1 ]] && echo enabled || echo disabled )."
|
||
elif [[ "${config_exists:-0}" -eq 1 ]]; then
|
||
CLIENT_ISOLATION=1
|
||
log "Client isolation: enabled (pre-v5.20 config - previous behaviour)."
|
||
elif [[ "$AUTO_YES" -eq 1 ]]; then
|
||
CLIENT_ISOLATION=1
|
||
log "Client isolation: enabled (--yes, default)."
|
||
else
|
||
local r_iso
|
||
read -rp "Isolate VPN clients from each other? [Y/n]: " r_iso < /dev/tty
|
||
case "$r_iso" in
|
||
[nN]*) CLIENT_ISOLATION=0; log "Client isolation disabled: clients will see each other inside the VPN." ;;
|
||
*) CLIENT_ISOLATION=1; log "Client isolation enabled." ;;
|
||
esac
|
||
fi
|
||
;;
|
||
*) die "Invalid --isolation='$CLI_ISOLATION'. Allowed: on|off." ;;
|
||
esac
|
||
export CLIENT_ISOLATION
|
||
}
|
||
|
||
# Brings ALLOWED_IPS in line with CLIENT_ISOLATION (idempotent, called on every
|
||
# run after the routing mode is determined). Isolation OFF: the tunnel subnet
|
||
# is appended to the list (modes 2/3; in mode 1, 0.0.0.0/0 already covers it).
|
||
# Isolation ON: our token is removed from mode 2 (off->on round-trip); mode 3
|
||
# is left untouched - the custom list belongs to the user, and isolation is
|
||
# enforced by the server-side DROP rule regardless.
|
||
# CLIENT_ISOLATION_NET tracks ownership of our token (empty if the token is
|
||
# user-owned or isolation is enabled) - needed to clean up the previous route
|
||
# when the tunnel subnet changes (issue #178, final audit).
|
||
_apply_isolation_to_allowed_ips() {
|
||
local net
|
||
net=$(tunnel_network_cidr "$AWG_TUNNEL_SUBNET") || return 0
|
||
# Strip ALL whitespace, not just spaces: validate_cidr_list accepts tabs
|
||
# as separators, and a tab-carrying token would otherwise slip past the
|
||
# pattern match below - duplicating instead of a no-op (PR #179 review).
|
||
local compact=",${ALLOWED_IPS//[[:space:]]/},"
|
||
|
||
# Tunnel subnet changed: our previous token (persisted CLIENT_ISOLATION_NET)
|
||
# differs from the current network - remove it in any mode and regardless
|
||
# of the isolation state: by construction the token was added by us, not
|
||
# the user.
|
||
if [[ -n "${CLIENT_ISOLATION_NET:-}" && "$CLIENT_ISOLATION_NET" != "$net" ]]; then
|
||
if [[ "$compact" == *",${CLIENT_ISOLATION_NET},"* ]]; then
|
||
# A loop, not a single replace: a corrupted list may carry the
|
||
# token more than once - purge every copy (PR #179 review).
|
||
while [[ "$compact" == *",${CLIENT_ISOLATION_NET},"* ]]; do
|
||
compact="${compact/,${CLIENT_ISOLATION_NET},/,}"
|
||
done
|
||
compact="${compact#,}"; compact="${compact%,}"
|
||
ALLOWED_IPS="${compact//,/, }"
|
||
log "Tunnel subnet changed: previous route ${CLIENT_ISOLATION_NET} removed from client AllowedIPs."
|
||
compact=",${ALLOWED_IPS// /},"
|
||
fi
|
||
CLIENT_ISOLATION_NET=""
|
||
fi
|
||
|
||
if [[ "${CLIENT_ISOLATION:-1}" -eq 0 ]]; then
|
||
if [[ "$ALLOWED_IPS_MODE" == "1" ]]; then
|
||
CLIENT_ISOLATION_NET=""
|
||
elif [[ "$compact" == *",${net},"* ]]; then
|
||
# Already present: our previous token (CLIENT_ISOLATION_NET==net kept)
|
||
# or a user-owned one (CLIENT_ISOLATION_NET empty) - ownership unchanged.
|
||
:
|
||
else
|
||
ALLOWED_IPS="${ALLOWED_IPS}, ${net}"
|
||
CLIENT_ISOLATION_NET="$net"
|
||
log "Isolation disabled: tunnel subnet ${net} added to client AllowedIPs."
|
||
fi
|
||
else
|
||
# Isolation ON: mode 2 - the token is always removed (the list is
|
||
# generated by us); mode 3 - only if we added the token (ownership
|
||
# tracked in CLIENT_ISOLATION_NET).
|
||
if [[ "$compact" == *",${net},"* ]] \
|
||
&& { [[ "$ALLOWED_IPS_MODE" == "2" ]] || [[ "${CLIENT_ISOLATION_NET:-}" == "$net" ]]; }; then
|
||
while [[ "$compact" == *",${net},"* ]]; do
|
||
compact="${compact/,${net},/,}"
|
||
done
|
||
compact="${compact#,}"; compact="${compact%,}"
|
||
ALLOWED_IPS="${compact//,/, }"
|
||
log "Isolation enabled: tunnel subnet ${net} removed from client AllowedIPs."
|
||
fi
|
||
CLIENT_ISOLATION_NET=""
|
||
fi
|
||
export CLIENT_ISOLATION_NET
|
||
}
|
||
|
||
# Server-name validation for the vpn:// URI (D#180): the description field is
|
||
# shown in the Amnezia app after import. The constraints follow from storage
|
||
# in awgsetup_cfg.init (the '...' wrapper) and JSON embedding: no quotes or
|
||
# backslash, no control characters (the whole [[:cntrl:]] class: an ESC from
|
||
# arrow keys in interactive input would break the JSON - je() does not escape
|
||
# controls), no leading/trailing spaces (the client would show a visually
|
||
# empty name). Length - up to 128 BYTES in the C locale (LC_ALL=C gives a
|
||
# predictable count on any system locale; 128 bytes fit 64 two-byte UTF-8
|
||
# characters).
|
||
validate_server_name() {
|
||
local n="$1"
|
||
local LC_ALL=C
|
||
[[ -n "$n" ]] || return 1
|
||
(( ${#n} <= 128 )) || return 1
|
||
[[ "$n" == *"'"* || "$n" == *'"'* || "$n" == *'\'* ]] && return 1
|
||
[[ "$n" == *[[:cntrl:]]* ]] && return 1
|
||
[[ "$n" == " "* || "$n" == *" " ]] && return 1
|
||
return 0
|
||
}
|
||
|
||
# Trim surrounding whitespace (friendliness: an accidental trailing space in
|
||
# --server-name or interactive input must not fail the install).
|
||
_trim_ws() {
|
||
local s="$1"
|
||
s="${s#"${s%%[![:space:]]*}"}"
|
||
s="${s%"${s##*[![:space:]]}"}"
|
||
printf '%s' "$s"
|
||
}
|
||
|
||
# --mobile (D#38, field test 26 jun): mobile-setup shorthand =
|
||
# --preset=mobile + port 443/udp. The main mobile problem is the port: on MTS
|
||
# the default 39743/udp is dead while 443/udp (looks like QUIC/HTTP3) works.
|
||
# Expanded into CLI_PRESET/CLI_PORT BEFORE their consumers: an explicit
|
||
# user --port wins, a contradicting --preset is an error.
|
||
resolve_mobile_flag() {
|
||
[[ "${CLI_MOBILE:-0}" -eq 1 ]] || return 0
|
||
if [[ -n "${CLI_PRESET:-}" && "$CLI_PRESET" != "mobile" ]]; then
|
||
die "--mobile is incompatible with --preset=${CLI_PRESET}: --mobile already implies preset mobile."
|
||
fi
|
||
CLI_PRESET="mobile"
|
||
if [[ -z "$CLI_PORT" ]]; then
|
||
CLI_PORT=443
|
||
log "--mobile: port 443/udp (mobile carriers often kill non-standard UDP ports)."
|
||
fi
|
||
}
|
||
|
||
# Server name in the Amnezia app (D#180). Priority: CLI flag > saved config >
|
||
# interactive question (first run only, no --yes) > 'AWG Server'. A config
|
||
# value is re-validated: the file can be hand-edited, and the name goes into
|
||
# the vpn:// URI JSON.
|
||
configure_server_name() {
|
||
local _name
|
||
if [[ -n "$CLI_SERVER_NAME" ]]; then
|
||
_name=$(_trim_ws "$CLI_SERVER_NAME")
|
||
validate_server_name "$_name" \
|
||
|| die "Invalid --server-name: no quotes, backslash or control characters, at most 128 bytes."
|
||
AWG_SERVER_NAME="$_name"
|
||
log "Server name from CLI: ${AWG_SERVER_NAME}"
|
||
elif [[ -n "${AWG_SERVER_NAME:-}" ]]; then
|
||
_name=$(_trim_ws "$AWG_SERVER_NAME")
|
||
if validate_server_name "$_name"; then
|
||
AWG_SERVER_NAME="$_name"
|
||
else
|
||
log_warn "AWG_SERVER_NAME from $CONFIG_FILE is invalid, using 'AWG Server'."
|
||
AWG_SERVER_NAME="AWG Server"
|
||
fi
|
||
elif [[ "${config_exists:-0}" -eq 1 || "$AUTO_YES" -eq 1 ]]; then
|
||
AWG_SERVER_NAME="AWG Server"
|
||
else
|
||
local input_name
|
||
while true; do
|
||
read -rp "Server name in the Amnezia app [AWG Server]: " input_name < /dev/tty
|
||
input_name=$(_trim_ws "$input_name")
|
||
if [[ -z "$input_name" ]]; then AWG_SERVER_NAME="AWG Server"; break; fi
|
||
if validate_server_name "$input_name"; then AWG_SERVER_NAME="$input_name"; break; fi
|
||
log_warn "No quotes, backslash or control characters, at most 128 bytes. Try again."
|
||
done
|
||
fi
|
||
export AWG_SERVER_NAME
|
||
}
|
||
|
||
# Subnet-change guard: [Peer] blocks are carried over verbatim on reinstall
|
||
# (render_server_config), and their addresses were issued in the OLD subnet.
|
||
# Changing the subnet under live clients breaks them: old IPv4s can fall
|
||
# outside the new range, and IPv6 suffixes can collide (the decimal /24
|
||
# encoding vs hex for non-/24 masks yields two peers with the same ::x). So the
|
||
# install aborts when peers exist and the subnet differs (PR #167 review).
|
||
# Self-contained (step 0, BEFORE awg_common.sh is downloaded). The old
|
||
# subnet is the first Address value in the awg0.conf [Interface]: it is the
|
||
# normalized <network+1>/<prefix>, and the new AWG_TUNNEL_SUBNET has been
|
||
# normalized by validate_subnet by the time of the call - a plain string
|
||
# comparison is enough.
|
||
guard_subnet_change_with_peers() {
|
||
[[ -f "$SERVER_CONF_FILE" ]] || return 0
|
||
grep -q '^\[Peer\]' "$SERVER_CONF_FILE" 2>/dev/null || return 0
|
||
local old_subnet
|
||
# Address may be dual-stack ("IPv4/n, IPv6/n") in any order - pick the IPv4
|
||
# element, not just the first comma field (an IPv6-first Address would
|
||
# otherwise look like a subnet change). No IPv4 -> empty -> fail closed below.
|
||
old_subnet=$(sed -n 's/^[[:space:]]*Address[[:space:]]*=[[:space:]]*//p' "$SERVER_CONF_FILE" 2>/dev/null \
|
||
| tr ',' '\n' | sed 's/[[:space:]]//g' \
|
||
| grep -m1 -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$')
|
||
if [[ -z "$old_subnet" ]]; then
|
||
# Peers exist but the old subnet cannot be determined - fail closed: a
|
||
# silent continue would re-render the config in the new subnet and break
|
||
# the clients.
|
||
die "${SERVER_CONF_FILE} already contains peers, but the Address line in [Interface] is unreadable - the subnet-change check is impossible. Restore the Address line, or remove the clients (sudo bash $MANAGE_SCRIPT_PATH remove <name>), or run --uninstall and reinstall from scratch."
|
||
fi
|
||
if [[ "$old_subnet" != "$AWG_TUNNEL_SUBNET" ]]; then
|
||
die "The tunnel subnet changed (${old_subnet} -> ${AWG_TUNNEL_SUBNET}), but ${SERVER_CONF_FILE} already contains peers: their addresses were issued in the old subnet, and changing it breaks the clients. Options: keep the previous subnet; remove all clients (sudo bash $MANAGE_SCRIPT_PATH remove <name>); or run --uninstall and reinstall from scratch."
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# Endpoint validation (FQDN / IPv4 / [IPv6]).
|
||
# Returns 0 if the endpoint is safe and matches one of the formats,
|
||
# otherwise 1 (the caller decides between die or log_warn + unset).
|
||
# Forbids newline/CR/quotes/backslash to prevent injection into
|
||
# awgsetup_cfg.init and client.conf via the --endpoint flag (audit).
|
||
validate_endpoint() {
|
||
local ep="$1"
|
||
[[ -n "$ep" ]] || return 1
|
||
# Forbid characters that could break the config or inject content
|
||
[[ "$ep" != *$'\n'* && "$ep" != *$'\r'* && \
|
||
"$ep" != *"'"* && "$ep" != *'"'* && "$ep" != *'\\'* && \
|
||
"$ep" != *' '* && "$ep" != *$'\t'* ]] || return 1
|
||
# Bracketed [IPv6] form: structural check of the bracket contents. The previous
|
||
# charset-only test let junk like [:::] / [1:2:3] through. Mirrors _valid_ipv6.
|
||
if [[ "$ep" == \[*\] ]]; then
|
||
local inner="${ep#\[}"; inner="${inner%\]}"
|
||
[[ "$inner" =~ ^[0-9A-Fa-f:]+$ ]] || return 1
|
||
case "$inner" in
|
||
*:::*|*::*::*) return 1 ;;
|
||
esac
|
||
[[ "$inner" == :* && "$inner" != ::* ]] && return 1
|
||
[[ "$inner" == *: && "$inner" != *:: ]] && return 1
|
||
local has_dcolon=0; [[ "$inner" == *::* ]] && has_dcolon=1
|
||
local IFS=':' parts=() p ngroups=0
|
||
read -ra parts <<< "$inner"
|
||
for p in "${parts[@]}"; do
|
||
[[ -z "$p" ]] && continue
|
||
[[ "$p" =~ ^[0-9A-Fa-f]{1,4}$ ]] || return 1
|
||
ngroups=$((ngroups + 1))
|
||
done
|
||
if [[ $has_dcolon -eq 1 ]]; then
|
||
(( ngroups <= 7 )) || return 1
|
||
else
|
||
(( ngroups == 8 )) || return 1
|
||
fi
|
||
return 0
|
||
fi
|
||
# Otherwise FQDN or IPv4
|
||
[[ "$ep" =~ ^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*|[0-9]{1,3}(\.[0-9]{1,3}){3})$ ]] || return 1
|
||
# If IPv4 format - additionally validate octet range 0-255
|
||
if [[ "$ep" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then
|
||
[[ "${BASH_REMATCH[1]}" -le 255 && "${BASH_REMATCH[2]}" -le 255 && \
|
||
"${BASH_REMATCH[3]}" -le 255 && "${BASH_REMATCH[4]}" -le 255 ]] || return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
validate_cidr_list() {
|
||
local input="$1" cidr o nospace
|
||
input="${input//$'\r'/}"
|
||
input="${input//$'\t'/ }"
|
||
# A newline means injection into awgsetup_cfg.init (read <<< only sees the
|
||
# first line, the rest would pass unchecked). Same policy as validate_endpoint.
|
||
[[ "$input" != *$'\n'* ]] || return 1
|
||
# Structural comma check before split: bash IFS drops a trailing empty element,
|
||
# so '10.0.0.0/24,' used to pass. Reject leading/trailing/double comma and empty
|
||
# input (spaces are ignored for this check).
|
||
nospace="${input// /}"
|
||
case "$nospace" in
|
||
""|,*|*,|*,,*) return 1 ;;
|
||
esac
|
||
IFS=',' read -ra cidrs <<< "$input"
|
||
for cidr in "${cidrs[@]}"; do
|
||
cidr="${cidr// /}"
|
||
# Octets without leading zeros; prefix 0-32 enforced in the regex (no octal).
|
||
if ! [[ "$cidr" =~ ^(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]{0,2})/([0-9]|[12][0-9]|3[0-2])$ ]]; then
|
||
return 1
|
||
fi
|
||
for o in "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}"; do
|
||
(( o <= 255 )) || return 1
|
||
done
|
||
done
|
||
}
|
||
|
||
configure_routing_mode() {
|
||
if [[ "$CLI_ROUTING_MODE" != "default" ]]; then
|
||
ALLOWED_IPS_MODE=$CLI_ROUTING_MODE
|
||
if [[ "$CLI_ROUTING_MODE" -eq 3 ]]; then
|
||
ALLOWED_IPS=$CLI_CUSTOM_ROUTES
|
||
if [ -z "$ALLOWED_IPS" ]; then die "No networks specified for --route-custom."; fi
|
||
fi
|
||
log "Routing mode from CLI: $ALLOWED_IPS_MODE"
|
||
elif [[ "$AUTO_YES" -eq 1 ]]; then
|
||
ALLOWED_IPS_MODE=2
|
||
log "Routing mode: Amnezia+DNS (--yes, default)."
|
||
else
|
||
echo ""
|
||
log "Select routing mode (client AllowedIPs):"
|
||
echo " 1) All traffic (0.0.0.0/0) - Max privacy, may block LAN"
|
||
echo " 2) Amnezia List+DNS (default) - Recommended for bypassing restrictions"
|
||
echo " 3) Only specified networks (Split Tunneling)"
|
||
read -rp "Your choice [2]: " r_mode < /dev/tty
|
||
ALLOWED_IPS_MODE=${r_mode:-2}
|
||
fi
|
||
case "$ALLOWED_IPS_MODE" in
|
||
1) ALLOWED_IPS="0.0.0.0/0"
|
||
log "Selected mode: All traffic." ;;
|
||
3) if [[ -z "$CLI_CUSTOM_ROUTES" ]]; then
|
||
read -rp "Enter networks (a.b.c.d/xx,...): " ALLOWED_IPS < /dev/tty
|
||
while ! validate_cidr_list "$ALLOWED_IPS"; do
|
||
log_warn "Invalid CIDR format: '$ALLOWED_IPS'. Expected: x.x.x.x/y[,x.x.x.x/y]"
|
||
read -rp "Try again: " ALLOWED_IPS < /dev/tty
|
||
done
|
||
else
|
||
ALLOWED_IPS=$CLI_CUSTOM_ROUTES
|
||
if ! validate_cidr_list "$ALLOWED_IPS"; then
|
||
die "Invalid CIDR format: '$ALLOWED_IPS'. Expected: x.x.x.x/y[,x.x.x.x/y]"
|
||
fi
|
||
fi
|
||
log "Selected mode: Custom ($ALLOWED_IPS)" ;;
|
||
*) ALLOWED_IPS_MODE=2
|
||
# iOS breaks the tunnel if the list starts with 0.0.0.0/5: that block covers
|
||
# the reserved 0.0.0.0/8 which the iOS kernel chokes on, so it never reaches the
|
||
# rest of the routes. 1.0.0.0/8 + 2.0.0.0/7 + 4.0.0.0/6 is the same range minus the
|
||
# zero block (0.0.0.0/8 is non-routable anyway). Do not revert to 0.0.0.0/5 (Issue #42).
|
||
ALLOWED_IPS="1.0.0.0/8, 2.0.0.0/7, 4.0.0.0/6, 8.0.0.0/7, 11.0.0.0/8, 12.0.0.0/6, 16.0.0.0/4, 32.0.0.0/3, 64.0.0.0/2, 128.0.0.0/3, 160.0.0.0/5, 168.0.0.0/6, 172.0.0.0/12, 172.32.0.0/11, 172.64.0.0/10, 172.128.0.0/9, 173.0.0.0/8, 174.0.0.0/7, 176.0.0.0/4, 192.0.0.0/9, 192.128.0.0/11, 192.160.0.0/13, 192.169.0.0/16, 192.170.0.0/15, 192.172.0.0/14, 192.176.0.0/12, 192.192.0.0/10, 193.0.0.0/8, 194.0.0.0/7, 196.0.0.0/6, 200.0.0.0/5, 208.0.0.0/4, 8.8.8.8/32, 1.1.1.1/32"
|
||
log "Selected mode: Amnezia List+DNS." ;;
|
||
esac
|
||
if [ -z "$ALLOWED_IPS" ]; then die "Failed to determine AllowedIPs."; fi
|
||
export ALLOWED_IPS_MODE ALLOWED_IPS
|
||
}
|
||
|
||
# ==============================================================================
|
||
# AWG 2.0 parameter generation (inline — needed in step 0, before downloading awg_common.sh)
|
||
# ==============================================================================
|
||
|
||
# Random number [min, max] via /dev/urandom (uint32 support)
|
||
rand_range() {
|
||
local min=$1 max=$2
|
||
local range=$((max - min + 1))
|
||
local random_val
|
||
random_val=$(od -An -tu4 -N4 /dev/urandom 2>/dev/null | tr -d ' ')
|
||
if [[ -z "$random_val" || ! "$random_val" =~ ^[0-9]+$ ]]; then
|
||
# Fallback: three $RANDOM (15 bits each) with XOR overlap cover bits
|
||
# 0-30, i.e. the full [0, 2^31-1]. The previous variant
|
||
# (RANDOM<<15|RANDOM) gave only 30 bits - the upper half of the H
|
||
# range could never come up.
|
||
random_val=$(( (RANDOM << 16) ^ (RANDOM << 8) ^ RANDOM ))
|
||
fi
|
||
echo $(( (random_val % range) + min ))
|
||
}
|
||
|
||
# Generate 4 non-overlapping ranges for AWG H1-H4.
|
||
# Algorithm: 8 random values → sort → 4 (low, high) pairs.
|
||
# Sorting gives low <= high; the strict checks below guarantee a gap between
|
||
# pairs (touching bounds = overlap at a single point) and a lower bound >= 5
|
||
# (values 1-4 are reserved for vanilla WireGuard message types).
|
||
# Minimum width per range = 1000 (for proper obfuscation).
|
||
# Prints 4 "low-high" lines to stdout. Returns 1 on failure.
|
||
# Mitigates Russian DPI fingerprinting of static H values (#38).
|
||
#
|
||
# Range: [0, 2^31-1] = [0, 2147483647]. The AmneziaWG spec allows the
|
||
# full uint32 (0-4294967295), but the standalone Windows client
|
||
# `amneziawg-windows-client` has a UI validator capped at 2^31-1 in
|
||
# `ui/syntax/highlighter.go:isValidHField()` (upstream bug
|
||
# amnezia-vpn/amneziawg-windows-client#85, not yet fixed). Values above
|
||
# 2^31-1 work on the server, but the client's config editor underlines
|
||
# them as invalid and blocks saving. For compatibility we generate in
|
||
# the safe half of the range (#40).
|
||
#
|
||
# Optimization: a single `od -N32 -tu4` call reads 32 bytes = 8 uint32
|
||
# values in one operation, instead of 8 separate subprocess calls via
|
||
# rand_range. Falls back to rand_range if /dev/urandom is unavailable.
|
||
generate_awg_h_ranges() {
|
||
local attempt=0 max_attempts=20
|
||
while (( attempt < max_attempts )); do
|
||
local raw arr=() _v
|
||
raw=$(od -An -N32 -tu4 /dev/urandom 2>/dev/null | tr -s ' \n' '\n' | sed '/^$/d')
|
||
if [[ -n "$raw" ]]; then
|
||
local count=0
|
||
while IFS= read -r _v; do
|
||
[[ "$_v" =~ ^[0-9]+$ ]] || continue
|
||
# Mask 0x7FFFFFFF: clears the top bit, value in [0, 2^31-1]
|
||
# with no bias (each lower bit stays independent).
|
||
arr+=("$(( _v & 2147483647 ))")
|
||
count=$((count + 1))
|
||
(( count == 8 )) && break
|
||
done <<< "$raw"
|
||
fi
|
||
if (( ${#arr[@]} != 8 )); then
|
||
arr=()
|
||
local _i
|
||
for _i in 1 2 3 4 5 6 7 8; do
|
||
arr+=("$(rand_range 0 2147483647)")
|
||
done
|
||
fi
|
||
local sorted
|
||
sorted=$(printf '%s\n' "${arr[@]}" | sort -n)
|
||
arr=()
|
||
while IFS= read -r _v; do arr+=("$_v"); done <<< "$sorted"
|
||
if (( ${arr[0]} >= 5 )) && \
|
||
(( ${arr[1]} - ${arr[0]} >= 1000 )) && \
|
||
(( ${arr[3]} - ${arr[2]} >= 1000 )) && \
|
||
(( ${arr[5]} - ${arr[4]} >= 1000 )) && \
|
||
(( ${arr[7]} - ${arr[6]} >= 1000 )) && \
|
||
(( ${arr[2]} > ${arr[1]} )) && \
|
||
(( ${arr[4]} > ${arr[3]} )) && \
|
||
(( ${arr[6]} > ${arr[5]} )); then
|
||
printf '%s-%s\n' "${arr[0]}" "${arr[1]}"
|
||
printf '%s-%s\n' "${arr[2]}" "${arr[3]}"
|
||
printf '%s-%s\n' "${arr[4]}" "${arr[5]}"
|
||
printf '%s-%s\n' "${arr[6]}" "${arr[7]}"
|
||
return 0
|
||
fi
|
||
attempt=$((attempt + 1))
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# Generate CPS string for I1
|
||
# Format: "<r N>" where N is the number of random bytes (32-256)
|
||
generate_cps_i1() {
|
||
local n
|
||
n=$(rand_range 32 256)
|
||
echo "<r ${n}>"
|
||
}
|
||
|
||
# Generate all AWG 2.0 parameters
|
||
generate_awg_params() {
|
||
local preset="${CLI_PRESET:-default}"
|
||
log "Generating AWG 2.0 parameters (preset: $preset)..."
|
||
|
||
case "$preset" in
|
||
default)
|
||
# Jc 3-6: balance between obfuscation and mobile compatibility (Discussion #38)
|
||
AWG_Jc=$(rand_range 3 6)
|
||
AWG_Jmin=$(rand_range 40 89)
|
||
# Jmax = Jmin + 50..250 (~90-339 bytes, Issue #42)
|
||
AWG_Jmax=$(( AWG_Jmin + $(rand_range 50 250) ))
|
||
;;
|
||
mobile)
|
||
# Jc=3 fixed: alkorrnd (Tele2) — Jc=3 >95%, Jc=4 ~30%, Jc=5 <5%
|
||
# Narrow Jmax: markmokrenko (Yota) — Jmax=70 works, Jmax>300 blocked
|
||
AWG_Jc=3
|
||
AWG_Jmin=$(rand_range 30 50)
|
||
AWG_Jmax=$(( AWG_Jmin + $(rand_range 20 80) ))
|
||
log " Preset 'mobile': Jc=3, narrow Jmax for mobile networks"
|
||
;;
|
||
*)
|
||
die "Unknown preset: '$preset'. Allowed: default, mobile"
|
||
;;
|
||
esac
|
||
|
||
# Individual CLI overrides (on top of preset)
|
||
if [[ -n "${CLI_JC:-}" ]]; then
|
||
validate_jc_value "$CLI_JC" || die "Invalid --jc=$CLI_JC (allowed: 1-128)"
|
||
AWG_Jc="$CLI_JC"
|
||
fi
|
||
if [[ -n "${CLI_JMIN:-}" ]]; then
|
||
validate_junk_size "$CLI_JMIN" || die "Invalid --jmin=$CLI_JMIN (allowed: 0-1280)"
|
||
AWG_Jmin="$CLI_JMIN"
|
||
fi
|
||
if [[ -n "${CLI_JMAX:-}" ]]; then
|
||
validate_junk_size "$CLI_JMAX" || die "Invalid --jmax=$CLI_JMAX (allowed: 0-1280)"
|
||
AWG_Jmax="$CLI_JMAX"
|
||
fi
|
||
|
||
# Sanity: Jmax >= Jmin
|
||
if [[ "$AWG_Jmax" -lt "$AWG_Jmin" ]]; then
|
||
die "Jmax ($AWG_Jmax) cannot be less than Jmin ($AWG_Jmin)"
|
||
fi
|
||
|
||
AWG_PRESET="$preset"
|
||
AWG_S1=$(rand_range 15 150)
|
||
AWG_S2=$(rand_range 15 150)
|
||
|
||
# Critical kernel constraint: S1+56 != S2
|
||
# Prevents init and response messages from having the same size
|
||
while [[ $((AWG_S1 + 56)) -eq $AWG_S2 ]]; do
|
||
AWG_S2=$(rand_range 15 150)
|
||
done
|
||
|
||
# ⚠️ The lower bounds of S3/S4 are incompatible with AmneziaWG 3.0 header
|
||
# protection. There the ChaCha20 nonce is never transmitted: it is taken from
|
||
# the first 12 bytes of the S padding of the message in question
|
||
# (HEADER_PROTECTION_NONCE_SIZE = 12), so both implementations REJECT a config
|
||
# where any of S1-S4 is below 12 while a header protection key is set. The
|
||
# kernel module returns -EINVAL (src/netlink.c, has_protection && val16 <
|
||
# HEADER_PROTECTION_NONCE_SIZE); amneziawg-go errors out in device/uapi.go
|
||
# (present since v3.0.0). So the failure is LOUD - there is no silent crypto
|
||
# weakening; verified against upstream sources on 2 aug 2026. While we stay on
|
||
# 2.0 and set no header protection key, these ranges are safe. WHEN header
|
||
# protection is enabled, raise both lower bounds to 12, otherwise a share of
|
||
# installs will simply fail to bring the interface up. Keep this in step with
|
||
# the _kernel_supports_awg3 gate.
|
||
AWG_S3=$(rand_range 8 55)
|
||
|
||
# Second size collision: response+S2 != cookie+S3, that is S3 != S2+28.
|
||
# Message sizes (src/messages.h of the kernel module): init 148, response 92,
|
||
# cookie reply 64. The first two were measured on the wire, the cookie one is
|
||
# 4 (header) + 4 (receiver_index) + 24 (nonce) + 32 (cookie 16 + authtag 16).
|
||
# That gives three ways for the final packet sizes to match:
|
||
# init/response -> S2 = S1 + 56 (handled by the loop above)
|
||
# response/cookie -> S3 = S2 + 28 (handled here)
|
||
# init/cookie -> S3 = S1 + 84 (unreachable: the minimum S1+84 is 99
|
||
# while S3 tops out at 55, no loop needed)
|
||
# We regenerate S3 rather than S2, since S2 already passed the S1+56 check.
|
||
while [[ $((AWG_S2 + 28)) -eq $AWG_S3 ]]; do
|
||
AWG_S3=$(rand_range 8 55)
|
||
done
|
||
|
||
AWG_S4=$(rand_range 4 27)
|
||
|
||
# H1-H4: 4 random non-overlapping uint32 ranges.
|
||
# Per-install randomization protects against Russian DPI fingerprinting
|
||
# of static H values (Discussion #38, elvaleto/Klavishnik).
|
||
# Algorithm: 8 random uint32 → sort → 4 non-overlapping pairs.
|
||
local _h_lines
|
||
mapfile -t _h_lines < <(generate_awg_h_ranges) || true
|
||
if [[ ${#_h_lines[@]} -ne 4 ]]; then
|
||
die "Failed to generate H1-H4 ranges."
|
||
fi
|
||
AWG_H1="${_h_lines[0]}"
|
||
AWG_H2="${_h_lines[1]}"
|
||
AWG_H3="${_h_lines[2]}"
|
||
AWG_H4="${_h_lines[3]}"
|
||
|
||
# I1: CPS concealment
|
||
AWG_I1=$(generate_cps_i1)
|
||
|
||
# I2-I5 are NOT generated here (the admin sets them manually in awg0.conf, issue #71).
|
||
# A fresh param set (first install or --preset/--jc/--jmin/--jmax) clears any stale
|
||
# I2-I5 loaded from awgsetup_cfg.init so the new obfuscation set does not carry old
|
||
# values (--preset regenerates the whole set).
|
||
unset AWG_I2 AWG_I3 AWG_I4 AWG_I5
|
||
|
||
export AWG_Jc AWG_Jmin AWG_Jmax AWG_S1 AWG_S2 AWG_S3 AWG_S4 AWG_PRESET
|
||
export AWG_H1 AWG_H2 AWG_H3 AWG_H4 AWG_I1
|
||
|
||
log " Jc=$AWG_Jc, Jmin=$AWG_Jmin, Jmax=$AWG_Jmax"
|
||
log " S1=$AWG_S1, S2=$AWG_S2, S3=$AWG_S3, S4=$AWG_S4"
|
||
log " H1=$AWG_H1"
|
||
log " H2=$AWG_H2"
|
||
log " H3=$AWG_H3"
|
||
log " H4=$AWG_H4"
|
||
log " I1=$AWG_I1"
|
||
log "AWG 2.0 parameters generated."
|
||
}
|
||
|
||
# ==============================================================================
|
||
# System optimization (new in v5.0)
|
||
# ==============================================================================
|
||
|
||
# Detect hardware characteristics
|
||
detect_hardware() {
|
||
TOTAL_RAM_MB=$(awk '/MemTotal/ {printf "%.0f", $2/1024}' /proc/meminfo)
|
||
CPU_CORES=$(nproc)
|
||
MAIN_NIC=$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1); exit}')
|
||
log "Hardware: RAM=${TOTAL_RAM_MB}MB, CPU=${CPU_CORES} cores, NIC=${MAIN_NIC}"
|
||
}
|
||
|
||
# _cleanup_package_list : the packages cleanup_system removes on this OS.
|
||
# One source for both the cleanup and the step 0 question, otherwise they drift apart.
|
||
# ⚠️ OS_ID deliberately has NO default: an unknown OS must not get the destructive
|
||
# superset (snapd, lxd-agent-loader and wiping the snap directories). Empty = not Ubuntu.
|
||
_cleanup_package_list() {
|
||
local list="modemmanager networkd-dispatcher unattended-upgrades packagekit udisks2"
|
||
[[ "${OS_ID:-}" == "ubuntu" ]] && list="snapd $list lxd-agent-loader"
|
||
printf '%s' "$list"
|
||
}
|
||
|
||
# _dpkg_usable : 0 if dpkg answers can be trusted.
|
||
# Telling "package not installed" from "dpkg database is broken" by return code is NOT
|
||
# possible: measured on Ubuntu 24.04, both give rc=1. So ask about a package that is
|
||
# certainly installed: if even that one is missing, the mechanism is broken, not the
|
||
# packages. Without this an empty list would silently skip the question, and step 1
|
||
# would later run with a working dpkg and remove what was never asked about.
|
||
_dpkg_usable() {
|
||
command -v dpkg-query >/dev/null 2>&1 || return 1
|
||
dpkg-query -W -f='${Status}' dpkg 2>/dev/null | grep -q "ok installed"
|
||
}
|
||
|
||
# _cloud_init_removable : 0 if an installed cloud-init will actually be removed.
|
||
# cloud-init sits outside the list above: it is removed only when it does NOT manage the
|
||
# network. That answer is needed in two places, the cleanup itself and the step 0
|
||
# question, so it lives here. Otherwise consent would be asked about one set while a
|
||
# different one gets removed, which is exactly the complaint behind issue #213.
|
||
# 🔴 Any FAILED check means "manages the network, leave it alone". The costs are not
|
||
# symmetric: a cloud-init left in place costs tens of megabytes, one removed by mistake
|
||
# costs the network after the next reboot on a remote server. That is why ls by glob is
|
||
# gone from here: it returns rc=2 both when the directory is missing and when nothing matched.
|
||
_cloud_init_removable() {
|
||
dpkg-query -W -f='${Status}' cloud-init 2>/dev/null | grep -q "ok installed" || return 1
|
||
local f
|
||
if [ -d /etc/netplan ]; then
|
||
[ -r /etc/netplan ] || return 1
|
||
for f in /etc/netplan/*cloud-init*; do
|
||
[ -e "$f" ] && return 1
|
||
done
|
||
grep -rq "cloud-init" /etc/netplan/ 2>/dev/null
|
||
case $? in
|
||
0) return 1 ;;
|
||
1) : ;;
|
||
*) return 1 ;;
|
||
esac
|
||
fi
|
||
if [ -f /etc/network/interfaces ] && grep -q "cloud-init" /etc/network/interfaces 2>/dev/null; then
|
||
return 1
|
||
fi
|
||
# On Debian cloud-init writes here rather than into the main file.
|
||
if [ -d /etc/network/interfaces.d ] \
|
||
&& grep -rq "cloud-init" /etc/network/interfaces.d/ 2>/dev/null; then
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# _snaps_dir_readable : 0 if the snap directory exists and is readable.
|
||
# An empty answer from _user_snaps with an unreadable directory means "could not look",
|
||
# not "no snaps". Confusing the two is not allowed: the default of a destructive question
|
||
# depends on it.
|
||
_snaps_dir_readable() {
|
||
[ -d /var/lib/snapd/snaps ] && [ -r /var/lib/snapd/snaps ]
|
||
}
|
||
|
||
# _user_snaps : names of snaps installed by the USER, one per line, WITHOUT duplicates.
|
||
# The directory holds one file per RETAINED REVISION, so without the dedup a snap that
|
||
# has ever been refreshed would appear in the warning several times in a row.
|
||
# Only snaps that carry no user data count as base ones: snapd, bare and core*.
|
||
# 🔴 lxd is NOT filtered: LXD from a snap keeps its containers and their data in
|
||
# /var/snap/lxd, and calling such a host "nothing to lose" means wiping them silently.
|
||
# ⚠️ Read the files rather than the output of 'snap list': the snap binary may already be
|
||
# gone from an earlier run, and the list would silently come back empty.
|
||
_user_snaps() {
|
||
local f name
|
||
for f in /var/lib/snapd/snaps/*.snap; do
|
||
[ -e "$f" ] || continue
|
||
name="${f##*/}"; name="${name%_*.snap}"
|
||
case "$name" in
|
||
snapd|bare|core|core[0-9]*) continue ;;
|
||
esac
|
||
printf '%s\n' "$name"
|
||
done | sort -u
|
||
}
|
||
|
||
# Consent for removing system packages (issue #213). Asked at STEP 0, where the other
|
||
# questions already live: everything after that should run without a human present.
|
||
# The answer is stored in awgsetup_cfg.init so a repeated or resumed run does not ask
|
||
# again and, more importantly, does not read silence as consent.
|
||
configure_package_cleanup() {
|
||
[[ "$NO_TWEAKS" -eq 1 ]] && return 0
|
||
# The decision already exists: a command line flag or a record from an earlier run.
|
||
[[ -n "$KEEP_PACKAGES" ]] && return 0
|
||
|
||
if ! _dpkg_usable; then
|
||
KEEP_PACKAGES=1
|
||
log_warn "Could not query dpkg, so I will leave the system packages alone."
|
||
return 0
|
||
fi
|
||
|
||
local installed=() pkg
|
||
for pkg in $(_cleanup_package_list); do
|
||
if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "ok installed"; then
|
||
installed+=("$pkg")
|
||
fi
|
||
done
|
||
# cloud-init is removed by a separate branch of the cleanup and only when it does not
|
||
# manage the network, so it joins the list under the same condition: the question has
|
||
# to be about exactly what will actually be removed.
|
||
if _cloud_init_removable; then
|
||
installed+=("cloud-init")
|
||
fi
|
||
|
||
# The snap directories are wiped by a separate rm -rf that does NOT depend on snapd
|
||
# making the list above: the package may sit in 'deinstall ok config-files' state.
|
||
local snap_dirs=0
|
||
if [[ "${OS_ID:-}" == "ubuntu" ]] && { [ -d /snap ] || [ -d /var/snap ]; }; then
|
||
snap_dirs=1
|
||
fi
|
||
|
||
if [ ${#installed[@]} -eq 0 ] && [ "$snap_dirs" -eq 0 ]; then
|
||
KEEP_PACKAGES=0
|
||
return 0
|
||
fi
|
||
|
||
# Look for the user snaps only when something threatens them: on Debian snapd is not in the list.
|
||
local snaps="" snaps_unknown=0
|
||
if [ "$snap_dirs" -eq 1 ] || [[ " ${installed[*]} " == *" snapd "* ]]; then
|
||
if _snaps_dir_readable; then
|
||
snaps="$(_user_snaps | tr '\n' ' ')"; snaps="${snaps% }"
|
||
else
|
||
snaps_unknown=1
|
||
fi
|
||
fi
|
||
|
||
log_warn "The server is being set up as single-purpose, so these packages will be removed:"
|
||
[ ${#installed[@]} -gt 0 ] && log_warn " ${installed[*]}"
|
||
if [ "$snap_dirs" -eq 1 ]; then
|
||
log_warn " Plus the /snap, /var/snap and /var/lib/snapd directories with every snap and its data."
|
||
if [ "$snaps_unknown" -eq 1 ]; then
|
||
log_warn " Could not check what you have installed: the snap directory is not accessible."
|
||
elif [[ -n "$snaps" ]]; then
|
||
log_warn " Your snaps that would be lost: $snaps"
|
||
fi
|
||
fi
|
||
if [[ " ${installed[*]} " == *" cloud-init "* ]]; then
|
||
log_warn " Removing cloud-init also wipes the /etc/cloud and /var/lib/cloud directories."
|
||
fi
|
||
|
||
if [[ "$AUTO_YES" -eq 1 ]]; then
|
||
KEEP_PACKAGES=0
|
||
log "Removal auto-confirmed (--yes). To keep the packages: --keep-packages."
|
||
return 0
|
||
fi
|
||
|
||
# Something to lose means removing ONLY on an explicit yes (an allowlist, like every
|
||
# other destructive question in the script). The earlier version tested the answer for
|
||
# the letter n, so "no thanks", a stray key or any answer in another language meant REMOVE.
|
||
local risky=0
|
||
if [[ -n "$snaps" ]] || [ "$snaps_unknown" -eq 1 ]; then risky=1; fi
|
||
|
||
local answer="" hint="[Y/n]"
|
||
[ "$risky" -eq 1 ] && hint="[y/N]"
|
||
if ! read -rp "Remove these packages? $hint: " answer < /dev/tty; then
|
||
KEEP_PACKAGES=1
|
||
log_warn "No terminal available, could not ask - keeping the packages."
|
||
return 0
|
||
fi
|
||
# Trim spaces and CR: an answer from putty arrives with a trailing \r.
|
||
answer="$(printf '%s' "$answer" | tr -d '[:space:]')"
|
||
|
||
if [ "$risky" -eq 1 ]; then
|
||
case "$answer" in
|
||
[Yy]|[Yy][Ee][Ss]|да|Да|ДА|д|Д) KEEP_PACKAGES=0 ;;
|
||
*) KEEP_PACKAGES=1 ;;
|
||
esac
|
||
else
|
||
case "$answer" in
|
||
[Nn]|[Nn][Oo]|нет|Нет|НЕТ|не|Не|н|Н) KEEP_PACKAGES=1 ;;
|
||
*) KEEP_PACKAGES=0 ;;
|
||
esac
|
||
fi
|
||
|
||
if [[ "$KEEP_PACKAGES" -eq 1 ]]; then
|
||
log "Keeping the packages. The firewall, Fail2Ban and the optimization stay in place."
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# Remove unnecessary packages and services
|
||
cleanup_system() {
|
||
log "Cleaning system of unnecessary components..."
|
||
|
||
# Snapshot default route BEFORE cleanup - detects when we break the network.
|
||
# Issue #84: on clean Ubuntu 26.04 server (subiquity, no cloud-init netplan
|
||
# markers) apt-get autoremove after purging cloud-init removed
|
||
# netplan-generator as a transitive dep, and the server lost its IP on reboot.
|
||
local pre_default_route
|
||
pre_default_route="$(ip -4 route show default 2>/dev/null | head -1 || true)"
|
||
log_debug "Pre-cleanup default route: ${pre_default_route:-<none>}"
|
||
|
||
# apt-mark hold for critical network stack packages: defence against
|
||
# accidental removal via transitive deps. Covers both netplan naming
|
||
# variants (netplan.io on 24.04, netplan-generator on 25.10/26.04) plus
|
||
# systemd-resolved and netcfg/ifupdown legacy. There is no standalone
|
||
# systemd-networkd package - the binary lives inside systemd, nothing to hold.
|
||
# Before holding we snapshot the user's existing holds so we never strip
|
||
# holds we did not place (e.g. on linux-image-* held by the user).
|
||
local _hold_pkgs="netplan.io netplan-generator systemd-resolved netcfg ifupdown"
|
||
local _preexisting_holds=""
|
||
_preexisting_holds="$(apt-mark showhold 2>/dev/null || true)"
|
||
local _held_actual=()
|
||
local _hpkg
|
||
for _hpkg in $_hold_pkgs; do
|
||
if dpkg-query -W -f='${Status}' "$_hpkg" 2>/dev/null | grep -q "ok installed"; then
|
||
# Skip if user already held - that hold is not ours to release.
|
||
if grep -qxF "$_hpkg" <<<"$_preexisting_holds"; then
|
||
continue
|
||
fi
|
||
apt-mark hold "$_hpkg" >/dev/null 2>&1 && _held_actual+=("$_hpkg")
|
||
fi
|
||
done
|
||
[ ${#_held_actual[@]} -gt 0 ] && log_debug "Apt-mark hold: ${_held_actual[*]}"
|
||
|
||
# Packages to remove (safe for VPS)
|
||
# snapd and lxd-agent-loader — Ubuntu only, not present on Debian
|
||
local packages_to_remove=()
|
||
local pkg
|
||
local cleanup_list
|
||
cleanup_list="$(_cleanup_package_list)"
|
||
for pkg in $cleanup_list; do
|
||
if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "ok installed"; then
|
||
packages_to_remove+=("$pkg")
|
||
fi
|
||
done
|
||
|
||
if [ ${#packages_to_remove[@]} -gt 0 ]; then
|
||
log "Removing: ${packages_to_remove[*]}"
|
||
DEBIAN_FRONTEND=noninteractive apt-get purge -y "${packages_to_remove[@]}" || log_warn "Error removing some packages"
|
||
fi
|
||
|
||
# Cleaning snap artifacts (Ubuntu only)
|
||
if [[ "${OS_ID:-}" == "ubuntu" && -d /snap ]]; then
|
||
log "Cleaning snap artifacts..."
|
||
rm -rf /snap /var/snap /var/lib/snapd 2>/dev/null || log_warn "snap cleanup error"
|
||
fi
|
||
|
||
# cloud-init: remove only if NOT managing network
|
||
# Conservative approach: check cloud-init markers first, then renderer
|
||
if dpkg-query -W -f='${Status}' cloud-init 2>/dev/null | grep -q "ok installed"; then
|
||
if _cloud_init_removable; then
|
||
log "Removing cloud-init (network doesn't depend on it)..."
|
||
DEBIAN_FRONTEND=noninteractive apt-get purge -y cloud-init 2>/dev/null || log_warn "cloud-init removal error"
|
||
rm -rf /etc/cloud /var/lib/cloud 2>/dev/null
|
||
else
|
||
log_warn "cloud-init manages network — skipping removal."
|
||
fi
|
||
fi
|
||
|
||
# apt-get autoremove dropped (was the source of Issue #84 on Ubuntu 26.04
|
||
# ISO): autoremove zapped netplan-generator as a transitive dep of
|
||
# cloud-init. Orphans left after purge take ~50-200 MB - acceptable trade
|
||
# for stability. User can manually run apt-get autoremove --no-install-recommends.
|
||
|
||
# Release apt-mark holds so packages do not stay frozen for the user.
|
||
local _upkg
|
||
for _upkg in "${_held_actual[@]}"; do
|
||
apt-mark unhold "$_upkg" >/dev/null 2>&1 || true
|
||
done
|
||
|
||
# Verify default route is still present. If lost, attempt recovery.
|
||
# We reinstall netplan.io unconditionally (present on every supported
|
||
# distro). netplan-generator only ships from Ubuntu 25.10+ / Debian 13+ -
|
||
# gate the install behind apt-cache show so Debian 12 does not abort the
|
||
# transaction trying to fetch a non-existent package.
|
||
local post_default_route
|
||
post_default_route="$(ip -4 route show default 2>/dev/null | head -1 || true)"
|
||
if [[ -n "$pre_default_route" && -z "$post_default_route" ]]; then
|
||
log_error "Default route lost after cleanup. Attempting recovery..."
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||
netplan.io 2>/dev/null || true
|
||
if apt-cache show netplan-generator &>/dev/null; then
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||
netplan-generator 2>/dev/null || true
|
||
fi
|
||
systemctl restart systemd-networkd 2>/dev/null || true
|
||
netplan apply 2>/dev/null || true
|
||
# Route-wait loop: up to ~26 seconds, polling every 1-5 seconds.
|
||
# Fixed sleeps are unreliable - DHCP route appearance on slow VMs is
|
||
# unpredictable.
|
||
local _wait
|
||
for _wait in 1 2 3 5 5 5 5; do
|
||
post_default_route="$(ip -4 route show default 2>/dev/null | head -1 || true)"
|
||
[[ -n "$post_default_route" ]] && break
|
||
sleep "$_wait"
|
||
done
|
||
# Last-ditch: bring up the interface from pre_default_route. Try
|
||
# networkctl renew first (for systemd-networkd-managed link); if the
|
||
# route still does not come back, fall through to dhclient (ifupdown).
|
||
if [[ -z "$post_default_route" ]]; then
|
||
local _iface
|
||
_iface="$(awk '{for (i=1; i<=NF; i++) if ($i == "dev") { print $(i+1); exit } }' <<<"$pre_default_route")"
|
||
if [[ -n "$_iface" ]]; then
|
||
log_warn "Last-ditch attempt to bring $_iface up..."
|
||
ip link set "$_iface" up 2>/dev/null || true
|
||
if command -v networkctl &>/dev/null; then
|
||
networkctl renew "$_iface" 2>/dev/null || true
|
||
sleep 3
|
||
post_default_route="$(ip -4 route show default 2>/dev/null | head -1 || true)"
|
||
fi
|
||
# If networkctl did not bring the route back (or is absent) - dhclient.
|
||
if [[ -z "$post_default_route" ]] && command -v dhclient &>/dev/null; then
|
||
dhclient -4 "$_iface" 2>/dev/null || true
|
||
sleep 3
|
||
post_default_route="$(ip -4 route show default 2>/dev/null | head -1 || true)"
|
||
fi
|
||
fi
|
||
fi
|
||
if [[ -z "$post_default_route" ]]; then
|
||
die "Network did not recover after cleanup_system. Restore it from the console (e.g. sudo dhclient -4 <iface>) and retry the installer with --no-tweaks flag."
|
||
fi
|
||
log_warn "Network recovered: $post_default_route"
|
||
fi
|
||
|
||
log "System cleanup completed."
|
||
}
|
||
|
||
# Swap configuration
|
||
optimize_swap() {
|
||
log "Optimizing swap..."
|
||
local target_swap_mb
|
||
|
||
if [[ $TOTAL_RAM_MB -le 2048 ]]; then
|
||
target_swap_mb=1024
|
||
else
|
||
target_swap_mb=512
|
||
fi
|
||
|
||
# Check current swap
|
||
local current_swap_mb
|
||
current_swap_mb=$(free -m | awk '/Swap:/ {print $2}')
|
||
|
||
if [[ $current_swap_mb -ge $target_swap_mb ]]; then
|
||
log "Swap is already sufficient: ${current_swap_mb}MB (target: ${target_swap_mb}MB)"
|
||
else
|
||
log "Creating swap file: ${target_swap_mb}MB"
|
||
# Disable existing swap file if present
|
||
if [[ -f /swapfile ]]; then
|
||
swapoff /swapfile 2>/dev/null
|
||
rm -f /swapfile
|
||
fi
|
||
dd if=/dev/zero of=/swapfile bs=1M count="$target_swap_mb" status=none 2>/dev/null || {
|
||
log_warn "Error creating swap file"
|
||
return 1
|
||
}
|
||
chmod 600 /swapfile
|
||
mkswap /swapfile >/dev/null 2>&1 || { log_warn "mkswap error"; return 1; }
|
||
swapon /swapfile || { log_warn "swapon error"; return 1; }
|
||
# Add to fstab if missing. Precise field match: ignore commented
|
||
# lines and partial matches (e.g. `/swapfile.bak` or an old entry
|
||
# left in a comment).
|
||
if ! awk '!/^[[:space:]]*#/ && $1 == "/swapfile" && $3 == "swap" {found=1} END {exit !(found+0)}' \
|
||
/etc/fstab; then
|
||
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||
fi
|
||
log "Swap file created: ${target_swap_mb}MB"
|
||
fi
|
||
|
||
# Setting swappiness
|
||
sysctl -w vm.swappiness=10 >/dev/null 2>&1
|
||
}
|
||
|
||
# Network interface optimization
|
||
optimize_nic() {
|
||
if [[ -z "$MAIN_NIC" ]]; then
|
||
log_warn "Main NIC not detected, skipping optimization."
|
||
return 1
|
||
fi
|
||
|
||
if ! command -v ethtool &>/dev/null; then
|
||
log_debug "ethtool not found, skipping NIC optimization."
|
||
return 0
|
||
fi
|
||
|
||
log "NIC optimization: $MAIN_NIC"
|
||
# Disable GRO/GSO/TSO — may interfere with VPN traffic
|
||
ethtool -K "$MAIN_NIC" gro off 2>/dev/null || log_debug "GRO: not supported/already off."
|
||
ethtool -K "$MAIN_NIC" gso off 2>/dev/null || log_debug "GSO: not supported/already off."
|
||
ethtool -K "$MAIN_NIC" tso off 2>/dev/null || log_debug "TSO: not supported/already off."
|
||
log "NIC optimization completed."
|
||
}
|
||
|
||
# Full system optimization
|
||
optimize_system() {
|
||
log "Optimizing system for VPN server..."
|
||
detect_hardware
|
||
optimize_swap
|
||
optimize_nic
|
||
log "System optimization completed."
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Sysctl configuration (minimal, for --no-tweaks)
|
||
# ==============================================================================
|
||
|
||
setup_minimal_sysctl() {
|
||
log "Configuring minimal sysctl (--no-tweaks)..."
|
||
local f="/etc/sysctl.d/99-amneziawg-forwarding.conf"
|
||
cat > "$f" << SYSEOF
|
||
# AmneziaWG — minimal settings (--no-tweaks)
|
||
net.ipv4.ip_forward = 1
|
||
SYSEOF
|
||
if [[ "${DISABLE_IPV6:-1}" -eq 1 ]]; then
|
||
cat >> "$f" << SYSEOF
|
||
net.ipv6.conf.all.disable_ipv6 = 1
|
||
net.ipv6.conf.default.disable_ipv6 = 1
|
||
net.ipv6.conf.lo.disable_ipv6 = 1
|
||
SYSEOF
|
||
else
|
||
cat >> "$f" << SYSEOF
|
||
net.ipv6.conf.all.forwarding = 1
|
||
SYSEOF
|
||
fi
|
||
sysctl -p "$f" >/dev/null 2>&1 || log_warn "sysctl -p error"
|
||
log "Minimal sysctl configured."
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Sysctl configuration (extended)
|
||
# ==============================================================================
|
||
|
||
setup_advanced_sysctl() {
|
||
log "Configuring sysctl..."
|
||
local f="/etc/sysctl.d/99-amneziawg-security.conf"
|
||
|
||
# Adaptive buffers based on RAM
|
||
local rmem_max wmem_max netdev_backlog
|
||
if [[ ${TOTAL_RAM_MB:-1024} -ge 2048 ]]; then
|
||
rmem_max=16777216 # 16MB
|
||
wmem_max=16777216
|
||
netdev_backlog=5000
|
||
else
|
||
rmem_max=4194304 # 4MB
|
||
wmem_max=4194304
|
||
netdev_backlog=2500
|
||
fi
|
||
|
||
cat > "$f" << EOF
|
||
# AmneziaWG 2.0 Security/Performance Settings - $(date)
|
||
# Auto-generated by install_amneziawg_en.sh v${SCRIPT_VERSION}
|
||
|
||
# --- IP Forwarding ---
|
||
net.ipv4.ip_forward = 1
|
||
$(if [[ "${DISABLE_IPV6:-1}" -eq 1 ]]; then
|
||
echo "net.ipv6.conf.all.disable_ipv6 = 1"
|
||
echo "net.ipv6.conf.default.disable_ipv6 = 1"
|
||
echo "net.ipv6.conf.lo.disable_ipv6 = 1"
|
||
else
|
||
echo "# IPv6 not disabled"
|
||
echo "net.ipv6.conf.all.forwarding = 1"
|
||
fi)
|
||
|
||
# --- TCP/IP Hardening ---
|
||
# rp_filter = 2 (loose mode): validates source IP against ANY route in the
|
||
# table, not against the reverse path through the same interface. Strict mode
|
||
# (=1) breaks routing on cloud hosters (Hetzner and similar) where the gateway
|
||
# is in a different subnet than the VPS IP — reply packets fail the strict
|
||
# reverse path check. Loose mode is safe: spoofed source IPs are still dropped
|
||
# if no route exists for them at all. Discussion #41 (z036).
|
||
net.ipv4.conf.all.rp_filter = 2
|
||
net.ipv4.conf.default.rp_filter = 2
|
||
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||
net.ipv4.icmp_ignore_bogus_error_responses = 1
|
||
net.ipv4.tcp_syncookies = 1
|
||
net.ipv4.tcp_max_syn_backlog = 4096
|
||
net.ipv4.tcp_synack_retries = 2
|
||
net.ipv4.tcp_syn_retries = 5
|
||
net.ipv4.tcp_rfc1337 = 1
|
||
|
||
# --- Redirects ---
|
||
net.ipv4.conf.all.accept_redirects = 0
|
||
net.ipv4.conf.default.accept_redirects = 0
|
||
net.ipv4.conf.all.secure_redirects = 0
|
||
net.ipv4.conf.default.secure_redirects = 0
|
||
net.ipv4.conf.all.send_redirects = 0
|
||
net.ipv4.conf.default.send_redirects = 0
|
||
net.ipv4.conf.all.accept_source_route = 0
|
||
net.ipv4.conf.default.accept_source_route = 0
|
||
$(if [[ "${DISABLE_IPV6:-1}" -ne 1 ]]; then
|
||
echo "net.ipv6.conf.all.accept_redirects = 0"
|
||
echo "net.ipv6.conf.default.accept_redirects = 0"
|
||
fi)
|
||
|
||
# --- BBR Congestion Control ---
|
||
net.core.default_qdisc = fq
|
||
net.ipv4.tcp_congestion_control = bbr
|
||
|
||
# --- Network Buffers (adaptive) ---
|
||
net.core.rmem_max = ${rmem_max}
|
||
net.core.wmem_max = ${wmem_max}
|
||
net.core.netdev_max_backlog = ${netdev_backlog}
|
||
|
||
# --- Conntrack ---
|
||
net.netfilter.nf_conntrack_max = 65536
|
||
|
||
# --- Security ---
|
||
vm.swappiness = 10
|
||
kernel.sysrq = 0
|
||
|
||
# Suppress kernel warning/notice messages in the hoster VNC console.
|
||
# Without this, fail2ban UFW blocks spam the VNC window with "[UFW BLOCK]"
|
||
# lines and make the console unusable.
|
||
# Format: console_loglevel default_msg_loglevel min_console_loglevel default_console_loglevel
|
||
# Value 3 = KERN_ERR — only errors and above reach the console.
|
||
# Discussion #41 (z036).
|
||
kernel.printk = 3 4 1 3
|
||
EOF
|
||
|
||
log "Applying sysctl..."
|
||
if ! sysctl -p "$f" >/dev/null 2>&1; then
|
||
# nf_conntrack may be unavailable before module is loaded
|
||
log_warn "Some sysctl parameters did not apply (nf_conntrack will be available later)."
|
||
sysctl -p "$f" 2>/dev/null || true
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Firewall and security
|
||
# ==============================================================================
|
||
|
||
# Detect the real SSH port(s) so the UFW rule does not lock you out.
|
||
# Without this, ufw limit 22/tcp + default deny incoming cuts server access
|
||
# after ufw enable when SSH runs on a non-standard port (Issue #91).
|
||
# Self-contained: called at step 4, BEFORE awg_common.sh is sourced.
|
||
# Sources:
|
||
# 1. CLI_SSH_PORT (--ssh-port=, manual override, comma-separated list) - authoritative
|
||
# otherwise UNION (not fallback - so we never miss the real port):
|
||
# 2. sshd -T (effective config: `Port` AND `ListenAddress host:port`, honours drop-ins)
|
||
# 3. ss -tlnp (real sshd listening sockets: ground truth for ListenAddress)
|
||
# 4. /etc/ssh/sshd_config + sshd_config.d/*.conf (parsing, only if 2-3 are empty)
|
||
# 5. 22 (default, if nothing is found)
|
||
# Prints unique valid ports (1-65535) space-separated to stdout.
|
||
# IMPORTANT: only log_warn/log_error (stderr) inside; log() writes to stdout
|
||
# and would corrupt the $(detect_ssh_ports) capture.
|
||
detect_ssh_ports() {
|
||
local ports="" p pp valid=""
|
||
# awk: pulls the port from `port N` and `listenaddress host:port` lines
|
||
# (IPv4 and [IPv6]); a bare address without a port is skipped.
|
||
local awk_ports='tolower($1)=="port"&&$2~/^[0-9]+$/{print $2} tolower($1)=="listenaddress"{v=$2; if(v~/\]:[0-9]+$/){sub(/.*\]:/,"",v); print v} else if(v~/^[0-9.]+:[0-9]+$/){sub(/.*:/,"",v); print v}}'
|
||
|
||
if [[ -n "$CLI_SSH_PORT" ]]; then
|
||
# 1. Manual override - authoritative source
|
||
ports="${CLI_SSH_PORT//,/ }"
|
||
else
|
||
# 2. sshd -T: effective configuration (Port + ListenAddress, drop-ins)
|
||
if command -v sshd &>/dev/null; then
|
||
ports+=" $(sshd -T 2>/dev/null | awk "$awk_ports" | tr '\n' ' ')"
|
||
fi
|
||
# 3. ss: real sshd listening sockets. Merged, not fallback - catches the
|
||
# ListenAddress port even when sshd -T prints the default port 22.
|
||
if command -v ss &>/dev/null; then
|
||
ports+=" $(ss -H -tlnp 2>/dev/null | awk '/"sshd"/{n=split($4,a,":"); print a[n]}' | tr '\n' ' ')"
|
||
fi
|
||
# 4. Parse config files - only if sshd -T and ss yielded nothing
|
||
if [[ -z "${ports// }" ]]; then
|
||
local cfgs=() d
|
||
[[ -f /etc/ssh/sshd_config ]] && cfgs+=(/etc/ssh/sshd_config)
|
||
for d in /etc/ssh/sshd_config.d/*.conf; do
|
||
[[ -f "$d" ]] && cfgs+=("$d")
|
||
done
|
||
if [[ "${#cfgs[@]}" -gt 0 ]]; then
|
||
ports+=" $(awk "$awk_ports" "${cfgs[@]}" 2>/dev/null | tr '\n' ' ')"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Validate (decimal 1-65535, 10# guards against octal) + dedup preserving order
|
||
for p in $ports; do
|
||
if [[ "$p" =~ ^[0-9]+$ ]]; then
|
||
pp=$((10#$p))
|
||
if (( pp >= 1 && pp <= 65535 )); then
|
||
case " $valid " in
|
||
*" $pp "*) ;;
|
||
*) valid+="${valid:+ }$pp" ;;
|
||
esac
|
||
fi
|
||
fi
|
||
done
|
||
|
||
# 5. Default if detection produced nothing valid
|
||
if [[ -z "$valid" ]]; then
|
||
[[ -n "$CLI_SSH_PORT" ]] && log_warn "--ssh-port has no valid ports, falling back to 22."
|
||
valid="22"
|
||
fi
|
||
printf '%s' "$valid"
|
||
}
|
||
|
||
setup_improved_firewall() {
|
||
log "Configuring UFW..."
|
||
if ! command -v ufw &>/dev/null; then install_packages ufw; fi
|
||
|
||
# Detect main network interface for route rule
|
||
local main_nic
|
||
main_nic=$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1); exit}')
|
||
if [[ -z "$main_nic" ]]; then
|
||
log_warn "Could not detect network interface for UFW route."
|
||
fi
|
||
|
||
# Detect the real SSH port(s) so we do not lock out access on a non-standard port (Issue #91)
|
||
local ssh_ports _sp
|
||
ssh_ports=$(detect_ssh_ports)
|
||
log "SSH port(s) for the UFW rule: ${ssh_ports}"
|
||
|
||
# Port change on reinstall: delete the old port's rule before adding the
|
||
# new one, otherwise the old UDP port stays open forever - the only other
|
||
# ufw delete lives in uninstall and reads the already rewritten config
|
||
# (Issue #175). SSH limit rules are deliberately left alone: auto-removing
|
||
# an SSH rule on a misdetected port would cut off access to the server.
|
||
if [[ -n "${PREV_AWG_PORT:-}" && "$PREV_AWG_PORT" =~ ^[0-9]+$ \
|
||
&& "$PREV_AWG_PORT" != "$AWG_PORT" ]]; then
|
||
if ufw delete allow "${PREV_AWG_PORT}/udp" >/dev/null 2>&1; then
|
||
log "UFW: old port rule ${PREV_AWG_PORT}/udp deleted (port changed to ${AWG_PORT})."
|
||
# Success - remove the pending delete from awgsetup_cfg.init. On
|
||
# failure the key stays: the next run retries instead of losing
|
||
# it for good (PR #176).
|
||
sed -i '/^export PREV_AWG_PORT=/d' "$CONFIG_FILE" 2>/dev/null \
|
||
|| log_warn "Failed to remove PREV_AWG_PORT from $CONFIG_FILE."
|
||
PREV_AWG_PORT=""
|
||
else
|
||
log_warn "UFW: failed to delete the old port rule ${PREV_AWG_PORT}/udp (the rule may not exist). Will retry on the next installer run."
|
||
fi
|
||
fi
|
||
|
||
local ufw_errors=0
|
||
if ufw status 2>/dev/null | grep -q inactive; then
|
||
log "UFW is inactive. Configuring..."
|
||
ufw default deny incoming || { log_warn "UFW: failed to set default deny incoming"; ufw_errors=1; }
|
||
ufw default allow outgoing || { log_warn "UFW: failed to set default allow outgoing"; ufw_errors=1; }
|
||
for _sp in $ssh_ports; do
|
||
ufw limit "${_sp}/tcp" comment "SSH Rate Limit" || { log_warn "UFW: failed to limit SSH (port ${_sp})"; ufw_errors=1; }
|
||
done
|
||
ufw allow "${AWG_PORT}/udp" comment "AmneziaWG VPN" || { log_warn "UFW: failed to allow VPN port"; ufw_errors=1; }
|
||
if [[ -n "$main_nic" ]]; then
|
||
ufw route allow in on awg0 out on "$main_nic" comment "AmneziaWG Routing" \
|
||
|| { log_warn "UFW: failed to add route rule"; ufw_errors=1; }
|
||
log "VPN routing rule added (awg0 → ${main_nic})."
|
||
fi
|
||
if [[ "$ufw_errors" -ne 0 ]]; then
|
||
log_error "One or more UFW rules failed to apply. Check settings manually."
|
||
return 1
|
||
fi
|
||
log "UFW rules added."
|
||
log_warn "--- ENABLING UFW ---"
|
||
log_warn "UFW will allow SSH ONLY on port(s): ${ssh_ports}. Make sure you connect over it."
|
||
if [[ "$ssh_ports" != "22" ]]; then
|
||
log_warn "NOTE: SSH on a non-standard port. If the port is detected wrong, you will lose server access."
|
||
log_warn "Override if needed: --ssh-port=PORT"
|
||
fi
|
||
local confirm_ufw="y"
|
||
if [[ "$AUTO_YES" -eq 0 ]]; then
|
||
sleep 5
|
||
read -rp "Enable UFW? [y/N]: " confirm_ufw < /dev/tty
|
||
else
|
||
log "Auto-enabling UFW (--yes)."
|
||
fi
|
||
if ! [[ "$confirm_ufw" =~ ^[[:space:]]*[Yy]([Ee][Ss])?[[:space:]]*$ ]]; then
|
||
log_warn "UFW configured but not activated by your choice."
|
||
log_warn "The server is running WITHOUT a firewall. Enable later: sudo ufw enable"
|
||
return 0
|
||
fi
|
||
if ! ufw --force enable; then die "UFW enable error."; fi
|
||
log "UFW enabled."
|
||
# Marker: UFW was enabled by our installer (not by the user beforehand).
|
||
# Used in step_uninstall to decide whether disabling UFW is safe.
|
||
# Protects against destructive uninstall on a VPS where UFW was used
|
||
# for SSH/web hardening BEFORE our script was installed (audit).
|
||
touch "$AWG_DIR/.ufw_enabled_by_installer" 2>/dev/null || \
|
||
log_warn "Failed to create UFW marker — uninstall will not disable UFW automatically."
|
||
else
|
||
log "UFW is active. Updating rules..."
|
||
for _sp in $ssh_ports; do
|
||
ufw limit "${_sp}/tcp" comment "SSH Rate Limit" || { log_warn "UFW: failed to limit SSH (port ${_sp})"; ufw_errors=1; }
|
||
done
|
||
ufw allow "${AWG_PORT}/udp" comment "AmneziaWG VPN" || { log_warn "UFW: failed to allow VPN port"; ufw_errors=1; }
|
||
if [[ -n "$main_nic" ]]; then
|
||
ufw route allow in on awg0 out on "$main_nic" comment "AmneziaWG Routing" \
|
||
|| { log_warn "UFW: failed to add route rule"; ufw_errors=1; }
|
||
fi
|
||
if [[ "$ufw_errors" -ne 0 ]]; then
|
||
log_error "One or more UFW rules failed to apply. Check settings manually."
|
||
return 1
|
||
fi
|
||
ufw reload || log_warn "UFW reload error."
|
||
log "Rules updated."
|
||
fi
|
||
log "UFW configured."
|
||
log "$(ufw status verbose 2>&1)"
|
||
return 0
|
||
}
|
||
|
||
secure_files() {
|
||
log "Setting secure file permissions..."
|
||
chmod 700 "$AWG_DIR" 2>/dev/null
|
||
chmod 700 /etc/amnezia 2>/dev/null
|
||
chmod 700 /etc/amnezia/amneziawg 2>/dev/null
|
||
chmod 600 /etc/amnezia/amneziawg/*.conf 2>/dev/null
|
||
find "$AWG_DIR" -name "*.conf" -type f -exec chmod 600 {} \; 2>/dev/null
|
||
find "$AWG_DIR" -name "*.key" -type f -exec chmod 600 {} \; 2>/dev/null
|
||
find "$AWG_DIR" -name "*.png" -type f -exec chmod 600 {} \; 2>/dev/null
|
||
find "$AWG_DIR" -name "*.vpnuri" -type f -exec chmod 600 {} \; 2>/dev/null
|
||
if [[ -d "$KEYS_DIR" ]]; then
|
||
chmod 700 "$KEYS_DIR" 2>/dev/null
|
||
chmod 600 "$KEYS_DIR"/* 2>/dev/null
|
||
fi
|
||
[[ -f "$CONFIG_FILE" ]] && chmod 600 "$CONFIG_FILE"
|
||
[[ -f "$LOG_FILE" ]] && chmod 640 "$LOG_FILE"
|
||
[[ -f "$MANAGE_SCRIPT_PATH" ]] && chmod 700 "$MANAGE_SCRIPT_PATH"
|
||
[[ -f "$COMMON_SCRIPT_PATH" ]] && chmod 700 "$COMMON_SCRIPT_PATH"
|
||
log "File permissions set."
|
||
}
|
||
|
||
setup_fail2ban() {
|
||
log "Configuring Fail2Ban..."
|
||
if ! command -v fail2ban-client &>/dev/null; then
|
||
install_packages fail2ban
|
||
# Marker: the fail2ban package was installed by our installer (rather
|
||
# than being present before it). step_uninstall purges fail2ban only
|
||
# when the marker exists, so it never wipes SSH protection the user
|
||
# had set up beforehand (symmetric to .ufw_enabled_by_installer).
|
||
if command -v fail2ban-client &>/dev/null; then
|
||
touch "$AWG_DIR/.fail2ban_installed_by_installer" 2>/dev/null || \
|
||
log_warn "Failed to create the fail2ban marker - uninstall will not remove the fail2ban package."
|
||
fi
|
||
fi
|
||
if ! command -v fail2ban-client &>/dev/null; then
|
||
log_warn "Fail2Ban not installed, skipping."
|
||
return 1
|
||
fi
|
||
|
||
# banaction=ufw only takes effect with UFW active: if the user declined to
|
||
# enable UFW at step 4, bans land in an inactive ruleset and effectively
|
||
# do nothing (while fail2ban itself looks "green").
|
||
if ufw status 2>/dev/null | grep -q inactive; then
|
||
log_warn "UFW is not active: fail2ban bans (banaction=ufw) have no effect while UFW is off. Enable with: sudo ufw enable"
|
||
fi
|
||
|
||
# Debian: journald instead of rsyslog, needs python3-systemd
|
||
if [[ "${OS_ID:-}" == "debian" ]]; then
|
||
install_packages python3-systemd
|
||
fi
|
||
|
||
mkdir -p /etc/fail2ban/jail.d 2>/dev/null
|
||
|
||
# Backend: systemd for Debian and Ubuntu (no rsyslog)
|
||
local f2b_backend="systemd"
|
||
|
||
cat > /etc/fail2ban/jail.d/amneziawg.conf << JAILEOF || { log_warn "jail.d/amneziawg.conf write error"; return 1; }
|
||
# AmneziaWG — SSH protection (managed by amneziawg-installer)
|
||
[sshd]
|
||
enabled = true
|
||
backend = ${f2b_backend}
|
||
maxretry = 5
|
||
findtime = 10m
|
||
bantime = 1h
|
||
banaction = ufw
|
||
JAILEOF
|
||
|
||
systemctl restart fail2ban
|
||
# Wait a second, service is restarting...
|
||
sleep 1
|
||
|
||
if systemctl is-active --quiet fail2ban; then
|
||
log "Fail2Ban configured and restarted."
|
||
else
|
||
log_warn "fail2ban restart error"
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Service status check
|
||
# ==============================================================================
|
||
|
||
check_service_status() {
|
||
log "Checking service status..."
|
||
local ok=1
|
||
|
||
if systemctl is-failed --quiet awg-quick@awg0; then
|
||
log_error "Service FAILED!"
|
||
ok=0
|
||
fi
|
||
|
||
if ! ip addr show awg0 &>/dev/null; then
|
||
log_error "Interface awg0 not found!"
|
||
ok=0
|
||
fi
|
||
|
||
if ! awg show 2>/dev/null | grep -q "interface: awg0"; then
|
||
log_error "awg show cannot see interface!"
|
||
ok=0
|
||
fi
|
||
|
||
# Port check
|
||
local port_check=${AWG_PORT:-0}
|
||
if [[ "$port_check" -eq 0 ]] && [[ -f "$CONFIG_FILE" ]]; then
|
||
# shellcheck source=/dev/null
|
||
port_check=$(safe_read_config_key "AWG_PORT" "$CONFIG_FILE")
|
||
port_check=${port_check:-0}
|
||
fi
|
||
if [[ "$port_check" -ne 0 ]]; then
|
||
if ! ss -lunp | grep -q ":${port_check} "; then
|
||
log_error "Port $port_check/udp is not listening!"
|
||
ok=0
|
||
fi
|
||
fi
|
||
|
||
# AWG 2.0 parameter check
|
||
if awg show awg0 2>/dev/null | grep -q "jc:"; then
|
||
log "AWG 2.0 parameters active."
|
||
else
|
||
log_warn "AWG 2.0 parameters not detected in awg show."
|
||
fi
|
||
|
||
if [[ "$ok" -eq 1 ]]; then
|
||
log "Service and interface status OK."
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Diagnostics
|
||
# ==============================================================================
|
||
|
||
create_diagnostic_report() {
|
||
# --diagnostic runs BEFORE initialize_setup (home of the main root check):
|
||
# as a regular user every log_msg write into /root/awg fails, the report
|
||
# is not created, and exit 0 would look like a false success.
|
||
if [ "$(id -u)" -ne 0 ]; then die "Run the script as root (sudo bash $0 --diagnostic)."; fi
|
||
log "Creating diagnostics..."
|
||
local rf
|
||
rf="$AWG_DIR/diag_$(date +%F_%T).txt"
|
||
{
|
||
echo "=== AMNEZIAWG 2.0 DIAGNOSTIC REPORT ==="
|
||
echo ""
|
||
echo "!!! WARNING: This report contains IP addresses, ports and routes."
|
||
echo "!!! Review and redact private data before posting to public issues."
|
||
echo ""
|
||
echo "Generated: $(date)"
|
||
echo "Hostname: $(hostname)"
|
||
echo "Installer: v${SCRIPT_VERSION}"
|
||
echo ""
|
||
echo "--- OS ---"
|
||
lsb_release -ds 2>/dev/null || cat /etc/os-release
|
||
uname -a
|
||
echo ""
|
||
echo "--- Hardware ---"
|
||
echo "RAM: $(awk '/MemTotal/ {printf "%.0f MB", $2/1024}' /proc/meminfo)"
|
||
echo "CPU: $(nproc) cores"
|
||
echo "Swap: $(free -m | awk '/Swap:/ {print $2}') MB"
|
||
echo ""
|
||
echo "--- Configuration ($CONFIG_FILE) ---"
|
||
if [[ -f "$CONFIG_FILE" ]]; then
|
||
sed 's/AWG_ENDPOINT=.*/AWG_ENDPOINT=[HIDDEN]/' "$CONFIG_FILE"
|
||
else
|
||
echo "File not found"
|
||
fi
|
||
echo ""
|
||
echo "--- Server Config ($SERVER_CONF_FILE) ---"
|
||
# Mask private key
|
||
if [[ -f "$SERVER_CONF_FILE" ]]; then
|
||
sed 's/PrivateKey = .*/PrivateKey = [HIDDEN]/' "$SERVER_CONF_FILE"
|
||
else
|
||
echo "File not found"
|
||
fi
|
||
echo ""
|
||
echo "--- Service Status ---"
|
||
systemctl status awg-quick@awg0 --no-pager -l 2>/dev/null || echo "Service not found"
|
||
echo ""
|
||
echo "--- AWG Status ---"
|
||
awg show 2>/dev/null || echo "awg show failed"
|
||
echo ""
|
||
echo "--- AWG Version ---"
|
||
awg --version 2>/dev/null || echo "awg --version failed"
|
||
echo ""
|
||
echo "--- Network Interfaces ---"
|
||
ip a 2>/dev/null
|
||
echo ""
|
||
echo "--- Listening Ports ---"
|
||
ss -lunp 2>/dev/null
|
||
echo ""
|
||
echo "--- Firewall Status ---"
|
||
if command -v ufw &>/dev/null; then ufw status verbose; else echo "UFW N/A"; fi
|
||
echo ""
|
||
echo "--- Routing Table ---"
|
||
ip route 2>/dev/null
|
||
echo ""
|
||
echo "--- Cascade / Split Routing ---"
|
||
# The cascade (CASCADE.en.md) lives outside awg0.conf: its own table, mark, ipset and mangle
|
||
# rules. Without this block the report cannot tell whether the split is applied (issue #212).
|
||
if [ -f "$AWG_DIR/awg-routing.sh" ] || ip link show awg1 &>/dev/null; then
|
||
# is-active prints "inactive" AND returns non-zero, so $(... || echo N/A) would emit
|
||
# BOTH strings at once. Take the output as is; N/A only when it comes back empty.
|
||
local _casc_active _casc_enabled _casc_out
|
||
_casc_active=$(systemctl is-active awg-routing 2>/dev/null || true)
|
||
_casc_enabled=$(systemctl is-enabled awg-routing 2>/dev/null || true)
|
||
echo "unit awg-routing: active=${_casc_active:-N/A}, enabled=${_casc_enabled:-N/A}"
|
||
# "not found" and "could not check" are kept apart on purpose: silencing stderr and
|
||
# printing the same line would turn a failed command into a claim that the rules are
|
||
# absent, sending triage the wrong way. grep -m10 instead of | head -10: it does not
|
||
# break the pipe, so pipefail cannot return 141 and fire the || branch after output.
|
||
if _casc_out=$(ip rule show 2>&1); then
|
||
grep -w fwmark <<< "$_casc_out" || echo "ip rule: no rules by mark"
|
||
else
|
||
echo "ip rule: CHECK FAILED: $(head -1 <<< "$_casc_out")"
|
||
fi
|
||
echo "table 100: $(ip route show table 100 2>/dev/null | tr '\n' '; ')"
|
||
echo "ipset sets: $(ipset list -n 2>/dev/null | tr '\n' ' ' || echo 'N/A')"
|
||
if _casc_out=$(ipset list ru 2>&1); then
|
||
grep "Number of entries" <<< "$_casc_out" || echo "ipset ru: entry counter not found"
|
||
else
|
||
echo "ipset ru: set not present ($(head -1 <<< "$_casc_out"))"
|
||
fi
|
||
echo "ru.zone: $(stat -c '%y, %s bytes' "$AWG_DIR/ru.zone" 2>/dev/null || echo 'no file')"
|
||
if _casc_out=$(iptables -t mangle -S PREROUTING 2>&1); then
|
||
grep -m10 -E "match-set|MARK" <<< "$_casc_out" || echo "mangle PREROUTING: no cascade rules"
|
||
else
|
||
echo "mangle PREROUTING: CHECK FAILED: $(head -1 <<< "$_casc_out")"
|
||
fi
|
||
# Grep for "-o awg1" rather than MASQUERADE: a plain MASQUERADE on the external
|
||
# interface is added by the installer itself in PostUp, it exists on EVERY install, and
|
||
# matching it would make the "no rules" branch unreachable while the report showed NAT
|
||
# as present with the cascade rule missing. NAT must be checked: the script applies it
|
||
# LAST, so a run cut short
|
||
# leaves everything else in place but not that rule. The symptom is deceptive: Russian
|
||
# sites work and nothing else does, while a report without this line would show a
|
||
# perfectly healthy cascade.
|
||
if _casc_out=$(iptables -t nat -S POSTROUTING 2>&1); then
|
||
grep -m10 -- "-o awg1" <<< "$_casc_out" || echo "nat POSTROUTING: no cascade rule (-o awg1)"
|
||
else
|
||
echo "nat POSTROUTING: CHECK FAILED: $(head -1 <<< "$_casc_out")"
|
||
fi
|
||
else
|
||
echo "not configured"
|
||
fi
|
||
echo ""
|
||
echo "--- Kernel Params ---"
|
||
sysctl net.ipv4.ip_forward net.ipv6.conf.all.disable_ipv6 2>/dev/null
|
||
echo ""
|
||
echo "--- AWG Journal (last 50) ---"
|
||
journalctl -u awg-quick@awg0 -n 50 --no-pager --output=cat 2>/dev/null || echo "N/A"
|
||
echo ""
|
||
echo "--- Client List ---"
|
||
grep "^#_Name = " "$SERVER_CONF_FILE" 2>/dev/null | sed 's/^#_Name = //' || echo "N/A"
|
||
echo ""
|
||
echo "--- DKMS Status ---"
|
||
dkms status 2>/dev/null || echo "N/A"
|
||
echo ""
|
||
echo "--- Module Info ---"
|
||
modinfo amneziawg 2>/dev/null || echo "N/A"
|
||
echo ""
|
||
echo "=== END ==="
|
||
} > "$rf" || log_error "Report write error."
|
||
chmod 600 "$rf" || log_warn "Report chmod error."
|
||
log "Report: $rf"
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Uninstall
|
||
# ==============================================================================
|
||
|
||
step_uninstall() {
|
||
log "### AMNEZIAWG UNINSTALL ###"
|
||
echo ""
|
||
echo "WARNING! Complete removal of AmneziaWG and configurations."
|
||
echo "This process is irreversible!"
|
||
echo ""
|
||
local confirm="" backup="Y"
|
||
if [[ "$AUTO_YES" -eq 0 ]]; then
|
||
read -rp "Are you sure? (type 'yes'): " confirm < /dev/tty
|
||
if [[ "$confirm" != "yes" ]]; then log "Uninstall cancelled."; exit 1; fi
|
||
read -rp "Create backup before removal? [Y/n]: " backup < /dev/tty
|
||
else
|
||
log "Auto-confirming uninstall (--yes)."
|
||
fi
|
||
if [[ -z "$backup" || "$backup" =~ ^[[:space:]]*[Yy]([Ee][Ss])?[[:space:]]*$ ]]; then
|
||
local bf
|
||
bf="$HOME/awg_uninstall_backup_$(date +%F_%H-%M-%S).tar.gz"
|
||
log "Creating backup: $bf"
|
||
if tar -czf "$bf" -C / etc/amnezia "$AWG_DIR" --ignore-failed-read 2>/dev/null \
|
||
&& chmod 600 "$bf"; then
|
||
log "Backup created: $bf"
|
||
else
|
||
log_warn "Backup failed — check $bf manually before continuing"
|
||
fi
|
||
fi
|
||
# Load --no-tweaks flag from saved configuration
|
||
local saved_no_tweaks=0
|
||
if [[ -f "$CONFIG_FILE" ]]; then
|
||
# shellcheck source=/dev/null
|
||
saved_no_tweaks=$(safe_read_config_key "NO_TWEAKS" "$CONFIG_FILE" 2>/dev/null) || saved_no_tweaks=0
|
||
saved_no_tweaks=${saved_no_tweaks:-0}
|
||
fi
|
||
log "Stopping service..."
|
||
systemctl stop awg-quick@awg0 2>/dev/null
|
||
# Isolation DROP rules (issue #178): the on-disk config's PostDown may no
|
||
# longer contain -D DROP (an on->off reinstall interrupted between steps
|
||
# 6 and 7) - drain stale rules explicitly, same as step 7.
|
||
while iptables -D FORWARD -i awg0 -o awg0 -j DROP 2>/dev/null; do :; done
|
||
while ip6tables -D FORWARD -i awg0 -o awg0 -j DROP 2>/dev/null; do :; done
|
||
systemctl disable awg-quick@awg0 2>/dev/null
|
||
modprobe -r amneziawg 2>/dev/null || true
|
||
# v5.12.0+: kernel module auto-repair on kernel upgrade.
|
||
# Remove apt hook and systemd unit BEFORE apt purge so the hook does not
|
||
# fire during amneziawg-dkms purge (the helper would try to rebuild DKMS,
|
||
# but the package is already gone). Files may be absent on installs from
|
||
# before v5.12.0 — all operations are idempotent.
|
||
log "Removing kernel module auto-repair components (v5.12.0+)..."
|
||
if systemctl is-enabled amneziawg-ensure-module.service &>/dev/null; then
|
||
systemctl disable amneziawg-ensure-module.service 2>/dev/null || true
|
||
fi
|
||
rm -f /etc/systemd/system/amneziawg-ensure-module.service \
|
||
/etc/apt/apt.conf.d/99-amneziawg-post-kernel \
|
||
/etc/logrotate.d/amneziawg-ensure-module \
|
||
/usr/local/sbin/amneziawg-ensure-module \
|
||
2>/dev/null
|
||
# Also clean up staging dotfiles that may be left over from an interrupted install (atomic deploy).
|
||
rm -f /etc/systemd/system/.amneziawg-ensure-module.service.new \
|
||
/etc/apt/apt.conf.d/.99-amneziawg-post-kernel.new \
|
||
/etc/logrotate.d/.amneziawg-ensure-module.new \
|
||
/usr/local/sbin/.amneziawg-ensure-module.new \
|
||
2>/dev/null || true
|
||
rm -f /var/log/amneziawg-ensure-module.log* 2>/dev/null || true
|
||
rm -rf /var/lib/amneziawg 2>/dev/null || true
|
||
systemctl daemon-reload 2>/dev/null || true
|
||
if [[ "$saved_no_tweaks" -eq 0 ]]; then
|
||
log "Cleaning up AmneziaWG UFW rules..."
|
||
if command -v ufw &>/dev/null; then
|
||
local port_to_del
|
||
if [[ -f "$CONFIG_FILE" ]]; then
|
||
# shellcheck source=/dev/null
|
||
port_to_del=$(safe_read_config_key "AWG_PORT" "$CONFIG_FILE")
|
||
fi
|
||
port_to_del=${port_to_del:-39743}
|
||
# Removing our rules is ALWAYS performed (idempotent)
|
||
ufw delete allow "${port_to_del}/udp" 2>/dev/null
|
||
# To delete a route rule we need an exact match with how it was created:
|
||
# "ufw route allow in on awg0 out on <nic>". Without "out on", UFW will
|
||
# not find the rule and it stays in ufw status. Discussion #41.
|
||
local _nic
|
||
_nic=$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1); exit}')
|
||
if [[ -n "$_nic" ]]; then
|
||
ufw route delete allow in on awg0 out on "$_nic" 2>/dev/null
|
||
fi
|
||
# Fallback: try deleting without out on (for compatibility with older rules)
|
||
ufw route delete allow in on awg0 2>/dev/null
|
||
|
||
# ufw disable runs ONLY if UFW was enabled by our installer.
|
||
# Protects against destructive uninstall on a VPS where UFW was used
|
||
# for SSH/web hardening BEFORE our script was installed (audit).
|
||
# Backwards compat: older installs without the marker keep UFW active.
|
||
if [[ -f "$AWG_DIR/.ufw_enabled_by_installer" ]]; then
|
||
log "Disabling UFW (was enabled by our installer)..."
|
||
ufw --force disable 2>/dev/null
|
||
rm -f "$AWG_DIR/.ufw_enabled_by_installer"
|
||
else
|
||
log "Leaving UFW active (was active before installation, or older installer version)."
|
||
fi
|
||
fi
|
||
log "Removing Fail2Ban bans..."
|
||
if command -v fail2ban-client &>/dev/null; then
|
||
fail2ban-client unban --all 2>/dev/null || true
|
||
systemctl stop fail2ban 2>/dev/null
|
||
fi
|
||
else
|
||
log "Skipping UFW/Fail2Ban (installed with --no-tweaks)."
|
||
fi
|
||
log "Removing packages..."
|
||
# Clear the hold on the PPA packages (set by the H0 pinned path) BEFORE the PPA
|
||
# is removed: `apt-mark unhold` needs an installed or candidate version, and once
|
||
# the PPA (below) is gone the candidate disappears and apt-mark fails with
|
||
# 'Can't select ... version', leaving the hold in dpkg selections -> that blocks
|
||
# a future reinstall. The dpkg fallback clears the selection directly if the PPA
|
||
# was already removed by a prior run.
|
||
local _hp
|
||
for _hp in amneziawg amneziawg-dkms; do
|
||
apt-mark unhold "$_hp" >/dev/null 2>&1 || true
|
||
if dpkg --get-selections "$_hp" 2>/dev/null | grep -q '[[:space:]]hold$'; then
|
||
echo "$_hp deinstall" | dpkg --set-selections >/dev/null 2>&1 || true
|
||
fi
|
||
done
|
||
if [[ "$saved_no_tweaks" -eq 0 ]]; then
|
||
local _purge_pkgs=(amneziawg-dkms amneziawg-tools qrencode)
|
||
# Purge fail2ban only if we installed it ourselves (marker from
|
||
# setup_fail2ban) - otherwise SSH protection the user had before the
|
||
# installer must not disappear together with the VPN. Our jail file
|
||
# is removed below in any case. Backwards compat: old installs
|
||
# without the marker keep fail2ban installed.
|
||
if [[ -f "$AWG_DIR/.fail2ban_installed_by_installer" ]]; then
|
||
_purge_pkgs+=(fail2ban)
|
||
else
|
||
log "fail2ban left installed (was present before the installer or an older installer version)."
|
||
fi
|
||
DEBIAN_FRONTEND=noninteractive apt-get purge -y "${_purge_pkgs[@]}" 2>/dev/null || log_warn "Purge error."
|
||
else
|
||
DEBIAN_FRONTEND=noninteractive apt-get purge -y amneziawg-dkms amneziawg-tools qrencode 2>/dev/null || log_warn "Purge error."
|
||
fi
|
||
DEBIAN_FRONTEND=noninteractive apt-get autoremove -y 2>/dev/null || log_warn "Autoremove error."
|
||
log "Removing PPA and files..."
|
||
rm -f /etc/apt/sources.list.d/amnezia-ppa.sources \
|
||
/etc/apt/sources.list.d/amnezia-ppa.list \
|
||
/etc/apt/sources.list.d/amnezia-ubuntu-ppa-*.list \
|
||
/etc/apt/sources.list.d/amnezia-ubuntu-ppa-*.sources \
|
||
/etc/apt/keyrings/amnezia-ppa.gpg 2>/dev/null
|
||
rm -rf /etc/amnezia \
|
||
/etc/modules-load.d/amneziawg.conf \
|
||
/etc/sysctl.d/99-amneziawg-security.conf \
|
||
/etc/sysctl.d/99-amneziawg-forwarding.conf \
|
||
/etc/logrotate.d/amneziawg* || log_warn "File removal error."
|
||
if [[ "$saved_no_tweaks" -eq 0 ]]; then
|
||
# Remove only our own jail file.
|
||
# Previously there was a heuristic "if jail.local contains banaction = ufw,
|
||
# remove the whole file" — too broad a filter, could wipe an unrelated
|
||
# jail.local with custom jails. Heuristic removed (audit).
|
||
# If a user still has a jail.local from very old installer versions,
|
||
# leave it for them to deal with.
|
||
rm -f /etc/fail2ban/jail.d/amneziawg.conf 2>/dev/null
|
||
# If fail2ban was not purged (it predates us) - restart it without our
|
||
# jail: it was stopped above (systemctl stop fail2ban).
|
||
if command -v fail2ban-client &>/dev/null && [[ ! -f "$AWG_DIR/.fail2ban_installed_by_installer" ]]; then
|
||
systemctl restart fail2ban 2>/dev/null || log_warn "Failed to restart fail2ban after removing our jail."
|
||
fi
|
||
fi
|
||
log "Removing DKMS..."
|
||
# Properly deregister the DKMS module (any amneziawg/* version) and remove the
|
||
# source tree in /usr/src, not just the state in /var/lib/dkms. The hold on the
|
||
# PPA packages was cleared above (before PPA removal). `dkms status`:
|
||
# 'amneziawg/1.0.0, <kern>...'.
|
||
if command -v dkms >/dev/null 2>&1; then
|
||
local _dv
|
||
while IFS= read -r _dv; do
|
||
[[ -n "$_dv" ]] || continue
|
||
if ! dkms remove -m amneziawg -v "$_dv" --all >/dev/null 2>&1; then
|
||
log_warn "dkms remove amneziawg/$_dv failed - cleaning files manually."
|
||
fi
|
||
done < <(dkms status 2>/dev/null | awk -F'[,/ ]+' '/^amneziawg[,/]/{print $2}' | sort -u)
|
||
fi
|
||
rm -rf /var/lib/dkms/amneziawg* /usr/src/amneziawg-* || log_warn "DKMS removal error."
|
||
# Clean up any leftover built .ko (if dkms remove did not run) + depmod.
|
||
find /lib/modules -name 'amneziawg.ko*' -path '*/updates/dkms/*' -delete 2>/dev/null || true
|
||
command -v depmod >/dev/null 2>&1 && depmod -a >/dev/null 2>&1 || true
|
||
log "Restoring sysctl..."
|
||
# Only the exact lines legacy versions of our installer wrote (=1 for
|
||
# all/default/lo). Previously ANY line containing disable_ipv6 was removed -
|
||
# including lines added by the user themselves (e.g. an =0 override).
|
||
if grep -qE '^net\.ipv6\.conf\.(all|default|lo)\.disable_ipv6[[:space:]]*=[[:space:]]*1[[:space:]]*$' /etc/sysctl.conf 2>/dev/null; then
|
||
sed -i -E '/^net\.ipv6\.conf\.(all|default|lo)\.disable_ipv6[[:space:]]*=[[:space:]]*1[[:space:]]*$/d' /etc/sysctl.conf || log_warn "sed sysctl.conf error"
|
||
fi
|
||
sysctl -p --system 2>/dev/null
|
||
rm -f /etc/apt/sources.list.d/*.bak-* "$AWG_DIR"/ubuntu.sources.bak-* 2>/dev/null || true
|
||
log "Removing cron and scripts..."
|
||
rm -f /etc/cron.d/awg-expiry 2>/dev/null
|
||
log "=== UNINSTALL COMPLETED ==="
|
||
# Copy log and remove working directory
|
||
cp "$LOG_FILE" "$HOME/awg_uninstall.log" 2>/dev/null || true
|
||
rm -rf "$AWG_DIR" 2>/dev/null || true
|
||
exit 0
|
||
}
|
||
|
||
# ==============================================================================
|
||
# STEP 0: Initialization
|
||
# ==============================================================================
|
||
|
||
initialize_setup() {
|
||
if [ "$(id -u)" -ne 0 ]; then die "Run the script as root (sudo bash $0)."; fi
|
||
|
||
mkdir -p "$AWG_DIR" || die "Error creating $AWG_DIR"
|
||
chown root:root "$AWG_DIR"
|
||
|
||
# Process-wide lock: prevents two install_amneziawg.sh instances from
|
||
# running concurrently. Without it two parallel runs could read the
|
||
# same setup_state, race each other on apt-get/dkms/ufw and corrupt
|
||
# package state (audit).
|
||
# FD 9 is fixed and does not conflict with update_state (uses 200).
|
||
# The lock is held open for the whole process lifetime — released
|
||
# automatically on exit.
|
||
INSTALL_LOCK_FILE="$AWG_DIR/.install.lock"
|
||
exec 9>"$INSTALL_LOCK_FILE" || die "Cannot open $INSTALL_LOCK_FILE"
|
||
if ! flock -n 9; then
|
||
die "Another install_amneziawg_en.sh instance is already running. Wait for it to finish, or if the process is hung, remove $INSTALL_LOCK_FILE and try again."
|
||
fi
|
||
|
||
touch "$LOG_FILE" || die "Failed to create log file $LOG_FILE"
|
||
chmod 640 "$LOG_FILE"
|
||
log "--- STARTING AmneziaWG 2.0 INSTALLATION (v${SCRIPT_VERSION}) ---"
|
||
log "### STEP 0: Initialization and parameter check ###"
|
||
cd "$AWG_DIR" || die "Error changing to $AWG_DIR"
|
||
log "Working directory: $AWG_DIR"
|
||
log "Log file: $LOG_FILE"
|
||
|
||
check_os_version
|
||
check_container
|
||
check_kernel_version
|
||
check_free_space
|
||
|
||
local default_port=39743
|
||
local default_subnet="10.9.9.1/24"
|
||
local config_exists=0
|
||
|
||
# Variable initialization
|
||
AWG_PORT=$default_port
|
||
AWG_TUNNEL_SUBNET=$default_subnet
|
||
DISABLE_IPV6="default"
|
||
ALLOWED_IPS_MODE="default"
|
||
ALLOWED_IPS=""
|
||
AWG_ENDPOINT=""
|
||
CLIENT_ISOLATION=""
|
||
# Hard reset (not ${VAR:-}): the internal ownership marker must not be
|
||
# inherited from the environment - an externally exported variable would
|
||
# otherwise reach the AllowedIPs route removal (PR #179 review).
|
||
CLIENT_ISOLATION_NET=""
|
||
|
||
# Hard reset before the config is loaded: the value must not come from env.
|
||
AWG_SERVER_NAME=""
|
||
|
||
# Load config
|
||
if [[ -f "$CONFIG_FILE" ]]; then
|
||
log "Configuration file found $CONFIG_FILE. Loading settings..."
|
||
config_exists=1
|
||
# shellcheck source=/dev/null
|
||
safe_load_config "$CONFIG_FILE" || log_warn "Failed to fully load settings from $CONFIG_FILE."
|
||
AWG_PORT=${AWG_PORT:-$default_port}
|
||
AWG_TUNNEL_SUBNET=${AWG_TUNNEL_SUBNET:-$default_subnet}
|
||
DISABLE_IPV6=${DISABLE_IPV6:-"default"}
|
||
ALLOWED_IPS_MODE=${ALLOWED_IPS_MODE:-"default"}
|
||
ALLOWED_IPS=${ALLOWED_IPS:-""}
|
||
AWG_ENDPOINT=${AWG_ENDPOINT:-""}
|
||
# CLIENT_ISOLATION from the config: strictly 0|1 (the whitelist parser
|
||
# does not check values, and configs get edited by hand). Otherwise the
|
||
# arithmetic context [[ "on" -eq 1 ]] dereferences the string as an
|
||
# empty variable (=0) and silently INVERTS the security setting: wrote
|
||
# on - got off (PR #179 review).
|
||
case "${CLIENT_ISOLATION:-}" in
|
||
""|0|1) : ;;
|
||
*)
|
||
log_warn "CLIENT_ISOLATION='$CLIENT_ISOLATION' in $CONFIG_FILE is invalid (0|1 allowed) - enabling isolation (safe default)."
|
||
CLIENT_ISOLATION=1
|
||
;;
|
||
esac
|
||
# CLIENT_ISOLATION_NET is an internal ownership marker: exactly one
|
||
# canonical IPv4 CIDR (tunnel_network_cidr output). Comma-carrying
|
||
# garbage would let the substring replace in
|
||
# _apply_isolation_to_allowed_ips eat adjacent user routes in a single
|
||
# substitution (PR #179 review).
|
||
if [[ -n "${CLIENT_ISOLATION_NET:-}" ]] \
|
||
&& [[ "$(tunnel_network_cidr "$CLIENT_ISOLATION_NET" || true)" != "$CLIENT_ISOLATION_NET" ]]; then
|
||
log_warn "CLIENT_ISOLATION_NET='$CLIENT_ISOLATION_NET' in $CONFIG_FILE is invalid (a single canonical CIDR expected) - resetting."
|
||
CLIENT_ISOLATION_NET=""
|
||
fi
|
||
log "Settings loaded from file."
|
||
else
|
||
log "Configuration file $CONFIG_FILE not found."
|
||
fi
|
||
|
||
# The old port from awgsetup_cfg.init: step 4 needs it to delete the stale
|
||
# UFW rule on a port change (Issue #175). Captured BEFORE the CLI override,
|
||
# otherwise the old value is lost for good - uninstall reads the already
|
||
# rewritten config and never learns the old port. PREV_AWG_PORT may already
|
||
# be loaded from awgsetup_cfg.init via safe_load_config - that is a pending
|
||
# delete from a previous run: step 1 ends in request_reboot, only a value
|
||
# written to disk survives until step 4 (PR #176).
|
||
PREV_AWG_PORT="${PREV_AWG_PORT:-}"
|
||
_cfg_awg_port=""
|
||
if [[ "$config_exists" -eq 1 ]]; then _cfg_awg_port="$AWG_PORT"; fi
|
||
|
||
# Previous isolation value - for the change warning (issue #178).
|
||
# A legacy config without the key = 1 (isolated): otherwise a legacy ->
|
||
# --isolation=off transition would not warn about regen.
|
||
_cfg_client_isolation=""
|
||
if [[ "$config_exists" -eq 1 ]]; then _cfg_client_isolation="${CLIENT_ISOLATION:-1}"; fi
|
||
|
||
# Previous server name - for the change warning (D#180).
|
||
# A legacy config without the key = 'AWG Server' (the old hardcode).
|
||
_cfg_server_name=""
|
||
if [[ "$config_exists" -eq 1 ]]; then _cfg_server_name="${AWG_SERVER_NAME:-AWG Server}"; fi
|
||
|
||
# --mobile expands into CLI_PRESET/CLI_PORT before their consumers (g0vd).
|
||
resolve_mobile_flag
|
||
|
||
# CLI override
|
||
AWG_PORT=${CLI_PORT:-$AWG_PORT}
|
||
# The port changed in this run - the previous value becomes a pending
|
||
# delete. If the port was changed back (matches the pending value), the
|
||
# delete is cancelled: the rule is needed again.
|
||
if [[ -n "$_cfg_awg_port" && "$_cfg_awg_port" != "$AWG_PORT" ]]; then
|
||
PREV_AWG_PORT="$_cfg_awg_port"
|
||
fi
|
||
if [[ "$PREV_AWG_PORT" == "$AWG_PORT" ]]; then PREV_AWG_PORT=""; fi
|
||
AWG_TUNNEL_SUBNET=${CLI_SUBNET:-$AWG_TUNNEL_SUBNET}
|
||
if [[ "$CLI_DISABLE_IPV6" != "default" ]]; then DISABLE_IPV6=$CLI_DISABLE_IPV6; fi
|
||
if [[ "$CLI_ROUTING_MODE" != "default" ]]; then
|
||
ALLOWED_IPS_MODE=$CLI_ROUTING_MODE
|
||
# An explicit CLI mode overrides the list too: previously --route-all/
|
||
# --route-amnezia on reinstall changed only the mode while ALLOWED_IPS
|
||
# kept the old value from awgsetup_cfg.init - the flag silently had no
|
||
# effect (Issue #170). An empty list forces configure_routing_mode to
|
||
# recompute it for the new mode.
|
||
ALLOWED_IPS=""
|
||
# Ownership dies with the list it described: otherwise a stale
|
||
# CLIENT_ISOLATION_NET could claim a user's token from a fresh
|
||
# --route-custom list (issue #178).
|
||
CLIENT_ISOLATION_NET=""
|
||
if [[ "$CLI_ROUTING_MODE" -eq 3 ]]; then ALLOWED_IPS=$CLI_CUSTOM_ROUTES; fi
|
||
fi
|
||
if [[ -n "$CLI_ENDPOINT" ]]; then
|
||
if ! validate_endpoint "$CLI_ENDPOINT"; then
|
||
die "Invalid --endpoint: '$CLI_ENDPOINT'. Allowed formats: FQDN (vpn.example.com), IPv4 (1.2.3.4), [IPv6] ([2001:db8::1]). Spaces, tabs, quotes, backslashes and newlines are forbidden."
|
||
fi
|
||
AWG_ENDPOINT=$CLI_ENDPOINT
|
||
fi
|
||
if [[ "$CLI_NO_TWEAKS" -eq 1 ]]; then NO_TWEAKS=1; fi
|
||
if [[ "$CLI_KEEP_PACKAGES" -eq 1 ]]; then KEEP_PACKAGES=1; fi
|
||
|
||
# Validate after CLI override
|
||
validate_port "$AWG_PORT"
|
||
validate_subnet "$AWG_TUNNEL_SUBNET"
|
||
# AWG_ENDPOINT may have come from CONFIG_FILE via safe_load_config (no CLI override).
|
||
# If the value is present and invalid — log_warn + reset to "" so the installer
|
||
# falls back to auto-detect via get_server_public_ip (audit).
|
||
if [[ -n "$AWG_ENDPOINT" ]] && ! validate_endpoint "$AWG_ENDPOINT"; then
|
||
log_warn "AWG_ENDPOINT='$AWG_ENDPOINT' from $CONFIG_FILE is invalid, falling back to auto-detect."
|
||
AWG_ENDPOINT=""
|
||
fi
|
||
|
||
# Request settings from user only on first run
|
||
if [[ "$config_exists" -eq 0 ]]; then
|
||
log "Requesting settings from user (first run)."
|
||
# Interactive input: a typo does not kill the install (the validator
|
||
# runs in a subshell -> die prints the error but only terminates the
|
||
# subshell, and the prompt repeats). The final validate_* calls
|
||
# outside the loop stay authoritative for CLI/config values (die is
|
||
# appropriate there).
|
||
if [[ "$AUTO_YES" -eq 0 ]]; then
|
||
while true; do
|
||
read -rp "Enter AmneziaWG UDP port (1-65535) [${AWG_PORT}]: " input_port < /dev/tty
|
||
[[ -z "$input_port" ]] && break
|
||
if ( validate_port "$input_port" ); then AWG_PORT=$input_port; break; fi
|
||
log_warn "Please re-enter the port."
|
||
done
|
||
fi
|
||
validate_port "$AWG_PORT"
|
||
if [[ "$AUTO_YES" -eq 0 ]]; then
|
||
while true; do
|
||
read -rp "Enter tunnel subnet [${AWG_TUNNEL_SUBNET}]: " input_subnet < /dev/tty
|
||
[[ -z "$input_subnet" ]] && break
|
||
if ( validate_subnet "$input_subnet" ); then AWG_TUNNEL_SUBNET=$input_subnet; break; fi
|
||
log_warn "Please re-enter the subnet."
|
||
done
|
||
fi
|
||
validate_subnet "$AWG_TUNNEL_SUBNET"
|
||
if [[ "$DISABLE_IPV6" == "default" ]]; then configure_ipv6; fi
|
||
if [[ "$ALLOWED_IPS_MODE" == "default" ]]; then configure_routing_mode; fi
|
||
else
|
||
log "Using settings from $CONFIG_FILE."
|
||
if [[ "$ALLOWED_IPS_MODE" == "3" ]] && [[ -n "$ALLOWED_IPS" ]]; then
|
||
if ! validate_cidr_list "$ALLOWED_IPS"; then
|
||
die "Invalid ALLOWED_IPS in config: '$ALLOWED_IPS'. Delete $CONFIG_FILE and re-run the installer."
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Consent for removing system packages is asked OUTSIDE the branching above.
|
||
# The call used to sit in the "no config" branch only, so a --force reinstall on an
|
||
# already configured server walked straight past the question: step99 deletes the
|
||
# state file, so a repeated run starts at step 1 and reaches the cleanup again. Configs
|
||
# written before 5.27.0 have no KEEP_PACKAGES entry at all, and its absence read as
|
||
# consent, which reproduced issue #213 on the very version that fixes it.
|
||
# The function itself returns immediately when the decision is already made.
|
||
if [[ -n "$KEEP_PACKAGES" && "$KEEP_PACKAGES" != "0" && "$KEEP_PACKAGES" != "1" ]]; then
|
||
log_warn "KEEP_PACKAGES in $CONFIG_FILE has an invalid value '$KEEP_PACKAGES' - assuming the packages must be kept."
|
||
KEEP_PACKAGES=1
|
||
fi
|
||
configure_package_cleanup
|
||
|
||
# Changing the subnet with live peers is forbidden - check before the
|
||
# init file is saved and before any on-disk changes (AWG_TUNNEL_SUBNET
|
||
# is final here).
|
||
guard_subnet_change_with_peers
|
||
|
||
# Default values
|
||
if [[ "$DISABLE_IPV6" == "default" ]]; then DISABLE_IPV6=1; fi
|
||
configure_ipv6_tunnel
|
||
if [[ "$ALLOWED_IPS_MODE" == "default" ]]; then ALLOWED_IPS_MODE=2; fi
|
||
if [[ -z "$ALLOWED_IPS" ]]; then configure_routing_mode; fi
|
||
|
||
# Client isolation (issue #178): choice + AllowedIPs alignment. Called
|
||
# before validate_cidr_list below - an appended subnet goes through the
|
||
# same mandatory validation as the rest of the list.
|
||
configure_client_isolation
|
||
_apply_isolation_to_allowed_ips
|
||
|
||
# Single mandatory AllowedIPs validation before saving the config: CLI
|
||
# --route-custom on a first run assigned ALLOWED_IPS without checking it
|
||
# (configure_routing_mode was skipped because the mode was already 3).
|
||
# Validate any non-empty list regardless of its source (CLI / config / mode).
|
||
if [[ -n "$ALLOWED_IPS" ]] && ! validate_cidr_list "$ALLOWED_IPS"; then
|
||
die "Invalid ALLOWED_IPS: '$ALLOWED_IPS'. Expected a list x.x.x.x/y[,x.x.x.x/y]."
|
||
fi
|
||
|
||
# Server name for the vpn:// URI (D#180): source selection + validation.
|
||
configure_server_name
|
||
|
||
# Port check (skip if AWG service is already listening on this port)
|
||
if ! systemctl is-active --quiet awg-quick@awg0 2>/dev/null; then
|
||
check_port_availability "$AWG_PORT" || die "Port $AWG_PORT/udp is occupied."
|
||
else
|
||
log "AWG service is active — skipping port check."
|
||
fi
|
||
|
||
# AWG 2.0 parameter generation
|
||
# Regenerate if: first run OR explicit CLI override (--preset/--jc/--jmin/--jmax)
|
||
if [[ -z "${AWG_Jc:-}" ]] || [[ -n "${CLI_PRESET:-}" ]] || [[ -n "${CLI_JC:-}" ]] \
|
||
|| [[ -n "${CLI_JMIN:-}" ]] || [[ -n "${CLI_JMAX:-}" ]]; then
|
||
# generate_awg_params regenerates the WHOLE set (S1-S4, H1-H4, I1),
|
||
# not just the requested parameter: on a reinstall over a live server
|
||
# every issued client config still holds the old H1-H4 and will stop
|
||
# connecting. Warn loudly.
|
||
if [[ "$config_exists" -eq 1 && -n "${AWG_Jc:-}" ]]; then
|
||
log_warn "WARNING: --preset/--jc/--jmin/--jmax on a reinstall regenerate ALL obfuscation parameters (including H1-H4/S1-S4/I1)."
|
||
log_warn "All existing client configs will stop connecting - reissue them after the install: sudo bash $MANAGE_SCRIPT_PATH regen"
|
||
fi
|
||
generate_awg_params
|
||
else
|
||
log "AWG 2.0 parameters already set from config."
|
||
fi
|
||
|
||
# CPS (I1) toggle (issue #159): --no-cps drops the I1 parameter that makes the
|
||
# desktop AmneziaVPN on macOS hang on connect (mobile and CLI clients handle
|
||
# CPS fine). Only I1 is cleared, the rest of the obfuscation set (Jc/S1-S4/
|
||
# H1-H4) is left intact. Explicit --preset/--jc/--jmin/--jmax without --no-cps
|
||
# re-enable CPS (a fresh set includes I1). Otherwise keep the state from init.
|
||
if [[ "${CLI_NO_CPS:-0}" -eq 1 ]]; then
|
||
NO_CPS=1
|
||
elif [[ -n "${CLI_PRESET:-}" || -n "${CLI_JC:-}" || -n "${CLI_JMIN:-}" || -n "${CLI_JMAX:-}" ]]; then
|
||
NO_CPS=0
|
||
fi
|
||
if [[ "${NO_CPS:-0}" -eq 1 ]]; then
|
||
if [[ -n "${AWG_I1:-}" && "$config_exists" -eq 1 ]]; then
|
||
log_warn "WARNING: --no-cps drops the I1 (CPS) parameter. Existing client configs that still carry I1 will stop connecting - reissue them: sudo bash $MANAGE_SCRIPT_PATH regen"
|
||
fi
|
||
AWG_I1=''
|
||
log "CPS (I1) disabled (--no-cps / persisted NO_CPS=1): the desktop AmneziaVPN on macOS does not support CPS."
|
||
fi
|
||
|
||
# Save configuration
|
||
log "Saving settings to $CONFIG_FILE..."
|
||
# temp in the target config's directory -> mv = atomic rename on the same
|
||
# filesystem (not a cross-fs copy+unlink when /tmp is mounted as tmpfs).
|
||
local temp_conf cfg_dir
|
||
cfg_dir="$(dirname "$CONFIG_FILE")"
|
||
mkdir -p "$cfg_dir" 2>/dev/null
|
||
temp_conf=$(mktemp -p "$cfg_dir") || die "mktemp error."
|
||
_install_temp_files+=("$temp_conf")
|
||
cat > "$temp_conf" << EOF
|
||
# AmneziaWG 2.0 installation configuration (Auto-generated)
|
||
# Used by installation and management scripts
|
||
export OS_ID='${OS_ID:-ubuntu}'
|
||
export OS_VERSION='${OS_VERSION:-}'
|
||
export OS_CODENAME='${OS_CODENAME:-}'
|
||
export AWG_PORT=${AWG_PORT}
|
||
export AWG_TUNNEL_SUBNET='${AWG_TUNNEL_SUBNET}'
|
||
export DISABLE_IPV6=${DISABLE_IPV6}
|
||
export ALLOWED_IPS_MODE=${ALLOWED_IPS_MODE}
|
||
export ALLOWED_IPS='${ALLOWED_IPS}'
|
||
export CLIENT_ISOLATION=${CLIENT_ISOLATION:-1}
|
||
export CLIENT_ISOLATION_NET='${CLIENT_ISOLATION_NET:-}'
|
||
export AWG_ENDPOINT='${AWG_ENDPOINT}'
|
||
export AWG_SERVER_NAME='${AWG_SERVER_NAME:-AWG Server}'
|
||
export AWG_MTU=${AWG_MTU:-1280}
|
||
# AWG 2.0 Parameters
|
||
export AWG_Jc=${AWG_Jc}
|
||
export AWG_Jmin=${AWG_Jmin}
|
||
export AWG_Jmax=${AWG_Jmax}
|
||
export AWG_S1=${AWG_S1}
|
||
export AWG_S2=${AWG_S2}
|
||
export AWG_S3=${AWG_S3}
|
||
export AWG_S4=${AWG_S4}
|
||
export AWG_H1='${AWG_H1}'
|
||
export AWG_H2='${AWG_H2}'
|
||
export AWG_H3='${AWG_H3}'
|
||
export AWG_H4='${AWG_H4}'
|
||
export AWG_I1='${AWG_I1}'
|
||
export AWG_I2='${AWG_I2:-}'
|
||
export AWG_I3='${AWG_I3:-}'
|
||
export AWG_I4='${AWG_I4:-}'
|
||
export AWG_I5='${AWG_I5:-}'
|
||
export AWG_PRESET='${AWG_PRESET:-default}'
|
||
export NO_TWEAKS=${NO_TWEAKS}
|
||
export KEEP_PACKAGES=${KEEP_PACKAGES:-1}
|
||
export NO_CPS=${NO_CPS}
|
||
export AWG_APPLY_MODE='${AWG_APPLY_MODE:-syncconf}'
|
||
export ALLOW_IPV6_TUNNEL=${ALLOW_IPV6_TUNNEL:-0}
|
||
export IPV6_SUBNET='${IPV6_SUBNET}'
|
||
export SERVER_HAS_NATIVE_IPV6=${SERVER_HAS_NATIVE_IPV6:-0}
|
||
EOF
|
||
# The pending delete of the old port's UFW rule must survive a reboot:
|
||
# step 4 runs in a different process after 1-2 reboots, a process variable
|
||
# does not live that long (PR #176). setup_improved_firewall removes the
|
||
# key after a successful ufw delete.
|
||
if [[ "$PREV_AWG_PORT" =~ ^[0-9]+$ ]]; then
|
||
echo "export PREV_AWG_PORT=${PREV_AWG_PORT}" >> "$temp_conf" \
|
||
|| die "Error writing PREV_AWG_PORT to $temp_conf"
|
||
fi
|
||
if ! mv "$temp_conf" "$CONFIG_FILE"; then
|
||
rm -f "$temp_conf"
|
||
die "Error saving $CONFIG_FILE"
|
||
fi
|
||
chmod 600 "$CONFIG_FILE" || log_warn "chmod $CONFIG_FILE error"
|
||
log "Settings saved."
|
||
export AWG_PORT AWG_TUNNEL_SUBNET DISABLE_IPV6 ALLOWED_IPS_MODE ALLOWED_IPS AWG_ENDPOINT
|
||
log "Port: ${AWG_PORT}/udp"
|
||
log "Subnet: ${AWG_TUNNEL_SUBNET}"
|
||
log "IPv6 disable: $DISABLE_IPV6"
|
||
log "AllowedIPs mode: $ALLOWED_IPS_MODE"
|
||
log "Client isolation: $( [[ "${CLIENT_ISOLATION:-1}" -eq 1 ]] && echo enabled || echo disabled )"
|
||
|
||
log "Server name: ${AWG_SERVER_NAME}"
|
||
# Changing the routing mode is a client-config operation: new clients get
|
||
# the new list, but for existing ones regen deliberately preserves
|
||
# AllowedIPs (per-client modify customizations). Hint the explicit way to
|
||
# apply the new mode to everyone (Issue #170).
|
||
if [[ "$config_exists" -eq 1 && "$CLI_ROUTING_MODE" != "default" ]]; then
|
||
log_warn "Routing mode changed. Existing client configs keep their old AllowedIPs."
|
||
log_warn "Apply the new mode to all clients: sudo bash $MANAGE_SCRIPT_PATH regen --reset-routes"
|
||
fi
|
||
# Isolation change - the same operation on client configs as a routing
|
||
# mode change: new clients get the new list, existing ones only via
|
||
# regen --reset-routes (issue #178).
|
||
if [[ "$config_exists" -eq 1 \
|
||
&& "$_cfg_client_isolation" != "$CLIENT_ISOLATION" ]]; then
|
||
log_warn "Client isolation mode changed. Existing client configs keep their old AllowedIPs."
|
||
log_warn "Apply the new mode to all clients: sudo bash $MANAGE_SCRIPT_PATH regen --reset-routes"
|
||
fi
|
||
# Port change: step 6 skips clients that already exist, their Endpoint
|
||
# keeps the old port and they silently stop connecting. Hint the explicit
|
||
# reissue - mirrors the routing-mode change warning (#170).
|
||
if [[ "$config_exists" -eq 1 && -n "$PREV_AWG_PORT" ]]; then
|
||
log_warn "Port changed (${PREV_AWG_PORT} -> ${AWG_PORT}). Existing client configs keep the old port in Endpoint and will lose connectivity."
|
||
log_warn "Reissue all clients: sudo bash $MANAGE_SCRIPT_PATH regen"
|
||
fi
|
||
# Server-name change (D#180): affects only the vpn:// URI; existing
|
||
# .vpnuri files keep the old name until reissued.
|
||
if [[ "$config_exists" -eq 1 && "$_cfg_server_name" != "$AWG_SERVER_NAME" ]]; then
|
||
log_warn "Server name changed ('${_cfg_server_name}' -> '${AWG_SERVER_NAME}'). Existing vpn:// links keep the old name."
|
||
log_warn "Reissue with the new name: sudo bash $MANAGE_SCRIPT_PATH regen"
|
||
fi
|
||
|
||
# Loading state
|
||
if [[ -f "$STATE_FILE" ]]; then
|
||
current_step=$(cat "$STATE_FILE")
|
||
if ! [[ "$current_step" =~ ^[0-9]+$ ]]; then
|
||
log_warn "$STATE_FILE corrupted."
|
||
current_step=1
|
||
update_state 1
|
||
else
|
||
log "Resuming from step $current_step."
|
||
fi
|
||
else
|
||
current_step=1
|
||
log "Starting from step 1."
|
||
update_state 1
|
||
fi
|
||
|
||
# Stale state (an interrupted step 7 leaves setup_state=7/99) + CLI flags
|
||
# affecting the firewall/configs: without the rollback the loop would skip
|
||
# steps 4-6, the new values would live only in awgsetup_cfg.init while
|
||
# awg0.conf, client configs and UFW rules silently kept the old ones
|
||
# (Issue #175). Roll back to step 4: firewall (port) + config regen (step 6).
|
||
if (( current_step > 4 )) && { [[ -n "$CLI_PORT" ]] || [[ -n "$CLI_SUBNET" ]] \
|
||
|| [[ -n "$CLI_SSH_PORT" ]] || [[ "$CLI_ROUTING_MODE" != "default" ]] \
|
||
|| [[ -n "$CLI_ENDPOINT" ]] || [[ "$CLI_DISABLE_IPV6" != "default" ]] \
|
||
|| [[ "${CLI_ALLOW_IPV6_TUNNEL:-0}" -eq 1 ]] || [[ -n "${CLI_PRESET:-}" ]] \
|
||
|| [[ -n "${CLI_JC:-}" ]] || [[ -n "${CLI_JMIN:-}" ]] || [[ -n "${CLI_JMAX:-}" ]] \
|
||
|| [[ "${CLI_ISOLATION:-default}" != "default" ]] \
|
||
|| [[ "${CLI_NO_CPS:-0}" -eq 1 ]]; }; then
|
||
log_warn "Unfinished install (step $current_step) + configuration CLI flags: rolling back to step 4 so the firewall and configs are regenerated with the new values."
|
||
current_step=4
|
||
update_state 4
|
||
fi
|
||
log "Step 0 completed."
|
||
}
|
||
|
||
# ==============================================================================
|
||
# STEP 1: System update, cleanup, and optimization
|
||
# ==============================================================================
|
||
|
||
step1_update_and_optimize() {
|
||
update_state 1
|
||
log "### STEP 1: System update, cleanup, and optimization ###"
|
||
|
||
# First-boot dpkg-lock resilience: unattended-upgrades and apt-daily often
|
||
# hold the lock for several minutes (issue #150 - apt full-upgrade used to
|
||
# fail immediately). DPkg::Lock::Timeout makes apt wait for the lock to be
|
||
# released instead of erroring out.
|
||
mkdir -p /etc/apt/apt.conf.d
|
||
printf 'DPkg::Lock::Timeout "300";\n' > /etc/apt/apt.conf.d/99-amneziawg-lock-timeout \
|
||
|| log_warn "Failed to write apt lock-timeout (issue #150 mitigation)."
|
||
|
||
# Clean unnecessary components (BEFORE update to save bandwidth/time)
|
||
if [[ "$NO_TWEAKS" -eq 1 ]]; then
|
||
log "Skipping system cleanup (--no-tweaks)."
|
||
elif [[ "$KEEP_PACKAGES" != "0" ]]; then
|
||
# Not a strict zero means either an explicit refusal or an unknown decision (empty
|
||
# or mangled value from a hand-edited config). Irreversible removal happens only on
|
||
# recorded consent; everything else is treated as "leave it alone".
|
||
log "Skipping the system cleanup: the packages are kept."
|
||
[[ -z "$KEEP_PACKAGES" ]] && log_warn "No consent for removing system packages is recorded - removing nothing."
|
||
else
|
||
cleanup_system
|
||
fi
|
||
|
||
log "Updating package lists..."
|
||
apt_update_tolerant || die "apt update error."
|
||
# Cache is fresh: install_packages below must not rerun apt update
|
||
# (sources do not change in step 1).
|
||
_APT_UPDATED=1
|
||
|
||
log "Unlocking dpkg..."
|
||
if ! apt-get check &>/dev/null; then
|
||
log_warn "dpkg locked or corrupted, fixing..."
|
||
DEBIAN_FRONTEND=noninteractive dpkg --configure -a || log_warn "dpkg --configure -a."
|
||
fi
|
||
|
||
log "Updating system..."
|
||
if ! DEBIAN_FRONTEND=noninteractive apt full-upgrade -y; then
|
||
_lock_holder="$(fuser /var/lib/dpkg/lock-frontend 2>/dev/null | tr -s ' ' || true)"
|
||
if [[ -n "$_lock_holder" ]]; then
|
||
log_warn "dpkg-lock is held by:${_lock_holder} (usually first-boot unattended-upgrades)."
|
||
fi
|
||
log_warn "apt full-upgrade failed, fixing dpkg and retrying..."
|
||
DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true
|
||
DEBIAN_FRONTEND=noninteractive apt full-upgrade -y \
|
||
|| die "apt full-upgrade error. Another apt/unattended-upgrades process is likely holding the dpkg lock. Wait for it to finish (check: fuser /var/lib/dpkg/lock-frontend) or run: systemctl stop unattended-upgrades; dpkg --configure -a - then run the script again."
|
||
fi
|
||
log "System updated."
|
||
|
||
install_packages curl wget gpg sudo ethtool
|
||
|
||
if [[ "$NO_TWEAKS" -eq 0 ]]; then
|
||
# System optimization
|
||
optimize_system
|
||
# Sysctl configuration
|
||
setup_advanced_sysctl
|
||
else
|
||
log "Skipping optimization and hardening (--no-tweaks)."
|
||
setup_minimal_sysctl
|
||
fi
|
||
|
||
log "Step 1 completed successfully."
|
||
request_reboot 2
|
||
}
|
||
|
||
# ==============================================================================
|
||
# ARM prebuilt support
|
||
# ==============================================================================
|
||
|
||
# _try_install_prebuilt_arm — download and install a prebuilt amneziawg .deb
|
||
# for the current ARM kernel from the arm-packages GitHub release.
|
||
#
|
||
# Returns 0 if a matching prebuilt was installed successfully.
|
||
# Returns 1 if no match was found or installation failed (caller falls back to DKMS).
|
||
#
|
||
# Prebuilt packages are built by .github/workflows/arm-build.yml and published
|
||
# to the arm-packages release tag. The filename encodes both the target ID and
|
||
# the exact kernel version: amneziawg-kmod-<target-id>_<kernel-version>_<arch>.deb
|
||
#
|
||
# Kernel version matching is exact — the module vermagic must match uname -r.
|
||
# DKMS is the preferred path for kernels that haven't been pre-built yet.
|
||
_try_install_prebuilt_arm() {
|
||
local kernel arch target_id asset_name asset_url tmpfile tmpsha expected_sha actual_sha
|
||
kernel="$(uname -r)"
|
||
arch="$(dpkg --print-architecture)"
|
||
|
||
# Map kernel string to a build target ID
|
||
if [[ "$kernel" == *+rpt-rpi-2712* ]]; then
|
||
target_id="rpi5-bookworm-arm64"
|
||
elif [[ "$kernel" == *+rpt* && "$arch" == "arm64" ]]; then
|
||
target_id="rpi-bookworm-arm64"
|
||
elif [[ "$kernel" == *+rpt* && "$arch" == "armhf" ]]; then
|
||
target_id="rpi-bookworm-armhf"
|
||
elif [[ "$kernel" == *-generic* && "${OS_VERSION:-}" == "24.04" ]]; then
|
||
target_id="ubuntu-2404-arm64"
|
||
elif [[ "$kernel" == *-generic* && "${OS_VERSION:-}" == "25.10" ]]; then
|
||
target_id="ubuntu-2510-arm64"
|
||
elif [[ "$kernel" == *-arm64* && "${OS_ID:-}" == "debian" && "${OS_VERSION:-}" == "13" ]]; then
|
||
target_id="debian-trixie-arm64"
|
||
elif [[ "$kernel" == *-arm64* && "${OS_ID:-}" == "debian" ]]; then
|
||
target_id="debian-bookworm-arm64"
|
||
else
|
||
log "No prebuilt target for kernel $kernel ($arch)"
|
||
return 1
|
||
fi
|
||
|
||
# Asset filename encodes the exact kernel version
|
||
asset_name="amneziawg-kmod-${target_id}_${kernel}_${arch}.deb"
|
||
asset_url="https://github.com/bivlked/amneziawg-installer/releases/download/arm-packages/${asset_name}"
|
||
|
||
log "Trying prebuilt: $asset_name"
|
||
tmpfile="$(mktemp /tmp/amneziawg-prebuilt-XXXXXX.deb)"
|
||
tmpsha="$(mktemp /tmp/amneziawg-prebuilt-XXXXXX.deb.sha256)"
|
||
|
||
# Download SHA256 checksum first
|
||
if ! curl -fsSL --retry 2 --connect-timeout 10 --max-time 60 \
|
||
-o "$tmpsha" "${asset_url}.sha256" 2>/dev/null; then
|
||
log "Prebuilt not available for $kernel — using DKMS"
|
||
rm -f "$tmpfile" "$tmpsha"
|
||
return 1
|
||
fi
|
||
|
||
if curl -fsSL --retry 2 --connect-timeout 10 --max-time 60 \
|
||
-o "$tmpfile" "$asset_url" 2>/dev/null; then
|
||
# Verify integrity before installing a kernel module
|
||
expected_sha="$(cat "$tmpsha")"
|
||
actual_sha="$(sha256sum "$tmpfile" | awk '{print $1}')"
|
||
rm -f "$tmpsha"
|
||
if [[ "$expected_sha" != "$actual_sha" ]]; then
|
||
log_warn "Prebuilt SHA256 mismatch — discarding download"
|
||
rm -f "$tmpfile"
|
||
return 1
|
||
fi
|
||
|
||
log "Downloaded prebuilt (SHA256 OK), installing..."
|
||
if dpkg -i "$tmpfile" 2>/dev/null; then
|
||
rm -f "$tmpfile"
|
||
log "Prebuilt installed: $asset_name"
|
||
return 0
|
||
else
|
||
log_warn "Prebuilt install failed (vermagic mismatch or corrupt package)"
|
||
rm -f "$tmpfile"
|
||
return 1
|
||
fi
|
||
else
|
||
log "Prebuilt not available for $kernel — using DKMS"
|
||
rm -f "$tmpfile" "$tmpsha"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# H0 (AWG 3.0, 31 jul 2026): on kernels < 6.7 the current PPA module is AmneziaWG
|
||
# 3.0, and we deliberately keep it out of there (why exactly - see
|
||
# _kernel_supports_awg3). We install the last pinned 2.0 module (the 1.0.x line)
|
||
# from source via DKMS:
|
||
# 1. git clone the pinned tag --depth=1;
|
||
# 2. VERIFY the commit against AWG2_PIN_COMMIT (integrity: an immutable commit is
|
||
# more robust than the GitHub auto-tarball SHA, which changes on recompression);
|
||
# 3. the upstream `make dkms-install` mechanism (lays it into /usr/src/amneziawg-1.0.0);
|
||
# 4. dkms add/build/install for the current kernel;
|
||
# 5. a modprobe check (built != loadable: Secure Boot may block it).
|
||
# The source dkms.conf carries AUTOINSTALL=yes, so our amneziawg-ensure-module helper
|
||
# (apt hook + systemd) rebuilds the pinned module on a kernel upgrade by itself - no
|
||
# separate maintenance code is needed. Returns: 0 success, 1 failure (logged to ERROR).
|
||
_install_pinned_awg2_module() {
|
||
local repo="https://github.com/amnezia-vpn/amneziawg-linux-kernel-module.git"
|
||
local kver work got_commit
|
||
local dkms_ver="1.0.0" # WIREGUARD_VERSION in the upstream Makefile (name of /usr/src/amneziawg-<ver>)
|
||
kver="$(uname -r)"
|
||
|
||
if ! command -v git >/dev/null 2>&1; then
|
||
log_error "git is not installed - cannot fetch the pinned module source."
|
||
return 1
|
||
fi
|
||
|
||
work="$(mktemp -d /tmp/awg2-pin-XXXXXX)" || { log_error "mktemp -d failed."; return 1; }
|
||
|
||
log "Cloning the pinned AmneziaWG 2.0 source ($AWG2_PIN_TAG)..."
|
||
if ! git clone --depth=1 --branch "$AWG2_PIN_TAG" "$repo" "$work/src" >/dev/null 2>&1; then
|
||
log_error "Failed to clone $repo (tag $AWG2_PIN_TAG). Check access to github.com."
|
||
rm -rf "$work"; return 1
|
||
fi
|
||
|
||
got_commit="$(git -C "$work/src" rev-parse HEAD 2>/dev/null || echo "")"
|
||
if [[ "$got_commit" != "$AWG2_PIN_COMMIT" ]]; then
|
||
log_error "Pin check failed: tag $AWG2_PIN_TAG -> commit '${got_commit:-<empty>}',"
|
||
log_error "expected $AWG2_PIN_COMMIT. Refusing (the tag may have been moved/tampered with)."
|
||
rm -rf "$work"; return 1
|
||
fi
|
||
log "Pinned commit confirmed: $got_commit"
|
||
|
||
# Lay out the DKMS source via the upstream mechanism (the Makefile is in src/).
|
||
# Save make output to a log: otherwise the real cause of a failure (environment /
|
||
# coreutils) is invisible - unlike the dkms build path, no make.log is created here.
|
||
local _mklog="/var/log/amneziawg-pin-dkms-install.log"
|
||
if ! make -C "$work/src/src" dkms-install PREFIX=/usr >"$_mklog" 2>&1; then
|
||
log_error "make dkms-install failed. Details: $_mklog"
|
||
rm -rf "$work"; return 1
|
||
fi
|
||
rm -rf "$work"
|
||
|
||
if [[ ! -f "/usr/src/amneziawg-${dkms_ver}/dkms.conf" ]]; then
|
||
log_error "/usr/src/amneziawg-${dkms_ver}/dkms.conf did not appear after dkms-install."
|
||
return 1
|
||
fi
|
||
|
||
# add is idempotent: on a re-run it is already added -> not fatal.
|
||
dkms add -m amneziawg -v "$dkms_ver" >/dev/null 2>&1 || true
|
||
# Idempotency (the installer is a resumable state machine): dkms build errors
|
||
# with "already built" for a kernel already done -> build ONLY if there is no
|
||
# build for this kernel yet. install --force below is idempotent by itself.
|
||
if dkms status -m amneziawg -v "$dkms_ver" -k "$kver" 2>/dev/null | grep -qE ': (built|installed)'; then
|
||
log "The pinned 2.0 module is already built for kernel $kver - skipping dkms build."
|
||
else
|
||
log "Building the pinned 2.0 module via DKMS (kernel $kver)..."
|
||
if ! dkms build -m amneziawg -v "$dkms_ver" -k "$kver" >/dev/null 2>&1; then
|
||
log_error "DKMS build of the pinned 2.0 module failed. See /var/lib/dkms/amneziawg/${dkms_ver}/${kver}/*/log/make.log"
|
||
return 1
|
||
fi
|
||
fi
|
||
if ! dkms install -m amneziawg -v "$dkms_ver" -k "$kver" --force >/dev/null 2>&1; then
|
||
log_error "DKMS install of the pinned 2.0 module failed."
|
||
return 1
|
||
fi
|
||
|
||
# Built != loadable: with Secure Boot enabled an unsigned module will not load.
|
||
if ! modprobe amneziawg 2>/dev/null; then
|
||
log_error "The module was built but modprobe amneziawg did not load it."
|
||
log_error "The likely cause is Secure Boot: an unsigned DKMS module is blocked."
|
||
log_error "Disable Secure Boot in the VPS BIOS/UEFI or enroll a MOK key."
|
||
return 1
|
||
fi
|
||
log "The pinned AmneziaWG 2.0 module is built and loaded (DKMS $dkms_ver, kernel $kver)."
|
||
return 0
|
||
}
|
||
|
||
# ==============================================================================
|
||
# STEP 2: Installing AmneziaWG and dependencies
|
||
# ==============================================================================
|
||
|
||
step2_install_amnezia() {
|
||
update_state 2
|
||
|
||
# Guard: make sure the user actually rebooted before step 2.
|
||
# If boot_id matches the one saved in request_reboot 2 — the reboot
|
||
# did not happen (e.g. user re-ran the script by mistake). Step 1's
|
||
# apt full-upgrade staged a new kernel on disk, but the running
|
||
# kernel is still the old one → DKMS would build the module against
|
||
# the old kernel and modprobe would fail after the next reboot.
|
||
local boot_id_file="$AWG_DIR/.boot_id_before_step2"
|
||
if [[ -f "$boot_id_file" ]] && [[ -r /proc/sys/kernel/random/boot_id ]]; then
|
||
local saved_boot_id current_boot_id
|
||
saved_boot_id=$(< "$boot_id_file")
|
||
current_boot_id=$(< /proc/sys/kernel/random/boot_id)
|
||
if [[ -n "$saved_boot_id" ]] && [[ "$saved_boot_id" == "$current_boot_id" ]]; then
|
||
die "Reboot expected before step 2 (kernel upgrade is only activated after reboot). Run: sudo reboot — then re-run the script."
|
||
fi
|
||
log "Reboot confirmed (boot_id changed) — continuing with step 2"
|
||
rm -f "$boot_id_file" 2>/dev/null || true
|
||
fi
|
||
|
||
log "### STEP 2: Installing AmneziaWG and dependencies ###"
|
||
_APT_UPDATED=0 # Reset: new sources will be added in this step
|
||
|
||
# --ppa-amnezia-tolerant is REQUIRED already here: if a PPA file with a
|
||
# broken suite is left on disk (404 Release; e.g. questing from an older
|
||
# version or after an in-place upgrade), a strict update died BEFORE the
|
||
# repair blocks below ever ran, so the repair never fired (live repro on
|
||
# Debian 12, v5.16.0 cycle). Base repository errors remain fail-closed;
|
||
# PPA errors are handled by the repair + post-PPA update +
|
||
# apt_wait_for_ppa_package below.
|
||
apt_update_tolerant --ppa-amnezia-tolerant || die "apt update error."
|
||
|
||
# PPA Amnezia (without software-properties-common)
|
||
log "Adding Amnezia PPA..."
|
||
|
||
# Determine codename for PPA
|
||
# On Debian, map to nearest Ubuntu codename since PPA is Launchpad (Ubuntu)
|
||
# Debian 12 (bookworm) → focal, Debian 13 (trixie) → noble
|
||
local codename ppa_codename
|
||
codename="${OS_CODENAME:-$(lsb_release -sc 2>/dev/null || echo "noble")}"
|
||
case "${OS_ID:-ubuntu}" in
|
||
debian)
|
||
case "$codename" in
|
||
bookworm) ppa_codename="focal" ;;
|
||
trixie) ppa_codename="noble" ;;
|
||
*) ppa_codename="noble" ;;
|
||
esac
|
||
log "Debian ($codename) → PPA codename: $ppa_codename"
|
||
;;
|
||
*)
|
||
ppa_codename="$codename"
|
||
# For Ubuntu non-LTS (questing/plucky/oracular/...) Amnezia PPA does
|
||
# not publish packages — dists/<codename>/Release returns 404.
|
||
# Pre-check via HEAD and fall back to noble (LTS): the noble build
|
||
# gets DKMS-compiled against the running kernel.
|
||
# Upstream: amnezia-vpn/amneziawg-linux-kernel-module#118
|
||
case "$ppa_codename" in
|
||
noble|jammy|focal)
|
||
# Known LTS — skip pre-check (PPA is reliably published)
|
||
;;
|
||
*)
|
||
log "Checking Amnezia PPA availability for Ubuntu '${ppa_codename}'..."
|
||
if ! curl -fsI --max-time 15 --retry 2 --retry-delay 5 \
|
||
"https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu/dists/${ppa_codename}/Release" \
|
||
>/dev/null 2>&1; then
|
||
log_warn "Amnezia PPA does not publish packages for Ubuntu '${ppa_codename}' (HTTP 404 or host unreachable)."
|
||
log_warn "Falling back to 'noble' — DKMS will build the module against the running kernel."
|
||
log_warn "Context: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/issues/118"
|
||
ppa_codename="noble"
|
||
else
|
||
log "Amnezia PPA is available for '${ppa_codename}'."
|
||
fi
|
||
;;
|
||
esac
|
||
;;
|
||
esac
|
||
|
||
local keyring_dir="/etc/apt/keyrings"
|
||
local keyring_file="${keyring_dir}/amnezia-ppa.gpg"
|
||
local ppa_sources="/etc/apt/sources.list.d/amnezia-ppa.sources"
|
||
local ppa_list="/etc/apt/sources.list.d/amnezia-ppa.list"
|
||
# Check for legacy files (from add-apt-repository of previous versions)
|
||
local legacy_list="/etc/apt/sources.list.d/amnezia-ubuntu-ppa-${codename}.list"
|
||
local legacy_sources="/etc/apt/sources.list.d/amnezia-ubuntu-ppa-${codename}.sources"
|
||
# Re-run on a server where a previous run (≤ v5.12.1) wrote a broken
|
||
# .sources file with Suites=questing/plucky/etc.: if the existing suite
|
||
# doesn't match the target ppa_codename, remove the file so it gets
|
||
# recreated below with the correct suite. Same check for legacy
|
||
# .sources (add-apt-repository format).
|
||
# If the file exists but `Suites:` can't be parsed — treat as corrupt
|
||
# and recreate, otherwise the broken file would slip through as
|
||
# "PPA already added".
|
||
local existing_suite=""
|
||
if [[ -f "$ppa_sources" ]]; then
|
||
existing_suite=$(awk '/^Suites:/{print $2; exit}' "$ppa_sources" 2>/dev/null)
|
||
fi
|
||
if [[ -f "$ppa_sources" && ( -z "$existing_suite" || "$existing_suite" != "$ppa_codename" ) ]]; then
|
||
if [[ -z "$existing_suite" ]]; then
|
||
log_warn "$ppa_sources exists but no Suites: line found — recreating."
|
||
else
|
||
log_warn "Existing PPA suite='${existing_suite}', target='${ppa_codename}' — recreating $ppa_sources."
|
||
fi
|
||
rm -f "$ppa_sources" "$ppa_list"
|
||
fi
|
||
local legacy_suite=""
|
||
if [[ -f "$legacy_sources" ]]; then
|
||
legacy_suite=$(awk '/^Suites:/{print $2; exit}' "$legacy_sources" 2>/dev/null)
|
||
fi
|
||
if [[ -f "$legacy_sources" && ( -z "$legacy_suite" || "$legacy_suite" != "$ppa_codename" ) ]]; then
|
||
log_warn "Legacy PPA $legacy_sources (suite='${legacy_suite:-<empty>}') does not match target '${ppa_codename}' — removing."
|
||
rm -f "$legacy_sources" "$legacy_list"
|
||
fi
|
||
# Same repair for the traditional .list (Debian 12): the suite is the token
|
||
# after the URL in a 'deb [opts] URL <suite> main' line. Without this check
|
||
# a file with an old/foreign suite (e.g. after an in-place upgrade
|
||
# bookworm->trixie) would slip through below as "PPA already added" and apt
|
||
# would keep pulling the wrong suite.
|
||
local list_suite=""
|
||
if [[ -f "$ppa_list" ]]; then
|
||
list_suite=$(awk '/^deb([[:space:]]|$)/ {
|
||
for (i = 2; i <= NF; i++) {
|
||
if ($i ~ /^https?:/) { print $(i+1); exit }
|
||
}
|
||
}' "$ppa_list" 2>/dev/null)
|
||
if [[ -z "$list_suite" || "$list_suite" != "$ppa_codename" ]]; then
|
||
log_warn "Existing $ppa_list (suite='${list_suite:-<empty>}') does not match target '${ppa_codename}' - recreating."
|
||
rm -f "$ppa_list"
|
||
fi
|
||
fi
|
||
if [[ -f "$legacy_list" ]] || [[ -f "$legacy_sources" ]]; then
|
||
log "PPA already added (legacy format)."
|
||
elif [[ -f "$ppa_sources" ]] || [[ -f "$ppa_list" ]]; then
|
||
log "PPA already added."
|
||
else
|
||
mkdir -p "$keyring_dir"
|
||
log "Importing Amnezia PPA GPG key..."
|
||
# Atomic: pipe into temp, then mv — a half-written keyring never
|
||
# lives on the target path, even if curl/gpg die mid-way.
|
||
local _kf_tmp
|
||
_kf_tmp=$(mktemp -p "$keyring_dir" ".amnezia-ppa.gpg.tmp.XXXXXX") \
|
||
|| die "Failed to create temp file for GPG key."
|
||
# --batch --no-tty --yes: gpg must not open /dev/tty (non-interactive
|
||
# SSH, cloud-init, Ansible, etc.) and must not abort with "File exists"
|
||
# when overwriting the mktemp-created tmp file. Without --yes gpg in
|
||
# batch mode refuses to write into the pre-existing empty tmp file.
|
||
# Request by the FULL 40-character fingerprint, not the short ID:
|
||
# short 32-bit IDs have preimage collisions (evil32), and
|
||
# keyserver.ubuntu.com accepts uploads of arbitrary keys. A swapped
|
||
# key would not give RCE (package signatures would not match), but it
|
||
# would break the install with a cryptic apt error.
|
||
local _ppa_key_fpr="75C9DD72C799870E310542E24166F2C257290828"
|
||
if ! curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x${_ppa_key_fpr}" \
|
||
| gpg --batch --no-tty --yes --dearmor -o "$_kf_tmp"; then
|
||
rm -f "$_kf_tmp" 2>/dev/null
|
||
die "Amnezia PPA GPG key import error."
|
||
fi
|
||
# Verify the downloaded key fingerprint against the expected one (pin).
|
||
local _got_fpr
|
||
_got_fpr=$(gpg --batch --no-tty --show-keys --with-colons "$_kf_tmp" 2>/dev/null \
|
||
| awk -F: '/^fpr:/{print $10; exit}')
|
||
if [[ "$_got_fpr" != "$_ppa_key_fpr" ]]; then
|
||
rm -f "$_kf_tmp" 2>/dev/null
|
||
die "Amnezia PPA GPG key failed the fingerprint check (got: '${_got_fpr:-<empty>}')."
|
||
fi
|
||
chmod 644 "$_kf_tmp" || { rm -f "$_kf_tmp" 2>/dev/null; die "chmod GPG key error."; }
|
||
mv -f "$_kf_tmp" "$keyring_file" \
|
||
|| { rm -f "$_kf_tmp" 2>/dev/null; die "Failed to move GPG key to target path."; }
|
||
|
||
# Debian 12 uses traditional .list format, Debian 13+ and Ubuntu 24.04+ use DEB822 .sources
|
||
if [[ "${OS_ID:-ubuntu}" == "debian" && "${OS_VERSION}" == "12" ]]; then
|
||
log "Debian 12: using traditional .list format"
|
||
echo "deb [signed-by=${keyring_file}] https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu ${ppa_codename} main" \
|
||
> "$ppa_list" || die "Failed to create $ppa_list"
|
||
chmod 644 "$ppa_list"
|
||
else
|
||
cat > "$ppa_sources" <<PPASRC || die "PPA sources creation error."
|
||
Types: deb
|
||
URIs: https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu
|
||
Suites: ${ppa_codename}
|
||
Components: main
|
||
Signed-By: ${keyring_file}
|
||
PPASRC
|
||
chmod 644 "$ppa_sources"
|
||
fi
|
||
log "PPA added."
|
||
fi
|
||
# apt-get update + error classification:
|
||
# - Errors only on the Amnezia PPA → continue, apt_wait_for_ppa_package
|
||
# below will retry (issue #68: ppa.launchpadcontent.net briefly down).
|
||
# - Any other non-source error (DNS / GPG mismatch / dpkg lock on the
|
||
# base mirror) → fail fast. Continuing on a stale apt-cache is unsafe —
|
||
# the next apt-get install would fail with a less actionable error
|
||
# (PR #69 review finding).
|
||
if ! apt_update_tolerant --ppa-amnezia-tolerant; then
|
||
log_error "apt-get update failed with a hard error — not a PPA outage (issue #68)."
|
||
log_error "Check: DNS, access to archive.ubuntu.com / deb.debian.org,"
|
||
log_error "integrity of keys in /etc/apt/keyrings, dpkg lock contention."
|
||
die "apt update returned an error (rc!=0, not the Amnezia PPA)."
|
||
fi
|
||
# PPA added, cache refreshed: sources do not change further in step 2, so
|
||
# install_packages must not repeat apt update (on slow mirrors every run
|
||
# is 10-60 seconds).
|
||
_APT_UPDATED=1
|
||
# apt-get update is tolerant to an unreachable InRelease (rc=0 even when
|
||
# the PPA is down). So we check that amneziawg-dkms actually appears in
|
||
# apt-cache, with three attempts and 30s/60s backoff (~1.5 min total).
|
||
# A brief ppa.launchpadcontent.net outage (issue #68) must not break
|
||
# the install.
|
||
if ! apt_wait_for_ppa_package amneziawg-dkms 3 30; then
|
||
log_error "Package amneziawg-dkms did not appear in apt-cache after 3 attempts."
|
||
log_error "ppa.launchpadcontent.net appears to be down — this is a"
|
||
log_error "Launchpad infrastructure outage, not a script bug."
|
||
log_error "Wait 10–15 minutes and re-run the script with the same args."
|
||
log_error "Details: https://github.com/bivlked/amneziawg-installer/issues/68"
|
||
die "Amnezia PPA is temporarily unavailable."
|
||
fi
|
||
|
||
# AmneziaWG + qrencode packages (NO Python!)
|
||
log "Installing AmneziaWG packages..."
|
||
|
||
# H0 (AWG 3.0, 31 jul 2026): decide the pinned 2.0 module path BEFORE installing
|
||
# any package - the hold must be in place before even the ARM prebuilt path, where
|
||
# install_packages installs amneziawg-tools whose Recommends would otherwise pull
|
||
# in the 3.0 module behind the gate. On kernels < 6.7 the PPA module is AmneziaWG
|
||
# 3.0; we do not install it here (deliberately, see _kernel_supports_awg3) and
|
||
# build the pinned 2.0 from source instead; only tools come from the PPA
|
||
# (version-aware, they do work with 2.0 - verified).
|
||
local use_pinned_awg2=0
|
||
if ! _kernel_supports_awg3; then
|
||
use_pinned_awg2=1
|
||
log "Kernel $(uname -r) is older than 6.7 - installing the tested AmneziaWG 2.0 module here, not 3.0 from the PPA."
|
||
log "Activated the pinned AmneziaWG 2.0 module path from source ($AWG2_PIN_TAG)."
|
||
# Re-entry: if a prior run / the stock installer already installed (or left
|
||
# half-configured) the 3.0 package - remove it and its source, otherwise its
|
||
# failing postinst and /usr/src/amneziawg-* ownership conflict with the build.
|
||
if dpkg -l amneziawg-dkms 2>/dev/null | grep -qE '^(ii|iU|iF|iH|rc)'; then
|
||
log "Found a previously installed amneziawg-dkms (AmneziaWG 3.0) - removing it before the pinned build."
|
||
DEBIAN_FRONTEND=noninteractive apt-get purge -y amneziawg-dkms amneziawg >/dev/null 2>&1 \
|
||
|| dpkg --purge --force-all amneziawg-dkms amneziawg >/dev/null 2>&1 \
|
||
|| log_warn "Could not fully remove the previously installed amneziawg-dkms - the install below may fail."
|
||
command -v dkms >/dev/null 2>&1 && dkms remove -m amneziawg -v 1.0.0 --all >/dev/null 2>&1 || true
|
||
rm -rf /var/lib/dkms/amneziawg* /usr/src/amneziawg-* 2>/dev/null || true
|
||
fi
|
||
# ⚠️ Hold BEFORE any install: amneziawg-tools RECOMMENDS amneziawg-dkms, apt
|
||
# installs recommends by default -> without a hold, installing tools (incl. on
|
||
# the ARM path) would drag in the 3.0 dkms, leaving TWO DKMS trees under the
|
||
# same module name amneziawg - the pinned 2.0 one and the packaged 3.0 one.
|
||
# This is a safety mechanism, so its failure is fatal (we verify the hold took
|
||
# effect).
|
||
apt-mark hold amneziawg-dkms amneziawg >/dev/null 2>&1 || true
|
||
# We verify amneziawg-dkms specifically - it is the load-bearing package: it
|
||
# is what amneziawg-tools Recommends and what carries the 3.0 module. The
|
||
# metapackage amneziawg need not be held (its Depends: amneziawg-dkms is held
|
||
# anyway), so we do not verify it separately.
|
||
if ! apt-mark showhold 2>/dev/null | grep -qx "amneziawg-dkms"; then
|
||
die "Failed to hold amneziawg-dkms. Without it, installing amneziawg-tools would pull the AmneziaWG 3.0 module from the PPA, bypassing the chosen path. Aborted (check for an apt/dpkg lock)."
|
||
fi
|
||
else
|
||
# Kernel >= 6.7: the normal path installs amneziawg-dkms from the PPA. Clear a
|
||
# possible hold left by an earlier pinned run (else apt install -y aborts on hold).
|
||
apt-mark unhold amneziawg-dkms amneziawg >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
# On ARM: try prebuilt .deb first (no build tools or headers required).
|
||
# Falls back to DKMS if no matching prebuilt is available or download fails.
|
||
# ⚠️ On a kernel < 6.7 (use_pinned_awg2=1) using the prebuilt .deb is SAFE: our ARM
|
||
# prebuilts are built from scripts/arm-module-version.txt, pinned to the same 2.0
|
||
# tag (v1.0.20260725) and locked by a test, so it is a KNOWN 2.0 module, not 3.0.
|
||
# ⚠️ That is the ONLY guarantee, and it is enough. The former second argument -
|
||
# "3.0 cannot compile for a kernel < 6.7 anyway, so a 3.0 asset for a target like
|
||
# debian-bookworm-arm64 cannot exist in the release" - is WRONG as of 31 jul 2026
|
||
# (upstream fixed the build, v3.0.20260731-04); do not lean on it. On no match
|
||
# _try_install_prebuilt_arm returns 1 and we fall through to the verified source
|
||
# build below. The hold set above also applies here (keeps tools from pulling the
|
||
# 3.0 dkms via Recommends).
|
||
local arch
|
||
arch="$(uname -m)"
|
||
if [[ "$arch" == "aarch64" || "$arch" == "armv7l" ]]; then
|
||
if _try_install_prebuilt_arm; then
|
||
log "Prebuilt kernel module installed. Installing userspace tools from PPA..."
|
||
# 🔴 The hold is REQUIRED here too, REGARDLESS of the kernel version.
|
||
# Above it is set only on the pinned path (kernel < 6.7), while the
|
||
# >= 6.7 branch does apt-mark unhold - and this ARM block runs AFTER
|
||
# that gate. The prebuilt package is named amneziawg-kmod-<KERNEL_ID>
|
||
# and declares no "Provides: amneziawg-modules", so it does NOT
|
||
# satisfy the alternative in amneziawg-tools' Recommends (live PPA
|
||
# metadata: "amneziawg-modules (>= 0.0.20171001) | amneziawg-dkms
|
||
# (>= ...)"), and amneziawg-modules itself is absent from the PPA.
|
||
# install_packages installs via apt install -y WITH recommends, so
|
||
# without the hold apt would pull amneziawg-dkms from the PPA, and a
|
||
# 3.0 tree in updates/dkms/ would land next to our 2.0 module in
|
||
# extra/. Two trees carrying a module of the SAME name is exactly what
|
||
# the hold exists to prevent. Reachable: the ubuntu-2510-arm64 and
|
||
# debian-trixie-arm64 prebuilt targets ship kernels 6.7+.
|
||
apt-mark hold amneziawg-dkms amneziawg >/dev/null 2>&1 || true
|
||
if ! apt-mark showhold 2>/dev/null | grep -qx "amneziawg-dkms"; then
|
||
die "Failed to put amneziawg-dkms on hold before installing amneziawg-tools. Without it the PPA module would land next to the prebuilt one - two trees named amneziawg. Check the apt/dpkg lock and run the script again: the prebuilt package is already installed, this step will simply repeat."
|
||
fi
|
||
install_packages "amneziawg-tools" "wireguard-tools" "qrencode"
|
||
# POSTCONDITION. The PRECONDITION (the hold is in place) is verified
|
||
# above, but nobody checked the result - in between apt could have
|
||
# installed the package for a reason we did not foresee, or dkms may
|
||
# be left over from an earlier install on this very host. Check the
|
||
# fact rather than the precondition: the fact is what catches a real
|
||
# failure.
|
||
# ⚠️ Deliberately NOT a die: "dkms left over from a previous run" is
|
||
# already a broken state, but aborting the install over it is not
|
||
# something to ship without a run on an ARM bench, and an abort with
|
||
# no path to a fix leaves the person with nothing. Hence a loud
|
||
# warning with the exact command. Tightening this to a die is a
|
||
# separate task.
|
||
if dpkg-query -W -f='${Status}' amneziawg-dkms 2>/dev/null | grep -q "ok installed"; then
|
||
log_warn "WARNING: the amneziawg-dkms package is installed next to the prebuilt module."
|
||
log_warn " How that ends was measured on a bench rather than guessed: as soon as kernel"
|
||
log_warn " headers are present, DKMS builds, DISPLACES the prebuilt file from extra/, and"
|
||
log_warn " after a reboot it is the one that loads - the server silently moves to the"
|
||
log_warn " other protocol line. The tunnel keeps working, and dpkg still believes the"
|
||
log_warn " prebuilt package is installed."
|
||
log_warn " Remove the extra one: sudo apt-mark unhold amneziawg-dkms && sudo apt-get purge -y amneziawg-dkms"
|
||
log_warn " then sudo apt-mark hold amneziawg-dkms, reinstall the module and reboot."
|
||
fi
|
||
log "Step 2 completed (prebuilt ARM)."
|
||
# request_reboot always terminates the process (exit), we never return here.
|
||
request_reboot 3
|
||
fi
|
||
log "No matching prebuilt — falling back to DKMS build."
|
||
fi
|
||
|
||
# Packages: on the pinned path (kernel < 6.7) we do NOT install amneziawg-dkms
|
||
# (it would be the 3.0 module); git is added instead to build the pinned 2.0
|
||
# source. The gate, hold and cleanup of a previously installed 3.0 were done
|
||
# above (before the ARM block).
|
||
local packages
|
||
if [[ "$use_pinned_awg2" -eq 1 ]]; then
|
||
packages=("amneziawg-tools" "wireguard-tools" "dkms"
|
||
"build-essential" "dpkg-dev" "git" "qrencode")
|
||
else
|
||
packages=("amneziawg-dkms" "amneziawg-tools" "wireguard-tools" "dkms"
|
||
"build-essential" "dpkg-dev" "qrencode")
|
||
fi
|
||
|
||
# Linux headers: on Debian, exact linux-headers-$(uname -r) may not be available
|
||
local current_headers
|
||
current_headers="linux-headers-$(uname -r)"
|
||
if dpkg -s "$current_headers" &>/dev/null || apt-cache show "$current_headers" &>/dev/null 2>&1; then
|
||
packages+=("$current_headers")
|
||
else
|
||
log_warn "No headers for $(uname -r), installing generic package..."
|
||
local kernel_release
|
||
kernel_release="$(uname -r)"
|
||
if [[ "$kernel_release" == *+rpt* || "$kernel_release" == *-rpi* ]]; then
|
||
# Raspberry Pi Foundation kernel (+rpt suffix) — use RPi meta-package
|
||
# linux-headers-rpi-2712: Pi 5 / Cortex-A76; linux-headers-rpi-v8: Pi 3/4 arm64
|
||
local rpi_headers
|
||
if [[ "$kernel_release" == *2712* ]]; then
|
||
rpi_headers="linux-headers-rpi-2712"
|
||
else
|
||
rpi_headers="linux-headers-rpi-v8"
|
||
fi
|
||
log "Raspberry Pi kernel detected, using $rpi_headers"
|
||
packages+=("$rpi_headers")
|
||
elif [[ "${OS_ID:-ubuntu}" == "debian" ]]; then
|
||
# On Debian: linux-headers-$(dpkg --print-architecture)
|
||
local arch_pkg
|
||
arch_pkg="linux-headers-$(dpkg --print-architecture 2>/dev/null || echo "amd64")"
|
||
packages+=("$arch_pkg")
|
||
else
|
||
packages+=("linux-headers-generic")
|
||
fi
|
||
fi
|
||
# v5.13.0: on 25.10/26.04 after an in-place upgrade from 24.04, the
|
||
# system may still carry kernel headers from 24.04 (6.8.x) compiled with
|
||
# gcc-13. 25.10 ships gcc-15 by default → dkms autoinstall in the
|
||
# amneziawg-dkms postinst fails when building against stale kernels, and
|
||
# dpkg leaves amneziawg* unconfigured. If we detect kernel headers other
|
||
# than the running one, install gcc-13 ahead of time (available in
|
||
# questing/universe and 26.04 archive) so autoinstall succeeds for every
|
||
# kernel.
|
||
local _running_kernel _has_stale=0 _hd _hd_kern
|
||
_running_kernel="$(uname -r)"
|
||
for _hd in /lib/modules/*/build; do
|
||
[[ -e "$_hd" ]] || continue
|
||
_hd_kern="${_hd#/lib/modules/}"
|
||
_hd_kern="${_hd_kern%/build}"
|
||
if [[ "$_hd_kern" != "$_running_kernel" ]]; then
|
||
_has_stale=1
|
||
break
|
||
fi
|
||
done
|
||
if [[ "$_has_stale" -eq 1 ]] && ! command -v gcc-13 >/dev/null 2>&1; then
|
||
if apt-cache madison gcc-13 2>/dev/null | grep -q .; then
|
||
log "Stale kernel headers detected (other than $_running_kernel) — installing gcc-13 for DKMS autoinstall compatibility."
|
||
DEBIAN_FRONTEND=noninteractive apt install -y gcc-13 \
|
||
|| log_warn "gcc-13 install failed — DKMS autoinstall may fail on stale kernels."
|
||
else
|
||
log_warn "Stale kernel headers detected, but gcc-13 is not in the repo — DKMS autoinstall may fail."
|
||
fi
|
||
fi
|
||
install_packages "${packages[@]}"
|
||
|
||
# H0: pinned path - build the 2.0 module from source INSTEAD of PPA amneziawg-dkms.
|
||
# Headers for the current kernel are already installed above (in packages); the
|
||
# hold was set earlier.
|
||
if [[ "$use_pinned_awg2" -eq 1 ]]; then
|
||
if ! _install_pinned_awg2_module; then
|
||
log_error "Failed to install the pinned AmneziaWG 2.0 module."
|
||
log_error "On kernels older than 6.7 (yours is $(uname -r)) the installer does not take"
|
||
log_error "the module from the PPA but builds it from source, and that step did not go"
|
||
log_error "through. The exact reason is in the lines above; usually it is missing kernel"
|
||
log_error "headers, no free space, a dropped network, or a module that built but will"
|
||
log_error "not load (Secure Boot). Fallback option: deploy the server on Ubuntu"
|
||
log_error "24.04/25.10 or Debian 13, where the module comes as a package from the PPA."
|
||
log_error "See README/INSTALL_VPS for details."
|
||
die "The pinned AmneziaWG 2.0 module was not installed."
|
||
fi
|
||
log "The pinned AmneziaWG 2.0 module is installed; PPA dkms is held (3.0 protection)."
|
||
fi
|
||
|
||
# v5.12.0: install a kernel-headers meta-package so apt automatically
|
||
# pulls matching headers on every kernel upgrade. Without the meta only
|
||
# linux-headers-$(uname -r) is installed, which does not track new
|
||
# kernels and the DKMS module fails to rebuild on the next apt upgrade.
|
||
#
|
||
# Detect kernel flavor (Ubuntu cloud images: aws/azure/gcp/oracle/kvm/
|
||
# lowlatency/raspi; Debian cloud-amd64) — a plain linux-headers-generic
|
||
# on an Azure VM does not track the right kernel pipeline. Take the
|
||
# uname -r suffix, try the flavor-specific meta first, fall back to
|
||
# generic / arch.
|
||
local arch_meta kernel_rel
|
||
arch_meta="$(dpkg --print-architecture 2>/dev/null || echo '')"
|
||
kernel_rel="$(uname -r)"
|
||
local -a meta_candidates=()
|
||
if [[ "$kernel_rel" == *+rpt* || "$kernel_rel" == *-rpi* ]]; then
|
||
: # RPi: linux-headers-rpi-{2712,v8} meta is already in packages above.
|
||
elif [[ "${OS_ID:-ubuntu}" == "ubuntu" ]]; then
|
||
# Ubuntu uname -r format: 6.8.0-49-generic / 6.8.0-1009-aws / ...
|
||
local flavor="${kernel_rel##*-}"
|
||
if [[ -n "$flavor" && "$flavor" != "$kernel_rel" ]]; then
|
||
meta_candidates+=("linux-headers-${flavor}")
|
||
fi
|
||
meta_candidates+=("linux-headers-generic")
|
||
elif [[ "${OS_ID:-}" == "debian" && -n "$arch_meta" ]]; then
|
||
# Debian: stock kernel 6.12.85+deb13-amd64, cloud — 6.12.85+deb13-cloud-amd64.
|
||
[[ "$kernel_rel" == *-cloud-* ]] \
|
||
&& meta_candidates+=("linux-headers-cloud-${arch_meta}")
|
||
meta_candidates+=("linux-headers-${arch_meta}")
|
||
fi
|
||
local meta meta_installed=0
|
||
for meta in "${meta_candidates[@]}"; do
|
||
if dpkg-query -W -f='${Status}' "$meta" 2>/dev/null \
|
||
| grep -q 'install ok installed'; then
|
||
log "$meta is already installed (auto-tracking kernel upgrades)."
|
||
meta_installed=1
|
||
break
|
||
fi
|
||
log "Installing meta-package $meta..."
|
||
if DEBIAN_FRONTEND=noninteractive apt install -y "$meta" 2>/dev/null; then
|
||
log "$meta installed."
|
||
meta_installed=1
|
||
break
|
||
fi
|
||
log_warn "Failed to install $meta — trying next candidate."
|
||
done
|
||
if [[ ${#meta_candidates[@]} -gt 0 && $meta_installed -eq 0 ]]; then
|
||
log_warn "No kernel-headers meta-package installed — auto-rebuild on kernel upgrade may not work."
|
||
fi
|
||
|
||
# v5.12.0: deploy the standalone helper /usr/local/sbin/amneziawg-ensure-module.
|
||
# It is invoked from the apt hook (DPkg::Post-Invoke) and from the Phase 4
|
||
# systemd unit. The helper is self-contained — it does NOT source
|
||
# awg_common.sh — so it keeps working even if /root/awg/ is moved.
|
||
#
|
||
# Deploy uses a staging file in the SAME filesystem as the destination
|
||
# plus a final `mv -f` — guaranteeing atomic replacement (a cross-FS
|
||
# rename is copy+remove, NOT atomic). The staging file starts with a
|
||
# dot so apt and logrotate skip dotfiles when scanning the directory.
|
||
log "Deploying DKMS auto-repair helper..."
|
||
mkdir -p /usr/local/sbin
|
||
local _stage_helper=/usr/local/sbin/.amneziawg-ensure-module.new
|
||
cat > "$_stage_helper" <<'AWG_ENSURE_HELPER_EOF'
|
||
#!/bin/bash
|
||
# amneziawg-ensure-module — rebuilds the AmneziaWG DKMS module after a
|
||
# kernel upgrade.
|
||
#
|
||
# Generated by install_amneziawg.sh (v5.12.0+). Do not edit; re-run the
|
||
# installer to refresh.
|
||
#
|
||
# Modes:
|
||
# --hook — invoked from /etc/apt/apt.conf.d/99-amneziawg-post-kernel
|
||
# (DPkg::Post-Invoke). Constraints:
|
||
# - MUST NOT call apt-get install: the parent apt still
|
||
# holds /var/lib/dpkg/lock-frontend, a nested install
|
||
# would deadlock.
|
||
# - Skips modprobe and systemctl: the running kernel may
|
||
# still be the old one. The newly-built module is
|
||
# loaded after reboot via the systemd unit, or via
|
||
# `manage repair-module`.
|
||
# Stamp-file fast-path keeps routine apt ops noise-free.
|
||
#
|
||
# --systemd — invoked from amneziawg-ensure-module.service at boot,
|
||
# ordered Before=awg-quick@awg0.service. Builds for every
|
||
# target kernel (same as --hook), then loads the module
|
||
# via modprobe so awg-quick can start. No stamp fast-path
|
||
# — boot must always verify load state, even if /lib/modules
|
||
# hasn't changed since the last build (module not loaded
|
||
# across reboots). Exit 1 if modprobe fails so systemd
|
||
# marks the unit as failed (visible via systemctl status).
|
||
#
|
||
# Iteration target: every kernel that exposes /lib/modules/<ver>/build
|
||
# (= a directory with installed headers). uname -r alone is insufficient
|
||
# in apt-hook context because it returns the OLD running kernel while
|
||
# the new kernel's headers are already on disk.
|
||
#
|
||
# Output: stdout / stderr; --hook appends to
|
||
# /var/log/amneziawg-ensure-module.log (rotated weekly via
|
||
# /etc/logrotate.d/amneziawg-ensure-module). --systemd writes to journal
|
||
# (StandardOutput=journal, StandardError=journal in the unit file).
|
||
|
||
set -euo pipefail
|
||
|
||
MODE="${1:-}"
|
||
case "$MODE" in
|
||
--hook|--systemd) ;;
|
||
--help|-h) echo "Usage: $0 --hook | --systemd"; exit 0 ;;
|
||
*) echo "amneziawg-ensure-module: missing or unknown mode (use --hook or --systemd)" >&2; exit 2 ;;
|
||
esac
|
||
|
||
ts() { date '+%Y-%m-%d %H:%M:%S'; }
|
||
log_line() { printf '[%s] [%s] %s\n' "$(ts)" "$MODE" "$*"; }
|
||
|
||
if [[ $(id -u) -ne 0 ]]; then
|
||
log_line "ERROR: root privileges required" >&2
|
||
exit 1
|
||
fi
|
||
|
||
if ! command -v dkms >/dev/null 2>&1; then
|
||
log_line "WARN: dkms is not installed — nothing to do"
|
||
exit 0
|
||
fi
|
||
|
||
declare -a target_kernels=()
|
||
shopt -s nullglob
|
||
for build_dir in /lib/modules/*/build; do
|
||
[[ -d "$build_dir" || -L "$build_dir" ]] || continue
|
||
target_kernels+=("$(basename "$(dirname "$build_dir")")")
|
||
done
|
||
shopt -u nullglob
|
||
|
||
if [[ ${#target_kernels[@]} -eq 0 ]]; then
|
||
log_line "WARN: no /lib/modules/*/build directories — kernel headers missing"
|
||
exit 0
|
||
fi
|
||
|
||
# Build per-run state signature (mtime + kver) used by both modes:
|
||
# --hook — for stamp-file fast-path comparison (silent exit if equal)
|
||
# --systemd — recorded after success so subsequent --hook calls can skip
|
||
STAMP_DIR=/var/lib/amneziawg
|
||
STAMP_FILE="${STAMP_DIR}/ensure-module.stamp"
|
||
current_state=""
|
||
for kver in "${target_kernels[@]}"; do
|
||
# stat may fail (build dir removed in flight) — guard against set -e abort.
|
||
# Empty mtime → comparison differs → we re-run dkms autoinstall (acceptable).
|
||
mtime="$(stat -c '%Y' "/lib/modules/${kver}/build" 2>/dev/null || true)"
|
||
current_state+="${mtime} ${kver} "
|
||
done
|
||
|
||
# Fast-path applies ONLY to --hook. Boot (--systemd) must always run the
|
||
# full path — module is not loaded across reboots even when /lib/modules
|
||
# state is unchanged.
|
||
if [[ "$MODE" == "--hook" ]] \
|
||
&& [[ -f "$STAMP_FILE" && "$(cat "$STAMP_FILE" 2>/dev/null)" == "$current_state" ]]; then
|
||
# Silent exit — routine apt ops don't add log noise.
|
||
exit 0
|
||
fi
|
||
|
||
# Strip the deprecated REMAKE_INITRD directive (triggers noisy warnings
|
||
# on modern DKMS releases).
|
||
for cfg in /var/lib/dkms/amneziawg/*/source/dkms.conf; do
|
||
[[ -f "$cfg" ]] && sed -i '/^REMAKE_INITRD=/d' "$cfg" 2>/dev/null || true
|
||
done
|
||
|
||
build_rc=0
|
||
for kver in "${target_kernels[@]}"; do
|
||
log_line "dkms autoinstall -k $kver"
|
||
if ! dkms autoinstall -k "$kver"; then
|
||
log_line "WARN: dkms autoinstall failed for kernel $kver" >&2
|
||
build_rc=1
|
||
fi
|
||
done
|
||
|
||
depmod -a 2>/dev/null || true
|
||
|
||
# --systemd: load the module so awg-quick can start. Exit 1 on modprobe
|
||
# failure — systemd marks the unit failed; visible via `systemctl status
|
||
# amneziawg-ensure-module.service`. awg-quick still starts (Before= is
|
||
# ordering only, not a dependency) and surfaces its own error if the
|
||
# module is unavailable.
|
||
if [[ "$MODE" == "--systemd" ]]; then
|
||
log_line "modprobe amneziawg"
|
||
if ! modprobe amneziawg 2>&1; then
|
||
log_line "ERROR: modprobe amneziawg failed for running kernel $(uname -r)" >&2
|
||
log_line " Check: /var/lib/dkms/amneziawg/<ver>/<kernel>/log/make.log" >&2
|
||
exit 1
|
||
fi
|
||
if ! lsmod 2>/dev/null | grep -q '^amneziawg '; then
|
||
log_line "ERROR: amneziawg module not present in lsmod after modprobe" >&2
|
||
exit 1
|
||
fi
|
||
log_line "amneziawg module loaded for $(uname -r)"
|
||
# Update stamp on --systemd success (current kernel is usable, what matters
|
||
# for boot) even if some other kernel's build failed (build_rc=1).
|
||
mkdir -p "$STAMP_DIR" 2>/dev/null || true
|
||
printf '%s' "$current_state" > "$STAMP_FILE" 2>/dev/null || true
|
||
log_line "done"
|
||
exit 0
|
||
fi
|
||
|
||
# --hook: update stamp only on full success — partial failures retry next run.
|
||
if [[ $build_rc -eq 0 ]]; then
|
||
mkdir -p "$STAMP_DIR" 2>/dev/null || true
|
||
printf '%s' "$current_state" > "$STAMP_FILE" 2>/dev/null || true
|
||
fi
|
||
|
||
log_line "done (rc=$build_rc)"
|
||
exit "$build_rc"
|
||
AWG_ENSURE_HELPER_EOF
|
||
chown root:root "$_stage_helper" 2>/dev/null || true
|
||
chmod 0755 "$_stage_helper" \
|
||
|| { rm -f "$_stage_helper"; die "Failed to chmod helper."; }
|
||
mv -f "$_stage_helper" /usr/local/sbin/amneziawg-ensure-module \
|
||
|| { rm -f "$_stage_helper"; die "Failed to deploy amneziawg-ensure-module helper."; }
|
||
log "Helper /usr/local/sbin/amneziawg-ensure-module deployed."
|
||
|
||
# v5.12.0: apt hook DPkg::Post-Invoke calls the helper after a kernel upgrade.
|
||
mkdir -p /etc/apt/apt.conf.d
|
||
local _stage_hook=/etc/apt/apt.conf.d/.99-amneziawg-post-kernel.new
|
||
cat > "$_stage_hook" <<'AWG_APT_HOOK_EOF'
|
||
// amneziawg-installer (v5.12.0+): rebuild DKMS module after kernel upgrades.
|
||
// Generated by install_amneziawg.sh — do not edit; re-run the installer to refresh.
|
||
DPkg::Post-Invoke {"if [ -x /usr/local/sbin/amneziawg-ensure-module ]; then /usr/local/sbin/amneziawg-ensure-module --hook >>/var/log/amneziawg-ensure-module.log 2>&1 || true; fi";};
|
||
AWG_APT_HOOK_EOF
|
||
chown root:root "$_stage_hook" 2>/dev/null || true
|
||
chmod 0644 "$_stage_hook" \
|
||
|| { rm -f "$_stage_hook"; die "Failed to chmod apt hook."; }
|
||
mv -f "$_stage_hook" /etc/apt/apt.conf.d/99-amneziawg-post-kernel \
|
||
|| { rm -f "$_stage_hook"; die "Failed to deploy apt hook."; }
|
||
log "Apt hook 99-amneziawg-post-kernel installed (auto-rebuild on apt kernel upgrade)."
|
||
|
||
# v5.12.0: logrotate config for /var/log/amneziawg-ensure-module.log
|
||
mkdir -p /etc/logrotate.d
|
||
local _stage_logrotate=/etc/logrotate.d/.amneziawg-ensure-module.new
|
||
cat > "$_stage_logrotate" <<'AWG_LOGROTATE_EOF'
|
||
/var/log/amneziawg-ensure-module.log {
|
||
weekly
|
||
rotate 4
|
||
compress
|
||
delaycompress
|
||
missingok
|
||
notifempty
|
||
copytruncate
|
||
}
|
||
AWG_LOGROTATE_EOF
|
||
chown root:root "$_stage_logrotate" 2>/dev/null || true
|
||
chmod 0644 "$_stage_logrotate" \
|
||
|| { rm -f "$_stage_logrotate"; die "Failed to chmod logrotate config."; }
|
||
mv -f "$_stage_logrotate" /etc/logrotate.d/amneziawg-ensure-module \
|
||
|| { rm -f "$_stage_logrotate"; die "Failed to deploy logrotate config."; }
|
||
log "Logrotate config /etc/logrotate.d/amneziawg-ensure-module installed (weekly, rotate 4)."
|
||
|
||
# v5.12.0 Phase 4: systemd unit guarantees the kernel module is built
|
||
# and loaded BEFORE awg-quick@awg0 starts on every boot. Type=oneshot +
|
||
# RemainAfterExit=yes + Before=awg-quick@awg0.service — the standard
|
||
# pre-load pattern (after a kernel upgrade DKMS may need to rebuild on
|
||
# the very first boot of the new kernel).
|
||
log "Deploying systemd unit amneziawg-ensure-module.service..."
|
||
mkdir -p /etc/systemd/system
|
||
local _stage_unit=/etc/systemd/system/.amneziawg-ensure-module.service.new
|
||
cat > "$_stage_unit" <<'AWG_SYSTEMD_UNIT_EOF'
|
||
[Unit]
|
||
Description=Ensure amneziawg kernel module is built and loaded
|
||
Documentation=https://github.com/bivlked/amneziawg-installer
|
||
Before=awg-quick@awg0.service
|
||
After=systemd-modules-load.service local-fs.target
|
||
ConditionPathExists=/usr/local/sbin/amneziawg-ensure-module
|
||
|
||
[Service]
|
||
Type=oneshot
|
||
RemainAfterExit=yes
|
||
ExecStart=/usr/local/sbin/amneziawg-ensure-module --systemd
|
||
TimeoutStartSec=300
|
||
StandardOutput=journal
|
||
StandardError=journal
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
AWG_SYSTEMD_UNIT_EOF
|
||
chown root:root "$_stage_unit" 2>/dev/null || true
|
||
chmod 0644 "$_stage_unit" \
|
||
|| { rm -f "$_stage_unit"; die "Failed to chmod systemd unit."; }
|
||
mv -f "$_stage_unit" /etc/systemd/system/amneziawg-ensure-module.service \
|
||
|| { rm -f "$_stage_unit"; die "Failed to deploy systemd unit."; }
|
||
if ! systemctl daemon-reload; then
|
||
log_warn "systemctl daemon-reload failed — the unit may not activate until reboot."
|
||
fi
|
||
if ! systemctl enable amneziawg-ensure-module.service; then
|
||
log_warn "Failed to enable amneziawg-ensure-module.service — boot-time auto-rebuild will not run."
|
||
fi
|
||
log "Systemd unit amneziawg-ensure-module.service installed and enabled (Before=awg-quick@awg0)."
|
||
|
||
# DKMS status
|
||
log "Checking DKMS status..."
|
||
local dkms_stat
|
||
dkms_stat=$(dkms status 2>&1)
|
||
if ! echo "$dkms_stat" | grep -q 'amneziawg.*installed'; then
|
||
log_warn "DKMS status not OK."
|
||
log_msg "WARN" "$dkms_stat"
|
||
else
|
||
log "DKMS status OK."
|
||
fi
|
||
|
||
log "Step 2 completed."
|
||
request_reboot 3
|
||
}
|
||
|
||
# ==============================================================================
|
||
# STEP 3: Kernel module check
|
||
# ==============================================================================
|
||
|
||
step3_check_module() {
|
||
update_state 3
|
||
log "### STEP 3: Kernel module check ###"
|
||
sleep 2
|
||
|
||
if ! lsmod | grep -q -w amneziawg; then
|
||
log "Module not loaded. Loading..."
|
||
modprobe amneziawg || die "modprobe amneziawg error."
|
||
log "Module loaded."
|
||
local mf="/etc/modules-load.d/amneziawg.conf"
|
||
mkdir -p "$(dirname "$mf")"
|
||
if ! grep -qxF 'amneziawg' "$mf" 2>/dev/null; then
|
||
echo "amneziawg" > "$mf" || log_warn "Write error $mf"
|
||
log "Added to $mf."
|
||
fi
|
||
else
|
||
log "amneziawg module loaded."
|
||
fi
|
||
|
||
log "Module information:"
|
||
modinfo amneziawg | grep -E "filename|version|vermagic|srcversion" | while IFS= read -r line; do
|
||
log " $line"
|
||
done
|
||
|
||
local cv kr
|
||
cv=$(modinfo amneziawg 2>/dev/null | awk '/^vermagic:/{print $2}')
|
||
if [[ -z "$cv" ]]; then
|
||
die "Failed to read amneziawg vermagic. Check: modprobe amneziawg && modinfo amneziawg"
|
||
fi
|
||
kr=$(uname -r)
|
||
if [[ "$cv" != "$kr" ]]; then
|
||
log_warn "VerMagic MISMATCH: Module($cv) != Kernel($kr)!"
|
||
else
|
||
log "VerMagic matches."
|
||
fi
|
||
|
||
# Check awg version
|
||
if command -v awg &>/dev/null; then
|
||
local awg_ver
|
||
awg_ver=$(awg --version 2>/dev/null || echo "unknown")
|
||
log "awg version: $awg_ver"
|
||
else
|
||
log_warn "awg command not found!"
|
||
fi
|
||
|
||
log "Step 3 completed."
|
||
update_state 4
|
||
}
|
||
|
||
# ==============================================================================
|
||
# STEP 4: Firewall configuration
|
||
# ==============================================================================
|
||
|
||
step4_setup_firewall() {
|
||
update_state 4
|
||
if [[ "$NO_TWEAKS" -eq 0 ]]; then
|
||
log "### STEP 4: UFW firewall configuration ###"
|
||
install_packages ufw
|
||
setup_improved_firewall || die "UFW configuration error."
|
||
log "Step 4 completed."
|
||
else
|
||
log "### STEP 4: Skipping UFW configuration (--no-tweaks) ###"
|
||
fi
|
||
update_state 5
|
||
}
|
||
|
||
# ==============================================================================
|
||
# STEP 5: Downloading scripts (NO Python!)
|
||
# ==============================================================================
|
||
|
||
verify_sha256() {
|
||
local file="$1" expected="$2" label="$3"
|
||
# Skip verification when:
|
||
# - SHA is not set (RELEASE_PLACEHOLDER — release not yet published)
|
||
# - AWG_BRANCH is overridden (test branch)
|
||
if [[ "$expected" == "RELEASE_PLACEHOLDER" ]]; then
|
||
log_debug "SHA256 for $label: skipped (placeholder, pre-release)."
|
||
return 0
|
||
fi
|
||
if [[ "${AWG_BRANCH}" != "v${SCRIPT_VERSION}" ]]; then
|
||
log_warn "SHA256 for $label: verification skipped (AWG_BRANCH=${AWG_BRANCH} != v${SCRIPT_VERSION}). File not verified."
|
||
return 0
|
||
fi
|
||
local actual
|
||
actual=$(sha256sum "$file" 2>/dev/null | awk '{print $1}')
|
||
if [[ "$actual" != "$expected" ]]; then
|
||
log_error "SHA256 mismatch for $label!"
|
||
log_error " Expected: $expected"
|
||
log_error " Got: $actual"
|
||
log_error " File may have been tampered with. Re-download the installer from GitHub."
|
||
return 1
|
||
fi
|
||
log_debug "SHA256 $label: OK ($actual)"
|
||
return 0
|
||
}
|
||
|
||
# _secure_download <url> <target> <expected_sha256> <label>
|
||
# Atomic download:
|
||
# 1. curl → mktemp on the same FS as target;
|
||
# 2. verify_sha256 on the temp file (not on target, so a corrupt file
|
||
# never lives on the target path even for a fraction of a second);
|
||
# 3. chmod 700 on temp;
|
||
# 4. mv -f temp → target (atomic rename).
|
||
# If any step fails, temp is removed and target is untouched.
|
||
_secure_download() {
|
||
local url="$1" target="$2" expected_sha256="$3" label="$4"
|
||
local tmp target_dir
|
||
target_dir=$(dirname "$target")
|
||
tmp=$(mktemp -p "$target_dir" ".${label//\//_}.tmp.XXXXXX") \
|
||
|| die "Failed to create temp file for $label"
|
||
if ! curl -fLso "$tmp" --max-time 60 --retry 2 "$url"; then
|
||
rm -f "$tmp" 2>/dev/null
|
||
die "$label download error"
|
||
fi
|
||
if ! verify_sha256 "$tmp" "$expected_sha256" "$label"; then
|
||
rm -f "$tmp" 2>/dev/null
|
||
die "$label integrity check failed (SHA256 mismatch). Installation aborted."
|
||
fi
|
||
if ! chmod 700 "$tmp"; then
|
||
rm -f "$tmp" 2>/dev/null
|
||
die "chmod $label error"
|
||
fi
|
||
if ! mv -f "$tmp" "$target"; then
|
||
rm -f "$tmp" 2>/dev/null
|
||
die "Failed to move $label to target path"
|
||
fi
|
||
log "$label downloaded and verified."
|
||
}
|
||
|
||
step5_download_scripts() {
|
||
update_state 5
|
||
log "### STEP 5: Downloading management scripts ###"
|
||
cd "$AWG_DIR" || die "Error changing to $AWG_DIR"
|
||
|
||
log "Downloading $COMMON_SCRIPT_PATH..."
|
||
_secure_download "$COMMON_SCRIPT_URL" "$COMMON_SCRIPT_PATH" \
|
||
"$COMMON_SCRIPT_SHA256" "awg_common.sh"
|
||
|
||
log "Downloading $MANAGE_SCRIPT_PATH..."
|
||
_secure_download "$MANAGE_SCRIPT_URL" "$MANAGE_SCRIPT_PATH" \
|
||
"$MANAGE_SCRIPT_SHA256" "manage_amneziawg.sh"
|
||
|
||
log "Step 5 completed."
|
||
update_state 6
|
||
}
|
||
|
||
# ==============================================================================
|
||
# STEP 6: Config generation (native, without awgcfg.py)
|
||
# ==============================================================================
|
||
|
||
step6_generate_configs() {
|
||
update_state 6
|
||
log "### STEP 6: AWG 2.0 config generation ###"
|
||
cd "$AWG_DIR" || die "cd $AWG_DIR error"
|
||
|
||
# Load shared library
|
||
if [[ ! -f "$COMMON_SCRIPT_PATH" ]]; then
|
||
die "awg_common.sh not found. Step 5 not completed?"
|
||
fi
|
||
# shellcheck source=/dev/null
|
||
source "$COMMON_SCRIPT_PATH"
|
||
|
||
# Create key directory
|
||
mkdir -p "$KEYS_DIR" || die "Error creating $KEYS_DIR"
|
||
|
||
# Generate server keys (if not yet present)
|
||
if [[ ! -f "$AWG_DIR/server_private.key" ]]; then
|
||
log "Generating server keys..."
|
||
generate_server_keys || die "Server key generation error."
|
||
else
|
||
log "Server keys already exist."
|
||
fi
|
||
|
||
# Backup existing server config BEFORE overwriting
|
||
if [[ -f "$SERVER_CONF_FILE" ]]; then
|
||
local s_bak
|
||
s_bak="${SERVER_CONF_FILE}.bak-$(date +%F_%H%M%S)"
|
||
cp "$SERVER_CONF_FILE" "$s_bak" || log_warn "Backup error $s_bak"
|
||
log "Server config backup: $s_bak"
|
||
fi
|
||
|
||
# Create the AWG 2.0 server config, carrying ALL existing [Peer] blocks
|
||
# over from the backup in ONE atomic write (render_server_config appends
|
||
# the peers into the temp BEFORE mv). Previously the append ran AFTER
|
||
# render as a separate operation: a failure in the window between them
|
||
# left the live config peer-less, and the next run of step 6 backed up
|
||
# the already peer-less file - all clients were lost on --force reinstall
|
||
# (recovery only by hand from a timestamped .bak).
|
||
# C5 history (semantics worth keeping): ALL blocks are restored, including
|
||
# the defaults my_phone/my_laptop - the idempotent loop below skips peers
|
||
# that already exist, and the guard in generate_client refuses to recreate
|
||
# one whose artifacts exist.
|
||
log "Creating server config..."
|
||
render_server_config "${s_bak:-}" || die "Server config creation error."
|
||
if [[ -n "${s_bak:-}" && -f "$s_bak" ]] && grep -q '^\[Peer\]' "$s_bak" 2>/dev/null; then
|
||
log "Existing peers restored from backup."
|
||
fi
|
||
|
||
# Generate default clients
|
||
log "Creating default clients..."
|
||
local client_name
|
||
for client_name in my_phone my_laptop; do
|
||
if grep -qxF "#_Name = ${client_name}" "$SERVER_CONF_FILE" 2>/dev/null; then
|
||
log "Client '$client_name' already exists."
|
||
else
|
||
log "Creating client '$client_name'..."
|
||
generate_client "$client_name" || log_warn "Client creation error '$client_name'"
|
||
fi
|
||
done
|
||
|
||
# Config validation
|
||
validate_awg_config || log_warn "Config validation found issues."
|
||
|
||
# Set file permissions
|
||
secure_files
|
||
|
||
log "Configuration files in $AWG_DIR:"
|
||
ls -la "$AWG_DIR"/*.conf "$AWG_DIR"/*.png 2>/dev/null | while IFS= read -r line; do
|
||
log " $line"
|
||
done
|
||
|
||
log "Step 6 completed."
|
||
update_state 7
|
||
}
|
||
|
||
# ==============================================================================
|
||
# STEP 7: Service startup
|
||
# ==============================================================================
|
||
|
||
step7_start_service() {
|
||
update_state 7
|
||
log "### STEP 7: Service startup and security configuration ###"
|
||
|
||
log "Enabling and starting awg-quick@awg0..."
|
||
|
||
# Isolation switched on->off: the new config's PostDown no longer has the
|
||
# DROP rule to remove, and the restart's down phase already runs against
|
||
# the new on-disk config. Remove stale rules explicitly, in a loop - a
|
||
# repeated interrupted run may have left more than one (issue #178,
|
||
# same deferred-cleanup pattern as PREV_AWG_PORT in #175).
|
||
if [[ "${CLIENT_ISOLATION:-1}" -eq 0 ]]; then
|
||
while iptables -D FORWARD -i awg0 -o awg0 -j DROP 2>/dev/null; do :; done
|
||
while ip6tables -D FORWARD -i awg0 -o awg0 -j DROP 2>/dev/null; do :; done
|
||
fi
|
||
|
||
if systemctl is-active --quiet awg-quick@awg0; then
|
||
log "Service already active — restarting to apply configuration..."
|
||
systemctl enable awg-quick@awg0 || log_warn "Failed to enable awg-quick@awg0 — check autostart manually"
|
||
systemctl restart awg-quick@awg0 || die "restart awg-quick@awg0 error."
|
||
else
|
||
systemctl enable --now awg-quick@awg0 || die "enable --now error."
|
||
fi
|
||
# The interface has just come up, so record the device-parameter set: the
|
||
# management script should know from the start what is on the live interface.
|
||
# Without this the very first removal detection would have nothing to compare.
|
||
awg_record_device_params
|
||
log "Service enabled and started."
|
||
|
||
log "Checking service status..."
|
||
local _attempt
|
||
for _attempt in 1 2 3 4 5; do
|
||
sleep 1
|
||
check_service_status 2>/dev/null && break
|
||
[[ $_attempt -lt 5 ]] && log_debug "Waiting for service startup... (attempt $_attempt/5)"
|
||
done
|
||
check_service_status || die "Service status check failed."
|
||
|
||
# Fail2Ban
|
||
if [[ "$NO_TWEAKS" -eq 0 ]]; then
|
||
setup_fail2ban
|
||
else
|
||
log "Skipping Fail2Ban (--no-tweaks)."
|
||
fi
|
||
|
||
log "Step 7 completed successfully."
|
||
update_state 99
|
||
}
|
||
|
||
# ==============================================================================
|
||
# STEP 99: Completion
|
||
# ==============================================================================
|
||
|
||
step99_finish() {
|
||
log "### INSTALLATION COMPLETE ###"
|
||
log "=============================================================================="
|
||
log "AmneziaWG 2.0 installation and configuration COMPLETED SUCCESSFULLY!"
|
||
log " "
|
||
log "CLIENT FILES:"
|
||
log " Configs (.conf) and QR codes (.png) in: $AWG_DIR"
|
||
log " Copy them securely."
|
||
log " Example (on your PC):"
|
||
log " scp root@<SERVER_IP>:$AWG_DIR/*.conf ./"
|
||
log " "
|
||
log "USEFUL COMMANDS:"
|
||
log " sudo bash $MANAGE_SCRIPT_PATH help # Client management"
|
||
log " systemctl status awg-quick@awg0 # VPN status"
|
||
log " awg show # AmneziaWG status"
|
||
log " ufw status verbose # Firewall status"
|
||
log " "
|
||
log "IMPORTANT: Use Amnezia VPN client >= 4.8.12.7 to connect"
|
||
log " with AWG 2.0 protocol support"
|
||
log " "
|
||
cleanup_apt
|
||
log " "
|
||
|
||
# Final checks
|
||
if [[ -f "$CONFIG_FILE" ]]; then
|
||
log "Settings file $CONFIG_FILE: OK"
|
||
else
|
||
log_error "Settings file $CONFIG_FILE MISSING!"
|
||
fi
|
||
|
||
# Remove state file
|
||
log "Removing installation state file..."
|
||
rm -f "$STATE_FILE" "${STATE_FILE}.lock" "$AWG_DIR/.boot_id_before_step2" || log_warn "Failed to remove $STATE_FILE"
|
||
log "Installation fully completed. Log: $LOG_FILE"
|
||
log "=============================================================================="
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Main execution loop
|
||
# ==============================================================================
|
||
|
||
if [[ "$HELP" -eq 1 ]]; then show_help; fi
|
||
if [[ "$UNINSTALL" -eq 1 ]]; then step_uninstall; fi
|
||
if [[ "$DIAGNOSTIC" -eq 1 ]]; then create_diagnostic_report; exit 0; fi
|
||
if [[ "$VERBOSE" -eq 1 ]]; then set -x; fi
|
||
|
||
# v5.13.0: idempotency guard — if AmneziaWG is already installed and
|
||
# running, a re-run wastes ~20 minutes (Step 1 re-tunes sysctl/swap/BBR,
|
||
# `apt-get upgrade` can pull a new kernel and force another reboot, Step 7
|
||
# restarts awg-quick@awg0 — handshakes drop for a few seconds). Server
|
||
# keys, peers and obfuscation parameters survive a re-run, but without
|
||
# explicit opt-in this behaviour looks like a silent reinstall. Guarded by
|
||
# an explicit flag.
|
||
# AWG_FORCE_REINSTALL=1 in the environment is equivalent to --force.
|
||
if [[ "${AWG_FORCE_REINSTALL:-0}" == "1" ]]; then
|
||
FORCE_REINSTALL=1
|
||
fi
|
||
if [[ "$FORCE_REINSTALL" -ne 1 ]] && [[ -f "$SERVER_CONF_FILE" ]] \
|
||
&& systemctl is-active --quiet awg-quick@awg0 2>/dev/null; then
|
||
log_error "AmneziaWG is already installed and running."
|
||
log_error "To reinstall — pass --force (or AWG_FORCE_REINSTALL=1)."
|
||
log_error "WARNING: a reinstall will rerun Step 1 (sysctl/swap/BBR) and Step 7 (service restart)."
|
||
log_error " Obfuscation parameters (Jc/Jmin/Jmax/H1-H4/I1) survive UNLESS you pass"
|
||
log_error " --preset/--jc/--jmin/--jmax (those flags regenerate the whole set - every"
|
||
log_error " issued client config would have to be reissued via regen)."
|
||
log_error "To manage clients: sudo bash $MANAGE_SCRIPT_PATH help"
|
||
log_error "To fully uninstall: sudo bash $0 --uninstall"
|
||
exit 0
|
||
fi
|
||
|
||
initialize_setup
|
||
|
||
while (( current_step < 99 )); do
|
||
log "Executing step $current_step..."
|
||
case $current_step in
|
||
1) step1_update_and_optimize ;;
|
||
2) step2_install_amnezia ;;
|
||
3) step3_check_module; current_step=4 ;;
|
||
4) step4_setup_firewall; current_step=5 ;;
|
||
5) step5_download_scripts; current_step=6 ;;
|
||
6) step6_generate_configs; current_step=7 ;;
|
||
7) step7_start_service; current_step=99 ;;
|
||
*) die "Error: Unknown step $current_step." ;;
|
||
esac
|
||
done
|
||
|
||
if (( current_step == 99 )); then step99_finish; fi
|
||
exit 0
|