Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 182 additions & 10 deletions amneziawg-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ NC='\033[0m'

AMNEZIAWG_DIR="/etc/amnezia/amneziawg"
WEB_PANEL_CONFIG_DIR="${AMNEZIAWG_DIR}/clients"
WEB_PANEL_ENV_FILE="/etc/amneziawg-web/env.conf"
WEB_PANEL_SYSTEMD_UNIT="/etc/systemd/system/amneziawg-web.service"
WEB_PANEL_DATA_DIR="/var/lib/amneziawg-web"

# Ensure sbin directories are in PATH for depmod, modprobe, sysctl, etc.
# Some minimal or non-login root shells may not include these by default.
Expand Down Expand Up @@ -1634,26 +1637,132 @@ function detectPublicIPv4() {
return 0
}

# Resolve the web panel's root-controlled environment file without sourcing it.
# A custom installer --env-file is recorded in the installed service unit.
function resolveWebPanelEnvFile() {
local env_file="${WEB_PANEL_ENV_FILE}"
local configured_env env_file_required=0 env_file_optional=0

if [[ -L "${WEB_PANEL_SYSTEMD_UNIT}" ]]; then
echo "ERROR: refusing unsafe web panel service unit '${WEB_PANEL_SYSTEMD_UNIT}'" >&2
return 1
fi
if [[ -e "${WEB_PANEL_SYSTEMD_UNIT}" ]]; then
if [[ ! -f "${WEB_PANEL_SYSTEMD_UNIT}" ]]; then
echo "ERROR: refusing unsafe web panel service unit '${WEB_PANEL_SYSTEMD_UNIT}'" >&2
return 1
fi
configured_env="$(sed -n 's/^[[:space:]]*EnvironmentFile=//p' "${WEB_PANEL_SYSTEMD_UNIT}" 2>/dev/null | tail -n 1)"
Comment thread
wiresock marked this conversation as resolved.
Outdated
if [[ "${configured_env}" == -* ]]; then
env_file_optional=1
configured_env="${configured_env#-}"
fi
configured_env="${configured_env#\"}"
configured_env="${configured_env%\"}"
configured_env="${configured_env#\'}"
configured_env="${configured_env%\'}"
if [[ -n "${configured_env}" ]]; then
if [[ "${configured_env}" != /* || "${configured_env}" =~ [[:space:][:cntrl:]] ]]; then
echo "ERROR: refusing unsafe web panel environment path '${configured_env}'" >&2
return 1
fi
env_file="${configured_env}"
env_file_required=1
fi
fi

if [[ -L "${env_file}" ]]; then
echo "ERROR: refusing unsafe web panel environment file '${env_file}'" >&2
return 1
fi
if [[ -e "${env_file}" ]]; then
if [[ ! -f "${env_file}" ]]; then
echo "ERROR: refusing unsafe web panel environment file '${env_file}'" >&2
return 1
fi
elif [[ "${env_file_required}" -eq 1 && "${env_file_optional}" -eq 0 ]]; then
echo "ERROR: web panel environment file '${env_file}' does not exist" >&2
return 1
fi

printf '%s\n' "${env_file}"
}

# Resolve the web panel's active config directory without sourcing its env file.
function resolveWebPanelConfigDir() {
local env_file configured_dir
env_file="$(resolveWebPanelEnvFile)" || return 1

if [[ -f "${env_file}" ]]; then
configured_dir="$(sed -n 's/^AWG_CONFIG_DIR=//p' "${env_file}" 2>/dev/null | tail -n 1)"
configured_dir="${configured_dir#\"}"
configured_dir="${configured_dir%\"}"
configured_dir="${configured_dir#\'}"
configured_dir="${configured_dir%\'}"
if [[ -n "${configured_dir}" ]]; then
if [[ "${configured_dir}" != /* || "${configured_dir}" =~ [[:space:][:cntrl:]] ]]; then
echo "ERROR: refusing unsafe AWG_CONFIG_DIR '${configured_dir}'" >&2
return 1
fi
printf '%s\n' "${configured_dir%/}"
return 0
fi
fi

printf '%s\n' "${WEB_PANEL_CONFIG_DIR%/}"
}

# Use the web database directory as the stable cross-process lifecycle lock.
# Unlike AWG_CONFIG_DIR it exists for the panel's lifetime, including when no
# client has been created yet. Standalone installer use (no panel env file)
# retains the config-directory fallback.
function resolveClientLifecycleLockDir() {
local env_file database_path
env_file="$(resolveWebPanelEnvFile)" || return 1

if [[ -f "${env_file}" ]]; then
database_path="$(sed -n 's/^AWG_WEB_DB=//p' "${env_file}" 2>/dev/null | tail -n 1)"
database_path="${database_path#\"}"
database_path="${database_path%\"}"
database_path="${database_path#\'}"
database_path="${database_path%\'}"
if [[ -n "${database_path}" ]]; then
if [[ "${database_path}" != /* || "${database_path}" =~ [[:space:][:cntrl:]] ]]; then
echo "ERROR: refusing unsafe AWG_WEB_DB '${database_path}'" >&2
return 1
fi
dirname -- "${database_path}"
return 0
fi
printf '%s\n' "${WEB_PANEL_DATA_DIR%/}"
return 0
fi

resolveWebPanelConfigDir
}

# Copy a client config file to the web panel config directory so the panel
# can discover and display it. This is a best-effort operation: if the web
# panel is not installed (directory absent), the copy is silently skipped.
function copyToWebPanelDir() {
local src_file="$1"
if [[ -d "${WEB_PANEL_CONFIG_DIR}" && ! -L "${WEB_PANEL_CONFIG_DIR}" && -f "${src_file}" && ! -L "${src_file}" ]]; then
local panel_config_dir
panel_config_dir="$(resolveWebPanelConfigDir)" || return 0
if [[ -d "${panel_config_dir}" && ! -L "${panel_config_dir}" && -f "${src_file}" && ! -L "${src_file}" ]]; then
local dest
dest="${WEB_PANEL_CONFIG_DIR}/$(basename "${src_file}")"
dest="${panel_config_dir}/$(basename "${src_file}")"
# Avoid following or overwriting a pre-existing symlink at the destination.
if [[ -L "${dest}" ]]; then
# Best-effort: warn and skip rather than risk clobbering the symlink target.
echo "Warning: refusing to copy '${src_file}' to '${dest}' because destination is a symlink" >&2
return 0
fi
cp -f "${src_file}" "${WEB_PANEL_CONFIG_DIR}/" 2>/dev/null || true
cp -f "${src_file}" "${dest}" 2>/dev/null || true
# Only adjust ownership and permissions on a regular non-symlink file we just copied.
if [[ -f "${dest}" && ! -L "${dest}" ]]; then
# Determine the directory's group; use it if available, otherwise fall back to root.
local dir_group dest_group
dir_group="$(stat -c '%G' "${WEB_PANEL_CONFIG_DIR}" 2>/dev/null || true)"
dir_group="$(stat -c '%G' "${panel_config_dir}" 2>/dev/null || true)"
if [[ -n "${dir_group}" ]]; then
dest_group="${dir_group}"
else
Expand All @@ -1669,8 +1778,10 @@ function copyToWebPanelDir() {
# Remove a client config file from the web panel config directory.
function removeFromWebPanelDir() {
local filename="$1"
if [[ -d "${WEB_PANEL_CONFIG_DIR}" && ! -L "${WEB_PANEL_CONFIG_DIR}" ]]; then
rm -f -- "${WEB_PANEL_CONFIG_DIR}/${filename}" 2>/dev/null || true
local panel_config_dir
panel_config_dir="$(resolveWebPanelConfigDir)" || return 0
if [[ -d "${panel_config_dir}" && ! -L "${panel_config_dir}" ]]; then
rm -f -- "${panel_config_dir}/${filename}" 2>/dev/null || true
fi
}

Expand Down Expand Up @@ -5702,7 +5813,66 @@ function manageMenu() {
#
# On error, prints a message to stderr and exits with a non-zero code.

function nonInteractiveAddClient() {
CLIENT_LIFECYCLE_LOCK_FD=""

# Acquire the same non-blocking lifecycle lock used by amneziawg-web. The
# persistent state directory is opened read-only, its descriptor identity
# is revalidated against the path, and the descriptor is locked. The root-run
# CLI therefore never creates, truncates, chowns, or chmods a service-writable
# lock pathname. The caller runs in a subshell so the descriptor, and therefore
# the lock, is released on every success, return, or exit path.
function acquireClientLifecycleLock() {
local lock_dir env_file panel_installed=0
local old_umask dir_identity descriptor_identity descriptor_path
lock_dir="$(resolveClientLifecycleLockDir)" || return 1
env_file="$(resolveWebPanelEnvFile)" || return 1
if [[ -f "${env_file}" || -e "${WEB_PANEL_SYSTEMD_UNIT}" ]]; then
panel_installed=1
fi

if ! command -v flock >/dev/null 2>&1; then
echo "ERROR: flock is required for serialized client lifecycle operations" >&2
return 1
fi
if [[ ! -e "${lock_dir}" && "${panel_installed}" -eq 1 ]]; then
echo "ERROR: web panel lifecycle directory '${lock_dir}' does not exist" >&2
return 1
fi
if [[ ! -e "${lock_dir}" ]]; then
old_umask="$(umask)"
umask 077
mkdir -p "${lock_dir}" || {
umask "${old_umask}"
echo "ERROR: could not create client lifecycle directory '${lock_dir}'" >&2
return 1
}
umask "${old_umask}"
fi
if [[ -L "${lock_dir}" || ! -d "${lock_dir}" ]]; then
echo "ERROR: refusing unsafe client lifecycle directory '${lock_dir}'" >&2
return 1
fi
if ! exec {CLIENT_LIFECYCLE_LOCK_FD}< "${lock_dir}"; then
echo "ERROR: could not open client lifecycle directory '${lock_dir}'" >&2
return 1
fi
descriptor_path="/proc/${BASHPID}/fd/${CLIENT_LIFECYCLE_LOCK_FD}"
dir_identity="$(stat -Lc '%d:%i' -- "${lock_dir}" 2>/dev/null || true)"
descriptor_identity="$(stat -Lc '%d:%i' -- "${descriptor_path}" 2>/dev/null || true)"
if [[ -L "${lock_dir}" || ! -d "${lock_dir}" || -z "${dir_identity}" || \
-z "${descriptor_identity}" || "${dir_identity}" != "${descriptor_identity}" ]]; then
exec {CLIENT_LIFECYCLE_LOCK_FD}>&-
echo "ERROR: client lifecycle directory changed while it was opened" >&2
return 1
fi
if ! flock -xn "${CLIENT_LIFECYCLE_LOCK_FD}"; then
exec {CLIENT_LIFECYCLE_LOCK_FD}>&-
echo "ERROR: another add/remove operation is already in progress" >&2
return 1
fi
}

function nonInteractiveAddClient() (
local CLIENT_NAME="$1"

# Validate the name format (same rules as interactive mode)
Expand All @@ -5718,6 +5888,7 @@ function nonInteractiveAddClient() {
echo "ERROR: client name must be at most 15 characters" >&2
exit 1
fi
acquireClientLifecycleLock || exit 1

# Ensure params are loaded and config path is set
SERVER_AWG_CONF="${AMNEZIAWG_DIR}/${SERVER_AWG_NIC}.conf"
Expand Down Expand Up @@ -5870,9 +6041,9 @@ AllowedIPs = ${PEER_ALLOWED_IPS}" >>"${SERVER_AWG_CONF}"

# Print the config path to stdout for the caller
echo "${client_conf}"
}
)

function nonInteractiveRemoveClient() {
function nonInteractiveRemoveClient() (
local CLIENT_NAME="$1"

if [[ -z "${CLIENT_NAME}" ]]; then
Expand All @@ -5887,6 +6058,7 @@ function nonInteractiveRemoveClient() {
echo "ERROR: client name must be at most 15 characters" >&2
exit 1
fi
acquireClientLifecycleLock || exit 1

SERVER_AWG_CONF="${AMNEZIAWG_DIR}/${SERVER_AWG_NIC}.conf"

Expand Down Expand Up @@ -5919,7 +6091,7 @@ function nonInteractiveRemoveClient() {
fi

echo "OK"
}
)

function nonInteractiveListClients() {
SERVER_AWG_CONF="${AMNEZIAWG_DIR}/${SERVER_AWG_NIC}.conf"
Expand Down
31 changes: 26 additions & 5 deletions amneziawg-web/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,18 @@ list of disabled public keys; the helper derives and filters the trusted
root-owned config internally before invoking `awg syncconf`.
Peer additions and removals are semantic operations: the helper holds a stable
per-interface lock, reconstructs an approved peer block or removes one exact
managed-client block, and atomically replaces the config. Arbitrary config
managed-client block, optionally after checking its expected public key under
the same lock, and atomically replaces the config. Arbitrary config
content, raw file reads, arbitrary `syncconf` stdin, and unknown operations are
rejected rather than forwarded.

The web panel holds an advisory lock on the open client-config directory across
the complete managed-client lifecycle, including database persistence and
client-config cleanup. Supported installer `--add-client` and `--remove-client`
operations lock the same directory descriptor, preventing an out-of-band
same-name replacement from appearing inside a web lifecycle operation without
introducing a mutable lock pathname in the service-writable directory.

---

### `config_store` module (`src/config_store/`)
Expand Down Expand Up @@ -103,9 +111,11 @@ binary via `sqlx::migrate!("./migrations")`.
A Tokio background task that wakes every `AWG_POLL_INTERVAL` seconds,
calls `awg::show_all_dump()`, and:

1. Inserts a row into `snapshots` for each non-archived peer.
2. Upserts each non-archived peer into the `peers` table.
3. Handles counter resets (values are stored as-is; UI layer detects
1. Removes due managed users through the same native lifecycle command used
by manual deletion. The first pass runs immediately at service startup.
2. Inserts a row into `snapshots` for each non-archived peer.
3. Upserts each non-archived peer into the `peers` table.
4. Handles counter resets (values are stored as-is; UI layer detects
decreases).

Both snapshot insertion and live-field upserts are SQL-guarded by the
Expand Down Expand Up @@ -151,7 +161,7 @@ SQLite is chosen for its zero-infrastructure footprint. A single

| Table | Purpose |
|--------------|-------------------------------------------------|
| `peers` | Canonical peer records, metadata, and archived disabled-key tombstones |
| `peers` | Canonical peer records, optional UTC expiration metadata, and archived disabled-key tombstones |
| `snapshots` | Time-series of per-poll stats |
| `interfaces` | Discovered AWG interfaces |
| `events` | Audit log of admin actions |
Expand All @@ -172,6 +182,17 @@ mapping window. The archive state transition, snapshot deletion, and
are retained; returning an archived key records `peer_restored`, leaves it
disabled, and does not restore deleted metadata or history.

### Removal retry invariant

Before the native removal path performs its first external mutation it sets a
durable `removal_pending` flag. Stale-peer cleanup and archiving exclude those
rows, preserving the identity and metadata needed to resume a partial manual
or expiration removal. Expiration edits reject removal-pending rows so an
administrator cannot cancel the automatic retry by clearing or extending its
deadline. Manual retry actions use the durable managed client name even after
config discovery fields are cleared. Successful removal uses the normal
peer-row deletion path, so the retry marker cannot become stale state.

---

## Reasoning
Expand Down
5 changes: 4 additions & 1 deletion amneziawg-web/docs/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,12 @@ allow-listed operations provide:
exclude a bounded list of validated disabled-peer keys, and sync it
- `read-params` – expose only the non-secret parameters needed for client generation
- `read-server-state` – expose only interface addresses, managed-client markers,
and peer AllowedIPs needed for allocation
validated peer public keys, and peer AllowedIPs needed for allocation and
lifecycle identity checks
- `append-peer` – validate and atomically append one reconstructed managed-peer block
- `remove-client` – atomically remove one exact, validated managed-client block
- `remove-client-if-key` – atomically validate a managed client's public-key
identity and remove its exact block

Every operation has a fixed argument shape. Unknown subcommands, malformed
interface/client names, keys or AllowedIPs, unsafe configuration paths,
Expand Down
2 changes: 1 addition & 1 deletion amneziawg-web/docs/MVP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
|21 | 232 unit + integration tests | Auth, domain, DB, config, history, web handler, lifecycle/admin layers |
|22 | User create (native Rust) | `POST /api/admin/users`, HTML form at `/admin/users/add`; validates name; allocates IPs; writes configs; syncs AWG directly |
|23 | User remove (native Rust) | `POST /api/admin/users/:id/remove`, HTML form at `/admin/users/:id/remove`; confirmation required; rewrites server config and syncs AWG directly |
|24 | Lifecycle locking + validation | Shared add/remove lock (`.create-client.lock`) and installer-name validation for managed user actions |
|24 | Lifecycle locking + validation | Cross-process add/remove lock on the client-config directory shared with installer CLI operations, plus installer-name validation for managed user actions |
|25 | User lifecycle audit events | `user_create_requested`, `user_created`, `user_create_failed`, `user_remove_requested`, `user_removed`, `user_remove_failed` |
|26 | Post-action config rescan | `poller::rescan_configs()` called after create/remove; no manual restart needed |

Expand Down
6 changes: 6 additions & 0 deletions amneziawg-web/migrations/0008_add_peer_expiration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Optional UTC expiration plus a stable lifecycle identity for managed client
-- configurations. Existing rows receive NULL and therefore remain permanent.
ALTER TABLE peers ADD COLUMN expires_at TEXT;
ALTER TABLE peers ADD COLUMN managed_client_name TEXT;

CREATE INDEX IF NOT EXISTS idx_peers_expires_at ON peers (expires_at);
6 changes: 6 additions & 0 deletions amneziawg-web/migrations/0009_add_peer_removal_pending.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Durable retry state for a managed-user removal that may already have
-- changed the server config, live interface, or client config. Stale cleanup
-- must not discard the row until the lifecycle path completes successfully.
ALTER TABLE peers
ADD COLUMN removal_pending INTEGER NOT NULL DEFAULT 0
CHECK (removal_pending IN (0, 1));
Loading