Skip to content

Commit fd1a560

Browse files
committed
fix(web): preserve expiration edits during remap
1 parent 89ce3ea commit fd1a560

4 files changed

Lines changed: 147 additions & 9 deletions

File tree

amneziawg-web/src/admin/script_bridge.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ pub fn validate_client_name(name: &str) -> Result<(), ScriptError> {
138138
Ok(())
139139
}
140140

141+
/// Return the stable installer client name encoded by a discovered config.
142+
///
143+
/// Installer-created configs use `<interface>-client-<name>.conf`. Generic
144+
/// config filenames must not be treated as lifecycle-managed users, even when
145+
/// their friendly name happens to be a valid client name.
146+
pub fn managed_client_name_from_config<'a>(
147+
config_name: &str,
148+
friendly_name: &'a str,
149+
) -> Option<&'a str> {
150+
let (interface_name, filename_client_name) = config_name.rsplit_once("-client-")?;
151+
(!interface_name.is_empty()
152+
&& filename_client_name == friendly_name
153+
&& validate_client_name(friendly_name).is_ok())
154+
.then_some(friendly_name)
155+
}
156+
141157
// ── Script bridge ────────────────────────────────────────────────────────────
142158

143159
/// Bridge to the `amneziawg-install.sh` script for client lifecycle actions.
@@ -386,6 +402,23 @@ mod tests {
386402
assert!(validate_client_name("..").is_err());
387403
}
388404

405+
#[test]
406+
fn managed_client_name_requires_installer_filename_shape() {
407+
assert_eq!(
408+
managed_client_name_from_config("awg0-client-alice", "alice"),
409+
Some("alice")
410+
);
411+
assert_eq!(managed_client_name_from_config("alice", "alice"), None);
412+
assert_eq!(
413+
managed_client_name_from_config("awg0-client-alice", "bob"),
414+
None
415+
);
416+
assert_eq!(
417+
managed_client_name_from_config("awg0-client-invalid.name", "invalid.name"),
418+
None
419+
);
420+
}
421+
389422
// ── build_args ───────────────────────────────────────────────────────
390423

391424
#[test]

amneziawg-web/src/db/peers.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -812,25 +812,33 @@ pub async fn clear_all_config_mappings(pool: &SqlitePool) -> Result<(), sqlx::Er
812812
/// Mark one peer as having a discovered config file.
813813
///
814814
/// Sets `has_config = 1`, `config_name`, `config_path`, and `friendly_name`
815-
/// for the peer identified by `public_key`. Returns `true` if a matching
816-
/// peer was found and updated, `false` if no peer with that public key
817-
/// exists (the config file may reference a key that does not correspond to
815+
/// for the peer identified by `public_key`. When config discovery proves the
816+
/// peer was created by the installer, `managed_client_name` stores that stable
817+
/// lifecycle identity if one has not already been recorded. Returns `true` if
818+
/// a matching peer was found and updated, `false` if no peer with that public
819+
/// key exists (the config file may reference a key that does not correspond to
818820
/// any known peer — for example the server's own public key).
819821
pub async fn apply_config_mapping(
820822
pool: &SqlitePool,
821823
public_key: &str,
822824
config_name: &str,
823825
config_path: &str,
824826
friendly_name: &str,
827+
managed_client_name: Option<&str>,
825828
) -> Result<bool, sqlx::Error> {
826829
let result = sqlx::query(
827830
"UPDATE peers
828-
SET has_config = 1, config_name = ?, config_path = ?, friendly_name = ?
831+
SET has_config = 1,
832+
config_name = ?,
833+
config_path = ?,
834+
friendly_name = ?,
835+
managed_client_name = COALESCE(managed_client_name, ?)
829836
WHERE public_key = ? AND archived = 0",
830837
)
831838
.bind(config_name)
832839
.bind(config_path)
833840
.bind(friendly_name)
841+
.bind(managed_client_name)
834842
.bind(public_key)
835843
.execute(pool)
836844
.await?;
@@ -1185,6 +1193,7 @@ mod tests {
11851193
"awg0-client-gramm",
11861194
"/etc/awg/awg0-client-gramm.conf",
11871195
"gramm",
1196+
Some("gramm"),
11881197
)
11891198
.await
11901199
.expect("apply mapping");
@@ -1198,6 +1207,14 @@ mod tests {
11981207
Some("/etc/awg/awg0-client-gramm.conf")
11991208
);
12001209
assert_eq!(row.friendly_name.as_deref(), Some("gramm"));
1210+
assert_eq!(row.managed_client_name.as_deref(), Some("gramm"));
1211+
1212+
clear_all_config_mappings(&db.pool)
1213+
.await
1214+
.expect("clear transient mapping fields");
1215+
let row = find_by_id(&db.pool, id).await.unwrap().unwrap();
1216+
assert_eq!(row.has_config, 0);
1217+
assert_eq!(row.managed_client_name.as_deref(), Some("gramm"));
12011218
}
12021219

