use anyhow::{Context, Result, anyhow};
use fips_core::config::{
IdentityConfig, NostrDiscoveryPolicy, RoutingMode, TcpConfig, TransportInstances, UdpConfig,
};
use fips_core::host_firewall::{HostFirewallConfig, HostFirewallGuard};
use fips_core::upper::tun::TunState;
use fips_core::{Config, Identity, Node};
use nostr_vpn_core::config::AppConfig;
use std::fs;
use std::net::Ipv6Addr;
use std::path::Path;
#[cfg(target_os = "linux")]
use std::process::Command as ProcessCommand;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
const FIPS_HOST_IFACE: &str = "nvpnfips0";
const FIPS_HOST_MTU: u16 = 1280;
const FIPS_HOST_DNS_BIND_ADDR: &str = "::1";
const FIPS_HOST_DNS_PORT: u16 = 5354;
const HOST_FIREWALL_LINUX_TABLE_NAME: &str = "nvpn_fips_host";
const HOST_FIREWALL_MACOS_ANCHOR_NAME: &str = "com.apple/to.nostrvpn/fips-host";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FipsHostTunnelConfig {
pub(crate) identity_nsec: String,
pub(crate) fips_address: Ipv6Addr,
pub(crate) dns_bind_addr: String,
pub(crate) dns_port: u16,
pub(crate) nostr_relays: Vec<String>,
pub(crate) stun_servers: Vec<String>,
pub(crate) share_local_candidates: bool,
pub(crate) inbound_tcp_ports: Vec<u16>,
}
impl FipsHostTunnelConfig {
pub(crate) fn from_app(app: &AppConfig) -> Result<Option<Self>> {
if !app.fips_host_tunnel_enabled {
return Ok(None);
}
if !HostFirewallGuard::platform_available() {
eprintln!("fips-host: disabled because no FIPS host firewall is available");
return Ok(None);
}
let identity = Identity::from_secret_str(&app.nostr.secret_key)
.context("failed to derive .fips identity from nostr secret key")?;
let mut inbound_tcp_ports = app.fips_host_inbound_tcp_ports.clone();
inbound_tcp_ports.sort_unstable();
inbound_tcp_ports.dedup();
Ok(Some(Self {
identity_nsec: app.nostr.secret_key.clone(),
fips_address: identity.address().to_ipv6(),
dns_bind_addr: FIPS_HOST_DNS_BIND_ADDR.to_string(),
dns_port: FIPS_HOST_DNS_PORT,
nostr_relays: app.nostr.relays.clone(),
stun_servers: app.nat.stun_servers.clone(),
share_local_candidates: app.lan_discovery_enabled,
inbound_tcp_ports,
}))
}
fn fips_config(&self) -> Config {
let mut config = Config::new();
config.node.identity = IdentityConfig {
nsec: Some(self.identity_nsec.clone()),
persistent: false,
};
config.node.system_files_enabled = false;
config.node.control.enabled = false;
config.node.routing.mode = RoutingMode::ReplyLearned;
config.tun.enabled = true;
config.tun.name = Some(FIPS_HOST_IFACE.to_string());
config.tun.mtu = Some(FIPS_HOST_MTU);
config.dns.enabled = true;
config.dns.bind_addr = Some(self.dns_bind_addr.clone());
config.dns.port = Some(self.dns_port);
config.node.discovery.nostr.enabled = !self.nostr_relays.is_empty();
config.node.discovery.nostr.advertise = false;
config.node.discovery.nostr.advert_relays = self.nostr_relays.clone();
config.node.discovery.nostr.dm_relays = self.nostr_relays.clone();
config.node.discovery.nostr.stun_servers = self.stun_servers.clone();
config.node.discovery.nostr.share_local_candidates = self.share_local_candidates;
config.node.discovery.nostr.policy = NostrDiscoveryPolicy::Open;
config.node.discovery.lan.enabled = self.share_local_candidates;
config.transports.udp = TransportInstances::Single(UdpConfig {
outbound_only: Some(true),
accept_connections: Some(false),
advertise_on_nostr: Some(false),
..UdpConfig::default()
});
config.transports.tcp = TransportInstances::Single(TcpConfig {
bind_addr: None,
advertise_on_nostr: Some(false),
..TcpConfig::default()
});
config
}
}
pub(crate) struct FipsHostTunnelRuntime {
config: FipsHostTunnelConfig,
shutdown_tx: Option<oneshot::Sender<()>>,
node_task: JoinHandle<Result<()>>,
resolver: Option<SystemResolverGuard>,
firewall: Option<HostFirewallGuard>,
}
impl FipsHostTunnelRuntime {
pub(crate) async fn start(config: FipsHostTunnelConfig) -> Result<Self> {
let mut node = Node::new(config.fips_config()).context("failed to create .fips node")?;
node.start().await.context("failed to start .fips node")?;
if node.tun_state() != TunState::Active {
let tun_state = node.tun_state();
let _ = node.stop().await;
return Err(anyhow!(".fips TUN did not become active: {tun_state}"));
}
let iface = node.tun_name().unwrap_or(FIPS_HOST_IFACE).to_string();
let firewall_config = host_firewall_config(&iface, &config.inbound_tcp_ports);
let firewall = match HostFirewallGuard::install(&firewall_config) {
Ok(guard) => Some(guard),
Err(error) => {
let _ = node.stop().await;
return Err(error).context("failed to install .fips host firewall");
}
};
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let node_task = spawn_fips_node_task(node, shutdown_rx);
let resolver = match SystemResolverGuard::install(&config) {
Ok(guard) => guard,
Err(error) => {
eprintln!("fips-host: failed to install .fips resolver: {error}");
None
}
};
Ok(Self {
config,
shutdown_tx: Some(shutdown_tx),
node_task,
resolver,
firewall,
})
}
pub(crate) fn requires_restart(&self, config: &FipsHostTunnelConfig) -> bool {
self.config != *config
}
pub(crate) async fn stop(mut self) -> Result<()> {
drop(self.firewall.take());
drop(self.resolver.take());
if let Some(shutdown_tx) = self.shutdown_tx.take() {
let _ = shutdown_tx.send(());
}
self.node_task
.await
.context(".fips node task join failed")?
.context(".fips node task failed")
}
pub(crate) fn cleanup_disabled_artifacts() {
SystemResolverGuard::cleanup_disabled_artifacts();
HostFirewallGuard::cleanup_disabled_artifacts(&host_firewall_config(FIPS_HOST_IFACE, &[]));
}
}
fn host_firewall_config(iface: &str, inbound_tcp_ports: &[u16]) -> HostFirewallConfig {
HostFirewallConfig::new(iface)
.with_inbound_tcp_ports(inbound_tcp_ports.iter().copied())
.with_linux_table_name(HOST_FIREWALL_LINUX_TABLE_NAME)
.with_macos_anchor_name(HOST_FIREWALL_MACOS_ANCHOR_NAME)
}
fn spawn_fips_node_task(
mut node: Node,
shutdown_rx: oneshot::Receiver<()>,
) -> JoinHandle<Result<()>> {
tokio::spawn(async move {
tokio::pin!(shutdown_rx);
let loop_result = tokio::select! {
result = node.run_rx_loop() => result.map_err(anyhow::Error::from),
_ = &mut shutdown_rx => Ok(()),
};
let stop_result = if node.state().can_stop() {
node.stop().await.map_err(anyhow::Error::from)
} else {
Ok(())
};
loop_result?;
stop_result
})
}
struct SystemResolverGuard {
backend: ResolverBackend,
}
enum ResolverBackend {
#[cfg(target_os = "macos")]
MacosResolver { path: String },
#[cfg(target_os = "linux")]
SystemdResolved { path: String },
#[cfg(target_os = "linux")]
Dnsmasq { path: String, service: &'static str },
}
impl SystemResolverGuard {
fn install(config: &FipsHostTunnelConfig) -> Result<Option<Self>> {
#[cfg(target_os = "macos")]
{
install_macos_resolver(config).map(|backend| backend.map(|backend| Self { backend }))
}
#[cfg(target_os = "linux")]
{
install_linux_resolver(config).map(|backend| backend.map(|backend| Self { backend }))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = config;
Ok(None)
}
}
fn cleanup_disabled_artifacts() {
#[cfg(target_os = "macos")]
{
remove_owned_file("/etc/resolver/fips");
}
#[cfg(target_os = "linux")]
{
if remove_owned_file("/etc/systemd/resolved.conf.d/nostr-vpn-fips.conf") {
restart_service("systemd-resolved");
}
if remove_owned_file("/etc/dnsmasq.d/nostr-vpn-fips.conf") {
reload_service("dnsmasq");
}
if remove_owned_file("/etc/NetworkManager/dnsmasq.d/nostr-vpn-fips.conf") {
reload_service("NetworkManager");
}
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl Drop for SystemResolverGuard {
fn drop(&mut self) {
match &self.backend {
#[cfg(target_os = "macos")]
ResolverBackend::MacosResolver { path } => {
remove_owned_file(path);
}
#[cfg(target_os = "linux")]
ResolverBackend::SystemdResolved { path } => {
if remove_owned_file(path) {
restart_service("systemd-resolved");
}
}
#[cfg(target_os = "linux")]
ResolverBackend::Dnsmasq { path, service } => {
if remove_owned_file(path) {
reload_service(service);
}
}
}
}
}
#[cfg(target_os = "macos")]
fn install_macos_resolver(config: &FipsHostTunnelConfig) -> Result<Option<ResolverBackend>> {
let path = "/etc/resolver/fips";
let contents = format!(
"# Managed by nostr-vpn for .fips host routing.\n\
nameserver {}\n\
port {}\n",
config.dns_bind_addr, config.dns_port
);
write_owned_file(path, &contents)?;
Ok(Some(ResolverBackend::MacosResolver {
path: path.to_string(),
}))
}
#[cfg(target_os = "linux")]
fn install_linux_resolver(config: &FipsHostTunnelConfig) -> Result<Option<ResolverBackend>> {
if service_is_active("systemd-resolved") {
let path = "/etc/systemd/resolved.conf.d/nostr-vpn-fips.conf";
let contents = format!(
"# Managed by nostr-vpn for .fips host routing.\n\
[Resolve]\n\
DNS=[{}]:{}\n\
Domains=~fips\n",
config.dns_bind_addr, config.dns_port
);
write_owned_file(path, &contents)?;
restart_service("systemd-resolved");
return Ok(Some(ResolverBackend::SystemdResolved {
path: path.to_string(),
}));
}
if service_is_active("dnsmasq") && Path::new("/etc/dnsmasq.d").is_dir() {
let path = "/etc/dnsmasq.d/nostr-vpn-fips.conf";
let contents = format!(
"# Managed by nostr-vpn for .fips host routing.\n\
server=/fips/{}#{}\n",
config.dns_bind_addr, config.dns_port
);
write_owned_file(path, &contents)?;
reload_service("dnsmasq");
return Ok(Some(ResolverBackend::Dnsmasq {
path: path.to_string(),
service: "dnsmasq",
}));
}
if service_is_active("NetworkManager") && Path::new("/etc/NetworkManager/dnsmasq.d").is_dir() {
let path = "/etc/NetworkManager/dnsmasq.d/nostr-vpn-fips.conf";
let contents = format!(
"# Managed by nostr-vpn for .fips host routing.\n\
server=/fips/{}#{}\n",
config.dns_bind_addr, config.dns_port
);
write_owned_file(path, &contents)?;
reload_service("NetworkManager");
return Ok(Some(ResolverBackend::Dnsmasq {
path: path.to_string(),
service: "NetworkManager",
}));
}
Ok(None)
}
fn write_owned_file(path: &str, contents: &str) -> Result<()> {
if let Ok(existing) = fs::read_to_string(path)
&& !existing.contains("Managed by nostr-vpn")
{
eprintln!("fips-host: leaving existing resolver file {path} in place");
return Ok(());
}
if let Some(parent) = Path::new(path).parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create resolver directory {}", parent.display()))?;
}
fs::write(path, contents).with_context(|| format!("failed to write {path}"))
}
fn remove_owned_file(path: &str) -> bool {
match fs::read_to_string(path) {
Ok(contents) if contents.contains("Managed by nostr-vpn") => fs::remove_file(path).is_ok(),
_ => false,
}
}
#[cfg(target_os = "linux")]
fn service_is_active(service: &str) -> bool {
ProcessCommand::new("systemctl")
.arg("is-active")
.arg("--quiet")
.arg(service)
.status()
.is_ok_and(|status| status.success())
}
#[cfg(target_os = "linux")]
fn restart_service(service: &str) {
let _ = ProcessCommand::new("systemctl")
.arg("restart")
.arg(service)
.status();
}
#[cfg(target_os = "linux")]
fn reload_service(service: &str) {
let _ = ProcessCommand::new("systemctl")
.arg("reload")
.arg(service)
.status();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn app_config_builds_outbound_only_embedded_node() {
let mut app = AppConfig::generated();
app.fips_host_tunnel_enabled = true;
app.fips_host_inbound_tcp_ports = vec![443, 22, 22];
let maybe_config = FipsHostTunnelConfig::from_app(&app).expect("valid fips host config");
if !HostFirewallGuard::platform_available() {
assert!(maybe_config.is_none());
return;
}
let config = maybe_config.expect("enabled when host firewall is available");
assert_eq!(config.inbound_tcp_ports, vec![22, 443]);
assert_eq!(
config.fips_address,
Identity::from_secret_str(&app.nostr.secret_key)
.expect("app identity")
.address()
.to_ipv6()
);
let fips = config.fips_config();
assert!(fips.tun.enabled);
assert_eq!(fips.tun.name.as_deref(), Some(FIPS_HOST_IFACE));
assert_eq!(fips.tun.mtu, Some(FIPS_HOST_MTU));
assert!(fips.dns.enabled);
assert_eq!(fips.dns.bind_addr.as_deref(), Some("::1"));
assert_eq!(fips.dns.port, Some(5354));
assert_eq!(fips.node.discovery.nostr.policy, NostrDiscoveryPolicy::Open);
assert!(!fips.node.discovery.nostr.advertise);
match fips.transports.udp {
TransportInstances::Single(udp) => {
assert_eq!(udp.outbound_only, Some(true));
assert_eq!(udp.accept_connections, Some(false));
assert_eq!(udp.advertise_on_nostr, Some(false));
}
_ => panic!("expected single udp transport"),
}
match fips.transports.tcp {
TransportInstances::Single(tcp) => {
assert!(tcp.bind_addr.is_none());
assert_eq!(tcp.advertise_on_nostr, Some(false));
}
_ => panic!("expected single tcp transport"),
}
}
#[test]
fn app_config_can_disable_host_tunnel() {
let mut app = AppConfig::generated();
app.fips_host_tunnel_enabled = false;
assert!(
FipsHostTunnelConfig::from_app(&app)
.expect("valid disabled config")
.is_none()
);
}
#[test]
fn host_firewall_config_uses_nvpn_artifact_names() {
let config = host_firewall_config("utun8", &[443, 22, 22]);
assert_eq!(config.interface(), "utun8");
assert_eq!(config.inbound_tcp_ports(), &[22, 443]);
assert_eq!(config.linux_table_name(), HOST_FIREWALL_LINUX_TABLE_NAME);
assert_eq!(config.macos_anchor_name(), HOST_FIREWALL_MACOS_ANCHOR_NAME);
}
}