3432 lines
122 KiB
Shell
3432 lines
122 KiB
Shell
#!/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 "$@"
|