🎄 Sync 2026-06-03 00:48:32

This commit is contained in:
github-actions[bot] 2026-06-03 00:48:33 +08:00
parent bd809f8479
commit 4efb3994e1
26 changed files with 10 additions and 3852 deletions

View File

@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=hickory-dns
PKG_VERSION:=0.26.1
PKG_RELEASE:=29
PKG_RELEASE:=30
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/hickory-dns/hickory-dns/tar.gz/v$(PKG_VERSION)?

View File

@ -1,8 +0,0 @@
$TTL 3D
@ IN SOA ns.lan. admin.lan. (
1 ; Serial
28800 ; Refresh
7200 ; Retry
604800 ; Expire
86400) ; Minimum TTL
NS ns.lan.

View File

@ -1,8 +0,0 @@
$TTL 3D
@ IN SOA ns.lan. admin.lan. (
199609203 ; Serial
28800 ; Refresh
7200 ; Retry
604800 ; Expire
86400) ; Minimum TTL
NS ns.lan.

View File

@ -1,10 +0,0 @@
$TTL 3D
@ IN SOA ns.lan. admin.lan. (
199609203 ; Serial
28800 ; Refresh
7200 ; Retry
604800 ; Expire
86400) ; Minimum TTL
NS ns.lan.
1 PTR localhost.

View File

@ -1,8 +0,0 @@
$TTL 3D
@ IN SOA ns.lan. admin.lan. (
199609203 ; Serial
28800 ; Refresh
7200 ; Retry
604800 ; Expire
86400) ; Minimum TTL
NS ns.lan.

View File

@ -1,8 +0,0 @@
$TTL 3D
@ IN SOA ns.lan. admin.lan. (
1 ; Serial
28800 ; Refresh
7200 ; Retry
604800 ; Expire
86400) ; Minimum TTL
NS ns.lan.

View File

@ -1,8 +0,0 @@
$TTL 3D
@ IN SOA ns.lan. admin.lan. (
1 ; Serial
28800 ; Refresh
7200 ; Retry
604800 ; Expire
86400) ; Minimum TTL
NS ns.lan.

View File

@ -1,123 +0,0 @@
listen_addrs_ipv6 = ["::"]
listen_addrs_ipv4 = ["0.0.0.0"]
listen_port = 53
prometheus_listen_addr = "[::]:9000"
user = "nobody"
group = "nogroup"
[[zones]]
zone = "localhost"
zone_type = "Primary"
file = "/etc/hickory-dns/localhost.zone"
[[zones]]
zone = "0.0.127.in-addr.arpa"
zone_type = "Secondary"
file = "/etc/hickory-dns/127.0.0.1.zone"
[[zones]]
zone = "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa"
zone_type = "Primary"
file = "/etc/hickory-dns/ipv6_1.zone"
[[zones]]
zone = "255.in-addr.arpa"
zone_type = "Primary"
file = "/etc/hickory-dns/255.zone"
[[zones]]
zone = "0.in-addr.arpa"
zone_type = "Primary"
file = "/etc/hickory-dns/0.zone"
[[zones]]
zone = "lan"
zone_type = "Primary"
axfr_policy = "AllowAll"
[zones.stores]
type = "sqlite"
zone_path = "/etc/hickory-dns/lan.zone"
journal_path = ":memory:"
allow_update = true
[[zones.stores.tsig_keys]]
name = "update-key."
key_file = "/etc/hickory-dns/update.key"
algorithm = "hmac-sha256"
fudge = 300
[[zones]]
zone = "d.f.ip6.arpa"
zone_type = "Primary"
axfr_policy = "AllowAll"
[zones.stores]
type = "sqlite"
zone_path = "/etc/hickory-dns/d.f.ip6.arpa.zone"
journal_path = ":memory:"
allow_update = true
[[zones.stores.tsig_keys]]
name = "update-key."
key_file = "/etc/hickory-dns/update.key"
algorithm = "hmac-sha256"
fudge = 300
[[zones]]
zone = "0.0.10.in-addr.arpa"
zone_type = "Primary"
axfr_policy = "AllowAll"
[zones.stores]
type = "sqlite"
zone_path = "/etc/hickory-dns/0.0.10.zone"
journal_path = ":memory:"
allow_update = true
[[zones.stores.tsig_keys]]
name = "update-key."
key_file = "/etc/hickory-dns/update.key"
algorithm = "hmac-sha256"
fudge = 300
[[zones]]
zone = "3.168.192.in-addr.arpa"
zone_type = "Primary"
axfr_policy = "AllowAll"
[zones.stores]
type = "sqlite"
zone_path = "/etc/hickory-dns/3.168.192.zone"
journal_path = ":memory:"
allow_update = true
[[zones.stores.tsig_keys]]
name = "update-key."
key_file = "/etc/hickory-dns/update.key"
algorithm = "hmac-sha256"
fudge = 300
[[zones]]
zone = "."
zone_type = "External"
[[zones.stores]]
type = "forward"
options = { edns0 = true, ip_strategy = "Ipv6AndIpv4", cache_size = 8192, use_hosts_file = "Always", num_concurrent_reqs = 1, server_ordering_strategy = "RoundRobin" }
[[zones.stores.name_servers]]
ip = "2400:3200:baba::1"
trust_negative_responses = true
connections = [
{ protocol = { type = "quic", server_name = "dns.alidns.com" } },
{ protocol = { type = "h3", server_name = "dns.alidns.com", path = "/dns-query", disable_grease = false } }
]
[[zones.stores.name_servers]]
ip = "2400:3200::1"
trust_negative_responses = true
connections = [
{ protocol = { type = "quic", server_name = "dns.alidns.com" } },
{ protocol = { type = "h3", server_name = "dns.alidns.com", path = "/dns-query", disable_grease = false } }
]

View File

@ -1,10 +0,0 @@
$TTL 3D
@ IN SOA ns.lan. admin.lan. (
199609203 ; Serial
28800 ; Refresh
7200 ; Retry
604800 ; Expire
86400) ; Minimum TTL
NS ns.lan.
1 PTR localhost.

View File

@ -1,11 +0,0 @@
$TTL 60
@ IN SOA ns.lan. admin.lan. (
1 ; Serial
3600 ; Refresh
600 ; Retry
86400 ; Expire
60 ) ; Negative caching TTL
IN NS ns.lan.
ns IN AAAA ::1

View File

@ -1,11 +0,0 @@
$TTL 3D
@ IN SOA ns.lan. admin.lan. (
199609203 ; Serial
28800 ; Refresh
7200 ; Retry
604800 ; Expire
86400) ; Minimum TTL
NS ns.lan.
localhost. A 127.0.0.1
AAAA ::1

View File

@ -1,39 +0,0 @@
#!/bin/sh
. /lib/functions.sh
IF_TO_WATCH="wan_6"
SERVICE="hickory-dns"
DELAY=5
LOCK_DIR="/tmp/hickory-hotplug.lock"
TAG="hickory-hotplug"
[ "$INTERFACE" = "$IF_TO_WATCH" ] || exit 0
[ "$ACTION" = "ifup" ] || exit 0
/etc/init.d/${SERVICE} enabled || {
logger -t "$TAG" "${SERVICE} not enabled, skipping restart"
exit 0
}
mkdir "$LOCK_DIR" 2>/dev/null || {
logger -t "$TAG" "Restart already pending, skipping"
exit 0
}
logger -t "$TAG" "${IF_TO_WATCH} ifup - ${SERVICE} restart in ${DELAY}s"
(
sleep "$DELAY"
/etc/init.d/${SERVICE} restart >/dev/null 2>&1
RET=$?
if [ "$RET" = "0" ]; then
logger -t "$TAG" "${SERVICE} restarted successfully after ${IF_TO_WATCH} ifup"
else
logger -t "$TAG" "${SERVICE} restart failed after ${IF_TO_WATCH} ifup, exit code ${RET}"
fi
rmdir "$LOCK_DIR"
) &

View File

@ -1,50 +0,0 @@
#!/bin/sh /etc/rc.common
USE_PROCD=1
START=18
STOP=51
PROG=/usr/bin/hickory-dns
CONF=/etc/hickory-dns/forwarder.toml
CONF_DIR=/etc/hickory-dns
TSIG_KEY=/etc/hickory-dns/update.key
RUN_USER=nobody
RUN_GROUP=nogroup
prepare_config_dir() {
[ -d "$CONF_DIR" ] || mkdir -p "$CONF_DIR"
chown -R "$RUN_USER:$RUN_GROUP" "$CONF_DIR" 2>/dev/null || true
find "$CONF_DIR" -type d -exec chmod 755 {} \; 2>/dev/null || true
find "$CONF_DIR" -type f -exec chmod 644 {} \; 2>/dev/null || true
}
generate_tsig_key() {
if [ ! -f "$TSIG_KEY" ]; then
dd if=/dev/urandom bs=32 count=1 of="$TSIG_KEY" 2>/dev/null
fi
chown "$RUN_USER:$RUN_GROUP" "$TSIG_KEY" 2>/dev/null || true
chmod 400 "$TSIG_KEY" 2>/dev/null || true
}
start_service() {
prepare_config_dir
generate_tsig_key
procd_open_instance hickory-dns
procd_set_param command "$PROG" -c "$CONF" -q
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn "${respawn_threshold:-3600}" "${respawn_timeout:-5}" "${respawn_retry:-5}"
procd_close_instance hickory-dns
procd_open_instance ipv6-neigh
procd_set_param command /usr/bin/ipv6-neigh-wrapper
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn "${respawn_threshold:-3600}" "${respawn_timeout:-5}" "${respawn_retry:-5}"
procd_close_instance ipv6-neigh
}

View File

@ -1,24 +0,0 @@
#!/bin/sh
NEIGH_PROG=/usr/bin/ipv6-neigh
RETRY=30
trap 'exit 1' TERM INT
logger -t ipv6-neigh-wrapper "waiting for DNS port 53"
i=0
while [ "$i" -lt "$RETRY" ]; do
if ss -lnt | grep -q ':53'; then
logger -t ipv6-neigh-wrapper "DNS port 53 ready, starting ipv6-neigh"
exec "$NEIGH_PROG" --private-subnet-v4 -d "[::1]:53" -z lan --router-alias router --keepalive-gua \
--ptr-ipv4-subnet 10.0.0.0/24 \
--ptr-ipv4-subnet 192.168.3.0/24 \
--ptr-ula
fi
i=$((i + 1))
sleep 1
done
logger -t ipv6-neigh-wrapper "DNS port 53 not ready after ${RETRY}s, exiting"
exit 1

