Skip to content

Commit 7c25e66

Browse files
committed
fix(web): preserve lifecycle update invariants
1 parent be8f94e commit 7c25e66

6 files changed

Lines changed: 212 additions & 44 deletions

File tree

amneziawg-install.sh

Lines changed: 102 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,25 +1688,81 @@ function resolveWebPanelEnvFile() {
16881688
printf '%s\n' "${env_file}"
16891689
}
16901690
1691-
# Resolve the web panel's active config directory without sourcing its env file.
1692-
function resolveWebPanelConfigDir() {
1693-
local env_file configured_dir
1691+
# Read one web-panel setting without evaluating shell syntax. EnvironmentFile
1692+
# values override inline Environment= values, matching systemd semantics.
1693+
function readWebPanelSetting() {
1694+
local setting_name="$1"
1695+
local env_file inline_assignment value="" use_env_file=1
16941696
env_file="$(resolveWebPanelEnvFile)" || return 1
1697+
if [[ -f "${WEB_PANEL_SYSTEMD_UNIT}" ]] && \
1698+
! grep -q '^[[:space:]]*EnvironmentFile=' "${WEB_PANEL_SYSTEMD_UNIT}" 2>/dev/null; then
1699+
use_env_file=0
1700+
fi
16951701
1696-
if [[ -f "${env_file}" ]]; then
1697-
configured_dir="$(sed -n 's/^AWG_CONFIG_DIR=//p' "${env_file}" 2>/dev/null | tail -n 1)"
1698-
configured_dir="${configured_dir#\"}"
1699-
configured_dir="${configured_dir%\"}"
1700-
configured_dir="${configured_dir#\'}"
1701-
configured_dir="${configured_dir%\'}"
1702-
if [[ -n "${configured_dir}" ]]; then
1703-
if [[ "${configured_dir}" != /* || "${configured_dir}" =~ [[:space:][:cntrl:]] ]]; then
1704-
echo "ERROR: refusing unsafe AWG_CONFIG_DIR '${configured_dir}'" >&2
1702+
if [[ "${use_env_file}" -eq 1 && -f "${env_file}" ]] && \
1703+
grep -q "^[[:space:]]*${setting_name}=" "${env_file}" 2>/dev/null; then
1704+
value="$(sed -n "s/^[[:space:]]*${setting_name}=//p" "${env_file}" 2>/dev/null | tail -n 1)"
1705+
else
1706+
while IFS= read -r inline_assignment; do
1707+
if [[ "${inline_assignment}" != *"${setting_name}="* ]]; then
1708+
continue
1709+
fi
1710+
inline_assignment="${inline_assignment#\"}"
1711+
inline_assignment="${inline_assignment%\"}"
1712+
inline_assignment="${inline_assignment#\'}"
1713+
inline_assignment="${inline_assignment%\'}"
1714+
if [[ "${inline_assignment}" != "${setting_name}="* ]]; then
1715+
echo "ERROR: unsupported inline ${setting_name} assignment in '${WEB_PANEL_SYSTEMD_UNIT}'" >&2
17051716
return 1
17061717
fi
1707-
printf '%s\n' "${configured_dir%/}"
1708-
return 0
1718+
value="${inline_assignment#*=}"
1719+
done < <(sed -n 's/^[[:space:]]*Environment=//p' "${WEB_PANEL_SYSTEMD_UNIT}" 2>/dev/null)
1720+
fi
1721+
1722+
value="${value#\"}"
1723+
value="${value%\"}"
1724+
value="${value#\'}"
1725+
value="${value%\'}"
1726+
printf '%s\n' "${value}"
1727+
}
1728+
1729+
# Resolve the service working directory used for relative database paths.
1730+
function resolveWebPanelWorkingDirectory() {
1731+
local working_dir=""
1732+
if [[ -f "${WEB_PANEL_SYSTEMD_UNIT}" ]]; then
1733+
working_dir="$(sed -n 's/^[[:space:]]*WorkingDirectory=//p' "${WEB_PANEL_SYSTEMD_UNIT}" 2>/dev/null | tail -n 1)"
1734+
working_dir="${working_dir#-}"
1735+
working_dir="${working_dir#\"}"
1736+
working_dir="${working_dir%\"}"
1737+
working_dir="${working_dir#\'}"
1738+
working_dir="${working_dir%\'}"
1739+
working_dir="${working_dir:-/}"
1740+
else
1741+
working_dir="${WEB_PANEL_DATA_DIR}"
1742+
fi
1743+
1744+
if [[ "${working_dir}" != /* || "${working_dir}" =~ [[:space:][:cntrl:]] ]]; then
1745+
echo "ERROR: refusing unsafe web panel WorkingDirectory '${working_dir}'" >&2
1746+
return 1
1747+
fi
1748+
if [[ "${working_dir}" != "/" ]]; then
1749+
working_dir="${working_dir%/}"
1750+
fi
1751+
printf '%s\n' "${working_dir}"
1752+
}
1753+
1754+
# Resolve the web panel's active config directory without sourcing its env file.
1755+
function resolveWebPanelConfigDir() {
1756+
local configured_dir
1757+
configured_dir="$(readWebPanelSetting AWG_CONFIG_DIR)" || return 1
1758+
1759+
if [[ -n "${configured_dir}" ]]; then
1760+
if [[ "${configured_dir}" != /* || "${configured_dir}" =~ [[:space:][:cntrl:]] ]]; then
1761+
echo "ERROR: refusing unsafe AWG_CONFIG_DIR '${configured_dir}'" >&2
1762+
return 1
17091763
fi
1764+
printf '%s\n' "${configured_dir%/}"
1765+
return 0
17101766
fi
17111767
17121768
printf '%s\n' "${WEB_PANEL_CONFIG_DIR%/}"
@@ -1717,28 +1773,42 @@ function resolveWebPanelConfigDir() {
17171773
# client has been created yet. Standalone installer use (no panel env file)
17181774
# retains the config-directory fallback.
17191775
function resolveClientLifecycleLockDir() {
1720-
local env_file database_path
1776+
local env_file database_path working_dir database_parent
17211777
env_file="$(resolveWebPanelEnvFile)" || return 1
17221778
1723-
if [[ -f "${env_file}" ]]; then
1724-
database_path="$(sed -n 's/^AWG_WEB_DB=//p' "${env_file}" 2>/dev/null | tail -n 1)"
1725-
database_path="${database_path#\"}"
1726-
database_path="${database_path%\"}"
1727-
database_path="${database_path#\'}"
1728-
database_path="${database_path%\'}"
1729-
if [[ -n "${database_path}" ]]; then
1730-
if [[ "${database_path}" != /* || "${database_path}" =~ [[:space:][:cntrl:]] ]]; then
1731-
echo "ERROR: refusing unsafe AWG_WEB_DB '${database_path}'" >&2
1732-
return 1
1733-
fi
1734-
dirname -- "${database_path}"
1735-
return 0
1736-
fi
1737-
printf '%s\n' "${WEB_PANEL_DATA_DIR%/}"
1738-
return 0
1779+
if [[ ! -f "${env_file}" && ! -e "${WEB_PANEL_SYSTEMD_UNIT}" ]]; then
1780+
resolveWebPanelConfigDir
1781+
return
17391782
fi
17401783
1741-
resolveWebPanelConfigDir
1784+
database_path="$(readWebPanelSetting AWG_WEB_DB)" || return 1
1785+
database_path="${database_path:-awg-web.db}"
1786+
if [[ "${database_path}" =~ [[:space:][:cntrl:]] ]]; then
1787+
echo "ERROR: refusing unsafe AWG_WEB_DB '${database_path}'" >&2
1788+
return 1
1789+
fi
1790+
case "${database_path}" in
1791+
sqlite://*) database_path="${database_path#sqlite://}" ;;
1792+
sqlite:*) database_path="${database_path#sqlite:}" ;;
1793+
esac
1794+
database_path="${database_path%%\?*}"
1795+
working_dir="$(resolveWebPanelWorkingDirectory)" || return 1
1796+
1797+
if [[ -z "${database_path}" || "${database_path}" == ":memory:" ]]; then
1798+
printf '%s\n' "${working_dir}"
1799+
return 0
1800+
fi
1801+
database_parent="$(dirname -- "${database_path}")"
1802+
if [[ "${database_path}" == /* ]]; then
1803+
if [[ "${database_parent}" != "/" ]]; then
1804+
database_parent="${database_parent%/}"
1805+
fi
1806+
printf '%s\n' "${database_parent}"
1807+
elif [[ "${database_parent}" == "." ]]; then
1808+
printf '%s\n' "${working_dir}"
1809+
else
1810+
printf '%s/%s\n' "${working_dir%/}" "${database_parent}"
1811+
fi
17421812
}
17431813
17441814
# Copy a client config file to the web panel config directory so the panel

amneziawg-web/docs/ARCHITECTURE.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,14 @@ the same lock, and atomically replaces the config. Arbitrary config
7777
content, raw file reads, arbitrary `syncconf` stdin, and unknown operations are
7878
rejected rather than forwarded.
7979

80-
The web panel holds an advisory lock on the open client-config directory across
81-
the complete managed-client lifecycle, including database persistence and
82-
client-config cleanup. Supported installer `--add-client` and `--remove-client`
83-
operations lock the same directory descriptor, preventing an out-of-band
84-
same-name replacement from appearing inside a web lifecycle operation without
85-
introducing a mutable lock pathname in the service-writable directory.
80+
The web panel holds an advisory lock on its open persistent state directory
81+
(the parent of the SQLite database) across the complete managed-client
82+
lifecycle, including database persistence and client-config cleanup. Supported
83+
installer `--add-client` and `--remove-client` operations resolve and lock the
84+
same directory descriptor from the installed service configuration. This inode
85+
remains available when the client-config directory is absent, prevents an
86+
out-of-band same-name replacement during web lifecycle work, and avoids a
87+
mutable lock pathname in a service-writable directory.
8688

8789
---
8890

amneziawg-web/docs/MVP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
|21 | 232 unit + integration tests | Auth, domain, DB, config, history, web handler, lifecycle/admin layers |
2828
|22 | User create (native Rust) | `POST /api/admin/users`, HTML form at `/admin/users/add`; validates name; allocates IPs; writes configs; syncs AWG directly |
2929
|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 |
30-
|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 |
30+
|24 | Lifecycle locking + validation | Cross-process add/remove lock on the persistent database state directory shared with installer CLI operations, plus installer-name validation for managed user actions |
3131
|25 | User lifecycle audit events | `user_create_requested`, `user_created`, `user_create_failed`, `user_remove_requested`, `user_removed`, `user_remove_failed` |
3232
|26 | Post-action config rescan | `poller::rescan_configs()` called after create/remove; no manual restart needed |
3333

amneziawg-web/src/db/peers.rs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ pub async fn find_snapshots(
357357
///
358358
/// Returns the updated `PeerRow`, or `None` if no peer with the given `id`
359359
/// exists.
360+
#[cfg(test)]
360361
pub async fn update_peer_metadata(
361362
pool: &SqlitePool,
362363
id: i64,
@@ -393,18 +394,19 @@ pub async fn update_peer_details(
393394
expiration_update: Option<Option<&str>>,
394395
managed_client_name: Option<&str>,
395396
) -> 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-
};
397+
let update_expiration = expiration_update.is_some();
398+
let expires_at = expiration_update.flatten();
399399
let result = sqlx::query(
400400
"UPDATE peers
401-
SET display_name = ?, comment = ?, expires_at = ?,
401+
SET display_name = ?, comment = ?,
402+
expires_at = CASE WHEN ? THEN ? ELSE expires_at END,
402403
managed_client_name = COALESCE(?, managed_client_name),
403404
updated_at = CURRENT_TIMESTAMP
404405
WHERE id = ? AND archived = 0 AND removal_pending = 0",
405406
)
406407
.bind(display_name)
407408
.bind(comment)
409+
.bind(update_expiration)
408410
.bind(expires_at)
409411
.bind(managed_client_name)
410412
.bind(id)
@@ -1384,6 +1386,47 @@ mod tests {
13841386
assert!(result.is_none());
13851387
}
13861388

1389+
#[tokio::test]
1390+
async fn update_peer_details_keeps_omitted_expiration_and_rejects_pending_removal() {
1391+
let db = test_db().await;
1392+
let id = insert_peer(&db.pool, "KEY_DETAILS_REMOVING=", Some("Original")).await;
1393+
update_peer_expiration(&db.pool, id, Some("2026-08-18T12:00:00Z"), Some("alice"))
1394+
.await
1395+
.unwrap()
1396+
.expect("set expiration");
1397+
1398+
let updated = update_peer_details(
1399+
&db.pool,
1400+
id,
1401+
Some("Before removal"),
1402+
Some("Before removal comment"),
1403+
None,
1404+
None,
1405+
)
1406+
.await
1407+
.unwrap()
1408+
.expect("metadata-only update");
1409+
assert_eq!(updated.expires_at.as_deref(), Some("2026-08-18T12:00:00Z"));
1410+
1411+
assert!(mark_removal_pending(&db.pool, id).await.unwrap());
1412+
assert!(update_peer_details(
1413+
&db.pool,
1414+
id,
1415+
Some("Changed"),
1416+
Some("Changed comment"),
1417+
None,
1418+
None,
1419+
)
1420+
.await
1421+
.unwrap()
1422+
.is_none());
1423+
1424+
let row = find_by_id(&db.pool, id).await.unwrap().unwrap();
1425+
assert_eq!(row.display_name.as_deref(), Some("Before removal"));
1426+
assert_eq!(row.comment.as_deref(), Some("Before removal comment"));
1427+
assert_eq!(row.expires_at.as_deref(), Some("2026-08-18T12:00:00Z"));
1428+
}
1429+
13871430
// ── expiration ──────────────────────────────────────────────────────────
13881431

13891432
#[tokio::test]

amneziawg-web/src/web/mod.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6144,6 +6144,7 @@ mod tests {
61446144

61456145
let app = test_router(db.clone());
61466146
let response = app
6147+
.clone()
61476148
.oneshot(
61486149
Request::builder()
61496150
.method("PATCH")
@@ -6157,6 +6158,20 @@ mod tests {
61576158
.await
61586159
.unwrap();
61596160
assert_eq!(response.status(), StatusCode::CONFLICT);
6161+
let response = app
6162+
.oneshot(
6163+
Request::builder()
6164+
.method("PATCH")
6165+
.uri(format!("/api/peers/{id}"))
6166+
.header("content-type", "application/json")
6167+
.body(Body::from(
6168+
r#"{"display_name":"Changed","comment":"Changed comment"}"#,
6169+
))
6170+
.unwrap(),
6171+
)
6172+
.await
6173+
.unwrap();
6174+
assert_eq!(response.status(), StatusCode::CONFLICT);
61606175
let row = crate::db::peers::find_by_id(&db.pool, id)
61616176
.await
61626177
.unwrap()
@@ -6202,6 +6217,7 @@ mod tests {
62026217

62036218
let app = test_router(db.clone());
62046219
let response = app
6220+
.clone()
62056221
.oneshot(
62066222
Request::builder()
62076223
.method("POST")
@@ -6215,6 +6231,18 @@ mod tests {
62156231
.await
62166232
.unwrap();
62176233
assert_eq!(response.status(), StatusCode::CONFLICT);
6234+
let response = app
6235+
.oneshot(
6236+
Request::builder()
6237+
.method("POST")
6238+
.uri(format!("/peers/{id}"))
6239+
.header("content-type", "application/x-www-form-urlencoded")
6240+
.body(Body::from("display_name=Changed&comment=Changed+comment"))
6241+
.unwrap(),
6242+
)
6243+
.await
6244+
.unwrap();
6245+
assert_eq!(response.status(), StatusCode::CONFLICT);
62186246
let row = crate::db::peers::find_by_id(&db.pool, id)
62196247
.await
62206248
.unwrap()

tests/test-functions.sh

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1164,11 +1164,13 @@ NIAC_CUSTOM_CONFIG_DIR="${NIAC_WEB_ROOT}/custom-clients"
11641164
NIAC_CUSTOM_STATE_DIR="${NIAC_WEB_ROOT}/custom-state"
11651165
mkdir -p "${NIAC_CUSTOM_STATE_DIR}"
11661166
cat > "${NIAC_WEB_ROOT}/custom.env" <<EOF
1167-
AWG_WEB_DB=${NIAC_CUSTOM_STATE_DIR}/awg-web.db
1167+
AWG_WEB_DB=sqlite://${NIAC_CUSTOM_STATE_DIR}/awg-web.db
11681168
AWG_CONFIG_DIR=${NIAC_CUSTOM_CONFIG_DIR}
11691169
EOF
11701170
cat > "${NIAC_WEB_ROOT}/amneziawg-web.service" <<EOF
11711171
[Service]
1172+
Environment=AWG_WEB_DB=${NIAC_LOCK_DIR}/ignored.db
1173+
Environment=AWG_CONFIG_DIR=${NIAC_LOCK_DIR}/ignored-clients
11721174
EnvironmentFile=${NIAC_WEB_ROOT}/custom.env
11731175
EOF
11741176
WEB_PANEL_CONFIG_DIR="${NIAC_LOCK_DIR}"
@@ -1189,12 +1191,35 @@ assert_rc 1 acquireClientLifecycleLock
11891191
assert_eq "false" "$([[ -e "${NIAC_CUSTOM_STATE_DIR}" ]] && echo true || echo false)" \
11901192
"root CLI does not recreate a missing panel state directory"
11911193

1194+
# Inline unit settings are supported too. Relative database paths resolve from
1195+
# WorkingDirectory, and an unreferenced environment file is ignored.
1196+
NIAC_INLINE_WORK_DIR="${NIAC_WEB_ROOT}/inline-work"
1197+
NIAC_INLINE_STATE_DIR="${NIAC_INLINE_WORK_DIR}/relative-state"
1198+
NIAC_INLINE_CONFIG_DIR="${NIAC_WEB_ROOT}/inline-clients"
1199+
mkdir -p "${NIAC_INLINE_STATE_DIR}"
1200+
WEB_PANEL_ENV_FILE="${NIAC_WEB_ROOT}/custom.env"
1201+
cat > "${NIAC_WEB_ROOT}/amneziawg-web.service" <<EOF
1202+
[Service]
1203+
WorkingDirectory=${NIAC_INLINE_WORK_DIR}
1204+
Environment="AWG_WEB_DB=sqlite:relative-state/awg-web.db"
1205+
Environment=AWG_CONFIG_DIR=${NIAC_INLINE_CONFIG_DIR}
1206+
EOF
1207+
assert_eq "${NIAC_INLINE_STATE_DIR}" "$(resolveClientLifecycleLockDir)" \
1208+
"inline relative database path resolves from WorkingDirectory"
1209+
assert_eq "${NIAC_INLINE_CONFIG_DIR}" "$(resolveWebPanelConfigDir)" \
1210+
"inline client config directory is discovered"
1211+
exec {NIAC_HELD_LOCK_FD}< "${NIAC_INLINE_STATE_DIR}"
1212+
flock -xn "${NIAC_HELD_LOCK_FD}"
1213+
assert_rc 1 _client_lock_must_be_busy
1214+
exec {NIAC_HELD_LOCK_FD}>&-
1215+
11921216
WEB_PANEL_CONFIG_DIR="${NIAC_ORIGINAL_WEB_PANEL_CONFIG_DIR}"
11931217
WEB_PANEL_ENV_FILE="${NIAC_ORIGINAL_WEB_PANEL_ENV_FILE}"
11941218
WEB_PANEL_SYSTEMD_UNIT="${NIAC_ORIGINAL_WEB_PANEL_SYSTEMD_UNIT}"
11951219
rm -rf "${NIAC_LOCK_DIR}"
11961220
rm -rf "${NIAC_WEB_ROOT}"
11971221
unset NIAC_LOCK_DIR NIAC_LOCK_LINK NIAC_CUSTOM_CONFIG_DIR NIAC_CUSTOM_STATE_DIR NIAC_WEB_ROOT
1222+
unset NIAC_INLINE_WORK_DIR NIAC_INLINE_STATE_DIR NIAC_INLINE_CONFIG_DIR
11981223
unset NIAC_ORIGINAL_WEB_PANEL_CONFIG_DIR NIAC_ORIGINAL_WEB_PANEL_ENV_FILE
11991224
unset NIAC_ORIGINAL_WEB_PANEL_SYSTEMD_UNIT NIAC_HELD_LOCK_FD
12001225
unset -f _client_lock_must_be_busy

0 commit comments

Comments
 (0)