awg-node-update/install_awg.sh

3432 lines
122 KiB
Shell
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -euo pipefail
# ===== AmneziaWG 2.0 Installer =====
# Usage: bash <(curl -sL URL)
# bash install_awg.sh --upgrade # repair full server stack, keep config+peers
# bash install_awg.sh --core-upgrade # transactional AWG core update only
# bash install_awg.sh --wipe # full reinstall with new keys (destroys peers!)
# Published source (self-hosted, weekly updater on the nodes pulls this):
# https://git2.zapret.moe/zapretdiscordyoutube/awg-node-update
# After local edits, push install_awg.sh to that repo (branch main).
# Tested on: Ubuntu 22.04/24.04, Debian 12/13
AWG_GO_REPO="https://github.com/amnezia-vpn/amneziawg-go"
AWG_GO_TAG="${AWG_GO_TAG:-v3.1.20260812}"
AWG_GO_COMMIT="${AWG_GO_COMMIT:-1f50ad736ecca22a9bfc7b4606805ec9ca49fe48}"
AWG_TOOLS_REPO="https://github.com/amnezia-vpn/amneziawg-tools"
AWG_TOOLS_TAG="${AWG_TOOLS_TAG:-v3.1.20260812}"
AWG_TOOLS_COMMIT="${AWG_TOOLS_COMMIT:-ee0f0a9aa34ff0a0da4b3433b9512781cfe02843}"
AWG_KERNEL_REPO="https://github.com/amnezia-vpn/amneziawg-linux-kernel-module"
AWG_KERNEL_TAG="${AWG_KERNEL_TAG:-v3.1.20260812}"
AWG_KERNEL_COMMIT="${AWG_KERNEL_COMMIT:-46803204e7ec3b068199cd671143bec661d3fe21}"
AWG_KERNEL_DKMS_VERSION="${AWG_KERNEL_DKMS_VERSION:-3.1.20260812}"
# Reviewed assets are served from the self-hosted Forgejo (the old GitHub
# repo and gist were deleted, breaking the previous update channel).
AWG_REVIEWED_ASSET_BASE_URL="${AWG_REVIEWED_ASSET_BASE_URL:-https://git.zapret.moe/zapretdiscordyoutube/awg-node-update/raw/branch/main/assets/awg/3.1.20260812/linux-amd64}"
# SHA pins below still carry the 3.0.20260805 values: reviewed 3.1 binaries are
# not published yet, so the reviewed-bundle path fails closed and the installer
# builds the pinned Git commits locally. Refresh after publishing the assets.
AWG_GO_BINARY_SHA256="27ebd7320fdfba0a10d4d53ee985fa1dfe9bdfe3b6b7d9614b591338984b4521"
AWG_TOOLS_BINARY_SHA256="6e032d29bbbd2f36b2b0e74b1b1ad6d51072cf186bd64d38d56638276c9acbd4"
AWG_QUICK_BINARY_SHA256="f4bb0f5d63665ade87f0cb9f2185c43515cff09868637eb311f98f65a318722c"
AWG_KERNEL_ARCHIVE_SHA256="19adc91b6df64a44aad0b85cc90347e9428521efc63eb0ac4aea53073bf93eab"
AWG_INTERFACE="awg0"
AWG_PORT=443
AWG_SUBNET="10.8.0.0/16"
AWG_SERVER_IP="10.8.0.1/16"
AWG_CONFIG_DIR="/etc/amnezia/amneziawg"
AWG_CONFIG_FILE="${AWG_CONFIG_DIR}/${AWG_INTERFACE}.conf"
AWG_ENDPOINT_HOST="${AWG_ENDPOINT_HOST:-}"
AWG_SERVER_NAME="${AWG_SERVER_NAME:-AWG 2.0 Server}"
AWG_COUNTRY_CODE="${AWG_COUNTRY_CODE:-}"
AWG_LOCATION="${AWG_LOCATION:-}"
AWG_POLICY_CHAIN_PREFIX="AWG_POLICY_"
AWG_DNS_BLOCK_PORT="15353"
AWG_DNS_UPSTREAM_PROXY_PORT="5354"
AWG_DNSMASQ_BASE_CONFIG="/etc/dnsmasq.d/awg-abuse-protection.conf"
AWG_DNSMASQ_BLOCKLIST="/etc/dnsmasq.d/awg-torrent-blocklist.conf"
AWG_DNS_SERVICE_NAME="awg-dns-sinkhole.service"
AWG_DNS_SERVICE_FILE="/etc/systemd/system/${AWG_DNS_SERVICE_NAME}"
AWG_DNS_PROXY_SCRIPT="/usr/local/bin/awg-dns-tcp-proxy"
AWG_DNS_PROXY_SERVICE_NAME="awg-dns-tcp-proxy.service"
AWG_DNS_PROXY_SERVICE_FILE="/etc/systemd/system/${AWG_DNS_PROXY_SERVICE_NAME}"
AWG_AUDIT_LOG_DIR="/var/log/awg"
AWG_AUDIT_LOG_FILE="${AWG_AUDIT_LOG_DIR}/conntrack-events.jsonl"
AWG_AUDIT_LOGGER_SCRIPT="/usr/local/bin/awg-conntrack-logger"
AWG_AUDIT_QUERY_SCRIPT="/usr/local/bin/awg-abuse-find"
AWG_AUDIT_SERVICE_NAME="awg-conntrack-logger.service"
AWG_AUDIT_SERVICE_FILE="/etc/systemd/system/${AWG_AUDIT_SERVICE_NAME}"
AWG_AUDIT_LOGROTATE_FILE="/etc/vpnbot/logrotate.d/awg-conntrack-logger"
AWG_SYSTEMD_UNIT_FILE="${AWG_SYSTEMD_UNIT_FILE:-/etc/systemd/system/awg-quick@.service}"
AWG_UPDATE_SCRIPT="${AWG_UPDATE_SCRIPT:-/usr/local/sbin/vpnbot-awg-update}"
AWG_UPDATE_SERVICE_FILE="${AWG_UPDATE_SERVICE_FILE:-/etc/systemd/system/vpnbot-awg-update.service}"
AWG_UPDATE_TIMER_FILE="${AWG_UPDATE_TIMER_FILE:-/etc/systemd/system/vpnbot-awg-update.timer}"
AWG_UPDATE_STATE_DIR="${AWG_UPDATE_STATE_DIR:-/var/lib/vpnbot/awg-update}"
AWG_UPDATE_STATE_FILE="${AWG_UPDATE_STATE_FILE:-${AWG_UPDATE_STATE_DIR}/installed.env}"
AWG_UPDATE_LOCK_FILE="${AWG_UPDATE_LOCK_FILE:-/run/lock/vpnbot-awg-update.lock}"
AWG_HEADER_MIN_VALUE=5
AWG_HEADER_MAX_SAFE_VALUE=2147483647
AWG2_JC_MIN=0
AWG2_JC_MAX=10
AWG2_JMIN_MIN=64
AWG2_JMAX_MAX=1024
AWG2_S123_MAX=64
AWG2_S4_MAX=32
GO_VERSION="1.26.5"
GO_LINUX_AMD64_SHA256="5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053"
VPNBOT_NODE_INSTALLER_BASE_URL="${VPNBOT_NODE_INSTALLER_BASE_URL:-https://raw.githubusercontent.com/youtubediscord/vpnbot_node_installer/refs/heads/main}"
VPNBOT_EGRESS_INSTALLER_URL="${VPNBOT_EGRESS_INSTALLER_URL:-${VPNBOT_NODE_INSTALLER_BASE_URL}/scripts/install_egress_policy.sh}"
VPNBOT_NODE_DISK_HYGIENE_INSTALLER_URL="${VPNBOT_NODE_DISK_HYGIENE_INSTALLER_URL:-${VPNBOT_NODE_INSTALLER_BASE_URL}/scripts/install_node_disk_hygiene.sh}"
VPNBOT_AWG_LOG_HYGIENE_INSTALLER_URL="${VPNBOT_AWG_LOG_HYGIENE_INSTALLER_URL:-${VPNBOT_NODE_INSTALLER_BASE_URL}/scripts/install_awg_log_hygiene.sh}"
# Install mode: "fresh", "upgrade", "wipe"
INSTALL_MODE="fresh"
AWG_KERNEL_TARGET=""
AWG_KERNEL_READY=false
AWG_KERNEL_REBOOT_REQUIRED=false
AWG_PRINT_BUNDLE=false
AWG_REQUIRE_KERNEL="${AWG_REQUIRE_KERNEL:-true}"
AWG_BUILD_JOBS="${AWG_BUILD_JOBS:-2}"
AWG_UPDATE_TRANSACTION_DIR=""
AWG_UPDATE_TRANSACTION_COMMITTED=false
AWG_UPDATE_CUTOVER_STARTED=false
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[✗]${NC} $*" >&2; }
info() { echo -e "${CYAN}[i]${NC} $*"; }
install_global_egress_policy() {
local installer
installer="$(mktemp /tmp/vpnbot-egress-installer.XXXXXX)"
curl -fsSL --retry 3 --connect-timeout 10 \
-H 'Cache-Control: no-cache' \
"${VPNBOT_EGRESS_INSTALLER_URL}?ts=$(date +%s)" -o "${installer}"
chmod 0755 "${installer}"
bash "${installer}"
rm -f "${installer}"
systemctl restart vpnbot-egress-policy.service
log "Единая политика запрета российских направлений установлена"
}
install_node_disk_hygiene() {
local installer status
installer="$(mktemp /tmp/vpnbot-node-disk-hygiene-installer.XXXXXX)"
if ! curl -fsSL --retry 3 --connect-timeout 10 --max-time 90 \
-H 'Cache-Control: no-cache' \
"${VPNBOT_NODE_DISK_HYGIENE_INSTALLER_URL}?ts=$(date +%s)" -o "${installer}"; then
rm -f "${installer}"
return 1
fi
chmod 0755 "${installer}"
status=0
bash "${installer}" || status=$?
rm -f "${installer}"
return "${status}"
}
install_awg_log_hygiene() {
local installer status
installer="$(mktemp /tmp/vpnbot-awg-log-hygiene-installer.XXXXXX)"
if ! curl -fsSL --retry 3 --connect-timeout 10 --max-time 90 \
-H 'Cache-Control: no-cache' \
"${VPNBOT_AWG_LOG_HYGIENE_INSTALLER_URL}?ts=$(date +%s)" -o "${installer}"; then
rm -f "${installer}"
return 1
fi
chmod 0755 "${installer}"
status=0
AWG_AUDIT_LOG_FILE="${AWG_AUDIT_LOG_FILE}" \
VPNBOT_NODE_INSTALLER_BASE_URL="${VPNBOT_NODE_INSTALLER_BASE_URL}" \
bash "${installer}" || status=$?
rm -f "${installer}"
return "${status}"
}
trim_dot_domain() {
local value="${1:-}"
value="${value#.}"
value="${value%.}"
printf '%s' "${value}"
}
is_ipv4_literal() {
local value="${1:-}"
IPV4_LITERAL_TO_CHECK="${value}" python3 - <<'PY'
import ipaddress
import os
import sys
value = os.environ.get("IPV4_LITERAL_TO_CHECK", "").strip()
try:
ip = ipaddress.ip_address(value)
except ValueError:
sys.exit(1)
sys.exit(0 if ip.version == 4 else 1)
PY
}
wait_for_host_resolution_to_ipv4() {
local host="$1"
local expected_ipv4="$2"
local host_role="${3:-host}"
local timeout="${4:-180}"
local interval="${5:-5}"
if [[ -z "${host}" || -z "${expected_ipv4}" ]]; then
return 0
fi
if is_ipv4_literal "${host}"; then
if [[ "${host}" == "${expected_ipv4}" ]]; then
return 0
fi
err "${host_role^} ${host} does not match this server IP ${expected_ipv4}."
return 1
fi
DOMAIN_TO_CHECK="${host}" EXPECTED_IPV4="${expected_ipv4}" WAIT_TIMEOUT="${timeout}" WAIT_INTERVAL="${interval}" python3 - <<'PY'
import os
import socket
import sys
import time
domain = os.environ["DOMAIN_TO_CHECK"]
expected = os.environ["EXPECTED_IPV4"]
timeout = int(os.environ["WAIT_TIMEOUT"])
interval = max(1, int(os.environ["WAIT_INTERVAL"]))
deadline = time.time() + timeout
last_seen = []
while time.time() < deadline:
try:
infos = socket.getaddrinfo(domain, None, socket.AF_INET, socket.SOCK_STREAM)
addresses = sorted({item[4][0] for item in infos if item and item[4]})
last_seen = addresses
if expected in addresses:
sys.exit(0)
except Exception:
last_seen = []
time.sleep(interval)
msg = ", ".join(last_seen) if last_seen else "<nothing>"
print(f"Domain {domain} did not resolve to {expected} within {timeout}s. Last seen: {msg}", file=sys.stderr)
sys.exit(1)
PY
}
apt_dns_works() {
timeout 5 getent hosts deb.debian.org >/dev/null 2>&1 \
&& timeout 5 getent hosts security.debian.org >/dev/null 2>&1
}
apt_dns_tcp_works() {
python3 - <<'PY'
import socket
import struct
import sys
query = bytes.fromhex("123401000001000000000000036465620664656269616e036f72670000010001")
packet = struct.pack("!H", len(query)) + query
for upstream in (("1.1.1.1", 53), ("8.8.8.8", 53), ("9.9.9.9", 53)):
try:
with socket.create_connection(upstream, timeout=4.0) as sock:
sock.sendall(packet)
hdr = sock.recv(2)
if len(hdr) != 2:
continue
total = struct.unpack("!H", hdr)[0]
data = b""
while len(data) < total:
chunk = sock.recv(total - len(data))
if not chunk:
break
data += chunk
if data:
sys.exit(0)
except Exception:
continue
sys.exit(1)
PY
}
force_tcp_dns_for_apt() {
if grep -q '^options .*use-vc' /etc/resolv.conf 2>/dev/null; then
return 0
fi
cp /etc/resolv.conf "/etc/resolv.conf.install_awg.bak.$(date +%s)" 2>/dev/null || true
python3 - <<'PY'
from pathlib import Path
p = Path("/etc/resolv.conf")
text = p.read_text(encoding="utf-8")
if "options use-vc" not in text:
if not text.endswith("\n"):
text += "\n"
text += "options use-vc timeout:2 attempts:2\n"
p.write_text(text, encoding="utf-8")
PY
warn "Enabled TCP DNS fallback in /etc/resolv.conf for apt operations"
}
apt_http_mirrors_reachable() {
timeout 10 curl -I -4 --max-time 8 http://deb.debian.org/debian/ >/dev/null 2>&1 \
&& timeout 10 curl -I -4 --max-time 8 http://security.debian.org/ >/dev/null 2>&1
}
prepare_apt_networking() {
if apt_dns_works; then
return 0
fi
warn "Standard DNS resolution failed for Debian mirrors. Checking if TCP DNS fallback is possible..."
if apt_dns_tcp_works; then
force_tcp_dns_for_apt
if apt_dns_works; then
log "DNS resolution restored via TCP DNS fallback"
else
err "TCP DNS fallback was enabled but Debian mirror hostnames still do not resolve"
return 1
fi
else
err "Debian mirror hostnames do not resolve, and TCP DNS fallback is also unavailable"
return 1
fi
if ! apt_http_mirrors_reachable; then
err "Debian HTTP mirrors are unreachable from this host. apt would hang or fail here."
err "Check provider egress filtering, firewall policy, or switch to reachable mirrors before retrying."
return 1
fi
return 0
}
# ===== VALIDATION =====
validate_port() {
local port="$1"
if ! [[ "${port}" =~ ^[0-9]+$ ]] || [[ "${port}" -lt 1 || "${port}" -gt 65535 ]]; then
err "Invalid port: ${port} (must be 1-65535)"
return 1
fi
}
validate_positive_int() {
local val="$1" name="$2"
if ! [[ "${val}" =~ ^[0-9]+$ ]]; then
err "Invalid ${name}: '${val}' (must be a positive integer)"
return 1
fi
}
validate_non_negative_int() {
local val="$1" name="$2"
if ! [[ "${val}" =~ ^[0-9]+$ ]]; then
err "Invalid ${name}: '${val}' (must be a non-negative integer)"
return 1
fi
}
validate_header_range() {
local val="$1" name="$2"
local start end
# Accept "N" or "N-M" where N,M are integers and N <= M
if [[ "${val}" =~ ^[0-9]+$ ]]; then
start="${val}"
end="${val}"
elif [[ "${val}" =~ ^([0-9]+)-([0-9]+)$ ]]; then
start="${BASH_REMATCH[1]}"
end="${BASH_REMATCH[2]}"
if [[ "${start}" -gt "${end}" ]]; then
err "Invalid ${name}: start (${start}) > end (${end})"
return 1
fi
else
err "Invalid ${name}: '${val}' (must be N or N-M)"
return 1
fi
if [[ "${start}" -lt "${AWG_HEADER_MIN_VALUE}" || "${end}" -gt "${AWG_HEADER_MAX_SAFE_VALUE}" ]]; then
err "Invalid ${name}: '${val}' (must stay within ${AWG_HEADER_MIN_VALUE}-${AWG_HEADER_MAX_SAFE_VALUE})"
return 1
fi
return 0
}
parse_header_bounds() {
local val="$1" start_var="$2" end_var="$3"
local start end
if [[ "${val}" =~ ^([0-9]+)-([0-9]+)$ ]]; then
start="${BASH_REMATCH[1]}"
end="${BASH_REMATCH[2]}"
else
start="${val}"
end="${val}"
fi
printf -v "${start_var}" '%s' "${start}"
printf -v "${end_var}" '%s' "${end}"
}
validate_header_uniqueness() {
local names=("H1" "H2" "H3" "H4")
local values=("$@")
local i j start_i end_i start_j end_j
for ((i=0; i<${#values[@]}; i++)); do
parse_header_bounds "${values[i]}" start_i end_i
for ((j=i+1; j<${#values[@]}; j++)); do
parse_header_bounds "${values[j]}" start_j end_j
if [[ "${start_i}" -le "${end_j}" && "${start_j}" -le "${end_i}" ]]; then
err "Invalid headers: ${names[i]} (${values[i]}) overlaps ${names[j]} (${values[j]})"
return 1
fi
done
done
}
validate_packet_sizes_unique() {
local names=("S1" "S2" "S3" "S4")
local bases=(148 92 64 32)
local values=("$@")
local sizes=()
local i j
for ((i=0; i<${#values[@]}; i++)); do
sizes[i]=$((values[i] + bases[i]))
done
for ((i=0; i<${#sizes[@]}; i++)); do
for ((j=i+1; j<${#sizes[@]}; j++)); do
if [[ "${sizes[i]}" -eq "${sizes[j]}" ]]; then
err "Invalid packet sizes: ${names[i]} + ${bases[i]} equals ${names[j]} + ${bases[j]} (${sizes[i]})"
return 1
fi
done
done
}
validate_awg_semantics() {
local valid=true
if [[ "${Jmin}" -gt "${Jmax}" ]]; then
err "Jmin (${Jmin}) > Jmax (${Jmax})"
valid=false
fi
if [[ "${Jc}" -lt "${AWG2_JC_MIN}" || "${Jc}" -gt "${AWG2_JC_MAX}" ]]; then
err "Jc (${Jc}) must stay within ${AWG2_JC_MIN}-${AWG2_JC_MAX}"
valid=false
fi
if [[ "${Jmin}" -lt "${AWG2_JMIN_MIN}" || "${Jmax}" -gt "${AWG2_JMAX_MAX}" ]]; then
err "Jmin/Jmax must stay within ${AWG2_JMIN_MIN}-${AWG2_JMAX_MAX}"
valid=false
fi
for p in S1 S2 S3; do
if [[ "${!p}" -gt "${AWG2_S123_MAX}" ]]; then
err "${p} (${!p}) must stay within 0-${AWG2_S123_MAX}"
valid=false
fi
done
if [[ "${S4}" -gt "${AWG2_S4_MAX}" ]]; then
err "S4 (${S4}) must stay within 0-${AWG2_S4_MAX}"
valid=false
fi
validate_header_uniqueness "${H1}" "${H2}" "${H3}" "${H4}" || valid=false
validate_packet_sizes_unique "${S1}" "${S2}" "${S3}" "${S4}" || valid=false
[[ "${valid}" == true ]]
}
validate_current_awg_inputs() {
local valid=true
for p in Jc Jmin Jmax; do
validate_positive_int "${!p}" "${p}" || valid=false
done
for p in S1 S2 S3 S4; do
validate_non_negative_int "${!p}" "${p}" || valid=false
done
for h in H1 H2 H3 H4; do
validate_header_range "${!h}" "${h}" || valid=false
done
if [[ "${valid}" == true ]] && ! validate_awg_semantics; then
valid=false
fi
[[ "${valid}" == true ]]
}
# ===== CHECKS =====
check_root() {
if [[ $EUID -ne 0 ]]; then
err "This script must be run as root"
exit 1
fi
}
parse_args() {
for arg in "$@"; do
case "${arg}" in
--upgrade)
INSTALL_MODE="upgrade"
;;
--core-upgrade)
INSTALL_MODE="core-upgrade"
;;
--print-bundle)
AWG_PRINT_BUNDLE=true
;;
--wipe)
INSTALL_MODE="wipe"
;;
--help|-h)
echo "Usage: bash install_awg.sh [--upgrade|--core-upgrade|--wipe|--print-bundle]"
echo ""
echo " (no flags) Fresh install (fails if already installed)"
echo " --upgrade Repair the complete AWG stack, keep config and peers"
echo " --core-upgrade Transactionally update only module, tools and Go fallback"
echo " --wipe Full reinstall with new keys (DESTROYS all peers!)"
echo " --print-bundle Print the pinned official bundle without changing the host"
exit 0
;;
*)
err "Unknown argument: ${arg}"
exit 2
;;
esac
done
}
print_awg_bundle() {
printf 'AWG_GO_TAG=%s\n' "${AWG_GO_TAG}"
printf 'AWG_GO_COMMIT=%s\n' "${AWG_GO_COMMIT}"
printf 'AWG_TOOLS_TAG=%s\n' "${AWG_TOOLS_TAG}"
printf 'AWG_TOOLS_COMMIT=%s\n' "${AWG_TOOLS_COMMIT}"
printf 'AWG_KERNEL_TAG=%s\n' "${AWG_KERNEL_TAG}"
printf 'AWG_KERNEL_COMMIT=%s\n' "${AWG_KERNEL_COMMIT}"
printf 'AWG_KERNEL_DKMS_VERSION=%s\n' "${AWG_KERNEL_DKMS_VERSION}"
}
validate_awg_release_settings() {
local value
for value in "${AWG_GO_COMMIT}" "${AWG_TOOLS_COMMIT}" "${AWG_KERNEL_COMMIT}"; do
if [[ ! "${value}" =~ ^[0-9a-f]{40}$ ]]; then
err "AWG release commit is not a full immutable Git commit"
return 1
fi
done
for value in "${AWG_GO_TAG}" "${AWG_TOOLS_TAG}" "${AWG_KERNEL_TAG}"; do
if [[ ! "${value}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
err "AWG release tag has an unsupported format: ${value}"
return 1
fi
done
if [[ "${AWG_GO_TAG}" != "${AWG_TOOLS_TAG}" || "${AWG_GO_TAG}" != "${AWG_KERNEL_TAG}" ]]; then
err "Refusing a mixed AWG release: Go, tools and kernel tags must match"
return 1
fi
if [[ "${AWG_KERNEL_DKMS_VERSION}" != "${AWG_KERNEL_TAG#v}" ]]; then
err "DKMS version must match the official kernel release tag"
return 1
fi
if [[ ! "${AWG_BUILD_JOBS}" =~ ^[1-9][0-9]*$ ]]; then
err "AWG_BUILD_JOBS must be a positive integer"
return 1
fi
}
detect_os() {
if [[ -f /etc/os-release ]]; then
. /etc/os-release
OS_ID="${ID}"
OS_VERSION="${VERSION_ID}"
OS_CODENAME="${VERSION_CODENAME:-}"
else
err "Cannot detect OS. Supported: Ubuntu 22.04/24.04, Debian 12"
exit 1
fi
if [[ -z "${OS_CODENAME}" ]]; then
OS_CODENAME=$(echo "${VERSION:-}" | grep -oE '\([^)]+\)' | tr -d '()' || true)
fi
case "${OS_ID}" in
ubuntu|debian) ;;
*)
warn "Untested OS: ${OS_ID} ${OS_VERSION}. Proceed at your own risk."
read -rp "Continue? [y/N]: " cont
[[ "${cont}" =~ ^[Yy]$ ]] || exit 0
;;
esac
log "Detected: ${OS_ID} ${OS_VERSION}"
}
check_existing_installation() {
local is_running=false
if systemctl is-active "awg-quick@${AWG_INTERFACE}" &>/dev/null; then
is_running=true
fi
local has_config=false
if [[ -f "${AWG_CONFIG_FILE}" ]]; then
has_config=true
fi
if [[ "${INSTALL_MODE}" == "fresh" ]]; then
if [[ "${is_running}" == true || "${has_config}" == true ]]; then
warn "AWG is already installed on this server!"
if [[ "${is_running}" == true ]]; then
echo ""
echo " awg show ${AWG_INTERFACE}:"
awg show "${AWG_INTERFACE}" 2>/dev/null || true
echo ""
fi
echo "Options:"
echo " bash install_awg.sh --upgrade # rebuild binaries, keep config+peers"
echo " bash install_awg.sh --wipe # full reinstall (DESTROYS peers!)"
exit 1
fi
elif [[ "${INSTALL_MODE}" == "upgrade" || "${INSTALL_MODE}" == "core-upgrade" ]]; then
if [[ "${has_config}" != true ]]; then
err "No existing config found at ${AWG_CONFIG_FILE}. Use fresh install instead."
exit 1
fi
log "Upgrade mode: will prepare binaries and kernel module while the existing tunnel keeps serving"
elif [[ "${INSTALL_MODE}" == "wipe" ]]; then
if [[ "${is_running}" == true || "${has_config}" == true ]]; then
local peer_count=0
if [[ "${is_running}" == true ]]; then
peer_count=$(awg show "${AWG_INTERFACE}" peers 2>/dev/null | wc -l || echo 0)
fi
warn "WIPE MODE: This will destroy the current installation!"
warn " Config: ${AWG_CONFIG_FILE}"
warn " Active peers: ${peer_count}"
warn " ALL client configs will become INVALID."
echo ""
read -rp "Type 'WIPE' to confirm: " confirm_wipe
if [[ "${confirm_wipe}" != "WIPE" ]]; then
info "Aborted."
exit 0
fi
if [[ "${is_running}" == true ]]; then
systemctl stop "awg-quick@${AWG_INTERFACE}" || true
fi
# Backup old config before wiping
if [[ -f "${AWG_CONFIG_FILE}" ]]; then
cp "${AWG_CONFIG_FILE}" "${AWG_CONFIG_FILE}.bak.$(date +%s)"
log "Old config backed up"
fi
fi
log "Wipe mode: full reinstall with new keys"
fi
}
# ===== DEPENDENCIES =====
wait_for_apt() {
local max_wait=120
local waited=0
while fuser /var/lib/dpkg/lock-frontend &>/dev/null 2>&1; do
if [[ ${waited} -eq 0 ]]; then
info "Waiting for apt lock (another package manager is running)..."
fi
sleep 2
((waited+=2))
if [[ ${waited} -ge ${max_wait} ]]; then
err "apt lock held for over ${max_wait}s. Kill the other process or retry later."
exit 1
fi
done
if [[ ${waited} -gt 0 ]]; then
log "apt lock released after ${waited}s"
fi
}
disable_backports_entries_in_file() {
local file="$1" codename="$2"
local target="${codename}-backports"
[[ -f "${file}" ]] || return 1
grep -Eq "^[[:space:]]*deb(-src)?[[:space:]].*${target}" "${file}" || return 1
cp "${file}" "${file}.bak.install_awg.$(date +%s)"
sed -Ei "/^[[:space:]]*deb(-src)?[[:space:]].*${target}/ s@^@# disabled by install_awg.sh: @" "${file}"
}
disable_broken_backports_sources() {
local codename="$1"
local changed=0
local file
for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list; do
[[ -f "${file}" ]] || continue
if disable_backports_entries_in_file "${file}" "${codename}"; then
log "Disabled broken ${codename}-backports entry in ${file}"
changed=1
fi
done
[[ ${changed} -eq 1 ]]
}
apt_update_with_self_heal() {
local log_file="/tmp/install_awg_apt_update.$$.log"
local repo_pattern=""
if [[ -n "${OS_CODENAME:-}" ]]; then
repo_pattern="${OS_CODENAME}-backports"
fi
if apt-get update -qq >"${log_file}" 2>&1; then
rm -f "${log_file}"
return 0
fi
warn "apt-get update failed. Trying to explain and repair common Debian repository issues..."
if [[ -n "${repo_pattern}" ]] && grep -Eq "${repo_pattern}.*(does not have a Release file|no Release file|404|Release file)" "${log_file}"; then
warn "Detected a broken ${repo_pattern} repository entry."
warn "This is an OS repository problem, not an AmneziaWG problem."
if disable_broken_backports_sources "${OS_CODENAME}"; then
warn "Temporarily disabled ${repo_pattern} sources and retrying apt-get update..."
rm -rf /var/lib/apt/lists/*
if apt-get update -qq >"${log_file}" 2>&1; then
log "apt repositories repaired automatically"
rm -f "${log_file}"
return 0
fi
fi
fi
err "apt-get update failed and automatic repair was not enough."
err "Relevant apt output:"
sed 's/^/ /' "${log_file}" >&2 || cat "${log_file}" >&2
rm -f "${log_file}"
return 1
}
install_dependencies() {
log "Installing system dependencies..."
prepare_apt_networking || exit 1
wait_for_apt
apt_update_with_self_heal || exit 1
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
build-essential git curl wget iptables python3 iproute2 conntrack logrotate dnsmasq-base dns-root-data 2>/dev/null
# Try to install iptables-persistent (may fail on some systems)
if ! command -v netfilter-persistent &>/dev/null; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
iptables-persistent netfilter-persistent 2>/dev/null || {
warn "Could not install iptables-persistent — firewall rules will NOT survive reboot!"
warn "You must manually ensure UDP port ${AWG_PORT} is open after reboot."
HAS_PERSISTENT_FW=false
}
fi
HAS_PERSISTENT_FW="${HAS_PERSISTENT_FW:-true}"
log "Dependencies installed"
}
load_existing_awg_config_identity() {
local -a values=()
mapfile -t values < <(AWG_EXISTING_CONFIG="${AWG_CONFIG_FILE}" python3 <<'PY'
import ipaddress
import os
from pathlib import Path
path = Path(os.environ["AWG_EXISTING_CONFIG"])
section = None
values = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
section = line
continue
if section != "[Interface]" or "=" not in line:
continue
key, value = (part.strip() for part in line.split("=", 1))
if key in {"Address", "ListenPort"} and key not in values:
values[key] = value
if set(values) != {"Address", "ListenPort"}:
raise SystemExit("existing AWG config must contain one Interface Address and ListenPort")
address = ipaddress.ip_interface(values["Address"])
if address.version != 4:
raise SystemExit("existing AWG Interface Address must be IPv4")
port = int(values["ListenPort"])
if not 1 <= port <= 65535:
raise SystemExit("existing AWG ListenPort is outside 1..65535")
print(str(address))
print(str(port))
print(str(address.network))
PY
)
if [[ ${#values[@]} -ne 3 ]]; then
err "Could not read the existing AWG address, port and subnet from ${AWG_CONFIG_FILE}"
return 1
fi
AWG_SERVER_IP="${values[0]}"
AWG_PORT="${values[1]}"
AWG_SUBNET="${values[2]}"
log "Existing AWG identity loaded: address=${AWG_SERVER_IP}, port=${AWG_PORT}, subnet=${AWG_SUBNET}"
}
clone_pinned_awg_repo() {
local repo="$1" tag="$2" commit="$3" destination="$4" label="$5"
local actual
rm -rf "${destination}"
if ! git clone --quiet --depth 1 --branch "${tag}" "${repo}" "${destination}"; then
err "Could not clone ${label} tag ${tag}"
return 1
fi
if ! actual=$(git -C "${destination}" rev-parse HEAD); then
err "Could not resolve the checked-out ${label} commit"
rm -rf "${destination}"
return 1
fi
if [[ "${actual}" != "${commit}" ]]; then
err "${label} tag ${tag} resolved to unexpected commit ${actual}"
err "Expected immutable commit ${commit}; refusing an unreviewed build"
rm -rf "${destination}"
return 1
fi
}
download_reviewed_awg_asset() {
local name="$1" expected_sha="$2" destination="$3"
if ! curl -4 -fsSL --retry 4 --retry-all-errors \
--connect-timeout 10 --max-time 300 \
"${AWG_REVIEWED_ASSET_BASE_URL}/${name}" -o "${destination}"; then
return 1
fi
if ! printf '%s %s\n' "${expected_sha}" "${destination}" | sha256sum -c -; then
err "Reviewed AWG asset ${name} failed its pinned SHA-256 check"
return 1
fi
}
prepare_reviewed_awg_kernel_source() {
local destination="$1" archive_root archive
archive_root=$(mktemp -d /tmp/amneziawg-kernel-archive.XXXXXX)
archive="${archive_root}/amneziawg-linux-kernel-module.tar.gz"
if ! download_reviewed_awg_asset \
amneziawg-linux-kernel-module.tar.gz \
"${AWG_KERNEL_ARCHIVE_SHA256}" "${archive}"; then
rm -rf "${archive_root}"
return 1
fi
if ! tar -C "${archive_root}" -xzf "${archive}"; then
rm -rf "${archive_root}"
return 1
fi
if [[ ! -f "${archive_root}/amneziawg-linux-kernel-module/src/version.h" ]] \
|| ! grep -Fq "\"${AWG_KERNEL_TAG#v}\"" \
"${archive_root}/amneziawg-linux-kernel-module/src/version.h"; then
err "Reviewed kernel archive does not declare ${AWG_KERNEL_TAG#v}"
rm -rf "${archive_root}"
return 1
fi
if ! mv "${archive_root}/amneziawg-linux-kernel-module" "${destination}"; then
rm -rf "${archive_root}"
return 1
fi
rm -rf "${archive_root}"
}
select_awg_kernel_target() {
local running candidate path
running=$(uname -r)
if [[ -d "/lib/modules/${running}/build" ]]; then
printf '%s\n' "${running}"
return 0
fi
path=$(readlink -f /vmlinuz 2>/dev/null || true)
candidate=${path##*/vmlinuz-}
if [[ -n "${candidate}" && -d "/lib/modules/${candidate}/build" ]]; then
printf '%s\n' "${candidate}"
return 0
fi
candidate=$(find /lib/modules -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null \
| while read -r candidate; do
[[ -d "/lib/modules/${candidate}/build" ]] && printf '%s\n' "${candidate}"
done \
| sort -V \
| tail -n 1)
[[ -n "${candidate}" ]] || return 1
printf '%s\n' "${candidate}"
}
install_awg_kernel_prerequisites() {
local running
running=$(uname -r)
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq dkms dwarves || return 1
if [[ ! -d "/lib/modules/${running}/build" ]]; then
if ! DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "linux-headers-${running}"; then
warn "Headers for the running kernel ${running} are no longer available"
case "${OS_ID}" in
debian)
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq linux-image-amd64 linux-headers-amd64 || return 1
;;
ubuntu)
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq linux-generic linux-headers-generic || return 1
;;
*)
return 1
;;
esac
fi
fi
AWG_KERNEL_TARGET=$(select_awg_kernel_target) || return 1
if [[ "${AWG_KERNEL_TARGET}" != "${running}" ]]; then
AWG_KERNEL_REBOOT_REQUIRED=true
warn "AmneziaWG kernel module will be built for ${AWG_KERNEL_TARGET}; the server currently runs ${running}"
warn "The existing userspace tunnel remains available until the next controlled reboot"
fi
}
install_awg_kernel_module() {
local build_dir src module_version target_status
AWG_KERNEL_READY=false
AWG_KERNEL_REBOOT_REQUIRED=false
log "Preparing the preferred AmneziaWG kernel implementation..."
if ! install_awg_kernel_prerequisites; then
warn "Kernel prerequisites are unavailable; keeping amneziawg-go as the safe fallback"
return 1
fi
module_version=$(modinfo -k "${AWG_KERNEL_TARGET}" -F version amneziawg 2>/dev/null || true)
target_status=$(dkms status -m amneziawg -v "${AWG_KERNEL_DKMS_VERSION}" 2>/dev/null || true)
if [[ "${module_version}" == "${AWG_KERNEL_TAG#v}" && "${target_status}" == *"${AWG_KERNEL_TARGET}"*"installed"* ]]; then
log "AmneziaWG kernel module ${module_version} is already installed for ${AWG_KERNEL_TARGET}"
if [[ "${AWG_KERNEL_TARGET}" == "$(uname -r)" ]]; then
modprobe amneziawg || return 1
AWG_KERNEL_READY=true
fi
return 0
fi
build_dir=$(mktemp -d /tmp/amneziawg-kernel-build.XXXXXX)
chmod 0700 "${build_dir}"
if ! prepare_reviewed_awg_kernel_source "${build_dir}/source"; then
warn "Reviewed kernel archive is unavailable; trying the exact official Git commit directly"
if ! clone_pinned_awg_repo \
"${AWG_KERNEL_REPO}" "${AWG_KERNEL_TAG}" "${AWG_KERNEL_COMMIT}" \
"${build_dir}/source" "amneziawg-linux-kernel-module"; then
rm -rf "${build_dir}"
return 1
fi
fi
src="${build_dir}/source/src"
if ! make -C "${src}" \
KERNELRELEASE="${AWG_KERNEL_TARGET}" \
KERNELDIR="/lib/modules/${AWG_KERNEL_TARGET}/build" \
-j"${AWG_BUILD_JOBS}"; then
err "AmneziaWG kernel module did not compile for ${AWG_KERNEL_TARGET}"
rm -rf "${build_dir}"
return 1
fi
if ! make -C "${src}" dkms-install WIREGUARD_VERSION="${AWG_KERNEL_DKMS_VERSION}"; then
rm -rf "${build_dir}"
return 1
fi
if ! sed -Ei \
"s/^PACKAGE_VERSION=.*/PACKAGE_VERSION=\"${AWG_KERNEL_DKMS_VERSION}\"/" \
"/usr/src/amneziawg-${AWG_KERNEL_DKMS_VERSION}/dkms.conf"; then
rm -rf "${build_dir}"
return 1
fi
# Upstream keeps the Makefile's historical 1.0.0 default even though its
# version.h carries the release tag. DKMS invokes that Makefile without an
# override, so pin the copied source too; otherwise modinfo would report
# 1.0.0 for a v3 release and the transactional verifier would reject it.
if ! sed -Ei \
"s/^WIREGUARD_VERSION[[:space:]]*=.*/WIREGUARD_VERSION = ${AWG_KERNEL_DKMS_VERSION}/" \
"/usr/src/amneziawg-${AWG_KERNEL_DKMS_VERSION}/Makefile"; then
rm -rf "${build_dir}"
return 1
fi
target_status=$(dkms status -m amneziawg -v "${AWG_KERNEL_DKMS_VERSION}" 2>/dev/null || true)
if [[ -z "${target_status}" ]]; then
if ! dkms add -m amneziawg -v "${AWG_KERNEL_DKMS_VERSION}"; then
rm -rf "${build_dir}"
return 1
fi
fi
if ! dkms build -m amneziawg -v "${AWG_KERNEL_DKMS_VERSION}" -k "${AWG_KERNEL_TARGET}"; then
rm -rf "${build_dir}"
return 1
fi
if ! dkms install --force -m amneziawg -v "${AWG_KERNEL_DKMS_VERSION}" -k "${AWG_KERNEL_TARGET}"; then
rm -rf "${build_dir}"
return 1
fi
rm -rf "${build_dir}"
module_version=$(modinfo -k "${AWG_KERNEL_TARGET}" -F version amneziawg 2>/dev/null || true)
if [[ "${module_version}" != "${AWG_KERNEL_TAG#v}" ]]; then
err "Installed module version ${module_version:-<missing>} does not match ${AWG_KERNEL_TAG#v}"
return 1
fi
if [[ "${AWG_KERNEL_TARGET}" == "$(uname -r)" ]]; then
modprobe amneziawg || return 1
AWG_KERNEL_READY=true
log "AmneziaWG kernel module ${module_version} is ready on the running kernel"
else
log "AmneziaWG kernel module ${module_version} is ready for the next kernel ${AWG_KERNEL_TARGET}"
fi
}
install_go() {
local need_install=true
if ! command -v go &>/dev/null && [[ -x /usr/local/go/bin/go ]]; then
export PATH="/usr/local/go/bin:${PATH}"
fi
if command -v go &>/dev/null; then
local current_go
current_go=$(go version | grep -oP '\d+\.\d+\.\d+')
if printf '%s\n%s' "${GO_VERSION}" "${current_go}" | sort -V | head -1 | grep -q "^${GO_VERSION}$"; then
log "Go ${current_go} >= ${GO_VERSION}, OK"
need_install=false
else
warn "Go ${current_go} < ${GO_VERSION}, upgrading..."
fi
fi
if [ "${need_install}" = true ]; then
log "Installing Go ${GO_VERSION}..."
if [[ -n "${AWG_GO_ARCHIVE_PATH:-}" ]]; then
cp "${AWG_GO_ARCHIVE_PATH}" /tmp/go.tar.gz
elif ! curl -4 -fsSL --retry 4 --retry-all-errors \
--connect-timeout 10 --max-time 600 \
"https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tar.gz; then
err "Could not download the official Go toolchain; the serving tunnel was not touched"
return 1
fi
if ! printf '%s %s\n' "${GO_LINUX_AMD64_SHA256}" /tmp/go.tar.gz | sha256sum -c -; then
err "Downloaded Go archive failed the pinned SHA-256 check"
rm -f /tmp/go.tar.gz
return 1
fi
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
rm /tmp/go.tar.gz
export PATH="/usr/local/go/bin:$PATH"
if ! grep -q '/usr/local/go/bin' /etc/profile.d/go.sh 2>/dev/null; then
echo 'export PATH="/usr/local/go/bin:$PATH"' >> /etc/profile.d/go.sh
fi
log "Go $(go version | grep -oP '\d+\.\d+\.\d+') installed"
fi
}
# ===== BUILD =====
atomic_install_file() {
local source="$1" target="$2" mode="$3"
local target_dir temporary
target_dir=$(dirname "${target}")
install -d "${target_dir}"
temporary=$(mktemp "${target_dir}/.$(basename "${target}").new.XXXXXX")
if ! install -m "${mode}" "${source}" "${temporary}"; then
rm -f "${temporary}"
return 1
fi
if ! mv -f "${temporary}" "${target}"; then
rm -f "${temporary}"
return 1
fi
}
install_reviewed_awg_binaries() {
local asset_dir architecture
architecture=$(uname -m)
if [[ "${architecture}" != "x86_64" && "${architecture}" != "amd64" ]]; then
warn "No reviewed prebuilt AWG bundle for architecture ${architecture}"
return 1
fi
asset_dir=$(mktemp -d /tmp/amneziawg-reviewed-binaries.XXXXXX)
if ! download_reviewed_awg_asset amneziawg-go "${AWG_GO_BINARY_SHA256}" "${asset_dir}/amneziawg-go" \
|| ! download_reviewed_awg_asset awg "${AWG_TOOLS_BINARY_SHA256}" "${asset_dir}/awg" \
|| ! download_reviewed_awg_asset awg-quick "${AWG_QUICK_BINARY_SHA256}" "${asset_dir}/awg-quick"; then
rm -rf "${asset_dir}"
return 1
fi
if ! chmod 0755 "${asset_dir}/amneziawg-go" "${asset_dir}/awg" "${asset_dir}/awg-quick"; then
rm -rf "${asset_dir}"
return 1
fi
if ! "${asset_dir}/amneziawg-go" --version 2>&1 | grep -Fq "amneziawg-go ${AWG_GO_TAG}" \
|| ! "${asset_dir}/awg" --version 2>&1 | grep -Fq "amneziawg-tools ${AWG_TOOLS_TAG}" \
|| ! bash -n "${asset_dir}/awg-quick"; then
err "Reviewed AWG binaries did not report the pinned synchronized release"
rm -rf "${asset_dir}"
return 1
fi
if ! atomic_install_file "${asset_dir}/amneziawg-go" /usr/local/bin/amneziawg-go 0755 \
|| ! atomic_install_file "${asset_dir}/awg" /usr/local/bin/awg 0755 \
|| ! atomic_install_file "${asset_dir}/awg-quick" /usr/local/bin/awg-quick 0755; then
rm -rf "${asset_dir}"
return 1
fi
rm -rf "${asset_dir}"
log "Installed the reviewed ${AWG_GO_TAG} linux-amd64 binary bundle"
}
build_amneziawg() {
log "Building pinned amneziawg-go fallback and management tools..."
local build_dir="/tmp/amneziawg-build"
if install_reviewed_awg_binaries; then
return 0
fi
warn "Reviewed binary bundle is unavailable; building the exact official commits locally"
install_go
rm -rf "${build_dir}"
mkdir -p "${build_dir}"
cd "${build_dir}"
clone_pinned_awg_repo \
"${AWG_GO_REPO}" "${AWG_GO_TAG}" "${AWG_GO_COMMIT}" \
amneziawg-go "amneziawg-go"
cd amneziawg-go
GOMAXPROCS="${AWG_BUILD_JOBS}" make
atomic_install_file amneziawg-go /usr/local/bin/amneziawg-go 0755
log "amneziawg-go built and installed"
cd "${build_dir}"
clone_pinned_awg_repo \
"${AWG_TOOLS_REPO}" "${AWG_TOOLS_TAG}" "${AWG_TOOLS_COMMIT}" \
amneziawg-tools "amneziawg-tools"
cd amneziawg-tools/src
make
make install
atomic_install_file wg /usr/local/bin/awg 0755
atomic_install_file wg-quick/linux.bash /usr/local/bin/awg-quick 0755
log "awg and awg-quick installed"
cd /
rm -rf "${build_dir}"
# make install may put binaries in /usr/bin/ instead of /usr/local/bin/
# systemd unit expects /usr/local/bin/ — create symlinks if needed
for bin in awg awg-quick; do
if [[ ! -f "/usr/local/bin/${bin}" ]] && command -v "${bin}" &>/dev/null; then
local actual_path
actual_path=$(command -v "${bin}")
ln -sf "${actual_path}" "/usr/local/bin/${bin}"
log "Symlinked ${actual_path} -> /usr/local/bin/${bin}"
fi
done
# Verify all binaries accessible from /usr/local/bin/
for bin in amneziawg-go awg awg-quick; do
if [[ ! -x "/usr/local/bin/${bin}" ]]; then
err "Binary '/usr/local/bin/${bin}' not found after build!"
exit 1
fi
done
log "All binaries verified in /usr/local/bin/"
}
# ===== CONFIGURE =====
generate_server_keys() {
SERVER_PRIVATE_KEY=$(awg genkey)
SERVER_PUBLIC_KEY=$(echo "${SERVER_PRIVATE_KEY}" | awg pubkey)
log "Server keypair generated"
}
generate_cps_signatures() {
local protocol="${1:-auto}"
eval "$(AWG_CPS_PROTOCOL="${protocol}" python3 <<'PY'
import os
import random
import secrets
import shlex
protocol = os.environ.get("AWG_CPS_PROTOCOL", "auto").strip().lower() or "auto"
auto_protocols = ("quic", "dns", "stun", "tls", "sip")
if protocol == "auto":
protocol = random.choice(auto_protocols)
def rb(n):
return secrets.token_bytes(int(n))
def u8(v):
return bytes([int(v) & 0xFF])
def u16(v):
return int(v & 0xFFFF).to_bytes(2, "big")
def u24(v):
return int(v & 0xFFFFFF).to_bytes(3, "big")
def u32(v):
return int(v & 0xFFFFFFFF).to_bytes(4, "big")
def cps_bytes(data):
return f"<b 0x{data.hex()}>"
def tag_b(n):
return cps_bytes(rb(max(1, min(int(n), 64))))
def tag_r(n):
return f"<r {max(1, min(int(n), 1000))}>"
def tag_rc(n):
return f"<rc {max(1, min(int(n), 1000))}>"
def tag_rd(n):
return f"<rd {max(1, min(int(n), 1000))}>"
def dns_name(host):
out = bytearray()
for label in host.split("."):
raw = label.encode("ascii")[:63]
out.append(len(raw))
out.extend(raw)
out.append(0)
return bytes(out)
def gen_dns():
hosts = ("www.google.com", "cloudflare.com", "discord.com", "youtube.com", "api.telegram.org")
qtypes = (1, 28, 65)
opt = b"\x00" + u16(41) + u16(1232) + u32(0) + u16(0)
return b"".join((u16(random.randint(1, 65535)), u16(0x0100), u16(1), u16(0), u16(0), u16(0), dns_name(random.choice(hosts)), u16(random.choice(qtypes)), u16(1), opt))
def stun_attr(kind, value):
pad = (4 - (len(value) % 4)) % 4
return u16(kind) + u16(len(value)) + value + (b"\x00" * pad)
def gen_stun():
username = f"{rb(4).hex()}:{rb(4).hex()}".encode("ascii")
attrs = stun_attr(0x0006, username) + stun_attr(0x0024, u32(random.randint(1_000_000, 2_130_706_431))) + stun_attr(0x802A, rb(8))
return u16(1) + u16(len(attrs)) + u32(0x2112A442) + rb(12) + attrs
def tls_ext(kind, data):
return u16(kind) + u16(len(data)) + data
def tls_client_hello_body(quic=False):
host = random.choice(("www.google.com", "cloudflare.com", "discord.com", "api.telegram.org", "youtube.com"))
host_raw = host.encode("ascii")
sni_entry = b"\x00" + u16(len(host_raw)) + host_raw
sni = tls_ext(0, u16(len(sni_entry)) + sni_entry)
versions = tls_ext(43, b"\x02\x03\x04" if quic else b"\x04\x03\x04\x03\x03")
groups = tls_ext(10, u16(4) + u16(29) + u16(23))
key_share = tls_ext(51, u16(36) + u16(29) + u16(32) + rb(32))
exts = sni + versions + groups + key_share
if not quic:
h2 = b"\x02h2"
http11 = b"\x08http/1.1"
exts += tls_ext(16, u16(len(h2) + len(http11)) + h2 + http11)
ciphers = u16(0x1301) + u16(0x1302) + u16(0x1303)
sid = b"" if quic else rb(32)
return u16(0x0303) + rb(32) + u8(len(sid)) + sid + u16(len(ciphers)) + ciphers + b"\x01\x00" + u16(len(exts)) + exts
def gen_tls():
body = tls_client_hello_body()
hs = b"\x01" + u24(len(body)) + body
return b"\x16\x03\x01" + u16(len(hs)) + hs
def varint(value):
if value < 64:
return u8(value)
if value < 16384:
return u16(value | 0x4000)
return u32(value | 0x80000000)
def gen_quic():
body = tls_client_hello_body(quic=True)
ch = b"\x01" + u24(len(body)) + body
crypto = b"\x06" + varint(0) + varint(len(ch)) + ch
payload = crypto + (b"\x00" * max(0, 1162 - len(crypto)))
dcid, scid = rb(8), rb(8)
return b"\xc0" + u32(1) + u8(len(dcid)) + dcid + u8(len(scid)) + scid + b"\x00" + varint(len(payload) + 1) + u8(random.randint(0, 255)) + payload
def gen_sip():
text = (
"INVITE sip:bob@example.com SIP/2.0\r\n"
"Via: SIP/2.0/UDP pc33.example.com;branch=z9hG4bK776asdhds\r\n"
"Max-Forwards: 70\r\n"
"To: Bob <sip:bob@example.com>\r\n"
"From: Alice <sip:alice@example.com>;tag=1928301774\r\n"
f"Call-ID: {rb(8).hex()}@pc33.example.com\r\n"
f"CSeq: {random.randint(1000, 999999)} INVITE\r\n"
"Contact: <sip:alice@pc33.example.com>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 0\r\n\r\n"
)
return text.encode("ascii")
generators = {"dns": gen_dns, "stun": gen_stun, "tls": gen_tls, "sip": gen_sip, "quic": gen_quic}
i1 = cps_bytes(generators.get(protocol, gen_quic)())
values = {
"I1": i1,
"I2": f"{tag_b(random.randint(8, 24))}<t>{tag_rc(random.randint(14, 33))}{tag_r(random.randint(48, 150))}",
"I3": f"{tag_b(4)}{tag_rd(random.randint(6, 17))}<t>{tag_r(random.randint(40, 130))}",
"I4": f"<b 0x2112a442>{tag_r(random.randint(12, 38))}<t>{tag_rc(random.randint(10, 26))}{tag_r(random.randint(32, 110))}",
"I5": f"{tag_rd(random.randint(10, 22))}<t>{tag_b(random.randint(4, 14))}{tag_r(random.randint(36, 120))}",
}
for key, value in values.items():
print(f"{key}={shlex.quote(value)}")
PY
)"
}
apply_preset_profile() {
local preset_id="$1"
local cps_protocol=""
case "${preset_id}" in
speed_stealth)
PRESET_NAME="speed_stealth"
PRESET_LABEL="Speed-stealth"
PRESET_HINT="Быстрый и практичный профиль: не похож на типичный WireGuard, но не слишком тяжёлый."
cps_protocol="quic"
Jc=4; Jmin=64; Jmax=160
S1=44; S2=63; S3=12; S4=8
H1="431245120-431245220"
H2="1187345001-1187345090"
H3="905120331-905120360"
H4="1688457701-1688457800"
I1='<b 0xc70000000108><rc 8><t><r 80>'
I2='<b 0xf6ab3267fa><t><rc 12><r 48>'
I3=""; I4=""; I5=""
;;
mobile_fallback)
PRESET_NAME="mobile_fallback"
PRESET_LABEL="Mobile-fallback"
PRESET_HINT="Более лёгкий профиль для мобильных сетей и нестабильных провайдеров."
cps_protocol="stun"
Jc=3; Jmin=64; Jmax=128
S1=28; S2=52; S3=0; S4=4
H1="245710100-245710160"
H2="514920300-514920360"
H3="845001220-845001260"
H4="1294500770-1294500830"
I1='<r 2><b 0x858000010001000000000669636c6f7564036366726d0000010001c00c00010001000105a0004445837373>'
I2=""; I3=""; I4=""; I5=""
;;
hard_dpi)
PRESET_NAME="hard_dpi"
PRESET_LABEL="Hard-DPI"
PRESET_HINT="Тяжёлый профиль для жёсткого DPI, когда лёгкие варианты уже не проходят."
cps_protocol="auto"
Jc=7; Jmin=80; Jmax=260
S1=56; S2=64; S3=24; S4=12
H1="1581200440-1581200540"
H2="1763402201-1763402290"
H3="1960044100-1960044170"
H4="2145007701-2145007800"
I1='<b 0xc70000000108><rc 8><t><r 160>'
I2='<r 2><b 0x858000010001000000000669636c6f7564036366726d0000010001c00c00010001000105a0004445837373>'
I3='<r 180>'
I4=""; I5=""
;;
quic)
PRESET_NAME="quic"
PRESET_LABEL="QUIC-safe"
PRESET_HINT="Стартовый профиль. Обычно его стоит пробовать первым."
cps_protocol="quic"
Jc=7; Jmin=64; Jmax=1000
S1=60; S2=63; S3=32; S4=16
H1="471800590-471800690"
H2="1246894907-1246895000"
H3="923637689-923637690"
H4="1769581055-1869581055"
I1='<b 0xc70000000108><rc 8><t><r 100>'
I2='<b 0xf6ab3267fa><t><rc 20><r 80>'
I3=""; I4=""; I5=""
;;
dns)
PRESET_NAME="dns"
PRESET_LABEL="DNS-fallback"
PRESET_HINT="Запасной профиль, если QUIC-подобный трафик режется или выглядит слишком заметно."
cps_protocol="dns"
Jc=5; Jmin=64; Jmax=500
S1=40; S2=40; S3=0; S4=0
H1="100000000-200000000"
H2="300000000-400000000"
H3="500000000-600000000"
H4="700000000-800000000"
I1='<r 2><b 0x858000010001000000000669636c6f7564036366726d0000010001c00c00010001000105a0004445837373>'
I2=""; I3=""; I4=""; I5=""
;;
stealth)
PRESET_NAME="stealth"
PRESET_LABEL="Stealth-heavy"
PRESET_HINT="Более тяжёлая маскировка. Лучше для жёсткого DPI, но может съедать скорость."
cps_protocol="auto"
Jc=10; Jmin=100; Jmax=1024
S1=64; S2=56; S3=50; S4=32
H1="1500000000-1600000000"
H2="1700000000-1800000000"
H3="1900000000-2000000000"
H4="2050000000-2147000000"
I1='<b 0xc70000000108><rc 8><t><r 200>'
I2='<r 2><b 0x858000010001000000000669636c6f7564036366726d0000010001c00c00010001000105a0004445837373>'
I3='<r 300>'
I4=""; I5=""
;;
fast)
PRESET_NAME="fast"
PRESET_LABEL="Speed-light"
PRESET_HINT="Минимальная маскировка. Подходит для чистых сетей, но слабее против DPI."
Jc=3; Jmin=64; Jmax=300
S1=20; S2=20; S3=0; S4=0
H1="100000000-200000000"
H2="300000000-400000000"
H3="500000000-600000000"
H4="700000000-800000000"
I1=""; I2=""; I3=""; I4=""; I5=""
;;
router_quic)
PRESET_NAME="router_quic"
PRESET_LABEL="Router-QUIC"
PRESET_HINT="Лёгкий QUIC-подобный профиль для роутеров и слабых устройств."
cps_protocol="quic"
Jc=2; Jmin=64; Jmax=128
S1=16; S2=32; S3=8; S4=0
H1="310001000-310001040"
H2="620002000-620002040"
H3="930003000-930003040"
H4="1240004000-1240004040"
I1='<b 0xc70000000108><rc 8><t><r 64>'
I2='<b 0xf6ab3267fa><t><rc 10><r 40>'
I3=""; I4=""; I5=""
;;
*)
err "Unknown AWG server template: ${preset_id}"
exit 1
;;
esac
if [[ -n "${cps_protocol}" ]]; then
generate_cps_signatures "${cps_protocol}"
fi
PRESET_TUNED="false"
}
mark_profile_tuned() {
if [[ "${PRESET_TUNED:-false}" != "true" ]]; then
PRESET_TUNED="true"
if [[ -n "${PRESET_LABEL:-}" ]]; then
PRESET_LABEL="${PRESET_LABEL} (tuned)"
else
PRESET_LABEL="Custom manual"
fi
fi
PRESET_NAME=""
}
print_awg_parameter_guide() {
echo ""
info "AWG 2.0 parameter guide:"
echo " I1-I5 — сигнатурные пакеты перед handshake. Их обычно меняют первыми."
echo " Jc/Jmin/Jmax — мусорные пакеты перед handshake. Размывают старт соединения."
echo " S1-S4 — добавочная длина пакетов. Маскирует размеры handshake и data."
echo " H1-H4 — заголовки разных типов пакетов. Это уже глубокая смена профиля."
echo ""
info "Practical tuning order when a network starts blocking AWG:"
echo " 1) I1-I5 2) port 3) Jc/Jmin/Jmax 4) S1-S3 5) H1-H4 6) S4 last"
echo ""
warn "If you change server-side AWG params, existing client configs may need re-issue."
}
print_current_awg_profile_summary() {
echo ""
info "Current AWG server profile:"
echo " Label: ${PRESET_LABEL:-Custom manual}"
if [[ -n "${PRESET_HINT:-}" ]]; then
echo " Purpose: ${PRESET_HINT}"
fi
echo " J: Jc=${Jc}, Jmin=${Jmin}, Jmax=${Jmax}"
echo " S: S1=${S1}, S2=${S2}, S3=${S3}, S4=${S4}"
echo " H: H1=${H1}"
echo " H2=${H2}"
echo " H3=${H3}"
echo " H4=${H4}"
echo " I: I1=${I1:-<empty>}"
echo " I2=${I2:-<empty>}"
echo " I3=${I3:-<empty>}"
echo " I4=${I4:-<empty>}"
echo " I5=${I5:-<empty>}"
}
prompt_edit_signatures() {
while true; do
echo ""
info "Signature packets (I1-I5)"
echo " These packets go before the real handshake and define what the start of traffic resembles."
echo " Start tuning here if a specific network begins blocking one profile."
echo " Press Enter to keep the current value, or type '-' to clear it."
read -rp "I1 [${I1:-<empty>}]: " input
if [[ "${input}" == "-" ]]; then I1=""; elif [[ -n "${input}" ]]; then I1="${input}"; fi
read -rp "I2 [${I2:-<empty>}]: " input
if [[ "${input}" == "-" ]]; then I2=""; elif [[ -n "${input}" ]]; then I2="${input}"; fi
read -rp "I3 [${I3:-<empty>}]: " input
if [[ "${input}" == "-" ]]; then I3=""; elif [[ -n "${input}" ]]; then I3="${input}"; fi
read -rp "I4 [${I4:-<empty>}]: " input
if [[ "${input}" == "-" ]]; then I4=""; elif [[ -n "${input}" ]]; then I4="${input}"; fi
read -rp "I5 [${I5:-<empty>}]: " input
if [[ "${input}" == "-" ]]; then I5=""; elif [[ -n "${input}" ]]; then I5="${input}"; fi
if validate_current_awg_inputs; then
mark_profile_tuned
break
fi
warn "Validation failed. Re-enter the signature packets."
done
}
prompt_edit_junk_params() {
while true; do
echo ""
info "Junk packets (Jc/Jmin/Jmax)"
echo " Jc controls how many junk packets go before the handshake."
echo " Jmin/Jmax control their size range."
echo " Useful after signature and port changes, but before deeper packet-shape changes."
read -rp "Jc [${Jc}]: " input; [[ -n "${input}" ]] && Jc="${input}"
read -rp "Jmin [${Jmin}]: " input; [[ -n "${input}" ]] && Jmin="${input}"
read -rp "Jmax [${Jmax}]: " input; [[ -n "${input}" ]] && Jmax="${input}"
if validate_current_awg_inputs; then
mark_profile_tuned
break
fi
warn "Validation failed. Re-enter Jc/Jmin/Jmax."
done
}
prompt_edit_padding_params() {
while true; do
echo ""
info "Packet paddings (S1-S4)"
echo " S1-S3 mostly affect handshake packets."
echo " S4 affects ordinary data packets and should be changed last."
echo " Zero is allowed for S3 and S4 if you want no extra padding there."
read -rp "S1 [${S1}]: " input; [[ -n "${input}" ]] && S1="${input}"
read -rp "S2 [${S2}]: " input; [[ -n "${input}" ]] && S2="${input}"
read -rp "S3 [${S3}]: " input; [[ -n "${input}" ]] && S3="${input}"
read -rp "S4 [${S4}]: " input; [[ -n "${input}" ]] && S4="${input}"
if validate_current_awg_inputs; then
mark_profile_tuned
break
fi
warn "Validation failed. Re-enter S1-S4."
done
}
prompt_edit_headers() {
while true; do
echo ""
info "Packet headers (H1-H4)"
echo " These values redefine packet type markers and are a deep profile change."
echo " Use only after signatures, port, junk, and paddings were already tried."
echo " Each header must be unique and ranges must not overlap."
read -rp "H1 [${H1}]: " input; [[ -n "${input}" ]] && H1="${input}"
read -rp "H2 [${H2}]: " input; [[ -n "${input}" ]] && H2="${input}"
read -rp "H3 [${H3}]: " input; [[ -n "${input}" ]] && H3="${input}"
read -rp "H4 [${H4}]: " input; [[ -n "${input}" ]] && H4="${input}"
if validate_current_awg_inputs; then
mark_profile_tuned
break
fi
warn "Validation failed. Re-enter H1-H4."
done
}
prompt_full_manual_profile() {
echo ""
info "Manual AWG profile editing"
echo " You are editing every group of parameters by hand."
echo " If you are unsure, start from QUIC-safe and only tune one group at a time."
prompt_edit_junk_params
prompt_edit_padding_params
prompt_edit_headers
prompt_edit_signatures
}
prompt_fine_tune_menu() {
while true; do
print_current_awg_profile_summary
echo ""
echo "Fine-tune menu:"
echo " 1) Edit I1-I5 signatures (first thing to try)"
echo " 2) Edit Jc/Jmin/Jmax junk packets"
echo " 3) Edit S1-S4 paddings"
echo " 4) Edit H1-H4 headers"
echo " 5) Edit all groups manually"
echo " 6) Finish tuning"
echo ""
read -rp "Choice [6]: " tune_choice
case "${tune_choice:-6}" in
1) prompt_edit_signatures ;;
2) prompt_edit_junk_params ;;
3) prompt_edit_padding_params ;;
4) prompt_edit_headers ;;
5) prompt_full_manual_profile ;;
6) break ;;
*) warn "Invalid choice" ;;
esac
done
}
select_preset() {
echo ""
info "Select AWG server template:"
echo ""
print_awg_parameter_guide
echo "These are VPnBot starting templates for this server, not official Amnezia presets."
echo "Clients must use the same S1-S4/H1-H4 as this server, so users cannot switch"
echo "between these templates later on the same AWG interface."
echo ""
echo "Recommended ready templates:"
echo " 1) CPS QUIC balanced — Fast main server template"
echo " 2) CPS DNS light — Lighter fallback for mobile and unstable networks"
echo " 3) CPS QUIC heavy — Heavier template for stricter DPI"
echo " 4) CPS QUIC router — Light template for MikroTik/GL.iNet/Keenetic/OpenWrt"
echo ""
echo "Legacy VPnBot templates:"
echo " 5) CPS QUIC legacy — Old balanced template"
echo " 6) CPS DNS legacy — Old DNS-like template"
echo " 7) CPS QUIC max — Old heavy template"
echo " 8) AWG light — Old minimal template without CPS"
echo ""
echo "Manual mode:"
echo " 9) Custom manual — Start from QUIC-safe and edit every group yourself"
echo ""
read -rp "Choice [1]: " preset_choice
preset_choice="${preset_choice:-1}"
case "${preset_choice}" in
1)
apply_preset_profile speed_stealth
;;
2)
apply_preset_profile mobile_fallback
;;
3)
apply_preset_profile hard_dpi
;;
4)
apply_preset_profile router_quic
;;
5)
apply_preset_profile quic
;;
6)
apply_preset_profile dns
;;
7)
apply_preset_profile stealth
;;
8)
apply_preset_profile fast
;;
9)
apply_preset_profile quic
PRESET_NAME=""
PRESET_LABEL="Custom manual"
PRESET_HINT="Ручная настройка всех групп параметров поверх стартовых QUIC-safe значений."
PRESET_TUNED="true"
prompt_full_manual_profile
;;
*)
err "Invalid choice"; exit 1
;;
esac
if [[ "${preset_choice}" != "9" ]]; then
print_current_awg_profile_summary
echo ""
ask_more="Open advanced fine-tuning menu? [y/N]: "
read -rp "${ask_more}" tune_more
if [[ "${tune_more}" =~ ^[Yy]$ ]]; then
prompt_fine_tune_menu
fi
fi
if ! validate_current_awg_inputs; then
err "AWG server profile ${PRESET_LABEL:-custom} failed validation"
exit 1
fi
log "Server template: ${PRESET_LABEL:-Custom manual}"
}
check_port_available() {
local port="$1"
# Check if something is already listening on this UDP port
if ss -ulnp 2>/dev/null | grep -q ":${port} "; then
local proc
proc=$(ss -ulnp 2>/dev/null | grep ":${port} " | grep -oP 'users:\(\("\K[^"]+' | head -1)
warn "UDP port ${port} is already in use by: ${proc:-unknown}"
return 1
fi
# Also check TCP (e.g. nginx on 443)
if ss -tlnp 2>/dev/null | grep -q ":${port} "; then
local proc
proc=$(ss -tlnp 2>/dev/null | grep ":${port} " | grep -oP 'users:\(\("\K[^"]+' | head -1)
info "Note: TCP port ${port} is used by ${proc:-something} (AWG uses UDP, no conflict)"
fi
return 0
}
configure_port() {
while true; do
read -rp "AWG listen port [${AWG_PORT}]: " user_port
user_port="${user_port:-${AWG_PORT}}"
if ! validate_port "${user_port}"; then
continue
fi
if ! check_port_available "${user_port}"; then
read -rp "Use port ${user_port} anyway? [y/N]: " force
if [[ ! "${force}" =~ ^[Yy]$ ]]; then
continue
fi
fi
AWG_PORT="${user_port}"
break
done
}
get_public_ip() {
SERVER_PUBLIC_IP=$(curl -s4 --max-time 5 ifconfig.me || curl -s4 --max-time 5 icanhazip.com || curl -s4 --max-time 5 api.ipify.org || echo "DETECT_FAILED")
if [[ "${SERVER_PUBLIC_IP}" == "DETECT_FAILED" ]]; then
read -rp "Could not detect public IP. Enter manually: " SERVER_PUBLIC_IP
fi
log "Public IP: ${SERVER_PUBLIC_IP}"
}
configure_endpoint_host() {
if [[ -z "${AWG_ENDPOINT_HOST}" ]]; then
AWG_ENDPOINT_HOST="${SERVER_PUBLIC_IP}"
else
AWG_ENDPOINT_HOST="$(trim_dot_domain "${AWG_ENDPOINT_HOST}")"
fi
}
validate_awg_endpoint_host() {
local timeout="${AWG_ENDPOINT_WAIT_TIMEOUT:-180}"
local interval="${AWG_ENDPOINT_WAIT_INTERVAL:-5}"
if [[ -z "${SERVER_PUBLIC_IP}" ]]; then
err "SERVER_PUBLIC_IP is empty; call get_public_ip before validating AWG endpoint host."
exit 1
fi
configure_endpoint_host
info "Checking that AWG endpoint ${AWG_ENDPOINT_HOST} points to this server (${SERVER_PUBLIC_IP})..."
if ! wait_for_host_resolution_to_ipv4 "${AWG_ENDPOINT_HOST}" "${SERVER_PUBLIC_IP}" "AWG endpoint host" "${timeout}" "${interval}"; then
err "Refusing to continue: AWG endpoint ${AWG_ENDPOINT_HOST} does not belong to this server."
err "Fix the A record so ${AWG_ENDPOINT_HOST} resolves to ${SERVER_PUBLIC_IP}, or set AWG_ENDPOINT_HOST=${SERVER_PUBLIC_IP}."
exit 1
fi
log "Verified AWG endpoint ${AWG_ENDPOINT_HOST} -> ${SERVER_PUBLIC_IP}"
}
# ===== SYSTEM SETUP =====
detect_default_interface() {
local iface_out
iface_out=$(ip route show default | awk '{print $5}' | head -1)
if [[ -z "${iface_out}" ]]; then
err "Cannot detect default network interface"
return 1
fi
printf '%s\n' "${iface_out}"
}
build_firewall_hook_commands() {
local iface_out="${1:-}"
local policy_chain=""
if [[ -z "${iface_out}" ]]; then
err "build_firewall_hook_commands requires default interface"
return 1
fi
policy_chain="${AWG_POLICY_CHAIN_PREFIX}%i"
# Low-overhead anti-torrent policy:
# block the most common BitTorrent / DHT / tracker ports before the broad
# "allow all forwarded traffic from AWG" rule. This does not pretend to be
# DPI and will not catch deliberate protocol camouflage, but it is cheap on
# CPU and stops the usual direct torrent usage patterns.
AWG_POSTUP_CMD="iptables -N ${policy_chain} 2>/dev/null || true; "
AWG_POSTUP_CMD+="iptables -F ${policy_chain}; "
AWG_POSTUP_CMD+="iptables -A ${policy_chain} -p tcp -m multiport --dports 6881:6999,51413,16881 -j REJECT --reject-with tcp-reset; "
AWG_POSTUP_CMD+="iptables -A ${policy_chain} -p udp -m multiport --dports 6881:6999,51413,6771,6969,16881 -j DROP; "
AWG_POSTUP_CMD+="iptables -A ${policy_chain} -p tcp --dport 853 -j REJECT --reject-with tcp-reset; "
AWG_POSTUP_CMD+="iptables -A ${policy_chain} -p udp --dport 853 -j DROP; "
AWG_POSTUP_CMD+="iptables -I INPUT -i %i -p udp --dport ${AWG_DNS_BLOCK_PORT} -j ACCEPT; "
AWG_POSTUP_CMD+="iptables -I INPUT -i %i -p tcp --dport ${AWG_DNS_BLOCK_PORT} -j ACCEPT; "
AWG_POSTUP_CMD+="iptables -t nat -A PREROUTING -i %i -p udp --dport 53 -j REDIRECT --to-ports ${AWG_DNS_BLOCK_PORT}; "
AWG_POSTUP_CMD+="iptables -t nat -A PREROUTING -i %i -p tcp --dport 53 -j REDIRECT --to-ports ${AWG_DNS_BLOCK_PORT}; "
AWG_POSTUP_CMD+="iptables -A FORWARD -i %i -j ${policy_chain}; "
AWG_POSTUP_CMD+="iptables -A FORWARD -i %i -j ACCEPT; "
AWG_POSTUP_CMD+="iptables -A FORWARD -o %i -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; "
AWG_POSTUP_CMD+="iptables -t nat -A POSTROUTING -o ${iface_out} -j MASQUERADE"
AWG_POSTDOWN_CMD="iptables -D INPUT -i %i -p udp --dport ${AWG_DNS_BLOCK_PORT} -j ACCEPT 2>/dev/null || true; "
AWG_POSTDOWN_CMD+="iptables -D INPUT -i %i -p tcp --dport ${AWG_DNS_BLOCK_PORT} -j ACCEPT 2>/dev/null || true; "
AWG_POSTDOWN_CMD+="iptables -D FORWARD -i %i -j ${policy_chain} 2>/dev/null || true; "
AWG_POSTDOWN_CMD+="iptables -D FORWARD -i %i -j ACCEPT 2>/dev/null || true; "
AWG_POSTDOWN_CMD+="iptables -D FORWARD -o %i -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true; "
AWG_POSTDOWN_CMD+="iptables -t nat -D PREROUTING -i %i -p udp --dport 53 -j REDIRECT --to-ports ${AWG_DNS_BLOCK_PORT} 2>/dev/null || true; "
AWG_POSTDOWN_CMD+="iptables -t nat -D PREROUTING -i %i -p tcp --dport 53 -j REDIRECT --to-ports ${AWG_DNS_BLOCK_PORT} 2>/dev/null || true; "
AWG_POSTDOWN_CMD+="iptables -t nat -D POSTROUTING -o ${iface_out} -j MASQUERADE 2>/dev/null || true; "
AWG_POSTDOWN_CMD+="iptables -F ${policy_chain} 2>/dev/null || true; "
AWG_POSTDOWN_CMD+="iptables -X ${policy_chain} 2>/dev/null || true"
}
enable_forwarding() {
sysctl -w net.ipv4.ip_forward=1 > /dev/null
sysctl -w net.ipv6.conf.all.forwarding=1 > /dev/null 2>&1 || true
# Debian Trixie may not have /etc/sysctl.conf — create if missing
touch /etc/sysctl.conf
if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf; then
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
fi
if ! grep -q "^net.ipv6.conf.all.forwarding=1" /etc/sysctl.conf; then
echo "net.ipv6.conf.all.forwarding=1" >> /etc/sysctl.conf
fi
log "IP forwarding enabled (v4 + v6)"
}
write_server_config() {
mkdir -p "${AWG_CONFIG_DIR}"
local iface_out
iface_out=$(detect_default_interface) || exit 1
log "Default interface: ${iface_out}"
build_firewall_hook_commands "${iface_out}" || exit 1
(
umask 077
{
printf '%s\n' '[Interface]'
printf 'PrivateKey = %s\n' "${SERVER_PRIVATE_KEY}"
printf 'Address = %s\n' "${AWG_SERVER_IP}"
printf 'ListenPort = %s\n' "${AWG_PORT}"
printf 'PostUp = %s\n' "${AWG_POSTUP_CMD}"
printf 'PostDown = %s\n' "${AWG_POSTDOWN_CMD}"
printf '\n'
printf 'Jc = %s\n' "${Jc}"
printf 'Jmin = %s\n' "${Jmin}"
printf 'Jmax = %s\n' "${Jmax}"
printf 'S1 = %s\n' "${S1}"
printf 'S2 = %s\n' "${S2}"
printf 'S3 = %s\n' "${S3}"
printf 'S4 = %s\n' "${S4}"
printf 'H1 = %s\n' "${H1}"
printf 'H2 = %s\n' "${H2}"
printf 'H3 = %s\n' "${H3}"
printf 'H4 = %s\n' "${H4}"
if [[ -n "${I1}" ]]; then printf 'I1 = %s\n' "${I1}"; fi
if [[ -n "${I2}" ]]; then printf 'I2 = %s\n' "${I2}"; fi
if [[ -n "${I3}" ]]; then printf 'I3 = %s\n' "${I3}"; fi
if [[ -n "${I4}" ]]; then printf 'I4 = %s\n' "${I4}"; fi
if [[ -n "${I5}" ]]; then printf 'I5 = %s\n' "${I5}"; fi
} > "${AWG_CONFIG_FILE}"
)
chmod 600 "${AWG_CONFIG_FILE}"
log "Server config written to ${AWG_CONFIG_FILE}"
}
self_check_written_server_config() {
log "Self-checking written AWG config..."
export _AWG_SELF_CONFIG_FILE="${AWG_CONFIG_FILE}"
export _AWG_SELF_Jc="${Jc}" _AWG_SELF_Jmin="${Jmin}" _AWG_SELF_Jmax="${Jmax}"
export _AWG_SELF_S1="${S1}" _AWG_SELF_S2="${S2}" _AWG_SELF_S3="${S3}" _AWG_SELF_S4="${S4}"
export _AWG_SELF_H1="${H1}" _AWG_SELF_H2="${H2}" _AWG_SELF_H3="${H3}" _AWG_SELF_H4="${H4}"
export _AWG_SELF_I1="${I1:-}" _AWG_SELF_I2="${I2:-}" _AWG_SELF_I3="${I3:-}" _AWG_SELF_I4="${I4:-}" _AWG_SELF_I5="${I5:-}"
python3 <<'PY'
import os
import sys
from pathlib import Path
path = Path(os.environ["_AWG_SELF_CONFIG_FILE"])
expected = {
"Jc": os.environ["_AWG_SELF_Jc"],
"Jmin": os.environ["_AWG_SELF_Jmin"],
"Jmax": os.environ["_AWG_SELF_Jmax"],
"S1": os.environ["_AWG_SELF_S1"],
"S2": os.environ["_AWG_SELF_S2"],
"S3": os.environ["_AWG_SELF_S3"],
"S4": os.environ["_AWG_SELF_S4"],
"H1": os.environ["_AWG_SELF_H1"],
"H2": os.environ["_AWG_SELF_H2"],
"H3": os.environ["_AWG_SELF_H3"],
"H4": os.environ["_AWG_SELF_H4"],
}
for key in ("I1", "I2", "I3", "I4", "I5"):
value = os.environ.get(f"_AWG_SELF_{key}", "")
if value:
expected[key] = value
if not path.exists():
print(f"config file is missing: {path}", file=sys.stderr)
raise SystemExit(1)
section = None
seen = {}
duplicates = []
for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
section = line
continue
if section != "[Interface]" or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if key in expected:
if key in seen:
duplicates.append(f"{key} at line {line_no}")
seen[key] = value
errors = []
for key, expected_value in expected.items():
actual_value = seen.get(key)
if actual_value is None:
errors.append(f"{key} is missing")
elif actual_value != expected_value:
errors.append(f"{key}: expected {expected_value!r}, got {actual_value!r}")
for key in ("I1", "I2", "I3", "I4", "I5"):
if key not in expected and key in seen:
errors.append(f"{key}: unexpected value {seen[key]!r}")
errors.extend(f"duplicate {item}" for item in duplicates)
if errors:
print("AWG config self-check failed:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
raise SystemExit(1)
PY
local stripped_config stripped_log
stripped_config=$(mktemp)
stripped_log=$(mktemp)
if awg-quick strip "${AWG_INTERFACE}" >"${stripped_config}" 2>"${stripped_log}"; then
if [[ -s "${stripped_config}" ]]; then
log " awg-quick strip ${AWG_INTERFACE}: OK"
else
err " awg-quick strip ${AWG_INTERFACE}: empty output"
rm -f "${stripped_config}" "${stripped_log}"
return 1
fi
else
err " awg-quick strip ${AWG_INTERFACE}: FAILED"
sed 's/^/ /' "${stripped_log}" >&2 || true
rm -f "${stripped_config}" "${stripped_log}"
return 1
fi
rm -f "${stripped_config}" "${stripped_log}"
log "AWG config self-check passed"
}
setup_systemd() {
local start_mode="${1:-start}"
cat > "${AWG_SYSTEMD_UNIT_FILE}" << 'UNIT'
[Unit]
Description=AmneziaWG via awg-quick for %I
After=network-online.target nss-lookup.target
Wants=network-online.target nss-lookup.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/awg-quick up %i
ExecStop=/usr/local/bin/awg-quick down %i
# awg-quick always tries the kernel interface first. These variables only keep
# amneziawg-go available as a fallback when the module cannot serve this kernel.
Environment=WG_QUICK_USERSPACE_IMPLEMENTATION=/usr/local/bin/amneziawg-go
Environment=WG_I_PREFER_BUGGY_USERSPACE_TO_POLISHED_KMOD=1
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable "awg-quick@${AWG_INTERFACE}"
if [[ "${start_mode}" == "start" ]]; then
systemctl start "awg-quick@${AWG_INTERFACE}"
log "systemd service enabled and started"
else
log "systemd service definition prepared without touching the serving tunnel"
fi
}
detect_active_awg_implementation() {
if ip -d link show "${AWG_INTERFACE}" 2>/dev/null | grep -q "amneziawg"; then
printf '%s\n' "kernel"
return 0
fi
if pgrep -f "(^|/)amneziawg-go[[:space:]]+${AWG_INTERFACE}([[:space:]]|$)" >/dev/null 2>&1; then
printf '%s\n' "userspace-go"
return 0
fi
printf '%s\n' "unknown"
return 1
}
verify_active_awg_runtime_config() {
local showconf_file showconf_log
showconf_file=$(mktemp)
showconf_log=$(mktemp)
if ! awg showconf "${AWG_INTERFACE}" >"${showconf_file}" 2>"${showconf_log}"; then
err " Runtime config check: awg showconf ${AWG_INTERFACE} failed"
sed 's/^/ /' "${showconf_log}" >&2 || true
rm -f "${showconf_file}" "${showconf_log}"
return 1
fi
export _AWG_SELF_CONFIG_FILE="${AWG_CONFIG_FILE}"
export _AWG_SELF_SHOWCONF_FILE="${showconf_file}"
if python3 <<'PY'
import os
import sys
from pathlib import Path
config_path = Path(os.environ["_AWG_SELF_CONFIG_FILE"])
showconf_path = Path(os.environ["_AWG_SELF_SHOWCONF_FILE"])
awg_keys = {"Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"}
def read_interface_values(path: Path) -> dict[str, str]:
section = None
values = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
section = line
continue
if section != "[Interface]" or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if key in awg_keys:
values[key] = value
return values
expected = read_interface_values(config_path)
actual = read_interface_values(showconf_path)
errors = []
for key, expected_value in expected.items():
actual_value = actual.get(key)
if actual_value is None:
if key in {"S1", "S2", "S3", "S4"} and expected_value == "0":
continue
errors.append(f"{key} is missing in awg showconf output")
elif actual_value != expected_value:
errors.append(f"{key}: expected {expected_value!r}, got {actual_value!r}")
if errors:
print("active AWG runtime config differs from disk config:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
raise SystemExit(1)
PY
then
log " Runtime AWG params: match active awg showconf"
rm -f "${showconf_file}" "${showconf_log}"
return 0
fi
rm -f "${showconf_file}" "${showconf_log}"
return 1
}
awg_bundle_fingerprint() {
print_awg_bundle | sha256sum | awk '{print $1}'
}
write_awg_bundle_state() {
local status="${1:-installed}" target="${AWG_KERNEL_TARGET:-$(uname -r)}"
local tmp
install -d -m 0700 "${AWG_UPDATE_STATE_DIR}"
tmp=$(mktemp "${AWG_UPDATE_STATE_DIR}/.installed.env.XXXXXX")
chmod 0600 "${tmp}"
{
printf 'BUNDLE_FINGERPRINT=%s\n' "$(awg_bundle_fingerprint)"
print_awg_bundle
printf 'STATUS=%s\n' "${status}"
printf 'KERNEL_TARGET=%s\n' "${target}"
printf 'APPLIED_AT=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} >"${tmp}"
mv -f "${tmp}" "${AWG_UPDATE_STATE_FILE}"
}
setup_weekly_awg_update() {
install -d -m 0755 "$(dirname "${AWG_UPDATE_SCRIPT}")"
install -d -m 0700 "${AWG_UPDATE_STATE_DIR}"
cat >"${AWG_UPDATE_SCRIPT}" <<'UPDATER'
#!/usr/bin/env bash
set -Eeuo pipefail
INSTALLER_URL="${VPNBOT_AWG_INSTALLER_URL:-https://git2.zapret.moe/zapretdiscordyoutube/awg-node-update/raw/branch/main/install_awg.sh}"
STATE_FILE="${VPNBOT_AWG_UPDATE_STATE_FILE:-/var/lib/vpnbot/awg-update/installed.env}"
LOCK_FILE="${VPNBOT_AWG_UPDATE_LOCK_FILE:-/run/lock/vpnbot-awg-update.lock}"
log() { printf '[vpnbot-awg-update] %s\n' "$*"; }
exec 9>"${LOCK_FILE}"
if ! flock -n 9; then
log "another AWG update is already running; skipping"
exit 0
fi
installer=$(mktemp /tmp/vpnbot-awg-installer.XXXXXX)
cleanup() { rm -f "${installer}"; }
trap cleanup EXIT
curl -fsSL --retry 3 --connect-timeout 10 --max-time 90 \
-H 'Cache-Control: no-cache' \
"${INSTALLER_URL}?ts=$(date +%s)" -o "${installer}"
chmod 0700 "${installer}"
bash -n "${installer}"
bundle=$(bash "${installer}" --print-bundle)
desired_fingerprint=$(printf '%s\n' "${bundle}" | sha256sum | awk '{print $1}')
desired_kernel_tag=$(printf '%s\n' "${bundle}" | awk -F= '$1 == "AWG_KERNEL_TAG" {print $2}')
if [[ ! "${desired_fingerprint}" =~ ^[0-9a-f]{64}$ || ! "${desired_kernel_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
log "downloaded installer did not expose a valid synchronized bundle"
exit 1
fi
installed_fingerprint=""
installed_kernel_tag=""
installed_status=""
installed_kernel_target=""
if [[ -f "${STATE_FILE}" ]]; then
installed_fingerprint=$(awk -F= '$1 == "BUNDLE_FINGERPRINT" {print $2}' "${STATE_FILE}")
installed_kernel_tag=$(awk -F= '$1 == "AWG_KERNEL_TAG" {print $2}' "${STATE_FILE}")
installed_status=$(awk -F= '$1 == "STATUS" {print $2}' "${STATE_FILE}")
installed_kernel_target=$(awk -F= '$1 == "KERNEL_TARGET" {print $2}' "${STATE_FILE}")
fi
if [[ -n "${installed_kernel_tag}" ]]; then
newest=$(printf '%s\n%s\n' "${installed_kernel_tag#v}" "${desired_kernel_tag#v}" | sort -V | tail -n1)
if [[ "${newest}" != "${desired_kernel_tag#v}" ]]; then
log "refusing downgrade from ${installed_kernel_tag} to ${desired_kernel_tag}"
exit 1
fi
fi
if [[ "${installed_fingerprint}" == "${desired_fingerprint}" \
&& "${installed_status}" == "pending_reboot" \
&& -n "${installed_kernel_target}" \
&& "${installed_kernel_target}" != "$(uname -r)" \
&& "$(modinfo -k "${installed_kernel_target}" -F version amneziawg 2>/dev/null || true)" == "${desired_kernel_tag#v}" ]]; then
log "${desired_kernel_tag} is already prepared for ${installed_kernel_target}; waiting for a controlled reboot"
exit 0
fi
runtime_kernel_version=$(modinfo -F version amneziawg 2>/dev/null || true)
runtime_implementation="unknown"
if ip -d link show awg0 2>/dev/null | grep -q amneziawg; then
runtime_implementation="kernel"
elif pgrep -f '(^|/)amneziawg-go[[:space:]]+awg0([[:space:]]|$)' >/dev/null 2>&1; then
runtime_implementation="userspace-go"
fi
if [[ "${installed_fingerprint}" == "${desired_fingerprint}" \
&& "${runtime_kernel_version}" == "${desired_kernel_tag#v}" \
&& "${runtime_implementation}" == "kernel" \
&& "$(systemctl is-active awg-quick@awg0.service 2>/dev/null || true)" == "active" ]]; then
if [[ "${installed_status}" == "pending_reboot" ]]; then
state_tmp=$(mktemp "${STATE_FILE}.XXXXXX")
awk -v kernel="$(uname -r)" -v activated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
/^STATUS=/ {print "STATUS=installed"; next}
/^KERNEL_TARGET=/ {print "KERNEL_TARGET=" kernel; next}
/^ACTIVATED_AT=/ {next}
{print}
END {print "ACTIVATED_AT=" activated}
' "${STATE_FILE}" >"${state_tmp}"
chmod 0600 "${state_tmp}"
mv -f "${state_tmp}" "${STATE_FILE}"
log "prepared bundle became active after the controlled reboot; state marked installed"
fi
log "${desired_kernel_tag} is already active through the kernel; nothing to update"
exit 0
fi
log "applying synchronized official bundle ${desired_kernel_tag}"
AWG_BUILD_JOBS="${AWG_BUILD_JOBS:-1}" \
AWG_REQUIRE_KERNEL=true \
VPNBOT_AWG_UPDATE_LOCK_HELD=1 \
bash "${installer}" --core-upgrade
UPDATER
chmod 0755 "${AWG_UPDATE_SCRIPT}"
cat >"${AWG_UPDATE_SERVICE_FILE}" <<'SERVICE'
[Unit]
Description=VPnBot weekly AmneziaWG kernel-first update
Documentation=https://github.com/amnezia-vpn/amneziawg-linux-kernel-module
After=network-online.target awg-quick@awg0.service
Wants=network-online.target
ConditionPathExists=/etc/amnezia/amneziawg/awg0.conf
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/vpnbot-awg-update
TimeoutStartSec=45min
Nice=10
IOSchedulingClass=idle
CPUAccounting=true
CPUQuota=60%
Environment=AWG_BUILD_JOBS=1
SERVICE
cat >"${AWG_UPDATE_TIMER_FILE}" <<'TIMER'
[Unit]
Description=Check for a reviewed AmneziaWG update once a week
[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=true
RandomizedDelaySec=18h
AccuracySec=30min
Unit=vpnbot-awg-update.service
[Install]
WantedBy=timers.target
TIMER
chmod 0644 "${AWG_UPDATE_SERVICE_FILE}" "${AWG_UPDATE_TIMER_FILE}"
systemctl daemon-reload
systemctl enable --now vpnbot-awg-update.timer
log "Weekly AWG update check installed; unchanged bundles are not rebuilt or restarted"
}
begin_awg_core_transaction() {
local module_path=""
install -d -m 0700 "${AWG_UPDATE_STATE_DIR}"
AWG_UPDATE_TRANSACTION_DIR=$(mktemp -d "${AWG_UPDATE_STATE_DIR}/transaction.XXXXXX")
chmod 0700 "${AWG_UPDATE_TRANSACTION_DIR}"
mkdir -p "${AWG_UPDATE_TRANSACTION_DIR}/files"
[[ -f /usr/local/bin/amneziawg-go ]] && cp -a /usr/local/bin/amneziawg-go "${AWG_UPDATE_TRANSACTION_DIR}/files/amneziawg-go"
[[ -f /usr/local/bin/awg ]] && cp -a /usr/local/bin/awg "${AWG_UPDATE_TRANSACTION_DIR}/files/awg"
[[ -f /usr/local/bin/awg-quick ]] && cp -a /usr/local/bin/awg-quick "${AWG_UPDATE_TRANSACTION_DIR}/files/awg-quick"
[[ -f "${AWG_SYSTEMD_UNIT_FILE}" ]] && cp -a "${AWG_SYSTEMD_UNIT_FILE}" "${AWG_UPDATE_TRANSACTION_DIR}/files/awg-quick@.service"
cp -a "${AWG_CONFIG_FILE}" "${AWG_UPDATE_TRANSACTION_DIR}/files/awg0.conf.evidence"
printf '%s\n' "$(systemctl is-active "awg-quick@${AWG_INTERFACE}" 2>/dev/null || true)" >"${AWG_UPDATE_TRANSACTION_DIR}/previous-service-state"
printf '%s\n' "$(detect_active_awg_implementation || true)" >"${AWG_UPDATE_TRANSACTION_DIR}/previous-implementation"
module_path=$(modinfo -n amneziawg 2>/dev/null || true)
if [[ -n "${module_path}" && -f "${module_path}" && "${module_path}" == /lib/modules/* ]]; then
printf '%s\n' "${module_path}" >"${AWG_UPDATE_TRANSACTION_DIR}/previous-module-path"
cp -a "${module_path}" "${AWG_UPDATE_TRANSACTION_DIR}/files/amneziawg-module"
fi
AWG_UPDATE_TRANSACTION_COMMITTED=false
AWG_UPDATE_CUTOVER_STARTED=false
log "Prepared protected AWG rollback snapshot ${AWG_UPDATE_TRANSACTION_DIR}"
}
restore_awg_transaction_file() {
local backup_name="$1" target="$2" mode="$3"
local backup="${AWG_UPDATE_TRANSACTION_DIR}/files/${backup_name}"
if [[ -f "${backup}" ]]; then
atomic_install_file "${backup}" "${target}" "${mode}"
elif [[ -e "${target}" || -L "${target}" ]]; then
mv -f "${target}" "${AWG_UPDATE_TRANSACTION_DIR}/files/failed-${backup_name}"
fi
}
rollback_awg_core_transaction() {
local current_module_path="" previous_module_path="" previous_service_state="" previous_implementation=""
trap - EXIT ERR
set +e
warn "AWG core update failed; restoring the previous runnable generation"
previous_service_state=$(cat "${AWG_UPDATE_TRANSACTION_DIR}/previous-service-state" 2>/dev/null || true)
previous_implementation=$(cat "${AWG_UPDATE_TRANSACTION_DIR}/previous-implementation" 2>/dev/null || true)
if [[ "${AWG_UPDATE_CUTOVER_STARTED}" == true ]]; then
systemctl stop "awg-quick@${AWG_INTERFACE}" >/dev/null 2>&1 || true
fi
restore_awg_transaction_file amneziawg-go /usr/local/bin/amneziawg-go 0755
restore_awg_transaction_file awg /usr/local/bin/awg 0755
restore_awg_transaction_file awg-quick /usr/local/bin/awg-quick 0755
restore_awg_transaction_file awg-quick@.service "${AWG_SYSTEMD_UNIT_FILE}" 0644
if [[ "${AWG_UPDATE_CUTOVER_STARTED}" == true \
|| "${previous_implementation}" == "userspace-go" ]]; then
modprobe -r amneziawg >/dev/null 2>&1 || true
fi
previous_module_path=$(cat "${AWG_UPDATE_TRANSACTION_DIR}/previous-module-path" 2>/dev/null || true)
if [[ -n "${previous_module_path}" && -f "${AWG_UPDATE_TRANSACTION_DIR}/files/amneziawg-module" ]]; then
install -D -m 0644 "${AWG_UPDATE_TRANSACTION_DIR}/files/amneziawg-module" "${previous_module_path}"
else
if lsmod | awk '{print $1}' | grep -Fxq amneziawg; then
modprobe -r amneziawg >/dev/null 2>&1 || true
fi
current_module_path=$(modinfo -n amneziawg 2>/dev/null || true)
if [[ -n "${current_module_path}" && -f "${current_module_path}" && "${current_module_path}" == /lib/modules/* ]]; then
mv -f "${current_module_path}" "${AWG_UPDATE_TRANSACTION_DIR}/files/failed-new-module"
fi
fi
if [[ -n "${previous_module_path}" || -f "${AWG_UPDATE_TRANSACTION_DIR}/files/failed-new-module" ]]; then
depmod -a "$(uname -r)" >/dev/null 2>&1 || true
fi
if [[ "${AWG_UPDATE_CUTOVER_STARTED}" == true && -n "${previous_module_path}" ]]; then
modprobe amneziawg >/dev/null 2>&1 || true
fi
systemctl daemon-reload >/dev/null 2>&1 || true
if [[ "${previous_service_state}" == "active" ]]; then
systemctl start "awg-quick@${AWG_INTERFACE}" >/dev/null 2>&1 || true
fi
if [[ "${previous_service_state}" == "active" ]] \
&& ! systemctl is-active --quiet "awg-quick@${AWG_INTERFACE}"; then
err "Rollback could not restore the AWG service; snapshot retained at ${AWG_UPDATE_TRANSACTION_DIR}"
return 1
fi
warn "Previous AWG generation restored; snapshot retained at ${AWG_UPDATE_TRANSACTION_DIR}"
}
verify_awg_core_cutover() {
local previous_public_key="$1" implementation module_version current_public_key
systemctl is-active --quiet "awg-quick@${AWG_INTERFACE}" || return 1
implementation=$(detect_active_awg_implementation || true)
if [[ "${implementation}" != "kernel" ]]; then
err "AWG cutover selected ${implementation}, expected the official kernel module"
return 1
fi
if pgrep -f "(^|/)amneziawg-go[[:space:]]+${AWG_INTERFACE}([[:space:]]|$)" >/dev/null 2>&1; then
err "amneziawg-go is still processing traffic after the kernel cutover"
return 1
fi
module_version=$(modinfo -F version amneziawg 2>/dev/null || true)
if [[ "${module_version}" != "${AWG_KERNEL_TAG#v}" ]]; then
err "Loaded AmneziaWG module ${module_version:-<missing>} does not match ${AWG_KERNEL_TAG#v}"
return 1
fi
current_public_key=$(awg show "${AWG_INTERFACE}" public-key 2>/dev/null || true)
if [[ -z "${current_public_key}" || "${current_public_key}" != "${previous_public_key}" ]]; then
err "AWG server public key changed during the core update"
return 1
fi
verify_active_awg_runtime_config || return 1
log "AWG core cutover verified: kernel=${module_version}, server identity and active parameters preserved"
}
finish_awg_core_transaction() {
local backup_dir stamp
AWG_UPDATE_TRANSACTION_COMMITTED=true
stamp=$(date -u +%Y%m%dT%H%M%SZ)
backup_dir="${AWG_UPDATE_STATE_DIR}/backup-${stamp}-${AWG_KERNEL_TAG#v}"
mv -f "${AWG_UPDATE_TRANSACTION_DIR}" "${backup_dir}"
AWG_UPDATE_TRANSACTION_DIR="${backup_dir}"
AWG_UPDATE_TRANSACTION_COMMITTED=true
log "Previous AWG generation retained at ${backup_dir}"
}
prune_obsolete_awg_dkms_versions() {
local version
while IFS= read -r version; do
[[ -n "${version}" ]] || continue
if [[ ! "${version}" =~ ^[0-9A-Za-z._+-]+$ ]]; then
warn "Ignoring an unexpected DKMS version label: ${version}"
continue
fi
[[ "${version}" == "${AWG_KERNEL_DKMS_VERSION}" ]] && continue
if dkms remove -m amneziawg -v "${version}" --all; then
log "Removed obsolete AmneziaWG DKMS generation ${version}"
else
warn "Could not remove obsolete AmneziaWG DKMS generation ${version}; the active tunnel remains on ${AWG_KERNEL_TAG}"
fi
done < <(
dkms status -m amneziawg 2>/dev/null \
| sed -n 's#^amneziawg/\([^,]*\),.*#\1#p' \
| sort -u
)
}
run_awg_core_upgrade_transaction() (
set -Eeuo pipefail
if [[ "${VPNBOT_AWG_UPDATE_LOCK_HELD:-0}" != "1" ]]; then
exec 8>"${AWG_UPDATE_LOCK_FILE}"
if ! flock -n 8; then
err "Another AWG core update is already running"
return 1
fi
fi
begin_awg_core_transaction
trap 'rc=$?; if [[ "${AWG_UPDATE_TRANSACTION_COMMITTED}" != true ]]; then rollback_awg_core_transaction || true; fi; exit "${rc}"' EXIT
install_dependencies
build_amneziawg
if ! install_awg_kernel_module; then
if [[ "${AWG_REQUIRE_KERNEL}" == true ]]; then
err "The official AmneziaWG kernel module could not be prepared; the serving tunnel was not cut over"
return 1
fi
fi
setup_systemd prepare-only
setup_weekly_awg_update
if [[ "${AWG_KERNEL_REBOOT_REQUIRED}" == true ]]; then
write_awg_bundle_state pending_reboot
finish_awg_core_transaction
prune_obsolete_awg_dkms_versions
log "AWG_UPDATE_RESULT=pending_reboot kernel_target=${AWG_KERNEL_TARGET}"
return 0
fi
if [[ "${AWG_KERNEL_READY}" != true ]]; then
err "The official AmneziaWG module is not ready for the running kernel"
return 1
fi
awg-quick save "${AWG_INTERFACE}"
previous_public_key=$(awg show "${AWG_INTERFACE}" public-key)
AWG_UPDATE_CUTOVER_STARTED=true
systemctl stop "awg-quick@${AWG_INTERFACE}"
if lsmod | awk '{print $1}' | grep -Fxq amneziawg; then
modprobe -r amneziawg
fi
modprobe amneziawg
systemctl start "awg-quick@${AWG_INTERFACE}"
sleep 2
verify_awg_core_cutover "${previous_public_key}"
write_awg_bundle_state installed
finish_awg_core_transaction
prune_obsolete_awg_dkms_versions
log "AWG_UPDATE_RESULT=installed tag=${AWG_KERNEL_TAG}"
)
write_connection_audit_assets() {
mkdir -p "${AWG_AUDIT_LOG_DIR}"
chmod 750 "${AWG_AUDIT_LOG_DIR}"
cat > "${AWG_AUDIT_LOGGER_SCRIPT}" <<'LOGGER'
#!/usr/bin/env python3
from __future__ import annotations
import ipaddress
import json
import os
import re
import signal
import subprocess
import sys
from datetime import datetime, timezone
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <awg-subnet-cidr> <log-file>", file=sys.stderr)
raise SystemExit(64)
subnet = ipaddress.ip_network(sys.argv[1], strict=False)
log_path = sys.argv[2]
os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
open(log_path, "a", encoding="utf-8").close()
os.chmod(log_path, 0o640)
event_re = re.compile(r"\[(?P<event>NEW|DESTROY)\]")
kv_re = re.compile(r"(?P<key>[a-z_]+)=(?P<value>\S+)")
running = True
child: subprocess.Popen[str] | None = None
def handle_stop(signum, frame):
global running
running = False
if child and child.poll() is None:
child.terminate()
signal.signal(signal.SIGTERM, handle_stop)
signal.signal(signal.SIGINT, handle_stop)
cmd = [
"conntrack",
"-E",
"-e",
"NEW,DESTROY",
"-s",
str(subnet),
"-o",
"timestamp,extended",
]
with subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
) as proc, open(log_path, "a", encoding="utf-8") as out:
child = proc
assert proc.stdout is not None
for raw_line in proc.stdout:
if not running:
break
line = raw_line.strip()
if not line:
continue
event_match = event_re.search(line)
event = event_match.group("event") if event_match else "UNKNOWN"
proto = ""
family = None
timeout = None
state = ""
after_event = re.split(r"\[(?:NEW|DESTROY)\]\s+", line, maxsplit=1)
if len(after_event) == 2:
tokens = after_event[1].split()
if len(tokens) >= 5:
family = tokens[0]
proto = tokens[2]
if tokens[4].isdigit():
timeout = int(tokens[4])
for token in tokens:
if token.startswith("[") and token.endswith("]") and token not in {"[NEW]", "[DESTROY]"}:
state = token.strip("[]")
break
values: dict[str, list[str]] = {}
for match in kv_re.finditer(line):
values.setdefault(match.group("key"), []).append(match.group("value"))
srcs = values.get("src", [])
dsts = values.get("dst", [])
sports = values.get("sport", [])
dports = values.get("dport", [])
if not srcs:
continue
try:
orig_src_ip = ipaddress.ip_address(srcs[0])
except ValueError:
continue
if orig_src_ip not in subnet:
continue
row = {
"logged_at": datetime.now(timezone.utc).isoformat(),
"event": event,
"proto": proto,
"family": family,
"timeout": timeout,
"state": state,
"orig_src": srcs[0] if len(srcs) > 0 else "",
"orig_dst": dsts[0] if len(dsts) > 0 else "",
"orig_sport": sports[0] if len(sports) > 0 else "",
"orig_dport": dports[0] if len(dports) > 0 else "",
"reply_src": srcs[1] if len(srcs) > 1 else "",
"reply_dst": dsts[1] if len(dsts) > 1 else "",
"reply_sport": sports[1] if len(sports) > 1 else "",
"reply_dport": dports[1] if len(dports) > 1 else "",
"raw": line,
}
out.write(json.dumps(row, ensure_ascii=False) + "\n")
out.flush()
LOGGER
chmod 755 "${AWG_AUDIT_LOGGER_SCRIPT}"
cat > "${AWG_AUDIT_QUERY_SCRIPT}" <<'QUERY'
#!/usr/bin/env python3
"""Search AWG conntrack flow logs by time, protocol and port."""
from __future__ import annotations
import argparse
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
import sys
DEFAULT_LOG = "/var/log/awg/conntrack-events.jsonl"
def parse_dt(value: str) -> datetime:
text = str(value or "").strip()
if not text:
raise ValueError("empty datetime")
if text.endswith("Z"):
text = text[:-1] + "+00:00"
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Search AWG conntrack flow logs. "
"Use public complaint time and public source port to find the internal AWG IP."
)
)
parser.add_argument("--log-file", default=DEFAULT_LOG)
parser.add_argument("--time", help="Center timestamp, e.g. 2026-03-31T18:03:15Z")
parser.add_argument("--window-seconds", type=int, default=300)
parser.add_argument("--since")
parser.add_argument("--until")
parser.add_argument("--port", help="Public or private port to match")
parser.add_argument("--proto", help="tcp / udp")
parser.add_argument("--internal-ip", help="Filter by internal AWG IP, e.g. 10.8.0.161")
args = parser.parse_args()
if args.time:
center = parse_dt(args.time)
delta = timedelta(seconds=max(1, int(args.window_seconds or 300)))
since = center - delta
until = center + delta
else:
since = parse_dt(args.since) if args.since else None
until = parse_dt(args.until) if args.until else None
wanted_port = str(args.port or "").strip()
wanted_proto = str(args.proto or "").strip().lower()
wanted_internal_ip = str(args.internal_ip or "").strip()
path = Path(args.log_file)
if not path.exists():
print(f"Log file not found: {path}", file=sys.stderr)
return 2
hits = 0
with path.open("r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
try:
logged_at = parse_dt(row.get("logged_at", ""))
except Exception:
continue
if since and logged_at < since:
continue
if until and logged_at > until:
continue
if wanted_proto and str(row.get("proto", "")).lower() != wanted_proto:
continue
if wanted_internal_ip and str(row.get("orig_src", "")) != wanted_internal_ip:
continue
if wanted_port:
ports = {
str(row.get("orig_sport", "")),
str(row.get("orig_dport", "")),
str(row.get("reply_sport", "")),
str(row.get("reply_dport", "")),
}
if wanted_port not in ports:
continue
hits += 1
print(
f"{row.get('logged_at')} {row.get('event')} {row.get('proto')} "
f"internal={row.get('orig_src')}:{row.get('orig_sport')} "
f"remote={row.get('orig_dst')}:{row.get('orig_dport')} "
f"nat={row.get('reply_dst')}:{row.get('reply_dport')} "
f"state={row.get('state') or '-'}"
)
if hits == 0:
print("No matching flow records found.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
QUERY
chmod 755 "${AWG_AUDIT_QUERY_SCRIPT}"
cat > "${AWG_AUDIT_SERVICE_FILE}" <<EOF
[Unit]
Description=AWG conntrack flow logger
After=network-online.target awg-quick@${AWG_INTERFACE}.service
Wants=network-online.target
Requires=awg-quick@${AWG_INTERFACE}.service
[Service]
Type=simple
ExecStart=${AWG_AUDIT_LOGGER_SCRIPT} ${AWG_SUBNET} ${AWG_AUDIT_LOG_FILE}
Restart=always
RestartSec=2
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
EOF
}
setup_connection_audit_stack() {
write_connection_audit_assets
systemctl daemon-reload
systemctl enable --now "${AWG_AUDIT_SERVICE_NAME}"
install_awg_log_hygiene
log "AWG connection audit enabled (${AWG_AUDIT_LOG_FILE})"
}
write_dns_blocking_assets() {
mkdir -p "$(dirname "${AWG_DNSMASQ_BASE_CONFIG}")"
cat > "${AWG_DNSMASQ_BASE_CONFIG}" <<EOF
# AWG abuse protection: transparent DNS sinkhole for common torrent domains.
port=${AWG_DNS_BLOCK_PORT}
listen-address=127.0.0.1
listen-address=${AWG_SERVER_IP%%/*}
bind-dynamic
no-resolv
server=127.0.0.1#${AWG_DNS_UPSTREAM_PROXY_PORT}
cache-size=10000
conf-file=${AWG_DNSMASQ_BLOCKLIST}
EOF
cat > "${AWG_DNSMASQ_BLOCKLIST}" <<'EOF'
# Common torrent trackers / torrent indexers / magnet gateways.
# This is a low-overhead blocklist, not a full DPI system.
address=/rutracker.org/0.0.0.0
address=/rutracker.org/::
address=/rutracker.net/0.0.0.0
address=/rutracker.net/::
address=/nnmclub.to/0.0.0.0
address=/nnmclub.to/::
address=/nnmclub.me/0.0.0.0
address=/nnmclub.me/::
address=/rutor.info/0.0.0.0
address=/rutor.info/::
address=/rutor.is/0.0.0.0
address=/rutor.is/::
address=/rutor.org/0.0.0.0
address=/rutor.org/::
address=/torbt.cz/0.0.0.0
address=/torbt.cz/::
address=/thepiratebay.org/0.0.0.0
address=/thepiratebay.org/::
address=/1337x.to/0.0.0.0
address=/1337x.to/::
address=/nyaa.si/0.0.0.0
address=/nyaa.si/::
address=/torrentgalaxy.to/0.0.0.0
address=/torrentgalaxy.to/::
address=/bt4gprx.com/0.0.0.0
address=/bt4gprx.com/::
address=/bt4g.org/0.0.0.0
address=/bt4g.org/::
address=/btmet.com/0.0.0.0
address=/btmet.com/::
address=/btdig.com/0.0.0.0
address=/btdig.com/::
address=/btcache.me/0.0.0.0
address=/btcache.me/::
address=/torrentz2.nz/0.0.0.0
address=/torrentz2.nz/::
address=/zooqle.com/0.0.0.0
address=/zooqle.com/::
address=/limetorrents.lol/0.0.0.0
address=/limetorrents.lol/::
address=/yts.mx/0.0.0.0
address=/yts.mx/::
address=/eztvx.to/0.0.0.0
address=/eztvx.to/::
# Public trackers.
address=/tracker.opentrackr.org/0.0.0.0
address=/tracker.opentrackr.org/::
address=/open.stealth.si/0.0.0.0
address=/open.stealth.si/::
address=/tracker.openbittorrent.com/0.0.0.0
address=/tracker.openbittorrent.com/::
address=/opentracker.i2p.rocks/0.0.0.0
address=/opentracker.i2p.rocks/::
address=/tracker.torrent.eu.org/0.0.0.0
address=/tracker.torrent.eu.org/::
address=/explodie.org/0.0.0.0
address=/explodie.org/::
address=/tracker.moeking.me/0.0.0.0
address=/tracker.moeking.me/::
address=/tracker.dler.org/0.0.0.0
address=/tracker.dler.org/::
address=/tracker.cyberia.is/0.0.0.0
address=/tracker.cyberia.is/::
address=/tracker.leechers-paradise.org/0.0.0.0
address=/tracker.leechers-paradise.org/::
EOF
}
setup_dns_blocking_stack() {
write_dns_blocking_assets
cat > "${AWG_DNS_PROXY_SCRIPT}" <<'PY'
#!/usr/bin/env python3
"""Minimal UDP DNS forwarder which relays upstream over TCP/53."""
from __future__ import annotations
import os
import socket
import struct
import sys
import threading
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = int(os.environ.get("AWG_DNS_UPSTREAM_PROXY_PORT", "5354"))
UPSTREAMS = [("1.1.1.1", 53), ("1.0.0.1", 53)]
TIMEOUT = 4.0
def query_tcp(upstream: tuple[str, int], payload: bytes) -> bytes:
with socket.create_connection(upstream, timeout=TIMEOUT) as sock:
sock.sendall(struct.pack("!H", len(payload)) + payload)
hdr = sock.recv(2)
if len(hdr) != 2:
raise RuntimeError("short DNS TCP length header")
total = struct.unpack("!H", hdr)[0]
data = b""
while len(data) < total:
chunk = sock.recv(total - len(data))
if not chunk:
raise RuntimeError("short DNS TCP payload")
data += chunk
return data
def handle_query(sock: socket.socket, payload: bytes, addr: tuple[str, int]) -> None:
last_error: Exception | None = None
for upstream in UPSTREAMS:
try:
data = query_tcp(upstream, payload)
sock.sendto(data, addr)
return
except Exception as exc:
last_error = exc
continue
# If every upstream failed, do not crash the service.
if last_error:
return
def main() -> int:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((LISTEN_HOST, LISTEN_PORT))
while True:
payload, addr = sock.recvfrom(4096)
threading.Thread(target=handle_query, args=(sock, payload, addr), daemon=True).start()
if __name__ == "__main__":
raise SystemExit(main())
PY
chmod 755 "${AWG_DNS_PROXY_SCRIPT}"
cat > "${AWG_DNS_PROXY_SERVICE_FILE}" <<EOF
[Unit]
Description=AWG DNS TCP upstream proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=AWG_DNS_UPSTREAM_PROXY_PORT=${AWG_DNS_UPSTREAM_PROXY_PORT}
ExecStart=${AWG_DNS_PROXY_SCRIPT}
Restart=always
RestartSec=2
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
EOF
cat > "${AWG_DNS_SERVICE_FILE}" <<EOF
[Unit]
Description=AWG DNS sinkhole
After=network-online.target ${AWG_DNS_PROXY_SERVICE_NAME}
Wants=network-online.target
Requires=${AWG_DNS_PROXY_SERVICE_NAME}
[Service]
Type=simple
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --conf-file=${AWG_DNSMASQ_BASE_CONFIG}
Restart=always
RestartSec=2
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now "${AWG_DNS_PROXY_SERVICE_NAME}"
systemctl enable --now "${AWG_DNS_SERVICE_NAME}"
log "Torrent DNS sinkhole enabled (${AWG_DNSMASQ_BLOCKLIST})"
}
sync_existing_config_firewall_hooks() {
local iface_out
local py="${AWG_CONFIG_DIR}/.sync_awg_hooks.py"
if [[ ! -f "${AWG_CONFIG_FILE}" ]]; then
warn "Existing config ${AWG_CONFIG_FILE} not found — skipping firewall hook sync"
return 0
fi
iface_out=$(detect_default_interface) || return 1
build_firewall_hook_commands "${iface_out}" || return 1
cat > "${py}" <<'PY'
from pathlib import Path
import sys
cfg_path = Path(sys.argv[1])
post_up = sys.argv[2]
post_down = sys.argv[3]
lines = cfg_path.read_text(encoding="utf-8").splitlines()
out = []
post_up_done = False
post_down_done = False
insert_at = None
for idx, line in enumerate(lines):
stripped = line.strip()
if insert_at is None and stripped.startswith("[Peer]"):
insert_at = idx
if stripped.startswith("PostUp = "):
out.append(f"PostUp = {post_up}")
post_up_done = True
continue
if stripped.startswith("PostDown = "):
out.append(f"PostDown = {post_down}")
post_down_done = True
continue
out.append(line)
if insert_at is None:
insert_at = len(out)
missing = []
if not post_up_done:
missing.append(f"PostUp = {post_up}")
if not post_down_done:
missing.append(f"PostDown = {post_down}")
if missing:
out = out[:insert_at] + missing + out[insert_at:]
cfg_path.write_text("\n".join(out) + "\n", encoding="utf-8")
PY
python3 "${py}" "${AWG_CONFIG_FILE}" "${AWG_POSTUP_CMD}" "${AWG_POSTDOWN_CMD}"
rm -f "${py}"
log "Existing AWG config firewall hooks synced"
}
open_firewall() {
if command -v ufw &>/dev/null && ufw status | grep -q "active"; then
ufw allow "${AWG_PORT}/udp"
log "UFW: opened port ${AWG_PORT}/udp"
else
iptables -I INPUT -p udp --dport "${AWG_PORT}" -j ACCEPT
if command -v netfilter-persistent &>/dev/null; then
netfilter-persistent save 2>/dev/null || true
log "iptables: opened port ${AWG_PORT}/udp (persisted via netfilter-persistent)"
else
warn "iptables: opened port ${AWG_PORT}/udp (TEMPORARY — will be lost on reboot!)"
warn "Install iptables-persistent or add the rule to your firewall config manually."
fi
fi
}
# ===== VERIFICATION =====
run_network_smoke_test() {
local smoke_errors=0
local iface_out runtime_port runtime_addr
echo ""
info "Server readiness smoke test:"
if grep -Fxq "Address = ${AWG_SERVER_IP}" "${AWG_CONFIG_FILE}"; then
log " Config Address: ${AWG_SERVER_IP}"
else
err " Config Address mismatch or missing"
smoke_errors=$((smoke_errors + 1))
fi
if grep -Fxq "ListenPort = ${AWG_PORT}" "${AWG_CONFIG_FILE}"; then
log " Config ListenPort: ${AWG_PORT}"
else
err " Config ListenPort mismatch or missing"
smoke_errors=$((smoke_errors + 1))
fi
runtime_port=$(awg show "${AWG_INTERFACE}" listen-port 2>/dev/null || true)
if [[ "${runtime_port}" == "${AWG_PORT}" ]]; then
log " Runtime ListenPort: ${runtime_port}"
else
err " Runtime ListenPort mismatch: expected ${AWG_PORT}, got ${runtime_port:-none}"
smoke_errors=$((smoke_errors + 1))
fi
runtime_addr=$(ip -o -4 addr show dev "${AWG_INTERFACE}" 2>/dev/null | awk '{print $4}' | head -1)
if [[ "${runtime_addr}" == "${AWG_SERVER_IP}" ]]; then
log " Runtime Address: ${runtime_addr}"
else
err " Runtime Address mismatch: expected ${AWG_SERVER_IP}, got ${runtime_addr:-none}"
smoke_errors=$((smoke_errors + 1))
fi
if iptables -C FORWARD -i "${AWG_INTERFACE}" -j ACCEPT &>/dev/null; then
log " FORWARD ingress rule: present"
else
err " FORWARD ingress rule missing"
smoke_errors=$((smoke_errors + 1))
fi
if iptables -S "${AWG_POLICY_CHAIN_PREFIX}${AWG_INTERFACE}" &>/dev/null; then
log " Anti-torrent policy chain: present"
else
err " Anti-torrent policy chain missing"
smoke_errors=$((smoke_errors + 1))
fi
if iptables -C FORWARD -o "${AWG_INTERFACE}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT &>/dev/null; then
log " FORWARD egress rule: present"
else
err " FORWARD egress rule missing"
smoke_errors=$((smoke_errors + 1))
fi
iface_out=$(detect_default_interface || true)
if [[ -n "${iface_out}" ]] && iptables -t nat -C POSTROUTING -o "${iface_out}" -j MASQUERADE &>/dev/null; then
log " MASQUERADE on ${iface_out}: present"
else
err " MASQUERADE rule missing for default route interface"
smoke_errors=$((smoke_errors + 1))
fi
if [[ ${smoke_errors} -eq 0 ]]; then
log " Smoke verdict: PASS"
else
err " Smoke verdict: FAIL (${smoke_errors} issue(s))"
fi
return ${smoke_errors}
}
verify_installation() {
echo ""
log "Running verification checks..."
local errors=0
local smoke_errors=0
for bin in amneziawg-go awg awg-quick; do
if command -v "${bin}" &>/dev/null; then
log " ${bin}: OK ($(command -v "${bin}"))"
else
err " ${bin}: NOT FOUND"
errors=$((errors + 1))
fi
done
if systemctl is-active "awg-quick@${AWG_INTERFACE}" &>/dev/null; then
log " Service awg-quick@${AWG_INTERFACE}: ACTIVE"
else
err " Service awg-quick@${AWG_INTERFACE}: NOT ACTIVE"
journalctl -u "awg-quick@${AWG_INTERFACE}" --no-pager -n 10 2>/dev/null || true
errors=$((errors + 1))
fi
if awg show "${AWG_INTERFACE}" &>/dev/null; then
log " Interface ${AWG_INTERFACE}: UP"
info " Listening port: $(awg show "${AWG_INTERFACE}" listen-port)"
info " Public key: $(awg show "${AWG_INTERFACE}" public-key)"
local peer_count
peer_count=$(awg show "${AWG_INTERFACE}" peers 2>/dev/null | wc -l)
info " Peers: ${peer_count}"
if ! verify_active_awg_runtime_config; then
errors=$((errors + 1))
fi
local active_implementation
active_implementation=$(detect_active_awg_implementation || true)
if [[ "${active_implementation}" == "kernel" ]]; then
log " Implementation: kernel module (preferred low-CPU path)"
elif [[ "${active_implementation}" == "userspace-go" ]]; then
if [[ "${AWG_KERNEL_READY}" == true ]]; then
err " Implementation: amneziawg-go even though the kernel module is ready"
errors=$((errors + 1))
else
warn " Implementation: amneziawg-go fallback"
fi
else
err " Implementation: could not be determined"
errors=$((errors + 1))
fi
else
err " Interface ${AWG_INTERFACE}: DOWN"
errors=$((errors + 1))
fi
if ss -ulnp | grep -q ":${AWG_PORT} " 2>/dev/null; then
log " UDP port ${AWG_PORT}: LISTENING"
else
warn " UDP port ${AWG_PORT}: not detected in ss (may be kernel-level)"
fi
local fwd
fwd=$(cat /proc/sys/net/ipv4/ip_forward)
if [[ "${fwd}" == "1" ]]; then
log " IPv4 forwarding: ON"
else
err " IPv4 forwarding: OFF"
errors=$((errors + 1))
fi
if [[ -f "${AWG_CONFIG_FILE}" ]]; then
log " Config: ${AWG_CONFIG_FILE} ($(stat -c%s "${AWG_CONFIG_FILE}") bytes)"
else
err " Config: ${AWG_CONFIG_FILE} NOT FOUND"
errors=$((errors + 1))
fi
if systemctl is-active "${AWG_DNS_SERVICE_NAME}" &>/dev/null; then
log " DNS sinkhole service ${AWG_DNS_SERVICE_NAME}: ACTIVE"
else
err " DNS sinkhole service ${AWG_DNS_SERVICE_NAME}: NOT ACTIVE"
errors=$((errors + 1))
fi
if [[ -f "${AWG_DNSMASQ_BASE_CONFIG}" ]]; then
log " DNS block config: ${AWG_DNSMASQ_BASE_CONFIG}"
else
err " DNS block config missing: ${AWG_DNSMASQ_BASE_CONFIG}"
errors=$((errors + 1))
fi
if [[ -f "${AWG_DNSMASQ_BLOCKLIST}" ]]; then
log " Torrent blocklist: ${AWG_DNSMASQ_BLOCKLIST}"
else
err " Torrent blocklist missing: ${AWG_DNSMASQ_BLOCKLIST}"
errors=$((errors + 1))
fi
if [[ -f "${AWG_DNS_SERVICE_FILE}" ]]; then
log " DNS sinkhole unit: ${AWG_DNS_SERVICE_FILE}"
else
err " DNS sinkhole unit missing: ${AWG_DNS_SERVICE_FILE}"
errors=$((errors + 1))
fi
if systemctl is-active "${AWG_DNS_PROXY_SERVICE_NAME}" &>/dev/null; then
log " DNS TCP proxy service ${AWG_DNS_PROXY_SERVICE_NAME}: ACTIVE"
else
err " DNS TCP proxy service ${AWG_DNS_PROXY_SERVICE_NAME}: NOT ACTIVE"
errors=$((errors + 1))
fi
if [[ -f "${AWG_DNS_PROXY_SERVICE_FILE}" ]]; then
log " DNS TCP proxy unit: ${AWG_DNS_PROXY_SERVICE_FILE}"
else
err " DNS TCP proxy unit missing: ${AWG_DNS_PROXY_SERVICE_FILE}"
errors=$((errors + 1))
fi
if systemctl is-active "${AWG_AUDIT_SERVICE_NAME}" &>/dev/null; then
log " Audit service ${AWG_AUDIT_SERVICE_NAME}: ACTIVE"
else
err " Audit service ${AWG_AUDIT_SERVICE_NAME}: NOT ACTIVE"
errors=$((errors + 1))
fi
if [[ -x "${AWG_AUDIT_QUERY_SCRIPT}" ]]; then
log " Abuse query helper: ${AWG_AUDIT_QUERY_SCRIPT}"
else
err " Abuse query helper missing: ${AWG_AUDIT_QUERY_SCRIPT}"
errors=$((errors + 1))
fi
if [[ -f "${AWG_AUDIT_LOGROTATE_FILE}" ]]; then
log " Logrotate config: ${AWG_AUDIT_LOGROTATE_FILE}"
else
err " Logrotate config missing: ${AWG_AUDIT_LOGROTATE_FILE}"
errors=$((errors + 1))
fi
if [[ "${errors}" -eq 0 ]]; then
run_network_smoke_test || smoke_errors=$?
errors=$((errors + smoke_errors))
fi
echo ""
if [[ ${errors} -eq 0 ]]; then
log "All checks passed!"
else
err "${errors} check(s) failed — review the output above"
fi
return ${errors}
}
# ===== OUTPUT =====
output_bot_config() {
local country_code
country_code="$(printf '%s' "${AWG_COUNTRY_CODE}" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')"
if [[ ! "${country_code}" =~ ^[A-Z]{2}$ ]]; then
country_code="$(curl -fsSL --max-time 3 https://ipinfo.io/country 2>/dev/null | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]' || true)"
fi
if [[ ! "${country_code}" =~ ^[A-Z]{2}$ ]]; then
country_code=""
warn "Could not detect the server country. Set AWG_COUNTRY_CODE before copying the runtime JSON."
fi
echo ""
echo "==========================================="
echo " AWG 2.0 installed successfully!"
echo "==========================================="
echo ""
info "Copy the JSON below into /home/codex-pve/vpnbot/vpnbotdata/config/awg_servers.json on the bot host:"
echo ""
# Use env vars to safely pass values with special chars to Python
export _AWG_Jc="${Jc}" _AWG_Jmin="${Jmin}" _AWG_Jmax="${Jmax}"
export _AWG_S1="${S1}" _AWG_S2="${S2}" _AWG_S3="${S3}" _AWG_S4="${S4}"
export _AWG_H1="${H1}" _AWG_H2="${H2}" _AWG_H3="${H3}" _AWG_H4="${H4}"
export _AWG_I1="${I1:-}" _AWG_I2="${I2:-}" _AWG_I3="${I3:-}" _AWG_I4="${I4:-}" _AWG_I5="${I5:-}"
export _AWG_HOST="${SERVER_PUBLIC_IP}" _AWG_ENDPOINT_HOST="${AWG_ENDPOINT_HOST}" _AWG_PORT="${AWG_PORT}"
export _AWG_PUBKEY="${SERVER_PUBLIC_KEY}" _AWG_SUBNET="${AWG_SUBNET}"
export _AWG_SERVER_ADDR="${AWG_SERVER_IP}"
export _AWG_SERVER_NAME="${AWG_SERVER_NAME}" _AWG_COUNTRY_CODE="${country_code}" _AWG_LOCATION="${AWG_LOCATION}"
python3 -c '
import json, os
e = os.environ
name = e["_AWG_SERVER_NAME"].strip() or "AWG 2.0 Server"
country_code = e["_AWG_COUNTRY_CODE"].strip().upper()
location = e["_AWG_LOCATION"].strip() or country_code
has_flag = (
len(name) >= 2
and all(0x1F1E6 <= ord(char) <= 0x1F1FF for char in name[:2])
)
if not has_flag:
flag = (
"".join(chr(0x1F1E6 + ord(char) - ord("A")) for char in country_code)
if len(country_code) == 2 and country_code.isascii() and country_code.isalpha()
else "🏳️"
)
name = f"{flag} {name}"
params = {
"Jc": int(e["_AWG_Jc"]), "Jmin": int(e["_AWG_Jmin"]), "Jmax": int(e["_AWG_Jmax"]),
"S1": int(e["_AWG_S1"]), "S2": int(e["_AWG_S2"]), "S3": int(e["_AWG_S3"]), "S4": int(e["_AWG_S4"]),
"H1": e["_AWG_H1"], "H2": e["_AWG_H2"], "H3": e["_AWG_H3"], "H4": e["_AWG_H4"],
}
for k in ("I1","I2","I3","I4","I5"):
v = e.get(f"_AWG_{k}", "")
if v: params[k] = v
obj = {
"name": name,
"host": e["_AWG_HOST"],
"endpoint_host": e["_AWG_ENDPOINT_HOST"],
"port": int(e["_AWG_PORT"]),
"location": location or "Не указано",
"enabled": True,
"management_generation": 1,
"server_public_key": e["_AWG_PUBKEY"],
"subnet": e["_AWG_SUBNET"],
"server_address": e["_AWG_SERVER_ADDR"],
"dns": ["1.1.1.1", "1.0.0.1"],
"mtu": 1420,
"awg_params": params,
"ssh_user": "root",
"ssh_port": 22,
"ssh_password": "YOUR_SSH_PASSWORD",
"access": {"products": {"awg": {"mode": "profile", "profile": "maximum"}}}
}
print(json.dumps({"awg_SERVER_ID_HERE": obj}, indent=4, ensure_ascii=False))
'
echo ""
info "Server public IP: ${SERVER_PUBLIC_IP}"
info "Client endpoint host: ${AWG_ENDPOINT_HOST}"
info "Server public key: ${SERVER_PUBLIC_KEY}"
info "Interface: ${AWG_INTERFACE} on port ${AWG_PORT}"
info "Implementation: $(detect_active_awg_implementation || printf '%s' 'unknown')"
info "Subnet: ${AWG_SUBNET}"
info "Server template used during install: ${PRESET_LABEL:-Custom manual}"
info "Runtime JSON intentionally stores awg_params only; the bot analyzes live params instead of trusting a preset label."
echo ""
info "Bot Runtime Hint"
echo " • In awg_servers.json keep host=${SERVER_PUBLIC_IP} as the real server/SSH address."
echo " • endpoint_host=${AWG_ENDPOINT_HOST} is what AWG clients will use in Endpoint."
echo " • If endpoint_host is a DNS name, this installer already checked that it resolves to ${SERVER_PUBLIC_IP}."
echo " • If you want the most reliable AWG endpoint, set AWG_ENDPOINT_HOST directly to the server IPv4."
echo ""
warn "IMPORTANT: Replace awg_SERVER_ID_HERE, FLAG, Country, ssh_password in the JSON!"
echo ""
info "Useful commands:"
echo " awg show ${AWG_INTERFACE} — show interface status & peers"
echo " systemctl status awg-quick@${AWG_INTERFACE} — service status"
echo " systemctl restart awg-quick@${AWG_INTERFACE} — restart"
echo " journalctl -u awg-quick@${AWG_INTERFACE} — logs"
echo " systemctl status ${AWG_DNS_PROXY_SERVICE_NAME} — TCP upstream proxy status"
echo " systemctl status ${AWG_DNS_SERVICE_NAME} — torrent DNS sinkhole status"
echo " grep -E 'rutracker|nnmclub|rutor|torbt' ${AWG_DNSMASQ_BLOCKLIST}"
echo " systemctl status ${AWG_AUDIT_SERVICE_NAME} — flow audit status"
echo " tail -f ${AWG_AUDIT_LOG_FILE} — structured flow log (JSONL)"
echo " ${AWG_AUDIT_QUERY_SCRIPT} --time 2026-03-31T18:03:15Z --window-seconds 300 --port 65220"
echo ""
info "Abuse / copyright investigation flow:"
echo " 1. Take the public timestamp and public source port from the complaint."
echo " 2. Run ${AWG_AUDIT_QUERY_SCRIPT} to find the internal AWG IP (for example 10.8.0.161)."
echo " 3. Then map that internal IP back to the bot user in the bot repo / AWG SQLite DB."
echo " The bot already stores server_id + internal address + user_id; the missing piece is this server-side flow log."
echo ""
info "Torrent blocking notes:"
echo " • Common BitTorrent / DHT ports are blocked in iptables."
echo " • Plain DNS (port 53) from AWG clients is transparently redirected to local dnsmasq on port ${AWG_DNS_BLOCK_PORT}."
echo " • dnsmasq forwards allowed domains to a local TCP-only upstream proxy on port ${AWG_DNS_UPSTREAM_PROXY_PORT}."
echo " • Known torrent domains and public trackers are sinkholed in ${AWG_DNSMASQ_BLOCKLIST}."
echo " • DNS-over-TLS on port 853 is blocked to reduce easy bypass of domain blocking."
echo " • This is still not full DPI: custom DoH / custom ports can bypass domain blocks unless you add stricter policy later."
}
# ===== MAIN =====
main_fresh() {
get_public_ip
validate_awg_endpoint_host
install_dependencies
build_amneziawg
if ! install_awg_kernel_module && [[ "${AWG_REQUIRE_KERNEL}" == true ]]; then
err "The official AmneziaWG kernel module is required on Linux; fresh installation stopped before generating server identity"
return 1
fi
if [[ "${AWG_KERNEL_REBOOT_REQUIRED}" == true && "${AWG_REQUIRE_KERNEL}" == true ]]; then
err "The official module is prepared for ${AWG_KERNEL_TARGET}, but that kernel is not running yet"
warn "Fresh installation paused before generating keys; reboot this new node once and run the installer again"
return 75
fi
generate_server_keys
select_preset
configure_port
enable_forwarding
write_server_config
self_check_written_server_config
setup_systemd
setup_dns_blocking_stack
setup_connection_audit_stack
open_firewall
install_global_egress_policy
install_node_disk_hygiene
verify_installation
setup_weekly_awg_update
if [[ "${AWG_KERNEL_REBOOT_REQUIRED}" == true ]]; then
write_awg_bundle_state pending_reboot
warn "A controlled reboot is required to activate the prepared kernel module ${AWG_KERNEL_TAG}."
else
write_awg_bundle_state installed
fi
output_bot_config
}
main_upgrade() {
if [[ -n "${AWG_ENDPOINT_HOST}" ]]; then
get_public_ip
validate_awg_endpoint_host
fi
load_existing_awg_config_identity
run_awg_core_upgrade_transaction
setup_dns_blocking_stack
sync_existing_config_firewall_hooks
setup_connection_audit_stack
# Apply any repaired firewall/DNS hooks after the transactional core update.
systemctl restart "awg-quick@${AWG_INTERFACE}" || systemctl start "awg-quick@${AWG_INTERFACE}"
install_global_egress_policy
install_node_disk_hygiene
verify_installation
if grep -Fqx 'STATUS=pending_reboot' "${AWG_UPDATE_STATE_FILE}" 2>/dev/null; then
warn "A controlled reboot is required to activate the prepared kernel module ${AWG_KERNEL_TAG}."
fi
log "Upgrade complete. Config and peers preserved."
}
main_core_upgrade() {
load_existing_awg_config_identity
run_awg_core_upgrade_transaction
}
main_wipe() {
# Same as fresh — generates new keys, new config
main_fresh
}
main() {
parse_args "$@"
if [[ "${AWG_PRINT_BUNDLE}" == true ]]; then
validate_awg_release_settings
print_awg_bundle
exit 0
fi
echo ""
echo "======================================"
echo " AmneziaWG 2.0 Installer"
echo " kernel-first with amneziawg-go fallback"
echo "======================================"
echo ""
check_root
validate_awg_release_settings
detect_os
check_existing_installation
case "${INSTALL_MODE}" in
fresh) main_fresh ;;
upgrade) main_upgrade ;;
core-upgrade) main_core_upgrade ;;
wipe) main_wipe ;;
esac
}
main "$@"