1512
ipv6-neigh/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +0,0 @@
[package]
name = "ipv6-neigh"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
rtnetlink = { version = "0.21.0" }
netlink-sys = { version = "0.8" }
tokio = { version = "1.52.3", default-features = false, features = ["rt", "macros", "net", "time", "io-util"] }
futures = "0.3.32"
netlink-packet-route = {version = "0.30" }
netlink-packet-core= {version ="0.8"}
clap = { version = "4.6", default-features = false, features = ["cargo", "derive", "help", "std", "suggestions"] }
hickory-proto = { git = "https://github.com/hickory-dns/hickory-dns.git", rev = "33cefb6e87b7ef16fb378680b8051c16013ed969", features = ["dnssec-ring"] }
ubus = "0.1.7"
serde_json = "1.0.150"
log = "0.4"
env_logger = "0.11"
socket2 = { version = "0.6", features = ["all"] }
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
overflow-checks = false
panic = "abort"

View File

@ -1,70 +0,0 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=ipv6-neigh
PKG_VERSION:=0.1.0
PKG_RELEASE:=19
PKG_BUILD_DEPENDS:=rust/host
PKG_BUILD_PARALLEL:=1
RUST_PKG_LOCKED:=0
include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/packages/lang/rust/rust-package.mk
# Derive Rust CPU flags from TARGET_CFLAGS (e.g. -mcpu=cortex-a53+crc+crypto)
_comma:=,
_empty:=
_space:=$(_empty) $(_empty)
_mcpu:=$(patsubst -mcpu=%,%,$(filter -mcpu=%,$(TARGET_CFLAGS)))
_mcpu_parts:=$(subst +,$(_space),$(_mcpu))
_cpu_base:=$(firstword $(_mcpu_parts))
_cpu_feats:=$(subst $(_space),$(_comma),$(addprefix +,$(wordlist 2,99,$(_mcpu_parts))))
PKG_RUST_CPU_FLAGS:=$(if $(_cpu_base),-Ctarget-cpu=$(_cpu_base)$(if $(_cpu_feats), -Ctarget-feature=$(_cpu_feats)))
define Package/ipv6-neigh
SECTION:=net
CATEGORY:=Network
SUBMENU:=IP Addresses and Names
TITLE:=LAN neighbor DNS updater for hickory-dns
URL:=https://github.com/nicebonn/hickory-dns-openwrt
DEPENDS:=$(RUST_ARCH_DEPENDS) +hickory-dns
endef
define Package/ipv6-neigh/description
Monitors Linux neighbor table (ARP/NDP) via netlink and dynamically
updates DNS records in a local hickory-dns server using RFC 2136
DNS UPDATE over TCP. Maps MAC addresses to hostnames via OpenWrt
DHCP leases (ubus).
endef
define Build/Prepare
mkdir -p $(PKG_BUILD_DIR)/src
$(CP) $(CURDIR)/src/. $(PKG_BUILD_DIR)/src/
$(CP) $(CURDIR)/Cargo.toml $(PKG_BUILD_DIR)/
$(CP) $(CURDIR)/Cargo.lock $(PKG_BUILD_DIR)/
endef
define Build/Compile
+$(CARGO_PKG_VARS) \
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \
CARGO_PROFILE_RELEASE_OVERFLOW_CHECKS=false \
CARGO_PROFILE_RELEASE_PANIC=abort \
RUSTFLAGS="$(CARGO_RUSTFLAGS) $(PKG_RUST_CPU_FLAGS)" \
cargo build -v --profile $(CARGO_PKG_PROFILE) \
--manifest-path "$(PKG_BUILD_DIR)/Cargo.toml" \
--target $(RUSTC_TARGET_ARCH) \
--no-default-features \
$(if $(filter --jobserver%,$(PKG_JOBS)),,-j1) \
$(CARGO_PKG_ARGS)
+$(INSTALL_DIR) $(PKG_INSTALL_DIR)/bin/
+$(INSTALL_BIN) $(PKG_BUILD_DIR)/target/$(RUSTC_TARGET_ARCH)/$(CARGO_PKG_PROFILE)/ipv6-neigh $(PKG_INSTALL_DIR)/bin/
endef
define Package/ipv6-neigh/install
$(INSTALL_DIR) $(1)/usr/bin/
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/bin/ipv6-neigh $(1)/usr/bin/ipv6-neigh
endef
$(eval $(call RustBinPackage,ipv6-neigh))
$(eval $(call BuildPackage,ipv6-neigh))

View File

