vopono_core 1.0.2

Library code for running VPN connections in network namespaces
Documentation
use std::process::Command;

use log::{debug, warn};
use std::collections::HashMap;

use crate::network::{netns::NetworkNamespace, port_forwarding::Forwarder};

pub fn get_host_env_vars() -> HashMap<String, String> {
    let mut env_vars = HashMap::new();

    // Best-effort: try to detect Pulse/pipewire server when available. In
    // daemon mode this would inspect root's session, not the client's; the
    // client forwards its own endpoint instead.
    if crate::util::is_daemon_mode() {
        debug!("Skipping audio-server detection in daemon mode");
    } else if which::which("pactl").is_ok() {
        match crate::util::pulseaudio::get_pulseaudio_server() {
            Ok(pa) => {
                debug!("Found PULSE_SERVER on host: {}", pa);
                env_vars.insert("PULSE_SERVER".to_string(), pa);
            }
            Err(e) => {
                warn!("Could not get PULSE_SERVER from host: {e:?}");
            }
        }
    } else {
        debug!("pactl not found on host, will not set PULSE_SERVER");
    }

    // Add any other host-specific environment variable lookups here in the future.

    env_vars
}

pub fn set_env_vars(
    ns: &NetworkNamespace,
    forwarder: Option<&dyn Forwarder>,
    cmd: &mut Command,
    host_vars: &HashMap<String, String>,
) {
    // Temporarily set env var referring to this network namespace IP
    // for the PostUp script and the application:
    for (key, value) in host_vars.iter() {
        cmd.env(key, value);
    }

    if let Some(ref veth_pair_ips) = ns.veth_pair_ips {
        if let Some(ipv4pair) = veth_pair_ips.ipv4.clone() {
            cmd.env("VOPONO_NS_IP", ipv4pair.namespace_ip.to_string());
            cmd.env("VOPONO_HOST_IP", ipv4pair.host_ip.to_string());
        } else {
            log::error!("No IPv4 veth pair!")
        };

        if let Some(ipv6pair) = veth_pair_ips.ipv6.clone() {
            cmd.env("VOPONO_NS_IPV6", ipv6pair.namespace_ip.to_string());
            cmd.env("VOPONO_HOST_IPV6", ipv6pair.host_ip.to_string());
        }
    }

    cmd.env("VOPONO_NS", &ns.name);

    // Mirror the ports reported by `exec --json` so scripts inside the netns
    // have the same visibility as the launch summary read outside. Empty
    // lists are exported as the empty string so scripts can distinguish
    // "no ports" from "not provided".
    cmd.env("VOPONO_OPEN_PORTS", join_ports(&ns.state.open_ports));
    cmd.env(
        "VOPONO_HOST_FORWARDED_PORTS",
        join_ports(&ns.state.host_forwarded_ports),
    );

    if let Some(f) = forwarder.as_ref() {
        cmd.env("VOPONO_FORWARDED_PORT", f.forwarded_port().to_string());
    }
}

/// Format a port list for a single env var, comma-separated and empty for no
/// ports, matching the array serialization used by `exec --json`.
fn join_ports(ports: &[u16]) -> String {
    ports
        .iter()
        .map(u16::to_string)
        .collect::<Vec<_>>()
        .join(",")
}

#[cfg(test)]
mod tests {
    use super::join_ports;

    #[test]
    fn joins_ports_comma_separated() {
        assert_eq!(join_ports(&[8080, 8443]), "8080,8443");
    }

    #[test]
    fn empty_port_list_is_empty_string() {
        assert_eq!(join_ports(&[]), "");
    }

    #[test]
    fn single_port_has_no_separator() {
        assert_eq!(join_ports(&[9000]), "9000");
    }
}