amneziawg-installer/awg_common_en.sh
Ivan Bondarev 1cbee098ca
v5.15.1 - maintenance release (audit fixes, IPv6 hardening, release gate)
Maintenance release after external code and documentation audits. No new
features; hardens the v5.15.0 dual-stack IPv6 work, tightens several manage
commands, fixes correctness and robustness issues, and adds a release
preflight gate plus a docs-consistency check.

Behavior change: split tunnel + IPv6 mirrors the IPv4 intent - a custom
ALLOWED_IPS keeps that IPv4 split and adds only the tunnel ULA for IPv6
instead of forcing a full ::/0 route. A full tunnel still gets ::/0 (native
IPv6) or the ULA.

Fixed: vpn:// URI carries the server's real MTU/keepalive/DNS; stricter
native-IPv6 detection (global non-ULA address and default route) with the
host IPv6 stack re-enabled before detection; install fails fast on apt update
errors; manage modify validates Endpoint/AllowedIPs; manage add is safe
against name reuse and cleans partial artifacts; manage restore prunes stale
clients/keys and recreates the expiry dir; manage help exit codes; manage
--json keeps stdout pure JSON; log messages stop doubling percent signs.

Docs/process: subnet and OS help accuracy, list --json documented, changelog
compare-links and anchors restored, SECURITY/CONTRIBUTING refreshed, public
RELEASE_PROCESS.md, release publish gated behind a full preflight job, new
docs-consistency workflow, signing design aligned with the asset draft.

Version bumped to 5.15.1 across all six scripts; SHA pins recomputed.
2026-06-02 14:08:36 +03:00

2180 lines
82 KiB
Bash