@ -1,387 +0,0 @@
use std::cell::RefCell;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hickory_proto::op::{Message, Query, ResponseCode, update_message};
use hickory_proto::rr::{Name, RData, Record, RecordSet, RecordType, TSigner};
use hickory_proto::rr::rdata::PTR as PtrRData;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
use crate::filter::if_ipv6_in_private_subnet;
const TCP_TIMEOUT: Duration = Duration::from_secs(5);
fn check_response(response: &Message, allowed: &[ResponseCode]) -> Result<(), Box<dyn std::error::Error>> {
let code = response.metadata.response_code;
if code == ResponseCode::NoError || allowed.contains(&code) {
Ok(())
} else {
Err(format!("DNS server returned {code:?}").into())
}
}
/// DNS dynamic update client that sends RFC 2136 updates over TCP to the hickory-dns server.
/// TCP connections are reused across calls to reduce syscall overhead.
///
/// Uses `RefCell` for interior mutability — safe because the entire program runs on a
/// single-threaded tokio runtime (`current_thread`).
pub(crate) struct DnsUpdater {
server_addr: SocketAddr,
zone: Name,
signer: TSigner,
/// (network as u32, prefix_len, reverse zone name) for IPv4 PTR
ipv4_ptr_zones: Vec<(u32, u8, Name)>,
/// Reverse zone for ULA IPv6 (d.f.ip6.arpa)
ula_ptr_zone: Option<Name>,
/// Reusable TCP connection (lazy connect + auto-reconnect on failure).
conn: RefCell<Option<TcpStream>>,
}
impl DnsUpdater {
pub fn new(server_addr: SocketAddr, zone: Name, signer: TSigner) -> Self {
Self {
server_addr,
zone,
signer,
ipv4_ptr_zones: vec![],
ula_ptr_zone: None,
conn: RefCell::new(None),
}
}
/// Configure reverse PTR zones.
/// `ipv4_subnets`: list of (network_addr, prefix_len); /8, /16, /24 boundaries supported.
/// `ula`: if true, enables ULA PTR via `d.f.ip6.arpa`.
pub fn with_ptr_zones(mut self, ipv4_subnets: &[(Ipv4Addr, u8)], ula: bool) -> Self {
self.ipv4_ptr_zones = ipv4_subnets
.iter()
.map(|(net, prefix_len)| (u32::from(*net), *prefix_len, ipv4_zone_name(*net, *prefix_len)))
.collect();
if ula {
self.ula_ptr_zone = Some(Name::from_ascii("d.f.ip6.arpa.").expect("always valid"));
}
self
}
// -- generic helpers (eliminates upsert_a/upsert_aaaa and delete_a/delete_aaaa duplication) --
/// Create-or-append a record (A or AAAA). Tries CREATE first; on YXRRSet
/// (name already exists) falls back to APPEND so multiple addresses per host work.
async fn upsert_record(
&self,
hostname: &str,
rtype: RecordType,
rdata: RData,
ttl: u32,
) -> Result<(), Box<dyn std::error::Error>> {
let name = Name::from_ascii(hostname)?.append_domain(&self.zone)?;
let mut rrset = RecordSet::with_ttl(name.clone(), rtype, ttl);
rrset.add_rdata(rdata.clone());
let msg = update_message::create(rrset, self.zone.clone(), false);
let response = self.send_tcp(msg).await?;
if response.metadata.response_code == ResponseCode::YXRRSet {
let mut rrset = RecordSet::with_ttl(name, rtype, ttl);
rrset.add_rdata(rdata);
let msg = update_message::append(rrset, self.zone.clone(), false, false);
let response = self.send_tcp(msg).await?;
check_response(&response, &[])?;
} else {
check_response(&response, &[])?;
}
Ok(())
}
/// Delete a single record by rdata (A or AAAA).
async fn delete_record(
&self,
hostname: &str,
rtype: RecordType,
rdata: RData,
) -> Result<(), Box<dyn std::error::Error>> {
let name = Name::from_ascii(hostname)?.append_domain(&self.zone)?;
let mut rrset = RecordSet::new(name, rtype, 0);
rrset.add_rdata(rdata);
let msg = update_message::delete_by_rdata(rrset, self.zone.clone(), false);
let response = self.send_tcp(msg).await?;
check_response(&response, &[])?;
Ok(())
}
// -- public A / AAAA wrappers --
/// Create or append a AAAA record.
pub async fn upsert_aaaa(&self, hostname: &str, addr: Ipv6Addr, ttl: u32) -> Result<(), Box<dyn std::error::Error>> {
self.upsert_record(hostname, RecordType::AAAA, RData::AAAA(addr.into()), ttl).await
}
/// Create or append an A record.
pub async fn upsert_a(&self, hostname: &str, addr: Ipv4Addr, ttl: u32) -> Result<(), Box<dyn std::error::Error>> {
self.upsert_record(hostname, RecordType::A, RData::A(addr.into()), ttl).await
}
/// Delete a specific AAAA record.
pub async fn delete_aaaa(&self, hostname: &str, addr: Ipv6Addr) -> Result<(), Box<dyn std::error::Error>> {
self.delete_record(hostname, RecordType::AAAA, RData::AAAA(addr.into())).await
}
/// Delete a specific A record.
pub async fn delete_a(&self, hostname: &str, addr: Ipv4Addr) -> Result<(), Box<dyn std::error::Error>> {
self.delete_record(hostname, RecordType::A, RData::A(addr.into())).await
}
// -- PTR --
/// Upsert a PTR record. Silently skips IPs with no configured reverse zone.
pub async fn upsert_ptr(
&self,
addr: IpAddr,
hostname: &str,
ttl: u32,
) -> Result<(), Box<dyn std::error::Error>> {
let (ptr_name, rev_zone) = match addr {
IpAddr::V4(ip) => {
let Some(zone) = self.find_ipv4_ptr_zone(ip).cloned() else { return Ok(()) };
(ipv4_ptr_name(ip), zone)
}
IpAddr::V6(ip) if if_ipv6_in_private_subnet(&ip) => {
let Some(zone) = self.ula_ptr_zone.clone() else { return Ok(()) };
(ipv6_ptr_name(ip), zone)
}
_ => return Ok(()),
};
let target = Name::from_ascii(hostname)?.append_domain(&self.zone)?;
// Delete any existing PTR rrset first (replace semantics: one PTR per IP).
let del_record = Record::update0(ptr_name.clone(), 0, RecordType::PTR);
let del_msg = update_message::delete_rrset(del_record, rev_zone.clone(), false);
let del_resp = self.send_tcp(del_msg).await?;
check_response(&del_resp, &[])?;
// Add the new PTR.
let mut rrset = RecordSet::with_ttl(ptr_name, RecordType::PTR, ttl);
rrset.add_rdata(RData::PTR(PtrRData(target)));
let msg = update_message::append(rrset, rev_zone, false, false);
let response = self.send_tcp(msg).await?;
check_response(&response, &[])?;
Ok(())
}
/// Delete a PTR record. Silently skips IPs with no configured reverse zone.
pub async fn delete_ptr(
&self,
addr: IpAddr,
) -> Result<(), Box<dyn std::error::Error>> {
let (ptr_name, rev_zone) = match addr {
IpAddr::V4(ip) => {
let Some(zone) = self.find_ipv4_ptr_zone(ip).cloned() else { return Ok(()) };
(ipv4_ptr_name(ip), zone)
}
IpAddr::V6(ip) if if_ipv6_in_private_subnet(&ip) => {
let Some(zone) = self.ula_ptr_zone.clone() else { return Ok(()) };
(ipv6_ptr_name(ip), zone)
}
_ => return Ok(()),
};
let record = Record::update0(ptr_name, 0, RecordType::PTR);
let msg = update_message::delete_rrset(record, rev_zone, false);
let response = self.send_tcp(msg).await?;
check_response(&response, &[])?;
Ok(())
}
fn find_ipv4_ptr_zone(&self, addr: Ipv4Addr) -> Option<&Name> {
let addr_u32 = u32::from(addr);
self.ipv4_ptr_zones
.iter()
.filter(|(net, prefix_len, _)| {
if *prefix_len == 0 { return true; }
let shift = 32u8.saturating_sub(*prefix_len);
(addr_u32 >> shift) == (net >> shift)
})
.max_by_key(|(_, prefix_len, _)| *prefix_len)
.map(|(_, _, name)| name)
}
// -- TCP transport (connection-reusing) --
/// Send a DNS message over TCP and read the response.
/// Reuses an existing connection when possible; reconnects automatically on failure.
async fn send_tcp(&self, mut msg: Message) -> Result<Message, Box<dyn std::error::Error>> {
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
msg.finalize(&self.signer, now)?;
let bytes = msg.to_vec()?;
let len_bytes = u16::try_from(bytes.len())?.to_be_bytes();
// Try the existing connection first.
// Take it out of the RefCell so we don't hold the borrow across await.
let existing = self.conn.borrow_mut().take();
if let Some(mut stream) = existing {
match Self::do_send_recv(&mut stream, &len_bytes, &bytes, self.server_addr).await {
Ok(resp) => {
// Put the connection back for reuse.
*self.conn.borrow_mut() = Some(stream);
return Ok(resp);
}
Err(_) => {
// Connection broken — will reconnect below.
}
}
}
// Establish a new connection.
let mut stream = timeout(TCP_TIMEOUT, TcpStream::connect(self.server_addr))
.await
.map_err(|_| -> Box<dyn std::error::Error> {
format!("DNS TCP connect timeout after {}s to {}", TCP_TIMEOUT.as_secs(), self.server_addr).into()
})??;
let resp = Self::do_send_recv(&mut stream, &len_bytes, &bytes, self.server_addr).await?;
*self.conn.borrow_mut() = Some(stream);
Ok(resp)
}
/// Write a length-prefixed DNS message and read the response on an existing stream.
async fn do_send_recv(
stream: &mut TcpStream,
len_bytes: &[u8],
msg_bytes: &[u8],
server_addr: SocketAddr,
) -> Result<Message, Box<dyn std::error::Error>> {
timeout(TCP_TIMEOUT, async {
stream.write_all(len_bytes).await?;
stream.write_all(msg_bytes).await?;
stream.flush().await?;
let resp_len = stream.read_u16().await? as usize;
let mut resp_buf = vec![0u8; resp_len];
stream.read_exact(&mut resp_buf).await?;
Ok::<_, Box<dyn std::error::Error>>(Message::from_vec(&resp_buf)?)
})
.await
.map_err(|_| -> Box<dyn std::error::Error> {
format!("DNS TCP timeout after {}s to {}", TCP_TIMEOUT.as_secs(), server_addr).into()
})?
}
// -- AXFR (uses its own connection -- streaming protocol) --
/// Fetch all A and AAAA records in the zone via AXFR (RFC 5936).
///
/// Returns a list of `(hostname_label, IpAddr)` pairs -- only records whose owner
/// name is directly under the zone apex (e.g. `foo.lan.` -> `"foo"`).
/// SOA, NS, and apex records are excluded.
///
/// Requires `axfr_policy = "AllowAll"` (or `"AllowSigned"`) in the server config.
pub async fn axfr_records(&self) -> Result<Vec<(String, IpAddr)>, Box<dyn std::error::Error>> {
const AXFR_TIMEOUT: Duration = Duration::from_secs(10);
let query = Query::new(self.zone.clone(), RecordType::AXFR);
let mut msg = Message::query();
msg.add_query(query);
let bytes = msg.to_vec()?;
let len = u16::try_from(bytes.len())?;
let zone = self.zone.clone();
let addr = self.server_addr;
let records = timeout(AXFR_TIMEOUT, async move {
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(&len.to_be_bytes()).await?;
stream.write_all(&bytes).await?;
stream.flush().await?;
let mut records: Vec<(String, IpAddr)> = Vec::new();
let mut soa_count = 0u32;
loop {
let resp_len = stream.read_u16().await? as usize;
if resp_len == 0 {
break;
}
let mut buf = vec![0u8; resp_len];
stream.read_exact(&mut buf).await?;
let response = Message::from_vec(&buf)?;
if response.metadata.response_code != ResponseCode::NoError {
return Err(format!("AXFR error: {:?}", response.metadata.response_code).into());
}
for record in &response.answers {
match &record.data {
RData::SOA(_) => {
soa_count += 1;
}
RData::A(a) => {
if let Some(host) = extract_hostname(&record.name, &zone) {
records.push((host, IpAddr::V4(a.0)));
}
}
RData::AAAA(aaaa) => {
if let Some(host) = extract_hostname(&record.name, &zone) {
records.push((host, IpAddr::V6(aaaa.0)));
}
}
_ => {}
}
}
if soa_count >= 2 {
break;
}
}
Ok::<_, Box<dyn std::error::Error>>(records)
})
.await
.map_err(|_| -> Box<dyn std::error::Error> {
format!("AXFR timeout after {}s connecting to {}", AXFR_TIMEOUT.as_secs(), self.server_addr).into()
})??;
Ok(records)
}
}
/// Build the PTR owner name for an IPv4 address.
/// e.g. 192.168.3.5 -> `5.3.168.192.in-addr.arpa.`
fn ipv4_ptr_name(addr: Ipv4Addr) -> Name {
let o = addr.octets();
Name::from_ascii(&format!("{}.{}.{}.{}.in-addr.arpa.", o[3], o[2], o[1], o[0]))
.expect("always valid")
}
/// Build the PTR owner name for an IPv6 address (nibble-reversed).
fn ipv6_ptr_name(addr: Ipv6Addr) -> Name {
let nibbles: String = addr
.octets()
.iter()
.rev()
.flat_map(|b| {
let lo = char::from_digit((b & 0x0f) as u32, 16).unwrap();
let hi = char::from_digit((b >> 4) as u32, 16).unwrap();
[lo, '.', hi, '.']
})
.collect();
Name::from_ascii(&format!("{}ip6.arpa.", nibbles)).expect("always valid")
}
/// Derive the reverse zone name for an IPv4 subnet (only /8, /16, /24 boundaries).
fn ipv4_zone_name(net: Ipv4Addr, prefix_len: u8) -> Name {
let o = net.octets();
let s = match prefix_len {
24..=32 => format!("{}.{}.{}.in-addr.arpa.", o[2], o[1], o[0]),
16..=23 => format!("{}.{}.in-addr.arpa.", o[1], o[0]),
8..=15 => format!("{}.in-addr.arpa.", o[0]),
_ => "in-addr.arpa.".to_string(),
};
Name::from_ascii(&s).expect("always valid")
}
/// Extract the single label that precedes the zone apex from a fully-qualified record name.
///
/// e.g. `"foo.lan."` with zone `"lan."` -> `Some("foo")`.
/// Returns `None` for the apex itself or for names not directly under the zone.
fn extract_hostname(name: &Name, zone: &Name) -> Option<String> {
let n = name.to_ascii().to_lowercase();
let z = zone.to_ascii().to_lowercase();
let n = n.trim_end_matches('.');
let z = z.trim_end_matches('.');
if n == z {
return None; // apex record
}
let suffix = format!(".{z}");
n.strip_suffix(&suffix).map(|s| s.to_string())
}

View File

@ -1,69 +0,0 @@
use std::net::{Ipv4Addr, Ipv6Addr};
use crate::types::LanPrefix;
/// Returns true if `addr` is link-local (fe80::/10).
/// Centralised here so every module uses the same check.
pub(crate) fn is_link_local_ipv6(addr: &Ipv6Addr) -> bool {
(addr.segments()[0] & 0xffc0) == 0xfe80
}
/// Returns true if `addr` is ULA (fc00::/7).
pub(crate) fn if_ipv6_in_private_subnet(ip: &Ipv6Addr) -> bool {
(ip.segments()[0] & 0xfe00) == 0xfc00
}
/// Returns true if `addr` is a Global Unicast Address (2000::/3).
pub(crate) fn is_gua_ipv6(addr: &Ipv6Addr) -> bool {
(addr.segments()[0] & 0xe000) == 0x2000
}
/// Returns true if this IPv6 address should be skipped for LAN purposes.
/// Skips link-local always; skips non-ULA when `private_subnet_v6` is set.
pub(crate) fn should_skip_v6(addr: &Ipv6Addr, private_subnet_v6: bool) -> bool {
if is_link_local_ipv6(addr) {
return true;
}
if private_subnet_v6 && !if_ipv6_in_private_subnet(addr) {
return true;
}
false
}
pub(crate) fn if_ipv4_in_private_subnet(ip: &Ipv4Addr) -> bool {
let octets = ip.octets();
// 10.0.0.0/8
if octets[0] == 10 {
return true;
}
// 172.16.0.0/12
if octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31) {
return true;
}
// 192.168.0.0/16
if octets[0] == 192 && octets[1] == 168 {
return true;
}
// 127.0.0.0/8 (loopback)
if octets[0] == 127 {
return true;
}
false
}
/// Returns true if `addr` falls within the given IPv6 prefix.
pub(crate) fn ipv6_in_prefix(addr: Ipv6Addr, prefix: &LanPrefix) -> bool {
let mask: u128 = if prefix.prefix_len == 0 {
0
} else if prefix.prefix_len >= 128 {
u128::MAX
} else {
!0u128 << (128 - prefix.prefix_len)
};
(u128::from(addr) & mask) == (u128::from(prefix.addr) & mask)
}
/// Returns true if `addr` is within any active LAN prefix, or if the prefix list is empty
/// (in which case prefix-based filtering is disabled).
pub(crate) fn ipv6_passes_active_prefix(addr: Ipv6Addr, active_prefixes: &[LanPrefix]) -> bool {
active_prefixes.is_empty() || active_prefixes.iter().any(|p| ipv6_in_prefix(addr, p))
}

View File

