mirror of
https://github.com/bivlked/amneziawg-installer.git
synced 2026-10-19 05:03:18 +00:00
Release v5.36.2: a patch release on top of v5.36.1 with one fix.
Routing mode 2 ("Amnezia") keeps the local network reachable in the
AmneziaWG Windows client again (#301). The mode-2 list gets 2000::/3
instead of ::/0, plus a service IPv6 address in the client's Address, so
the device's global IPv6 still goes into the tunnel while the LAN stays
outside it. Mode 1 and dual-stack clients keep ::/0; a plain regen moves
profiles issued with ::/0 whose list matches the server's current list.
Release prep: version 5.36.2 across the six scripts, the README badges and
facts block, the pinned raw URLs and the four SHA pins, the changelog
entry, and the six signatures.
6378 lines
326 KiB
Bash
6378 lines
326 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.36.2
|
||
# Date: 2026-09-24
|
||
# Repository: https://github.com/bivlked/amneziawg-installer
|
||
# ==============================================================================
|
||
|
||
# --- Safe mode and Constants ---
|
||
set -o pipefail
|
||
# awg show colours its labels when WG_COLOR_MODE=always is in the environment,
|
||
# even into a pipe. The secrets filter then does not recognise "header
|
||
# protection key" and lets the key into the diagnostic report, and the step 7
|
||
# service check finds no "interface: awg0" and fails falsely (and warns falsely
|
||
# on "jc:"). Nothing here needs colour from the tools, so it is off for the whole
|
||
# script.
|
||
export WG_COLOR_MODE=never
|
||
|
||
SCRIPT_VERSION="5.36.2"
|
||
|
||
AWG_DIR="/root/awg"
|
||
CONFIG_FILE="$AWG_DIR/awgsetup_cfg.init"
|
||
STATE_FILE="$AWG_DIR/setup_state"
|
||
BOOT_CRITICAL_SNAPSHOT_FILE="$AWG_DIR/boot-critical.pkgs"
|
||
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="ae9e64ca3f4bbad9ff707e3d4d0b7346925eeb0b44ac25683022cea03d7bc53b"
|
||
MANAGE_SCRIPT_SHA256="26a1e72b60b3f7828c6059f3087537cac74bfb15f3cb1b2f8f08b15d5bbd570f"
|
||
|
||
# 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
|
||
# Protocol generation requested by the flag. Empty = not given; then the marker
|
||
# of an existing install applies, and on a new one PROTOCOL_DEFAULT below.
|
||
CLI_PROTOCOL=""
|
||
# 🔴 A separate "was supplied" flag, because an empty value and a missing
|
||
# flag are DIFFERENT cases. A check like [[ -n "$CLI_PROTOCOL" ]] cannot
|
||
# tell them apart, and then --protocol= quietly falls through to the
|
||
# default instead of a clear refusal. Found by external review 9 sep 2026;
|
||
# our own test missed it because it passed a space, not an empty string.
|
||
CLI_PROTOCOL_SET=0
|
||
# 🔴 The default of this PHASE, not of the final release. eng.md describes
|
||
# v6.0.0, where the default is 3.1; the switch is a separate, later phase
|
||
# (phase 5), after the third-line path is ready as a whole, with downgrade and
|
||
# the lifecycle of both generations. Until then the default is 2.0, and that
|
||
# is not a forgotten edit: the 3.1 generator and render already exist, but a
|
||
# profile with no way back must not be handed out.
|
||
PROTOCOL_DEFAULT="2.0"
|
||
|
||
# --- Auto-cleanup of temporary files ---
|
||
_install_temp_files=()
|
||
_install_cleaned=0
|
||
# A warning from a trap that can fire BEFORE the logger is defined: on a signal
|
||
# during argument parsing log_warn does not exist yet, and the error message
|
||
# itself would die with "command not found".
|
||
_probe_warn() {
|
||
if type log_warn >/dev/null 2>&1; then
|
||
log_warn "$1"
|
||
else
|
||
printf '%s\n' "$1" >&2
|
||
fi
|
||
}
|
||
_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 _probe_rec="" _probe_if="" _probe_safe="" _probe_f="" _probe_rc=0 _probe_head_rc=0 _probe_keep=0
|
||
for f in "${_install_temp_files[@]}"; do [[ -f "$f" ]] && rm -f "$f"; done
|
||
# Leftovers of the module probe. The trap inside the probe itself cleans up
|
||
# on an ordinary exit, but on a signal the subshell may not get there, so the
|
||
# cleanup is repeated here, where the exit is guaranteed.
|
||
# 🔴 What marks a probe as having happened is a FILE on disk, not a
|
||
# variable. The probe is called through $( ), so nothing it assigns reaches
|
||
# this far: a flag in a variable made this cleanup dead code on every real
|
||
# run.
|
||
# 🔴 This block runs on EVERY exit of the installer, --help included, and it
|
||
# runs as root - so it trusts not one byte of what it reads. The path is
|
||
# predictable (/tmp plus our pid, and the pid is visible in /proc) and the
|
||
# directory is world writable. Hence three checks in a row:
|
||
# - a regular file and not a symlink: a FIFO can be planted at that path.
|
||
# ⚠️ This used to read "would block the read forever". Since the read
|
||
# went under a bound that is UNTRUE, and leaving it there is dangerous:
|
||
# a reader concludes the check cannot be removed because of a hang,
|
||
# while in fact without it the read merely sits out the bound and comes
|
||
# back empty. The real cost is different - a genuine leftover then goes
|
||
# unremoved, because we never learn the name of the interface;
|
||
# - the name has to be one the probe of THIS run could have created, or a
|
||
# planted string like eth0 would take the machine off the network;
|
||
# - the delete is bounded, because a wedged netlink is exactly the state
|
||
# the probe refuses over.
|
||
# When no probe happened, not one external command runs at all: compgen is
|
||
# a builtin.
|
||
if compgen -G "${TMPDIR:-/tmp}/awg31probe.$$.*" >/dev/null 2>&1; then
|
||
_probe_rec="${TMPDIR:-/tmp}/awg31probe.$$.iface"
|
||
if [[ -f "$_probe_rec" && ! -L "$_probe_rec" ]]; then
|
||
# 🔴 The read is bounded by a SEPARATE process, not by `read -t`.
|
||
# The regular-file test and the open are two operations, and between
|
||
# them the file can be swapped for a FIFO. Measured: `read -t` never
|
||
# reaches the open and hangs for good, while a bound around the
|
||
# process does fire.
|
||
# The cost of this form is stated plainly: it brings in a
|
||
# dependency on head. With no head or no timeout the output is empty,
|
||
# the cleanup takes the "empty or unreadable" branch, the record is
|
||
# kept and the warning is heard, but a real leftover is not removed.
|
||
# That is still better than an installer that hangs for good.
|
||
# 🔴 A bound in TIME is not a bound in SIZE, and the comment above
|
||
# promises that this block trusts no byte it read. `head -n 1` on a
|
||
# file with no newline in it pulls the WHOLE file: measured, a 20 MB
|
||
# file fitted inside the five second bound and produced a twenty
|
||
# million character variable, which then goes into a warning, that
|
||
# is into the log under /root and onto the console. The directory is
|
||
# world writable, the pid is readable from /proc, and this cleanup
|
||
# runs on EVERY exit of the installer, `--help` included, where no
|
||
# probe ever ran. A legal record is `awgp<pid>x<n>` plus a newline,
|
||
# under twenty bytes; sixty four is generous.
|
||
_probe_if=$(timeout -k 1 5 head -c 64 "$_probe_rec" 2>/dev/null)
|
||
_probe_head_rc=$?
|
||
_probe_if="${_probe_if%%$'\n'*}"
|
||
_probe_if="${_probe_if%$'\r'}"
|
||
# The contents of a file in a world writable directory must not be
|
||
# able to draw on a root console: only printable characters go out.
|
||
_probe_safe="${_probe_if//[^[:print:]]/?}"
|
||
if [[ "$_probe_if" =~ ^awgp$$x[1-5]$ ]]; then
|
||
if ! timeout -k 1 5 ip link del "$_probe_if" >/dev/null 2>&1; then
|
||
timeout -k 1 5 ip link show "$_probe_if" >/dev/null 2>&1
|
||
_probe_rc=$?
|
||
# 🔴 Quiet ONLY on code 1, that is "no such device". On
|
||
# Ctrl-C the probe subshell can remove the interface first,
|
||
# and warning then would send a person looking for nothing.
|
||
# But a timeout and a missing binary are "do not know", not
|
||
# "taken away": the interface may still be there, and being
|
||
# quiet about that is not allowed.
|
||
if (( _probe_rc != 1 )); then
|
||
_probe_keep=1
|
||
_probe_warn "A leftover of the module probe could not be removed: $_probe_safe (checking the device returned $_probe_rc). Take it away by hand: ip link del $_probe_safe."
|
||
fi
|
||
fi
|
||
elif [[ -n "$_probe_if" && $_probe_head_rc -ne 0 ]]; then
|
||
# 🔴 The read was CUT SHORT, but something had already arrived:
|
||
# `timeout` killing `head` does not take back what it printed.
|
||
# So what we hold is a FRAGMENT, not the contents of the record,
|
||
# and it must not be spoken of as the contents - which is exactly
|
||
# what the previous form did, because it looked at emptiness
|
||
# before it looked at the exit status. The cost: a person was
|
||
# told confidently about a name that is not theirs, was not told
|
||
# the read had failed, and was left with a real leftover and no
|
||
# way to find it.
|
||
_probe_keep=1
|
||
_probe_warn "The module probe record was read only in part (code $_probe_head_rc), it begins: $_probe_safe. It cannot be judged from; look for a leftover with: ip link show type amneziawg | grep awgp$$. The record itself is kept in the temporary directory."
|
||
elif [[ -n "$_probe_if" ]]; then
|
||
# 🔴 Staying quiet here is not allowed. "There is a record and I
|
||
# did not understand it" is not the same as "there was no probe",
|
||
# and the difference only shows if it is said out loud. The
|
||
# interface is left alone: the name is not one this run could
|
||
# have made.
|
||
_probe_keep=1
|
||
_probe_warn "The module probe record carries a name the probe of this run could not have created: $_probe_safe. The interface is left alone and the record itself is kept in the temporary directory."
|
||
elif (( _probe_head_rc == 0 )); then
|
||
# Empty AND read successfully: the record was created but never
|
||
# filled. The ordinary way here is death between creating the
|
||
# file and writing the name into it, and `ip link add` comes
|
||
# AFTER that, so most likely no interface exists. ⚠️ "Most
|
||
# likely" on purpose: this block is built on not trusting what
|
||
# the directory holds, and three lines above a record naming
|
||
# eth0 is treated as planted. An empty record can be planted too,
|
||
# or truncated after the interface already existed. So the way to
|
||
# find a leftover is offered here as well - it costs one clause
|
||
# and does not lie if the assumption is wrong.
|
||
_probe_keep=1
|
||
_probe_warn "The module probe record is empty: the name was never written, so most likely no interface was created. If in doubt, look: ip link show type amneziawg | grep awgp$$. The record itself is kept in the temporary directory."
|
||
else
|
||
# 🔴 The read FAILED, which is a different thing entirely. The
|
||
# ways here are the bound firing (a planted FIFO, a hung mount)
|
||
# and head or timeout being absent. In all three an interface
|
||
# very likely DOES exist and we do not know its name. The old
|
||
# wording told such a person the record was "empty or
|
||
# unreadable" and gave neither a name nor a command, while both
|
||
# changelogs promise the opposite. There is no name to give, but
|
||
# there is a way to FIND it, and that is the only action
|
||
# available here at all.
|
||
_probe_keep=1
|
||
_probe_warn "The module probe record cannot be read (code $_probe_head_rc). A leftover may exist; find it with: ip link show type amneziawg | grep awgp$$. The record itself is kept in the temporary directory."
|
||
fi
|
||
elif [[ -e "$_probe_rec" || -L "$_probe_rec" ]]; then
|
||
_probe_keep=1
|
||
_probe_warn "The module probe record is not a regular file, so it is neither read nor removed. Please look at the temporary directory."
|
||
fi
|
||
# 🔴 No record at all means there is nothing to say. This branch used to
|
||
# fire on a missing record too: the operator was told it "cannot be read"
|
||
# while the sweep behind it removed the one file that was there, because
|
||
# the guard protected a path that did not exist. The same "says it out
|
||
# loud, then destroys the evidence" class, inside the branch written to
|
||
# close it.
|
||
# 🔴 The evidence we have just spoken about is not destroyed along with
|
||
# the rest: "go and look at the record" has to stay a usable instruction.
|
||
for _probe_f in "${TMPDIR:-/tmp}/awg31probe.$$."*; do
|
||
[[ "$_probe_keep" -eq 1 && "$_probe_f" == "$_probe_rec" ]] && continue
|
||
rm -f "$_probe_f" 2>/dev/null
|
||
done
|
||
fi
|
||
# 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 ;;
|
||
--protocol=*) CLI_PROTOCOL="${1#*=}"; CLI_PROTOCOL_SET=1 ;;
|
||
# The space form is accepted on purpose, even though every other
|
||
# valued flag here takes "=" only. This one gets typed right after a
|
||
# refusal, from what the refusal printed, and the spec and docs write
|
||
# the way out as "--protocol 2.0". Being picky about the equals sign
|
||
# at that moment costs more than uniformity buys. A missing value
|
||
# yields an empty string and fails the check below.
|
||
# The next argument is taken as the value ONLY if it looks like
|
||
# one. An unconditional shift would eat the neighbouring flag:
|
||
# "--protocol --yes" would take "--yes" as the value and lose the
|
||
# auto-confirmation.
|
||
# 🔴 The value is cleared BEFORE the next argument is inspected.
|
||
# Without the reset a repeated valueless flag ("--protocol=2.0
|
||
# --protocol --yes") would quietly keep the earlier value and the
|
||
# installation would carry on, although the second time no value
|
||
# was given at all.
|
||
--protocol) CLI_PROTOCOL_SET=1; CLI_PROTOCOL=""
|
||
if [[ -n "${2-}" && "${2-}" != -* ]]; then
|
||
CLI_PROTOCOL="$2"; shift
|
||
fi ;;
|
||
--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 'All traffic' mode (0.0.0.0/0) - chosen by default
|
||
--route-amnezia 'Amnezia' mode - public IPv4 into the tunnel, private networks outside
|
||
--route-custom=NETS 'Custom' mode: only the listed networks go into the tunnel
|
||
--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
|
||
--protocol=2.0|3.1 Protocol generation for a NEW install (default 2.0).
|
||
On an already configured server a flag naming a
|
||
DIFFERENT generation ends the install: the generation
|
||
of a running install changes only by reinstalling and
|
||
reissuing every client profile. A matching one is
|
||
accepted quietly.
|
||
This version does not emit 3.1 - it refuses and
|
||
names the reason
|
||
-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 --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 the step 1 upgrade may have staged a 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 13. The script also runs on Ubuntu 25.10/26.04 and Debian 12, but 25.10 and Debian 12 are past regular support, so do not pick either for a new server."
|
||
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 both with an argument (from awg31_environment_blocker) and without (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
|
||
}
|
||
|
||
# -- Environment gate for the AmneziaWG 3.1 profile ---------------------------
|
||
# The gate has TWO stages, and that is a requirement of the plan rather than an
|
||
# elaboration: the refusal has to be explained BEFORE the first package is
|
||
# installed, while what actually landed on the machine can only be established
|
||
# AFTER it.
|
||
# pre - step 0, before any change to the system: architecture and kernel;
|
||
# post - step 3, after the module and the tools are installed: tools capability.
|
||
#
|
||
# Both stages live HERE and not in awg_common.sh, even though the specification
|
||
# files the function under the library. The reason is the same one that makes
|
||
# the installer carry its own safe_load_config(): awg_common.sh is downloaded at
|
||
# step 5, while the gate has to answer at steps 0 and 3, when it is not on the
|
||
# server yet.
|
||
#
|
||
# The reason code exists so that a refusal tells the operator what exactly is
|
||
# wrong on their machine. A single "3.1 is unavailable" would send the owner of
|
||
# a Debian 12 box and the owner of an ARM box into the same dead end, even
|
||
# though their ways out are different.
|
||
|
||
# _awg31_host_arch : package architecture, falling back to uname -m.
|
||
_awg31_host_arch() {
|
||
local a=""
|
||
a=$(dpkg --print-architecture 2>/dev/null) || a=""
|
||
[[ -z "$a" ]] && a=$(uname -m 2>/dev/null)
|
||
printf '%s' "$a"
|
||
}
|
||
|
||
# awg31_tools_support : do the INSTALLED awg tools understand 3.1 parameters?
|
||
# Returns 0 when they do, 1 when they do not, are missing, or answer implausibly.
|
||
#
|
||
# CAPABILITY is probed, not the version, and that is not pedantry. The PPA
|
||
# package amneziawg-tools carries the version string 1.0.20210914 plus a build
|
||
# suffix: that is wireguard-tools heritage, and 3.1 support is not visible in it
|
||
# at all. Measured 7 sep 2026: the PPA builds the tools from commit ee0f0a9,
|
||
# that is from tag v3.1.20260812 ("feat: add awg 3.1 params"), while the package
|
||
# version stays 1.0.20210914-0~202608130144. A version gate would have blocked
|
||
# an environment that is in fact ready, and a build-date gate even more so.
|
||
# The version must not come back as a FALLBACK signal either ("accept it also
|
||
# when --version advertises 3.1"): a version claim is not confirmed by the
|
||
# usage, and that kind of mistake errs in the dangerous direction - it would
|
||
# hand a 3.1 profile to tools that cannot parse it.
|
||
#
|
||
# The probe: `awg set` with fewer than three arguments prints the FULL usage
|
||
# with the list of supported parameters and returns 1 without opening a device
|
||
# (upstream src/set.c: the argc < 3 branch sits BEFORE config_read_cmd, and the
|
||
# dispatcher in wg.c calls function(argc - 1, argv + 1), so `awg set` arrives
|
||
# with argc = 1). The presence of `header-protection-key` in that output is what
|
||
# says the tools know the third line.
|
||
#
|
||
# The status must be EXACTLY 1; "any non-zero" will not do. The codes 124
|
||
# (timeout fired), 125 (timeout itself failed) and 137 (KILL) are non-zero too,
|
||
# so under an "rc != 0" check a wrapper that printed the usage with the key and
|
||
# then hung would be counted as supporting - precisely the case the timeout is
|
||
# there for. Measured by an external review on 7 sep 2026.
|
||
# Erring towards refusal is the safe direction here: the price of a false "not
|
||
# supported" is an installation that stays on 2.0, and 2.0 works everywhere.
|
||
awg31_tools_support() {
|
||
local usage="" rc=0
|
||
command -v awg >/dev/null 2>&1 || return 1
|
||
# 🔴 -k IS REQUIRED, not decoration. Without it timeout sends TERM and
|
||
# WAITS: a wrapper that ignores the signal is not bounded at all, and
|
||
# step 3 hangs forever without printing a thing. The verdict would stay
|
||
# correct (124 is not 1, so tools_old), but an unattended install would
|
||
# stall. Found by review of this pull request.
|
||
# </dev/null for the same reason every read in this project uses
|
||
# /dev/tty: the probe must not eat the rest of a script fed through a
|
||
# pipe.
|
||
usage=$(timeout -k 1 5 awg set </dev/null 2>&1); rc=$?
|
||
(( rc == 1 )) || return 1
|
||
# 🔴 BOTH names, not one. The probe sends the header protection key and the
|
||
# padding range in a single command. A tools build that knows only the first
|
||
# would pass this gate and then run into the probe, whose text talks about
|
||
# permissions and netlink - while the cure is a tools upgrade, for which
|
||
# tools_old already carries the right advice. Measured 19 sep 2026: the
|
||
# usage of real 3.1 tools (ee0f0a9, tag v3.1.20260812) prints both names
|
||
# next to each other, so this does not turn away a healthy build.
|
||
[[ "$usage" == *header-protection-key* && "$usage" == *content-padding-addition* ]]
|
||
}
|
||
|
||
# awg31_module_support : does the LOADED module understand the third-line
|
||
# parameters. Returns 0 - it does; 1 - second line; 2 - could not check.
|
||
#
|
||
# 🔴 The line cannot be derived from the module version string: a measurement on
|
||
# 30 aug 2026 saw the same 3.1.20260812 string on two different builds. So the
|
||
# CAPABILITY is probed, the way the tools are: a header protection key and a
|
||
# padding range are set on a temporary interface and read back. A match means a
|
||
# third-line module.
|
||
#
|
||
# 🔴 The key and the padding go in ONE command together with S1..S4: with a key
|
||
# set the module requires S1..S4 of at least 12, a fresh interface has zeros,
|
||
# and a probe carrying only the key would be refused by a healthy third-line
|
||
# module.
|
||
#
|
||
# 🔴 A refusal from `awg set` is not a verdict on its own: it looks the same for
|
||
# a second-line module and for an environment problem (permissions, netlink, a
|
||
# namespace). So a refusal is followed by TWO control steps, each taking one
|
||
# thing away. Step 1 is the same padding sizes without the third-line
|
||
# parameters: if it fails, they are not the reason and the module cannot be
|
||
# judged. Step 2 is the same plus the header protection key alone: a REAL
|
||
# refusal (code 1) there, WITH THE INTERFACE STILL PRESENT, is the second
|
||
# line - the device check is not decoration, an interface that went away
|
||
# returns the same one. If the key was taken, the refusal
|
||
# was about the padding and the line cannot be named from it; any other code (a
|
||
# timeout, a missing binary) is not a verdict either.
|
||
#
|
||
# The key is one-shot and lives only inside the probe; it never goes into argv
|
||
# (the module gets a file path), the file is created under umask 077 and is
|
||
# removed on every ordinary exit, a signal included.
|
||
awg31_module_support() {
|
||
local verdict=""
|
||
verdict=$(_awg31_module_probe)
|
||
case "$verdict" in
|
||
ok) return 0 ;;
|
||
line2) return 1 ;;
|
||
*) return 2 ;;
|
||
esac
|
||
}
|
||
|
||
# _awg31_module_probe : prints ok | line2 | failed. The body is a subshell
|
||
# (round brackets instead of braces) on purpose: the cleanup trap then leaves
|
||
# the caller's traps alone, and the interface and the key file are removed on
|
||
# every exit, a signal included. The showconf output never reaches the log: it
|
||
# carries the header protection key in clear. There is no private key in it -
|
||
# the probe never sets one, and a fresh amneziawg interface does not carry one
|
||
# (measured on a stand, 19 sep 2026).
|
||
_awg31_module_probe() (
|
||
case $- in *x*) set +x ;; esac
|
||
umask 077
|
||
# 🔴 The probe can refuse for a good many different reasons, and until this
|
||
# was added a person learned none of them. There is deliberately NO number
|
||
# here: it has already drifted from the code twice, and the second time it
|
||
# was raised to the count of the PREVIOUS commit - the very commit that had
|
||
# added another reason. The code can be asked (`grep -c '_probe_say "'` over
|
||
# the function body), while a written number rots in silence. What matters
|
||
# is not the count but the invariant: no refusal is silent on any path the
|
||
# probe chooses ITSELF - the signal handler below is silent on purpose, and
|
||
# that is the only exception.
|
||
# Why none of them were learned before: the verdict goes to stdout and
|
||
# everything else was thrown away. stdout is taken, but stderr is free and
|
||
# is not captured. The key never reaches it: the module is handed a PATH to
|
||
# a file, and the showconf output does not go into diagnostics at all.
|
||
# Through log_debug when it exists, to respect --verbose and the log format.
|
||
_probe_say() {
|
||
if type log_debug >/dev/null 2>&1; then
|
||
log_debug "module probe: $1"
|
||
else
|
||
printf 'module probe: %s\n' "$1" >&2
|
||
fi
|
||
}
|
||
local ifn="" kf="" rec="" key="" out="" line="" ctl="" rc=0 arc=0 i=0 made=0 cleaned=0
|
||
local klines=() kraw="" kbytes="" krc=0
|
||
local seen=0 hpk=0 cpa=0 hpk_name=0 cpa_name=0
|
||
command -v ip >/dev/null 2>&1 || { _probe_say "the ip command was not found"; printf 'failed'; exit 0; }
|
||
command -v awg >/dev/null 2>&1 || { _probe_say "the awg command was not found"; printf 'failed'; exit 0; }
|
||
|
||
kf=$(mktemp "${TMPDIR:-/tmp}/awg31probe.$$.XXXXXX" 2>/dev/null) || { _probe_say "a temporary file could not be created in ${TMPDIR:-/tmp}"; printf 'failed'; exit 0; }
|
||
# 🔴 The name of the interface goes into a FILE, not into a variable. The
|
||
# installer cleanup lives in the parent shell, the probe is called through
|
||
# $( ), and anything assigned inside is lost with the subshell. A file
|
||
# outlives both the subshell and a SIGKILL of the probe itself, which is also
|
||
# what makes it the marker for "a probe happened here". ⚠️ If the whole
|
||
# installer gets a SIGKILL, the record and the interface stay until a reboot:
|
||
# the next run only looks for records of its own $$.
|
||
rec="${TMPDIR:-/tmp}/awg31probe.$$.iface"
|
||
# The cleanup has to survive both an ordinary exit and a signal: the machine
|
||
# must not keep an interface of ours after the probe.
|
||
_probe_cleanup() {
|
||
# Idempotent, following _install_cleanup: on a signal the cleanup runs
|
||
# from the handler and then once more on EXIT, and the second call has
|
||
# to be a no-op. Otherwise Ctrl-C costs another `ip link del` against an
|
||
# interface that is already gone - up to five more seconds of silence in
|
||
# exactly the wedged-netlink state the probe exists to refuse over.
|
||
[[ "$cleaned" -eq 1 ]] && return 0
|
||
cleaned=1
|
||
rm -f "$kf" 2>/dev/null
|
||
if [[ "$made" -eq 1 ]]; then
|
||
# The record is dropped ONLY after a delete that worked. If it
|
||
# did not, the record stays, the installer cleanup picks the
|
||
# interface up and says so out loud. Saying it from here is not
|
||
# possible: stdout carries the verdict.
|
||
if timeout -k 1 5 ip link del "$ifn" >/dev/null 2>&1; then
|
||
rm -f "$rec" 2>/dev/null
|
||
fi
|
||
fi
|
||
}
|
||
# 🔴 A signal needs an EXPLICIT exit, not just the cleanup. Without it the
|
||
# body carries on after the interrupted command - sending awg set and
|
||
# showconf to an interface the trap has just removed - and the cleanup runs
|
||
# a second time on EXIT. The same class that is already fixed for the
|
||
# installer itself above.
|
||
trap '_probe_cleanup' EXIT
|
||
trap '_probe_cleanup; printf "failed"; exit 0' INT TERM HUP
|
||
|
||
timeout -k 1 5 awg genkey </dev/null > "$kf" 2>/dev/null || { _probe_say "awg genkey did not produce a key"; printf 'failed'; exit 0; }
|
||
# 🔴 But BOTH lines have to be read. `read` takes only the first, while the
|
||
# module is handed the WHOLE file: a file with a correct first line and junk
|
||
# behind it would pass the shape check, the tool would refuse it, and the
|
||
# refusal at control step 2 would call a healthy module second line - exactly
|
||
# the defect this probe exists to prevent. The old `tr` joined the whole file
|
||
# and caught such a tail; the library (_awg_hpk_file_valid) also checks the
|
||
# size of the file.
|
||
# 🔴 The FILE itself is validated, not a cleaned copy of it. This is the
|
||
# FOURTH visit to this one place, and the root was the same all four times:
|
||
# I compared what I had READ and normalised, while the tool is handed the
|
||
# file AS IT IS.
|
||
# 🔴 This one was worse than the three before it. The checks CITED the rule
|
||
# that the library demands exactly 45 bytes, and then never counted a byte:
|
||
# the expected string was rebuilt out of data bash had already normalised
|
||
# and compared with itself. Measured 20 sep 2026: a 46-byte file shaped
|
||
# `<44 base64><NUL><LF>` passed ALL THREE checks, because `mapfile` and
|
||
# `read -N` both drop a NUL. The tool then refused the file, the refusal at
|
||
# control step 2 called a healthy module second line, and its owner went off
|
||
# to rebuild a module that was fine - the exact defect this probe exists to
|
||
# prevent.
|
||
# 🔴 So the size is measured the way the library measures it
|
||
# (_awg_hpk_file_valid): with `wc -c`. Builtins CANNOT do this - bash cannot
|
||
# hold a NUL in a variable at all, so every reader loses it in silence. The
|
||
# old promise that the key path runs no external command is withdrawn
|
||
# DELIBERATELY: the key VALUE still never leaves the file - it is fed on
|
||
# stdin, only a number comes back, and without counting the bytes that
|
||
# promise was costing a verdict.
|
||
# ⚠️ Exactly about the PATH, because the previous wording promised more than
|
||
# it kept: the path DOES reach argv, both here in `timeout`/`sh` and below in
|
||
# `awg set`, which is handed it on purpose. It is not a secret: it is a random
|
||
# name from `mktemp` whose only job is to make a race hard, not to hide.
|
||
# What each check catches (measured 20 sep 2026, one file per corrupt
|
||
# genkey stub; again NO number here, for the same reason):
|
||
# not 45 bytes -> a NUL, and any byte the readers cannot see;
|
||
# not one line -> two lines and junk; it gives the better message;
|
||
# file is not line + \n -> no trailing newline;
|
||
# shape is wrong -> a carriage return, junk inside the line.
|
||
# 🔴 The checks overlap ON PURPOSE, and "one each" would be untrue: two
|
||
# lines are caught by the first and by the second alike. Removing one of two
|
||
# overlapping guards leaves the verdict where it was - measured by mutation,
|
||
# not asserted.
|
||
# 🔴 The TRIPLE first, and it answers WITHOUT OPENING the file. That is not
|
||
# decoration: a `timeout` around `wc` does NOT bound the open, because the
|
||
# redirection `< "$kf"` is performed by the CALLING shell before `timeout`
|
||
# ever runs. Measured 21 sep 2026: `timeout -k 1 5 wc -c < FIFO` NEVER
|
||
# returns, while the same command with the redirection INSIDE the bounded
|
||
# process honestly gives 124 after five seconds. So a planted FIFO hung the
|
||
# probe for good, and silently - the exact state it was written to avoid.
|
||
# Both things are done here: the triple answers first and cheaply, and the
|
||
# redirection moved inside the bound, which closes the race between the test
|
||
# and the open. The same trap is described above for `read -t`, in `_install_cleanup`.
|
||
[[ -f "$kf" && ! -L "$kf" && -r "$kf" ]] || { _probe_say "the key file is not a regular readable file"; printf 'failed'; exit 0; }
|
||
kbytes=$(timeout -k 1 5 sh -c 'wc -c < "$0"' "$kf" 2>/dev/null)
|
||
krc=$?
|
||
# 🔴 Only BLANKS are stripped - spaces and tabs, `[[:blank:]]` - and the
|
||
# exit status is read. Not `[[:space:]]`: a newline is whitespace too, so
|
||
# `4\n5` would collapse into "45", the very joining this was moved away from. The first
|
||
# form threw away every non-digit (`${kbytes//[^0-9]/}`) and ignored the
|
||
# status: a `wc` that printed `4x5` and failed then collapsed into "45" and
|
||
# opened a path to a WRONG verdict - the guard put there to catch an extra
|
||
# byte let that byte through itself. Whitespace still has to go: not every
|
||
# `wc` prints the number without padding.
|
||
kbytes="${kbytes//[[:blank:]]/}"
|
||
# The comparison is EXACT precisely because the right-hand side is a literal:
|
||
# put a variable or a pattern there and the exactness disappears in silence,
|
||
# with no test noticing.
|
||
[[ $krc -eq 0 && "$kbytes" == 45 ]] || { _probe_say "the key file is not exactly 45 bytes (code $krc, bytes: ${kbytes:-unknown})"; printf 'failed'; exit 0; }
|
||
mapfile -t klines 2>/dev/null < "$kf" || :
|
||
(( ${#klines[@]} == 1 )) || { _probe_say "the key file is not exactly one line (lines: ${#klines[@]})"; printf 'failed'; exit 0; }
|
||
key="${klines[0]}"
|
||
IFS= read -r -N 46 kraw < "$kf" 2>/dev/null || :
|
||
[[ "$kraw" == "$key"$'\n' ]] || { _probe_say "the key file is not exactly one line plus a newline"; printf 'failed'; exit 0; }
|
||
# The key shape is judged by the same measure the library uses
|
||
# (_awg_hpk_file_valid): 44 base64 characters. "Not empty" is not enough - a
|
||
# genkey that exits zero and prints rubbish would pass it, control step 2
|
||
# would then refuse over the KEY FILE, and we would call a healthy module
|
||
# second line and send its owner to rebuild it for nothing.
|
||
[[ "$key" =~ ^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$ ]] || { _probe_say "the key is not of the shape the library requires"; printf 'failed'; exit 0; }
|
||
|
||
# A name shorter than 15 characters and never awg0: the probe has no
|
||
# business touching the working interface. The protection here is the SHAPE
|
||
# of the name (`awgp<pid>x<n>` cannot equal awg0), and a test pins it; there
|
||
# is deliberately no separate awg0 check, which would be an unreachable line
|
||
# reading like a live guard. A taken name means the next one, never a write
|
||
# into someone else's device.
|
||
for i in 1 2 3 4 5; do
|
||
ifn="awgp$$x$i"
|
||
# A hanging ip is "do not know", not "the name is free": going on and
|
||
# creating an interface under an unread name would be writing blind.
|
||
timeout -k 1 5 ip link show "$ifn" >/dev/null 2>&1
|
||
rc=$?
|
||
(( rc == 0 )) && continue
|
||
if (( rc == 124 || rc == 125 || rc == 137 )); then _probe_say "checking the name $ifn did not answer within the bound (code $rc)"; printf 'failed'; exit 0; fi
|
||
# 🔴 The record is laid down BEFORE anything is created, not after. A
|
||
# signal fits between a successful `ip link add` and the write, and then
|
||
# the interface is on the machine with nothing to find it by - exactly
|
||
# the silent loss this record exists to prevent. If the write fails,
|
||
# NOTHING is created: an honest refusal beats a trace nobody knows of.
|
||
# 🔴 set -C (O_EXCL) is required: the path is predictable, the installer
|
||
# runs as root, and a plain `>` would follow a symlink planted there and
|
||
# overwrite somebody else's file.
|
||
rm -f "$rec" 2>/dev/null
|
||
( set -C; printf '%s\n' "$ifn" > "$rec" ) 2>/dev/null || { _probe_say "the interface name could not be written to $rec"; printf 'failed'; exit 0; }
|
||
timeout -k 1 5 ip link add "$ifn" type amneziawg >/dev/null 2>&1
|
||
rc=$?
|
||
if (( rc == 0 )); then made=1; break; fi
|
||
# 🔴 The PRESENCE OF THE DEVICE decides, not the exit code. The command
|
||
# may have been cut short after the kernel had already created the
|
||
# interface: a timeout, a signal from outside, a delayed netlink
|
||
# acknowledgement. The previous form caught only the three timeout codes
|
||
# and, on any other, dropped the record and took the next name - measured
|
||
# at five interfaces on the machine and not one record naming them. The
|
||
# name is built from OUR pid, so it cannot be anyone else's: a device
|
||
# found here is ours, and the record of it has to stay.
|
||
timeout -k 1 5 ip link show "$ifn" >/dev/null 2>&1
|
||
arc=$?
|
||
if (( arc != 1 )); then
|
||
_probe_say "creating the interface $ifn was not confirmed (code $rc), but the device is there or could not be checked (code $arc); the record is left for the cleanup"
|
||
printf 'failed'; exit 0
|
||
fi
|
||
# No device: the creation really did fail, and the same reason will
|
||
# repeat on the other names, so there is nothing to walk to.
|
||
rm -f "$rec" 2>/dev/null
|
||
_probe_say "the interface $ifn was not created (code $rc), and the reason will repeat on the other names"
|
||
printf 'failed'; exit 0
|
||
done
|
||
[[ "$made" -eq 1 ]] || { _probe_say "all five temporary names are taken"; printf 'failed'; exit 0; }
|
||
|
||
timeout -k 1 5 awg set "$ifn" s1 15 s2 15 s3 12 s4 12 \
|
||
header-protection-key "$kf" content-padding-addition 32-128 </dev/null >/dev/null 2>&1
|
||
rc=$?
|
||
if (( rc != 0 )); then
|
||
# A timeout is not a verdict about the module: no answer came back. 124
|
||
# is timeout firing, 125 is timeout itself failing, 137 is the KILL that
|
||
# -k sends. The control steps cannot help here: they say WHAT was
|
||
# rejected, and after a hang nothing was rejected at all, so their answer
|
||
# would describe another command rather than the one that never replied.
|
||
if (( rc == 124 || rc == 125 || rc == 137 )); then _probe_say "the main awg set did not answer within the bound (code $rc)"; printf 'failed'; exit 0; fi
|
||
# The control takes TWO steps, because the refused command carried two
|
||
# different third-line parameters at once.
|
||
# Step 1: the same padding sizes without the third-line parameters. A
|
||
# refusal here means they are not the reason, and the module cannot be
|
||
# judged.
|
||
timeout -k 1 5 awg set "$ifn" s1 15 s2 15 s3 12 s4 12 </dev/null >/dev/null 2>&1
|
||
ctl=$?
|
||
if (( ctl != 0 )); then _probe_say "control step 1 refused (code $ctl): the third-line parameters are not the reason"; printf 'failed'; exit 0; fi
|
||
# Step 2: the same plus the header protection key ALONE. A refusal here
|
||
# is a module that does not understand the third line. If the key was
|
||
# taken and the refusal was about the padding, the line cannot be named
|
||
# from that refusal: "update the module" would be wrong advice, so saying
|
||
# the check could not be made is the honest answer.
|
||
# 🔴 Step 2 judges by exit code 1, and `awg set` returns one on ANY
|
||
# error, a key file it cannot read included. If the file went away
|
||
# between the shape check and this step, the refusal would be about the
|
||
# FILE, and we would call a healthy module second line.
|
||
# 🔴 The SAME thing is checked here as at validation: a regular file,
|
||
# not a symlink, readable, and exactly 45 bytes. ⚠️ Only the SIZE half
|
||
# is held behaviourally, by the case where the file grows between the
|
||
# shape check and this step.
|
||
# 🔴 But the -f/! -L/-r triple is NOT redundant, and the previous wording
|
||
# of this comment lied by saying it had no case of its own. Its case is a
|
||
# file whose size CANNOT be measured: a FIFO can be planted at that path,
|
||
# and `wc -c` on one sits there rather than answering. ⚠️ The previous
|
||
# wording credited the `timeout` with stopping that, and it was WRONG: a
|
||
# bound does not cover the open when the redirection is performed by the
|
||
# calling shell (the measurement and the reasoning are at the first size
|
||
# check). What stops a FIFO here is the TRIPLE: `-f` on one is false and
|
||
# answers without opening. It also sees a symlink to a perfectly good
|
||
# file, which the size cannot see at all. ⚠️ The `! -L` half DOES have a test - the case that
|
||
# swaps the key file for a symlink. What is left without one is `-f` and
|
||
# `-r`: neither a FIFO at that path nor an unreadable file can be made on
|
||
# the development host. The old `-s` form asked
|
||
# only "not empty" and let through precisely the file it was put there
|
||
# to stop - one replaced between the validation and this step.
|
||
[[ -f "$kf" && ! -L "$kf" && -r "$kf" ]] || { _probe_say "the key file is not a regular readable file before control step 2"; printf 'failed'; exit 0; }
|
||
kbytes=$(timeout -k 1 5 sh -c 'wc -c < "$0"' "$kf" 2>/dev/null)
|
||
krc=$?
|
||
kbytes="${kbytes//[[:blank:]]/}"
|
||
[[ $krc -eq 0 && "$kbytes" == 45 ]] || { _probe_say "the key file is not exactly 45 bytes before control step 2 (code $krc, bytes: ${kbytes:-unknown})"; printf 'failed'; exit 0; }
|
||
timeout -k 1 5 awg set "$ifn" s1 15 s2 15 s3 12 s4 12 \
|
||
header-protection-key "$kf" </dev/null >/dev/null 2>&1
|
||
ctl=$?
|
||
# Only a REAL refusal from the tool (code 1) is a verdict about the
|
||
# module. A timeout, a missing binary or any other code cannot be: a hung
|
||
# command would otherwise declare a healthy module second line and send a
|
||
# person to rebuild it for nothing - the same thing the timeout check on
|
||
# the main command guards against.
|
||
if (( ctl == 1 )); then
|
||
# 🔴 A one from `awg set` means "any error", not "the module said
|
||
# no". An interface that went away, or became unreachable, returns
|
||
# the same one. What is checked is the device itself, not the text
|
||
# of the error: the wording of a tool changes between versions, the
|
||
# presence of a device does not.
|
||
timeout -k 1 5 ip link show "$ifn" >/dev/null 2>&1 || { _probe_say "the refusal on step 2 came from a vanished interface, not from the module"; printf 'failed'; exit 0; }
|
||
_probe_say "verdict: second line - the tool refused the key ALONE on step 2 and the interface is still there"
|
||
printf 'line2'
|
||
else
|
||
_probe_say "step 2 returned code $ctl, and only 1 counts as a verdict about the module"
|
||
printf 'failed'
|
||
fi
|
||
exit 0
|
||
fi
|
||
|
||
out=$(timeout -k 1 5 awg showconf "$ifn" </dev/null 2>/dev/null) || { _probe_say "awg showconf refused"; printf 'failed'; exit 0; }
|
||
# 🔴 The parsing uses no external command, and that is not a matter of
|
||
# style but of three reasons.
|
||
# 1. The key must not reach the argv of ANY command: argv is visible to
|
||
# anyone through /proc/<pid>/cmdline. The previous version built a
|
||
# pattern out of the key and handed it to grep, which made the promise
|
||
# "it never appears in a command line" untrue, in the comment and in the
|
||
# changelog alike.
|
||
# 2. A failure of grep or sed themselves (a missing binary, a pattern error)
|
||
# landed in the second-line branch, so a person was told to rebuild a
|
||
# healthy module because a utility was missing.
|
||
# 3. The escaping disappears: a random base64 key carries '+' or '/' about
|
||
# three times in four, and that branch was never once tested with one.
|
||
# A whole-line comparison has nothing to escape.
|
||
# Measured on a stand, 19 sep 2026: the key is read back VERBATIM (checked
|
||
# with two different random keys) and the range exactly as it was set.
|
||
while IFS= read -r line; do
|
||
line="${line#"${line%%[![:space:]]*}"}"
|
||
line="${line%"${line##*[![:space:]]}"}"
|
||
case "$line" in
|
||
"[Interface]") seen=1 ;;
|
||
"HeaderProtectionKey = $key") hpk=1; hpk_name=1 ;;
|
||
"ContentPaddingAddition = 32-128") cpa=1; cpa_name=1 ;;
|
||
"HeaderProtectionKey"*) hpk_name=1 ;;
|
||
"ContentPaddingAddition"*) cpa_name=1 ;;
|
||
esac
|
||
done <<< "$out"
|
||
# The answer has to be a config. `awg showconf` on an interface that exists
|
||
# always prints at least the [Interface] line (same measurement), so a zero
|
||
# exit with empty or unrecognisable output speaks about the environment and
|
||
# not about the generation: a substituted binary, a truncated pipe, a device
|
||
# that vanished.
|
||
(( seen == 1 )) || { _probe_say "the awg showconf answer does not look like a config"; printf 'failed'; exit 0; }
|
||
# 🔴 A verdict says what it rests on as well. Only the refusals used to
|
||
# speak, and the three verdicts were silent - so a wrong "second line",
|
||
# which costs its owner a module rebuild and a reboot, arrived WITH NO
|
||
# EVIDENCE, `--verbose` included. The most expensive answer has to be the
|
||
# most traceable one.
|
||
if (( hpk == 1 && cpa == 1 )); then
|
||
_probe_say "verdict: third line - both parameters came back with the values we set"
|
||
printf 'ok'
|
||
elif (( hpk_name == 0 && cpa_name == 0 )); then
|
||
# 🔴 A silent acceptance is when the answer carries NOT EVEN THE NAMES of
|
||
# the two parameters: an older module passes unknown netlink attributes
|
||
# over without a word. What is compared here is the presence of the NAME,
|
||
# not a matching value. Otherwise an answer where the module took
|
||
# everything and gave everything back, only in another shape - an extra
|
||
# space, a masked value, a differently written range - would land in
|
||
# "second line" and its owner would be told to rebuild a healthy module.
|
||
# The output shape rests on a single bench measurement, and a drift in it
|
||
# has to lead to "could not check", never to a confident verdict.
|
||
_probe_say "verdict: second line - the tool took everything in silence and the answer carries NEITHER NAME"
|
||
printf 'line2'
|
||
else
|
||
# 🔴 Everything else lands here: one of the two came back, or the names
|
||
# are there and the values are not ours. Neither is the second line.
|
||
# Upstream shipped the third-line parameters at different times, so an
|
||
# intermediate build knows the key but not the padding; and a value that
|
||
# does not match means we simply did not understand the answer. The
|
||
# second-line text would be false in both of its clauses, and its advice
|
||
# to rebuild the module would be a guess.
|
||
_probe_say "only part of the values came back (key: $hpk, padding: $cpa), the generation cannot be named"
|
||
printf 'failed'
|
||
fi
|
||
exit 0
|
||
)
|
||
|
||
# awg31_environment_blocker : empty when the 3.1 profile is available on this
|
||
# environment, otherwise the reason CODE.
|
||
# Codes: arch_unknown | arch_unsupported | arm | kernel | tools_old |
|
||
# module_line2 | module_probe_failed | not_implemented_yet | internal_error.
|
||
# Arg $1: stage, REQUIRED: 'pre' or 'post'.
|
||
# Arg $2: architecture (for tests; defaults to _awg31_host_arch).
|
||
# Arg $3: kernel release (for tests; defaults to uname -r).
|
||
#
|
||
# THE post STAGE CONTAINS pre IN FULL, and that is not duplication. An empty
|
||
# answer means "install 3.1", so every stage has to be a self-contained final
|
||
# verdict. Had post checked the tools alone, calling post on ARM would return
|
||
# empty - that is, allow what pre forbade - and the whole protection would rest
|
||
# on the caller not forgetting to carry the pre answer across a reboot. There
|
||
# are two of those between steps 0 and 3.
|
||
#
|
||
# THE ORDER OF THE CHECKS IS PART OF THE CONTRACT: architecture and kernel come
|
||
# BEFORE the tools probe. Otherwise an ARM box with old tools would be told
|
||
# tools_old - a temporary reason instead of a permanent one - the operator would
|
||
# go and upgrade the tools, which cannot possibly help, and we would have run an
|
||
# external binary on a platform we deliberately do not ship on.
|
||
awg31_environment_blocker() {
|
||
local stage="${1-}" arch="${2:-}" kver="${3:-}"
|
||
|
||
case "$stage" in
|
||
pre|post) : ;;
|
||
*)
|
||
# The stage has NO default on purpose. `${1:-pre}` looks convenient
|
||
# but makes the WEAKEST stage the default: an empty string from an
|
||
# unset variable would silently become pre, and the tools probe would
|
||
# be skipped without a single message. The branch below catches a
|
||
# misspelled stage name, but with a default it could not catch an
|
||
# empty string at all. There are two reboots between steps 0 and 3
|
||
# and the caller reads its state from disk, so losing a variable is
|
||
# easy.
|
||
#
|
||
# And not die here: the typical call is
|
||
# blocker=$(awg31_environment_blocker ...), and inside a substitution
|
||
# die would kill the SUBSHELL, the installer would carry on, and
|
||
# blocker would stay EMPTY - that is, "3.1 is available". So the
|
||
# output itself carries the safety: a non-empty code blocks in any
|
||
# case, a non-zero return is visible to callers that check the
|
||
# status, and a human reads the reason on stderr.
|
||
printf 'internal_error'
|
||
echo "awg31_environment_blocker: stage must be pre or post, got '${stage}'" >&2
|
||
return 2
|
||
;;
|
||
esac
|
||
|
||
[[ -z "$arch" ]] && arch=$(_awg31_host_arch)
|
||
[[ -z "$kver" ]] && kver=$(uname -r)
|
||
arch="${arch//[[:space:]]/}"
|
||
|
||
# Not knowing the architecture is NOT the same as knowing it is suitable. An
|
||
# empty answer here would read as permission, so the unknown blocks with an
|
||
# explicit code.
|
||
if [[ -z "$arch" ]]; then
|
||
printf 'arch_unknown'
|
||
return 0
|
||
fi
|
||
|
||
# An ALLOW list, not a deny list, and this is the only place in the installer
|
||
# shaped that way. The reason is that an empty answer means "ship it": "not
|
||
# ARM" is not the same as "amd64" here. The PPA builds the dkms package for
|
||
# riscv64, ppc64el and s390x among others (checked against the Launchpad
|
||
# index on 7 sep 2026), and with a deny list such a machine would be handed a
|
||
# 3.1 profile on a platform nobody measured.
|
||
#
|
||
# The arm code covers ANY ARM, not only a matched prebuilt. Telling "the
|
||
# prebuilt matched" from "we went the DKMS way" is impossible here: in the
|
||
# second case the module would be third line, the gate would formally pass,
|
||
# and we would ship the profile on a platform we decided not to ship on until
|
||
# a separate measurement. The pattern covers both dpkg names (arm64, armhf,
|
||
# armel) and uname -m ones (armv7l, aarch64, aarch64_be).
|
||
case "$arch" in
|
||
amd64|x86_64)
|
||
:
|
||
;;
|
||
arm*|aarch64*)
|
||
printf 'arm'
|
||
return 0
|
||
;;
|
||
*)
|
||
printf 'arch_unsupported'
|
||
return 0
|
||
;;
|
||
esac
|
||
|
||
if ! _kernel_supports_awg3 "$kver"; then
|
||
printf 'kernel'
|
||
return 0
|
||
fi
|
||
|
||
if [[ "$stage" == "post" ]]; then
|
||
awg31_tools_support || { printf 'tools_old'; return 0; }
|
||
# The line of the LOADED module is checked AFTER the tools: the probe
|
||
# runs through those same tools, and with an old awg its refusal would
|
||
# be about them rather than about the module. A person would be told
|
||
# "second-line module" where apt is all it takes.
|
||
awg31_module_support
|
||
case $? in
|
||
0) : ;;
|
||
1) printf 'module_line2'; return 0 ;;
|
||
*) printf 'module_probe_failed'; return 0 ;;
|
||
esac
|
||
fi
|
||
|
||
# UNTIL PHASE 5: the 3.1 profile generator, the header protection key and
|
||
# writing the profile into the configs already exist, but it is too early to
|
||
# hand the third line to people - there is no downgrade and no lifecycle of
|
||
# both generations (phase 4). So the environment may be as suitable as it
|
||
# likes and the installer still refuses. This check is LAST on purpose: that
|
||
# way an operator on an unsuitable platform gets the durable reason, the one
|
||
# that stays true after the path opens, instead of a temporary one. This line
|
||
# goes away in phase 5, and removing it must turn red the test that watches
|
||
# for it.
|
||
printf 'not_implemented_yet'
|
||
return 0
|
||
|
||
# 🔴 The explicit "environment fits" terminal: empty output, status 0.
|
||
# Unreachable today, and here for phase 5: delete the TWO lines above
|
||
# without leaving this one and the function's last command becomes the
|
||
# stage check, which is false on pre - the function would return 1 with
|
||
# empty output. The refusal would be safe, but the third line would be
|
||
# dead on arrival and the symptom would look like a crashed gate.
|
||
return 0
|
||
}
|
||
|
||
# _awg31_blocker_message : the human-readable refusal for a reason CODE.
|
||
# Arg $1: the code from awg31_environment_blocker.
|
||
#
|
||
# 🔴 Every code has its OWN way out, and that is the whole point of codes. One
|
||
# shared "the 3.1 profile is unavailable" would send a Debian 12 owner, an ARM
|
||
# owner and someone with old tools into the same dead end, while their exits
|
||
# differ: the first needs another system, the second cannot be helped until a
|
||
# separate measurement, the third only needs apt. A test asserts the texts are
|
||
# DIFFERENT and that each names its own exit - otherwise in a year they collapse
|
||
# back into one.
|
||
#
|
||
# ⚠️ The reasons split into permanent (kernel, arm, arch_*) and temporary
|
||
# (tools_old, module_line2). The temporary ones name the package whose upgrade
|
||
# fixes them, so that nobody abandons a machine that is almost ready.
|
||
_awg31_blocker_message() {
|
||
local code="${1-}"
|
||
case "$code" in
|
||
kernel)
|
||
printf '%s' "The AmneziaWG 3.1 profile will not run on this server: kernel $(uname -r) is older than 6.7. On such kernels the installer deliberately builds the proven second-line module, and the third line will not work here. Way out: install with --protocol=2.0, which is a working and supported path. If you need the third line on this very machine, it takes a system with kernel 6.7 or newer; note that this installer version emits no 3.1 profile on any machine."
|
||
;;
|
||
arm)
|
||
# 🔴 The text makes NO claim about which module gets installed here,
|
||
# and that is a correction of fact, not of style. The earlier wording
|
||
# said "here the installer pins the second-line module" - untrue for an
|
||
# ARM64 box on a recent kernel with no matching prebuilt:
|
||
# _try_install_prebuilt_arm picks a target from a closed list, returns 1
|
||
# on a miss, the flow falls back to DKMS, and on kernel 6.7+ an UNPINNED
|
||
# module arrives from the PPA. A refusal must explain the REASON FOR THE
|
||
# REFUSAL, not describe someone else's machine from memory. The precise
|
||
# wording about prebuilts lives in the README.
|
||
printf '%s' "An AmneziaWG 3.1 profile is not issued on ARM: this architecture has not been measured for the third line, and the decision is deliberate until a separate measurement. Upgrading packages changes nothing. Separately, so you do not look for the way out in the wrong place: no architecture gets a 3.1 profile from this installer version. Way out: --protocol=2.0."
|
||
;;
|
||
arch_unsupported)
|
||
# 🔴 "REQUIRES x86_64", not "ships for x86_64 only": today the 3.1
|
||
# profile ships NOWHERE, and on a suitable machine the gate ends at
|
||
# not_implemented_yet. The check order is such that the owner of an ARM
|
||
# or exotic box sees THIS text and never sees not_implemented_yet, so
|
||
# they would leave believing x86_64 already gets the third line.
|
||
printf '%s' "The AmneziaWG 3.1 profile requires x86_64, and this machine is '$(_awg31_host_arch)'. We have not measured it and will not emit the third line there. Separately, so you do not change machines for nothing: no architecture gets a 3.1 profile from this installer version. Way out: --protocol=2.0."
|
||
;;
|
||
arch_unknown)
|
||
printf '%s' "The machine architecture could not be determined, and not knowing it is not the same as knowing it fits. Way out: --protocol=2.0. If you believe this is wrong, send the output of 'dpkg --print-architecture' and 'uname -m'."
|
||
;;
|
||
tools_old)
|
||
printf '%s' "The installed awg tools do not understand third-line parameters. This one is fixed by upgrading the tools: apt-get update && apt-get install --only-upgrade amneziawg-tools, then run the installer again. Or install with --protocol=2.0."
|
||
;;
|
||
module_line2)
|
||
printf '%s' "The loaded amneziawg kernel module does not understand the third-line parameters: it either refuses the header protection key or takes it without a word and does not give it back. A 3.1 profile would be written on such a module and the connection would never come up. This one is fixed by updating the module: apt-get update && apt-get install --only-upgrade amneziawg-dkms, then a reboot (the module is rebuilt for your kernel) and another run of the installer. Or install with --protocol=2.0."
|
||
;;
|
||
module_probe_failed)
|
||
printf '%s' "Whether the loaded module understands the third-line parameters could not be checked: the probe either could not create a temporary interface and get an answer, or got an answer that does not name the generation. The reasons differ - permissions, the state of netlink, the network namespace of a container. We do not know whether the module fits, and guessing is not an option here. Way out: install with --protocol=2.0. If you think this is wrong, send the output of six commands: 'ip link add awgprobe type amneziawg', 'awg set awgprobe s1 15 s2 15 s3 12 s4 12 header-protection-key <a file with a 32-byte key in base64> content-padding-addition 32-128' (the main command of the probe; the probe can stop here too), 'awg set awgprobe s1 15 s2 15 s3 12 s4 12', 'awg set awgprobe s1 15 s2 15 s3 12 s4 12 header-protection-key <the same file>', 'awg showconf awgprobe' (the read back; the probe can stop here too) and 'ip link del awgprobe' (the last one takes the temporary interface away again). The key file is made like this: 'umask 077; awg genkey > /tmp/probekey', and take it away afterwards: 'rm -f /tmp/probekey'. The probe also stops before any of these commands - if it cannot create a temporary file, or if 'awg genkey' does not give a key of the right shape - and on the third and fourth commands, that is on either control step. The first command is a stopping point too, and the most common one: if the module is not loaded, the amneziawg link type is not registered and the interface is not created, even though the awg tool itself is present. Quicker than running the commands by hand: start the installer with --verbose and the probe writes to the log where exactly it stopped."
|
||
;;
|
||
not_implemented_yet)
|
||
printf '%s' "This installer version (v${SCRIPT_VERSION}) does not issue the AmneziaWG 3.1 profile: your environment fits, and it is not your machine. Way out: --protocol=2.0."
|
||
;;
|
||
internal_error)
|
||
printf '%s' "Internal error in the environment gate: the gate itself got invalid input. This is our defect, not a problem with your machine. Install with --protocol=2.0 and report it with the installer output attached."
|
||
;;
|
||
*)
|
||
# An unknown code is NOT treated as permission: staying silent here
|
||
# would mean a new reason code added later without a text quietly
|
||
# becomes an empty refusal with no explanation.
|
||
printf '%s' "The AmneziaWG 3.1 profile is unavailable, and reason code '${code}' is unknown to this installer version. Install with --protocol=2.0 and report the code to the developer."
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# _awg31_resolve_protocol : settle the installation generation and, when the
|
||
# third line is requested, run the environment through the gate (stage pre).
|
||
# Arg $1: 1 - an installation config already exists, 0 - this install is new.
|
||
# Mutates the global AWG_PROTOCOL. On a gate refusal or a bad flag value it ends
|
||
# the installation through die.
|
||
#
|
||
# 🔴 Split out into its own function for testability, not for looks: inside
|
||
# initialize_setup this logic would sit amid four hundred lines with no test at
|
||
# all, while it IS the protective contour this phase exists for. Here bats calls
|
||
# it directly.
|
||
# 🔴 config_exists is passed as an ARGUMENT even though bash would hand it over
|
||
# through the caller's dynamic scope. The implicit link would survive a rename
|
||
# in the caller silently, and the function would start treating every install as
|
||
# new - that is, allowing a generation change where profiles are already handed
|
||
# out.
|
||
_awg31_resolve_protocol() {
|
||
local config_exists="${1-}"
|
||
|
||
# 🔴 The argument is checked, not assumed. [[ "$x" -eq 1 ]] is equally
|
||
# FALSE for an empty string and for an unknown word, i.e. "this install
|
||
# is new", and the requested generation would overwrite the marker
|
||
# where profiles are already handed out. A caller bug must not turn
|
||
# into permission.
|
||
case "$config_exists" in
|
||
0|1) : ;;
|
||
*) die "_awg31_resolve_protocol: the existing-install flag must be 0 or 1, got '${config_exists}'. This is an internal installer error, please report it." ;;
|
||
esac
|
||
|
||
if [[ "$CLI_PROTOCOL_SET" -eq 1 ]]; then
|
||
case "$CLI_PROTOCOL" in
|
||
2.0|3.1) : ;;
|
||
*) die "--protocol='${CLI_PROTOCOL}': only 2.0 and 3.1 are allowed. An empty value usually means a missing argument: write --protocol=2.0 or --protocol 2.0." ;;
|
||
esac
|
||
if [[ "$config_exists" -eq 1 ]]; then
|
||
# 🔴 The flag does NOT change the generation of an existing install,
|
||
# and staying silent about that is not an option. Changing the
|
||
# generation in place means reissuing EVERY client profile and handing
|
||
# them out again; doing it in passing from a flag would void other
|
||
# people's distributed configs without asking. The marker of "existing"
|
||
# is the config file, not whether the service runs: --force on top of a
|
||
# working install lands here too, and rightly so - profiles are already
|
||
# out there.
|
||
# 🔴 A REFUSAL, NOT A WARNING. Carrying on with the previous
|
||
# generation would hand the person something OTHER than what
|
||
# they asked for, and the warning about it would drown in a long
|
||
# installation log. That is exactly the silent substitution the
|
||
# rest of this code exists to prevent. The same choice is already
|
||
# made above for a 3.1 marker in the config: die there too,
|
||
# rather than "quietly correct it".
|
||
if [[ "$CLI_PROTOCOL" != "$AWG_PROTOCOL" ]]; then
|
||
die "--protocol=${CLI_PROTOCOL} cannot be carried out on this server: the installation is marked as generation ${AWG_PROTOCOL} (the AWG_PROTOCOL marker in $CONFIG_FILE), and the generation of a running install does not change in place - that means reissuing EVERY client profile and handing them out again. Drop the flag to continue on ${AWG_PROTOCOL}, or deploy the server from scratch."
|
||
else
|
||
log "The requested generation ${CLI_PROTOCOL} matches the generation of this installation."
|
||
fi
|
||
else
|
||
AWG_PROTOCOL="$CLI_PROTOCOL"
|
||
log "Generation for the new installation set by flag: ${AWG_PROTOCOL}."
|
||
fi
|
||
elif [[ "$config_exists" -eq 0 ]]; then
|
||
# 🔴 The default of a NEW install and the rule for reading the marker are
|
||
# DIFFERENT things and must not share a line. awg_installed_protocol has
|
||
# to read a missing marker as 2.0 forever: that is how every install made
|
||
# before the marker existed is marked, and changing it would declare them
|
||
# third-line after the fact. The new-install default, in turn, flips in
|
||
# phase 5. Today both values are 2.0, so the line below changes nothing -
|
||
# it exists so that in phase 5 the edit is in ONE place and in plain
|
||
# sight, rather than found by searching the file.
|
||
AWG_PROTOCOL="$PROTOCOL_DEFAULT"
|
||
fi
|
||
|
||
# ── Environment gate, stage pre ──────────────────────────────────────────
|
||
# Called ONLY when the third line is requested. On an ordinary 2.0 install
|
||
# the gate is useless and harmful: it would run the tools probe and could
|
||
# refuse someone who never wanted the third line.
|
||
# Stage pre runs at step 0 - before packages are installed, sysctl is
|
||
# touched and the machine reboots. (The working directory, the log and the
|
||
# lock file already exist by then: those are our own files, not changes to
|
||
# the system. Correction from external review 9 sep: the earlier wording,
|
||
# "before the first change to the system", claimed more than was true.)
|
||
if [[ "$AWG_PROTOCOL" == "3.1" ]]; then
|
||
local _awg31_blocker _awg31_rc
|
||
_awg31_blocker=$(awg31_environment_blocker pre); _awg31_rc=$?
|
||
if [[ -n "$_awg31_blocker" ]]; then
|
||
log "3.1 environment gate (pre): reason code '${_awg31_blocker}'."
|
||
die "$(_awg31_blocker_message "$_awg31_blocker")"
|
||
fi
|
||
# 🔴 A GATE THAT COULD NOT ANSWER IS NOT PERMISSION. Empty output means
|
||
# "the third line is allowed", and looking at the output alone is not
|
||
# enough: a call that failed prints nothing either, and its silence
|
||
# would read as "yes". So the status is checked separately from the
|
||
# output. Found by external review 9 sep 2026.
|
||
if (( _awg31_rc != 0 )); then
|
||
die "The environment gate could not determine whether this machine fits the AmneziaWG 3.1 profile (exit code ${_awg31_rc}, no reason given). Without an answer we do not ship the third line. Install with --protocol=2.0 and report this."
|
||
fi
|
||
fi
|
||
}
|
||
|
||
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).
|
||
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_PROTOCOL|AWG_CPA|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
|
||
}
|
||
|
||
# awg_installed_protocol : the INSTALLATION's generation, from the AWG_PROTOCOL
|
||
# marker in awgsetup_cfg.init (already loaded by safe_load_config). Prints '2.0'
|
||
# or '3.1'.
|
||
# 🔴 A missing field IS 2.0, not "unknown": that is what every install made
|
||
# before the marker existed looks like, and any other answer could silently
|
||
# change its generation. Any other value is a failure (code 1) with NO output
|
||
# and no quiet default: a corrupt marker on a third-line server would otherwise
|
||
# (once regen consults the marker) make regen hand out second-line profiles that
|
||
# silently fail to connect. The
|
||
# caller prints the error text: the function body is identical in all four
|
||
# copies (RU/EN, shared library/installer), and the parity test checks that.
|
||
# Optional argument: path to the init file. With it the file is checked
|
||
# fail-closed by one anchored grep without a pipeline (a pipeline under pipefail
|
||
# took SIGPIPE on a large file and switched the guard off): a line of the form
|
||
# "AWG_PROTOCOL =" in any case with an empty value (the field did not parse:
|
||
# indentation, spaces around '=', broken quotes; or it was written empty) is a
|
||
# corrupt marker, not a missing one; two or more such lines are corrupt too
|
||
# (which one is true cannot be guessed). Both fail, or a corrupt marker would
|
||
# quietly read as 2.0.
|
||
# 🔴 The pattern allows a leading BOM ON PURPOSE: safe_load_config parses such a
|
||
# line, so the guard has to see it too. Without that, a corrupt marker behind a
|
||
# BOM (a file that went through a Windows editor) did not match the pattern, the
|
||
# guard read the marker as absent and answered 2.0 - exactly the silent
|
||
# substitution it is written against. For the same reason a second marker line
|
||
# did not match when the first carried a BOM, so a duplicate passed as a single
|
||
# marker.
|
||
# 🔴 grep's exit codes are not interchangeable: 1 means no match (normal), 2 and
|
||
# above mean grep itself failed (a regular file that cannot be read). The
|
||
# former '|| n=0' form equated them and turned a failure into "no
|
||
# marker", that is, into a confident 2.0. A read error now refuses as well.
|
||
# 🔴 grep only ever gets a regular file (-f): on a FIFO it would wait for a
|
||
# writer forever, on /dev/zero it would read without end. A missing path and a
|
||
# non-regular file in place of the init never reach grep and read as "no
|
||
# marker", so the -f check must stay. In manage such an init never reaches the
|
||
# function: check_dependencies refuses it earlier. The installer treats it as
|
||
# absent at step 0 and calls the function with the same path; the -f check is
|
||
# what gives it 2.0 instead of a hang.
|
||
awg_installed_protocol() {
|
||
local cfg="${1:-}" n=0 _rc=0 _bom=$'\xef\xbb\xbf'
|
||
if [[ -n "$cfg" && -f "$cfg" ]]; then
|
||
n=$(grep -ciE "^(${_bom})?[[:space:]]*(export[[:space:]]+)?AWG_PROTOCOL[[:space:]]*=" "$cfg")
|
||
_rc=$?
|
||
if [[ "$_rc" -ge 2 ]]; then
|
||
return 1
|
||
fi
|
||
[[ "$_rc" -eq 0 ]] || n=0
|
||
if [[ "$n" -gt 1 ]]; then
|
||
return 1
|
||
fi
|
||
fi
|
||
case "${AWG_PROTOCOL:-}" in
|
||
"")
|
||
if [[ "$n" -ge 1 ]]; then
|
||
return 1
|
||
fi
|
||
echo "2.0" ;;
|
||
2.0) echo "2.0" ;;
|
||
3.1) echo "3.1" ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
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() {
|
||
local r_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 [[ "$ALLOWED_IPS_MODE" != "default" && -n "$ALLOWED_IPS_MODE" ]]; then
|
||
# The mode is ALREADY known - read from the server config or inferred from
|
||
# it further down this file, and we are
|
||
# here only because the route list is empty. It must be REBUILT for the
|
||
# saved mode rather than have the mode replaced by today's default:
|
||
# otherwise changing the installer default silently changes the mode of a
|
||
# working server. The --yes branch used to assign its default over the
|
||
# saved value whatever it was, so the same defect predates this change.
|
||
# Mode 3 has NOTHING to rebuild from: its list is written by a human.
|
||
# Without it the branch below would go and ask through /dev/tty, and when
|
||
# there is no terminal read fails instantly, validation rejects the empty
|
||
# string, and the "try again" loop spins FOREVER printing nothing.
|
||
# Measured: with and without --yes alike. So the refusal is unconditional,
|
||
# loud and carries the next step - a silent hang costs more than a refusal.
|
||
if [[ "$ALLOWED_IPS_MODE" == "3" && -z "$CLI_CUSTOM_ROUTES" && -z "$ALLOWED_IPS" ]]; then
|
||
die "$CONFIG_FILE says routing mode 3, but the network list is empty. Pass --route-custom=NETS or put ALLOWED_IPS into the config."
|
||
fi
|
||
log_warn "Server routing mode: $ALLOWED_IPS_MODE, but the route list is empty - rebuilding it for that mode."
|
||
elif [[ "$AUTO_YES" -eq 1 ]]; then
|
||
ALLOWED_IPS_MODE=1
|
||
log "Routing mode: all traffic (--yes, default)."
|
||
else
|
||
echo ""
|
||
log "Select routing mode (client AllowedIPs):"
|
||
echo " 1) All traffic (0.0.0.0/0) (default) - the full-tunnel form clients expect"
|
||
echo " 2) Amnezia List+DNS - public IPv4 into the tunnel, private networks outside;"
|
||
echo " some clients treat such a list as split routing"
|
||
echo " 3) Only specified networks (Split Tunneling)"
|
||
# The exit status of read matters here too: with no terminal r_mode would
|
||
# be taken FROM THE ENVIRONMENT, so a stray r_mode=2 would silently pick a
|
||
# mode nobody asked about, and r_mode=3 would walk into the network prompt.
|
||
if ! read -rp "Your choice [1]: " r_mode < /dev/tty; then
|
||
r_mode=""
|
||
log_warn "No terminal available, could not ask about the routing mode - using the default."
|
||
fi
|
||
ALLOWED_IPS_MODE=${r_mode:-1}
|
||
fi
|
||
case "$ALLOWED_IPS_MODE" in
|
||
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." ;;
|
||
3) if [[ -z "$CLI_CUSTOM_ROUTES" ]]; then
|
||
# 🔴 The EXIT STATUS of read must be checked, not just its value.
|
||
# With no terminal (a dropped ssh session, cron, a run without -t)
|
||
# the /dev/tty redirect fails and read does not execute AT ALL,
|
||
# leaving the previous value; on Ctrl-D it does execute and yields
|
||
# an empty string. Either way the validator rejects the empty string
|
||
# and an unbounded loop spins forever, flooding the log: measured at
|
||
# over seventy thousand iterations in five seconds. The correct
|
||
# pattern lives in this same file - the package removal question.
|
||
local _aip_tries=0
|
||
while :; do
|
||
if ! read -rp "Enter networks (a.b.c.d/xx,...): " ALLOWED_IPS < /dev/tty; then
|
||
die "Input ended or no terminal is available - nobody to ask for the network list. Pass --route-custom=NETS or pick another routing mode."
|
||
fi
|
||
validate_cidr_list "$ALLOWED_IPS" && break
|
||
if (( ++_aip_tries >= 5 )); then
|
||
die "Five invalid entries in a row. Pass --route-custom=NETS or pick another routing mode."
|
||
fi
|
||
log_warn "Invalid CIDR format: '$ALLOWED_IPS'. Expected: x.x.x.x/y[,x.x.x.x/y]"
|
||
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)" ;;
|
||
*) # Anything unrecognised falls back to the default mode - the contract
|
||
# is unchanged, only WHICH mode that is has moved. But it must not be
|
||
# silent: someone who missed the "2" key would otherwise learn their
|
||
# mode from the issued config. An empty answer is a deliberate choice
|
||
# of the default and gets no warning.
|
||
if [[ -n "$ALLOWED_IPS_MODE" && "$ALLOWED_IPS_MODE" != "1" ]]; then
|
||
log_warn "Mode '$ALLOWED_IPS_MODE' not recognised - falling back to the default (all traffic)."
|
||
fi
|
||
ALLOWED_IPS_MODE=1
|
||
ALLOWED_IPS="0.0.0.0/0"
|
||
log "Selected mode: All traffic." ;;
|
||
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 the CPS string for I1: a concealment packet shaped like a DNS reply.
|
||
#
|
||
# 🔴 Until 10 sep 2026 this emitted `<r 32..256>` - 32 to 256 bytes of pure
|
||
# randomness - and on Russian cellular that broke the handshake. A device-window
|
||
# measurement (MTS Moscow, route to Los Angeles) produced a decisive pair of
|
||
# profiles of the SAME 128-byte size: the random one never completed a handshake,
|
||
# the DNS-shaped one completed in 40 seconds. The packet's SHAPE decides, not its
|
||
# size, so the fix is not to lower the upper bound - the packet has to look like
|
||
# something. The vendor does exactly this: its stock first special packet is a
|
||
# forged DNS reply.
|
||
#
|
||
# ⚠️ The failure looks deceptively like a working link: the server shows the
|
||
# peer, the endpoint moves to the mobile address, the received counter grows -
|
||
# and the latest handshake never moves. Transport packets travel alone and get
|
||
# through; the handshake packet travels inside the opening burst together with
|
||
# I1 and is lost along with it.
|
||
#
|
||
# Portable tags only: `<b>`, `<r>` and `<rc>` are understood by both the kernel
|
||
# module and amneziawg-go. `<r 2>` (the transaction id) and `<rc N>` (the name
|
||
# label) are expanded afresh in EVERY packet, so this is a structure and not a
|
||
# static blob: two consecutive I1 packets do not match byte for byte.
|
||
#
|
||
# The upper bound is held at the measured 128 bytes: above that nothing was
|
||
# tested, and there is nothing to guess on the measurement's behalf. The lower 70
|
||
# does NOT follow from the measurement - it follows from the packet's shape, it
|
||
# never went on the wire, and it rests only on the vendor's ~44-byte packet
|
||
# living in the field across the whole fleet of the application.
|
||
generate_cps_i1() {
|
||
local label answers hex qtail ttl o1 o2 o3 o4 i
|
||
label=$(rand_range 20 62) # name label; the DNS limit is 63 bytes
|
||
answers=$(rand_range 1 2)
|
||
|
||
# Header: flags 0x8580, one question, answers answer records, zero
|
||
# authority and additional records. The last byte is the label length,
|
||
# immediately followed by the label's letters.
|
||
#
|
||
# ⚠️ 0x8580 is QR+AA+RD+RA, that is "authoritative answer" and "recursion
|
||
# available" at the same time - an odd combination for a real resolver, and
|
||
# the urge to rewrite it as the usual 0x8180 comes naturally. DO NOT: 0x8580
|
||
# is exactly what the vendor's stock special packet carries, so that is what
|
||
# the whole fleet of the official application sends, and it is what our
|
||
# measurement passed with. A "common sense" fix would move us out of the
|
||
# measurement and out of a large foreign population into a small own one.
|
||
hex=$(printf '85800001%04x00000000%02x' "$answers" "$label")
|
||
|
||
# Question tail: cdns.icloud.com, type A, class IN.
|
||
qtail='0463646e730669636c6f756403636f6d0000010001'
|
||
|
||
# The TTL is drawn ONCE per packet: records of one set must carry the same
|
||
# TTL, and that is how resolvers answer. Two records for one name with
|
||
# different TTLs is precisely the small thing a forgery is spotted by.
|
||
case $(rand_range 1 4) in
|
||
1) ttl='0000003c' ;; # 60 s
|
||
2) ttl='0000012c' ;; # 300 s
|
||
3) ttl='00000384' ;; # 900 s
|
||
*) ttl='00000e10' ;; # 3600 s
|
||
esac
|
||
|
||
for (( i = 0; i < answers; i++ )); do
|
||
# Answer address. The first octet stays within 1-221, and the two
|
||
# special values are remapped onto 222 and 223. That is a one-to-one
|
||
# mapping, so 221 equally likely values without 10 and 127.
|
||
#
|
||
# 🔴 Why a remap and not a redraw loop, and not one constant for both. A
|
||
# constant would occur twice as often as any other value and would
|
||
# become a marker of its own. The `while` loop was written first and
|
||
# struck out: `rand_range` is called through command substitution, that
|
||
# is in a subshell, and any counter or state inside it is lost - on the
|
||
# bench such a loop went infinite, and in the installer that would have
|
||
# been a step wedged for good. A remap has neither a loop nor state and
|
||
# therefore cannot hang.
|
||
#
|
||
# ⚠️ This is NOT the full list of special-purpose ranges: 172.16/12,
|
||
# 192.168/16, 169.254/16 and 100.64/10 can still land here. The packet
|
||
# is never routed and nobody resolves its contents, so more than this is
|
||
# not needed.
|
||
o1=$(rand_range 1 221)
|
||
case "$o1" in
|
||
10) o1=222 ;;
|
||
127) o1=223 ;;
|
||
esac
|
||
o2=$(rand_range 0 255)
|
||
o3=$(rand_range 0 255)
|
||
o4=$(rand_range 1 254)
|
||
# Pointer 0xc00c to the question's name, type A, class IN, TTL, length 4.
|
||
qtail="${qtail}$(printf 'c00c00010001%s0004%02x%02x%02x%02x' \
|
||
"$ttl" "$o1" "$o2" "$o3" "$o4")"
|
||
done
|
||
|
||
local out
|
||
out=$(printf '<r 2><b 0x%s><rc %s><b 0x%s>' "$hex" "$label" "$qtail")
|
||
|
||
# 🔴 A self-check before handing the value out. Without it the function has
|
||
# no failure signal at all: its exit status is that of the last printf, and
|
||
# that succeeds whatever the content is. A degradation measurement
|
||
# (rand_range returning an empty string) produced `<rc >` with an empty
|
||
# count and ANCOUNT=0000 at exit status 0 - such a value travels into the
|
||
# settings file, into the server config and into EVERY client profile, and
|
||
# only fails at step 7 when the interface comes up. The check lives here
|
||
# rather than at the call site because at this step the installer has not
|
||
# fetched awg_common.sh yet and cannot use our own parser. Note also that
|
||
# `%02x` is a MINIMUM WIDTH, not a truncation, so a value above 255 would
|
||
# give an odd number of hex characters, and both implementations reject such
|
||
# a tag. The label and answer bounds are checked separately: today they are
|
||
# held only by the literal rand_range arguments, and that link is invisible.
|
||
[[ "$out" =~ ^\<r\ 2\>\<b\ 0x[0-9a-f]{22}\>\<rc\ [0-9]{1,2}\>\<b\ 0x([0-9a-f]{2})+\>$ ]] || return 1
|
||
[[ "$label" -ge 1 && "$label" -le 63 && "$answers" -ge 1 && "$answers" -le 9 ]] || return 1
|
||
|
||
printf '%s\n' "$out"
|
||
}
|
||
|
||
# Generate all AWG 2.0 parameters
|
||
generate_awg_params() {
|
||
local preset="${CLI_PRESET:-default}"
|
||
log "Generating AWG ${AWG_PROTOCOL:-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"
|
||
# The third line (AWG_PROTOCOL=3.1) is meant for a header protection key (the
|
||
# next part adds generating it and writing it into the config), and with the
|
||
# key the first 12 bytes of the S padding serve as the nonce: the S3 and S4
|
||
# lower bounds are raised to 12 (S1 and S2 already start at 15). The 2.0 branch
|
||
# is not meant for a key, and without a key S below 12 is legal, so its ranges
|
||
# stay as they were.
|
||
local _s3_min=8 _s4_min=4
|
||
if [[ "${AWG_PROTOCOL:-2.0}" == "3.1" ]]; then
|
||
_s3_min=12
|
||
_s4_min=12
|
||
fi
|
||
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 2.0 lower bounds (S3 from 8, S4 from 4) 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. That is why the
|
||
# 3.1 branch raises both lower bounds to 12 (_s3_min and _s4_min above), while
|
||
# the 2.0 branch is not meant for a key and keeps its ranges. The raise must apply to both
|
||
# the first S3 draw and the collision retry below, otherwise the retry brings
|
||
# S3 below 12 back.
|
||
AWG_S3=$(rand_range "$_s3_min" 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 "$_s3_min" 55)
|
||
done
|
||
|
||
AWG_S4=$(rand_range "$_s4_min" 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.
|
||
if [[ "${AWG_PROTOCOL:-2.0}" == "3.1" ]]; then
|
||
# With a header protection key the wire is statistically indistinguishable for
|
||
# any H, and random ranges no longer hide anything; H must still not overlap.
|
||
AWG_H1=1
|
||
AWG_H2=2
|
||
AWG_H3=3
|
||
AWG_H4=4
|
||
else
|
||
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]}"
|
||
fi
|
||
|
||
# I1: CPS concealment
|
||
AWG_I1=$(generate_cps_i1) || die "Could not build the I1 concealment packet - the generator returned an invalid value"
|
||
|
||
# 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
|
||
|
||
# ContentPaddingAddition only in the 3.1 branch, value 32-128. A constant rather
|
||
# than a draw: the generator runs before awg_common_en.sh is downloaded and does
|
||
# not call awg_cpa_check_safe. That the value stays under the 65535 cap (above it
|
||
# the tools silently wrap it modulo 65536) is checked by the test through that
|
||
# predicate.
|
||
if [[ "${AWG_PROTOCOL:-2.0}" == "3.1" ]]; then
|
||
AWG_CPA='32-128'
|
||
export AWG_CPA
|
||
else
|
||
unset AWG_CPA
|
||
fi
|
||
|
||
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"
|
||
if [[ -n "${AWG_CPA:-}" ]]; then
|
||
log " ContentPaddingAddition=$AWG_CPA"
|
||
fi
|
||
log "AWG ${AWG_PROTOCOL:-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"
|
||
}
|
||
|
||
# _boot_critical_package_list : packages whose loss leaves the server unable to
|
||
# boot or unable to reach the network. The core of the list comes from Issue
|
||
# #223: there `apt full-upgrade` in step 1 removed packages including udev,
|
||
# initramfs-tools and netplan.io among them, and the server stopped booting.
|
||
# Without udev there is no /dev/disk/by-label, systemd never sees the partitions
|
||
# that fstab refers to by label, and it drops into emergency mode (after 90
|
||
# seconds of waiting by default, see DefaultDeviceTimeoutSec; both partitions
|
||
# were waited for in parallel, not one after the other). Some names were added
|
||
# on reasoning rather than from that incident: losing openssh-server,
|
||
# systemd-resolved or ifupdown cuts off access just as reliably.
|
||
#
|
||
# How it gets there. cleanup_system purges its own list, and the ubuntu-server
|
||
# meta-package turns out to be a reverse dependency of what is being removed, so
|
||
# it goes along. On images where it was the only manual root, everything hanging
|
||
# under it (ubuntu-standard, ubuntu-minimal and their dependencies) becomes "no
|
||
# longer required". That alone is not a removal: apt lists such packages and
|
||
# suggests `apt autoremove`. But while resolving dependencies for the upgrade
|
||
# the resolver may pick removal over upgrading, and for a package nobody needs
|
||
# any more that is the cheap choice. In Issue #223 it made exactly that choice.
|
||
# We never reproduced the resolver's decision: the outcome is known, the motive
|
||
# is not.
|
||
#
|
||
# ⚠️ The names ubuntu-server/ubuntu-minimal/ubuntu-standard exist only on
|
||
# Ubuntu. Debian has no such chain, and there the list acts as ordinary
|
||
# insurance: _installed_boot_critical simply will not find the absent ones.
|
||
#
|
||
# ⚠️ This list is NOT the hold list from cleanup_system: that one guards during
|
||
# the purge, this one during the upgrade. They overlap; their purpose differs.
|
||
_boot_critical_package_list() {
|
||
printf '%s' "udev initramfs-tools openssh-server netplan.io netplan-generator systemd-resolved ifupdown ubuntu-minimal ubuntu-standard"
|
||
}
|
||
|
||
# _pkg_present : 0 if the package is present in any working shape. We look at
|
||
# the third Status field rather than at the "ok installed" substring: right
|
||
# after a purge or an interrupted upgrade a package can sit as half-configured
|
||
# or unpacked. For our purposes it is present and has to be protected.
|
||
_pkg_present() {
|
||
local state
|
||
state="$(dpkg-query -W -f='${Status}' "$1" 2>/dev/null | awk '{print $3}')"
|
||
case "$state" in
|
||
""|not-installed|config-files) return 1 ;;
|
||
*) return 0 ;;
|
||
esac
|
||
}
|
||
|
||
# _installed_boot_critical : the ones actually installed on this system.
|
||
# Prints one name per line; empty output is a reason to worry rather than a
|
||
# normal outcome, since udev is present on virtually every server.
|
||
_installed_boot_critical() {
|
||
local critical_list
|
||
critical_list="$(_boot_critical_package_list)"
|
||
local pkg
|
||
for pkg in $critical_list; do
|
||
if _pkg_present "$pkg"; then
|
||
printf '%s\n' "$pkg"
|
||
fi
|
||
done
|
||
}
|
||
|
||
# _awg_pkg_held <package> : 0 if the package is on hold, 1 otherwise.
|
||
# The first source is the package selection in dpkg, the first word of Status:
|
||
# apt takes the hold from there itself, and `hold ok not-installed` (the mark on
|
||
# a package not installed yet) is a hold too. The second is the
|
||
# `apt-mark showhold` list, read into a variable in full, as a fallback: it is
|
||
# built from the same selection, only through the apt cache.
|
||
# 🔴 The list is NOT piped into grep. Issue #285: on Debian 12 the check
|
||
# `apt-mark showhold | grep -qx` refused while the hold was in place. The cause
|
||
# on the reporter's host is not proven, but the mechanism reproduces with the
|
||
# real apt-mark on a long list of holds: it writes line by line, grep -q quits
|
||
# at the first match, and if more held packages sort after this one the next
|
||
# write gets SIGPIPE; under pipefail the pipeline then counts as failed
|
||
# although the line was found.
|
||
# A failing showhold does not count as a list even if it printed something:
|
||
# without the hold in dpkg that stays a refusal, not a guess in favour of a hold.
|
||
# An empty name is a refusal: otherwise the empty line that <<< adds would match
|
||
# it exactly. The name is passed literally (dpkg-query reads it as a pattern, so
|
||
# no * ? [) and assumes a single-architecture package: for a package in several
|
||
# architectures dpkg-query glues the statuses together without a separator.
|
||
_awg_pkg_held() {
|
||
local pkg="${1:-}" status holds
|
||
[[ -n "$pkg" ]] || return 1
|
||
status=$(dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null) || status=""
|
||
[[ "${status%% *}" == "hold" ]] && return 0
|
||
holds=$(apt-mark showhold 2>/dev/null) || return 1
|
||
grep -qxF -- "$pkg" <<< "$holds"
|
||
}
|
||
|
||
# _awg_hold_refusal_log <apt-mark hold output> : before refusing, show what
|
||
# apt-mark and dpkg answered. Without it the reason (a lock, a package with no
|
||
# candidate, a damaged dpkg database) stayed in /dev/null, and the hint in the
|
||
# refusal text could point the wrong way. Both answers are folded into one line
|
||
# and cut from the start, like apt answers elsewhere: the log stamps only the
|
||
# first line, and apt usually puts the reason at the end.
|
||
_awg_hold_refusal_log() {
|
||
local mark dpkg
|
||
mark=$(printf '%s' "${1:-}" | tr '\n' ' ' | tail -c 300)
|
||
dpkg=$(dpkg-query -W -f='${Status}' amneziawg-dkms 2>&1 | tr '\n' ' ' | tail -c 300)
|
||
log_error "apt-mark hold said: ${mark:-<empty>}"
|
||
log_error "amneziawg-dkms status in dpkg: ${dpkg:-<empty>}"
|
||
}
|
||
|
||
# _pkg_installed_ok : 0 only if the package is fully installed AND configured.
|
||
# The difference from _pkg_present is deliberate and the risk is asymmetric. For
|
||
# the SNAPSHOT, "unpacked but not configured" counts as present: the package is
|
||
# there and has to be protected. For the VERDICT before the reboot it does not:
|
||
# initramfs-tools left unpacked means postinst never ran and no initramfs was
|
||
# built for the new kernel. The server will not boot, though the package
|
||
# formally "exists".
|
||
_pkg_installed_ok() {
|
||
# We look at the THIRD field only. A full Status line is "<want> <error>
|
||
# <status>", and anchoring the whole "install ok installed" pins the want
|
||
# flag as well: `apt-mark hold` sets "hold ok installed", so a perfectly
|
||
# healthy package would read as lost. That is not theoretical here -
|
||
# cleanup_system goes out of its way to preserve operator holds. The
|
||
# installed state still rejects what this predicate exists for: unpacked,
|
||
# half-configured, half-installed, config-files.
|
||
[[ "$(dpkg-query -W -f='${Status}' "$1" 2>/dev/null | awk '{print $3}')" == "installed" ]]
|
||
}
|
||
|
||
# The snapshot SURVIVES installer restarts and can only grow.
|
||
#
|
||
# The installer itself asks the user to run it again after a failure, and by
|
||
# then a package may already be gone: apt can remove udev and then fail, in
|
||
# which case the check at the end of the step never runs at all. A snapshot
|
||
# taken afresh on the next run will not see the removed package, and the check
|
||
# will compare the system against an impoverished baseline - staying silent
|
||
# about exactly the state it was written for. So we merge with what was
|
||
# recorded earlier.
|
||
#
|
||
# ONLY known names are taken from the file: a corrupted or substituted file must
|
||
# not turn into a list of arbitrary packages to install.
|
||
_boot_critical_snapshot() {
|
||
local now stored known union
|
||
now="$(_installed_boot_critical)"
|
||
stored=""
|
||
if [[ -e "$BOOT_CRITICAL_SNAPSHOT_FILE" && ! -r "$BOOT_CRITICAL_SNAPSHOT_FILE" ]]; then
|
||
# Otherwise an unreadable file is indistinguishable from a missing one:
|
||
# the list would quietly shrink and the function would immediately
|
||
# overwrite the history with it, while its contract is to only grow.
|
||
log_warn "$BOOT_CRITICAL_SNAPSHOT_FILE exists but cannot be read. The list from previous runs will not be taken into account."
|
||
elif [[ -r "$BOOT_CRITICAL_SNAPSHOT_FILE" ]]; then
|
||
known="$(_boot_critical_package_list | tr ' ' '\n')"
|
||
stored="$(grep -Fxf <(printf '%s\n' "$known") "$BOOT_CRITICAL_SNAPSHOT_FILE" 2>/dev/null || true)"
|
||
fi
|
||
union="$(printf '%s\n%s\n' "$now" "$stored" | grep -v '^$' | sort -u)"
|
||
if [[ -n "$union" ]]; then
|
||
mkdir -p "$AWG_DIR" 2>/dev/null || true
|
||
printf '%s\n' "$union" > "$BOOT_CRITICAL_SNAPSHOT_FILE" 2>/dev/null \
|
||
|| log_warn "Could not save the protected package list to $BOOT_CRITICAL_SNAPSHOT_FILE. The check survives this run but not the next one."
|
||
fi
|
||
printf '%s' "$union"
|
||
}
|
||
|
||
# _verify_boot_critical : the last line of defence before the reboot. Takes the
|
||
# snapshot made BEFORE the upgrade and compares it against the state right now.
|
||
#
|
||
# The call sits right next to request_reboot and must not drift away from it.
|
||
# The whole point is that nothing capable of removing a package runs after it:
|
||
# step 1 still has install_packages between the upgrade and the reboot, and that
|
||
# calls apt install without --no-remove. A check placed before it would leave a
|
||
# window of exactly the class it is meant to close.
|
||
#
|
||
# Why this is needed at all: apt is allowed to remove packages in order to
|
||
# resolve dependencies, and in Issue #223 udev went that way - the server
|
||
# stopped booting, and the reboot is one we trigger ourselves. So the catch
|
||
# belongs here, while the server is still reachable: after the reboot the repair
|
||
# would need the hosting provider's console.
|
||
_verify_boot_critical() {
|
||
local critical_before="$1"
|
||
if [[ -z "$critical_before" ]]; then
|
||
log_warn "The protected package list is empty, there is nothing to compare against. That is abnormal for Ubuntu and Debian: check dpkg-query -W udev, the server may fail to boot after the reboot."
|
||
return 0
|
||
fi
|
||
local critical_lost=""
|
||
local pkg
|
||
for pkg in $critical_before; do
|
||
_pkg_installed_ok "$pkg" || critical_lost+="$pkg "
|
||
done
|
||
[[ -n "$critical_lost" ]] || return 0
|
||
critical_lost="${critical_lost% }"
|
||
|
||
# Before blaming the upgrade: a broken dpkg produces exactly the same
|
||
# picture, and a message saying "packages were removed" would send the
|
||
# diagnosis the wrong way.
|
||
_dpkg_usable || die "dpkg stopped answering, so there is no way to check the package state. Do NOT reboot the server. Run: dpkg --configure -a; apt-get check - then start the installer again."
|
||
|
||
log_warn "Packages the server cannot boot without have disappeared: $critical_lost"
|
||
log_warn "Restoring them..."
|
||
local restore_out restore_rc
|
||
|
||
# Stage 1: ONE transaction with every lost name at once.
|
||
# What matters is WHAT becomes a resolver goal. One at a time, `apt-get
|
||
# install udev` knows nothing about the other lost names: they are not
|
||
# goals, and apt is free to leave them absent. In one command they all
|
||
# become goals and apt looks for a version set that suits the group. In
|
||
# Issue #223 the per-package pass produced five refusals and a single
|
||
# transaction was never tried, which is the reason to start with it.
|
||
# ⚠️ Not a guarantee, for two reasons.
|
||
# First: if the old version is pinned by a package that is NOT in the lost
|
||
# list (in Issue #223 that was systemd-resolved, with a Depends on exactly
|
||
# 8.12 of both systemd and libsystemd-shared; the second link there is udev
|
||
# declaring Breaks on a systemd older than 8.17, and together the two
|
||
# conditions locked the group), it does not become a goal here either. apt
|
||
# MAY touch it anyway and sometimes does, but that is its
|
||
# choice, not an obligation: it prefers to leave non-goal packages alone.
|
||
# Second: --no-remove aborts the transaction on ANY removal in the plan, not
|
||
# only on removing something protected. A solution of the form "drop the
|
||
# package in the way and install the group" is rejected outright.
|
||
# Hence stage 2 below, and the final verdict from the full-set re-check. The
|
||
# flag is still needed: restoring one package must not cost another.
|
||
restore_out="$(DEBIAN_FRONTEND=noninteractive apt-get install -y --no-remove $critical_lost 2>&1)"
|
||
restore_rc=$?
|
||
# The wording is cautious on purpose: a zero from apt means "there was
|
||
# nothing to do" just as much as "done". Whether the packages are back is
|
||
# decided by stage 2 below and the full-set re-check, not by this line.
|
||
if [[ "$restore_rc" -eq 0 ]]; then
|
||
log "The single transaction completed without errors."
|
||
elif [[ -n "$restore_out" ]]; then
|
||
log_warn "Single transaction did not work (code $restore_rc), trying one by one. apt said: $(printf '%s' "$restore_out" | tr '\n' ' ' | tail -c 300)"
|
||
else
|
||
log_warn "Single transaction did not work (code $restore_rc) and apt produced no output. Trying one by one."
|
||
fi
|
||
|
||
# Stage 2: one at a time, and only for those still missing.
|
||
# A separate pass is needed because a single name with no installation
|
||
# candidate aborts the whole transaction, and then nothing is restored,
|
||
# including packages that install perfectly well. This lesson is already
|
||
# paid for in this project: cleanup_system (defined BELOW in this file)
|
||
# installs netplan.io and netplan-generator separately, because on Debian
|
||
# 12 the latter does not exist and it kills the whole transaction.
|
||
for pkg in $critical_lost; do
|
||
_pkg_installed_ok "$pkg" && continue
|
||
restore_out="$(DEBIAN_FRONTEND=noninteractive apt-get install -y --no-remove "$pkg" 2>&1)"
|
||
restore_rc=$?
|
||
if [[ "$restore_rc" -eq 0 ]] && _pkg_installed_ok "$pkg"; then
|
||
log "Restored: $pkg"
|
||
elif [[ "$restore_rc" -eq 0 ]]; then
|
||
# apt also returns zero when it decided there was nothing to do.
|
||
# Without this branch the log would contradict itself: "Restored"
|
||
# and three lines below "Boot-critical packages missing".
|
||
log_warn "apt reported success, but $pkg is still not installed."
|
||
elif [[ -n "$restore_out" ]]; then
|
||
# Single line: log_msg timestamps only the first one, and a
|
||
# multi-line answer breaks the log format exactly where it will
|
||
# later be parsed.
|
||
log_warn "Failed to install $pkg (code $restore_rc). apt said: $(printf '%s' "$restore_out" | tr '\n' ' ' | tail -c 300)"
|
||
else
|
||
log_warn "Failed to install $pkg (code $restore_rc) and apt produced no output: the command probably did not run at all."
|
||
fi
|
||
done
|
||
|
||
# Re-check the WHOLE set, not just what went missing. The direct route to
|
||
# losing a neighbour is closed by --no-remove above; this is the fallback for
|
||
# the day that stops holding.
|
||
local still_lost=""
|
||
for pkg in $critical_before; do
|
||
_pkg_installed_ok "$pkg" || still_lost+="$pkg "
|
||
done
|
||
if [[ -n "$still_lost" ]]; then
|
||
still_lost="${still_lost% }"
|
||
log_error "Do NOT reboot the server: in its current state it will not come back."
|
||
log_error "Boot-critical packages missing: $still_lost"
|
||
log_error "Try installing them in ONE command, every name at once: sudo apt-get install $still_lost"
|
||
log_error "One command rather than one at a time: that way apt picks versions for the whole group at once."
|
||
log_error "No -y on purpose: if apt then wants to REMOVE something, read the list before you confirm. Losing one more of the packages above only makes things worse."
|
||
log_error "If apt answers that a package not in your command is in the way (of the form 'X : Breaks: Y' or 'X : Depends: Y'), add Y to the same command: in Issue #223 that turned out to be systemd, which is not in the list above."
|
||
log_error "If apt refuses because of held packages, release the hold: sudo apt-mark unhold <name>"
|
||
log_error "If a package is gone from the repositories (renamed by a release upgrade), drop its name from $BOOT_CRITICAL_SNAPSHOT_FILE"
|
||
die "Stopping while the server is still reachable. Deal with the above, then run the installer again."
|
||
fi
|
||
log "Boot-critical packages restored."
|
||
}
|
||
|
||
|
||
# _die_upgrade_failed : name the cause of a failed upgrade and stop.
|
||
#
|
||
# Pulled out into its own function for a reason, not for tidiness. While this
|
||
# reasoning lived inline inside step1_update_and_optimize, tests could only
|
||
# check it by grepping the source, and an outside review showed that almost any
|
||
# mutation inside survived the whole suite green: deleting the second lock
|
||
# measurement, inverting the condition, dropping -s, dropping timeout. A test
|
||
# can load a function whole and assert WHICH verdict is printed for WHICH state.
|
||
#
|
||
# The rule of this block: name the cause by evidence, not by guess. The previous
|
||
# version blamed the dpkg lock unconditionally, including when fuser had found
|
||
# nothing, and sent the investigation the wrong way: in Issue #223 the real
|
||
# answer was a resolver refusal.
|
||
_die_upgrade_failed() {
|
||
local lock_holder apt_why apt_why_rc
|
||
# Measure AGAIN rather than reusing the sample taken before the retry:
|
||
# dpkg --configure -a and a whole second apt run happened in between, and
|
||
# the process found earlier may have exited while a new one appeared.
|
||
lock_holder="$(fuser /var/lib/dpkg/lock-frontend 2>/dev/null | tr -s ' ' || true)"
|
||
if [[ -n "$lock_holder" ]]; then
|
||
die "System update failed and the dpkg lock is held by:${lock_holder}. Wait for those processes to finish or run: systemctl stop unattended-upgrades; dpkg --configure -a - then run the script again."
|
||
fi
|
||
# The dry run (-s) asks apt whether a plan resolves. Its refusal usually
|
||
# means dependencies, but not always: an unparsable sources.list, missing
|
||
# package lists or a damaged dpkg state fail exactly the same way. So its
|
||
# answer is QUOTED, not interpreted.
|
||
# The timeout belongs here, on the fatal path: the real attempts above go
|
||
# without one deliberately, while hanging here is not acceptable - that SSH
|
||
# session may be all the user has left. timeout itself is always there,
|
||
# coreutils is Essential.
|
||
apt_why="$(timeout 120 env DEBIAN_FRONTEND=noninteractive apt-get upgrade -s --with-new-pkgs 2>&1)"
|
||
apt_why_rc=$?
|
||
if [[ "$apt_why_rc" -eq 0 ]]; then
|
||
die "System update failed, but the dependencies resolve on a re-check, so they are not the cause. Look at the apt output on screen, it is not written to the log file: usually network or mirror, disk space on / or /boot, or the package's own script."
|
||
fi
|
||
if [[ -n "$apt_why" ]]; then
|
||
die "System update failed. The dependency re-check answered: $(printf '%s' "$apt_why" | tr '\n' ' ' | tail -c 400)"
|
||
fi
|
||
# Third outcome: the check failed and said nothing. Claiming anything about
|
||
# dependencies here is exactly how an unknown turns into a confident wrong
|
||
# diagnosis.
|
||
die "System update failed, and the dependency re-check did not answer either (code $apt_why_rc, no output; code 124 means it did not finish within 120 seconds). Look at the apt output on screen, it is not written to the log file."
|
||
}
|
||
|
||
# _warn_kept_back : say out loud that not everything was upgraded.
|
||
# apt-get upgrade leaves a package at its current version when upgrading it
|
||
# would require removing a neighbour, and returns ZERO while doing so. That is
|
||
# the deliberate trade (see the upgrade block in step 1), but staying silent
|
||
# about it is not acceptable: with full-upgrade this outcome was rare (it held
|
||
# back little beyond packages under hold and whatever Ubuntu itself phases),
|
||
# whereas with upgrade it is routine, and without a dedicated line it would pass
|
||
# entirely unnoticed. A warning, not a failure: the server boots either way, but
|
||
# this list is what decides a future investigation.
|
||
#
|
||
# ⚠️ apt gives no machine-readable list of what it held back, so this uses
|
||
# upgradable, which is a SUPERSET: packages under hold and packages stuck on an
|
||
# unresolvable chain land there too. Passing it off as something narrower is not
|
||
# acceptable, and the message below does not.
|
||
_warn_kept_back() {
|
||
local raw rc kept list
|
||
raw="$(apt list --upgradable 2>/dev/null)"
|
||
rc=$?
|
||
# Take the exit code from apt ITSELF, not from the pipeline: in a pipeline
|
||
# it comes from awk unless pipefail is set, and then a failing apt reads as
|
||
# "nothing to upgrade". The branch below decides between silence and a
|
||
# warning, so it must not rest on a global shell option.
|
||
if [[ "$rc" -ne 0 ]]; then
|
||
# A failure of the check itself must not turn into contented silence:
|
||
# this function exists for diagnosability, so its own failure has to be
|
||
# audible.
|
||
log_warn "Could not obtain the list of packages left behind (apt list returned $rc). Check by hand: apt list --upgradable"
|
||
return 0
|
||
fi
|
||
kept="$(printf '%s\n' "$raw" | awk -F/ '/\//{printf "%s ", $1}')"
|
||
[[ -n "${kept// /}" ]] || return 0
|
||
list="${kept% }"
|
||
# Truncation is EXPLICIT and marked: on a server with months of pending
|
||
# updates this list runs into thousands of characters, and silent truncation
|
||
# nearby has already been called out as a defect.
|
||
if [[ "${#list}" -gt 400 ]]; then
|
||
list="${list:0:400}... (truncated, full list: apt list --upgradable)"
|
||
fi
|
||
log "Not every package was upgraded, these stayed at their current versions: $list"
|
||
log "Most often this means upgrading such a package would have to remove another one, which we deliberately do not do (Issue #223), or that the release is still being phased in. The list is not exhaustive though: packages under hold and packages stuck on unresolvable dependencies show up here too. If the list is not empty and it worries you, look at the reason: apt-get -s upgrade"
|
||
}
|
||
|
||
# _boot_critical_guard : take the snapshot and verify it. The wrapper exists for
|
||
# the places that have not taken a snapshot yet, which is step 2.
|
||
#
|
||
# ⚠️ The self-tests sit HERE, before the assignment, and not inside
|
||
# _boot_critical_snapshot, and that matters: a die inside a command
|
||
# substitution would only end the subshell, the script would carry on with an
|
||
# empty snapshot, and the fatal check would silently become optional.
|
||
_boot_critical_guard() {
|
||
_dpkg_usable || die "dpkg does not answer, and without it there is no way to tell whether udev and initramfs-tools survive the reboot (Issue #223). Run: dpkg --configure -a; apt-get check - then start the installer again."
|
||
_pkg_present dpkg || die "Could not determine package state (dpkg-query or awk do not behave as expected). Without it there is no way to be sure the server will boot (Issue #223)."
|
||
local snapshot
|
||
snapshot="$(_boot_critical_snapshot)"
|
||
_verify_boot_critical "$snapshot"
|
||
}
|
||
|
||
# _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
|
||
# Make sure the file ends with a newline. Without it our entry
|
||
# would be glued onto the last fstab line, turning it into a
|
||
# single malformed record of 11 fields instead of six. Command
|
||
# substitution strips trailing newlines, so a properly
|
||
# terminated file yields an empty string and no extra newline
|
||
# is added.
|
||
if [[ -s /etc/fstab && -n "$(tail -c1 /etc/fstab)" ]]; then
|
||
echo >> /etc/fstab
|
||
fi
|
||
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
|
||
|
||
# timeout: re-running the installer over a server with hand-inflated
|
||
# I1-I5 would otherwise hang here forever (#228). The failure below is
|
||
# already loud.
|
||
if ! timeout 10 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 timeout 10 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
|
||
# ==============================================================================
|
||
|
||
# The report is meant to be pasted into a PUBLIC issue: our own bug template asks
|
||
# for its contents. Key values are stripped by one shared function rather than by
|
||
# separate masking inside each section: sections get added over time and masking
|
||
# done inside one of them drifts apart from the rest. That is exactly how
|
||
# PresharedKey ended up in the report in clear text while PrivateKey was masked on
|
||
# the line next to it.
|
||
# Applied at TWO points (one implementation, not two sources of truth): at the
|
||
# report boundary and separately to the server config.
|
||
# NOTE: the second point covers the server config ONLY. awg show output and the
|
||
# journal do not pass through it, so those are what would leak if the outer
|
||
# pipeline were ever detached.
|
||
# NOTE: the AWG_ENDPOINT masking further down this function is a deliberate
|
||
# exception to the "one function" rule: it hides an address rather than a key.
|
||
#
|
||
# The expressions are CONTEXT BOUND. An unanchored version stripped a value wherever
|
||
# a key name appeared and damaged unrelated fields: the server name is free text
|
||
# allowing spaces and equals signs, so an AWG_SERVER_NAME line containing the text
|
||
# "PrivateKey = Office" lost both the value and its closing quote. Client names are
|
||
# protected from that by the ^[a-zA-Z0-9_-]+$ validation (which lives in
|
||
# manage_amneziawg.sh and awg_common.sh, not here), but a hand-edited #_Name may
|
||
# contain anything.
|
||
#
|
||
# Four contexts:
|
||
# 1. a configuration line AT START OF LINE, with an optional comment marker. The
|
||
# marker is needed NOT because awg would parse such a line: it discards it
|
||
# entirely (config_read_line truncates at the first hash BEFORE parsing). It is
|
||
# needed because the value physically sits in a file that gets pasted into a
|
||
# public issue. Masked TO END OF LINE: parsing strips whitespace beforehand, so
|
||
# a record like "PrivateKey = AA BB=" is valid and cutting at the first space
|
||
# would have left the tail of the key in the report.
|
||
# 2. an awg show label at start of line. HeaderProtectionKey is mandatory here:
|
||
# awg show prints it IN CLEAR TEXT (show.c: key() instead of masked_key()),
|
||
# unlike the private and preshared keys which it hides itself. This also
|
||
# settles WG_HIDE_KEYS=never.
|
||
# 3. "Line unrecognized: ..." - unanchored. awg prints the line to stderr ALREADY
|
||
# CLEANED (truncated at the hash, whitespace removed) and wrapped in a backtick
|
||
# and a quote. Hence the ".?" in the expression: it skips that backtick.
|
||
# DO NOT REMOVE ".?": without it the real line does not match at all.
|
||
# The error branch fires on ANY unrecognized line: a typo in a key name, a key
|
||
# in the wrong section (PrivateKey inside [Peer] goes to stderr in full), a key
|
||
# from another implementation. A hand-added third-line parameter is one case
|
||
# among them, not the only one.
|
||
# The name comes from the full list of parameters awg parses (key_match in
|
||
# src/config.c), not only the three keys: the name of a parameter outside its
|
||
# section (RandomTrailers in [Peer], ListenPort on old tools) is needed for
|
||
# diagnosis, while the value is always hidden, because an unrecognized line
|
||
# can hold anything.
|
||
# 3b. a "Line unrecognized: ..." line that rule 3 did not mask is hidden in full.
|
||
# awg strips whitespace from the line, so ordinary hand-edit mistakes give a
|
||
# line with no known name and equals sign before the value: a missing "="
|
||
# (`PrivateKeyVALUE'), a typo in the name (`PrivatKey=...'), a colon instead of
|
||
# "=", the tail of a key wrapped onto the next line by an editor. A "letters,
|
||
# then =" heuristic for the name cannot be used here: a base64 key tail itself
|
||
# ends with "=" and would pass for a name.
|
||
# 4. "Key is not the correct length or format: ..." - same place, but the message
|
||
# carries NO key name at all, so it cannot be matched by one.
|
||
# 4b. "Unable to parse IP address: `...'" for a value with no dot and no colon,
|
||
# that is, certainly not an address. This is how awg answers an AllowedIPs
|
||
# that received a key: it prints the part BEFORE the first "/", and that part
|
||
# can be shorter than 20 characters, so rule 5 does not reach it.
|
||
# 5. a value in a backtick and quote that looks like a key as a whole (base64
|
||
# characters, at least 20 long). amneziawg-tools repeat the value this way in
|
||
# about ten messages (`Unable to parse %s: `%s'', addresses, endpoint, fwmark),
|
||
# and a key that landed in another field would otherwise go out in clear text.
|
||
# Addresses, ports, endpoints and numbers contain dots or colons or are
|
||
# shorter, so they stay visible and useful for diagnosis.
|
||
# NOTE: the anchors on 1 and 2 mean those forms are NOT caught in the Service Status
|
||
# section, where systemctl status adds its own timestamped prefix. The journal
|
||
# section does not suffer from this: journalctl is called there with --output=cat,
|
||
# that is, without a prefix.
|
||
# Case insensitivity (flag I) because config parsing is case insensitive too
|
||
# (strncasecmp).
|
||
#
|
||
# FIVE UPSTREAM STRING LITERALS carry the whole thing: "private key:",
|
||
# "header protection key:", "Line unrecognized:", "Key is not the correct length or
|
||
# format:", "Unable to parse IP address:", plus the `value' form of repeated
|
||
# values (rule 5) and the parameter name list of rule 3. Checked against
|
||
# amneziawg-tools ee0f0a9 (src/config.c, src/show.c) on 25 aug 2026, the value form
|
||
# against tag v3.1.20260812 on 17 sep 2026. If any of them is reworded upstream the filter silently stops
|
||
# matching, and the tests stay green because they hard-code the same strings.
|
||
# RE-CHECK when bumping amneziawg-tools.
|
||
_mask_report_secrets() {
|
||
sed -E \
|
||
-e 's/^([[:space:]]*#?[[:space:]]*(PrivateKey|PresharedKey|HeaderProtectionKey)[[:space:]]*=[[:space:]]*).*/\1[HIDDEN]/I' \
|
||
-e 's/^([[:space:]]*(private key|preshared key|header protection key)[[:space:]]*:[[:space:]]*).*/\1(hidden)/I' \
|
||
-e 's/(Line unrecognized:[[:space:]]*.?(PrivateKey|PresharedKey|HeaderProtectionKey|PublicKey|ListenPort|FwMark|Jc|Jmin|Jmax|S[1-4]|H[1-4]|I[1-5]|ContentPaddingAddition|RekeyAfterTime|RekeyTimeout|RejectAfterTime|KeepaliveTimeout|MaxHandshakeAttempts|RandomTrailers|DisableCookies|Endpoint|AllowedIPs|PersistentKeepalive|AdvancedSecurity)[[:space:]]*=[[:space:]]*).*/\1[HIDDEN]/I' \
|
||
-e '/\[HIDDEN\]/!s/(Line unrecognized:[[:space:]]*).*/\1[HIDDEN]/I' \
|
||
-e 's/(Key is not the correct length or format:[[:space:]]*).*/\1[HIDDEN]/I' \
|
||
-e "s|(Unable to parse IP address:[[:space:]]*\`)[^.:']*'|\1[HIDDEN]'|I" \
|
||
-e "s|\`[A-Za-z0-9+/]{20,}={0,2}'|\`[HIDDEN]'|g"
|
||
}
|
||
|
||
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 _diag_umask
|
||
rf="$AWG_DIR/diag_$(date +%F_%T).txt"
|
||
# The file is created by redirection BEFORE chmod, so its mode at creation
|
||
# time comes from the umask. Same idiom as for keys in awg_common.sh: narrow
|
||
# the permissions up front instead of repairing them afterwards. --diagnostic
|
||
# runs BEFORE secure_files, so /root/awg may exist with default permissions
|
||
# and the 0644 window is genuinely reachable.
|
||
_diag_umask=$(umask); umask 077
|
||
{
|
||
echo "=== AMNEZIAWG 2.0 DIAGNOSTIC REPORT ==="
|
||
echo ""
|
||
echo "!!! WARNING: PrivateKey, PresharedKey and HeaderProtectionKey values are"
|
||
echo "!!! stripped wherever they are labelled by name or by an awg show label."
|
||
echo "!!! What stays in the report: IP addresses, ports, routes, obfuscation"
|
||
echo "!!! parameters, client names and public keys. The server endpoint is"
|
||
echo "!!! additionally hidden. Review what of that you do not want to be"
|
||
echo "!!! public before posting to a public issue."
|
||
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) ---"
|
||
# Second enforcement point, SAME function: one implementation, two places.
|
||
# That is not a second source of truth, and if the outer filter is ever
|
||
# detached from the block, the riskiest raw input stays covered.
|
||
if [[ -f "$SERVER_CONF_FILE" ]]; then
|
||
_mask_report_secrets < "$SERVER_CONF_FILE" || echo "ERROR: could not read or filter $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 ---"
|
||
# 🔴 The timeout is mandatory: oversized I1-I5 make the interface dump
|
||
# loop (amneziawg-linux-kernel-module#228), and this is precisely the
|
||
# command the documentation asks people to attach to a report. Without
|
||
# a bound, someone hitting the defect could not even collect a report
|
||
# about it.
|
||
timeout 10 awg show 2>/dev/null || echo "awg show failed or timed out"
|
||
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 ==="
|
||
} | _mask_report_secrets > "$rf" || die "Report write error: $rf"
|
||
umask "$_diag_umask"
|
||
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=""
|
||
# The generation marker is reset the same way: `AWG_PROTOCOL=3.1 bash install.sh`
|
||
# must not mark an install as third-line behind the file's back.
|
||
AWG_PROTOCOL=""
|
||
|
||
# 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
|
||
# ALLOWED_IPS_MODE from the config: strictly 1|2|3. It was the only routing
|
||
# key with no validation, and a garbage value reached
|
||
# _apply_isolation_to_allowed_ips, where the comparisons against "1" and "2"
|
||
# give mode-3 semantics: with isolation off the tunnel subnet was appended
|
||
# to 0.0.0.0/0 and the client profile stopped being the exact pair
|
||
# 0.0.0.0/0, ::/0 - the very thing the default was moved for. The check
|
||
# lives HERE and not inside configure_routing_mode: that one runs only when
|
||
# the route list is empty, so a config with a garbage mode AND a non-empty
|
||
# list never reaches it.
|
||
case "${ALLOWED_IPS_MODE:-}" in
|
||
""|default|1|2|3) : ;;
|
||
*)
|
||
die "ALLOWED_IPS_MODE='$ALLOWED_IPS_MODE' in $CONFIG_FILE is invalid (1, 2 or 3 allowed). Fix the value or pass --route-all / --route-amnezia / --route-custom=NETS."
|
||
;;
|
||
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 installation's generation: from the marker in the init, 2.0 when absent.
|
||
# Read HERE once from the file; --force (with or without presets, --no-cps,
|
||
# --jc*) and a run while the service is down take the value from the file
|
||
# and write it back as is (tests/test_protocol_marker.bats). Any future CLI
|
||
# override must come BELOW this line and pass the same awg_installed_protocol
|
||
# check.
|
||
local _proto_raw="${AWG_PROTOCOL:-}"
|
||
AWG_PROTOCOL=$(awg_installed_protocol "$CONFIG_FILE") || die "The generation marker AWG_PROTOCOL in $CONFIG_FILE cannot be read (value '${_proto_raw}'; 2.0 and 3.1 are allowed, the line must look like export AWG_PROTOCOL='2.0'; found: $(grep -niE '^[[:space:]]*(export[[:space:]]+)?AWG_PROTOCOL' "$CONFIG_FILE" 2>/dev/null | head -3 | tr '\n' ' ')). Fix the file by hand, stating the generation the server actually runs."
|
||
# The third-line path is closed in this installer version: the 3.1 generator
|
||
# and render already exist, but without downgrade and the lifecycle of both
|
||
# generations a 3.1 install would be left with no way back. Refuse until the
|
||
# path opens (phase 5).
|
||
if [[ "$AWG_PROTOCOL" == "3.1" ]]; then
|
||
die "This installation is marked as AmneziaWG 3.1 (AWG_PROTOCOL in $CONFIG_FILE), and this installer version only supports 2.0. Do not run it on top of a server marked as third line."
|
||
fi
|
||
|
||
# The installation generation and, when the third line is requested, the
|
||
# environment gate. The body lives in _awg31_resolve_protocol - see there.
|
||
_awg31_resolve_protocol "$config_exists"
|
||
|
||
# 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 routing mode - for the change warning when no flag was passed.
|
||
# A flag is caught through CLI_ROUTING_MODE, but the mode also changes without
|
||
# one: a key-less config has it inferred, and an unrecognised value normalised.
|
||
# A legacy config with no key yields 'default', which never equals 1|2|3, so the
|
||
# legacy -> current default transition does get the warning.
|
||
_cfg_allowed_ips_mode=""
|
||
if [[ "$config_exists" -eq 1 ]]; then _cfg_allowed_ips_mode="${ALLOWED_IPS_MODE:-default}"; 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.
|
||
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
|
||
# A config WITHOUT the ALLOWED_IPS_MODE key but WITH a list is an install older
|
||
# than the key itself. Whose list it is we do not know - it may well be a custom
|
||
# one. But it must not be labelled mode 1, because the mode decides what happens
|
||
# to the tunnel subnet: with isolation off, mode 1 does not append it (0.0.0.0/0
|
||
# already covers it) while a list-based mode does - and the clients would stop
|
||
# seeing each other. Hence a CONSERVATIVE inference: a non-empty list that is not
|
||
# 0.0.0.0/0 counts as the list-based mode. That is exactly what such a config used
|
||
# to get, back when every key-less config became mode 2.
|
||
# ⚠️ The second branch does differ from the former behaviour, deliberately: a
|
||
# key-less config whose list is EXACTLY 0.0.0.0/0 used to be labelled mode 2 as
|
||
# well and is now labelled 1. Clients still get the same 0.0.0.0/0; what changes
|
||
# is that with isolation off the tunnel subnet is no longer appended - the zero
|
||
# route already covers it.
|
||
if [[ "$ALLOWED_IPS_MODE" == "default" ]]; then
|
||
if [[ -n "$ALLOWED_IPS" && "${ALLOWED_IPS//[[:space:]]/}" != "0.0.0.0/0" ]]; then
|
||
ALLOWED_IPS_MODE=2
|
||
else
|
||
ALLOWED_IPS_MODE=1
|
||
fi
|
||
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."
|
||
# Installations made before September 2026 carry an I1 of random bytes.
|
||
# On some cellular networks such a packet is dropped together with the
|
||
# handshake packet and the tunnel never comes up, even though the server
|
||
# shows the peer. Regenerating it here silently is NOT an option: that
|
||
# would change the parameters of a working installation without asking.
|
||
# So it is simply said out loud, and only to those it concerns.
|
||
if [[ "${AWG_I1:-}" =~ ^\<r\ [0-9]+\>$ ]]; then
|
||
log_warn "I1 here is random bytes (${AWG_I1}): that is what versions before September 2026 wrote."
|
||
log_warn "If the tunnel does not come up on a cellular network, see ADVANCED.en.md, section \"The handshake never completes on cellular\"."
|
||
fi
|
||
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}
|
||
export AWG_CPA='${AWG_CPA:-}'
|
||
# Protocol generation of this installation. Do not edit by hand: a different
|
||
# generation requires reissuing every client profile. A missing field reads as 2.0.
|
||
export AWG_PROTOCOL='${AWG_PROTOCOL}'
|
||
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 AWG_PROTOCOL
|
||
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" ]] \
|
||
|| [[ "$_cfg_allowed_ips_mode" != "$ALLOWED_IPS_MODE" ]]; }; 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 "$_cfg_allowed_ips_mode" && "$_cfg_allowed_ips_mode" != "$ALLOWED_IPS_MODE" ]] \
|
||
|| [[ -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
|
||
|
||
# The meta-packages that udev, initramfs-tools and the network stack hang
|
||
# from may have been orphaned: the cleanup above does that, but they can
|
||
# equally arrive orphaned with the image itself. So this block runs
|
||
# UNCONDITIONALLY, --no-tweaks and --keep-packages included, when no cleanup
|
||
# happened at all. Mark such packages manual again. The upgrade below can no
|
||
# longer drop them (the command was changed over Issue #223: apt-get
|
||
# upgrade has no such right), but the "no longer required" status stays a
|
||
# trap for later: any subsequent apt operation is free to act on it. Ours
|
||
# included - install_packages below calls apt install without --no-remove -
|
||
# and so is any autoremove the user runs afterwards.
|
||
# Manual rather than hold on purpose: a hold would block the upgrade, and
|
||
# upgrading them is exactly what we want; manual only clears the status.
|
||
# ⚠️ The marking guarantees nothing: apt may drop a manual package too while
|
||
# resolving dependencies. The guarantee is the _verify_boot_critical check
|
||
# before the reboot.
|
||
#
|
||
# This block sits AFTER the dpkg repair above, and that is not cosmetic: the
|
||
# snapshot is built by asking dpkg. A locked database still answers fine, but
|
||
# an unreachable or damaged one answers empty for everything at once, and the
|
||
# protection would then switch itself off silently along with the
|
||
# post-upgrade check, on exactly the machines where the defect bites. The
|
||
# self-test below catches that case too.
|
||
_dpkg_usable || die "dpkg does not answer, and without it there is no way to tell whether udev and initramfs-tools survive the upgrade (Issue #223). Run: dpkg --configure -a; apt-get check - then start the installer again."
|
||
# Self-test of the predicate. _dpkg_usable only answers for dpkg, while the
|
||
# predicate also leans on awk: a broken awk would return an empty status,
|
||
# that is "absent" for everything at once, and the guard would switch itself
|
||
# off in silence.
|
||
_pkg_present dpkg || die "Could not determine package state (dpkg-query or awk do not behave as expected). Without it there is no way to make sure the upgrade will not take udev away (Issue #223)."
|
||
local critical_before
|
||
critical_before="$(_boot_critical_snapshot)"
|
||
if [[ -n "$critical_before" ]]; then
|
||
log "Protected from removal: $(printf '%s' "$critical_before" | tr '\n' ' ')"
|
||
# udev is present on virtually every Ubuntu and Debian server. Its
|
||
# absence means not "nothing to protect" but that the machine may
|
||
# already be damaged, by an interrupted earlier run for instance.
|
||
_pkg_installed_ok udev \
|
||
|| log_warn "udev is not installed or not configured. That is abnormal for Ubuntu and Debian: check dpkg-query -W udev, the server may fail to boot."
|
||
# Unquoted on purpose: the list arrives as newline-separated names and
|
||
# splitting it into arguments is exactly what is wanted here.
|
||
apt-mark manual $critical_before >/dev/null 2>&1 \
|
||
|| log_warn "Failed to restore the manual mark on: $(printf '%s' "$critical_before" | tr '\n' ' ') - they stay flagged \"no longer required\", the check before the reboot will catch that."
|
||
else
|
||
log_warn "Not a single boot-critical package was found installed. That is unusual for Ubuntu and Debian; check: dpkg-query -W udev"
|
||
fi
|
||
log "Updating system..."
|
||
# Deliberately upgrade --with-new-pkgs rather than full-upgrade. The
|
||
# difference is not cosmetic: full-upgrade is by definition allowed to
|
||
# REMOVE installed packages to resolve dependencies, and in Issue #223 it
|
||
# used that right - it took udev away and the server stopped booting.
|
||
# upgrade has no such right at all: a package that cannot be upgraded
|
||
# without removing a neighbour is simply left at its current version.
|
||
# --with-new-pkgs keeps the only reason full-upgrade was needed here: a new
|
||
# kernel arrives as a package with a NEW name
|
||
# (linux-image-6.8.0-NNN-generic), and a plain upgrade refuses to install
|
||
# new names.
|
||
# The trade is deliberate: a VPN server does not need systemd to be the
|
||
# freshest, it needs the machine to boot. The pre-reboot verification below
|
||
# stays as the second line of defence.
|
||
if ! DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --with-new-pkgs; then
|
||
local _lock_holder
|
||
_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 "Update failed, fixing dpkg and retrying..."
|
||
DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true
|
||
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --with-new-pkgs || _die_upgrade_failed
|
||
fi
|
||
_warn_kept_back
|
||
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
|
||
|
||
# Checked as the very last action of the step: nothing capable of
|
||
# removing a package runs after this line.
|
||
_verify_boot_critical "$critical_before"
|
||
|
||
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
|
||
}
|
||
|
||
# ==============================================================================
|
||
# Amnezia PPA key
|
||
# ==============================================================================
|
||
|
||
# The public key that signs the indexes of ppa.launchpadcontent.net/amnezia/ppa.
|
||
# Embedded in the installer: it used to be downloaded from keyserver.ubuntu.com,
|
||
# and a server that could not reach that host failed step 2 even with the PPA
|
||
# up (D#292). RSA 4096, no expiry. The fingerprint was checked against the
|
||
# signing_key_fingerprint field of the PPA page on Launchpad and against the
|
||
# signatures of the live PPA indexes. If Amnezia changes the key, a new
|
||
# installer release is needed (as it already was because of the pin).
|
||
_amnezia_ppa_key_armored() {
|
||
cat <<'PPAKEY'
|
||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||
|
||
xsFNBGV0UhsBEAC33rMndHSN/k+u7gcZbh9/FjgYfGltQAtVe2QDxzn7UV+k/ChX
|
||
OrYRw6Izw/DrhaapkNCThK2jwJE64e0NjboLH7UrrmSJLXMfOlDFbyGJVRA+1sTB
|
||
lo7kKHY0xiZ1CHDzjKNV3czbesu80A9nuTZYyWHEn9ax6wsqKG3N8SvzQkUrIOVD
|
||
2wZjh0p273CCEGkBnax1ghAV3MF8OrsPU6FRJ+ZakzKbu54g68xoV+2813YECme0
|
||
JKsWfUUe/1uEJOXCvuACURSxnYr0sihJd8QI/jHSGlfeq72e5MflFEOrnu5xaDSJ
|
||
r2W5lvUetG7EGSxtNKd7Jm/KhUV04g7arA0qydRjRToW3QqyzG7VB2nXKz3AOBYN
|
||
earWAuBcTkfPvRVchxbjiYonKZA5tIlVrpawMZsdxKvYwl6LVnpBcccFWPhpudfy
|
||
4TpCqCxRoAanOCvSirI3/y7TcZMBw643SaxXi1ifGeg6eyMzrLtP3CeonKBHGzrt
|
||
1eeKGtEw/PFN4RmwpBePxi+uj0CoTD6zjCQa3c8EeB4Qz7tt6PnpibxdtZE8sBdd
|
||
51wSA/fPGi2tFph8IVAsws7oxcQxZYl8CyncKDLcoR4dxVHYdFEDDf1GjRjoQ3Ai
|
||
nD7fxD5qYzExe50DBVpuUbWcAiGICNxfvzQtUSRRtMoSHDcvzsy03KC6VwARAQAB
|
||
zR5MYXVuY2hwYWQgUFBBIGZvciBJdXJpaSBFZ29yb3bCwY4EEwEKADgWIQR1yd1y
|
||
x5mHDjEFQuJBZvLCVykIKAUCZXRSGwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIX
|
||
gAAKCRBBZvLCVykIKBu5D/9akmHCHlUqm2RTTBeTMbLNGc0l6YugpPaCM6vz0O9k
|
||
BFP5PfRaNSRzyF7wHFHNY3JUHcor28my1fD8AE4+C3PwXz8tVYLh57UUsp4wjqHY
|
||
+MTl/1ngDViPGD3PRjB8ZlO+19yerfplZv1Jaw7FZZv2BZOAXb+ddqUG4EmlzOnC
|
||
EhcSDdFrzEBB3RGthjIb3QkKWKGbELDiMfogmsO9BE139Raiw23blagDrbnWsG4j
|
||
ReZeu3atjG6AW8eL7m+i7bKKshD2CYVMznI5cYGLMKo9w7sb33uylPj1Vx9O7joP
|
||
2GFf2rTpCY8wgzk7i1RqsipJ80u1/DY91Xdizv3f2BBe6UY7qHKoK00O11J0y8yU
|
||
is2Asycy33Wy51pf6rCFUBLQu+c1fEypHF6jqANmQwaH7pPBliy4gGWvrVggzV4m
|
||
xv7SnRiMi4PFyVwjKWm8dmuMxi/B9s++VG/ed+5aYgJYL58MohG3MUI/L58eitSC
|
||
DDcQ1iAnBmawnGMKPqzMgRFB3OU3wDwfh7LNVvQqWpQ4q7pr4Cq1CvZvGoggXWDo
|
||
1/vylPsRmiiuNetfsoVYmrkgtj1om07m5Xp1v4SyXJH11c3dc/xfMmn/4RlMWIpq
|
||
86IsOjpr3avsw3FVUNCgD5Wf5+rHG+7gNmM6Cm/F8MDfAnnRmsw4h6hgvcJNQT5D
|
||
ig==
|
||
=OY2W
|
||
-----END PGP PUBLIC KEY BLOCK-----
|
||
PPAKEY
|
||
}
|
||
|
||
# Puts the PPA key into keyring $1. Atomic: a temp file next to the target,
|
||
# a fingerprint check, then mv - a half-written or foreign key never lands
|
||
# on the target path.
|
||
install_amnezia_ppa_keyring() {
|
||
local keyring_file="$1" kf_tmp keys n_keys got_fpr gpg_err
|
||
# Checked against the FULL 40-character fingerprint and for exactly one
|
||
# key. The key is embedded, but the check stays: it catches a damaged,
|
||
# swapped or appended block in an edit. apt would trust an extra key in
|
||
# the keyring for the PPA, so a matching first fingerprint is not enough.
|
||
local ppa_key_fpr="75C9DD72C799870E310542E24166F2C257290828"
|
||
kf_tmp=$(mktemp -p "$(dirname "$keyring_file")" ".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.
|
||
if ! _amnezia_ppa_key_armored | 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
|
||
# gpg's stderr is captured: on a fresh server gpg reports the keybox and
|
||
# trustdb it creates, which a newcomer would take for an error. On a
|
||
# refusal the gpg text goes into the die message, and so into the log.
|
||
if ! gpg_err=$(gpg --batch --no-tty --show-keys --with-colons "$kf_tmp" 2>&1 >"${kf_tmp}.keys"); then
|
||
rm -f "$kf_tmp" "${kf_tmp}.keys" 2>/dev/null
|
||
die "gpg could not read the embedded Amnezia PPA key: ${gpg_err:0:500}. Check that the gpg package is installed and works."
|
||
fi
|
||
keys=$(<"${kf_tmp}.keys")
|
||
rm -f "${kf_tmp}.keys" 2>/dev/null
|
||
n_keys=$(grep -c '^pub:' <<< "$keys")
|
||
got_fpr=$(awk -F: '/^fpr:/{print $10; exit}' <<< "$keys")
|
||
if [[ "$n_keys" != 1 || "$got_fpr" != "$ppa_key_fpr" ]]; then
|
||
rm -f "$kf_tmp" 2>/dev/null
|
||
die "Amnezia PPA GPG key failed the fingerprint check: expected exactly one key ${ppa_key_fpr}, got ${n_keys:-0} key(s), first: '${got_fpr:-<none>}'. The installer file is damaged or modified: download it again from the release page and check its signature."
|
||
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."; }
|
||
}
|
||
|
||
# ==============================================================================
|
||
# 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). The step 1
|
||
# upgrade may have 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" || die "Failed to create directory $keyring_dir."
|
||
log "Importing the Amnezia PPA GPG key (embedded in the installer)..."
|
||
install_amnezia_ppa_keyring "$keyring_file"
|
||
|
||
# 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).
|
||
local _hold_out=""
|
||
_hold_out=$(apt-mark hold amneziawg-dkms amneziawg 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 ! _awg_pkg_held amneziawg-dkms; then
|
||
_awg_hold_refusal_log "$_hold_out"
|
||
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+.
|
||
local _hold_out=""
|
||
_hold_out=$(apt-mark hold amneziawg-dkms amneziawg 2>&1) || true
|
||
if ! _awg_pkg_held amneziawg-dkms; then
|
||
_awg_hold_refusal_log "$_hold_out"
|
||
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)."
|
||
_boot_critical_guard
|
||
# 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 LTS, Ubuntu 26.04 or Debian 13, where the module comes 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
|
||
|
||
# Step 2 installs packages and reboots the machine as well, so it needs
|
||
# the same line of defence.
|
||
_boot_critical_guard
|
||
|
||
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
|
||
|
||
# ── Environment gate, stage post ─────────────────────────────────────────
|
||
# This asks what step 0 could not know: whether the tools that ARRIVED
|
||
# and the loaded module understand third-line parameters. There are two reboots between steps 0
|
||
# and 3, so the generation comes from the marker read afresh from disk, not
|
||
# from the memory of an earlier run.
|
||
#
|
||
# ⚠️ THE BRANCH COMES ALIVE IN PHASE 5, when the 3.1 path opens to people.
|
||
# Today a 3.1 marker never reaches here: the flag is refused by stage pre at
|
||
# step 0, and a config carrying a 3.1 marker is refused there too. It stands
|
||
# here in advance for one reason: forgetting it in phase 5 would mean
|
||
# shipping the third line on tools that cannot parse it, and hearing about it
|
||
# from a user whose handshake silently never happens. A test calls it
|
||
# directly with the marker injected, so the branch cannot rot unnoticed.
|
||
# ⚠️ The 2.0 default here is the one place in this change where absence
|
||
# reads as an answer, and it is safe for two reasons at once:
|
||
# initialize_setup runs before EVERY step and either sets the marker or
|
||
# dies, so an empty one never reaches here; and if it did, the mistake
|
||
# would lead to 2.0, the generation that works everywhere. The opposite
|
||
# default would be permission. Noted by review of this pull request.
|
||
if [[ "${AWG_PROTOCOL:-2.0}" == "3.1" ]]; then
|
||
local _awg31_blocker _awg31_rc
|
||
_awg31_blocker=$(awg31_environment_blocker post); _awg31_rc=$?
|
||
if [[ -n "$_awg31_blocker" ]]; then
|
||
log "3.1 environment gate (post): reason code '${_awg31_blocker}'."
|
||
die "$(_awg31_blocker_message "$_awg31_blocker")"
|
||
fi
|
||
# The same fail-closed rule as at step 0: the silence of a gate that
|
||
# crashed is not permission.
|
||
if (( _awg31_rc != 0 )); then
|
||
die "The environment gate could not check the tools and the module for the AmneziaWG 3.1 profile (exit code ${_awg31_rc}, no reason given). The installation stops: without an answer we do not ship the third line."
|
||
fi
|
||
log "3.1 environment gate (post) passed."
|
||
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_undo_31 <reason> <server config backup or empty> <client>...
|
||
# A refusal of step 6 on a 3.1 install AFTER the server config was rewritten. A
|
||
# 3.1 profile is only usable as a complete set, so nothing is left half done:
|
||
# the files of the clients made by THIS attempt are removed, the server config
|
||
# comes back from its backup, and on a first install (no backup) it is removed.
|
||
# What stays is the server keys and server_hpk.key (including ones this attempt
|
||
# created: a rerun of step 6 takes the same ones) and the backup file itself.
|
||
# The config comes back through a temporary file (awg_mktemp, a fresh name) and
|
||
# mv: a copy cut off halfway would otherwise leave a fragment in place of it.
|
||
# The final message says whether the undo worked: if any of its steps failed,
|
||
# the folder is inconsistent (for example, the config was not restored while the
|
||
# client files are already gone), and the install must not be rerun without a
|
||
# manual check.
|
||
_step6_undo_31() {
|
||
local why="$1" bak="$2" name tmp failed=0
|
||
shift 2
|
||
for name in "$@"; do
|
||
if ! _remove_client_files "$name"; then
|
||
log_error "The files of client '$name' were not all removed: check $AWG_DIR and $KEYS_DIR"
|
||
failed=1
|
||
fi
|
||
done
|
||
if [[ -n "$bak" ]]; then
|
||
if ! { tmp=$(awg_mktemp "$(dirname "$SERVER_CONF_FILE")") && cp -p "$bak" "$tmp" && mv -f "$tmp" "$SERVER_CONF_FILE"; }; then
|
||
[[ -n "${tmp:-}" ]] && rm -f "$tmp"
|
||
log_error "The server config was not restored from the backup $bak: restore it by hand"
|
||
failed=1
|
||
fi
|
||
elif ! rm -f "$SERVER_CONF_FILE"; then
|
||
log_error "The server config $SERVER_CONF_FILE was not removed: remove it by hand"
|
||
failed=1
|
||
fi
|
||
if (( failed )); then
|
||
die "${why}: the 3.1 install stopped, but the undo did NOT complete (errors above). Check $AWG_DIR and the server config by hand before running the install again."
|
||
fi
|
||
die "${why}: the 3.1 install was undone, the files of this attempt are removed, the server keys are kept for a rerun."
|
||
}
|
||
|
||
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"
|
||
|
||
# 3.1 install: a profile is only usable as a complete set. The tools for the
|
||
# set and client leftovers are checked BEFORE the first change, including
|
||
# before the server keys are generated; the header protection key, the config
|
||
# backup and the render itself are checked below, after that. Default clients
|
||
# already in the server config are carried over and not recreated, so their
|
||
# files are not leftovers; only the clients this attempt will create are
|
||
# checked for leftovers. On 2.0 the step takes its old path, and so it does
|
||
# with an unreadable marker (without a key; with one, it stops at the key check).
|
||
local gen31=0 client_name new_clients=() created=()
|
||
if [[ "$(_awg_generation_from_init "$CONFIG_FILE" 2>/dev/null)" == "3.1" ]]; then
|
||
gen31=1
|
||
fi
|
||
for client_name in my_phone my_laptop; do
|
||
grep -qxF "#_Name = ${client_name}" "$SERVER_CONF_FILE" 2>/dev/null || new_clients+=("$client_name")
|
||
done
|
||
if (( gen31 )); then
|
||
_awg31_require_client_tools || die "The 3.1 install stopped before any change: tools for the client set are missing."
|
||
_awg31_refuse_client_leftovers "${new_clients[@]}" || die "The 3.1 install stopped before any change: files of earlier clients are left."
|
||
fi
|
||
|
||
# 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
|
||
|
||
# Header protection key: created on a first 3.1 install, restored from the config
|
||
# when the file is lost, and on a mismatch the install stops BEFORE the server
|
||
# config is rewritten. On 2.0 without a key the check does nothing.
|
||
awg_hpk_ensure install || die "The header protection key is inconsistent, the server config was not rewritten."
|
||
|
||
# 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)"
|
||
if ! cp "$SERVER_CONF_FILE" "$s_bak"; then
|
||
# On 3.1 the undo restores the config from this backup: no backup, no rewrite.
|
||
(( gen31 )) && die "Backup error $s_bak: on a 3.1 install the server config is not rewritten without a backup."
|
||
log_warn "Backup error $s_bak"
|
||
fi
|
||
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..."
|
||
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'..."
|
||
if (( gen31 )); then
|
||
# The leftovers check proved absent files only for new_clients. A name
|
||
# outside that list was in the old config but the render did not carry
|
||
# it over: creating that client would later have the undo remove
|
||
# files that existed before the attempt.
|
||
if [[ " ${new_clients[*]} " != *" $client_name "* ]]; then
|
||
_step6_undo_31 "Client '$client_name' was in the old server config but was not carried into the new one" "${s_bak:-}" "${created[@]}"
|
||
fi
|
||
created+=("$client_name")
|
||
generate_client "$client_name" || _step6_undo_31 "Client '$client_name' was not created" "${s_bak:-}" "${created[@]}"
|
||
else
|
||
generate_client "$client_name" || log_warn "Client creation error '$client_name'"
|
||
fi
|
||
fi
|
||
done
|
||
|
||
# On 3.1 generate_client does not treat a failed QR code or vpn:// link as an
|
||
# error, so the set is checked as a whole for EVERY default client in the
|
||
# config, not only the ones created now: after a step 6 cut off by a signal or
|
||
# a crash the config may carry a client without files, and a rerun would
|
||
# accept it quietly.
|
||
if (( gen31 )); then
|
||
for client_name in my_phone my_laptop; do
|
||
grep -qxF "#_Name = ${client_name}" "$SERVER_CONF_FILE" 2>/dev/null || continue
|
||
awg_client_artifacts_check "$client_name" && continue
|
||
# Step 6 does not recreate a carried-over client, so a rerun alone will not
|
||
# fix it: the message names the way out.
|
||
if [[ " ${created[*]} " == *" $client_name "* ]]; then
|
||
_step6_undo_31 "The set of client '$client_name' is incomplete" "${s_bak:-}" "${created[@]}"
|
||
fi
|
||
_step6_undo_31 "The set of the existing client '$client_name' is incomplete, and a rerun will not fix it: regenerate the client (sudo bash $MANAGE_SCRIPT_PATH regen $client_name) or remove it (sudo bash $MANAGE_SCRIPT_PATH remove $client_name)" "${s_bak:-}" "${created[@]}"
|
||
done
|
||
fi
|
||
|
||
# Config validation
|
||
if (( gen31 )); then
|
||
validate_awg_config || _step6_undo_31 "The config failed validation" "${s_bak:-}" "${created[@]}"
|
||
else
|
||
validate_awg_config || log_warn "Config validation found issues."
|
||
fi
|
||
|
||
# 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..."
|
||
# The protected package snapshot goes too: it is needed between the steps,
|
||
# but surviving the install it would only grow stale. A stale name (a
|
||
# package renamed by a release upgrade) would stop the next install for no
|
||
# reason the user can see.
|
||
rm -f "$STATE_FILE" "${STATE_FILE}.lock" "$AWG_DIR/.boot_id_before_step2" \
|
||
"$BOOT_CRITICAL_SNAPSHOT_FILE" || 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
|