Peers communicating on uncensored networks can benefit from raw WireGuard
performance over direct UDP without QUIC framing or double-encryption
overhead. This provides Tailscale-like direct tunnel performance while
preserving Tsunagi's decentralized mesh architecture.
Key changes:
- Create `tsunagi-wireguard` crate implementing the `IpPlugin` and
`PacketTransport` contracts:
- `WG_PROTOCOL = "wg"` with wire version 1.
- Bounded `WgAnnouncement` carrying WireGuard public keys, listening UDP
ports, and candidate IP addresses (filtering out overlay interfaces).
- `WgKeyStore` managing persistent per-network WireGuard X25519 keys
in SQLite with zeroization on drop.
- `WireguardTransport` providing direct UDP packet transmission, candidate
address tracking, inbound link delivery, and roaming support.
- `WireguardDevice` running userspace WireGuard encryption via `boringtun`,
timer maintenance, and AllowedIPs source enforcement.
- Integrate `wg` into `tsunagi-cli`:
- Set default protocols to `"wg,tcp-tls,wg-quic"` so all protocols are
enabled by default in order of throughput and performance.
- Support `"wireguard"` as a convenience alias for `"wg"` in `--protocol`.
- Bind `PureWgTransport` and launch the inbound datagram accept loop on `up`.
- Pass `pure_wg` plugin to `AgentControl` and update `build_report` so
active WireGuard tunnels and overlay metrics are reported in `tsunagi status`.
- Clean up WireGuard keys on network removal in `forget_protocol_state`.
- Comprehensive test coverage in `tsunagi-wireguard`:
- `announcement_tests`: round-trip serialization and verification of malformed,
mismatched network, zero port, or self-claiming announcements.
- `transport_tests`: loopback UDP link delivery between transports.
- `plugin_tests`: end-to-end two-peer handshake and bidirectional IP packet
transit over direct UDP.
Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed.
161 lines
5.8 KiB
Rust
161 lines
5.8 KiB
Rust
//! The plugin's own key store.
|
|
//!
|
|
//! Deliberately a separate SQLite file from the agent's `state.sqlite`: plugin
|
|
//! keys are not the iroh identity and not the network secret, and their
|
|
//! lifecycle is the plugin's business alone.
|
|
//!
|
|
//! One key per network, so a participant presents a different WireGuard
|
|
//! identity in each network it belongs to.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
|
|
use rusqlite::{Connection, OptionalExtension, params};
|
|
|
|
use tsunagi::dataplane::PluginError;
|
|
use tsunagi::identity::NetworkId;
|
|
|
|
use crate::keys::{KEY_LEN, WgSecretKey};
|
|
|
|
/// Schema version written by this build.
|
|
pub const SCHEMA_VERSION: i64 = 1;
|
|
|
|
/// Per-network WireGuard private keys.
|
|
#[derive(Debug)]
|
|
pub struct WgKeyStore {
|
|
conn: Mutex<Connection>,
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl WgKeyStore {
|
|
/// Opens, creating the file and its directory if needed.
|
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
|
|
let path = path.as_ref().to_path_buf();
|
|
if let Some(parent) = path.parent() {
|
|
tsunagi::storage::create_dir(parent)
|
|
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
|
|
}
|
|
let existed = path.exists();
|
|
let conn = Connection::open(&path).map_err(|err| {
|
|
PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
|
|
})?;
|
|
tsunagi::storage::restrict_path_permissions(&path)
|
|
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
|
|
|
|
conn.busy_timeout(std::time::Duration::from_secs(5))
|
|
.and_then(|()| conn.pragma_update(None, "journal_mode", "WAL"))
|
|
.and_then(|()| conn.pragma_update(None, "synchronous", "NORMAL"))
|
|
.map_err(|err| PluginError::Other(format!("cannot configure the key store: {err}")))?;
|
|
|
|
if existed {
|
|
let integrity: String = conn
|
|
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
|
.map_err(|err| {
|
|
PluginError::Other(format!("WireGuard key store is unusable: {err}"))
|
|
})?;
|
|
if integrity != "ok" {
|
|
return Err(PluginError::Other(format!(
|
|
"WireGuard key store at {} is corrupt and will not be recreated: {integrity}",
|
|
path.display()
|
|
)));
|
|
}
|
|
}
|
|
|
|
let found: i64 = conn
|
|
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
|
.map_err(|err| PluginError::Other(format!("cannot read the schema version: {err}")))?;
|
|
if found > SCHEMA_VERSION {
|
|
return Err(PluginError::Other(format!(
|
|
"WireGuard key store schema {found} is newer than {SCHEMA_VERSION}"
|
|
)));
|
|
}
|
|
if found < SCHEMA_VERSION {
|
|
conn.execute_batch(
|
|
"BEGIN;
|
|
CREATE TABLE IF NOT EXISTS network_keys (
|
|
network_id BLOB PRIMARY KEY,
|
|
secret BLOB NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
PRAGMA user_version = 1;
|
|
COMMIT;",
|
|
)
|
|
.map_err(|err| PluginError::Other(format!("cannot create the schema: {err}")))?;
|
|
}
|
|
|
|
Ok(Self {
|
|
conn: Mutex::new(conn),
|
|
path,
|
|
})
|
|
}
|
|
|
|
/// Path of the underlying file.
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
/// Loads the stored key for `network`, or generates, stores and returns a fresh one.
|
|
pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> {
|
|
let guard = self
|
|
.conn
|
|
.lock()
|
|
.map_err(|_| PluginError::Other("key store lock is poisoned".into()))?;
|
|
|
|
let existing: Option<Vec<u8>> = guard
|
|
.query_row(
|
|
"SELECT secret FROM network_keys WHERE network_id = ?1",
|
|
params![network.as_bytes().as_slice()],
|
|
|row| row.get(0),
|
|
)
|
|
.optional()
|
|
.map_err(|err| PluginError::Other(format!("cannot read the key store: {err}")))?;
|
|
|
|
if let Some(secret) = existing {
|
|
let bytes = <[u8; KEY_LEN]>::try_from(secret.as_slice()).map_err(|_| {
|
|
PluginError::Other(format!(
|
|
"stored WireGuard key for {} is {} bytes, expected {KEY_LEN}",
|
|
network.fmt_short(),
|
|
secret.len()
|
|
))
|
|
})?;
|
|
return Ok(WgSecretKey::from_bytes(&bytes));
|
|
}
|
|
|
|
let fresh = WgSecretKey::generate();
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_err(|err| PluginError::Other(format!("system clock is broken: {err}")))?
|
|
.as_secs() as i64;
|
|
|
|
guard
|
|
.execute(
|
|
"INSERT INTO network_keys (network_id, secret, created_at) VALUES (?1, ?2, ?3)",
|
|
params![
|
|
network.as_bytes().as_slice(),
|
|
fresh.as_bytes().as_slice(),
|
|
now
|
|
],
|
|
)
|
|
.map_err(|err| PluginError::Other(format!("cannot write the key store: {err}")))?;
|
|
|
|
Ok(fresh)
|
|
}
|
|
|
|
/// Removes the stored key for `network`.
|
|
pub fn forget(&self, network: NetworkId) -> Result<(), PluginError> {
|
|
let guard = self
|
|
.conn
|
|
.lock()
|
|
.map_err(|_| PluginError::Other("key store lock is poisoned".into()))?;
|
|
guard
|
|
.execute(
|
|
"DELETE FROM network_keys WHERE network_id = ?1",
|
|
params![network.as_bytes().as_slice()],
|
|
)
|
|
.map_err(|err| {
|
|
PluginError::Other(format!("cannot delete from the key store: {err}"))
|
|
})?;
|
|
Ok(())
|
|
}
|
|
}
|