innernet_shared/
wg.rs

1use crate::{Error, IoErrorContext, NetworkOpts, Peer, PeerDiff};
2use ipnet::IpNet;
3use std::{
4    io,
5    net::{IpAddr, SocketAddr},
6    time::Duration,
7};
8use wireguard_control::{
9    Backend, Device, DeviceUpdate, InterfaceName, Key, PeerConfigBuilder, PeerInfo,
10};
11
12#[cfg(any(target_os = "macos", target_os = "openbsd"))]
13fn cmd(bin: &str, args: &[&str]) -> Result<std::process::Output, io::Error> {
14    let output = std::process::Command::new(bin).args(args).output()?;
15    log::debug!("cmd: {} {}", bin, args.join(" "));
16    log::debug!("status: {:?}", output.status.code());
17    log::trace!("stdout: {}", String::from_utf8_lossy(&output.stdout));
18    log::trace!("stderr: {}", String::from_utf8_lossy(&output.stderr));
19    if output.status.success() {
20        Ok(output)
21    } else {
22        Err(io::Error::other(format!(
23            "failed to run {} {} command: {}",
24            bin,
25            args.join(" "),
26            String::from_utf8_lossy(&output.stderr)
27        )))
28    }
29}
30
31#[cfg(target_os = "macos")]
32pub fn set_addr(interface: &InterfaceName, addr: IpNet) -> Result<(), io::Error> {
33    let real_interface = wireguard_control::backends::userspace::resolve_tun(interface)?;
34
35    if matches!(addr, IpNet::V4(_)) {
36        cmd(
37            "ifconfig",
38            &[
39                &real_interface,
40                "inet",
41                &addr.to_string(),
42                &addr.addr().to_string(),
43                "alias",
44            ],
45        )
46        .map(|_output| ())
47    } else {
48        cmd(
49            "ifconfig",
50            &[&real_interface, "inet6", &addr.to_string(), "alias"],
51        )
52        .map(|_output| ())
53    }
54}
55
56#[cfg(target_os = "macos")]
57pub fn set_up(interface: &InterfaceName, mtu: u32) -> Result<(), io::Error> {
58    let real_interface = wireguard_control::backends::userspace::resolve_tun(interface)?;
59    cmd("ifconfig", &[&real_interface, "mtu", &mtu.to_string()])?;
60    Ok(())
61}
62
63#[cfg(target_os = "openbsd")]
64pub fn set_addr(interface: &InterfaceName, addr: IpNet) -> Result<(), io::Error> {
65    let af = match &addr {
66        IpNet::V4(_) => "inet",
67        IpNet::V6(_) => "inet6",
68    };
69    cmd("ifconfig", &[&interface.to_string(), af, &addr.to_string()]).map(|_output| ())
70}
71
72#[cfg(target_os = "openbsd")]
73pub fn set_up(interface: &InterfaceName, mtu: u32) -> Result<(), io::Error> {
74    cmd(
75        "ifconfig",
76        &[&interface.to_string(), "mtu", &mtu.to_string()],
77    )?;
78    Ok(())
79}
80
81#[cfg(target_os = "linux")]
82pub use super::netlink::set_addr;
83
84#[cfg(target_os = "linux")]
85pub use super::netlink::set_up;
86
87pub fn up(
88    interface: &InterfaceName,
89    private_key: &str,
90    address: IpNet,
91    listen_port: Option<u16>,
92    peer: Option<(&str, IpAddr, SocketAddr)>,
93    network: NetworkOpts,
94) -> Result<(), io::Error> {
95    let mut device = DeviceUpdate::new();
96    if let Some((public_key, address, endpoint)) = peer {
97        let prefix = if address.is_ipv4() { 32 } else { 128 };
98        let peer_config = PeerConfigBuilder::new(
99            &wireguard_control::Key::from_base64(public_key).map_err(|_| {
100                io::Error::new(
101                    io::ErrorKind::InvalidInput,
102                    "failed to parse base64 public key",
103                )
104            })?,
105        )
106        .add_allowed_ip(address, prefix)
107        .set_persistent_keepalive_interval(25)
108        .set_endpoint(endpoint);
109        device = device.add_peer(peer_config);
110    }
111    if let Some(listen_port) = listen_port {
112        device = device.set_listen_port(listen_port);
113    }
114    device
115        .set_private_key(wireguard_control::Key::from_base64(private_key).unwrap())
116        .apply(interface, network.backend)?;
117    set_addr(interface, address)?;
118    set_up(interface, network.mtu.unwrap_or(1280))?;
119    if !network.no_routing {
120        // On OpenBSD, `ifconfig` handles this for us
121        #[cfg(not(target_os = "openbsd"))]
122        add_route(interface, address)?;
123    }
124    Ok(())
125}
126
127pub fn set_listen_port(
128    interface: &InterfaceName,
129    listen_port: Option<u16>,
130    backend: Backend,
131) -> Result<(), Error> {
132    let mut device = DeviceUpdate::new();
133    if let Some(listen_port) = listen_port {
134        device = device.set_listen_port(listen_port);
135    } else {
136        device = device.randomize_listen_port();
137    }
138    device.apply(interface, backend)?;
139
140    Ok(())
141}
142
143pub fn down(interface: &InterfaceName, backend: Backend) -> Result<(), Error> {
144    Ok(Device::get(interface, backend)
145        .with_str(interface.as_str_lossy())?
146        .delete()
147        .with_str(interface.as_str_lossy())?)
148}
149
150/// Add a route in the OS's routing table to get traffic flowing through this interface.
151/// Returns an error if the process doesn't exit successfully, otherwise returns
152/// true if the route was changed, false if the route already exists.
153#[cfg(target_os = "macos")]
154pub fn add_route(interface: &InterfaceName, cidr: IpNet) -> Result<bool, io::Error> {
155    let real_interface = wireguard_control::backends::userspace::resolve_tun(interface)?;
156    let output = cmd(
157        "route",
158        &[
159            "-n",
160            "add",
161            if matches!(cidr, IpNet::V4(_)) {
162                "-inet"
163            } else {
164                "-inet6"
165            },
166            &cidr.to_string(),
167            "-interface",
168            &real_interface,
169        ],
170    )?;
171    let stderr = String::from_utf8_lossy(&output.stderr);
172    if !output.status.success() {
173        Err(io::Error::other(format!(
174            "failed to add route for device {} ({}): {}",
175            &interface, real_interface, stderr
176        )))
177    } else {
178        Ok(!stderr.contains("File exists"))
179    }
180}
181
182#[cfg(target_os = "linux")]
183pub use super::netlink::add_route;
184
185pub trait DeviceExt {
186    /// Diff the output of a wgctrl device with a list of server-reported peers.
187    fn diff<'a>(&'a self, peers: &'a [Peer]) -> Vec<PeerDiff<'a>>;
188
189    // /// Get a peer by their public key, a helper function.
190    fn get_peer(&self, public_key: &str) -> Option<&PeerInfo>;
191}
192
193impl DeviceExt for Device {
194    fn diff<'a>(&'a self, peers: &'a [Peer]) -> Vec<PeerDiff<'a>> {
195        let interface_public_key = self
196            .public_key
197            .as_ref()
198            .map(|k| k.to_base64())
199            .unwrap_or_default();
200        let existing_peers = &self.peers;
201
202        // Match existing peers (by pubkey) to new peer information from the server.
203        let modifications = peers.iter().filter_map(|peer| {
204            if peer.is_disabled || peer.public_key == interface_public_key {
205                None
206            } else {
207                let existing_peer = existing_peers
208                    .iter()
209                    .find(|p| p.config.public_key.to_base64() == peer.public_key);
210                PeerDiff::new(existing_peer, Some(peer)).unwrap()
211            }
212        });
213
214        // Remove any peers on the interface that aren't in the server's peer list any more.
215        let removals = existing_peers.iter().filter_map(|existing| {
216            let public_key = existing.config.public_key.to_base64();
217            if peers.iter().any(|p| p.public_key == public_key) {
218                None
219            } else {
220                PeerDiff::new(Some(existing), None).unwrap()
221            }
222        });
223
224        modifications.chain(removals).collect::<Vec<_>>()
225    }
226
227    fn get_peer(&self, public_key: &str) -> Option<&PeerInfo> {
228        Key::from_base64(public_key)
229            .ok()
230            .and_then(|key| self.peers.iter().find(|peer| peer.config.public_key == key))
231    }
232}
233
234pub trait PeerInfoExt {
235    /// WireGuard rejects any communication after REJECT_AFTER_TIME, so we can use this
236    /// as a heuristic for "currentness" without relying on heavier things like ICMP.
237    fn is_recently_connected(&self) -> bool;
238}
239impl PeerInfoExt for PeerInfo {
240    fn is_recently_connected(&self) -> bool {
241        const REJECT_AFTER_TIME: Duration = Duration::from_secs(180);
242
243        let last_handshake = self
244            .stats
245            .last_handshake_time
246            .and_then(|t| t.elapsed().ok())
247            .unwrap_or(Duration::MAX);
248
249        last_handshake <= REJECT_AFTER_TIME
250    }
251}