@ -1,152 +0,0 @@
use std::net::{IpAddr, SocketAddr};
use futures::stream::TryStreamExt;
use log::{info, warn};
use netlink_packet_route::address::{AddressAttribute, AddressFlags, AddressHeaderFlags, AddressMessage};
use rtnetlink::Handle;
use tokio::time::{self, Duration};
use crate::db::DnsUpdater;
use crate::filter::should_skip_v6;
use crate::types::{DEFAULT_TTL, LanPrefix};
/// Check whether an address message is deprecated (preferred lifetime expired).
pub(crate) fn is_addr_deprecated(msg: &AddressMessage) -> bool {
if msg.header.flags.contains(AddressHeaderFlags::Deprecated) {
return true;
}
for attr in &msg.attributes {
match attr {
AddressAttribute::Flags(f) if f.contains(AddressFlags::Deprecated) => return true,
AddressAttribute::CacheInfo(ci) if ci.ifa_preferred == 0 => return true,
_ => {}
}
}
false
}
/// Look up the interface index and collect all address messages for `iface`.
/// Shared between `get_active_lan_prefixes` and `register_router_addresses`
/// to avoid duplicating the link-lookup + address-enumeration boilerplate.
async fn get_iface_addresses(
handle: Handle,
iface: &str,
) -> Result<(u32, Vec<AddressMessage>), String> {
let mut links = handle.link().get().match_name(iface.to_owned()).execute();
let link = match links.try_next().await {
Ok(Some(l)) => l,
Ok(None) => return Err(format!("interface {} not found", iface)),
Err(e) => return Err(format!("failed to find interface {}: {}", iface, e)),
};
let ifindex = link.header.index;
let mut addr_stream = handle.address().get().set_link_index_filter(ifindex).execute();
let mut msgs = Vec::new();
while let Ok(Some(msg)) = addr_stream.try_next().await {
msgs.push(msg);
}
Ok((ifindex, msgs))
}
/// Enumerate non-link-local IPv6 prefixes currently assigned to `iface`.
/// Used during startup to filter out neighbour entries from expired/old prefixes.
pub(crate) async fn get_active_lan_prefixes(
handle: Handle,
iface: &str,
private_subnet_v6: bool,
) -> Vec<LanPrefix> {
let (_ifindex, msgs) = match get_iface_addresses(handle, iface).await {
Ok(v) => v,
Err(_) => return vec![],
};
let mut prefixes = Vec::new();
for msg in &msgs {
let prefix_len = msg.header.prefix_len;
if is_addr_deprecated(msg) {
continue;
}
for attr in &msg.attributes {
if let AddressAttribute::Address(IpAddr::V6(addr)) = attr {
if should_skip_v6(addr, private_subnet_v6) {
continue;
}
prefixes.push(LanPrefix { addr: *addr, prefix_len });
}
}
}
prefixes
}
/// Enumerate addresses on `iface`, register A/AAAA records for the router itself.
pub(crate) async fn register_router_addresses(
handle: Handle,
iface: &str,
hostname: &str,
extra_alias: Option<&str>,
updater: &DnsUpdater,
private_subnet_v6: bool,
) {
let (_ifindex, msgs) = match get_iface_addresses(handle, iface).await {
Ok(v) => v,
Err(e) => {
warn!("{}, skipping router address registration", e);
return;
}
};
for msg in &msgs {
if is_addr_deprecated(msg) {
continue;
}
for attr in &msg.attributes {
let ip = match attr {
AddressAttribute::Address(ip) => ip,
_ => continue,
};
match ip {
IpAddr::V6(addr) => {
if should_skip_v6(addr, private_subnet_v6) {
continue;
}
for name in std::iter::once(hostname).chain(extra_alias) {
match updater.upsert_aaaa(name, *addr, DEFAULT_TTL).await {
Ok(()) => info!("registered router {} AAAA {}", name, addr),
Err(e) => warn!("failed to register router AAAA {} for {}: {}", addr, name, e),
}
}
if let Err(e) = updater.upsert_ptr(IpAddr::V6(*addr), hostname, DEFAULT_TTL).await {
warn!("failed to register router PTR for {}: {}", addr, e);
}
}
IpAddr::V4(addr) => {
for name in std::iter::once(hostname).chain(extra_alias) {
match updater.upsert_a(name, *addr, DEFAULT_TTL).await {
Ok(()) => info!("registered router {} A {}", name, addr),
Err(e) => warn!("failed to register router A {} for {}: {}", addr, name, e),
}
}
if let Err(e) = updater.upsert_ptr(IpAddr::V4(*addr), hostname, DEFAULT_TTL).await {
warn!("failed to register router PTR for {}: {}", addr, e);
}
}
}
}
}
}
/// Block until a TCP connection to `addr` succeeds, retrying every 2 seconds.
/// This ensures the DNS server is ready before we attempt any dynamic updates.
pub(crate) async fn wait_for_dns_server(addr: SocketAddr) {
use tokio::net::TcpStream;
loop {
match TcpStream::connect(addr).await {
Ok(_) => {
info!("DNS server at {} is reachable, proceeding", addr);
return;
}
Err(e) => {
warn!("DNS server at {} not ready ({}), retrying in 2s...", addr, e);
time::sleep(Duration::from_secs(2)).await;
}
}
}
}

View File

