Skip to content

Commit 0a4233e

Browse files
committed
fix(web): make expiration lifecycle updates atomic
1 parent c78ee54 commit 0a4233e

8 files changed

Lines changed: 215 additions & 53 deletions

File tree

amneziawg-web/docs/ARCHITECTURE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ list of disabled public keys; the helper derives and filters the trusted
7272
root-owned config internally before invoking `awg syncconf`.
7373
Peer additions and removals are semantic operations: the helper holds a stable
7474
per-interface lock, reconstructs an approved peer block or removes one exact
75-
managed-client block, and atomically replaces the config. Arbitrary config
75+
managed-client block, optionally after checking its expected public key under
76+
the same lock, and atomically replaces the config. Arbitrary config
7677
content, raw file reads, arbitrary `syncconf` stdin, and unknown operations are
7778
rejected rather than forwarded.
7879

amneziawg-web/docs/INSTALL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ allow-listed operations provide:
175175
lifecycle identity checks
176176
- `append-peer` – validate and atomically append one reconstructed managed-peer block
177177
- `remove-client` – atomically remove one exact, validated managed-client block
178+
- `remove-client-if-key` – atomically validate a managed client's public-key
179+
identity and remove its exact block
178180

179181
Every operation has a fixed argument shape. Unknown subcommands, malformed
180182
interface/client names, keys or AllowedIPs, unsafe configuration paths,

amneziawg-web/scripts/amneziawg-web-privileged

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,11 +539,16 @@ append_peer_block() {
539539
remove_client_block() {
540540
local path="$1"
541541
local client_name="$2"
542+
local expected_public_key="${3:-}"
543+
local actual_public_key=""
542544
local marker_index=-1 marker_count=0 start end index
543545
local interface_seen=false marker_after_interface=false
544546
local -a config_lines=()
545547

546548
validate_client_name "${client_name}"
549+
if [[ -n "${expected_public_key}" ]]; then
550+
validate_public_key "${expected_public_key}"
551+
fi
547552
mapfile -t config_lines < "${path}"
548553

549554
for index in "${!config_lines[@]}"; do
@@ -572,11 +577,17 @@ remove_client_block() {
572577

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

581592
# The web panel creates an exact five-line managed block. Refuse to
582593
# remove only its prefix if an operator has added another field or comment;
@@ -686,6 +697,18 @@ main() {
686697
remove_client_block "${remove_path}" "$2"
687698
return 0
688699
;;
700+
remove-client-if-key)
701+
require_arg_count "${subcommand}" 3 "$#"
702+
validate_interface "$1"
703+
validate_client_name "$2"
704+
validate_public_key "$3"
705+
local conditional_remove_path="${AWG_CONFIG_ROOT}/$1.conf"
706+
validate_server_config_path "${conditional_remove_path}"
707+
acquire_server_config_lock "$1"
708+
validate_server_config_path "${conditional_remove_path}"
709+
remove_client_block "${conditional_remove_path}" "$2" "$3"
710+
return 0
711+
;;
689712
*)
690713
fail "unsupported subcommand: ${subcommand}"
691714
;;

amneziawg-web/src/admin/client_manager.rs

Lines changed: 10 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
//! | Read safe params | `…-privileged read-params` |
1616
//! | Read sanitized server state | `…-privileged read-server-state <iface>` |
1717
//! | Append peer to server config | `…-privileged append-peer <iface> <name>` |
18-
//! | Remove managed peer block | `…-privileged remove-client <iface> <name>` |
18+
//! | Remove managed peer block | `…-privileged remove-client[-if-key] …` |
1919
//! | Reconcile live interface | `…-privileged reconcile-interface <iface>` |
2020
//!
2121
//! Key generation (`awg genkey`, `awg pubkey`, `awg genpsk`) does **not**
@@ -950,26 +950,6 @@ fn server_config_contains_public_key(server_config: &str, expected_public_key: &
950950
})
951951
}
952952

