use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Surface {
pub ip: IpAddr,
pub name: Option<String>,
}
pub const LOOPBACK_BASE: [u8; 3] = [127, 0, 1];
pub const TUN_BASE: [u8; 3] = [10, 99, 1];
pub fn allocate(taken: &[IpAddr], base: [u8; 3]) -> Result<IpAddr> {
for host in 2u8..=254 {
let ip = IpAddr::V4(Ipv4Addr::new(base[0], base[1], base[2], host));
if !taken.contains(&ip) {
return Ok(ip);
}
}
anyhow::bail!(
"no free address in {}.{}.{}.0/24; unproject a surface first",
base[0],
base[1],
base[2]
)
}
pub fn ensure_addr(ip: IpAddr) -> Result<()> {
if is_stock_loopback(ip) {
return Ok(());
}
add_addr(ip)
}
pub fn remove_addr(ip: IpAddr) -> Result<()> {
if is_stock_loopback(ip) {
return Ok(());
}
del_addr(ip)
}
fn is_stock_loopback(ip: IpAddr) -> bool {
ip == IpAddr::V4(Ipv4Addr::LOCALHOST)
}
#[cfg(target_os = "linux")]
fn add_addr(_ip: IpAddr) -> Result<()> {
Ok(())
}
#[cfg(target_os = "linux")]
fn del_addr(_ip: IpAddr) -> Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
fn add_addr(ip: IpAddr) -> Result<()> {
run("ifconfig", &["lo0", "alias", &ip.to_string(), "up"])
}
#[cfg(target_os = "macos")]
fn del_addr(ip: IpAddr) -> Result<()> {
run("ifconfig", &["lo0", "-alias", &ip.to_string()])
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn add_addr(ip: IpAddr) -> Result<()> {
anyhow::bail!(
"projecting {} needs a loopback alias, unsupported on this OS (use --ip 127.0.0.1)",
ip
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn del_addr(_ip: IpAddr) -> Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
fn run(cmd: &str, args: &[&str]) -> Result<()> {
let out = std::process::Command::new(cmd)
.args(args)
.output()
.with_context(|| format!("failed to run {}", cmd))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("{} {}: {}", cmd, args.join(" "), err.trim());
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SurfaceRecord {
pub key: String,
pub ip: IpAddr,
pub name: Option<String>,
}
pub struct SurfaceStore {
path: PathBuf,
}
impl SurfaceStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn load(&self) -> Result<Vec<SurfaceRecord>> {
if !self.path.exists() {
return Ok(Vec::new());
}
let data = std::fs::read(&self.path)
.with_context(|| format!("failed to read {}", self.path.display()))?;
serde_json::from_slice(&data)
.with_context(|| format!("failed to parse {}", self.path.display()))
}
pub fn add(&self, key: &str, ip: IpAddr, name: Option<String>) -> Result<()> {
let mut records = self.load()?;
records.retain(|r| r.key != key);
records.push(SurfaceRecord {
key: key.to_string(),
ip,
name,
});
self.save(&records)
}
pub fn remove(&self, key: &str) -> Result<()> {
let mut records = self.load()?;
records.retain(|r| r.key != key);
self.save(&records)
}
fn save(&self, records: &[SurfaceRecord]) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let data = serde_json::to_vec_pretty(records)?;
std::fs::write(&self.path, data)
.with_context(|| format!("failed to write {}", self.path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(host: u8) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(127, 0, 1, host))
}
#[test]
fn allocate_skips_taken() {
let taken = vec![ip(2), ip(3), ip(5)];
assert_eq!(allocate(&taken, LOOPBACK_BASE).unwrap(), ip(4));
}
#[test]
fn allocate_from_empty_starts_at_two() {
assert_eq!(allocate(&[], LOOPBACK_BASE).unwrap(), ip(2));
}
#[test]
fn allocate_uses_the_given_base() {
let ip = allocate(&[], TUN_BASE).unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(10, 99, 1, 2)));
}
#[test]
fn allocate_errors_when_range_is_full() {
let taken: Vec<IpAddr> = (2u8..=254).map(ip).collect();
assert!(allocate(&taken, LOOPBACK_BASE).is_err());
}
#[test]
fn stock_loopback_is_never_touched() {
let lo = IpAddr::V4(Ipv4Addr::LOCALHOST);
ensure_addr(lo).unwrap();
remove_addr(lo).unwrap();
}
}