Skip to content

Commit 9b655b0

Browse files
committed
fix(web): align lifecycle locks and atomic edits
1 parent 9c5d4ef commit 9b655b0

5 files changed

Lines changed: 267 additions & 51 deletions

File tree

amneziawg-install.sh

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ NC='\033[0m'
1010

1111
AMNEZIAWG_DIR="/etc/amnezia/amneziawg"
1212
WEB_PANEL_CONFIG_DIR="${AMNEZIAWG_DIR}/clients"
13+
WEB_PANEL_ENV_FILE="/etc/amneziawg-web/env.conf"
14+
WEB_PANEL_SYSTEMD_UNIT="/etc/systemd/system/amneziawg-web.service"
1315

1416
# Ensure sbin directories are in PATH for depmod, modprobe, sysctl, etc.
1517
# Some minimal or non-login root shells may not include these by default.
@@ -1634,26 +1636,93 @@ function detectPublicIPv4() {
16341636
return 0
16351637
}
16361638
1639+
# Resolve the web panel's active config directory without sourcing its env file.
1640+
# A custom web installer --env-file is recorded in the installed service unit,
1641+
# while AWG_CONFIG_DIR itself is recorded in that root-controlled env file.
1642+
function resolveWebPanelConfigDir() {
1643+
local env_file="${WEB_PANEL_ENV_FILE}"
1644+
local configured_env configured_dir env_file_required=0 env_file_optional=0
1645+
1646+
if [[ -L "${WEB_PANEL_SYSTEMD_UNIT}" ]]; then
1647+
echo "ERROR: refusing unsafe web panel service unit '${WEB_PANEL_SYSTEMD_UNIT}'" >&2
1648+
return 1
1649+
fi
1650+
if [[ -e "${WEB_PANEL_SYSTEMD_UNIT}" ]]; then
1651+
if [[ ! -f "${WEB_PANEL_SYSTEMD_UNIT}" ]]; then
1652+
echo "ERROR: refusing unsafe web panel service unit '${WEB_PANEL_SYSTEMD_UNIT}'" >&2
1653+
return 1
1654+
fi
1655+
configured_env="$(sed -n 's/^[[:space:]]*EnvironmentFile=//p' "${WEB_PANEL_SYSTEMD_UNIT}" 2>/dev/null | tail -n 1)"
1656+
if [[ "${configured_env}" == -* ]]; then
1657+
env_file_optional=1
1658+
configured_env="${configured_env#-}"
1659+
fi
1660+
configured_env="${configured_env#\"}"
1661+
configured_env="${configured_env%\"}"
1662+
configured_env="${configured_env#\'}"
1663+
configured_env="${configured_env%\'}"
1664+
if [[ -n "${configured_env}" ]]; then
1665+
if [[ "${configured_env}" != /* || "${configured_env}" =~ [[:space:][:cntrl:]] ]]; then
1666+
echo "ERROR: refusing unsafe web panel environment path '${configured_env}'" >&2
1667+
return 1
1668+
fi
1669+
env_file="${configured_env}"
1670+
env_file_required=1
1671+
fi
1672+
fi
1673+
1674+
if [[ -L "${env_file}" ]]; then
1675+
echo "ERROR: refusing unsafe web panel environment file '${env_file}'" >&2
1676+
return 1
1677+
fi
1678+
if [[ -e "${env_file}" ]]; then
1679+
if [[ ! -f "${env_file}" ]]; then
1680+
echo "ERROR: refusing unsafe web panel environment file '${env_file}'" >&2
1681+
return 1
1682+
fi
1683+
configured_dir="$(sed -n 's/^AWG_CONFIG_DIR=//p' "${env_file}" 2>/dev/null | tail -n 1)"
1684+
configured_dir="${configured_dir#\"}"
1685+
configured_dir="${configured_dir%\"}"
1686+
configured_dir="${configured_dir#\'}"
1687+
configured_dir="${configured_dir%\'}"
1688+
if [[ -n "${configured_dir}" ]]; then
1689+
if [[ "${configured_dir}" != /* || "${configured_dir}" =~ [[:space:][:cntrl:]] ]]; then
1690+
echo "ERROR: refusing unsafe AWG_CONFIG_DIR '${configured_dir}'" >&2
1691+
return 1
1692+
fi
1693+
printf '%s\n' "${configured_dir%/}"
1694+
return 0
1695+
fi
1696+
elif [[ "${env_file_required}" -eq 1 && "${env_file_optional}" -eq 0 ]]; then
1697+
echo "ERROR: web panel environment file '${env_file}' does not exist" >&2
1698+
return 1
1699+
fi
1700+
1701+
printf '%s\n' "${WEB_PANEL_CONFIG_DIR%/}"
1702+
}
1703+
16371704
# Copy a client config file to the web panel config directory so the panel
16381705
# can discover and display it. This is a best-effort operation: if the web
16391706
# panel is not installed (directory absent), the copy is silently skipped.
16401707
function copyToWebPanelDir() {
16411708
local src_file="$1"
1642-
if [[ -d "${WEB_PANEL_CONFIG_DIR}" && ! -L "${WEB_PANEL_CONFIG_DIR}" && -f "${src_file}" && ! -L "${src_file}" ]]; then
1709+
local panel_config_dir
1710+
panel_config_dir="$(resolveWebPanelConfigDir)" || return 0
1711+
if [[ -d "${panel_config_dir}" && ! -L "${panel_config_dir}" && -f "${src_file}" && ! -L "${src_file}" ]]; then
16431712
local dest
1644-
dest="${WEB_PANEL_CONFIG_DIR}/$(basename "${src_file}")"
1713+
dest="${panel_config_dir}/$(basename "${src_file}")"
16451714
# Avoid following or overwriting a pre-existing symlink at the destination.
16461715
if [[ -L "${dest}" ]]; then
16471716
# Best-effort: warn and skip rather than risk clobbering the symlink target.
16481717
echo "Warning: refusing to copy '${src_file}' to '${dest}' because destination is a symlink" >&2
16491718
return 0
16501719
fi
1651-
cp -f "${src_file}" "${WEB_PANEL_CONFIG_DIR}/" 2>/dev/null || true
1720+
cp -f "${src_file}" "${dest}" 2>/dev/null || true
16521721
# Only adjust ownership and permissions on a regular non-symlink file we just copied.
16531722
if [[ -f "${dest}" && ! -L "${dest}" ]]; then
16541723
# Determine the directory's group; use it if available, otherwise fall back to root.
16551724
local dir_group dest_group
1656-
dir_group="$(stat -c '%G' "${WEB_PANEL_CONFIG_DIR}" 2>/dev/null || true)"
1725+
dir_group="$(stat -c '%G' "${panel_config_dir}" 2>/dev/null || true)"
16571726
if [[ -n "${dir_group}" ]]; then
16581727
dest_group="${dir_group}"
16591728
else
@@ -1669,8 +1738,10 @@ function copyToWebPanelDir() {
16691738
# Remove a client config file from the web panel config directory.
16701739
function removeFromWebPanelDir() {
16711740
local filename="$1"
1672-
if [[ -d "${WEB_PANEL_CONFIG_DIR}" && ! -L "${WEB_PANEL_CONFIG_DIR}" ]]; then
1673-
rm -f -- "${WEB_PANEL_CONFIG_DIR}/${filename}" 2>/dev/null || true
1741+
local panel_config_dir
1742+
panel_config_dir="$(resolveWebPanelConfigDir)" || return 0
1743+
if [[ -d "${panel_config_dir}" && ! -L "${panel_config_dir}" ]]; then
1744+
rm -f -- "${panel_config_dir}/${filename}" 2>/dev/null || true
16741745
fi
16751746
}
16761747
@@ -5711,8 +5782,9 @@ CLIENT_LIFECYCLE_LOCK_FD=""
57115782
# lock pathname. The caller runs in a subshell so the descriptor, and therefore
57125783
# the lock, is released on every success, return, or exit path.
57135784
function acquireClientLifecycleLock() {
5714-
local lock_dir="${WEB_PANEL_CONFIG_DIR}"
5785+
local lock_dir
57155786
local old_umask dir_identity descriptor_identity descriptor_path
5787+
lock_dir="$(resolveWebPanelConfigDir)" || return 1
57165788

57175789
if ! command -v flock >/dev/null 2>&1; then
57185790
echo "ERROR: flock is required for serialized client lifecycle operations" >&2

amneziawg-web/src/admin/mod.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,27 @@ pub async fn execute_update_peer_expiration(
464464
.await
465465
}
466466

467+
/// Update metadata and an optional expiration as one guarded database change.
468+
pub async fn execute_update_peer_details(
469+
db: &Database,
470+
peer_id: i64,
471+
display_name: Option<&str>,
472+
comment: Option<&str>,
473+
expiration_update: Option<Option<&str>>,
474+
managed_client_name: Option<&str>,
475+
) -> Result<Option<PeerRow>, sqlx::Error> {
476+
let _guard = EXPIRATION_STATE_LOCK.lock().await;
477+
crate::db::peers::update_peer_details(
478+
&db.pool,
479+
peer_id,
480+
display_name,
481+
comment,
482+
expiration_update,
483+
managed_client_name,
484+
)
485+
.await
486+
}
487+
467488
async fn execute_remove_user_inner(
468489
db: &Database,
469490
config_dir: &std::path::Path,

amneziawg-web/src/db/peers.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,43 @@ pub async fn update_peer_metadata(
380380
find_visible_by_id(pool, id).await
381381
}
382382

383+
/// Atomically update peer metadata and, when requested, its expiration.
384+
///
385+
/// `expiration_update = None` leaves the current deadline unchanged, while
386+
/// `Some(None)` makes the peer permanent. Expiration changes are rejected once
387+
/// native removal has started, and the metadata update is rejected with them.
388+
pub async fn update_peer_details(
389+
pool: &SqlitePool,
390+
id: i64,
391+
display_name: Option<&str>,
392+
comment: Option<&str>,
393+
expiration_update: Option<Option<&str>>,
394+
managed_client_name: Option<&str>,
395+
) -> Result<Option<PeerRow>, sqlx::Error> {
396+
let Some(expires_at) = expiration_update else {
397+
return update_peer_metadata(pool, id, display_name, comment).await;
398+
};
399+
let result = sqlx::query(
400+
"UPDATE peers
401+
SET display_name = ?, comment = ?, expires_at = ?,
402+
managed_client_name = COALESCE(?, managed_client_name),
403+
updated_at = CURRENT_TIMESTAMP
404+
WHERE id = ? AND archived = 0 AND removal_pending = 0",
405+
)
406+
.bind(display_name)
407+
.bind(comment)
408+
.bind(expires_at)
409+
.bind(managed_client_name)
410+
.bind(id)
411+
.execute(pool)
412+
.await?;
413+
414+
if result.rows_affected() == 0 {
415+
return Ok(None);
416+
}
417+
find_visible_by_id(pool, id).await
418+
}
419+
383420
/// Set or clear a peer's UTC expiration timestamp.
384421
///
385422
/// `None` makes the peer permanent. The caller is responsible for validating

0 commit comments

Comments
 (0)