953-
fn named_client_has_public_key(
954-
server_config: &str,
955-
name: &str,
956-
expected_public_key: &str,
957-
) -> Option<bool> {
958-
let marker = format!("### Client {name}");
959-
let mut lines = server_config.lines();
960-
lines.find(|line| line.trim() == marker)?;
961-
962-
Some(
963-
lines
964-
.take_while(|line| !line.trim_start().starts_with("### Client "))
965-
.any(|line| {
966-
line.split_once('=')
967-
.filter(|(key, _)| key.trim().eq_ignore_ascii_case("PublicKey"))
968-
.is_some_and(|(_, value)| value.trim() == expected_public_key)
969-
}),
970-
)
971-
}
972-
973953
fn live_interfaces_contain_public_key(
974954
interfaces: &[awg::AwgInterface],
975955
expected_public_key: &str,
@@ -1394,13 +1374,15 @@ fn remove_client_inner(
13941374
.map_err(|e| RemoveClientError::ParamsRead(format!("failed to read server config: {e}")))?;
13951375

13961376
if remove_client_block(&server_config, name).is_some() {
1397-
if expected_public_key.is_some_and(|public_key| {
1398-
named_client_has_public_key(&server_config, name, public_key) != Some(true)
1399-
}) {
1400-
return Err(RemoveClientError::IdentityMismatch(name.to_string()));
1377+
match expected_public_key {
1378+
Some(public_key) => {
1379+
// The helper revalidates this identity while holding the
1380+
// server-config lock, closing the read/delete TOCTOU window.
1381+
awg::remove_client_if_key_via_sudo(&params.server_awg_nic, name, public_key)
1382+
}
1383+
None => awg::remove_client_via_sudo(&params.server_awg_nic, name),
14011384
}
1402-
awg::remove_client_via_sudo(&params.server_awg_nic, name)
1403-
.map_err(|e| RemoveClientError::FileWrite(format!("remove from server config: {e}")))?;
1385+
.map_err(|e| RemoveClientError::FileWrite(format!("remove from server config: {e}")))?;
14041386
} else if !allow_missing_server_block {
14051387
return Err(RemoveClientError::ClientNotFound(name.to_string()));
14061388
} else if expected_public_key
@@ -1525,21 +1507,9 @@ mod tests {
15251507
}
15261508

15271509
#[test]
1528-
fn resumable_removal_identity_helpers_detect_renamed_and_mismatched_peers() {
1510+
fn resumable_removal_identity_helpers_detect_keys_across_config_and_live_state() {
15291511
let server = "[Interface]\nPrivateKey = S\n\n### Client renamed\n[Peer]\nPublicKey = ALICE_KEY=\n\n### Client bob\n[Peer]\nPublicKey = BOB_KEY=\n";
15301512

1531-
assert_eq!(
1532-
named_client_has_public_key(server, "renamed", "ALICE_KEY="),
1533-
Some(true)
1534-
);
1535-
assert_eq!(
1536-
named_client_has_public_key(server, "renamed", "BOB_KEY="),
1537-
Some(false)
1538-
);
1539-
assert_eq!(
1540-
named_client_has_public_key(server, "alice", "ALICE_KEY="),
1541-
None
1542-
);
15431513
assert!(server_config_contains_public_key(server, "ALICE_KEY="));
15441514
assert!(!server_config_contains_public_key(server, "MISSING_KEY="));
15451515

amneziawg-web/src/admin/mod.rs

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ use crate::domain::{normalize_comment, PublicKey};
2121

2222
use self::client_manager::{acquire_lifecycle_lock, RemoveClientError};
2323

24-
/// Serializes expiration edits with automated expiration cleanup. The native
25-
/// lifecycle lock still protects server/config rewrites; this lock closes the
26-
/// smaller in-process race between revalidating a deadline and starting that
27-
/// rewrite.
24+
/// Serializes expiration edits with creation metadata persistence and
25+
/// automated expiration cleanup. The native lifecycle lock still protects
26+
/// server/config rewrites; this lock closes the smaller in-process races at
27+
/// the database/lifecycle boundary.
2828
static EXPIRATION_STATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
2929

3030
fn map_lock_error(err: std::io::Error) -> RemoveClientError {
@@ -184,8 +184,8 @@ pub async fn execute_suggest_ips(
184184
/// 4. Logs `user_created` or `user_create_failed`.
185185
///
186186
/// The caller is responsible for triggering a config rescan after success.
187-
// The test-only native-result injection adds an eighth parameter; production
188-
// retains the existing seven-argument lifecycle boundary.
187+
// Test-only native-result and lock-acquisition injections extend this
188+
// boundary; production retains the existing seven parameters.
189189
#[cfg_attr(test, allow(clippy::too_many_arguments))]
190190
pub async fn execute_create_user(
191191
db: &Database,
@@ -196,6 +196,7 @@ pub async fn execute_create_user(
196196
actor: &str,
197197
ip_override: &client_manager::IpOverride,
198198
#[cfg(test)] create_result_override: Option<client_manager::CreateClientResult>,
199+
#[cfg(test)] expiration_lock_acquired: Option<tokio::sync::oneshot::Sender<()>>,
199200
) -> Result<CreateUserResult, client_manager::CreateClientError> {
200201
// Pre-validate name (fail fast for the UI).
201202
script_bridge::validate_client_name(name)?;
@@ -241,7 +242,16 @@ pub async fn execute_create_user(
241242
}
242243
};
243244

244-
// Keep a single lifecycle lock held across native creation and expiration
245+
// Expiration state always precedes the native lifecycle lock. Keep both
246+
// held through metadata persistence so an edit cannot land on a
247+
// poller-created row and then be overwritten by the creation upsert.
248+
let _expiration_guard = EXPIRATION_STATE_LOCK.lock().await;
249+
#[cfg(test)]
250+
if let Some(acquired) = expiration_lock_acquired {
251+
let _ = acquired.send(());
252+
}
253+
254+
// Keep a single lifecycle lock held across native creation and metadata
245255
// persistence. If the database write fails, rollback runs under the same
246256
// lock, so no concurrent add/remove can strand or replace this client.
247257
#[cfg(test)]
@@ -918,6 +928,7 @@ mod tests {
918928
"test-admin",
919929
&client_manager::IpOverride::default(),
920930
Some(created_client_result(true)),
931+
None,
921932
)
922933
.await
923934
.expect("durable creation must remain a success");
@@ -978,6 +989,7 @@ mod tests {
978989
"test-admin",
979990
&client_manager::IpOverride::default(),
980991
Some(created_client_result(true)),
992+
None,
981993
)
982994
.await;
983995

@@ -1095,4 +1107,83 @@ mod tests {
10951107
assert_eq!(persisted.has_config, 1);
10961108
assert_eq!(persisted.sync_pending, 1);
10971109
}
1110+
1111+
#[tokio::test]
1112+
async fn expiration_edit_waits_for_creation_metadata_persistence() {
1113+
let db = Database::connect_for_test().await.expect("connect");
1114+
sqlx::query(
1115+
"INSERT INTO peers (public_key, allowed_ips)
1116+
VALUES ('CREATED_PUBLIC_KEY=', '10.66.66.2/32')",
1117+
)
1118+
.execute(&db.pool)
1119+
.await
1120+
.expect("seed poller-created peer");
1121+
let peer = find_by_public_key(&db.pool, "CREATED_PUBLIC_KEY=")
1122+
.await
1123+
.expect("query peer")
1124+
.expect("seeded peer");
1125+
1126+
// Hold the config-mapping lock so creation pauses after taking the
1127+
// expiration lock but before its upsert can overwrite the seeded row.
1128+
let mapping_guard = crate::poller::acquire_config_mapping_lock().await;
1129+
let task_db = db.clone();
1130+
let dir = tempfile::tempdir().expect("tempdir");
1131+
let dir_path = dir.path().to_path_buf();
1132+
let (lock_acquired_tx, lock_acquired_rx) = tokio::sync::oneshot::channel();
1133+
let create_task = tokio::spawn(async move {
1134+
let ip_override = client_manager::IpOverride::default();
1135+
execute_create_user(
1136+
&task_db,
1137+
&dir_path,
1138+
"alice",
1139+
None,
1140+
None,
1141+
"test-admin",
1142+
&ip_override,
1143+
Some(created_client_result(false)),
1144+
Some(lock_acquired_tx),
1145+
)
1146+
.await
1147+
});
1148+
1149+
lock_acquired_rx
1150+
.await
1151+
.expect("creation must acquire the expiration lock before persistence");
1152+
1153+
let update_db = db.clone();
1154+
let update_task = tokio::spawn(async move {
1155+
execute_update_peer_expiration(
1156+
&update_db,
1157+
peer.id,
1158+
Some("2026-08-18T12:00:00Z"),
1159+
Some("alice"),
1160+
)
1161+
.await
1162+
});
1163+
tokio::task::yield_now().await;
1164+
assert!(
1165+
!update_task.is_finished(),
1166+
"expiration edit must wait until creation metadata is durable"
1167+
);
1168+
1169+
drop(mapping_guard);
1170+
create_task
1171+
.await
1172+
.expect("creation task")
1173+
.expect("create user");
1174+
update_task
1175+
.await
1176+
.expect("expiration task")
1177+
.expect("update expiration")
1178+
.expect("updated peer");
1179+
1180+
let persisted = find_by_public_key(&db.pool, "CREATED_PUBLIC_KEY=")
1181+
.await
1182+
.expect("query peer")
1183+
.expect("persisted peer");
1184+
assert_eq!(
1185+
persisted.expires_at.as_deref(),
1186+
Some("2026-08-18T12:00:00Z")
1187+
);
1188+
}
10981189
}

amneziawg-web/src/awg/mod.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,37 @@ pub fn remove_client_via_sudo(interface: &str, name: &str) -> Result<(), AwgErro
393393
Ok(())
394394
}
395395

396+
/// Remove one installer-managed client block only if its public key still
397+
/// matches `expected_public_key`. The helper performs the identity check and
398+
/// config rewrite atomically while holding the per-interface lock.
399+
#[cfg_attr(not(unix), allow(dead_code))]
400+
pub fn remove_client_if_key_via_sudo(
401+
interface: &str,
402+
name: &str,
403+
expected_public_key: &str,
404+
) -> Result<(), AwgError> {
405+
let output = Command::new(SUDO_BIN)
406+
.args([
407+
"-n",
408+
PRIVILEGED_HELPER_BIN,
409+
"remove-client-if-key",
410+
interface,
411+
name,
412+
expected_public_key,
413+
])
414+
.output()?;
415+
416+
if !output.status.success() {
417+
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
418+
return Err(AwgError::NonZeroExit {
419+
status: output.status.code().unwrap_or(-1),
420+
stderr,
421+
});
422+
}
423+
424+
Ok(())
425+
}
426+
396427
/// Remove `[Peer]` sections from a WireGuard stripped config whose
397428
/// `PublicKey` appears in `disabled_keys`.
398429
///
@@ -827,6 +858,31 @@ awg0\tCLIENT2_PUB_KEY=\t(none)\t(none)\t10.8.0.3/32\t0\t0\t0\toff\n\
827858
"alice",
828859
]
829860
);
861+
862+
let mut conditional_remove_cmd = Command::new(SUDO_BIN);
863+
conditional_remove_cmd.args([
864+
"-n",
865+
PRIVILEGED_HELPER_BIN,
866+
"remove-client-if-key",
867+
"awg0",
868+
"alice",
869+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
870+
]);
871+
let conditional_remove_args: Vec<_> = conditional_remove_cmd
872+
.get_args()
873+
.map(|a| a.to_string_lossy().to_string())
874+
.collect();
875+
assert_eq!(
876+
conditional_remove_args,
877+
vec![
878+
"-n",
879+
PRIVILEGED_HELPER_BIN,
880+
"remove-client-if-key",
881+
"awg0",
882+
"alice",
883+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
884+
]
885+
);
830886
}
831887

832888
// ── filter_disabled_peers tests ─────────────────────────────────

amneziawg-web/src/web/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2807,6 +2807,8 @@ async fn api_create_user(
28072807
&ip_override,
28082808
#[cfg(test)]
28092809
injected_create_result(&headers, &state, &name, &ip_override),
2810+
#[cfg(test)]
2811+
None,
28102812
)
28112813
.await
28122814
{
@@ -3033,6 +3035,8 @@ async fn post_add_user_form(
30333035
&ip_override,
30343036
#[cfg(test)]
30353037
injected_create_result(&headers, &state, &name, &ip_override),
3038+
#[cfg(test)]
3039+
None,
30363040
)
30373041
.await
30383042
{

0 commit comments

Comments
 (0)