use super::firewall::Firewall;
use super::netns::NetworkNamespace;
use anyhow::{Context, anyhow};
use log::{debug, error, info};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::net::{IpAddr, Ipv4Addr};
use std::os::fd::AsFd;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{Duration, Instant};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(120);
const MAX_STARTUP_OUTPUT: usize = 1024 * 1024;
#[derive(Serialize, Deserialize, Debug)]
pub struct OpenFortiVpn {
pub(crate) pid: u32,
#[serde(skip)]
_runtime_dir: Option<tempfile::TempDir>,
#[serde(skip)]
cleanup_enabled: bool,
}
fn validate_unprivileged_config(config_file: &Path) -> anyhow::Result<()> {
const EXECUTION_DIRECTIVES: &[&str] = &["pinentry", "pppd-plugin", "pppd-call", "ppp-system"];
let contents = std::fs::read_to_string(config_file)
.with_context(|| format!("Failed to read {}", config_file.display()))?;
for (line_number, line) in contents.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let key = line.split_once('=').map_or(line, |(key, _)| key).trim();
anyhow::ensure!(
!EXECUTION_DIRECTIVES.contains(&key),
"OpenFortiVPN directive '{key}' is not allowed in daemon mode (line {})",
line_number + 1
);
}
Ok(())
}
pub fn server_from_config(config_file: &Path) -> anyhow::Result<String> {
let contents = std::fs::read_to_string(config_file)
.with_context(|| format!("Failed to read {}", config_file.display()))?;
contents
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.find_map(|line| {
let (key, value) = line.split_once('=')?;
(key.trim() == "host")
.then(|| value.trim())
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.ok_or_else(|| anyhow!("OpenFortiVPN config has no host option"))
}
pub fn apply_killswitch(
netns: &NetworkNamespace,
firewall: Firewall,
disable_ipv6: bool,
config_file: &Path,
) -> anyhow::Result<()> {
let server = server_from_config(config_file)?;
super::firewall::apply_tunnel_only_killswitch_for_server(
netns,
firewall,
disable_ipv6,
&server,
&["ppp"],
)
}
impl OpenFortiVpn {
#[allow(clippy::too_many_arguments)]
pub fn run(
netns: &mut NetworkNamespace,
config_file: PathBuf,
open_ports: Option<&Vec<u16>>,
forward_ports: Option<&Vec<u16>>,
hosts_entries: Option<&Vec<String>>,
firewall: Firewall,
allow_host_access: bool,
) -> anyhow::Result<Self> {
if let Err(x) = which::which("openfortivpn") {
error!("OpenFortiVPN not found. Is OpenFortiVPN installed and on PATH?");
return Err(anyhow!(
"OpenFortiVPN not found. Is OpenFortiVPN installed and on PATH?: {:?}",
x
));
}
if crate::util::is_daemon_mode() {
validate_unprivileged_config(&config_file)?;
}
info!("Launching OpenFortiVPN...");
let runtime_dir = super::etc_overlay::trusted_runtime_dir("openfortivpn-")?;
let pppd_log = runtime_dir.path().join("pppd.log");
let command_vec = ([
"openfortivpn",
"-c",
config_file.to_str().expect("Invalid config path"),
"--no-dns",
"--pppd-log",
pppd_log.to_str().context("Invalid PPP log path")?,
])
.to_vec();
let mut handle = NetworkNamespace::exec_no_block(
&netns.name,
&command_vec,
None,
None,
false,
true,
false,
None,
)
.context("Failed to launch OpenFortiVPN - is openfortivpn installed?")?;
let mut stdout = handle
.stdout
.take()
.context("OpenFortiVPN stdout was not piped")?;
let id = handle.id();
info!(
"Waiting for OpenFortiVPN to establish connection - you may be prompted on your 2FA device"
);
info!("If your VPN password is not in the OpenFortiVPN config file then enter it here now");
let setup_result = (|| -> anyhow::Result<()> {
use nix::fcntl::{FcntlArg, OFlag, fcntl};
let flags = fcntl(stdout.as_fd(), FcntlArg::F_GETFL)?;
fcntl(
stdout.as_fd(),
FcntlArg::F_SETFL(OFlag::from_bits_truncate(flags) | OFlag::O_NONBLOCK),
)?;
let deadline = Instant::now() + STARTUP_TIMEOUT;
let mut bytes = Vec::with_capacity(16_000);
let mut chunk = [0_u8; 4096];
loop {
match stdout.read(&mut chunk) {
Ok(0) => {
let status = handle.try_wait()?;
anyhow::bail!(
"OpenFortiVPN closed its output before the tunnel became ready{}",
status.map_or_else(String::new, |value| format!(" ({value})"))
);
}
Ok(count) => {
anyhow::ensure!(
bytes.len() + count <= MAX_STARTUP_OUTPUT,
"OpenFortiVPN startup output exceeded {} bytes",
MAX_STARTUP_OUTPUT
);
std::io::stdout().write_all(&chunk[..count])?;
std::io::stdout().flush()?;
bytes.extend_from_slice(&chunk[..count]);
if bytes
.windows(b"Tunnel is up and running".len())
.any(|window| window == b"Tunnel is up and running")
{
break;
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
Err(error) => return Err(error.into()),
}
if let Some(status) = handle.try_wait()? {
anyhow::bail!("OpenFortiVPN exited before the tunnel became ready: {status}");
}
anyhow::ensure!(
Instant::now() < deadline,
"Timed out after {} seconds waiting for OpenFortiVPN",
STARTUP_TIMEOUT.as_secs()
);
std::thread::sleep(Duration::from_millis(100));
}
let buffer = String::from_utf8(bytes).context("OpenFortiVPN emitted invalid UTF-8")?;
debug!("Full OpenFortiVPN stdout: {:?}", buffer);
let remote_peer = get_remote_peer(&pppd_log)?;
debug!("Found OpenFortiVPN route: {remote_peer:?}");
NetworkNamespace::exec(&netns.name, &["ip", "route", "del", "default"])?;
NetworkNamespace::exec(
&netns.name,
&[
"ip",
"route",
"add",
"default",
"via",
&remote_peer.to_string(),
],
)?;
let dns = get_dns(&buffer)?;
let suffixes: Vec<&str> = dns.1.iter().map(|x| x.as_str()).collect();
netns.dns_config(&dns.0, &suffixes, hosts_entries, allow_host_access)?;
if let Some(opens) = open_ports {
crate::util::open_ports(netns, opens.as_slice(), firewall)?;
}
if let Some(forwards) = forward_ports {
crate::util::open_ports(netns, forwards.as_slice(), firewall)?;
}
Ok(())
})();
if let Err(error) = setup_result {
let _ = handle.kill();
let _ = handle.wait();
return Err(error);
}
Ok(Self {
pid: id,
_runtime_dir: Some(runtime_dir),
cleanup_enabled: true,
})
}
}
impl Drop for OpenFortiVpn {
fn drop(&mut self) {
if !self.cleanup_enabled {
return;
}
match nix::sys::signal::kill(
nix::unistd::Pid::from_raw(self.pid as i32),
nix::sys::signal::Signal::SIGKILL,
) {
Ok(_) => debug!("Killed OpenFortiVPN (pid: {})", self.pid),
Err(e) => error!("Failed to kill OpenFortiVPN (pid: {}): {:?}", self.pid, e),
}
}
}
impl OpenFortiVpn {
pub(crate) fn set_cleanup_enabled(&mut self, enabled: bool) {
self.cleanup_enabled = enabled;
}
}
pub fn get_remote_peer(pppd_log: &Path) -> anyhow::Result<Ipv4Addr> {
let stdout = std::fs::read_to_string(pppd_log)
.context(format!("Opening pppd log file: {pppd_log:?}"))?;
let re = Regex::new(r"remote IP address (?P<ip>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})").unwrap();
let mut ips = Vec::new();
for caps in re.captures_iter(&stdout) {
ips.push(Ipv4Addr::from_str(&caps["ip"]).expect("Failed to parse IP address in stdout"));
}
ips.pop()
.ok_or_else(|| anyhow!("Could not find remote IP address in pppd log"))
}
pub fn get_dns(stdout: &str) -> anyhow::Result<(Vec<IpAddr>, Vec<String>)> {
let re = Regex::new(r"ns \[(?P<ip>[^\]]+)\]").unwrap();
let mut ips = Vec::new();
for caps in re.captures_iter(stdout) {
for ip_raw in caps["ip"].split(", ") {
let ip = IpAddr::from_str(ip_raw)?;
if !ips.contains(&ip) && ip != IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)) {
ips.push(ip);
}
}
}
let re = Regex::new(r"ns_suffix \[(?P<suffix>[^\]]+)\]").unwrap();
let mut suffixes = Vec::new();
for caps in re.captures_iter(stdout) {
for suffix_raw in caps["suffix"].split(';') {
let suffix = suffix_raw.to_string();
if !suffixes.contains(&suffix) {
suffixes.push(suffix);
}
}
}
debug!(
"Found OpenFortiVPN DNS ips: {:?}, ns suffixes: {:?}",
ips, suffixes
);
Ok((ips, suffixes))
}
#[cfg(test)]
mod tests {
use super::{server_from_config, validate_unprivileged_config};
use std::io::Write;
#[test]
fn reads_host_from_config() {
let mut config = tempfile::NamedTempFile::new().unwrap();
writeln!(config, "# comment\nhost = vpn.company.example\nport = 443").unwrap();
assert_eq!(
server_from_config(config.path()).unwrap(),
"vpn.company.example"
);
}
#[test]
fn daemon_config_rejects_execution_directives() {
for directive in ["pinentry", "pppd-plugin", "pppd-call", "ppp-system"] {
let mut config = tempfile::NamedTempFile::new().unwrap();
writeln!(config, "host = vpn.example.com\n{directive} = /tmp/evil").unwrap();
let error = validate_unprivileged_config(config.path())
.unwrap_err()
.to_string();
assert!(error.contains(directive));
}
}
#[test]
fn daemon_config_accepts_non_execution_directives() {
let mut config = tempfile::NamedTempFile::new().unwrap();
writeln!(
config,
"host = vpn.example.com\nusername = alice\n# pinentry = ignored"
)
.unwrap();
validate_unprivileged_config(config.path()).unwrap();
}
}