@ -1,715 +0,0 @@
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr as StdSocketAddr};
use std::time::Instant;
use futures::stream::StreamExt;
use futures::stream::TryStreamExt;
use log::{debug, info, warn};
use netlink_packet_core::NetlinkPayload;
use netlink_packet_route::RouteNetlinkMessage;
use netlink_packet_route::neighbour::NeighbourMessage;
use netlink_packet_route::neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState};
use netlink_packet_route::route::RouteType;
use rtnetlink::{Error, Handle, new_connection};
use tokio::time::{self, Duration};
use clap::Parser;
use hickory_proto::rr::Name;
use hickory_proto::rr::TSigner;
use hickory_proto::rr::rdata::tsig::TsigAlgorithm;
use netlink_sys::{AsyncSocket, SocketAddr};
mod db;
mod filter;
mod iface;
mod op;
mod probe;
mod reconcile;
mod types;
use filter::{if_ipv4_in_private_subnet, if_ipv6_in_private_subnet, ipv6_in_prefix, ipv6_passes_active_prefix, is_gua_ipv6};
use iface::{get_active_lan_prefixes, register_router_addresses, wait_for_dns_server};
use probe::{Prober, probe_gua_keepalive, probe_registered_neighbours, prune_gua_keepalive};
use reconcile::{do_delete_dns, process_new_neigh, prune_ula_for_host, reconcile_dns};
use types::{
RTNLGRP_NEIGH, GuaKeepaliveEntry, Neigh, RegisteredEntry, format_mac, nl_mgrp,
};
#[derive(Debug, Parser)]
#[clap()]
struct Cli {
/// Restrict IPv4 neighbours to private subnets only (10/8, 172.16/12, 192.168/16, 127/8)
#[clap(long)]
private_subnet_v4: bool,
/// Also publish GUA (Global Unicast Address) AAAA records; by default only ULA (fc00::/7) is published
#[clap(long)]
publish_gua: bool,
/// hickory-dns server address for DNS updates (e.g. "[::1]:5335")
#[clap(short, long, default_value = "[::1]:5335")]
dns_server: StdSocketAddr,
/// DNS zone to update (e.g. "lan")
#[clap(short, long, default_value = "lan")]
zone: String,
/// Path to TSIG key file (raw HMAC secret)
#[clap(short, long, default_value = "/etc/hickory-dns/update.key")]
key_file: std::path::PathBuf,
/// TSIG key name
#[clap(short = 'n', long, default_value = "update-key.")]
key_name: String,
/// Log level (error, warn, info, debug, trace)
#[clap(short = 'l', long, default_value = "info")]
log_level: String,
/// Probe interval in seconds for active reachability checks (0 to disable)
#[clap(long, default_value = "75")]
probe_interval: u64,
/// Network interface to read the router's own addresses from
#[clap(long, default_value = "br-lan")]
router_iface: String,
/// Additional DNS name to register router addresses under (e.g. "router" -> "router.lan")
#[clap(long)]
router_alias: Option<String>,
/// Periodically probe the newest GUA per host to maintain NUD/Wi-Fi reachability (without publishing to DNS)
#[clap(long)]
keepalive_gua: bool,
/// Probe interval in seconds for GUA keepalive (only used with --keepalive-gua)
#[clap(long, default_value = "120")]
keepalive_gua_interval: u64,
/// Maximum number of GUA addresses to keepalive-probe per host
#[clap(long, default_value = "2")]
keepalive_gua_per_host: usize,
/// Maximum number of ULA AAAA records to publish per host (oldest pruned first)
#[clap(long, default_value = "2")]
max_ula_per_host: usize,
/// IPv4 subnets for PTR reverse DNS (CIDR notation, e.g. "192.168.3.0/24", repeatable)
#[clap(long, value_name = "SUBNET")]
ptr_ipv4_subnet: Vec<String>,
/// Enable PTR reverse DNS for ULA IPv6 addresses (fc00::/7) via d.f.ip6.arpa
#[clap(long)]
ptr_ula: bool,
}
fn should_skip_route_type(route_type: RouteType) -> bool {
matches!(route_type, RouteType::Multicast | RouteType::Broadcast | RouteType::Local)
}
fn should_skip_neigh(neigh: &Neigh) -> bool {
should_skip_route_type(neigh.kind)
}
/// Whether this NUD state indicates the neighbour is definitely gone (remove from DNS).
fn is_failed_state(state: NeighbourState) -> bool {
matches!(state, NeighbourState::Failed)
}
/// Parse an IPv4 CIDR string like "192.168.3.0/24" into (network_addr, prefix_len).
fn parse_ipv4_cidr(s: &str) -> (Ipv4Addr, u8) {
let (addr_str, prefix_str) = s.split_once('/').unwrap_or_else(|| {
panic!("invalid --ptr-ipv4-subnet \"{s}\": expected CIDR notation like \"192.168.3.0/24\"")
});
let addr: Ipv4Addr = addr_str.parse().unwrap_or_else(|_| {
panic!("invalid --ptr-ipv4-subnet \"{s}\": \"{addr_str}\" is not a valid IPv4 address")
});
let prefix_len: u8 = prefix_str.parse().unwrap_or_else(|_| {
panic!("invalid --ptr-ipv4-subnet \"{s}\": prefix length must be 0-32")
});
assert!(prefix_len <= 32, "invalid --ptr-ipv4-subnet \"{s}\": prefix length {prefix_len} > 32");
(addr, prefix_len)
}
/// Register a new REACHABLE neighbour in DNS and add to the registered map.
/// Handles ULA pruning and delegates to `process_new_neigh` for the actual DNS update.
/// Caller must ensure the (hostname, ip) key is not already in `registered`.
async fn try_register_neigh(
neigh: &Neigh,
hostname: &str,
ip_str: &str,
registered: &mut HashMap<(String, String), RegisteredEntry>,
leases: &HashMap<String, String>,
updater: &db::DnsUpdater,
max_ula_per_host: usize,
private_subnet_v6: bool,
) {
// Enforce per-host ULA limit before adding.
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if if_ipv6_in_private_subnet(addr) {
prune_ula_for_host(hostname, max_ula_per_host, registered, updater).await;
}
}
if process_new_neigh(neigh, updater, leases, private_subnet_v6).await {
registered.insert(
(hostname.to_owned(), ip_str.to_owned()),
RegisteredEntry {
hostname: hostname.to_owned(),
last_confirmed: Instant::now(),
ifindex: neigh.ifindex,
},
);
}
}
fn inet_to_string(addr: &NeighbourAddress) -> String {
match addr {
NeighbourAddress::Inet(ip) => ip.to_string(),
NeighbourAddress::Inet6(ip) => ip.to_string(),
other => format!("{other:?}"),
}
}
fn is_link_local_neigh_addr(addr: &NeighbourAddress) -> bool {
matches!(addr, NeighbourAddress::Inet6(ip) if filter::is_link_local_ipv6(ip))
}
fn parse_neighbour_message(neigh: NeighbourMessage, private_subnet_v4: bool) -> Option<Neigh> {
let state = neigh.header.state;
// Filter out static and incomplete entries
if matches!(state, NeighbourState::Permanent | NeighbourState::Noarp) {
return None;
}
let addr: NeighbourAddress = neigh.attributes.iter().find_map(|attr| match attr {
NeighbourAttribute::Destination(inet) => Some(inet.to_owned()),
_ => None,
})?;
// Link-local IPv6 addresses are interface-scoped and useless in DNS
if is_link_local_neigh_addr(&addr) {
return None;
}
// IPv4 private-subnet filter stays here (no keepalive concept for IPv4 GUA)
if let NeighbourAddress::Inet(ipv4) = &addr {
if private_subnet_v4 && !if_ipv4_in_private_subnet(ipv4) {
return None;
}
}
let kind = neigh.header.kind;
let ifindex = neigh.header.ifindex;
let mac_bytes = neigh.attributes.iter().find_map(|attr| match attr {
NeighbourAttribute::LinkLayerAddress(mac) => Some(mac.to_owned()),
_ => None,
});
// FAILED events may arrive without a hardware address (e.g. when ARP/NDP never
// succeeded, or the kernel cleared the lladdr on failure). Allow them through
// with an empty MAC so the FAILED handler can fall back to IP-based lookup.
let mac_str = match mac_bytes {
Some(m) => format_mac(&m),
None if matches!(state, NeighbourState::Failed) => String::new(),
None => return None,
};
// Filter out empty or all-zero MACs for non-FAILED states
// (router own addresses, incomplete entries).
if !matches!(state, NeighbourState::Failed) && (mac_str.is_empty() || mac_str == "00:00:00:00:00:00") {
return None;
}
Some(Neigh {
ifindex,
state,
kind,
inet: addr,
mac: mac_str,
})
}
async fn dump_neighbours(handle: Handle, private_subnet_v4: bool) -> Result<Vec<Neigh>, Error> {
let mut neighbours = handle.neighbours().get().execute();
let mut vec: Vec<Neigh> = Vec::new();
while let Some(route) = neighbours.try_next().await? {
if let Some(neigh) = parse_neighbour_message(route, private_subnet_v4) {
if !should_skip_neigh(&neigh) {
vec.push(neigh);
}
}
}
Ok(vec)
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), ()> {
let args = Cli::parse();
env_logger::Builder::new()
.filter_level(
args.log_level
.parse()
.expect("invalid log level (use: error, warn, info, debug, trace)"),
)
.filter_module("netlink_packet_route", log::LevelFilter::Error)
.format_timestamp_secs()
.init();
let private_subnet_v4 = args.private_subnet_v4;
let private_subnet_v6 = !args.publish_gua;
let keepalive_gua = args.keepalive_gua;
let keepalive_gua_interval = args.keepalive_gua_interval;
let keepalive_gua_per_host = args.keepalive_gua_per_host;
let max_ula_per_host = args.max_ula_per_host;
let zone = Name::from_ascii(&args.zone).expect("invalid zone name");
let key_data = std::fs::read(&args.key_file)
.unwrap_or_else(|e| panic!("failed to read TSIG key file {:?}: {e}", args.key_file));
let key_name = Name::from_ascii(&args.key_name).expect("invalid TSIG key name");
let signer = TSigner::new(key_data, TsigAlgorithm::HmacSha256, key_name, 300)
.expect("invalid TSIG key");
let ipv4_ptr_subnets: Vec<(Ipv4Addr, u8)> = args.ptr_ipv4_subnet
.iter()
.map(|s| parse_ipv4_cidr(s))
.collect();
let updater = db::DnsUpdater::new(args.dns_server, zone, signer)
.with_ptr_zones(&ipv4_ptr_subnets, args.ptr_ula);
// Create reusable ICMP probe sockets once at startup.
let prober = Prober::new().expect("failed to create ICMP probe sockets");
// Wait for the DNS server to become available before sending any updates.
wait_for_dns_server(args.dns_server).await;
let (mut connection, handle, mut messages) = new_connection().unwrap();
connection
.socket_mut()
.socket_mut()
.bind(&SocketAddr::new(0, nl_mgrp(RTNLGRP_NEIGH)))
.expect("Failed to bind netlink multicast group");
tokio::spawn(connection);
// Register router's own addresses in DNS
let router_hostname = std::fs::read_to_string("/proc/sys/kernel/hostname")
.unwrap_or_default()
.trim()
.to_owned();
if router_hostname.is_empty() {
warn!("could not read router hostname from /proc/sys/kernel/hostname");
} else {
let router_alias = args.router_alias.as_deref().filter(|a| *a != router_hostname);
register_router_addresses(
handle.clone(),
&args.router_iface,
&router_hostname,
router_alias,
&updater,
private_subnet_v6,
)
.await;
}
// Load DHCP leases (mac -> hostname) from ubus.
let mut leases: HashMap<String, String> = tokio::task::spawn_blocking(|| {
op::get_lease().unwrap_or_default()
})
.await
.unwrap_or_default();
info!("loaded {} DHCP leases", leases.len());
let mut registered: HashMap<(String, String), RegisteredEntry> = HashMap::new();
let mut gua_keepalive: HashMap<String, Vec<GuaKeepaliveEntry>> = HashMap::new();
let prefix_filter_v6 = if keepalive_gua { false } else { private_subnet_v6 };
let active_prefixes = get_active_lan_prefixes(handle.clone(), &args.router_iface, prefix_filter_v6).await;
if active_prefixes.is_empty() {
warn!("no active IPv6 prefixes found on {}, neighbour prefix-filtering disabled", args.router_iface);
} else {
info!(
"active LAN IPv6 prefixes: {}",
active_prefixes
.iter()
.map(|p| format!("{}/{}", p.addr, p.prefix_len))
.collect::<Vec<_>>()
.join(", ")
);
}
// Dump existing neighbours
debug!("dumping neighbours");
if let Ok(neighbours) = dump_neighbours(handle.clone(), private_subnet_v4).await {
for neigh in &neighbours {
debug!("{:?}", neigh);
if should_skip_neigh(neigh) {
continue;
}
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if !active_prefixes.is_empty() && !active_prefixes.iter().any(|p| ipv6_in_prefix(*addr, p)) {
debug!("init dump: skipping {} -- not in any active LAN prefix", addr);
continue;
}
}
// Route GUA addresses to keepalive map instead of DNS.
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if is_gua_ipv6(addr) {
if keepalive_gua {
if neigh.state == NeighbourState::Reachable {
if let Some(hostname) = leases.get(&neigh.mac) {
let entries = gua_keepalive.entry(neigh.mac.clone()).or_default();
if !entries.iter().any(|e| e.addr == *addr) {
let now = Instant::now();
entries.push(GuaKeepaliveEntry {
hostname: hostname.clone(),
addr: *addr,
ifindex: neigh.ifindex,
first_seen: now,
last_confirmed: now,
});
debug!("init dump: GUA keepalive tracked {} -> {}", hostname, addr);
}
}
} else if matches!(neigh.state, NeighbourState::Stale | NeighbourState::Delay | NeighbourState::Probe) {
match prober.send_icmpv6_echo(*addr, neigh.ifindex) {
Ok(()) => debug!("init dump: probing stale GUA {}", addr),
Err(e) => debug!("init GUA probe failed for {}: {}", addr, e),
}
}
}
if private_subnet_v6 {
continue;
}
}
}
let ip_str = inet_to_string(&neigh.inet);
match neigh.state {
NeighbourState::Reachable => {
if let Some(hostname) = leases.get(&neigh.mac) {
let key = (hostname.clone(), ip_str.clone());
if !registered.contains_key(&key) {
try_register_neigh(neigh, hostname, &ip_str, &mut registered, &leases, &updater, max_ula_per_host, private_subnet_v6).await;
}
} else {
debug!("no lease for mac {}, skipping DNS update", neigh.mac);
}
}
NeighbourState::Stale | NeighbourState::Delay | NeighbourState::Probe => {
match &neigh.inet {
NeighbourAddress::Inet6(addr) => {
match prober.send_icmpv6_echo(*addr, neigh.ifindex) {
Ok(()) => debug!("init dump: probing stale neighbour {}", addr),
Err(e) => debug!("init probe failed for {}: {}", addr, e),
}
}
NeighbourAddress::Inet(addr) => {
if let Err(e) = prober.send_icmpv4_echo(*addr, neigh.ifindex) {
debug!("init probe failed for {}: {}", addr, e);
}
}
_ => {}
}
}
_ => {}
}
}
}
let probe_interval = args.probe_interval;
let mut probe_timer = if probe_interval > 0 {
time::interval(Duration::from_secs(probe_interval))
} else {
time::interval(Duration::from_secs(u64::MAX / 2))
};
probe_timer.tick().await;
let mut gua_keepalive_timer = if keepalive_gua && keepalive_gua_interval > 0 {
time::interval(Duration::from_secs(keepalive_gua_interval))
} else {
time::interval(Duration::from_secs(u64::MAX / 2))
};
gua_keepalive_timer.tick().await;
let mut lease_refresh_timer = time::interval(Duration::from_secs(60));
lease_refresh_timer.tick().await;
loop {
tokio::select! {
msg_opt = messages.next() => {
let Some((message, _)) = msg_opt else {
break;
};
let payload = message.payload;
if let NetlinkPayload::InnerMessage(msg) = payload {
match msg {
RouteNetlinkMessage::NewNeighbour(new_neigh) => {
let Some(neigh) = parse_neighbour_message(new_neigh, private_subnet_v4) else {
continue;
};
if should_skip_neigh(&neigh) {
continue;
}
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if !ipv6_passes_active_prefix(*addr, &active_prefixes) {
debug!("event: skipping {} -- not in any active LAN prefix", addr);
let key = (neigh.mac.clone(), inet_to_string(&neigh.inet));
if let Some(entry) = registered.remove(&key) {
do_delete_dns(&entry.hostname, &neigh.inet, &updater).await;
}
if let Some(entries) = gua_keepalive.get_mut(&neigh.mac) {
entries.retain(|e| e.addr != *addr);
}
continue;
}
}
// Route GUA addresses to keepalive map instead of DNS.
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if is_gua_ipv6(addr) && private_subnet_v6 {
if keepalive_gua {
if is_failed_state(neigh.state) {
if let Some(entries) = gua_keepalive.get_mut(&neigh.mac) {
entries.retain(|e| e.addr != *addr);
debug!("GUA keepalive: removed failed {} for mac {}", addr, neigh.mac);
}
} else if neigh.state == NeighbourState::Reachable {
if let Some(hostname) = leases.get(&neigh.mac) {
let entries = gua_keepalive.entry(neigh.mac.clone()).or_default();
if let Some(e) = entries.iter_mut().find(|e| e.addr == *addr) {
e.last_confirmed = Instant::now();
e.ifindex = neigh.ifindex;
} else {
let now = Instant::now();
entries.push(GuaKeepaliveEntry {
hostname: hostname.clone(),
addr: *addr,
ifindex: neigh.ifindex,
first_seen: now,
last_confirmed: now,
});
debug!("GUA keepalive: tracking {} -> {}", hostname, addr);
}
}
} else if matches!(neigh.state, NeighbourState::Stale | NeighbourState::Delay | NeighbourState::Probe) {
if let Some(entries) = gua_keepalive.get_mut(&neigh.mac) {
if let Some(e) = entries.iter_mut().find(|e| e.addr == *addr) {
e.ifindex = neigh.ifindex;
}
}
}
}
continue;
}
}
let ip_str = inet_to_string(&neigh.inet);
if is_failed_state(neigh.state) {
debug!("Neighbour failed: {:?}", neigh);
let key_opt: Option<(String, String)> = leases
.get(&neigh.mac)
.map(|h| (h.clone(), ip_str.clone()))
.or_else(|| registered.keys().find(|(_, ip)| ip == &ip_str).cloned());
if let Some(key) = key_opt {
if let Some(entry) = registered.remove(&key) {
if !do_delete_dns(&entry.hostname, &neigh.inet, &updater).await {
registered.insert(key, entry);
}
} else {
debug!("Neighbour failed: not in registered map, key={:?}", key);
}
} else {
debug!("Neighbour failed: no registered key for ip={}", ip_str);
}
} else if neigh.state == NeighbourState::Reachable {
if let Some(hostname) = leases.get(&neigh.mac) {
let key = (hostname.clone(), ip_str.clone());
if let Some(entry) = registered.get_mut(&key) {
entry.last_confirmed = Instant::now();
entry.ifindex = neigh.ifindex;
} else {
debug!("New neighbour: {:?}", neigh);
try_register_neigh(&neigh, hostname, &ip_str, &mut registered, &leases, &updater, max_ula_per_host, private_subnet_v6).await;
}
} else {
debug!("no lease for mac {}, skipping DNS update", neigh.mac);
}
} else if matches!(neigh.state, NeighbourState::Stale | NeighbourState::Delay | NeighbourState::Probe) {
if let Some(hostname) = leases.get(&neigh.mac) {
let key = (hostname.clone(), ip_str.clone());
if let Some(entry) = registered.get_mut(&key) {
entry.ifindex = neigh.ifindex;
}
}
}
}
RouteNetlinkMessage::DelNeighbour(del_neigh) => {
let Some(neigh) = parse_neighbour_message(del_neigh, private_subnet_v4) else {
continue;
};
if should_skip_neigh(&neigh) {
continue;
}
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if is_gua_ipv6(addr) && keepalive_gua {
if let Some(entries) = gua_keepalive.get_mut(&neigh.mac) {
entries.retain(|e| e.addr != *addr);
debug!("GUA keepalive: removed deleted {} for mac {}", addr, neigh.mac);
}
if private_subnet_v6 {
continue;
}
}
}
let ip_str = inet_to_string(&neigh.inet);
let key_opt: Option<(String, String)> = leases
.get(&neigh.mac)
.map(|h| (h.clone(), ip_str.clone()))
.or_else(|| registered.keys().find(|(_, ip)| ip == &ip_str).cloned());
debug!("Del neighbour: {:?}", neigh);
if let Some(key) = key_opt {
registered.remove(&key);
do_delete_dns(&key.0, &neigh.inet, &updater).await;
}
}
_ => {}
}
}
}
_ = probe_timer.tick() => {
if probe_interval == 0 {
continue;
}
probe_registered_neighbours(&prober, &registered).await;
reconcile_dns(
&updater,
&mut registered,
&leases,
&active_prefixes,
private_subnet_v4,
private_subnet_v6,
&prober,
)
.await;
if let Ok(neighbours) = dump_neighbours(handle.clone(), private_subnet_v4).await {
let all_dump_ips: std::collections::HashSet<String> = neighbours
.iter()
.map(|n| inet_to_string(&n.inet))
.collect();
for neigh in &neighbours {
if should_skip_neigh(neigh) {
continue;
}
if is_failed_state(neigh.state) {
let ip_str = inet_to_string(&neigh.inet);
let key_opt: Option<(String, String)> = leases
.get(&neigh.mac)
.map(|h| (h.clone(), ip_str.clone()))
.or_else(|| {
registered
.keys()
.find(|(_, ip)| ip == &ip_str)
.cloned()
});
if let Some(key) = key_opt {
if let Some(entry) = registered.remove(&key) {
debug!("reconcile neigh: kernel FAILED {} -> {:?}", entry.hostname, neigh.inet);
if !do_delete_dns(&entry.hostname, &neigh.inet, &updater).await {
registered.insert(key, entry);
}
}
}
continue;
}
if neigh.state != NeighbourState::Reachable {
continue;
}
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if !active_prefixes.is_empty()
&& !active_prefixes.iter().any(|p| ipv6_in_prefix(*addr, p))
{
continue;
}
if is_gua_ipv6(addr) && private_subnet_v6 {
continue;
}
}
let ip_str = inet_to_string(&neigh.inet);
let Some(hostname) = leases.get(&neigh.mac) else {
continue;
};
let key = (hostname.clone(), ip_str.clone());
if let Some(entry) = registered.get_mut(&key) {
entry.last_confirmed = Instant::now();
entry.ifindex = neigh.ifindex;
} else {
debug!("reconcile neigh: kernel orphan {} -> {:?}", hostname, neigh.inet);
try_register_neigh(neigh, hostname, &ip_str, &mut registered, &leases, &updater, max_ula_per_host, private_subnet_v6).await;
}
}
let stale_cutoff = Duration::from_secs(probe_interval.saturating_mul(2));
let now = Instant::now();
let to_remove: Vec<(String, String)> = registered
.iter()
.filter(|((_hostname, ip_str), entry)| {
!all_dump_ips.contains(ip_str.as_str())
&& now.duration_since(entry.last_confirmed) > stale_cutoff
})
.map(|(k, _)| k.clone())
.collect();
for key in to_remove {
if let Some(entry) = registered.remove(&key) {
let ip_str = &key.1;
let inet = if let Ok(addr) = ip_str.parse::<std::net::Ipv6Addr>() {
NeighbourAddress::Inet6(addr)
} else if let Ok(addr) = ip_str.parse::<std::net::Ipv4Addr>() {
NeighbourAddress::Inet(addr)
} else {
registered.insert(key, entry);
continue;
};
debug!("reconcile neigh: evicted {} -> {}", entry.hostname, ip_str);
if !do_delete_dns(&entry.hostname, &inet, &updater).await {
registered.insert(key, entry);
}
}
}
}
}
_ = gua_keepalive_timer.tick() => {
if !keepalive_gua || keepalive_gua_interval == 0 {
continue;
}
probe_gua_keepalive(&prober, &gua_keepalive, keepalive_gua_per_host);
prune_gua_keepalive(&mut gua_keepalive, keepalive_gua_interval, keepalive_gua_per_host);
}
_ = lease_refresh_timer.tick() => {
match tokio::task::spawn_blocking(|| op::get_lease().map_err(|e| e.to_string())).await {
Ok(Ok(new_leases)) => {
leases = new_leases;
debug!("refreshed {} DHCP leases", leases.len());
}
Ok(Err(e)) => warn!("failed to refresh DHCP leases from ubus: {}", e),
Err(e) => warn!("DHCP lease refresh task panicked: {}", e),
}
}
}
}
Ok(())
}

