use std::{
io::Write,
os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
process::{Command, Stdio},
};
use anyhow::{Context, bail};
const TUN_IFACE: &str = "geph-tun";
const TUN_V4: &str = "100.64.0.1/10";
const TUN_V6: &str = "fd00:6765::1/64";
const TUN_MTU: &str = "16384";
const RT_TABLE: &str = "26469";
const PRIO_GEPH_DIRECT: &str = "90";
const PRIO_GEPH_GUARD: &str = "95";
const PRIO_TUN_ALL: &str = "120";
const TUNSETIFF: u64 = 0x4004_54ca;
const IFF_TUN: libc::c_short = 0x0001;
const IFF_NO_PI: libc::c_short = 0x1000;
pub(super) struct VpnHandle {
tun: OwnedFd,
}
impl VpnHandle {
pub(super) fn tun_fd(&self) -> RawFd {
self.tun.as_raw_fd()
}
}
pub(super) fn setup(geph_uid: u32) -> anyhow::Result<VpnHandle> {
firewall_preflight()?;
let rollback = scopeguard::guard((), |_| cleanup_stale());
let tun = create_tun().context("creating tun device")?;
run("ip", &["link", "set", TUN_IFACE, "up"])?;
run("ip", &["link", "set", TUN_IFACE, "mtu", TUN_MTU])?;
firewall_install(geph_uid).context("installing nft kill switch")?;
setup_rules("-4", geph_uid).context("installing v4 uid policy routing")?;
let _ = setup_rules("-6", geph_uid);
let handle = VpnHandle { tun };
scopeguard::ScopeGuard::into_inner(rollback);
Ok(handle)
}
pub(super) fn reconcile(_handle: &mut VpnHandle, geph_uid: u32) -> anyhow::Result<()> {
firewall_preflight()?;
firewall_install(geph_uid).context("reasserting nft kill switch")?;
run("ip", &["link", "set", TUN_IFACE, "up"])?;
run("ip", &["link", "set", TUN_IFACE, "mtu", TUN_MTU])?;
setup_rules("-4", geph_uid).context("reasserting v4 uid policy routing")?;
let _ = setup_rules("-6", geph_uid);
Ok(())
}
fn setup_rules(family: &str, geph_uid: u32) -> anyhow::Result<()> {
let uids = format!("{geph_uid}-{geph_uid}");
let addr = if family == "-6" { TUN_V6 } else { TUN_V4 };
run("ip", &[family, "addr", "replace", addr, "dev", TUN_IFACE])?;
run(
"ip",
&[
family, "route", "replace", "default", "dev", TUN_IFACE, "table", RT_TABLE,
],
)?;
replace_rule(
family,
PRIO_GEPH_DIRECT,
&["uidrange", &uids, "table", "main"],
)?;
replace_rule(
family,
PRIO_GEPH_GUARD,
&["uidrange", &uids, "type", "unreachable"],
)?;
replace_rule(family, PRIO_TUN_ALL, &["table", RT_TABLE])?;
Ok(())
}
fn replace_rule(family: &str, priority: &str, selector: &[&str]) -> anyhow::Result<()> {
while run("ip", &[family, "rule", "del", "priority", priority]).is_ok() {}
let mut args = vec![family, "rule", "add"];
args.extend_from_slice(selector);
args.extend_from_slice(&["priority", priority]);
run("ip", &args)
}
pub(super) fn cleanup_stale() {
firewall_remove();
for family in ["-4", "-6"] {
for prio in [PRIO_GEPH_DIRECT, PRIO_GEPH_GUARD, PRIO_TUN_ALL] {
let _ = run("ip", &[family, "rule", "del", "priority", prio]);
}
let _ = run("ip", &[family, "route", "flush", "table", RT_TABLE]);
}
let _ = run("ip", &["link", "del", TUN_IFACE]);
}
pub(super) fn cleanup(handle: VpnHandle) {
drop(handle);
cleanup_stale();
}
#[derive(Clone)]
pub(super) struct NetworkSnapshot;
pub(super) fn network_snapshot(_handle: &VpnHandle) -> NetworkSnapshot {
NetworkSnapshot
}
pub(super) fn network_check(_snapshot: &NetworkSnapshot) -> super::NetworkAction {
super::NetworkAction::Healthy
}
fn create_tun() -> anyhow::Result<OwnedFd> {
let fd = unsafe { libc::open(c"/dev/net/tun".as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
if fd < 0 {
return Err(std::io::Error::last_os_error()).context("open /dev/net/tun");
}
let owned = unsafe { OwnedFd::from_raw_fd(fd) };
#[repr(C)]
struct IfReq {
name: [libc::c_char; 16],
flags: libc::c_short,
_pad: [u8; 22],
}
let mut req = IfReq {
name: [0; 16],
flags: IFF_TUN | IFF_NO_PI,
_pad: [0; 22],
};
for (i, b) in TUN_IFACE.bytes().enumerate() {
req.name[i] = b as libc::c_char;
}
let rc = unsafe { libc::ioctl(owned.as_raw_fd(), TUNSETIFF as _, &mut req as *mut IfReq) };
if rc < 0 {
return Err(std::io::Error::last_os_error()).context("ioctl(TUNSETIFF)");
}
Ok(owned)
}
fn firewall_preflight() -> anyhow::Result<()> {
let ok = Command::new("nft")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
bail!("`nft` (nftables) not found — install nftables for VPN mode");
}
Ok(())
}
fn firewall_install(geph_uid: u32) -> anyhow::Result<()> {
let ruleset = format!(
"table inet geph
delete table inet geph
table inet geph {{
\tchain killswitch {{
\t\ttype filter hook postrouting priority filter; policy accept;
\t\toifname \"lo\" accept
\t\toifname \"{iface}\" accept
\t\tmeta skuid {uid} accept
\t\tcounter drop
\t}}
}}
",
uid = geph_uid,
iface = TUN_IFACE,
);
nft_apply(&ruleset)
}
fn firewall_remove() {
let _ = nft_apply("table inet geph\ndelete table inet geph\n");
}
fn nft_apply(ruleset: &str) -> anyhow::Result<()> {
let mut child = Command::new("nft")
.arg("-f")
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("spawning nft")?;
child
.stdin
.take()
.context("nft stdin")?
.write_all(ruleset.as_bytes())?;
let out = child.wait_with_output()?;
if !out.status.success() {
bail!(
"nft failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
fn run(cmd: &str, args: &[&str]) -> anyhow::Result<()> {
let out = Command::new(cmd)
.args(args)
.output()
.with_context(|| format!("spawning {cmd}"))?;
if !out.status.success() {
bail!(
"`{cmd} {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}