use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4, ToSocketAddrs};
use std::time::Duration;
use crate::protocol::CA_REPEATER_PORT;
use epics_base_rs::error::{CaError, CaResult};
#[derive(Debug, Clone)]
pub struct CasUdpConfig {
pub intf_addrs: Vec<Ipv4Addr>,
pub beacon_addrs: Vec<SocketAddr>,
pub ignore_addrs: Vec<Ipv4Addr>,
pub beacon_period: Duration,
pub mcast_addrs: Vec<Ipv4Addr>,
}
impl Default for CasUdpConfig {
fn default() -> Self {
Self {
intf_addrs: vec![Ipv4Addr::UNSPECIFIED],
beacon_addrs: vec![SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::BROADCAST,
CA_REPEATER_PORT,
))],
ignore_addrs: Vec::new(),
beacon_period: Duration::from_secs(15),
mcast_addrs: Vec::new(),
}
}
}
pub fn from_env() -> CaResult<CasUdpConfig> {
let mut cfg = CasUdpConfig::default();
if let Some(list) = epics_base_rs::runtime::env::get("EPICS_CAS_INTF_ADDR_LIST") {
let parsed = parse_ipv4_list(&list);
let (mcast, unicast): (Vec<_>, Vec<_>) =
parsed.into_iter().partition(|ip| ip.is_multicast());
if !unicast.is_empty() {
cfg.intf_addrs = unicast;
}
if !mcast.is_empty() {
cfg.mcast_addrs = mcast;
}
}
let beacon_port = epics_base_rs::runtime::env::get("EPICS_CAS_BEACON_PORT")
.and_then(|s| s.parse::<u16>().ok())
.or_else(|| {
epics_base_rs::runtime::env::get("EPICS_CA_REPEATER_PORT")
.and_then(|s| s.parse::<u16>().ok())
})
.unwrap_or(CA_REPEATER_PORT);
let mut beacon_addrs: Vec<SocketAddr> = Vec::new();
if let Some(list) = epics_base_rs::runtime::env::get("EPICS_CAS_BEACON_ADDR_LIST") {
beacon_addrs.extend(parse_addr_list(&list, beacon_port));
}
let auto_beacon = epics_base_rs::runtime::env::get("EPICS_CAS_AUTO_BEACON_ADDR_LIST");
let auto_on = match auto_beacon.as_deref() {
None | Some("") => true,
Some(s) => s.eq_ignore_ascii_case("YES"),
};
let intf_specific: Vec<Ipv4Addr> = cfg
.intf_addrs
.iter()
.copied()
.filter(|ip| !ip.is_unspecified())
.collect();
let intf_has_wildcard = cfg.intf_addrs.iter().any(|ip| ip.is_unspecified());
if !intf_specific.is_empty() && intf_has_wildcard {
return Err(CaError::Protocol(
"EPICS_CAS_INTF_ADDR_LIST may not mix 0.0.0.0 with specific interface IPs \
(rsrv `cantProceed` parity, caservertask.c:390-392). \
Use either 0.0.0.0 alone or a list of specific interface IPs."
.to_string(),
));
}
if auto_on {
let bcast_iter: Vec<Ipv4Addr> = if !intf_specific.is_empty() {
intf_specific
.iter()
.filter_map(|ip| broadcast_for_ip(*ip))
.collect()
} else {
discover_broadcast_addrs()
};
for bcast in bcast_iter {
let entry = SocketAddr::V4(SocketAddrV4::new(bcast, beacon_port));
if !beacon_addrs.contains(&entry) {
beacon_addrs.push(entry);
}
}
if beacon_addrs.is_empty() {
beacon_addrs.push(SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::BROADCAST,
beacon_port,
)));
}
} else if beacon_addrs.is_empty() {
eprintln!("Warning: RSRV has empty beacon address list");
}
cfg.beacon_addrs = beacon_addrs;
if let Some(list) = epics_base_rs::runtime::env::get("EPICS_CAS_IGNORE_ADDR_LIST") {
cfg.ignore_addrs = parse_ipv4_list(&list);
}
let raw_period = epics_base_rs::runtime::env::get("EPICS_CAS_BEACON_PERIOD")
.or_else(|| epics_base_rs::runtime::env::get("EPICS_CA_BEACON_PERIOD"));
if let Some(period) = raw_period.and_then(|s| s.parse::<f64>().ok()) {
if period > 0.0 && period.is_finite() {
cfg.beacon_period = Duration::from_secs_f64(period);
}
}
Ok(cfg)
}
pub fn parse_addr_list(list: &str, default_port: u16) -> Vec<SocketAddr> {
let mut out = Vec::new();
for token in list.split_whitespace() {
if let Some(addr) = resolve_token(token, default_port) {
out.push(addr);
}
}
out
}
fn resolve_token(token: &str, default_port: u16) -> Option<SocketAddr> {
if let Ok(addr) = token.parse::<SocketAddr>() {
return Some(addr);
}
if let Ok(ip) = token.parse::<Ipv4Addr>() {
return Some(SocketAddr::V4(SocketAddrV4::new(ip, default_port)));
}
let (host, port) = match token.rsplit_once(':') {
Some((h, p)) => (h, p.parse::<u16>().ok()?),
None => (token, default_port),
};
let candidates = format!("{host}:{port}").to_socket_addrs().ok()?;
candidates.into_iter().find(|a| a.is_ipv4())
}
fn parse_ipv4_list(list: &str) -> Vec<Ipv4Addr> {
list.split_whitespace()
.filter_map(|tok| {
let (host, _) = tok.rsplit_once(':').unwrap_or((tok, ""));
host.parse::<Ipv4Addr>().ok().or_else(|| {
format!("{tok}:0")
.to_socket_addrs()
.ok()?
.find_map(|sa| match sa {
SocketAddr::V4(v4) => Some(*v4.ip()),
_ => None,
})
})
})
.collect()
}
pub fn discover_broadcast_addrs() -> Vec<Ipv4Addr> {
let mut out = Vec::new();
let Ok(ifs) = if_addrs::get_if_addrs() else {
return out;
};
for iface in ifs {
if iface.is_loopback() {
continue;
}
let IpAddr::V4(_v4) = iface.ip() else {
continue;
};
if let if_addrs::IfAddr::V4(v4) = iface.addr {
if let Some(b) = v4.broadcast {
if b.is_unspecified() {
continue;
}
if !out.contains(&b) {
out.push(b);
}
}
}
}
out
}
pub fn broadcast_for_ip(match_ip: Ipv4Addr) -> Option<Ipv4Addr> {
if match_ip.is_unspecified() || match_ip.is_loopback() {
return None;
}
let ifs = if_addrs::get_if_addrs().ok()?;
for iface in ifs {
if iface.is_loopback() {
continue;
}
let if_addrs::IfAddr::V4(v4) = iface.addr else {
continue;
};
if v4.ip != match_ip {
continue;
}
if let Some(b) = v4.broadcast {
if !b.is_unspecified() {
return Some(b);
}
}
#[cfg(unix)]
{
if let Some(dst) = ifa_dstaddr_for_ipv4(match_ip) {
return Some(dst);
}
}
return None;
}
None
}
#[cfg(unix)]
fn ifa_dstaddr_for_ipv4(match_ip: Ipv4Addr) -> Option<Ipv4Addr> {
unsafe {
let mut head: *mut libc::ifaddrs = std::ptr::null_mut();
if libc::getifaddrs(&mut head) != 0 || head.is_null() {
return None;
}
let mut result: Option<Ipv4Addr> = None;
let mut cur = head;
while !cur.is_null() {
let entry = &*cur;
let next = entry.ifa_next;
if !entry.ifa_addr.is_null()
&& (*entry.ifa_addr).sa_family as i32 == libc::AF_INET
&& entry.ifa_flags as libc::c_int & libc::IFF_POINTOPOINT != 0
{
let in4: &libc::sockaddr_in = &*(entry.ifa_addr as *const libc::sockaddr_in);
let ip_octets = u32::from_be(in4.sin_addr.s_addr).to_be_bytes();
let if_ip = Ipv4Addr::from(ip_octets);
#[cfg(target_os = "linux")]
let dstaddr = entry.ifa_ifu;
#[cfg(not(target_os = "linux"))]
let dstaddr = entry.ifa_dstaddr;
if if_ip == match_ip && !dstaddr.is_null() {
let dst4: &libc::sockaddr_in = &*(dstaddr as *const libc::sockaddr_in);
let dst_octets = u32::from_be(dst4.sin_addr.s_addr).to_be_bytes();
let dst_ip = Ipv4Addr::from(dst_octets);
if !dst_ip.is_unspecified() {
result = Some(dst_ip);
break;
}
}
}
cur = next;
}
libc::freeifaddrs(head);
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_addr_list_with_ports() {
let parsed = parse_addr_list("10.0.0.1 192.168.1.255:5066", 5065);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].port(), 5065);
assert_eq!(parsed[1].port(), 5066);
}
#[test]
fn parse_ipv4_list_drops_garbage() {
let v = parse_ipv4_list("1.2.3.4 not-an-ip 5.6.7.8");
assert_eq!(
v,
vec![Ipv4Addr::new(1, 2, 3, 4), Ipv4Addr::new(5, 6, 7, 8)]
);
}
#[test]
fn broadcast_for_ip_rejects_unspecified_and_loopback() {
assert_eq!(broadcast_for_ip(Ipv4Addr::UNSPECIFIED), None);
assert_eq!(broadcast_for_ip(Ipv4Addr::LOCALHOST), None);
}
#[test]
fn broadcast_for_ip_unknown_address_returns_none() {
assert_eq!(broadcast_for_ip(Ipv4Addr::new(198, 51, 100, 1)), None);
}
#[test]
fn empty_list_returns_empty() {
assert!(parse_addr_list("", 5065).is_empty());
assert!(parse_ipv4_list(" ").is_empty());
}
#[test]
#[serial_test::serial]
fn from_env_does_not_fall_back_to_ca_addr_list() {
let saved_beacon = std::env::var("EPICS_CAS_BEACON_ADDR_LIST").ok();
let saved_ca = std::env::var("EPICS_CA_ADDR_LIST").ok();
let saved_auto = std::env::var("EPICS_CAS_AUTO_BEACON_ADDR_LIST").ok();
unsafe {
std::env::remove_var("EPICS_CAS_BEACON_ADDR_LIST");
std::env::set_var("EPICS_CA_ADDR_LIST", "203.0.113.42:5070");
std::env::set_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST", "NO");
}
let cfg = from_env().expect("from_env in test");
let leaked = cfg
.beacon_addrs
.iter()
.any(|a| matches!(a, SocketAddr::V4(v4) if v4.ip().octets() == [203, 0, 113, 42]));
assert!(
!leaked,
"EPICS_CA_ADDR_LIST entry leaked into beacon_addrs: {:?}",
cfg.beacon_addrs
);
unsafe {
match saved_beacon {
Some(v) => std::env::set_var("EPICS_CAS_BEACON_ADDR_LIST", v),
None => std::env::remove_var("EPICS_CAS_BEACON_ADDR_LIST"),
}
match saved_ca {
Some(v) => std::env::set_var("EPICS_CA_ADDR_LIST", v),
None => std::env::remove_var("EPICS_CA_ADDR_LIST"),
}
match saved_auto {
Some(v) => std::env::set_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST", v),
None => std::env::remove_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST"),
}
}
}
#[test]
#[serial_test::serial]
fn from_env_uses_beacon_addr_list_when_set() {
let saved_beacon = std::env::var("EPICS_CAS_BEACON_ADDR_LIST").ok();
let saved_auto = std::env::var("EPICS_CAS_AUTO_BEACON_ADDR_LIST").ok();
unsafe {
std::env::set_var("EPICS_CAS_BEACON_ADDR_LIST", "198.51.100.7:5099");
std::env::set_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST", "NO");
}
let cfg = from_env().expect("from_env in test");
let hit = cfg.beacon_addrs.iter().any(|a| {
matches!(a, SocketAddr::V4(v4)
if v4.ip().octets() == [198, 51, 100, 7] && v4.port() == 5099)
});
assert!(
hit,
"EPICS_CAS_BEACON_ADDR_LIST entry missing from beacon_addrs: {:?}",
cfg.beacon_addrs
);
unsafe {
match saved_beacon {
Some(v) => std::env::set_var("EPICS_CAS_BEACON_ADDR_LIST", v),
None => std::env::remove_var("EPICS_CAS_BEACON_ADDR_LIST"),
}
match saved_auto {
Some(v) => std::env::set_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST", v),
None => std::env::remove_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST"),
}
}
}
#[test]
#[serial_test::serial]
fn from_env_beacon_period_matches_c_default_on_nonpositive() {
let saved = std::env::var("EPICS_CAS_BEACON_PERIOD").ok();
let saved_legacy = std::env::var("EPICS_CA_BEACON_PERIOD").ok();
let saved_auto = std::env::var("EPICS_CAS_AUTO_BEACON_ADDR_LIST").ok();
unsafe {
std::env::remove_var("EPICS_CA_BEACON_PERIOD");
std::env::set_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST", "NO");
std::env::set_var("EPICS_CAS_BEACON_PERIOD", "0");
}
let cfg = from_env().expect("from_env in test");
assert_eq!(
cfg.beacon_period,
Duration::from_secs(15),
"explicit 0 must fall back to 15s default (C parity)"
);
unsafe {
std::env::set_var("EPICS_CAS_BEACON_PERIOD", "-5");
}
let cfg = from_env().expect("from_env in test");
assert_eq!(
cfg.beacon_period,
Duration::from_secs(15),
"negative must fall back to 15s default (C parity)"
);
unsafe {
std::env::set_var("EPICS_CAS_BEACON_PERIOD", "garbage");
}
let cfg = from_env().expect("from_env in test");
assert_eq!(
cfg.beacon_period,
Duration::from_secs(15),
"parse failure must keep default"
);
unsafe {
std::env::set_var("EPICS_CAS_BEACON_PERIOD", "0.05");
}
let cfg = from_env().expect("from_env in test");
assert_eq!(
cfg.beacon_period,
Duration::from_secs_f64(0.05),
"tiny positive must be honoured verbatim — no synthetic floor"
);
unsafe {
match saved {
Some(v) => std::env::set_var("EPICS_CAS_BEACON_PERIOD", v),
None => std::env::remove_var("EPICS_CAS_BEACON_PERIOD"),
}
match saved_legacy {
Some(v) => std::env::set_var("EPICS_CA_BEACON_PERIOD", v),
None => std::env::remove_var("EPICS_CA_BEACON_PERIOD"),
}
match saved_auto {
Some(v) => std::env::set_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST", v),
None => std::env::remove_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST"),
}
}
}
#[test]
#[serial_test::serial]
fn from_env_auto_beacon_no_with_empty_list_yields_empty() {
let saved_beacon = std::env::var("EPICS_CAS_BEACON_ADDR_LIST").ok();
let saved_auto = std::env::var("EPICS_CAS_AUTO_BEACON_ADDR_LIST").ok();
unsafe {
std::env::remove_var("EPICS_CAS_BEACON_ADDR_LIST");
std::env::set_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST", "NO");
}
let cfg = from_env().expect("from_env in test");
assert!(
cfg.beacon_addrs.is_empty(),
"AUTO=NO with empty explicit list must yield empty beacon_addrs (C parity), got {:?}",
cfg.beacon_addrs
);
unsafe {
match saved_beacon {
Some(v) => std::env::set_var("EPICS_CAS_BEACON_ADDR_LIST", v),
None => std::env::remove_var("EPICS_CAS_BEACON_ADDR_LIST"),
}
match saved_auto {
Some(v) => std::env::set_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST", v),
None => std::env::remove_var("EPICS_CAS_AUTO_BEACON_ADDR_LIST"),
}
}
}
}