View File

@ -1,118 +0,0 @@
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
pub fn call_ubus(obj_path: &str, method: &str) -> Result<Value, Box<dyn std::error::Error>> {
let socket = Path::new("/var/run/ubus/ubus.sock");
let mut connection = ubus::Connection::connect(&socket)?;
let json = connection.call(obj_path, method, "")?;
let parsed: Value = serde_json::from_str(&json)?;
Ok(parsed)
}
/// Decode a hex string (e.g. "44237cdcb75b") to raw bytes and format via
/// the shared `types::format_mac`, producing "44:23:7c:dc:b7:5b".
fn hex_to_mac(hex: &str) -> String {
let bytes: Vec<u8> = hex
.as_bytes()
.chunks(2)
.filter_map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
.collect();
crate::types::format_mac(&bytes)
}
/// Sanitize a DHCP hostname into a valid DNS label.
/// Replaces invalid characters with '-', collapses consecutive hyphens,
/// strips leading/trailing hyphens, and truncates to 63 chars.
fn sanitize_hostname(raw: &str) -> String {
let sanitized: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
// Collapse consecutive hyphens and strip leading/trailing
let collapsed: String = sanitized
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<&str>>()
.join("-");
// Truncate to 63 chars (max DNS label length)
collapsed.chars().take(63).collect()
}
/// Extract a MAC address from a DHCPv6 DUID string.
/// Supports DUID-LLT (type 1) and DUID-LL (type 3) with any hardware type.
/// Returns the colon-separated MAC, e.g. "30:c5:99:7d:d5:ae".
fn mac_from_duid(duid: &str) -> Option<String> {
// All chars must be hex digits
if !duid.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let duid_type = u16::from_str_radix(duid.get(0..4)?, 16).ok()?;
// DUID-LLT (1): type(4) + hw_type(4) + time(8) = 16 hex chars before MAC
// DUID-LL (3): type(4) + hw_type(4) = 8 hex chars before MAC
let mac_offset = match duid_type {
1 => 16,
3 => 8,
_ => return None,
};
let mac_hex = duid.get(mac_offset..mac_offset + 12)?;
Some(hex_to_mac(mac_hex))
}
// get mac to hostname mapping
pub fn get_lease() -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
// dhcpv6 does not use mac address, so we only need to get ipv4 leases
let leases = call_ubus("dhcp", "ipv4leases")?;
let devices = leases["device"].as_object()
.ok_or("missing 'device' in ipv4leases response")?;
let mut result = HashMap::new();
for (_device, leases) in devices {
let leases = leases["leases"].as_array()
.ok_or("missing 'leases' array in device")?;
for lease in leases {
let Some(raw_mac) = lease["mac"].as_str() else { continue };
let Some(hostname) = lease["hostname"].as_str() else { continue };
let mac = hex_to_mac(raw_mac);
let hostname = sanitize_hostname(hostname);
if hostname.is_empty() {
continue;
}
// Store bare hostname only; the zone is appended by DnsUpdater
result.insert(mac, hostname);
}
}
// Supplement with IPv6 leases: extract MAC from DUID when available.
// IPv4 entries take precedence; only fill in missing MACs here.
if let Ok(v6leases) = call_ubus("dhcp", "ipv6leases") {
if let Some(devices) = v6leases["device"].as_object() {
for (_device, device_data) in devices {
let Some(leases) = device_data["leases"].as_array() else { continue };
for lease in leases {
let Some(duid) = lease["duid"].as_str() else { continue };
let Some(hostname) = lease["hostname"].as_str() else { continue };
let hostname = sanitize_hostname(hostname);
if hostname.is_empty() {
continue;
}
let Some(mac) = mac_from_duid(duid) else { continue };
// IPv4 mapping takes precedence
result.entry(mac).or_insert(hostname);
}
}
}
}
Ok(result)
}