#!/bin/bash
# ==============================================================================
# Shared function library for AmneziaWG 2.0
# Author: @bivlked
# Version: 5.15.1
# Date: 2026-06-02
# Repository: https://github.com/bivlked/amneziawg-installer
# ==============================================================================
#
# This file contains shared functions for key generation, config rendering,
# peer management, and working with AWG 2.0 parameters.
# Intended to be included via source from the install and manage scripts.
# ==============================================================================
# --- Constants (can be overridden before source) ---
AWG_DIR="${AWG_DIR:-/root/awg}"
CONFIG_FILE="${CONFIG_FILE:-$AWG_DIR/awgsetup_cfg.init}"
SERVER_CONF_FILE="${SERVER_CONF_FILE:-/etc/amnezia/amneziawg/awg0.conf}"
KEYS_DIR="${KEYS_DIR:-$AWG_DIR/keys}"
# --- Auto-cleanup of temporary files ---
# NOTE: trap is NOT set here to avoid overwriting the caller's trap handler.
# The calling script must invoke _awg_cleanup() in its own EXIT handler.
_AWG_TEMP_FILES=()
_awg_cleanup() {
local f
for f in "${_AWG_TEMP_FILES[@]}"; do
[[ -f "$f" ]] && rm -f "$f"
done
}
# mktemp wrapper with auto-cleanup
awg_mktemp() {
local f
f=$(mktemp) || return 1
_AWG_TEMP_FILES+=("$f")
echo "$f"
}
# --- Logging stubs (overridden by the calling script) ---
if ! declare -f log >/dev/null 2>&1; then
log() { echo "[INFO] $1"; }
log_warn() { echo "[WARN] $1" >&2; }
log_error() { echo "[ERROR] $1" >&2; }
log_debug() { echo "[DEBUG] $1"; }
fi
# ==============================================================================
# Utilities
# ==============================================================================
# Detect primary network interface
get_main_nic() {
ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1); exit}'
}
# Detect server public IP (with caching).
#
# The 6-service list covers common NAT and cloud scenarios without
# hard ranking by uptime: ifconfig.me has been historically stable on
# regular VPS (Hetzner, Vultr, OVH), checkip.amazonaws.com remains
# reachable from AWS / GCP / OCI private subnets behind a NAT Gateway,
# ipinfo.io / icanhazip / ifconfig.io are extra fallbacks against
# rate-limit on any single endpoint. Order is alphabetical (deterministic
# for tests and diffs). First-wins: when one service returns a valid IP,
# the rest are skipped.
_CACHED_PUBLIC_IP=""
get_server_public_ip() {
if [[ -n "$_CACHED_PUBLIC_IP" ]]; then
echo "$_CACHED_PUBLIC_IP"
return 0
fi
local ip="" svc
for svc in \
https://api.ipify.org \
https://checkip.amazonaws.com \
https://icanhazip.com \
https://ifconfig.io \
https://ifconfig.me \
https://ipinfo.io/ip
do
ip=$(curl -4 -sf --max-time 5 "$svc" 2>/dev/null | tr -d '[:space:]')
if [[ -n "$ip" && "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
_CACHED_PUBLIC_IP="$ip"
# Observability: write trace to LOG_FILE directly. Never to stdout
# (the function's stdout IS the IP; any extra bytes corrupt the
# caller's $(get_server_public_ip) capture and the generated
# client Endpoint line).
if [[ -n "${LOG_FILE:-}" && -w "$(dirname "${LOG_FILE}")" ]]; then
printf '[%s] DEBUG: public IP detected: %s (via %s)\n' \
"$(date +'%F %T')" "$ip" "$svc" >>"$LOG_FILE" 2>/dev/null || true
fi
echo "$ip"
return 0
fi
done
if [[ -n "${LOG_FILE:-}" && -w "$(dirname "${LOG_FILE}")" ]]; then
printf '[%s] DEBUG: public IP detection failed (all 6 services unreachable or invalid)\n' \
"$(date +'%F %T')" >>"$LOG_FILE" 2>/dev/null || true
fi
echo ""
return 1
}
# Fallback: first non-loopback IPv4 on a network interface.
# Used when curl to ifconfig.me / ipify / ... does not go through
# (LXC without egress, outbound firewall, etc.). On bare metal / regular
# VPS this usually matches the public IP; on a NAT'd host it returns a
# private address — in that case the caller must emit log_warn so the
# user can hand-edit the Endpoint in the client .conf files.
_try_local_ip() {
local ip
ip=$(ip -4 -o addr show scope global 2>/dev/null \
| awk '{print $4}' \
| cut -d/ -f1 \
| grep -v '^127\.' \
| head -1)
[[ -n "$ip" && "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
echo "$ip"
return 0
}
# Note: apt_update_tolerant() is defined inline in install_amneziawg_en.sh
# (needed in steps 1-2 before this file is downloaded). Not duplicated here.
# ==============================================================================
# AWG 2.0 parameter generation (used in tests + manage)
# ==============================================================================
# Random number [min, max] via /dev/urandom (uint32 support).
# Mirrors install_amneziawg_en.sh:rand_range — needed here for tests and regen.
rand_range() {
local min=$1 max=$2
local range=$((max - min + 1))
local random_val
random_val=$(od -An -tu4 -N4 /dev/urandom 2>/dev/null | tr -d ' ')
if [[ -z "$random_val" || ! "$random_val" =~ ^[0-9]+$ ]]; then
random_val=$(( (RANDOM << 15) | RANDOM ))
fi
echo $(( (random_val % range) + min ))
}
# Generate 4 non-overlapping ranges for AWG H1-H4.
# Algorithm: 8 random values → sort → 4 (low, high) pairs.
# Sorting guarantees low ≤ high and non-overlap between pairs.
# Minimum width per range = 1000.
# Prints 4 "low-high" lines to stdout. Returns 1 on failure.
# Mitigates Russian DPI fingerprinting of static H values (#38).
#
# Range: [0, 2^31-1] = [0, 2147483647]. The AmneziaWG spec allows the
# full uint32 (0-4294967295), but the standalone Windows client
# `amneziawg-windows-client` has a UI validator capped at 2^31-1 in
# `ui/syntax/highlighter.go:isValidHField()` (upstream bug
# amnezia-vpn/amneziawg-windows-client#85, not yet fixed). Values
# above 2^31-1 work on the server, but the client's config editor
# underlines them as invalid and blocks saving. For compatibility we
# generate in the safe half of the range (#40).
#
# Optimization: a single `od -N32 -tu4` call reads 32 bytes = 8 uint32
# values in one operation, instead of 8 separate subprocess calls via
# rand_range. Falls back to rand_range if /dev/urandom is unavailable.
generate_awg_h_ranges() {
local attempt=0 max_attempts=20
while (( attempt < max_attempts )); do
local raw arr=() _v
# One 32-byte read from /dev/urandom = 8 uint32 values
raw=$(od -An -N32 -tu4 /dev/urandom 2>/dev/null | tr -s ' \n' '\n' | sed '/^$/d')
if [[ -n "$raw" ]]; then
local count=0
while IFS= read -r _v; do
[[ "$_v" =~ ^[0-9]+$ ]] || continue
# Mask 0x7FFFFFFF: clears the top bit, value in [0, 2^31-1]
# with no bias (each lower bit stays independent).
arr+=("$(( _v & 2147483647 ))")
count=$((count + 1))
(( count == 8 )) && break
done <<< "$raw"
fi
# Fallback: 8 separate rand_range calls (if urandom unavailable)
if (( ${#arr[@]} != 8 )); then
arr=()
local _i
for _i in 1 2 3 4 5 6 7 8; do
arr+=("$(rand_range 0 2147483647)")
done
fi
# Sort
local sorted
sorted=$(printf '%s\n' "${arr[@]}" | sort -n)
arr=()
while IFS= read -r _v; do arr+=("$_v"); done <<< "$sorted"
# Minimum width per pair
if (( ${arr[1]} - ${arr[0]} >= 1000 )) && \
(( ${arr[3]} - ${arr[2]} >= 1000 )) && \
(( ${arr[5]} - ${arr[4]} >= 1000 )) && \
(( ${arr[7]} - ${arr[6]} >= 1000 )); then
printf '%s-%s\n' "${arr[0]}" "${arr[1]}"
printf '%s-%s\n' "${arr[2]}" "${arr[3]}"
printf '%s-%s\n' "${arr[4]}" "${arr[5]}"
printf '%s-%s\n' "${arr[6]}" "${arr[7]}"
return 0
fi
attempt=$((attempt + 1))
done
return 1
}
# ==============================================================================
# DKMS / amneziawg kernel module auto-recovery
# ==============================================================================
#
# After an apt kernel upgrade the DKMS module must be rebuilt for the new
# kernel. If that did not happen automatically (or the module was unbound),
# the 4 functions below perform an idempotent recovery:
#
# _sanitize_awg_dkms_conf — strip the deprecated REMAKE_INITRD= directive
# _install_kernel_headers — distro-aware fallback chain (Ubuntu/Debian)
# _ensure_awg_quick_running — start awg-quick@awg0 if inactive
# ensure_amneziawg_kernel_module — master, public entry point
#
# === Use context and safety contract ===
#
# Master ensure_amneziawg_kernel_module() assumes that the running kernel
# (uname -r) is the target kernel — i.e. it is suited for post-reboot
# contexts only: manage repair-module, manage add/remove (after the user
# rebooted), the systemd unit (which fires at boot when the new kernel is
# already running). From a DPkg::Post-Invoke hook uname -r still returns the
# OLD kernel — for that case the Phase 3 apt-hook helper will use a separate
# wrapper that iterates target kernels via /lib/modules/*/build.
#
# Master does NOT call apt-get install by default (deadlock in any context
# where a parent process holds /var/lib/dpkg/lock-frontend). The apt step is
# gated by the AWG_ALLOW_APT_IN_ENSURE=1 environment variable, which is set
# only by install_amneziawg step 2 / manage repair-module. The apt hook
# helper and the systemd unit do NOT set it; master skips the headers step.
#
# Headers must be set up separately at install time via a meta-package
# (linux-headers-$(arch) on Debian, linux-headers-generic on Ubuntu) — apt
# then pulls matching headers automatically on apt kernel upgrade.
# Strip the deprecated REMAKE_INITRD= directive from the amneziawg dkms.conf.
# Modern DKMS versions consider it deprecated and print noisy warnings.
_sanitize_awg_dkms_conf() {
local conf
for conf in /var/lib/dkms/amneziawg/*/source/dkms.conf; do
[[ -f "$conf" ]] && sed -i '/^REMAKE_INITRD=/d' "$conf"
done
}
# Install a kernel headers package via a distro-aware fallback chain.
# Argument: kernel version (defaults to $(uname -r)).
# Returns: 0 if at least one candidate installed successfully, 1 if all failed.
#
# IMPORTANT: only call from contexts where the apt lock is available
# (install_amneziawg step 2 or manage repair-module). MUST NOT be called from
# the DPkg::Post-Invoke hook.
#
# Recognises Raspberry Pi Foundation kernels (+rpt/-rpi suffix):
# linux-headers-rpi-2712 (Pi 5 / Cortex-A76) or linux-headers-rpi-v8 (Pi 3/4 arm64).
_install_kernel_headers() {
# Defense-in-depth: this function calls apt-get install and must never
# run from a hook context (deadlock on dpkg lock). Master already gates
# it via AWG_ALLOW_APT_IN_ENSURE, but the _ prefix is not enforced — the
# same gate is added here so an accidental direct call from a third-party
# script still cannot bypass the protection.
if [[ "${AWG_ALLOW_APT_IN_ENSURE:-0}" != "1" ]]; then
log_error "_install_kernel_headers: AWG_ALLOW_APT_IN_ENSURE is not set — apt invocation forbidden in this context."
return 1
fi
local kernel_ver="${1:-$(uname -r)}"
local candidates=()
# RPi Foundation kernel (suffix +rpt or -rpi) — separate meta-package
# regardless of distro. Pattern check order: 2712 → v7l → v7 → v8 (default).
if [[ "$kernel_ver" == *+rpt* || "$kernel_ver" == *-rpi* ]]; then
if [[ "$kernel_ver" == *2712* ]]; then
candidates+=("linux-headers-rpi-2712") # Pi 5 / Cortex-A76
elif [[ "$kernel_ver" == *-rpi-v7l* ]]; then
candidates+=("linux-headers-rpi-v7l") # armhf 32-bit (LPAE)
elif [[ "$kernel_ver" == *-rpi-v7* ]]; then
candidates+=("linux-headers-rpi-v7") # armhf 32-bit older
else
candidates+=("linux-headers-rpi-v8") # Pi 3/4 arm64 default
fi
fi
case "${OS_ID:-}" in
ubuntu)
candidates+=(
"linux-headers-${kernel_ver}"
"linux-headers-generic"
"raspberrypi-kernel-headers"
)
;;
debian)
local arch
arch=$(dpkg --print-architecture 2>/dev/null)
candidates+=("linux-headers-${kernel_ver}")
if [[ -n "$arch" ]]; then
# Debian cloud images use a dedicated meta-package
# linux-headers-cloud-${arch} instead of the generic
# linux-headers-${arch} (different kernel ABI — sched/IRQ
# timers trimmed for VMs). Prefer cloud-meta when the
# running kernel is explicitly a cloud build — otherwise
# repair-module fails on AWS/Azure/GCP/cloud-Hetzner after
# a kernel upgrade, even though headers are available via
# the cloud meta-package.
if [[ "$kernel_ver" == *-cloud-* ]]; then
candidates+=("linux-headers-cloud-${arch}")
fi
candidates+=("linux-headers-${arch}")
fi
;;
*)
log_error "Installing kernel headers: unknown OS_ID='${OS_ID:-}' (only ubuntu/debian are supported)."
return 1
;;
esac
local pkg
for pkg in "${candidates[@]}"; do
if apt-get install -y "$pkg" >/dev/null 2>&1; then
log "Installed kernel headers: $pkg"
return 0
fi
log_warn "Failed to install $pkg, trying next candidate..."
done
log_error "Failed to install any kernel headers package (${candidates[*]})."
return 1
}
# Start awg-quick@<iface> if the service is inactive.
# Argument: interface name (defaults to awg0).
# Returns: 0 on successful start or if already active, 1 on failure.
_ensure_awg_quick_running() {
local iface="${1:-awg0}"
local svc="awg-quick@${iface}.service"
if systemctl is-active --quiet "$svc"; then
return 0
fi
log "Starting $svc (was inactive)..."
if systemctl start "$svc"; then
log "$svc started."
return 0
fi
log_error "Failed to start $svc. Details: systemctl status $svc"
return 1
}
# Master: ensure that the amneziawg kernel module is built and loaded for the running kernel.
# Idempotent: fast-path returns 0 if the module is already loaded.
#
# Argument: mode — "full" (default: module + start awg-quick) or
# "module-only" (module only, no service start).
#
# IMPORTANT: master is intended for post-reboot contexts (manage repair-module,
# manage add/remove after a reboot, the systemd unit at boot). Apt/dpkg hook
# code MUST NOT call master — uname -r inside Post-Invoke still returns the
# OLD kernel, so the hook must use a separate wrapper that iterates target
# kernels via /lib/modules/*/build (Phase 3 helper).
#
# Environment: AWG_ALLOW_APT_IN_ENSURE=1 enables the kernel-headers install step
# via apt-get install (dangerous in hook context — deadlock on dpkg lock).
# When unset → headers step is skipped with a warning (assumes headers are
# already on disk via the linux-headers-$(arch) meta-package).
#
# When needed, runs a 5-step recovery:
# headers → sanitize → dkms autoinstall → depmod → modprobe.
#
# Returns:
# 0 — module loaded successfully (and in "full" mode awg-quick is active).
# 1 — final modprobe failed, or invalid mode argument
# (with a 4-step manual recovery printed to the log).
ensure_amneziawg_kernel_module() {
local mode="${1:-full}"
case "$mode" in
full|module-only) ;;
*)
log_error "ensure_amneziawg_kernel_module: invalid mode '$mode' (expected 'full' or 'module-only')."
return 1
;;
esac
local kernel_ver
kernel_ver="$(uname -r)"
# Fast-path: module already loaded.
if lsmod 2>/dev/null | awk '{print $1}' | grep -qx 'amneziawg'; then
if [[ "$mode" == "full" ]]; then
_ensure_awg_quick_running awg0 || \
log_warn "Module is active but awg-quick@awg0 did not start (module OK, this is a service issue)."
fi
return 0
fi
# Module on disk for the running kernel — try modprobe before full repair.
if find "/lib/modules/${kernel_ver}" -name 'amneziawg.ko*' -print -quit 2>/dev/null | grep -q .; then
if modprobe amneziawg 2>/dev/null && \
lsmod 2>/dev/null | awk '{print $1}' | grep -qx 'amneziawg'; then
log "amneziawg module found on disk and loaded successfully."
if [[ "$mode" == "full" ]]; then
_ensure_awg_quick_running awg0 || \
log_warn "Module loaded but awg-quick@awg0 did not start (module OK, this is a service issue)."
fi
return 0
fi
fi
log_warn "amneziawg module is not loaded and not built for kernel ${kernel_ver}."
log_warn "Starting automatic recovery..."
# Step 1: kernel headers — only when apt is allowed by the calling context.
if [[ "${AWG_ALLOW_APT_IN_ENSURE:-0}" == "1" ]]; then
case "${OS_ID:-}" in
ubuntu|debian)
local headers_pkg="linux-headers-${kernel_ver}"
if ! dpkg-query -W -f='${Status}' "$headers_pkg" 2>/dev/null | grep -q 'install ok installed'; then
log "Kernel headers ($headers_pkg) are not installed. Installing..."
_install_kernel_headers "$kernel_ver" || \
log_warn "Failed to install kernel headers. The DKMS module build may fail."
fi
;;
esac
elif [[ ! -d "/lib/modules/${kernel_ver}/build" ]]; then
log_warn "/lib/modules/${kernel_ver}/build is missing, headers are not installed."
log_warn "Apt install skipped (context does not allow apt). The DKMS build will most likely fail."
fi
# Step 2: strip the deprecated REMAKE_INITRD from dkms.conf
_sanitize_awg_dkms_conf
# Step 3: dkms autoinstall for the running kernel.
# If this step reports an error, still try modprobe below — that's the definitive check.
if command -v dkms >/dev/null 2>&1; then
log "Running: dkms autoinstall -k ${kernel_ver}"
if ! dkms autoinstall -k "${kernel_ver}" >/dev/null 2>&1; then
log_warn "dkms autoinstall reported an error for kernel ${kernel_ver}."
local dkms_log
dkms_log=$(find /var/lib/dkms/amneziawg -name 'make.log' -path "*${kernel_ver}*" 2>/dev/null | head -n 1)
if [[ -n "$dkms_log" ]]; then
log_warn "Last 20 lines of the DKMS build log (${dkms_log}):"
tail -20 "$dkms_log" | while IFS= read -r line; do log_warn " $line"; done
else
log_warn "Build log not found. Details under /var/lib/dkms/amneziawg/."
fi
fi
else
log_warn "The dkms package is not installed. Cannot rebuild the kernel module."
fi
# Step 4: rebuild module dependency cache for the specific kernel.
if command -v depmod >/dev/null 2>&1; then
depmod -a "$kernel_ver" 2>/dev/null || \
log_warn "depmod -a $kernel_ver reported an error; modprobe below will give the final diagnosis."
fi
# Step 5: final modprobe attempt.
if ! modprobe amneziawg 2>/dev/null; then
log_error "amneziawg kernel module could not be loaded for kernel ${kernel_ver}."
log_error "The module is not present in /lib/modules/${kernel_ver}/."
log_error "Manual recovery:"
log_error " 1. apt install -y \"linux-headers-${kernel_ver}\""
log_error " 2. dkms autoinstall -k \"${kernel_ver}\" && depmod -a"
log_error " 3. modprobe amneziawg"
log_error " 4. systemctl start \"awg-quick@awg0\""
return 1
fi
log "amneziawg module loaded successfully for kernel ${kernel_ver}."
if [[ "$mode" == "full" ]]; then
_ensure_awg_quick_running awg0 || \
log_warn "Module loaded but awg-quick@awg0 did not start (module OK, this is a service issue)."
fi
return 0
}
# ==============================================================================
# Loading / saving parameters
# ==============================================================================
# Safe configuration loader (whitelist parser, no source/eval)
# Parses only allowed keys in KEY=VALUE or export KEY=VALUE format
safe_load_config() {
local config_file="${1:-$CONFIG_FILE}"
if [[ ! -f "$config_file" ]]; then return 1; fi
local line key value first_line=1
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$first_line" -eq 1 ]]; then
line="${line#$'\xEF\xBB\xBF'}"
first_line=0
fi
line="${line%$'\r'}"
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// /}" ]] && continue
line="${line#export }"
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
if [[ "$value" == \'*\' ]]; then
value="${value#\'}"
value="${value%\'}"
elif [[ "$value" == \"*\" ]]; then
value="${value#\"}"
value="${value%\"}"
fi
case "$key" in
OS_ID|OS_VERSION|OS_CODENAME|AWG_PORT|AWG_TUNNEL_SUBNET|\
DISABLE_IPV6|ALLOWED_IPS_MODE|ALLOWED_IPS|AWG_ENDPOINT|AWG_MTU|\
AWG_Jc|AWG_Jmin|AWG_Jmax|AWG_S1|AWG_S2|AWG_S3|AWG_S4|\
AWG_H1|AWG_H2|AWG_H3|AWG_H4|AWG_I1|AWG_PRESET|NO_TWEAKS|\
AWG_APPLY_MODE|ALLOW_IPV6_TUNNEL|IPV6_SUBNET|SERVER_HAS_NATIVE_IPV6)
export "$key=$value"
;;
esac
fi
done < "$config_file"
}
# Parser for the live AmneziaWG server config (source of truth for AWG_*).
# Reads the [Interface] section of awg0.conf and exports AWG_* variables
# ATOMICALLY: either all 11 required parameters (Jc/Jmin/Jmax/S1-S4/H1-H4)
# are found and exported, or nothing changes in the environment and 1
# is returned. Protects against mixed state when awg0.conf is partially
# corrupt. I1, ListenPort are optional — exported only if found.
# Fixes #38: regen used stale values from the init file instead of the
# actual awg0.conf after manual edits.
# shellcheck disable=SC2120 # Optional argument is only used in tests
load_awg_params_from_server_conf() {
local conf="${1:-$SERVER_CONF_FILE}"
[[ -f "$conf" ]] || return 1
# Local accumulation — all-or-nothing export at the end
local _Jc="" _Jmin="" _Jmax=""
local _S1="" _S2="" _S3="" _S4=""
local _H1="" _H2="" _H3="" _H4=""
local _I1="" _Port="" _MTU=""
local in_iface=0 line key value
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^\[Interface\] ]]; then in_iface=1; continue; fi
if [[ "$line" =~ ^\[ ]]; then in_iface=0; continue; fi
(( in_iface )) || continue
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// /}" ]] && continue
if [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9]+)[[:space:]]*=[[:space:]]*(.+)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
value="${value%"${value##*[![:space:]]}"}"
case "$key" in
Jc) _Jc="$value" ;;
Jmin) _Jmin="$value" ;;
Jmax) _Jmax="$value" ;;
S1) _S1="$value" ;;
S2) _S2="$value" ;;
S3) _S3="$value" ;;
S4) _S4="$value" ;;
H1) _H1="$value" ;;
H2) _H2="$value" ;;
H3) _H3="$value" ;;
H4) _H4="$value" ;;
I1) _I1="$value" ;;
ListenPort) _Port="$value" ;;
MTU) _MTU="$value" ;;
esac
fi
done < "$conf"
# Atomic check: are all 11 required fields present?
[[ -n "$_Jc" && -n "$_Jmin" && -n "$_Jmax" && \
-n "$_S1" && -n "$_S2" && -n "$_S3" && -n "$_S4" && \
-n "$_H1" && -n "$_H2" && -n "$_H3" && -n "$_H4" ]] || return 1
# Atomic export — environment is modified only on full success
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"
[[ -n "$_I1" ]] && export AWG_I1="$_I1"
[[ -n "$_Port" ]] && export AWG_PORT="$_Port"
if _validate_mtu "${_MTU:-}"; then
export AWG_MTU="$_MTU"
fi
return 0
}
# Load AWG parameters.
#
# Source semantics (important for preventing split-brain between server
# and client configs, see #38):
#
# * init file ($CONFIG_FILE = awgsetup_cfg.init) — for NON-AWG settings
# (OS_ID, ALLOWED_IPS, AWG_PORT, AWG_ENDPOINT etc.). Always loaded
# when present.
# * Live server config ($SERVER_CONF_FILE = /etc/amnezia/amneziawg/awg0.conf)
# — the SOLE source of truth for AWG protocol parameters
# (Jc/Jmin/Jmax/S1-S4/H1-H4/I1) when the file exists.
#
# If the live server config exists but does NOT contain a complete set of
# AWG parameters (corruption / incomplete manual edit) — the function
# returns 1 with an explicit error. Silently falling back to stale values
# from the init file would create split-brain: the server runs the new
# awg0.conf while regen would issue clients old J*/S*/H*. This is exactly
# the class of issue reported by elvaleto and Klavishnik in Discussion #38.
#
# The init file is used for AWG parameters ONLY when the live server
# config is missing entirely — that is the bootstrap path of the first
# install when awg0.conf has not been written yet but generate_awg_params
# has already stored values in the init file.
load_awg_params() {
# 1. Base settings from init (always, for non-AWG keys)
if [[ -f "$CONFIG_FILE" ]]; then
safe_load_config "$CONFIG_FILE" || log_warn "Failed to load $CONFIG_FILE"
fi
# 2. AWG protocol parameters
# If CLI specified --preset/--jc/--jmin/--jmax, params are already set via generate_awg_params.
# Skip reload from awg0.conf to preserve the fresh values.
if [[ -n "${CLI_PRESET:-}" || -n "${CLI_JC:-}" || -n "${CLI_JMIN:-}" || -n "${CLI_JMAX:-}" ]]; then
log_debug "CLI overrides set — AWG params from generate_awg_params, not from $SERVER_CONF_FILE"
elif [[ -f "$SERVER_CONF_FILE" ]]; then
# Live config exists — it is the sole source of truth.
# No fallback to init: that would create split-brain.
# Unset I1 before parsing: I1 is optional, if absent from live conf
# it must not leak stale value from init file.
unset AWG_I1
if ! load_awg_params_from_server_conf; then
log_error "$SERVER_CONF_FILE is missing required AWG parameters"
log_error "(Jc/Jmin/Jmax/S1-S4/H1-H4). Refusing to use stale values from"
log_error "$CONFIG_FILE, that would create a split-brain between server"
log_error "and client configs. Restore the [Interface] section in"
log_error "$SERVER_CONF_FILE or restore awg0.conf from a backup."
return 1
fi
log_debug "AWG parameters loaded from $SERVER_CONF_FILE (live config)"
else
# Bootstrap: server config does not exist yet (first install).
# AWG_* must be in env via safe_load_config above.
log_debug "$SERVER_CONF_FILE missing — using AWG params from $CONFIG_FILE (bootstrap)"
fi
# 3. Check required AWG 2.0 parameters
local missing=0
local param
for param in AWG_Jc AWG_Jmin AWG_Jmax AWG_S1 AWG_S2 AWG_S3 AWG_S4 AWG_H1 AWG_H2 AWG_H3 AWG_H4; do
if [[ -z "${!param:-}" ]]; then
log_error "Parameter $param not found"
missing=1
fi
done
if [[ $missing -eq 1 ]]; then
return 1
fi
return 0
}
# ==============================================================================
# Key generation
# ==============================================================================
# Generate keypair (private + public)
# generate_keypair <name>
# Result: keys/<name>.private, keys/<name>.public
generate_keypair() {
local name="$1"
if [[ -z "$name" ]]; then
log_error "generate_keypair: name not specified"
return 1
fi
mkdir -p "$KEYS_DIR" || {
log_error "Failed to create $KEYS_DIR"
return 1
}
local privkey pubkey
privkey=$(awg genkey) || {
log_error "Failed to generate private key for '$name'"
return 1
}
pubkey=$(echo "$privkey" | awg pubkey) || {
log_error "Failed to generate public key for '$name'"
return 1
}
echo "$privkey" > "$KEYS_DIR/${name}.private" || {
log_error "Failed to write private key for '$name'"
return 1
}
echo "$pubkey" > "$KEYS_DIR/${name}.public" || {
log_error "Failed to write public key for '$name'"
return 1
}
chmod 600 "$KEYS_DIR/${name}.private" "$KEYS_DIR/${name}.public" || {
log_error "Failed to set permissions on keys for '$name'"
return 1
}
log_debug "Keys for '$name' generated."
return 0
}
# Generate server keys
# Result: server_private.key, server_public.key in AWG_DIR
generate_server_keys() {
local privkey pubkey
privkey=$(awg genkey) || {
log_error "Failed to generate server private key"
return 1
}
pubkey=$(echo "$privkey" | awg pubkey) || {
log_error "Failed to generate server public key"
return 1
}
echo "$privkey" > "$AWG_DIR/server_private.key" || return 1
echo "$pubkey" > "$AWG_DIR/server_public.key" || return 1
chmod 600 "$AWG_DIR/server_private.key" "$AWG_DIR/server_public.key" || {
log_error "Failed to set permissions on server keys"
return 1
}
log "Server keys generated."
return 0
}
# Ensure $AWG_DIR/server_public.key is present.
# If missing — tries to reconstruct it from the PrivateKey in awg0.conf
# (useful for manual setups outside my installer, where the cached
# server pubkey from install step 6 does not exist). Returns 0 if the
# key is already there or has been reconstructed, 1 otherwise.
_ensure_server_public_key() {
[[ -f "$AWG_DIR/server_public.key" ]] && return 0
[[ -f "$SERVER_CONF_FILE" ]] || {
log_error "Cannot reconstruct server_public.key — $SERVER_CONF_FILE is missing"
return 1
}
local _srv_priv
_srv_priv=$(awk '
/^\[Interface\]/ {in_iface=1; next}
in_iface && /^[ \t]*PrivateKey[ \t]*=/ {
sub(/^[ \t]*PrivateKey[ \t]*=[ \t]*/, "")
gsub(/[[:space:]]/, "")
print
exit
}
/^\[/ && !/^\[Interface\]/ {in_iface=0}
' "$SERVER_CONF_FILE")
if [[ -z "$_srv_priv" ]]; then
log_error "PrivateKey not found in $SERVER_CONF_FILE — cannot reconstruct server_public.key"
return 1
fi
mkdir -p "$AWG_DIR"
local _tmp
_tmp=$(awg_mktemp) || return 1
if ! echo "$_srv_priv" | awg pubkey > "$_tmp"; then
rm -f "$_tmp"
log_error "awg pubkey failed to compute the public key"
return 1
fi
if ! mv -f "$_tmp" "$AWG_DIR/server_public.key"; then
rm -f "$_tmp"
log_error "Failed to move to $AWG_DIR/server_public.key"
return 1
fi
chmod 600 "$AWG_DIR/server_public.key" 2>/dev/null || true
log "server_public.key reconstructed from awg0.conf PrivateKey."
return 0
}
# ==============================================================================
# Config rendering
# ==============================================================================
# Derive the server IPv6 address (host ::1) from the tunnel subnet.
# Input: PREFIX::/MASK (e.g. fddd:2c4:2c4:2c4::/64).
# Output: PREFIX::1/MASK (e.g. fddd:2c4:2c4:2c4::1/64).
# Assumption: subnet always ends with ::/MASK (that is how the installer writes it).
# If no trailing ::/ is present I return the input unchanged (defensive fallback).
_derive_ipv6_server_addr() {
local subnet="$1"
if [[ "$subnet" == *"::/"* ]]; then
echo "${subnet/::\//::1\/}"
else
echo "$subnet"
fi
}
# Render server config for AWG 2.0
# Uses global variables from load_awg_params()
# shellcheck disable=SC2154 # AWG_* vars loaded via load_awg_params -> source
render_server_config() {
load_awg_params || return 1
local server_privkey
if [[ -f "$AWG_DIR/server_private.key" ]]; then
server_privkey=$(cat "$AWG_DIR/server_private.key")
else
log_error "Server private key not found: $AWG_DIR/server_private.key"
return 1
fi
local nic
nic=$(get_main_nic)
if [[ -z "$nic" ]]; then
log_error "Failed to detect network interface."
return 1
fi
local server_ip subnet_mask
server_ip=$(echo "$AWG_TUNNEL_SUBNET" | cut -d'/' -f1)
subnet_mask=$(echo "$AWG_TUNNEL_SUBNET" | cut -d'/' -f2)
# [Interface] Address: IPv4 always, IPv6 only when the tunnel is enabled.
# The server takes host ::1 in the tunnel IPv6 subnet.
# IPV6_SUBNET has the form PREFIX::/MASK (default fddd:2c4:2c4:2c4::/64),
# so I derive the server address by replacing trailing ::/MASK with ::1/MASK.
local address_line="${server_ip}/${subnet_mask}"
if [[ "${ALLOW_IPV6_TUNNEL:-0}" -eq 1 ]]; then
local ipv6_subnet="${IPV6_SUBNET:-fddd:2c4:2c4:2c4::/64}"
local ipv6_server_addr
ipv6_server_addr=$(_derive_ipv6_server_addr "$ipv6_subnet")
address_line="${address_line}, ${ipv6_server_addr}"
fi
local conf_dir
conf_dir=$(dirname "$SERVER_CONF_FILE")
mkdir -p "$conf_dir" || {
log_error "Failed to create $conf_dir"
return 1
}
# PostUp/PostDown rules for routing
local postup="iptables -I FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ${nic} -j MASQUERADE"
local postdown="iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ${nic} -j MASQUERADE"
# IPv6 rules: enabled when the IPv6 tunnel is on (FORWARD inside the tunnel +
# MASQUERADE to the public interface). MASQUERADE is harmless without native
# IPv6 on the VPS - it is a no-op while there is no IPv6 default route, while
# peer-to-peer traffic inside the tunnel still works. I reuse the same nic as
# the IPv4 MASQUERADE (no hardcoded interface).
# The DISABLE_IPV6=0 condition is kept for byte-identical compatibility with v5.14.x:
# an install with --allow-ipv6 (no tunnel) gets the same IPv6 filter rules as before.
if [[ "${ALLOW_IPV6_TUNNEL:-0}" -eq 1 || "${DISABLE_IPV6:-1}" -eq 0 ]]; then
postup="${postup}; ip6tables -I FORWARD -i %i -j ACCEPT; ip6tables -t nat -A POSTROUTING -o ${nic} -j MASQUERADE"
postdown="${postdown}; ip6tables -D FORWARD -i %i -j ACCEPT; ip6tables -t nat -D POSTROUTING -o ${nic} -j MASQUERADE"
fi
# Build config via temp file (atomic write)
local tmpfile
tmpfile=$(awg_mktemp) || { log_error "mktemp failed"; return 1; }
cat > "$tmpfile" << EOF
[Interface]
PrivateKey = ${server_privkey}
Address = ${address_line}
MTU = ${AWG_MTU:-1280}
ListenPort = ${AWG_PORT}
PostUp = ${postup}
PostDown = ${postdown}
Jc = ${AWG_Jc}
Jmin = ${AWG_Jmin}
Jmax = ${AWG_Jmax}
S1 = ${AWG_S1}
S2 = ${AWG_S2}
S3 = ${AWG_S3}
S4 = ${AWG_S4}
H1 = ${AWG_H1}
H2 = ${AWG_H2}
H3 = ${AWG_H3}
H4 = ${AWG_H4}
EOF
# Add I1 only if set (CPS is optional)
if [[ -n "${AWG_I1}" ]]; then
echo "I1 = ${AWG_I1}" >> "$tmpfile"
fi
if ! mv "$tmpfile" "$SERVER_CONF_FILE"; then
rm -f "$tmpfile"
log_error "Failed to write server config"
return 1
fi
chmod 600 "$SERVER_CONF_FILE"
log "Server config created: $SERVER_CONF_FILE"
return 0
}
# Acceptable MTU range for AWG / WireGuard.
# Lower bound 576 (classic IPv4 minimum), upper bound 9100 (just under jumbo).
# Values outside the range are treated as invalid and dropped (fallback to 1280).
_validate_mtu() {
local v="$1"
[[ "$v" =~ ^[0-9]+$ ]] || return 1
(( v >= 576 && v <= 9100 )) || return 1
return 0
}
# Extract MTU from the [Interface] section of server awg0.conf (if the file
# exists). Prints the integer on stdout, or nothing if MTU is missing or the
# file is unreadable. Last-wins: if [Interface] holds several MTU = ... lines,
# the last one is returned (matching the way awg-quick applies the final
# assignment). Used by render_client_config to sync the client MTU with the
# server (v5.14.0 bug: manual MTU edit in awg0.conf was not picked up by regen).
_extract_mtu_from_server_conf() {
local conf="${SERVER_CONF_FILE:-/etc/amnezia/amneziawg/awg0.conf}"
[[ -r "$conf" ]] || return 1
local val
val=$(awk '
/^\[Interface\]/ {in_iface=1; next}
/^\[/ {in_iface=0}
in_iface && /^[[:space:]]*MTU[[:space:]]*=/ {
gsub(/^[[:space:]]*MTU[[:space:]]*=[[:space:]]*/, "")
gsub(/[[:space:]].*$/, "")
if ($0 ~ /^[0-9]+$/) { mtu=$0 }
}
END { if (mtu != "") print mtu }
' "$conf")
_validate_mtu "$val" || return 1
echo "$val"
}
# Render client config for AWG 2.0
# render_client_config <name> <client_ip> <client_privkey> <server_pubkey> <endpoint> <port> [client_ipv6]
#
# client_ipv6 (optional 7th argument): client IPv6 address without prefix
# length (e.g. fddd:2c4:2c4:2c4::5). If non-empty and ALLOW_IPV6_TUNNEL=1:
# - Address = <ipv4>/32, <ipv6>/128
# - AllowedIPs (mirror the IPv4 routing mode into IPv6, intent-mirroring):
# full tunnel (ALLOWED_IPS=0.0.0.0/0): + ::/0 (native) or + <IPV6_SUBNET> (no-native)
# split tunnel (custom ALLOWED_IPS): IPv4 list UNCHANGED + ONLY <IPV6_SUBNET>,
# NEVER ::/0 - there is no IPv6 split-list, hijacking all IPv6 breaks split-tunnel.
# If empty (legacy client): Address = <ipv4>/32, AllowedIPs unchanged.
render_client_config() {
local name="$1"
local client_ip="$2"
local client_privkey="$3"
local server_pubkey="$4"
local endpoint="$5"
local port="$6"
local client_ipv6="${7:-}"
load_awg_params || return 1
local conf_file="$AWG_DIR/${name}.conf"
local allowed_ips
if [[ -n "$client_ipv6" ]]; then
# Dual-stack: mirror the IPv4 routing intent into IPv6.
# full tunnel (IPv4=0.0.0.0/0) -> ::/0 (native) or tunnel ULA (no-native).
# split tunnel (custom ALLOWED_IPS) -> IPv4 split AS-IS + ONLY tunnel ULA,
# never ::/0 (no IPv6 split-list, must not hijack all IPv6).
local ipv4_part ipv6_part
ipv4_part="${ALLOWED_IPS:-0.0.0.0/0}"
if [[ "$ipv4_part" == "0.0.0.0/0" && "${SERVER_HAS_NATIVE_IPV6:-0}" == "1" ]]; then
ipv6_part="::/0"
else
ipv6_part="${IPV6_SUBNET:-fddd:2c4:2c4:2c4::/64}"
fi
# Defensive de-dup: ALLOWED_IPS is IPv4-only by construction, but do not
# duplicate ipv6_part if it is already present as a token in the list.
case ",${ipv4_part// /}," in
*",${ipv6_part},"*) allowed_ips="$ipv4_part" ;;
*) allowed_ips="${ipv4_part}, ${ipv6_part}" ;;
esac
else
allowed_ips="${ALLOWED_IPS:-0.0.0.0/0}"
fi
# MTU resolution order: server awg0.conf > AWG_MTU from awgsetup_cfg.init >
# 1280 fallback. Server config is the source of truth for a running server -
# the user could have hand-edited MTU in /etc/amnezia/amneziawg/awg0.conf
# and regen has to pick that up (MyAI-sdge, Discussion #38). Out-of-range
# values (outside 576..9100) at any stage roll back to 1280.
local mtu
mtu=$(_extract_mtu_from_server_conf) || mtu=""
if [[ -z "$mtu" ]]; then
if _validate_mtu "${AWG_MTU:-}"; then
mtu="$AWG_MTU"
else
mtu=1280
fi
fi
local tmpfile
tmpfile=$(awg_mktemp) || { log_error "mktemp failed"; return 1; }
local address_line
if [[ -n "$client_ipv6" ]]; then
address_line="${client_ip}/32, ${client_ipv6}/128"
else
address_line="${client_ip}/32"
fi
cat > "$tmpfile" << EOF
[Interface]
PrivateKey = ${client_privkey}
Address = ${address_line}
DNS = 1.1.1.1
MTU = ${mtu}
Jc = ${AWG_Jc}
Jmin = ${AWG_Jmin}
Jmax = ${AWG_Jmax}
S1 = ${AWG_S1}
S2 = ${AWG_S2}
S3 = ${AWG_S3}
S4 = ${AWG_S4}
H1 = ${AWG_H1}
H2 = ${AWG_H2}
H3 = ${AWG_H3}
H4 = ${AWG_H4}
EOF
if [[ -n "${AWG_I1}" ]]; then
echo "I1 = ${AWG_I1}" >> "$tmpfile"
fi
cat >> "$tmpfile" << EOF
[Peer]
PublicKey = ${server_pubkey}
EOF
# Optional PresharedKey — extra layer on top of AWG 2.0 obfuscation
# (enabled via `manage add --psk`). Must match on server peer and
# client [Peer].
if [[ -n "${CLIENT_PSK:-}" ]]; then
echo "PresharedKey = ${CLIENT_PSK}" >> "$tmpfile"
fi
cat >> "$tmpfile" << EOF
Endpoint = ${endpoint}:${port}
AllowedIPs = ${allowed_ips}
PersistentKeepalive = 33
EOF
if ! mv "$tmpfile" "$conf_file"; then
rm -f "$tmpfile"
log_error "Failed to write config for client '$name'"
return 1
fi
chmod 600 "$conf_file"
log_debug "Config for '$name' created: $conf_file"
return 0
}
# ==============================================================================
# Config application (syncconf)
# ==============================================================================
# Apply configuration changes
# AWG_SKIP_APPLY=1: skip apply (for batch automation)
# AWG_APPLY_MODE=syncconf|restart: apply method (config or --apply-mode CLI)
# flock on .awg_apply.lock: prevents concurrent apply calls
apply_config() {
# Skip apply (AWG_SKIP_APPLY=1 manage add/remove ...)
if [[ "${AWG_SKIP_APPLY:-0}" == "1" ]]; then
log_debug "apply_config skipped (AWG_SKIP_APPLY=1)."
return 0
fi
# Inter-process lock for apply_config
local apply_lockfile="${AWG_DIR}/.awg_apply.lock"
local apply_fd
exec {apply_fd}>"$apply_lockfile"
if ! flock -x -w 120 "$apply_fd"; then
log_warn "Failed to acquire apply_config lock."
exec {apply_fd}>&-
return 1
fi
local rc=0
if [[ "${AWG_APPLY_MODE:-syncconf}" == "restart" ]]; then
log "Restarting service (apply-mode=restart)..."
systemctl restart awg-quick@awg0 2>/dev/null; rc=$?
[[ $rc -ne 0 ]] && log_warn "Service restart error."
exec {apply_fd}>&-
return $rc
fi
local strip_out
strip_out=$(timeout 10 awg-quick strip awg0 2>/dev/null) || {
log_warn "awg-quick strip failed or timed out, falling back to full restart."
systemctl restart awg-quick@awg0 2>/dev/null; rc=$?
[[ $rc -ne 0 ]] && log_warn "Service restart error."
exec {apply_fd}>&-
return $rc
}
echo "$strip_out" | timeout 10 awg syncconf awg0 /dev/stdin 2>/dev/null || {
log_warn "awg syncconf failed or timed out, falling back to full restart."
systemctl restart awg-quick@awg0 2>/dev/null; rc=$?
[[ $rc -ne 0 ]] && log_warn "Service restart error."
exec {apply_fd}>&-
return $rc
}
log_debug "Config applied (syncconf)."
exec {apply_fd}>&-
return 0
}
# ==============================================================================
# Peer management
# ==============================================================================
# Get next free IP in subnet
get_next_client_ip() {
local subnet_base
subnet_base=$(echo "${AWG_TUNNEL_SUBNET:-10.9.9.1/24}" | cut -d'/' -f1 | cut -d'.' -f1-3)
# Associative array for O(1) lookup
declare -A used_set
used_set["${subnet_base}.1"]=1
if [[ -f "$SERVER_CONF_FILE" ]]; then
while IFS= read -r ip; do
used_set["$ip"]=1
done < <(grep -oP 'AllowedIPs\s*=\s*\K[0-9.]+' "$SERVER_CONF_FILE")
fi
local i candidate
for i in $(seq 2 254); do
candidate="${subnet_base}.${i}"
if [[ -z "${used_set[$candidate]+x}" ]]; then
echo "$candidate"
return 0
fi
done
log_error "No free IPs in subnet ${subnet_base}.0/24"
return 1
}
# Derive the IPv6 address for a client from its IPv4 address (deterministic
# by last octet). Used only when ALLOW_IPV6_TUNNEL=1. Allocation mirrors IPv4:
# client 10.9.9.N gets fddd:2c4:2c4:2c4::N (same index N, /128).
# Argument: client IPv4 address, e.g. 10.9.9.5 -> fddd:2c4:2c4:2c4::5
# Returns the address string without a prefix length.
#
# get_next_client_ipv6 <ipv4_addr>
get_next_client_ipv6() {
local ipv4="$1"
if [[ -z "$ipv4" ]]; then
log_error "get_next_client_ipv6: no IPv4 address supplied"
return 1
fi
local n="${ipv4##*.}"
local subnet="${IPV6_SUBNET:-fddd:2c4:2c4:2c4::/64}"
local prefix="${subnet%%::*}"
[[ "$prefix" == *:* ]] || { log_error "get_next_client_ipv6: IPV6_SUBNET does not contain :: (value: $subnet)"; return 1; }
echo "${prefix}::${n}"
return 0
}
# [Peer] addition to server config (atomic via tmpfile + mv).
#
# LOCKING CONTRACT: the caller MUST hold an exclusive flock on
# ${AWG_DIR}/.awg_config.lock when invoking this function. The lock is
# acquired by generate_client() — the only current caller. Do not call
# add_peer_to_server directly without holding the lock.
#
# Why an inner flock is not possible here: bash flock is not re-entrant
# across different file descriptors on the same file. generate_client()
# opens .awg_config.lock on its own fd and holds an exclusive lock; an
# attempt to open the same file on a new fd inside add_peer_to_server
# and take an exclusive lock there would self-deadlock (the parent lock
# is seen as foreign). Contract-based locking is the only reliable
# option in this situation. Re-entrant behaviour is possible only if
# the sub-function uses the SAME fd as the parent (via inheritance),
# which would require passing the fd as an argument.
#
# add_peer_to_server <name> <pubkey> <client_ip> [client_ipv6]
#
# client_ipv6 (optional 4th argument): IPv6 address without prefix length.
# If non-empty: AllowedIPs = <ipv4>/32, <ipv6>/128
# If empty (legacy): AllowedIPs = <ipv4>/32
add_peer_to_server() {
local name="$1"
local pubkey="$2"
local client_ip="$3"
local client_ipv6="${4:-}"
if [[ -z "$name" || -z "$pubkey" || -z "$client_ip" ]]; then
log_error "add_peer_to_server: insufficient arguments"
return 1
fi
if grep -qxF "#_Name = ${name}" "$SERVER_CONF_FILE" 2>/dev/null; then
log_error "Peer '$name' already exists in config"
return 1
fi
# Add peer via temp file (atomic)
local tmpfile
tmpfile=$(awg_mktemp) || { log_error "mktemp failed"; return 1; }
cp "$SERVER_CONF_FILE" "$tmpfile" || {
rm -f "$tmpfile"
log_error "Failed to copy server config"
return 1
}
cat >> "$tmpfile" << EOF
[Peer]
#_Name = ${name}
PublicKey = ${pubkey}
EOF
# PresharedKey — optional, written if passed via CLIENT_PSK env.
# Must match the server peer and client [Peer].
if [[ -n "${CLIENT_PSK:-}" ]]; then
echo "PresharedKey = ${CLIENT_PSK}" >> "$tmpfile"
fi
if [[ -n "$client_ipv6" ]]; then
echo "AllowedIPs = ${client_ip}/32, ${client_ipv6}/128" >> "$tmpfile"
else
echo "AllowedIPs = ${client_ip}/32" >> "$tmpfile"
fi
if ! mv "$tmpfile" "$SERVER_CONF_FILE"; then
rm -f "$tmpfile"
log_error "Failed to update server config"
return 1
fi
chmod 600 "$SERVER_CONF_FILE"
log "Peer '$name' added to server config."
return 0
}
# Remove [Peer] from server config by name (with locking)
# remove_peer_from_server <name>
remove_peer_from_server() {
local name="$1"
if [[ -z "$name" ]]; then
log_error "remove_peer_from_server: name not specified"
return 1
fi
# Inter-process lock
local lockfile="${AWG_DIR}/.awg_config.lock"
local lock_fd
exec {lock_fd}>"$lockfile"
if ! flock -x -w 10 "$lock_fd"; then
log_error "Failed to acquire config lock"
exec {lock_fd}>&-
return 1
fi
if ! grep -qxF "#_Name = ${name}" "$SERVER_CONF_FILE" 2>/dev/null; then
log_error "Peer '$name' not found in config"
exec {lock_fd}>&-
return 1
fi
local tmpfile
tmpfile=$(awg_mktemp) || { log_error "mktemp failed"; exec {lock_fd}>&-; return 1; }
# Remove [Peer] block containing #_Name = name
# Logic: buffer each [Peer] block, check name, print only if not matching
awk -v target="$name" '
BEGIN { buf=""; is_target=0 }
/^\[Peer\]/ {
# Print previous buffer if not target
if (buf != "" && !is_target) printf "%s", buf
buf = $0 "\n"
is_target = 0
next
}
/^\[/ && !/^\[Peer\]/ {
# Any other section — flush buffer
if (buf != "" && !is_target) printf "%s", buf
buf = ""
is_target = 0
print
next
}
{
if (buf != "") {
buf = buf $0 "\n"
if ($0 == "#_Name = " target) is_target = 1
} else {
print
}
}
END {
if (buf != "" && !is_target) printf "%s", buf
}
' "$SERVER_CONF_FILE" > "$tmpfile"
# Normalize: squeeze multiple blank lines into one
local tmpclean
tmpclean=$(awg_mktemp) || { log_error "mktemp failed"; exec {lock_fd}>&-; return 1; }
if cat -s "$tmpfile" > "$tmpclean" 2>/dev/null; then
mv "$tmpclean" "$tmpfile"
else
rm -f "$tmpclean"
fi
if ! mv "$tmpfile" "$SERVER_CONF_FILE"; then
rm -f "$tmpfile"
log_error "Failed to update server config"
exec {lock_fd}>&-
return 1
fi
chmod 600 "$SERVER_CONF_FILE"
exec {lock_fd}>&-
log "Peer '$name' removed from server config."
return 0
}
# ==============================================================================
# Full client lifecycle
# ==============================================================================
# Generate QR code for client
# generate_qr <name>
generate_qr() {
local name="$1"
local conf_file="$AWG_DIR/${name}.conf"
local png_file="$AWG_DIR/${name}.png"
if [[ ! -f "$conf_file" ]]; then
log_error "Client config '$name' not found: $conf_file"
return 1
fi
if ! command -v qrencode &>/dev/null; then
log_warn "qrencode is not installed, QR code not created for '$name'."
return 1
fi
qrencode -t png -o "$png_file" < "$conf_file" || {
log_error "Failed to generate QR code for '$name'"
return 1
}
chmod 600 "$png_file"
log_debug "QR code for '$name' created: $png_file"
return 0
}
# Generate vpn:// URI for import into Amnezia Client
# generate_vpn_uri <name>
generate_vpn_uri() {
local name="$1"
local conf_file="$AWG_DIR/${name}.conf"
local uri_file="$AWG_DIR/${name}.vpnuri"
if [[ ! -f "$conf_file" ]]; then
log_error "Client config '$name' not found: $conf_file"
return 1
fi
if ! command -v perl &>/dev/null; then
log_warn "perl not found, vpn:// URI not created for '$name'."
return 1
fi
if ! perl -MCompress::Zlib -MMIME::Base64 -e '1' 2>/dev/null; then
log_warn "Perl modules Compress::Zlib/MIME::Base64 not found, vpn:// URI not created."
return 1
fi
load_awg_params || return 1
local client_privkey client_ip client_ipv6 server_pubkey endpoint allowed_ips client_psk
client_privkey=$(grep -oP 'PrivateKey\s*=\s*\K\S+' "$conf_file") || return 1
# Extract IPv4 from Address (first field before comma, without /prefix).
# Regex stops at digits and dots - does not capture IPv6 in dual-stack configs.
client_ip=$(awk '/^Address[[:space:]]*=/{
sub(/^Address[[:space:]]*=[[:space:]]*/, "")
sub(/\r$/, "")
n = split($0, parts, /[[:space:]]*,[[:space:]]*/)
sub(/\/[0-9]+$/, "", parts[1])
print parts[1]; exit
}' "$conf_file") || return 1
# Extract IPv6 from Address (second field, if present), without /prefix.
client_ipv6=$(awk '/^Address[[:space:]]*=/{
sub(/^Address[[:space:]]*=[[:space:]]*/, "")
sub(/\r$/, "")
n = split($0, parts, /[[:space:]]*,[[:space:]]*/)
if (n >= 2) {
sub(/\/[0-9]+$/, "", parts[2])
gsub(/[[:space:]]/, "", parts[2])
print parts[2]
}
exit
}' "$conf_file" 2>/dev/null)
client_ipv6="${client_ipv6:-}"
_ensure_server_public_key || return 1
server_pubkey=$(cat "$AWG_DIR/server_public.key" 2>/dev/null) || return 1
# PresharedKey is optional. awk instead of grep so an empty result is not
# treated as failure (grep -P without a match → rc=1, not what we want here).
# Also strip a trailing CR (CRLF from Windows editors) and trailing spaces
# — leaking them into the JSON psk_key would break the handshake just as
# cleanly as the missing field. Without psk_key in inner JSON AmneziaVPN
# import via vpn:// loses the PSK and the handshake fails (issue #67,
# fix v5.11.4).
client_psk=$(awk '/^[[:space:]]*PresharedKey[[:space:]]*=/{sub(/^[[:space:]]*PresharedKey[[:space:]]*=[[:space:]]*/, ""); sub(/\r$/, ""); sub(/[ \t]+$/, ""); print; exit}' "$conf_file" 2>/dev/null)
local raw_endpoint
raw_endpoint=$(grep -oP 'Endpoint\s*=\s*\K\S+' "$conf_file") || return 1
if [[ "$raw_endpoint" == \[* ]]; then
# IPv6: [addr]:port
endpoint="${raw_endpoint%%]:*}"
endpoint="${endpoint#\[}"
else
# IPv4/hostname: addr:port
endpoint="${raw_endpoint%:*}"
fi
# tr -d ' \r' — strips spaces AND CR (on CRLF configs '.+' greedily
# captures \r into the value, which breaks JSON.allowed_ips).
allowed_ips=$(grep -oP 'AllowedIPs\s*=\s*\K.+' "$conf_file" | tr -d ' \r') || allowed_ips="0.0.0.0/0"
# MTU/PersistentKeepalive/DNS from .conf - these can be changed via manage modify.
# On vpn:// import the Amnezia client uses the structured inner-JSON fields
# (awgConfigurator takes mtu from the structured field, not the embedded config),
# so hardcoding them would desync from .conf - same class as issue #67 (the
# structured psk_key field was authoritative).
local mtu keepalive dns_line dns1 dns2
mtu=$(grep -oP '^MTU\s*=\s*\K[0-9]+' "$conf_file" | head -n1); mtu="${mtu:-1280}"
keepalive=$(grep -oP '^PersistentKeepalive\s*=\s*\K[0-9]+' "$conf_file" | head -n1); keepalive="${keepalive:-33}"
dns_line=$(grep -oP '^DNS\s*=\s*\K.+' "$conf_file" | head -n1 | tr -d ' \r')
dns1="${dns_line%%,*}"; dns1="${dns1:-1.1.1.1}"
if [[ "$dns_line" == *,* ]]; then dns2="${dns_line#*,}"; dns2="${dns2%%,*}"; else dns2="$dns1"; fi
local vpn_uri perl_err
perl_err=$(awg_mktemp) || perl_err="/tmp/awg_perl_err.$$"
# shellcheck disable=SC2016
vpn_uri=$(perl -MCompress::Zlib -MMIME::Base64 -e '
my ($conf_path, $h1,$h2,$h3,$h4, $jc,$jmin,$jmax,
$s1,$s2,$s3,$s4, $i1, $port, $ep, $cip, $cipv6, $cpk, $spk, $aips, $psk,
$mtu, $keepalive, $dns1, $dns2) = @ARGV;
open my $fh, "<", $conf_path or die;
local $/; my $raw = <$fh>; close $fh;
chomp $raw;
sub je {
my $s = shift;
$s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g;
$s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g;
$s =~ s/\t/\\t/g; return $s;
}
my $inner = "{";
$inner .= qq("H1":"$h1","H2":"$h2","H3":"$h3","H4":"$h4",);
$inner .= qq("Jc":"$jc","Jmin":"$jmin","Jmax":"$jmax",);
$inner .= qq("S1":"$s1","S2":"$s2","S3":"$s3","S4":"$s4",);
if ($i1 ne "") {
my $ei1 = je($i1);
$inner .= qq("I1":"$ei1","I2":"","I3":"","I4":"","I5":"",);
}
my $eraw = je($raw);
my @ips = split(/,/, $aips);
my $ips_json = join(",", map { qq("$_") } @ips);
$inner .= qq("allowed_ips":[$ips_json],);
$inner .= qq("client_ip":"$cip",);
$cipv6 //= "";
$inner .= qq("client_ipv6":"$cipv6",);
$inner .= qq("client_priv_key":"$cpk",);
if (defined $psk && $psk ne "") {
my $epsk = je($psk);
$inner .= qq("psk_key":"$epsk",);
}
$inner .= qq("config":"$eraw",);
$inner .= qq("hostName":"$ep","mtu":"$mtu",);
$inner .= qq("persistent_keep_alive":"$keepalive","port":$port,);
$inner .= qq("server_pub_key":"$spk"});
my $einner = je($inner);
my $outer = "{";
$outer .= qq("containers":[{"awg":{"isThirdPartyConfig":true,);
$outer .= qq("last_config":"$einner",);
$outer .= qq("port":"$port","protocol_version":"2",);
$outer .= qq("transport_proto":"udp"\},"container":"amnezia-awg"\}],);
$outer .= qq("defaultContainer":"amnezia-awg",);
$outer .= qq("description":"AWG Server",);
my $ed1 = je($dns1); my $ed2 = je($dns2);
$outer .= qq("dns1":"$ed1","dns2":"$ed2",);
$outer .= qq("hostName":"$ep"});
my $compressed = compress($outer);
my $payload = pack("N", length($outer)) . $compressed;
my $b64 = encode_base64($payload, "");
$b64 =~ tr|+/|-_|;
$b64 =~ s/=+$//;
print "vpn://" . $b64;
' "$conf_file" \
"$AWG_H1" "$AWG_H2" "$AWG_H3" "$AWG_H4" \
"$AWG_Jc" "$AWG_Jmin" "$AWG_Jmax" \
"$AWG_S1" "$AWG_S2" "$AWG_S3" "$AWG_S4" \
"$AWG_I1" "$AWG_PORT" "$endpoint" \
"$client_ip" "$client_ipv6" "$client_privkey" "$server_pubkey" "$allowed_ips" "$client_psk" \
"$mtu" "$keepalive" "$dns1" "$dns2" 2>"$perl_err"
)
if [[ -z "$vpn_uri" ]]; then
log_warn "Failed to generate vpn:// URI for '$name'."
[[ -s "$perl_err" ]] && log_warn "Perl: $(cat "$perl_err")"
rm -f "$perl_err"
return 1
fi
rm -f "$perl_err"
echo "$vpn_uri" > "$uri_file"
chmod 600 "$uri_file"
log_debug "vpn:// URI for '$name' created: $uri_file"
return 0
}
# Generate QR code from vpn:// URI (for one-tap import into Amnezia VPN app Android/iOS/Desktop)
# generate_qr_vpnuri <name>
#
# Writes via a temp file in the same directory + atomic mv so that on
# qrencode or chmod failure the user never sees a truncated `.vpnuri.png`:
# the previous version stays intact and the new one only appears whole.
generate_qr_vpnuri() {
local name="$1"
local uri_file="$AWG_DIR/${name}.vpnuri"
local png_file="$AWG_DIR/${name}.vpnuri.png"
local tmp_png="${png_file}.tmp.$$"
if [[ ! -f "$uri_file" ]]; then
log_error "vpn:// URI for '$name' not found: $uri_file"
return 1
fi
if ! command -v qrencode &>/dev/null; then
log_warn "qrencode is not installed, vpn:// QR not created for '$name'."
return 1
fi
# qrencode flags for long vpn:// URIs with PSK (issue #72):
# -s 6 module size of 6 pixels instead of the default 3 - this is the real fix.
# At the default scale modules were too small for the iPhone camera to
# distinguish when scanning the PNG off a computer screen, producing
# error 900 ImportInvalidConfigError in AmneziaVPN iOS for @haritos90
# in issue #72.
# -l L lowest error correction level - this is already the qrencode default,
# pinned explicitly to guard against future default changes in libqrencode.
# -m 4 standard quiet zone of 4 modules - also the default, pinned explicitly.
if ! qrencode -t png -l L -s 6 -m 4 -o "$tmp_png" < "$uri_file"; then
log_error "Failed to generate vpn:// QR for '$name'"
rm -f "$tmp_png"
return 1
fi
if ! chmod 600 "$tmp_png"; then
log_error "Failed to chmod 600 $tmp_png"
rm -f "$tmp_png"
return 1
fi
mv -f "$tmp_png" "$png_file"
log_debug "vpn:// QR for '$name' created: $png_file"
return 0
}
# Removes partially created client artifacts (keys + .conf). Used by the
# early-error paths of generate_client - C10: do not leave orphan keys when a
# step fails before the peer is committed to the server config.
_rollback_client_artifacts() {
rm -f "$KEYS_DIR/$1.private" "$KEYS_DIR/$1.public" "$AWG_DIR/$1.conf"
}
# Full client creation cycle:
# keypair -> next IP -> client config -> add peer -> QR
# generate_client <name> [endpoint]
#
# Env var contract:
# CLIENT_PSK — optional. If set to "auto", a fresh PSK is generated via
# `awg genpsk` and written to both the server [Peer] and the client
# [Peer]. If set to a concrete value (32-byte base64), it is used as
# is without regenerating. Empty/unset — no PSK is added (default).
generate_client() {
local name="$1"
local endpoint="${2:-}"
if [[ -z "$name" ]]; then
log_error "generate_client: name not specified"
return 1
fi
# Load parameters
load_awg_params || return 1
# Optional PresharedKey: "auto" -> `awg genpsk`, otherwise use the
# given value as-is. Empty/unset -> no PSK.
if [[ "${CLIENT_PSK:-}" == "auto" ]]; then
CLIENT_PSK=$(awg genpsk) || {
log_warn "awg genpsk failed — client will be created without PresharedKey"
CLIENT_PSK=""
}
fi
# Inter-process lock: atomicity of IP allocation + peer addition
local lockfile="${AWG_DIR}/.awg_config.lock"
local lock_fd
exec {lock_fd}>"$lockfile"
if ! flock -x -w 30 "$lock_fd"; then
log_error "Failed to acquire config lock"
exec {lock_fd}>&-
return 1
fi
# C6: the client must not already exist. Check UNDER the lock, BEFORE
# generating keys - otherwise `add <existing_name>` would silently overwrite
# a live client's keys (generate_keypair overwrites unconditionally), and a
# concurrent same-name add would race to overwrite.
if [[ -e "$KEYS_DIR/${name}.private" || -e "$KEYS_DIR/${name}.public" || -e "$AWG_DIR/${name}.conf" ]]; then
log_error "Client '$name' already exists. Use 'remove' or a different name."
exec {lock_fd}>&-
return 1
fi
# Generate keys. From here on, any early failure must remove the freshly
# created keys/conf (C10) via _rollback_client_artifacts.
generate_keypair "$name" || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
# Next free IP
local client_ip
client_ip=$(get_next_client_ip) || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
# IPv6 address for client (when ALLOW_IPV6_TUNNEL=1)
local client_ipv6=""
if [[ "${ALLOW_IPV6_TUNNEL:-0}" == "1" ]]; then
client_ipv6=$(get_next_client_ipv6 "$client_ip") || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
log_debug "Allocated IPv6 address ${client_ipv6} for client ${name}"
fi
# Read keys
local client_privkey client_pubkey server_pubkey
client_privkey=$(cat "$KEYS_DIR/${name}.private") || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
client_pubkey=$(cat "$KEYS_DIR/${name}.public") || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
# Try to reconstruct server_public.key from awg0.conf when the cache
# is missing (supports manual setups without the installer step 6).
_ensure_server_public_key || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
server_pubkey=$(cat "$AWG_DIR/server_public.key") || { _rollback_client_artifacts "$name"; exec {lock_fd}>&-; return 1; }
# Endpoint: argument → AWG_ENDPOINT (awgsetup_cfg.init) → curl to
# external services → local IP on a network interface.
# The last fallback targets LXC / egress-restricted setups: it may be a
# NAT address, so we warn the user via the log.
if [[ -z "$endpoint" ]]; then
endpoint="${AWG_ENDPOINT:-}"
fi
if [[ -z "$endpoint" ]]; then
endpoint=$(get_server_public_ip)
fi
if [[ -z "$endpoint" ]]; then
endpoint=$(_try_local_ip) && log_warn "Using local server IP as Endpoint ('$endpoint') — curl to external services did not go through. If the server is behind NAT, hand-edit the Endpoint in the client .conf files."
fi
if [[ -z "$endpoint" ]]; then
log_error "Failed to determine server public IP. Use --endpoint=IP"
_rollback_client_artifacts "$name"
exec {lock_fd}>&-
return 1
fi
# Client config
render_client_config "$name" "$client_ip" "$client_privkey" "$server_pubkey" "$endpoint" "${AWG_PORT}" "$client_ipv6" || {
log_error "Rollback: removing artifacts for '$name'"
_rollback_client_artifacts "$name"
exec {lock_fd}>&-
return 1
}
# Add peer to server config
if ! add_peer_to_server "$name" "$client_pubkey" "$client_ip" "$client_ipv6"; then
log_error "Rollback: removing artifacts for '$name'"
_rollback_client_artifacts "$name"
exec {lock_fd}>&-
return 1
fi
# Release lock — peer written, remaining operations are non-critical
exec {lock_fd}>&-
# QR code (optional, failure is non-fatal)
if ! generate_qr "$name"; then
log_warn "QR code not created. Config: $AWG_DIR/${name}.conf"
fi
# vpn:// URI and QR for Amnezia VPN app (optional).
# QR vpn:// is attempted only if URI was generated successfully — no source otherwise.
if ! generate_vpn_uri "$name"; then
log_warn "vpn:// URI not created for '$name'."
elif ! generate_qr_vpnuri "$name"; then
log_warn "vpn:// QR not created for '$name'."
fi
log "Client '$name' created (IP: $client_ip)."
return 0
}
# Regenerate config and QR for existing client
# regenerate_client <name> [endpoint]
#
# v5.11.0 A5.3: protected by .awg_config.lock (serializes with
# modify_client / remove and concurrent regens on the same client) and
# checks the return code of each sed -i that restores user settings —
# previously sed failures were silently ignored.
#
# Lock scope: held only while mutating $AWG_DIR/${name}.conf.
# generate_qr / generate_vpn_uri / generate_qr_vpnuri are called OUTSIDE
# the lock as best-effort derived artifacts — if a concurrent modify
# changes the conf between our sed and QR generation, the QR may be
# stale by one tick. A concurrent `manage remove <name>` may also delete
# the client after we release the lock, and regen will "resurrect"
# `.conf` / `.png` / `.vpnuri` / `.vpnuri.png` for an already-removed
# peer (stale artefacts in $AWG_DIR). Acceptable: the user gets correct
# state on the next operation (repeat `remove` or `regen`), and the
# peer is already out of the server config — no traffic flows through
# it. Including QR/URI in the lock is more expensive (holding the lock
# for several seconds) with no server-state integrity gain.
regenerate_client() {
local name="$1"
local endpoint="${2:-}"
if [[ -z "$name" ]]; then
log_error "regenerate_client: name not specified"
return 1
fi
# Cross-process lock: guards against races with modify_client/remove
# and concurrent regens on the same client name.
local lockfile="${AWG_DIR}/.awg_config.lock"
local lock_fd
exec {lock_fd}>"$lockfile"
if ! flock -x -w 10 "$lock_fd"; then
log_error "Failed to acquire config lock (another operation is running)"
exec {lock_fd}>&-
return 1
fi
load_awg_params || { exec {lock_fd}>&-; return 1; }
# Check that client exists in server config
if ! grep -qxF "#_Name = ${name}" "$SERVER_CONF_FILE" 2>/dev/null; then
log_error "Client '$name' not found in server config"
exec {lock_fd}>&-
return 1
fi
# Read client private key
local client_privkey client_ip server_pubkey
if [[ -f "$KEYS_DIR/${name}.private" ]]; then
client_privkey=$(cat "$KEYS_DIR/${name}.private")
elif [[ -f "$AWG_DIR/${name}.conf" ]]; then
# Try to extract from existing config
client_privkey=$(sed -n 's/^PrivateKey[ \t]*=[ \t]*//p' "$AWG_DIR/${name}.conf" | tr -d '[:space:]')
fi
if [[ -z "$client_privkey" ]]; then
log_error "Private key for client '$name' not found"
exec {lock_fd}>&-
return 1
fi
# Client IP from server config
# Find [Peer] block with #_Name = name, then AllowedIPs
# For dual-stack: ips[1] = IPv4/32, ips[2] = IPv6/128 (if present)
local _regen_awk_out
_regen_awk_out=$(awk -v target="$name" '
/^\[Peer\]/ { in_peer=1; found=0; next }
in_peer && $0 == "#_Name = " target { found=1; next }
in_peer && found && /^AllowedIPs/ {
sub(/^AllowedIPs[ \t]*=[ \t]*/, "")
n = split($0, ips, /[ \t]*,[ \t]*/)
sub(/\/[0-9]+$/, "", ips[1])
gsub(/^[ \t]+|[ \t]+$/, "", ips[1])
ipv4 = ips[1]
ipv6 = ""
if (n >= 2) {
sub(/\/[0-9]+$/, "", ips[2])
gsub(/^[ \t]+|[ \t]+$/, "", ips[2])
ipv6 = ips[2]
}
print ipv4 " " ipv6
exit
}
/^\[/ && !/^\[Peer\]/ { in_peer=0; found=0 }
' "$SERVER_CONF_FILE")
client_ip="${_regen_awk_out%% *}"
local client_ipv6="${_regen_awk_out#* }"
# Defensive guard: awk always prints trailing space, so client_ipv6 is "" for IPv4-only.
# This guard fires only if awk produces no trailing space (not expected in practice).
if [[ "$client_ipv6" == "$client_ip" ]]; then
client_ipv6=""
fi
# Only carry IPv6 forward if ALLOW_IPV6_TUNNEL is enabled
if [[ "${ALLOW_IPV6_TUNNEL:-0}" != "1" ]]; then
client_ipv6=""
fi
if [[ -z "$client_ip" ]]; then
log_error "Client IP for '$name' not found in server config"
exec {lock_fd}>&-
return 1
fi
# Auto-gen from awg0.conf if the cache is missing (manual setup)
_ensure_server_public_key || { exec {lock_fd}>&-; return 1; }
server_pubkey=$(cat "$AWG_DIR/server_public.key" 2>/dev/null) || {
log_error "Server public key not found"
exec {lock_fd}>&-
return 1
}
# Endpoint chain: arg → AWG_ENDPOINT → curl → local IP (best-effort).
if [[ -z "$endpoint" ]]; then
endpoint="${AWG_ENDPOINT:-}"
fi
if [[ -z "$endpoint" ]]; then
endpoint=$(get_server_public_ip)
fi
if [[ -z "$endpoint" ]]; then
endpoint=$(_try_local_ip) && log_warn "Using local server IP as Endpoint ('$endpoint') — curl to external services did not go through."
fi
if [[ -z "$endpoint" ]]; then
log_error "Failed to determine server public IP."
exec {lock_fd}>&-
return 1
fi
# Preserve user settings from current .conf (modified via modify command)
local current_dns="1.1.1.1" current_keepalive="33" current_allowed_ips="${ALLOWED_IPS:-0.0.0.0/0}"
if [[ -f "$AWG_DIR/${name}.conf" ]]; then
local _v
_v=$(sed -n 's/^DNS[ \t]*=[ \t]*//p' "$AWG_DIR/${name}.conf" | tr -d '[:space:]')
[[ -n "$_v" ]] && current_dns="$_v"
_v=$(sed -n 's/^PersistentKeepalive[ \t]*=[ \t]*//p' "$AWG_DIR/${name}.conf" | tr -d '[:space:]')
[[ -n "$_v" ]] && current_keepalive="$_v"
_v=$(sed -n '/^\[Peer\]/,$ s/^AllowedIPs[ \t]*=[ \t]*//p' "$AWG_DIR/${name}.conf" | tr -d '[:space:]')
[[ -n "$_v" ]] && current_allowed_ips="$_v"
# v5.11.1: preserve PresharedKey through regen. Without this,
# clients added with `manage add --psk` would lose their PSK on
# regen — the server peer still holds the PSK but the client
# conf would drop it, breaking the handshake. CLIENT_PSK is
# passed through to render_client_config.
local _psk
_psk=$(sed -n '/^\[Peer\]/,$ s/^PresharedKey[ \t]*=[ \t]*//p' "$AWG_DIR/${name}.conf" | tr -d '[:space:]')
if [[ -n "$_psk" ]]; then
export CLIENT_PSK="$_psk"
else
unset CLIENT_PSK
fi
fi
# Config regeneration (pass client_ipv6 if dual-stack)
render_client_config "$name" "$client_ip" "$client_privkey" "$server_pubkey" "$endpoint" "${AWG_PORT}" "$client_ipv6" || {
exec {lock_fd}>&-
unset CLIENT_PSK
return 1
}
# Restore user settings (escape & and \ for sed replacement)
local _dns _ka _aip
_dns=$(printf '%s' "$current_dns" | sed 's/[&\\/]/\\&/g')
_ka=$(printf '%s' "$current_keepalive" | sed 's/[&\\/]/\\&/g')
_aip=$(printf '%s' "$current_allowed_ips" | sed 's/[&\\/]/\\&/g')
local _client_conf="$AWG_DIR/${name}.conf"
if ! sed -i "s/^DNS = .*/DNS = ${_dns}/" "$_client_conf"; then
log_error "sed error writing DNS to $_client_conf"
exec {lock_fd}>&-
unset CLIENT_PSK
return 1
fi
if ! sed -i "s/^PersistentKeepalive = .*/PersistentKeepalive = ${_ka}/" "$_client_conf"; then
log_error "sed error writing PersistentKeepalive to $_client_conf"
exec {lock_fd}>&-
unset CLIENT_PSK
return 1
fi
if ! sed -i "s|^AllowedIPs = .*|AllowedIPs = ${_aip}|" "$_client_conf"; then
log_error "sed error writing AllowedIPs to $_client_conf"
exec {lock_fd}>&-
unset CLIENT_PSK
return 1
fi
# Release lock — config written, remaining ops are non-critical
exec {lock_fd}>&-
# QR code
generate_qr "$name"
# vpn:// URI and QR for Amnezia VPN app (best-effort).
# QR vpn:// is attempted only if URI was regenerated successfully.
if generate_vpn_uri "$name"; then
generate_qr_vpnuri "$name" || log_warn "vpn:// QR not updated for '$name'."
else
log_warn "vpn:// URI not updated for '$name'."
fi
# Hygiene: do not let PSK leak into later operations in the same shell
unset CLIENT_PSK
log "Client config for '$name' regenerated."
return 0
}
# ==============================================================================
# Validation
# ==============================================================================
# Validate AWG 2.0 server config
validate_awg_config() {
if [[ ! -f "$SERVER_CONF_FILE" ]]; then
log_error "Server config not found: $SERVER_CONF_FILE"
return 1
fi
local ok=1
local param val
local int_params=("Jc" "Jmin" "Jmax" "S1" "S2" "S3" "S4")
local range_params=("H1" "H2" "H3" "H4")
for param in "${int_params[@]}"; do
val=$(sed -n "s/^${param} = //p" "$SERVER_CONF_FILE" | head -1)
if [[ -z "$val" ]]; then
log_error "Parameter '$param' not found in server config"
ok=0
elif ! [[ "$val" =~ ^[0-9]+$ ]]; then
log_error "Parameter '$param' has invalid value: '$val' (expected integer)"
ok=0
fi
done
# Protocol boundary checks (defense-in-depth for restored backups)
local jc jmin jmax s3 s4
jc=$(sed -n 's/^Jc = //p' "$SERVER_CONF_FILE" | head -1)
jmin=$(sed -n 's/^Jmin = //p' "$SERVER_CONF_FILE" | head -1)
jmax=$(sed -n 's/^Jmax = //p' "$SERVER_CONF_FILE" | head -1)
s3=$(sed -n 's/^S3 = //p' "$SERVER_CONF_FILE" | head -1)
s4=$(sed -n 's/^S4 = //p' "$SERVER_CONF_FILE" | head -1)
if [[ "$jc" =~ ^[0-9]+$ ]]; then
if [[ "$jc" -lt 1 || "$jc" -gt 128 ]]; then
log_error "Jc=$jc is out of range (1-128)"
ok=0
fi
fi
if [[ "$jmin" =~ ^[0-9]+$ && "$jmax" =~ ^[0-9]+$ ]]; then
if [[ "$jmin" -gt 1280 ]]; then
log_error "Jmin=$jmin exceeds 1280"
ok=0
fi
if [[ "$jmax" -gt 1280 ]]; then
log_error "Jmax=$jmax exceeds 1280"
ok=0
fi
if [[ "$jmax" -lt "$jmin" ]]; then
log_error "Jmax ($jmax) is less than Jmin ($jmin)"
ok=0
fi
fi
if [[ "$s3" =~ ^[0-9]+$ && "$s3" -gt 64 ]]; then
log_error "S3=$s3 exceeds maximum (64)"
ok=0
fi
if [[ "$s4" =~ ^[0-9]+$ && "$s4" -gt 32 ]]; then
log_error "S4=$s4 exceeds maximum (32)"
ok=0
fi
for param in "${range_params[@]}"; do
val=$(sed -n "s/^${param} = //p" "$SERVER_CONF_FILE" | head -1)
if [[ -z "$val" ]]; then
log_error "Parameter '$param' not found in server config"
ok=0
elif ! [[ "$val" =~ ^[0-9]+-[0-9]+$ ]]; then
log_error "Parameter '$param' has invalid value: '$val' (expected MIN-MAX format)"
ok=0
else
local range_lo="${val%-*}" range_hi="${val#*-}"
if [[ "$range_lo" -ge "$range_hi" ]]; then
log_error "Parameter '$param': lower bound ($range_lo) >= upper bound ($range_hi)"
ok=0
fi
fi
done
# I1 is optional but recommended for AWG 2.0
if ! grep -q "^I1 = " "$SERVER_CONF_FILE"; then
log_warn "Parameter I1 (CPS) not found — CPS concealment is not active"
fi
if [[ $ok -eq 1 ]]; then
log "AWG 2.0 config validation: OK"
return 0
else
return 1
fi
}
# ==============================================================================
# Client expiry
# ==============================================================================
EXPIRY_DIR="${AWG_DIR}/expiry"
EXPIRY_CRON="/etc/cron.d/awg-expiry"
# Parse duration string to seconds: 1h, 12h, 1d, 7d, 30d
# parse_duration <duration_string>
parse_duration() {
local input="$1"
local num unit
if [[ "$input" =~ ^([0-9]+)([hdw])$ ]]; then
num="${BASH_REMATCH[1]}"
unit="${BASH_REMATCH[2]}"
else
log_error "Invalid duration format: '$input'. Use: 1h, 12h, 1d, 7d, 4w"
return 1
fi
case "$unit" in
h) echo $((num * 3600)) ;;
d) echo $((num * 86400)) ;;
w) echo $((num * 604800)) ;; # 7 days
*) return 1 ;;
esac
}
# Set client expiry
# set_client_expiry <name> <duration>
set_client_expiry() {
local name="$1"
local duration="$2"
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_error "Invalid client name: '$name'"
return 1
fi
if ! grep -qxF "#_Name = ${name}" "$SERVER_CONF_FILE" 2>/dev/null; then
log_error "Client '$name' not found."
return 1
fi
local seconds
seconds=$(parse_duration "$duration") || return 1
local now
now=$(date +%s)
local expires_at=$((now + seconds))
mkdir -p "$EXPIRY_DIR" || {
log_error "Failed to create $EXPIRY_DIR"
return 1
}
echo "$expires_at" > "$EXPIRY_DIR/$name" || {
log_error "Failed to write expiry for '$name'"
return 1
}
chmod 600 "$EXPIRY_DIR/$name"
local expires_date
expires_date=$(date -d "@$expires_at" '+%F %T' 2>/dev/null || echo "$expires_at")
log "Expiry for '$name': $expires_date ($duration)"
return 0
}
# Get client expiry (unix timestamp or empty)
# get_client_expiry <name>
get_client_expiry() {
local name="$1"
local efile="$EXPIRY_DIR/$name"
if [[ -f "$efile" ]]; then
cat "$efile"
fi
}
# Format remaining time
# format_remaining <expires_at_timestamp>
format_remaining() {
local expires_at="$1"
local now
now=$(date +%s)
local diff=$((expires_at - now))
if [[ $diff -le 0 ]]; then
local ago=$(( (-diff) / 3600 ))
if [[ $ago -ge 24 ]]; then
echo "expired $(( ago / 24 ))d ago"
elif [[ $ago -ge 1 ]]; then
echo "expired ${ago}h ago"
else
local ago_mins=$(( (-diff) / 60 ))
if [[ $ago_mins -ge 1 ]]; then
echo "expired ${ago_mins}m ago"
else
echo "just expired"
fi
fi
return 0
fi
local days=$((diff / 86400))
local hours=$(( (diff % 86400) / 3600 ))
if [[ $days -gt 0 ]]; then
echo "${days}d ${hours}h"
else
local mins=$(( (diff % 3600) / 60 ))
echo "${hours}h ${mins}m"
fi
}
# Check and remove expired clients
check_expired_clients() {
if [[ ! -d "$EXPIRY_DIR" ]]; then return 0; fi
local removed=0
local efile
for efile in "$EXPIRY_DIR"/*; do
[[ -f "$efile" ]] || continue
local name
name=$(basename "$efile")
# Name validation: same regex as validate_client_name in manage_amneziawg.sh.
# Defense-in-depth — EXPIRY_DIR is root-only, but protection against an
# accidentally placed invalid file (or symlink attack if expiry_dir
# ever becomes shared) is needed before using $name in paths and
# passing it to remove_peer_from_server (self-audit).
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_warn "Skipping invalid expiry file: '$name'"
continue
fi
local expires_at
expires_at=$(cat "$efile" 2>/dev/null)
if [[ -z "$expires_at" || ! "$expires_at" =~ ^[0-9]+$ ]]; then
log_warn "Malformed expiry data for '$name': '$(head -c 50 "$efile" 2>/dev/null)'"
continue
fi
local now
now=$(date +%s)
if [[ $now -ge $expires_at ]]; then
log "Client '$name' expired. Removing..."
if remove_peer_from_server "$name" 2>/dev/null; then
rm -f "$AWG_DIR/$name.conf" "$AWG_DIR/$name.png" "$AWG_DIR/$name.vpnuri"
rm -f "$KEYS_DIR/${name}.private" "$KEYS_DIR/${name}.public"
rm -f "$efile"
log "Client '$name' removed (expired)."
((removed++))
else
log_warn "Failed to remove expired client '$name'."
fi
fi
done
if [[ $removed -gt 0 ]]; then
log "Expired clients removed: $removed. Applying config..."
if ! apply_config; then
log_error "apply_config failed after removing expired clients. Peers removed from config and expiry/, but may still be present on live interface. Manual restart required: systemctl restart awg-quick@awg0"
return 1
fi
fi
return 0
}
# Install cron job for auto-removal
install_expiry_cron() {
if [[ -f "$EXPIRY_CRON" ]]; then
log_debug "Expiry cron job already installed."
return 0
fi
cat > "$EXPIRY_CRON" << CRONEOF
# AmneziaWG client expiry check — every 5 minutes
AWG_DIR="${AWG_DIR}"
CONFIG_FILE="${CONFIG_FILE}"
SERVER_CONF_FILE="${SERVER_CONF_FILE}"
*/5 * * * * root /bin/bash -c 'source "${AWG_DIR}/awg_common.sh" || exit 1; check_expired_clients' >> "${AWG_DIR}/expiry.log" 2>&1
CRONEOF
chmod 644 "$EXPIRY_CRON"
log "Expiry cron job installed: $EXPIRY_CRON"
}
# Remove client expiry data
remove_client_expiry() {
local name="$1"
rm -f "$EXPIRY_DIR/$name" 2>/dev/null
# Remove cron if no more clients with expiry
if [[ -d "$EXPIRY_DIR" ]] && [[ -z "$(ls -A "$EXPIRY_DIR" 2>/dev/null)" ]]; then
rm -f "$EXPIRY_CRON" 2>/dev/null
log_debug "Expiry cron job removed (no clients with expiry)."
fi
}