feat: support AmneziaWG 3.1

This commit is contained in:
2026-08-28 00:28:26 +05:00
parent 3912b91bc4
commit 185991504a
16 changed files with 282 additions and 110 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "awg2-harness"
version = "0.3.0"
version = "0.3.1"
edition = "2024"
[dependencies]
+5 -5
View File
@@ -881,12 +881,12 @@ mod tests {
let raw = include_str!("../../config.example.toml");
let cfg = toml::from_str::<Config>(raw).expect("config.example.toml parse");
cfg.validate_config().expect("config.example.toml validate");
assert_eq!(cfg.amnezia.wg_binaries, vec!["awg", "wg"]);
assert!(
cfg.amnezia
.container_names
.contains(&"amnezia-awg2".to_string())
assert_eq!(
cfg.amnezia.container_names,
vec!["amnezia-awg2", "amnezia-awg"]
);
assert_eq!(cfg.amnezia.wg_binaries, vec!["awg", "wg"]);
assert_eq!(cfg.anti_torrent.max_connections_per_peer, 500);
}
#[test]
+61 -25
View File
@@ -29,10 +29,9 @@ pub struct PeerRuntime {
pub transfer_tx_bytes: Option<u64>,
}
/// Peer state is only readable inside the container's mount namespace: AmneziaWG is a
/// userspace implementation and its UAPI socket lives under `RUNSTATEDIR/amneziawg/`
/// in the container filesystem. A host-side `wg` entered into the container netns via
/// `nsenter -n` cannot reach that socket, so `docker exec` is the only valid transport.
/// Peer state is read with the official container-side tools. This is the stable access
/// point for both a kernel `amneziawg` interface and the `amneziawg-go` fallback, and it
/// avoids coupling the harness to the version or presence of host-side wg/awg tools.
fn container_transport(target: &RuntimeTarget) -> DockerExecTransport {
DockerExecTransport {
container: target.container_name.clone(),
@@ -175,8 +174,8 @@ fn parse_fallback_wg_show(raw: &str, peer_cidr: &str) -> anyhow::Result<Vec<Peer
///
/// Column count is not a discriminator and must never be used as one. The width of the
/// interface line is decided by the version of `amneziawg-tools` doing the printing, not
/// by which protocol parameters the tunnel actually has configured: AWG 3.0 tools emit the
/// extended row for an AWG 2.0 configuration too, with the newer fields simply zero or
/// by which protocol parameters the tunnel actually has configured: AWG 3.x tools emit an
/// extended row for an older configuration too, with the newer fields simply zero or
/// empty. Any rule of the form "N columns means version X" is wrong by construction.
///
/// The interface line is also *longer* than a peer line, and its handshake/rx/tx positions
@@ -274,15 +273,23 @@ mod tests {
use std::sync::Mutex;
/// Interface line printed by AmneziaWG 3.0 tools with header protection enabled.
const AWG3_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1-5\t2-6\t3-7\t4-8\t(null)\t(null)\t(null)\t(null)\t(null)\thProtectionKeyAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t10-40\t120-180\t5-10\t180-200\t25-30\t5\toff";
const AWG30_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1-5\t2-6\t3-7\t4-8\t(null)\t(null)\t(null)\t(null)\t(null)\thProtectionKeyAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t10-40\t120-180\t5-10\t180-200\t25-30\t5\toff";
/// Same tools, header protection disabled: the key column is `(none)` and the AWG 3.0
/// range fields collapse to single values.
const AWG3_INTERFACE_LINE_NO_HEADER_PROTECTION: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1\t2\t3\t4\t(null)\t(null)\t(null)\t(null)\t(null)\t(none)\t0\t120\t5\t180\t25\t5\toff";
const AWG30_INTERFACE_LINE_NO_HEADER_PROTECTION: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1\t2\t3\t4\t(null)\t(null)\t(null)\t(null)\t(null)\t(none)\t0\t120\t5\t180\t25\t5\toff";
/// An AWG 2.0 *configuration* read through AWG 3.0 tools: still the wide row, with the
/// newer fields zeroed. This is precisely why column count cannot identify a version.
const AWG2_CONFIG_VIA_AWG3_TOOLS_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1\t2\t3\t4\t(null)\t(null)\t(null)\t(null)\t(null)\t(none)\t0\t0\t0\t0\t0\t0\toff";
const AWG2_CONFIG_VIA_AWG30_TOOLS_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1\t2\t3\t4\t(null)\t(null)\t(null)\t(null)\t(null)\t(none)\t0\t0\t0\t0\t0\t0\toff";
/// Interface row emitted by amneziawg-tools v3.1.20260812. AWG 3.1 adds
/// RandomTrailers and DisableCookies immediately before fwmark, for 30 fields total.
const AWG31_INTERFACE_LINE: &str = "awg0\twPrivateKeyDevice31AAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDevice31AAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1-5\t2-6\t3-7\t4-8\t(null)\t(null)\t(null)\t(null)\t(null)\thProtectionKey31AAAAAAAAAAAAAAAAAAAAAAAAAA=\t10-40\t120-180\t5-10\t180-200\t25-30\t5\ton\ton\toff";
/// Same AWG 3.1 tools with optional protection/range values disabled and both new
/// boolean parameters off. The row width is still 30 fields.
const AWG31_INTERFACE_LINE_DISABLED: &str = "awg0\twPrivateKeyDevice31DisabledAAAAAAAAAAAAA=\twPublicKeyDevice31DisabledAAAAAAAAAAAAAA=\t0\t0\t0\t0\t0\t0\t0\t0\t1\t2\t3\t4\t(null)\t(null)\t(null)\t(null)\t(null)\t(none)\t0\t0\t0\t0\t0\t0\toff\toff\toff";
/// Interface line printed by AmneziaWG 2.0 tools.
const AWG2_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t1\t2\t3\t4\toff";
@@ -416,7 +423,7 @@ mod tests {
#[test]
fn parses_wg_show_dump_runtime_peers() {
let raw = format!(
"{AWG3_INTERFACE_LINE}\nawg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0\nawg0\tpubkey2\t(none)\t(none)\t10.8.1.27/32,192.168.1.5/32\t0\t0\t0\t0"
"{AWG31_INTERFACE_LINE}\nawg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0\nawg0\tpubkey2\t(none)\t(none)\t10.8.1.27/32,192.168.1.5/32\t0\t0\t0\t0"
);
let peers = parse_wg_show_dump_runtime_peers(&raw, "10.8.1.0/24").expect("parse");
assert_eq!(peers.len(), 2);
@@ -436,7 +443,7 @@ mod tests {
/// AmneziaWG 3.0 prints protocol timers as ranges; the peer keepalive column may be
/// `20-30` instead of a single integer. It is not read, but must not break parsing.
#[test]
fn accepts_awg3_range_persistent_keepalive() {
fn accepts_awg30_range_persistent_keepalive() {
let raw = "awg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t20-30";
let peers = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect("parse");
assert_eq!(peers.len(), 1);
@@ -445,17 +452,45 @@ mod tests {
}
#[test]
fn rejects_awg3_interface_line() {
let cols: Vec<&str> = AWG3_INTERFACE_LINE.split('\t').collect();
fn rejects_awg30_interface_line() {
let cols: Vec<&str> = AWG30_INTERFACE_LINE.split('\t').collect();
assert_eq!(cols.len(), 28);
assert!(!dump_line_is_peer(&cols));
assert!(
parse_wg_show_dump_runtime_peers(AWG3_INTERFACE_LINE, "0.0.0.0/0")
parse_wg_show_dump_runtime_peers(AWG30_INTERFACE_LINE, "0.0.0.0/0")
.expect("parse")
.is_empty()
);
}
#[test]
fn rejects_both_awg31_interface_variants() {
for (raw, random_trailers, disable_cookies) in [
(AWG31_INTERFACE_LINE, "on", "on"),
(AWG31_INTERFACE_LINE_DISABLED, "off", "off"),
] {
let cols: Vec<&str> = raw.split('\t').collect();
assert_eq!(cols.len(), 30);
assert_eq!(cols[27], random_trailers);
assert_eq!(cols[28], disable_cookies);
assert_eq!(cols[29], "off");
assert!(!dump_line_is_peer(&cols));
assert!(
parse_wg_show_dump_runtime_peers(raw, "0.0.0.0/0")
.expect("parse")
.is_empty()
);
}
}
#[test]
fn awg31_interface_and_peer_yield_exactly_one_peer() {
let raw = format!("{AWG31_INTERFACE_LINE}\n{PEER_LINE}");
let peers = parse_wg_show_dump_runtime_peers(&raw, "10.8.1.0/24").expect("parse");
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].public_key, "pubkey1");
}
#[test]
fn rejects_awg2_interface_line() {
let cols: Vec<&str> = AWG2_INTERFACE_LINE.split('\t').collect();
@@ -464,8 +499,8 @@ mod tests {
}
#[test]
fn rejects_awg3_interface_line_without_header_protection() {
let cols: Vec<&str> = AWG3_INTERFACE_LINE_NO_HEADER_PROTECTION
fn rejects_awg30_interface_line_without_header_protection() {
let cols: Vec<&str> = AWG30_INTERFACE_LINE_NO_HEADER_PROTECTION
.split('\t')
.collect();
assert!(!dump_line_is_peer(&cols));
@@ -475,14 +510,14 @@ mod tests {
/// read through AWG 3.0 tools still prints the wide interface row. Peers must parse
/// and the interface row must be dropped, exactly as with a native AWG 3.0 tunnel.
#[test]
fn awg2_configuration_read_through_awg3_tools() {
let raw = format!("{AWG2_CONFIG_VIA_AWG3_TOOLS_INTERFACE_LINE}\n{PEER_LINE}");
let cols: Vec<&str> = AWG2_CONFIG_VIA_AWG3_TOOLS_INTERFACE_LINE
fn awg2_configuration_read_through_awg30_tools() {
let raw = format!("{AWG2_CONFIG_VIA_AWG30_TOOLS_INTERFACE_LINE}\n{PEER_LINE}");
let cols: Vec<&str> = AWG2_CONFIG_VIA_AWG30_TOOLS_INTERFACE_LINE
.split('\t')
.collect();
assert_eq!(
cols.len(),
AWG3_INTERFACE_LINE.split('\t').count(),
AWG30_INTERFACE_LINE.split('\t').count(),
"an AWG 2.0 config through AWG 3.0 tools has the same width as AWG 3.0"
);
assert!(!dump_line_is_peer(&cols));
@@ -496,7 +531,7 @@ mod tests {
#[test]
fn parses_multiple_interfaces_in_one_dump() {
let raw = format!(
"{AWG3_INTERFACE_LINE}\n{PEER_LINE}\n{AWG2_INTERFACE_LINE}\nwg0\tpubkey2\t(none)\t5.6.7.8:51820\t10.8.1.20/32\t1715000001\t7\t8\t0"
"{AWG31_INTERFACE_LINE}\n{PEER_LINE}\n{AWG30_INTERFACE_LINE}\n{AWG2_INTERFACE_LINE}\n{WG_INTERFACE_LINE}\nwg0\tpubkey2\t(none)\t5.6.7.8:51820\t10.8.1.20/32\t1715000001\t7\t8\t0"
);
let peers = parse_wg_show_dump_runtime_peers(&raw, "10.8.1.0/24").expect("parse");
assert_eq!(peers.len(), 2);
@@ -506,9 +541,10 @@ mod tests {
#[test]
fn interface_without_peers_yields_no_peers() {
let peers =
parse_wg_show_dump_runtime_peers(AWG3_INTERFACE_LINE, "10.8.1.0/24").expect("parse");
assert!(peers.is_empty());
for raw in [AWG31_INTERFACE_LINE, AWG31_INTERFACE_LINE_DISABLED] {
let peers = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect("parse");
assert!(peers.is_empty());
}
}
/// Truncated or garbled rows must be skipped, not partially interpreted.
@@ -536,7 +572,7 @@ mod tests {
#[test]
fn never_exposes_interface_private_key_as_peer() {
let raw = format!(
"{AWG3_INTERFACE_LINE}\n{AWG2_INTERFACE_LINE}\n{WG_INTERFACE_LINE}\nawg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0"
"{AWG31_INTERFACE_LINE}\n{AWG31_INTERFACE_LINE_DISABLED}\n{AWG30_INTERFACE_LINE}\n{AWG2_INTERFACE_LINE}\n{WG_INTERFACE_LINE}\nawg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0"
);
let peers = parse_wg_show_dump_runtime_peers(&raw, "0.0.0.0/0").expect("parse");
assert_eq!(peers.len(), 1);