Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
58 changes: 54 additions & 4 deletions amneziawg-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5702,7 +5702,55 @@ 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
# configuration directory itself 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="${WEB_PANEL_CONFIG_DIR}"
Comment thread
wiresock marked this conversation as resolved.
Outdated
local old_umask dir_identity descriptor_identity descriptor_path

if ! command -v flock >/dev/null 2>&1; then
echo "ERROR: flock is required for serialized client lifecycle operations" >&2
return 1
fi
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}"
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 +5766,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 +5919,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 +5936,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 +5969,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));
31 changes: 30 additions & 1 deletion amneziawg-web/scripts/amneziawg-web-privileged
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,12 @@ emit_safe_server_state() {
fail "server Address contains unexpected content"
fi
printf 'Address = %s\n' "${value}"
elif [[ "${trimmed}" =~ ^PublicKey[[:space:]]*=[[:space:]]*(.*)$ ]]; then
value="${BASH_REMATCH[1]}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
validate_public_key "${value}"
printf 'PublicKey = %s\n' "${value}"
elif [[ "${trimmed}" =~ ^AllowedIPs[[:space:]]*=[[:space:]]*(.*)$ ]]; then
value="${BASH_REMATCH[1]}"
if [[ ! "${value}" =~ ^[0-9A-Fa-f:.,/[:space:]]+$ ]]; then
Expand Down Expand Up @@ -533,11 +539,16 @@ append_peer_block() {
remove_client_block() {
local path="$1"
local client_name="$2"
local expected_public_key="${3:-}"
local actual_public_key=""
local marker_index=-1 marker_count=0 start end index
local interface_seen=false marker_after_interface=false
local -a config_lines=()

validate_client_name "${client_name}"
if [[ -n "${expected_public_key}" ]]; then
validate_public_key "${expected_public_key}"
fi
mapfile -t config_lines < "${path}"

for index in "${!config_lines[@]}"; do
Expand Down Expand Up @@ -566,11 +577,17 @@ remove_client_block() {

if (( marker_index + 4 >= ${#config_lines[@]} )) || \
[[ ! "${config_lines[marker_index + 1]}" =~ ^[[:space:]]*\[Peer\][[:space:]]*$ ]] || \
[[ ! "${config_lines[marker_index + 2]}" =~ ^[[:space:]]*PublicKey[[:space:]]*= ]] || \
[[ ! "${config_lines[marker_index + 3]}" =~ ^[[:space:]]*PresharedKey[[:space:]]*= ]] || \
[[ ! "${config_lines[marker_index + 4]}" =~ ^[[:space:]]*AllowedIPs[[:space:]]*= ]]; then
fail "managed client block has an unexpected shape: ${client_name}"
fi
if [[ ! "${config_lines[marker_index + 2]}" =~ ^[[:space:]]*PublicKey[[:space:]]*=[[:space:]]*([A-Za-z0-9+/]{43}=)[[:space:]]*$ ]]; then
fail "managed client block has an invalid public key: ${client_name}"
fi
actual_public_key="${BASH_REMATCH[1]}"
if [[ -n "${expected_public_key}" && "${actual_public_key}" != "${expected_public_key}" ]]; then
fail "managed client public key does not match the expected identity: ${client_name}"
fi

# The web panel creates an exact five-line managed block. Refuse to
# remove only its prefix if an operator has added another field or comment;
Expand Down Expand Up @@ -680,6 +697,18 @@ main() {
remove_client_block "${remove_path}" "$2"
return 0
;;
remove-client-if-key)
require_arg_count "${subcommand}" 3 "$#"
validate_interface "$1"
validate_client_name "$2"
validate_public_key "$3"
local conditional_remove_path="${AWG_CONFIG_ROOT}/$1.conf"
validate_server_config_path "${conditional_remove_path}"
acquire_server_config_lock "$1"
validate_server_config_path "${conditional_remove_path}"
remove_client_block "${conditional_remove_path}" "$2" "$3"
return 0
;;
*)
fail "unsupported subcommand: ${subcommand}"
;;
Expand Down
Loading