mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-07-27 18:41:15 +08:00
🎄 Sync 2026-06-03 00:48:32
This commit is contained in:
parent
bd809f8479
commit
4efb3994e1
@ -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)?
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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 } }
|
||||
]
|
||||
@ -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.
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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"
|
||||
) &
|
||||
@ -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
|
||||
}
|
||||
@ -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
1512
ipv6-neigh/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
@ -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))
|
||||
@ -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())
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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, ®istered).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(())
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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()
|
||||
});
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user