View File

@ -1,142 +0,0 @@
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::num::NonZeroU32;
use std::time::Instant;
use log::debug;
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
use tokio::time::Duration;
use crate::types::{GuaKeepaliveEntry, RegisteredEntry};
fn compute_icmpv4_checksum(packet: &mut [u8]) {
packet[2] = 0;
packet[3] = 0;
let mut sum = 0u32;
for chunk in packet.chunks(2) {
let word = u16::from_be_bytes([chunk[0], *chunk.get(1).unwrap_or(&0)]);
sum = sum.wrapping_add(word as u32);
}
while (sum >> 16) > 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
let checksum = !(sum as u16);
packet[2..4].copy_from_slice(&checksum.to_be_bytes());
}
/// Reusable ICMP probe sockets. Created once at startup and shared across all
/// probe call-sites, avoiding the overhead of creating and destroying a raw
/// socket for every single probe packet.
pub(crate) struct Prober {
v6: Socket,
v4: Socket,
}
impl Prober {
pub fn new() -> std::io::Result<Self> {
let v6 = Socket::new(Domain::IPV6, Type::RAW, Some(Protocol::ICMPV6))?;
v6.set_nonblocking(true)?;
let v4 = Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::ICMPV4))?;
v4.set_nonblocking(true)?;
Ok(Self { v6, v4 })
}
pub fn send_icmpv6_echo(&self, addr: Ipv6Addr, ifindex: u32) -> std::io::Result<()> {
// Re-bind outgoing interface before each send (lightweight setsockopt).
self.v6.bind_device_by_index_v6(NonZeroU32::new(ifindex))?;
// ICMPv6 Echo Request: type=128, code=0, checksum=0 (kernel computes), id=0, seq=1
let packet: [u8; 8] = [128, 0, 0, 0, 0, 0, 0, 1];
let dest = SockAddr::from(std::net::SocketAddrV6::new(addr, 0, 0, 0));
self.v6.send_to(&packet, &dest)?;
Ok(())
}
pub fn send_icmpv4_echo(&self, addr: Ipv4Addr, ifindex: u32) -> std::io::Result<()> {
// Re-bind outgoing interface before each send (lightweight setsockopt).
self.v4.bind_device_by_index_v4(NonZeroU32::new(ifindex))?;
// ICMPv4 Echo Request: type=8, code=0, checksum (computed), id=0, seq=1
let mut packet: [u8; 8] = [8, 0, 0, 0, 0, 0, 0, 1];
compute_icmpv4_checksum(&mut packet);
let dest = SockAddr::from(std::net::SocketAddrV4::new(addr, 0));
self.v4.send_to(&packet, &dest)?;
Ok(())
}
}
/// Send ICMPv6/ICMPv4 Echo Requests to all registered neighbours that haven't been
/// confirmed recently. This forces the kernel NUD state machine to verify reachability,
/// generating NewNeighbour events with the resulting state (Reachable or Failed).
pub(crate) async fn probe_registered_neighbours(
prober: &Prober,
registered: &HashMap<(String, String), RegisteredEntry>,
) {
let now = Instant::now();
for ((_, ip_str), entry) in registered.iter() {
// Only probe entries not confirmed recently (older than 30s)
if now.duration_since(entry.last_confirmed) < Duration::from_secs(30) {
continue;
}
if let Ok(addr) = ip_str.parse::<Ipv6Addr>() {
if let Err(e) = prober.send_icmpv6_echo(addr, entry.ifindex) {
debug!("probe failed for {}: {}", ip_str, e);
}
} else if let Ok(addr) = ip_str.parse::<Ipv4Addr>() {
if let Err(e) = prober.send_icmpv4_echo(addr, entry.ifindex) {
debug!("probe failed for {}: {}", ip_str, e);
}
}
}
}
/// Send ICMPv6 Echo Requests to the newest GUA addresses per host for NUD keepalive.
/// Only the top `per_host` addresses (by most-recently-seen) are probed per MAC.
/// This does NOT publish any records to DNS.
pub(crate) fn probe_gua_keepalive(
prober: &Prober,
gua_keepalive: &HashMap<String, Vec<GuaKeepaliveEntry>>,
per_host: usize,
) {
let now = Instant::now();
for (mac, entries) in gua_keepalive.iter() {
// Sort indices by first_seen descending to pick the newest GUAs.
let mut indices: Vec<usize> = (0..entries.len()).collect();
indices.sort_by(|a, b| entries[*b].first_seen.cmp(&entries[*a].first_seen));
for &idx in indices.iter().take(per_host) {
let e = &entries[idx];
// Skip entries confirmed recently (within 30s)
if now.duration_since(e.last_confirmed) < Duration::from_secs(30) {
continue;
}
match prober.send_icmpv6_echo(e.addr, e.ifindex) {
Ok(()) => debug!("GUA keepalive probe: {} ({}) -> {}", e.hostname, mac, e.addr),
Err(err) => debug!("GUA keepalive probe failed for {}: {}", e.addr, err),
}
}
}
}
/// Remove GUA keepalive entries that haven't been confirmed REACHABLE within 3x the
/// keepalive interval, and drop excess entries beyond `per_host` (oldest first).
pub(crate) fn prune_gua_keepalive(
gua_keepalive: &mut HashMap<String, Vec<GuaKeepaliveEntry>>,
keepalive_interval: u64,
per_host: usize,
) {
let timeout = Duration::from_secs(keepalive_interval.saturating_mul(3));
let now = Instant::now();
gua_keepalive.retain(|_, entries| {
// Remove timed-out entries first.
entries.retain(|e| now.duration_since(e.last_confirmed) < timeout);
// Then keep only the newest `per_host` entries (by first_seen desc).
if entries.len() > per_host {
entries.sort_by(|a, b| b.first_seen.cmp(&a.first_seen));
entries.truncate(per_host);
}
!entries.is_empty()
});
}

View File