12031220
#[tokio::test]
@@ -1210,6 +1227,7 @@ mod tests {
12101227
"ghost",
12111228
"/etc/awg/ghost.conf",
12121229
"ghost",
1230+
None,
12131231
)
12141232
.await
12151233
.expect("should not error");
@@ -1230,6 +1248,7 @@ mod tests {
12301248
"idem-config",
12311249
"/etc/awg/idem.conf",
12321250
"idem-config",
1251+
None,
12331252
)
12341253
.await
12351254
.unwrap();
@@ -1775,6 +1794,7 @@ mod tests {
17751794
"reappeared",
17761795
"/etc/amnezia/amneziawg/reappeared.conf",
17771796
"reappeared",
1797+
None,
17781798
)
17791799
.await
17801800
.unwrap());
@@ -2015,6 +2035,7 @@ mod tests {
20152035
"awg0-client-stale",
20162036
"/etc/amnezia/amneziawg/clients/awg0-client-stale.conf",
20172037
"stale",
2038+
Some("stale"),
20182039
)
20192040
.await
20202041
.expect("apply config mapping");

amneziawg-web/src/poller/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,10 @@ async fn apply_config_mappings(
529529
let mut mapped_by_ip: usize = 0;
530530
for config in configs {
531531
let path_str = config.path.to_string_lossy();
532+
let managed_client_name = crate::admin::script_bridge::managed_client_name_from_config(
533+
&config.name,
534+
&config.friendly_name,
535+
);
532536

533537
// ── Strategy 1: exact public-key match ──────────────────
534538
//
@@ -544,6 +548,7 @@ async fn apply_config_mappings(
544548
&config.name,
545549
&path_str,
546550
&config.friendly_name,
551+
managed_client_name,
547552
)
548553
.await
549554
{
@@ -603,6 +608,7 @@ async fn apply_config_mappings(
603608
&config.name,
604609
&path_str,
605610
&config.friendly_name,
611+
managed_client_name,
606612
)
607613
.await
608614
{

amneziawg-web/src/web/mod.rs

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -715,12 +715,8 @@ fn managed_client_name_for_expiration(row: &PeerRow) -> Option<&str> {
715715
return None;
716716
}
717717
let config_name = row.config_name.as_deref()?;
718-
let (interface_name, filename_client_name) = config_name.rsplit_once("-client-")?;
719718
let friendly_name = row.friendly_name.as_deref()?;
720-
(!interface_name.is_empty()
721-
&& filename_client_name == friendly_name
722-
&& crate::admin::script_bridge::validate_client_name(friendly_name).is_ok())
723-
.then_some(friendly_name)
719+
crate::admin::script_bridge::managed_client_name_from_config(config_name, friendly_name)
724720
})
725721
}
726722

@@ -6108,6 +6104,78 @@ mod tests {
61086104
assert_eq!(row.managed_client_name.as_deref(), Some("alice"));
61096105
}
61106106

6107+
#[tokio::test]
6108+
async fn expiration_edits_use_stable_identity_during_config_remap() {
6109+
let db = test_db().await;
6110+
let api_id = insert_peer(&db, "EXPIRY_REMAP_API_KEY=", Some("Alice")).await;
6111+
crate::db::peers::apply_config_mapping(
6112+
&db.pool,
6113+
"EXPIRY_REMAP_API_KEY=",
6114+
"awg0-client-alice",
6115+
"/etc/amnezia/amneziawg/clients/awg0-client-alice.conf",
6116+
"alice",
6117+
Some("alice"),
6118+
)
6119+
.await
6120+
.unwrap();
6121+
crate::db::peers::clear_all_config_mappings(&db.pool)
6122+
.await
6123+
.unwrap();
6124+
6125+
let html_id = insert_peer(&db, "EXPIRY_REMAP_HTML_KEY=", Some("Bob")).await;
6126+
crate::db::peers::apply_config_mapping(
6127+
&db.pool,
6128+
"EXPIRY_REMAP_HTML_KEY=",
6129+
"awg0-client-bob",
6130+
"/etc/amnezia/amneziawg/clients/awg0-client-bob.conf",
6131+
"bob",
6132+
Some("bob"),
6133+
)
6134+
.await
6135+
.unwrap();
6136+
crate::db::peers::clear_all_config_mappings(&db.pool)
6137+
.await
6138+
.unwrap();
6139+
6140+
let app = test_router(db.clone());
6141+
let response = app
6142+
.clone()
6143+
.oneshot(
6144+
Request::builder()
6145+
.method("PATCH")
6146+
.uri(format!("/api/peers/{api_id}"))
6147+
.header("content-type", "application/json")
6148+
.body(Body::from(r#"{"expiration_days":7}"#))
6149+
.unwrap(),
6150+
)
6151+
.await
6152+
.unwrap();
6153+
assert_eq!(response.status(), StatusCode::OK);
6154+
6155+
let response = app
6156+
.oneshot(
6157+
Request::builder()
6158+
.method("POST")
6159+
.uri(format!("/peers/{html_id}"))
6160+
.header("content-type", "application/x-www-form-urlencoded")
6161+
.body(Body::from("display_name=Bob&comment=&expiration_days=7"))
6162+
.unwrap(),
6163+
)
6164+
.await
6165+
.unwrap();
6166+
assert_eq!(response.status(), StatusCode::SEE_OTHER);
6167+
6168+
for (id, expected_name) in [(api_id, "alice"), (html_id, "bob")] {
6169+
let row = crate::db::peers::find_by_id(&db.pool, id)
6170+
.await
6171+
.unwrap()
6172+
.unwrap();
6173+
assert_eq!(row.has_config, 0);
6174+
assert_eq!(row.managed_client_name.as_deref(), Some(expected_name));
6175+
assert!(row.expires_at.is_some());
6176+
}
6177+
}
6178+
61116179
#[tokio::test]
61126180
async fn patch_peer_rejects_expiration_for_unmanaged_peer() {
61136181
let db = test_db().await;
@@ -7314,6 +7382,7 @@ mod tests {
73147382
"test-client",
73157383
conf_path.to_str().unwrap(),
73167384
"test-client",
7385+
None,
73177386
)
73187387
.await
73197388
.unwrap();
@@ -7732,6 +7801,7 @@ mod tests {
77327801
"existing-client",
77337802
"/etc/amnezia/amneziawg/existing-client.conf",
77347803
"existing-client",
7804+
None,
77357805
)
77367806
.await
77377807
.unwrap());
@@ -7757,6 +7827,7 @@ mod tests {
77577827
"test-dl",
77587828
"/etc/awg/test-dl.conf",
77597829
"test-dl",
7830+
None,
77607831
)
77617832
.await
77627833
.unwrap();
@@ -8602,6 +8673,7 @@ mod tests {
86028673
"test-qr",
86038674
conf_path.to_str().unwrap(),
86048675
"test-qr",
8676+
None,
86058677
)
86068678
.await
86078679
.unwrap();
@@ -8664,6 +8736,7 @@ mod tests {
86648736
"big-qr",
86658737
conf_path.to_str().unwrap(),
86668738
"big-qr",
8739+
None,
86678740
)
86688741
.await
86698742
.unwrap();
@@ -8713,6 +8786,7 @@ mod tests {
87138786
"test-qrui",
87148787
"/etc/awg/test-qrui.conf",
87158788
"test-qrui",
8789+
None,
87168790
)
87178791
.await
87188792
.unwrap();
@@ -8822,6 +8896,7 @@ mod tests {
88228896
"escaped",
88238897
outside_conf.to_str().unwrap(),
88248898
"escaped",
8899+
None,
88258900
)
88268901
.await
88278902
.unwrap();
@@ -8859,6 +8934,7 @@ mod tests {
88598934
"escaped-qr",
88608935
outside_conf.to_str().unwrap(),
88618936
"escaped-qr",
8937+
None,
88628938
)
88638939
.await
88648940
.unwrap();
@@ -8900,6 +8976,7 @@ mod tests {
89008976
"link",
89018977
link_conf.to_str().unwrap(),
89028978
"link",
8979+
None,
89038980
)
89048981
.await
89058982
.unwrap();
@@ -8939,6 +9016,7 @@ mod tests {
89399016
"link-qr",
89409017
link_conf.to_str().unwrap(),
89419018
"link-qr",
9019+
None,
89429020
)
89439021
.await
89449022
.unwrap();

0 commit comments

Comments
 (0)