use std::net::IpAddr;
use std::path::Path;
use std::process::Command;
use super::FirewallError;
const ANCHOR_FILE: &str = "/etc/pf.anchors/zlayer-overlay";
const PF_CONF: &str = "/etc/pf.conf";
const PF_CONF_MARKER: &str = "# managed by zlayer-overlay";
const ANCHOR_DECL: &str = "anchor \"zlayer-overlay\"";
const ANCHOR_LOAD: &str = "load anchor \"zlayer-overlay\" from \"/etc/pf.anchors/zlayer-overlay\"";
fn build_overlay_anchor(wg_port: u16, api_port: u16, raft_port: u16) -> String {
let mut out = String::new();
out.push_str("# managed by zlayer-overlay — DO NOT EDIT (regenerated by ZLayer)\n");
out.push_str("# Overlay cluster + DNS allow-rules.\n");
out.push_str(&pass_rule(wg_port, true));
out.push_str(&pass_rule(api_port, false));
out.push_str(&pass_rule(raft_port, false));
out.push_str(&pass_rule(53, true));
out.push_str(&pass_rule(53, false));
out
}
fn pass_rule(port: u16, udp: bool) -> String {
let proto = if udp { "udp" } else { "tcp" };
format!("pass in quick proto {proto} from any to any port {port}\n")
}
fn inject_pf_conf(current: &str) -> Option<String> {
let has_decl = current
.lines()
.any(|l| l.trim() == ANCHOR_DECL || l.trim_start().starts_with(ANCHOR_DECL));
let has_load = current
.lines()
.any(|l| l.trim() == ANCHOR_LOAD || l.trim_start().starts_with(ANCHOR_LOAD));
if has_decl && has_load {
return None;
}
let mut out = current.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !has_decl {
out.push_str(ANCHOR_DECL);
out.push(' ');
out.push_str(PF_CONF_MARKER);
out.push('\n');
}
if !has_load {
out.push_str(ANCHOR_LOAD);
out.push(' ');
out.push_str(PF_CONF_MARKER);
out.push('\n');
}
Some(out)
}
fn strip_pf_conf(current: &str) -> String {
let mut out = String::new();
for line in current.lines().filter(|l| !l.contains(PF_CONF_MARKER)) {
out.push_str(line);
out.push('\n');
}
if !current.ends_with('\n') {
let _ = out.pop();
}
out
}
fn edit_published_port(current: &str, port: u16, udp: bool, add: bool) -> String {
let rule = pass_rule(port, udp);
let rule_line = rule.trim_end();
let mut out = String::new();
for line in current.lines().filter(|l| l.trim_end() != rule_line) {
out.push_str(line);
out.push('\n');
}
if add {
out.push_str(&rule);
}
out
}
fn read_or_empty(path: &str) -> Result<String, std::io::Error> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(e),
}
}
fn reload_pf() -> Result<bool, FirewallError> {
let load = Command::new("pfctl")
.args(["-f", PF_CONF])
.output()
.map_err(|e| FirewallError::AddRule {
name: "pfctl -f /etc/pf.conf".to_string(),
reason: e.to_string(),
})?;
if !load.status.success() {
let stderr = String::from_utf8_lossy(&load.stderr);
if is_permission_failure(&stderr) {
return Ok(false);
}
return Err(FirewallError::AddRule {
name: "pfctl -f /etc/pf.conf".to_string(),
reason: stderr.trim().to_string(),
});
}
let enable = Command::new("pfctl")
.arg("-E")
.output()
.map_err(|e| FirewallError::AddRule {
name: "pfctl -E".to_string(),
reason: e.to_string(),
})?;
if !enable.status.success() {
let stderr = String::from_utf8_lossy(&enable.stderr);
if stderr.contains("already enabled") || stderr.contains("altered to") {
return Ok(true);
}
if is_permission_failure(&stderr) {
return Ok(false);
}
return Err(FirewallError::AddRule {
name: "pfctl -E".to_string(),
reason: stderr.trim().to_string(),
});
}
Ok(true)
}
fn is_permission_failure(stderr: &str) -> bool {
let s = stderr.to_ascii_lowercase();
s.contains("permission denied")
|| s.contains("operation not permitted")
|| s.contains("you must be root")
|| s.contains("not permitted")
|| s.contains("/dev/pf")
}
pub fn ensure_overlay_rules(
wg_port: u16,
api_port: u16,
raft_port: u16,
) -> Result<(), FirewallError> {
let ruleset = build_overlay_anchor(wg_port, api_port, raft_port);
if let Some(dir) = Path::new(ANCHOR_FILE).parent() {
if let Err(e) = std::fs::create_dir_all(dir) {
if e.kind() != std::io::ErrorKind::AlreadyExists {
return warn_manual_ports(wg_port, api_port, raft_port, &e.to_string());
}
}
}
if let Err(e) = std::fs::write(ANCHOR_FILE, &ruleset) {
return warn_manual_ports(wg_port, api_port, raft_port, &e.to_string());
}
let conf = match read_or_empty(PF_CONF) {
Ok(c) => c,
Err(e) => return warn_manual_ports(wg_port, api_port, raft_port, &e.to_string()),
};
if let Some(updated) = inject_pf_conf(&conf) {
if let Err(e) = std::fs::write(PF_CONF, updated) {
return warn_manual_ports(wg_port, api_port, raft_port, &e.to_string());
}
}
if reload_pf()? {
Ok(())
} else {
warn_manual_ports(
wg_port,
api_port,
raft_port,
"pfctl requires root / pf is unavailable",
)
}
}
#[allow(clippy::unnecessary_wraps)]
fn warn_manual_ports(
wg_port: u16,
api_port: u16,
raft_port: u16,
reason: &str,
) -> Result<(), FirewallError> {
tracing::warn!(
reason,
"could not configure macOS pf overlay rules; macOS ships with pf \
disabled by default (the Application Firewall is app-based, not \
port-based), so this is normally belt-and-suspenders. If your host \
has pf enabled, open these inbound ports manually: WireGuard udp/{wg}, \
API tcp/{api}, Raft tcp/{raft}, DNS udp/53 + tcp/53",
wg = wg_port,
api = api_port,
raft = raft_port,
);
Ok(())
}
pub fn remove_overlay_rules() -> Result<(), FirewallError> {
if let Err(e) = std::fs::remove_file(ANCHOR_FILE) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
error = %e,
"could not remove macOS pf overlay anchor file {ANCHOR_FILE}; \
skipping (pf is likely disabled / requires root)"
);
return Ok(());
}
}
match read_or_empty(PF_CONF) {
Ok(conf) => {
let stripped = strip_pf_conf(&conf);
if stripped != conf {
if let Err(e) = std::fs::write(PF_CONF, stripped) {
tracing::warn!(
error = %e,
"could not rewrite {PF_CONF} to drop overlay anchor refs"
);
return Ok(());
}
}
}
Err(e) => {
tracing::warn!(error = %e, "could not read {PF_CONF} during overlay teardown");
return Ok(());
}
}
match reload_pf() {
Ok(_) => Ok(()),
Err(FirewallError::AddRule { name, reason }) => {
tracing::warn!(name, reason, "pf reload failed during overlay teardown");
Err(FirewallError::RemoveRule { name, reason })
}
Err(other) => Err(other),
}
}
pub fn ensure_published_port(port: u16, udp: bool) -> Result<(), FirewallError> {
let current = match read_or_empty(ANCHOR_FILE) {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "could not read pf anchor file to publish port {port}");
return Ok(());
}
};
let updated = edit_published_port(¤t, port, udp, true);
if updated != current {
if let Err(e) = std::fs::write(ANCHOR_FILE, updated) {
tracing::warn!(error = %e, "could not write pf anchor file to publish port {port}");
return Ok(());
}
}
if reload_pf()? {
Ok(())
} else {
tracing::warn!(
"pf unavailable / not root; published port {port} ({proto}) not enforced — \
open it manually if your host has pf enabled",
proto = if udp { "udp" } else { "tcp" },
);
Ok(())
}
}
pub fn remove_published_port(port: u16, udp: bool) {
let Ok(current) = read_or_empty(ANCHOR_FILE) else {
return;
};
let updated = edit_published_port(¤t, port, udp, false);
if updated != current && std::fs::write(ANCHOR_FILE, updated).is_err() {
return;
}
let _ = reload_pf();
}
const ISO_ANCHOR_ROOT: &str = "zlayer-overlay-iso";
const ISO_ANCHOR_DIR: &str = "/etc/pf.anchors";
fn network_hash(network: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
network.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn iso_table_name(network: &str) -> String {
format!("zliso_{}", network_hash(network))
}
fn iso_anchor_name(network: &str) -> String {
format!("{ISO_ANCHOR_ROOT}/{}", network_hash(network))
}
fn iso_anchor_file(network: &str) -> String {
format!(
"{ISO_ANCHOR_DIR}/zlayer-overlay-iso-{}",
network_hash(network)
)
}
fn build_iso_anchor(table: &str, node_ip: IpAddr, overlay_cidr: &str) -> String {
use std::fmt::Write as _;
let host_mask = if node_ip.is_ipv6() { "128" } else { "32" };
let mut out = String::new();
out.push_str("# managed by zlayer-overlay — DO NOT EDIT (regenerated by ZLayer)\n");
out.push_str("# Per-network L3 isolation anchor.\n");
let _ = writeln!(out, "table <{table}> persist");
let _ = writeln!(out, "pass quick from <{table}> to <{table}>");
let _ = writeln!(out, "pass quick from <{table}> to {node_ip}/{host_mask}");
let _ = writeln!(out, "pass quick from <{table}> to ! {overlay_cidr}");
let _ = writeln!(out, "block drop quick from <{table}> to {overlay_cidr}");
out
}
fn load_iso_anchor(
network: &str,
node_ip: IpAddr,
overlay_cidr: &str,
) -> Result<bool, FirewallError> {
let table = iso_table_name(network);
let file = iso_anchor_file(network);
let anchor = iso_anchor_name(network);
let ruleset = build_iso_anchor(&table, node_ip, overlay_cidr);
if let Some(dir) = Path::new(&file).parent() {
if let Err(e) = std::fs::create_dir_all(dir) {
if e.kind() != std::io::ErrorKind::AlreadyExists {
return Ok(false);
}
}
}
if std::fs::write(&file, &ruleset).is_err() {
return Ok(false);
}
let load = Command::new("pfctl")
.args(["-a", &anchor, "-f", &file])
.output()
.map_err(|e| FirewallError::AddRule {
name: format!("pfctl -a {anchor} -f {file}"),
reason: e.to_string(),
})?;
if load.status.success() {
return Ok(true);
}
let stderr = String::from_utf8_lossy(&load.stderr);
if is_permission_failure(&stderr) {
return Ok(false);
}
Err(FirewallError::AddRule {
name: format!("pfctl -a {anchor} -f {file}"),
reason: stderr.trim().to_string(),
})
}
pub fn ensure_member_isolation(
network: &str,
member_ip: IpAddr,
peers: &[IpAddr],
node_ip: IpAddr,
overlay_cidr: &str,
) -> Result<(), FirewallError> {
let _ = peers;
if !load_iso_anchor(network, node_ip, overlay_cidr)? {
tracing::warn!(
network,
member = %member_ip,
"could not load macOS pf isolation anchor (pf disabled / requires root); \
per-network L3 isolation not enforced on this node"
);
return Ok(());
}
let table = iso_table_name(network);
let anchor = iso_anchor_name(network);
let member = member_ip.to_string();
let add = Command::new("pfctl")
.args(["-a", &anchor, "-t", &table, "-T", "add", &member])
.output()
.map_err(|e| FirewallError::AddRule {
name: format!("pfctl -a {anchor} -t {table} -T add {member}"),
reason: e.to_string(),
})?;
if !add.status.success() {
let stderr = String::from_utf8_lossy(&add.stderr);
if is_permission_failure(&stderr) {
tracing::warn!(
network,
member = %member_ip,
"could not add member to macOS pf isolation table (pf disabled / requires root)"
);
return Ok(());
}
return Err(FirewallError::AddRule {
name: format!("pfctl -a {anchor} -t {table} -T add {member}"),
reason: stderr.trim().to_string(),
});
}
Ok(())
}
pub fn remove_member_isolation(
network: &str,
member_ip: IpAddr,
peers: &[IpAddr],
node_ip: IpAddr,
overlay_cidr: &str,
) {
let _ = (peers, node_ip, overlay_cidr);
let table = iso_table_name(network);
let anchor = iso_anchor_name(network);
let member = member_ip.to_string();
let _ = Command::new("pfctl")
.args(["-a", &anchor, "-t", &table, "-T", "delete", &member])
.output();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anchor_text_contains_all_cluster_ports() {
let text = build_overlay_anchor(51820, 3669, 4001);
assert!(text.contains("pass in quick proto udp from any to any port 51820"));
assert!(text.contains("pass in quick proto tcp from any to any port 3669"));
assert!(text.contains("pass in quick proto tcp from any to any port 4001"));
assert!(text.contains("pass in quick proto udp from any to any port 53"));
assert!(text.contains("pass in quick proto tcp from any to any port 53"));
assert!(text.contains("managed by zlayer-overlay"));
}
#[test]
fn anchor_text_honours_custom_ports() {
let text = build_overlay_anchor(1, 2, 3);
assert!(text.contains("port 1\n"));
assert!(text.contains("port 2\n"));
assert!(text.contains("port 3\n"));
}
#[test]
fn pass_rule_picks_proto() {
assert_eq!(
pass_rule(80, false),
"pass in quick proto tcp from any to any port 80\n"
);
assert_eq!(
pass_rule(53, true),
"pass in quick proto udp from any to any port 53\n"
);
}
#[test]
fn inject_appends_both_lines_to_empty_conf() {
let updated = inject_pf_conf("").expect("empty conf must change");
assert!(updated.contains(ANCHOR_DECL));
assert!(updated.contains(ANCHOR_LOAD));
assert!(updated.contains(PF_CONF_MARKER));
}
#[test]
fn inject_is_idempotent() {
let base = "scrub-anchor \"com.apple/*\"\n";
let once = inject_pf_conf(base).expect("first inject must change");
assert!(inject_pf_conf(&once).is_none());
}
#[test]
fn inject_does_not_duplicate_when_decl_present_without_load() {
let base = format!("{ANCHOR_DECL}\n");
let updated = inject_pf_conf(&base).expect("missing load must be added");
let decl_count = updated
.lines()
.filter(|l| l.trim_start().starts_with(ANCHOR_DECL))
.count();
assert_eq!(decl_count, 1, "anchor decl should appear exactly once");
assert!(updated.contains(ANCHOR_LOAD));
}
#[test]
fn inject_normalizes_missing_trailing_newline() {
let base = "rdr-anchor \"com.apple/*\""; let updated = inject_pf_conf(base).expect("must change");
assert!(updated.starts_with("rdr-anchor \"com.apple/*\"\n"));
assert!(updated.contains(ANCHOR_DECL));
}
#[test]
fn strip_removes_only_marked_lines() {
let conf = format!(
"scrub-anchor \"com.apple/*\"\n{ANCHOR_DECL} {PF_CONF_MARKER}\n\
{ANCHOR_LOAD} {PF_CONF_MARKER}\npass out all\n"
);
let cleaned = strip_pf_conf(&conf);
assert!(!cleaned.contains(ANCHOR_DECL));
assert!(!cleaned.contains(ANCHOR_LOAD));
assert!(!cleaned.contains(PF_CONF_MARKER));
assert!(cleaned.contains("scrub-anchor \"com.apple/*\""));
assert!(cleaned.contains("pass out all"));
}
#[test]
fn strip_is_noop_on_clean_conf() {
let conf = "scrub-anchor \"com.apple/*\"\npass out all\n";
assert_eq!(strip_pf_conf(conf), conf);
}
#[test]
fn inject_then_strip_roundtrips() {
let base = "scrub-anchor \"com.apple/*\"\n";
let injected = inject_pf_conf(base).expect("inject changes");
let stripped = strip_pf_conf(&injected);
assert_eq!(stripped, base);
}
#[test]
fn edit_published_port_adds_once() {
let added = edit_published_port("", 8080, false, true);
assert!(added.contains("pass in quick proto tcp from any to any port 8080"));
let twice = edit_published_port(&added, 8080, false, true);
assert_eq!(
twice.matches("port 8080\n").count(),
1,
"published port must not be duplicated"
);
}
#[test]
fn edit_published_port_removes() {
let added = edit_published_port("", 9000, true, true);
assert!(added.contains("port 9000"));
let removed = edit_published_port(&added, 9000, true, false);
assert!(!removed.contains("port 9000"));
}
#[test]
fn edit_published_port_remove_absent_is_noop() {
let base = "pass in quick proto tcp from any to any port 80\n";
let removed = edit_published_port(base, 443, false, false);
assert_eq!(removed, base);
}
#[test]
fn edit_published_port_tcp_and_udp_are_distinct() {
let with_tcp = edit_published_port("", 53, false, true);
let with_both = edit_published_port(&with_tcp, 53, true, true);
assert!(with_both.contains("proto tcp from any to any port 53"));
assert!(with_both.contains("proto udp from any to any port 53"));
let only_tcp = edit_published_port(&with_both, 53, true, false);
assert!(only_tcp.contains("proto tcp from any to any port 53"));
assert!(!only_tcp.contains("proto udp from any to any port 53"));
}
#[test]
fn permission_failure_classification() {
assert!(is_permission_failure("pfctl: Operation not permitted"));
assert!(is_permission_failure("you must be root"));
assert!(is_permission_failure("pfctl: /dev/pf: Permission denied"));
assert!(!is_permission_failure("syntax error in rule"));
}
fn ip(s: &str) -> IpAddr {
s.parse().expect("valid IP literal")
}
#[test]
fn iso_table_name_is_stable_and_within_pf_limit() {
let a = iso_table_name("net-a");
let b = iso_table_name("net-a");
let c = iso_table_name("net-b");
assert_eq!(a, b);
assert_ne!(a, c);
assert!(a.len() <= 31, "pf table name too long: {a}");
assert!(a.starts_with("zliso_"));
}
#[test]
fn iso_anchor_name_nests_under_root() {
let anchor = iso_anchor_name("net-a");
assert!(anchor.starts_with("zlayer-overlay-iso/"));
let table = iso_table_name("net-a");
let hash = table.strip_prefix("zliso_").unwrap();
assert!(anchor.ends_with(hash));
}
#[test]
fn build_iso_anchor_emits_table_and_four_rules() {
let table = iso_table_name("net-a");
let text = build_iso_anchor(&table, ip("10.200.0.1"), "10.200.0.0/16");
assert!(text.contains(&format!("table <{table}> persist")));
assert!(text.contains(&format!("pass quick from <{table}> to <{table}>")));
assert!(text.contains(&format!("pass quick from <{table}> to 10.200.0.1/32")));
assert!(text.contains(&format!("pass quick from <{table}> to ! 10.200.0.0/16")));
assert!(text.contains(&format!("block drop quick from <{table}> to 10.200.0.0/16")));
assert!(text.contains("managed by zlayer-overlay"));
}
#[test]
fn build_iso_anchor_host_masks_ipv6_node() {
let table = iso_table_name("net-v6");
let text = build_iso_anchor(&table, ip("fd00::1"), "fd00::/16");
assert!(text.contains(&format!("pass quick from <{table}> to fd00::1/128")));
assert!(text.contains(&format!("block drop quick from <{table}> to fd00::/16")));
}
}