@ -1,275 +0,0 @@
use std::collections::HashMap;
use std::net::{IpAddr, Ipv6Addr};
use std::time::Instant;
use log::{debug, error, info, warn};
use netlink_packet_route::neighbour::NeighbourAddress;
use crate::db::DnsUpdater;
use crate::filter::{if_ipv4_in_private_subnet, if_ipv6_in_private_subnet, ipv6_in_prefix, is_gua_ipv6};
use crate::probe::Prober;
use crate::types::{LanPrefix, Neigh, RegisteredEntry, DEFAULT_TTL};
pub(crate) async fn process_new_neigh(
neigh: &Neigh,
updater: &DnsUpdater,
leases: &HashMap<String, String>,
private_subnet_v6: bool,
) -> bool {
let Some(hostname) = leases.get(&neigh.mac) else {
debug!("no lease for mac {}, skipping DNS update", neigh.mac);
return false;
};
// Guard: never publish GUA to DNS when private_subnet_v6 is set.
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if private_subnet_v6 && is_gua_ipv6(addr) {
debug!("skipping GUA DNS publish for {} (private_subnet_v6)", hostname);
return false;
}
}
let result = match &neigh.inet {
NeighbourAddress::Inet6(addr) => updater.upsert_aaaa(hostname, *addr, DEFAULT_TTL).await,
NeighbourAddress::Inet(addr) => updater.upsert_a(hostname, *addr, DEFAULT_TTL).await,
_ => return false,
};
match result {
Ok(()) => {
info!("DNS update: added {} -> {:?}", hostname, neigh.inet);
let ip = match &neigh.inet {
NeighbourAddress::Inet6(addr) => IpAddr::V6(*addr),
NeighbourAddress::Inet(addr) => IpAddr::V4(*addr),
_ => return true,
};
if let Err(e) = updater.upsert_ptr(ip, hostname, DEFAULT_TTL).await {
warn!("PTR update failed for {} ({}): {}", hostname, ip, e);
}
true
}
Err(e) => {
error!("DNS update failed for {}: {}", hostname, e);
false
}
}
}
/// Delete a specific DNS record directly by hostname and address.
pub(crate) async fn do_delete_dns(
hostname: &str,
inet: &NeighbourAddress,
updater: &DnsUpdater,
) -> bool {
let result = match inet {
NeighbourAddress::Inet6(addr) => updater.delete_aaaa(hostname, *addr).await,
NeighbourAddress::Inet(addr) => updater.delete_a(hostname, *addr).await,
_ => return true,
};
match result {
Ok(()) => {
info!("DNS update: removed {} -> {:?}", hostname, inet);
let ip = match inet {
NeighbourAddress::Inet6(addr) => IpAddr::V6(*addr),
NeighbourAddress::Inet(addr) => IpAddr::V4(*addr),
_ => return true,
};
if let Err(e) = updater.delete_ptr(ip).await {
warn!("PTR delete failed for {} ({}): {}", hostname, ip, e);
}
true
}
Err(e) => {
error!("DNS delete failed for {}: {}", hostname, e);
false
}
}
}
/// Enforce the per-host ULA AAAA limit. If `host` already has `max` or more ULA AAAA
/// records in `registered`, remove the oldest (by `last_confirmed`) and delete from DNS.
/// Returns the number of existing ULA AAAA records for this host (after pruning).
pub(crate) async fn prune_ula_for_host(
host: &str,
max: usize,
registered: &mut HashMap<(String, String), RegisteredEntry>,
updater: &DnsUpdater,
) -> usize {
// Collect existing ULA AAAA keys for this host.
let mut ula_keys: Vec<((String, String), Instant)> = registered
.iter()
.filter(|((h, ip_str), _)| {
h == host && ip_str.parse::<Ipv6Addr>().map_or(false, |a| if_ipv6_in_private_subnet(&a))
})
.map(|(k, e)| (k.clone(), e.last_confirmed))
.collect();
let initial_count = ula_keys.len();
if initial_count < max {
return initial_count;
}
// Sort oldest first (by last_confirmed asc).
ula_keys.sort_by_key(|(_, ts)| *ts);
// Remove excess (keep the newest `max - 1` so there's room for the new one).
let to_remove = initial_count - (max.saturating_sub(1));
let mut actually_removed = 0;
for (key, _) in ula_keys.into_iter().take(to_remove) {
if let Some(entry) = registered.remove(&key) {
let addr: Ipv6Addr = key.1.parse().unwrap();
let inet = NeighbourAddress::Inet6(addr);
do_delete_dns(&entry.hostname, &inet, updater).await;
info!("ULA pruning: removed oldest {} -> {} for host {}", entry.hostname, key.1, host);
actually_removed += 1;
}
}
// Return count after pruning (computed, no second scan needed).
initial_count - actually_removed
}
/// Reconcile the in-memory `registered` map against the live DNS zone obtained via AXFR.
///
/// Two corrections are made on each call:
/// 1. **DNS orphans** -- records present in DNS but absent from `registered`.
/// These are either stale leftovers from a previous run or records from a prefix that
/// is no longer active. Records that fail prefix/subnet filtering are deleted from DNS
/// immediately; records that pass filtering are probed with ICMP so the kernel NUD
/// state machine can confirm reachability and re-populate `registered` via the event loop.
/// 2. **Registered orphans** -- entries in `registered` that are missing from DNS (e.g.
/// because hickory-dns restarted and lost its in-memory state). These are re-pushed
/// via DNS UPDATE so the zone stays consistent.
pub(crate) async fn reconcile_dns(
updater: &DnsUpdater,
registered: &mut HashMap<(String, String), RegisteredEntry>,
leases: &HashMap<String, String>,
active_prefixes: &[LanPrefix],
private_subnet_v4: bool,
private_subnet_v6: bool,
prober: &Prober,
) {
use std::collections::{HashMap as Map, HashSet};
let dns_records = match updater.axfr_records().await {
Ok(r) => r,
Err(e) => {
warn!("AXFR reconciliation failed: {}", e);
return;
}
};
// Only consider records whose hostname appears in the lease table; this avoids
// accidentally touching manually-added records or the router's own addresses.
let lease_hostnames: HashSet<&str> = leases.values().map(String::as_str).collect();
// Map: ip_string -> hostname, for DNS records that pass all filters.
// Records that fail filtering are deleted from DNS here.
let mut dns_ips: Map<String, String> = Map::new();
for (hostname, ip) in &dns_records {
if !lease_hostnames.contains(hostname.as_str()) {
continue;
}
let passes = match ip {
IpAddr::V6(addr) => {
let subnet_ok = !private_subnet_v6 || if_ipv6_in_private_subnet(addr);
let prefix_ok = active_prefixes.is_empty()
|| active_prefixes.iter().any(|p| ipv6_in_prefix(*addr, p));
subnet_ok && prefix_ok
}
IpAddr::V4(addr) => !private_subnet_v4 || if_ipv4_in_private_subnet(addr),
};
if passes {
dns_ips.insert(ip.to_string(), hostname.clone());
} else {
// Stale record (wrong prefix / subnet) -- remove from DNS.
let result = match ip {
IpAddr::V6(addr) => updater.delete_aaaa(hostname, *addr).await,
IpAddr::V4(addr) => updater.delete_a(hostname, *addr).await,
};
match result {
Ok(()) => {
info!("reconcile: deleted stale DNS {} -> {}", hostname, ip);
if let Err(e) = updater.delete_ptr(*ip).await {
warn!("reconcile: PTR delete failed for stale {} {}: {}", hostname, ip, e);
}
}
Err(e) => warn!("reconcile: failed to delete stale DNS {} {}: {}", hostname, ip, e),
}
}
}
// Registered ip set for quick lookup.
let registered_ips: HashSet<&str> =
registered.keys().map(|(_, ip)| ip.as_str()).collect();
// --- DNS orphans (in DNS but not in registered) ---
for (ip_str, hostname) in &dns_ips {
if registered_ips.contains(ip_str.as_str()) {
continue;
}
info!("reconcile: DNS orphan {} -> {}, deleting and probing", hostname, ip_str);
let del_result = match ip_str.parse::<IpAddr>() {
Ok(IpAddr::V6(addr)) => updater.delete_aaaa(hostname, addr).await,
Ok(IpAddr::V4(addr)) => updater.delete_a(hostname, addr).await,
_ => continue,
};
match del_result {
Ok(()) => {
info!("reconcile: deleted orphan DNS {} -> {}", hostname, ip_str);
if let Ok(ip) = ip_str.parse::<IpAddr>() {
if let Err(e) = updater.delete_ptr(ip).await {
warn!("reconcile: PTR delete failed for orphan {} {}: {}", hostname, ip_str, e);
}
}
}
Err(e) => warn!("reconcile: failed to delete orphan {} {}: {}", hostname, ip_str, e),
}
// Probe so alive devices trigger a REACHABLE event and re-register.
match ip_str.parse::<IpAddr>() {
Ok(IpAddr::V6(addr)) => {
if let Err(e) = prober.send_icmpv6_echo(addr, 0) {
debug!("reconcile: probe failed for {}: {}", addr, e);
}
}
Ok(IpAddr::V4(addr)) => {
if let Err(e) = prober.send_icmpv4_echo(addr, 0) {
debug!("reconcile: probe failed for {}: {}", addr, e);
}
}
_ => {}
}
}
// --- Registered orphans (in registered but not in DNS) ---
for ((_, ip_str), entry) in registered.iter_mut() {
if dns_ips.contains_key(ip_str.as_str()) {
continue;
}
let hostname = &entry.hostname;
debug!("reconcile: registered orphan {} -> {}, re-pushing", hostname, ip_str);
let result = match ip_str.parse::<IpAddr>() {
Ok(IpAddr::V6(addr)) => updater.upsert_aaaa(hostname, addr, DEFAULT_TTL).await,
Ok(IpAddr::V4(addr)) => updater.upsert_a(hostname, addr, DEFAULT_TTL).await,
_ => continue,
};
match result {
Ok(()) => {
info!("reconcile: re-pushed {} -> {}", hostname, ip_str);
if let Ok(ip) = ip_str.parse::<IpAddr>() {
if let Err(e) = updater.upsert_ptr(ip, hostname, DEFAULT_TTL).await {
warn!("reconcile: PTR re-push failed for {} {}: {}", hostname, ip_str, e);
}
}
entry.last_confirmed = Instant::now();
}
Err(e) => warn!("reconcile: failed to re-push {} {}: {}", hostname, ip_str, e),
}
}
}

View File

@ -1,59 +0,0 @@
use std::fmt::Write;
use std::net::Ipv6Addr;
use std::time::Instant;
use netlink_packet_route::neighbour::{NeighbourAddress, NeighbourState};
use netlink_packet_route::route::RouteType;
pub(crate) const RTNLGRP_NEIGH: u32 = 3;
pub(crate) const DEFAULT_TTL: u32 = 60;
pub(crate) const fn nl_mgrp(group: u32) -> u32 {
if group > 31 {
panic!("use netlink_sys::Socket::add_membership() for this group");
}
if group == 0 { 0 } else { 1 << (group - 1) }
}
/// Format raw MAC bytes as colon-separated hex string, e.g. "44:23:7c:dc:b7:5b".
/// Shared across modules to avoid duplicate formatting logic.
pub(crate) fn format_mac(mac: &[u8]) -> String {
let mut s = String::with_capacity(mac.len() * 3);
for (i, byte) in mac.iter().enumerate() {
if i > 0 { s.push(':'); }
write!(s, "{:02x}", byte).unwrap();
}
s
}
#[derive(Debug)]
pub(crate) struct Neigh {
pub(crate) ifindex: u32,
pub(crate) state: NeighbourState,
pub(crate) kind: RouteType,
pub(crate) inet: NeighbourAddress,
pub(crate) mac: String,
}
/// An IPv6 prefix (address + length) representing an active LAN prefix on the router interface.
pub(crate) struct LanPrefix {
pub(crate) addr: Ipv6Addr,
pub(crate) prefix_len: u8,
}
/// State tracked for each (mac, ip) pair that has been successfully registered in DNS.
pub(crate) struct RegisteredEntry {
pub(crate) hostname: String,
pub(crate) last_confirmed: Instant,
pub(crate) ifindex: u32,
}
/// A GUA address tracked for keepalive probing only (not published to DNS).
pub(crate) struct GuaKeepaliveEntry {
pub(crate) hostname: String,
pub(crate) addr: Ipv6Addr,
pub(crate) ifindex: u32,
/// When this GUA was first observed -- used to select the "newest" address per host.
pub(crate) first_seen: Instant,
/// Last time this address was confirmed REACHABLE by the kernel.
pub(crate) last_confirmed: Instant,
}

View File

@ -263,14 +263,14 @@ header h3 a {
font-weight: bold;
}
.cbi-value[data-widget="CBI.FlagValue"]:has(> .cbi-value-field > .cbi-checkbox) {
.cbi-value:not(.hidden)[data-widget="CBI.FlagValue"]:has(> .cbi-value-field > .cbi-checkbox) {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
column-gap: 10px;
}
.cbi-value[data-widget="CBI.FlagValue"]:has(> .cbi-value-field > .cbi-checkbox) > label.cbi-value-title {
.cbi-value:not(.hidden)[data-widget="CBI.FlagValue"]:has(> .cbi-value-field > .cbi-checkbox) > label.cbi-value-title {
width: auto;
max-width: none;
padding: 0;
@ -280,15 +280,15 @@ header h3 a {
white-space: normal;
}
.cbi-value[data-widget="CBI.FlagValue"]:has(> .cbi-value-field > .cbi-checkbox) > .cbi-value-field {
.cbi-value:not(.hidden)[data-widget="CBI.FlagValue"]:has(> .cbi-value-field > .cbi-checkbox) > .cbi-value-field {
display: contents;
}
.cbi-value[data-widget="CBI.FlagValue"]:has(> .cbi-value-field > .cbi-checkbox) > .cbi-value-field > .cbi-checkbox {
.cbi-value:not(.hidden)[data-widget="CBI.FlagValue"]:has(> .cbi-value-field > .cbi-checkbox) > .cbi-value-field > .cbi-checkbox {
grid-column: 2;
}
.cbi-value[data-widget="CBI.FlagValue"]:has(> .cbi-value-field > .cbi-checkbox) > .cbi-value-field > .cbi-value-description {
.cbi-value:not(.hidden)[data-widget="CBI.FlagValue"]:has(> .cbi-value-field > .cbi-checkbox) > .cbi-value-field > .cbi-value-description {
grid-column: 1 / -1;
width: auto;
max-width: calc(100% - 1.25em);
@ -296,6 +296,10 @@ header h3 a {
margin: .25em 0 0 1.25em;
}
.cbi-value.hidden {
display: none !important;
}
.cbi-value-field,
.cbi-select,
.cbi-dynlist,