🌴 Sync 2026-06-02 21:18:52

This commit is contained in:
github-actions[bot] committed 2026-06-02 21:18:52 +08:00
1 parent e4571a1c21
commit ce89c56dda
25 files changed
+3094 -2893

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=ipv6-neigh
PKG_VERSION:=0.1.0
PKG_RELEASE:=18
PKG_RELEASE:=19
PKG_BUILD_DEPENDS:=rust/host
PKG_BUILD_PARALLEL:=1
+138 -107
View File
@@ -1,3 +1,4 @@
use std::cell::RefCell;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -8,6 +9,8 @@ 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>> {
@@ -20,6 +23,10 @@ fn check_response(response: &Message, allowed: &[ResponseCode]) -> Result<(), Bo
}
/// 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,
@@ -28,11 +35,20 @@ pub(crate) struct DnsUpdater {
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 }
Self {
server_addr,
zone,
signer,
ipv4_ptr_zones: vec![],
ula_ptr_zone: None,
conn: RefCell::new(None),
}
}
/// Configure reverse PTR zones.
@@ -49,6 +65,74 @@ impl DnsUpdater {
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,
@@ -61,20 +145,18 @@ impl DnsUpdater {
let Some(zone) = self.find_ipv4_ptr_zone(ip).cloned() else { return Ok(()) };
(ipv4_ptr_name(ip), zone)
}
IpAddr::V6(ip) if (ip.segments()[0] & 0xfe00) == 0xfc00 => {
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)));
@@ -94,7 +176,7 @@ impl DnsUpdater {
let Some(zone) = self.find_ipv4_ptr_zone(ip).cloned() else { return Ok(()) };
(ipv4_ptr_name(ip), zone)
}
IpAddr::V6(ip) if (ip.segments()[0] & 0xfe00) == 0xfc00 => {
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)
}
@@ -120,118 +202,72 @@ impl DnsUpdater {
.map(|(_, _, name)| name)
}
/// Create or append a AAAA record.
pub async fn upsert_aaaa(
&self,
hostname: &str,
addr: Ipv6Addr,
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(), RecordType::AAAA, ttl);
rrset.add_rdata(RData::AAAA(addr.into()));
// -- TCP transport (connection-reusing) --
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, RecordType::AAAA, ttl);
rrset.add_rdata(RData::AAAA(addr.into()));
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(())
}
/// Create or append an A record.
pub async fn upsert_a(
&self,
hostname: &str,
addr: Ipv4Addr,
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(), RecordType::A, ttl);
rrset.add_rdata(RData::A(addr.into()));
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, RecordType::A, ttl);
rrset.add_rdata(RData::A(addr.into()));
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 specific AAAA record.
pub async fn delete_aaaa(
&self,
hostname: &str,
addr: Ipv6Addr,
) -> Result<(), Box<dyn std::error::Error>> {
let name = Name::from_ascii(hostname)?.append_domain(&self.zone)?;
let mut rrset = RecordSet::new(name, RecordType::AAAA, 0);
rrset.add_rdata(RData::AAAA(addr.into()));
let msg = update_message::delete_by_rdata(rrset, self.zone.clone(), false);
let response = self.send_tcp(msg).await?;
check_response(&response, &[])?;
Ok(())
}
/// Delete a specific A record.
pub async fn delete_a(
&self,
hostname: &str,
addr: Ipv4Addr,
) -> Result<(), Box<dyn std::error::Error>> {
let name = Name::from_ascii(hostname)?.append_domain(&self.zone)?;
let mut rrset = RecordSet::new(name, RecordType::A, 0);
rrset.add_rdata(RData::A(addr.into()));
let msg = update_message::delete_by_rdata(rrset, self.zone.clone(), false);
let response = self.send_tcp(msg).await?;
check_response(&response, &[])?;
Ok(())
}
/// Send a DNS message over TCP (2-byte length prefix + message bytes) and read the response.
/// 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 = u16::try_from(bytes.len())?;
let len_bytes = u16::try_from(bytes.len())?.to_be_bytes();
let result = timeout(TCP_TIMEOUT, async {
let mut stream = TcpStream::connect(self.server_addr).await?;
stream.write_all(&len.to_be_bytes()).await?;
stream.write_all(&bytes).await?;
// 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 connecting to {}", TCP_TIMEOUT.as_secs(), self.server_addr).into()
})??;
Ok(result)
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"`).
/// 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.
@@ -241,9 +277,9 @@ impl DnsUpdater {
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;
@@ -264,11 +300,9 @@ impl DnsUpdater {
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(_) => {
@@ -287,25 +321,22 @@ impl DnsUpdater {
_ => {}
}
}
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.`
/// 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]))
@@ -341,7 +372,7 @@ fn ipv4_zone_name(net: Ipv4Addr, prefix_len: u8) -> Name {
/// Extract the single label that precedes the zone apex from a fully-qualified record name.
///
/// e.g. `"foo.lan."` with zone `"lan."` → `Some("foo")`.
/// 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();
+19 -8
View File
@@ -1,10 +1,14 @@
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 {
// Check if address is ULA (fc00::/7)
// Note: link-local is always filtered earlier by is_link_local_ipv6()
(ip.segments()[0] & 0xfe00) == 0xfc00
}
@@ -13,29 +17,36 @@ 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
}
+37 -36
View File
@@ -1,5 +1,4 @@
use std::net::{IpAddr, SocketAddr};
use futures::stream::TryStreamExt;
use log::{info, warn};
use netlink_packet_route::address::{AddressAttribute, AddressFlags, AddressHeaderFlags, AddressMessage};
@@ -7,6 +6,7 @@ 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).
@@ -24,6 +24,28 @@ pub(crate) fn is_addr_deprecated(msg: &AddressMessage) -> bool {
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(
@@ -31,29 +53,20 @@ pub(crate) async fn get_active_lan_prefixes(
iface: &str,
private_subnet_v6: bool,
) -> Vec<LanPrefix> {
let mut links = handle.link().get().match_name(iface.to_owned()).execute();
let link = match links.try_next().await {
Ok(Some(l)) => l,
_ => return vec![],
let (_ifindex, msgs) = match get_iface_addresses(handle, iface).await {
Ok(v) => v,
Err(_) => return vec![],
};
let ifindex = link.header.index;
let mut addresses = handle.address().get().set_link_index_filter(ifindex).execute();
let mut prefixes = Vec::new();
while let Ok(Some(msg)) = addresses.try_next().await {
for msg in &msgs {
let prefix_len = msg.header.prefix_len;
// Skip deprecated addresses; they are no longer used for new connections
// and should not be treated as active LAN prefixes.
if is_addr_deprecated(&msg) {
if is_addr_deprecated(msg) {
continue;
}
for attr in &msg.attributes {
if let AddressAttribute::Address(IpAddr::V6(addr)) = attr {
// Skip link-local
if (addr.segments()[0] & 0xffc0) == 0xfe80 {
continue;
}
let is_ula = (addr.segments()[0] & 0xfe00) == 0xfc00;
if private_subnet_v6 && !is_ula {
if should_skip_v6(addr, private_subnet_v6) {
continue;
}
prefixes.push(LanPrefix { addr: *addr, prefix_len });
@@ -72,22 +85,16 @@ pub(crate) async fn register_router_addresses(
updater: &DnsUpdater,
private_subnet_v6: bool,
) {
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) => {
warn!("interface {} not found, skipping router address registration", iface);
return;
}
let (_ifindex, msgs) = match get_iface_addresses(handle, iface).await {
Ok(v) => v,
Err(e) => {
warn!("failed to find interface {}: {}", iface, e);
warn!("{}, skipping router address registration", e);
return;
}
};
let ifindex = link.header.index;
let mut addresses = handle.address().get().set_link_index_filter(ifindex).execute();
while let Ok(Some(msg)) = addresses.try_next().await {
if is_addr_deprecated(&msg) {
for msg in &msgs {
if is_addr_deprecated(msg) {
continue;
}
for attr in &msg.attributes {
@@ -97,13 +104,7 @@ pub(crate) async fn register_router_addresses(
};
match ip {
IpAddr::V6(addr) => {
// Always skip link-local
if (addr.segments()[0] & 0xffc0) == 0xfe80 {
continue;
}
// Restrict to ULA only when private_subnet_v6 is set
let is_ula = (addr.segments()[0] & 0xfe00) == 0xfc00;
if private_subnet_v6 && !is_ula {
if should_skip_v6(addr, private_subnet_v6) {
continue;
}
for name in std::iter::once(hostname).chain(extra_alias) {
+166 -242
View File
@@ -1,10 +1,10 @@
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr as StdSocketAddr};
use std::net::{Ipv4Addr, SocketAddr as StdSocketAddr};
use std::time::Instant;
use futures::stream::StreamExt;
use futures::stream::TryStreamExt;
use log::{debug, error, info, warn};
use log::{debug, info, warn};
use netlink_packet_core::NetlinkPayload;
use netlink_packet_route::RouteNetlinkMessage;
use netlink_packet_route::neighbour::NeighbourMessage;
@@ -29,10 +29,10 @@ 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::{probe_gua_keepalive, probe_registered_neighbours, prune_gua_keepalive, send_icmpv4_echo, send_icmpv6_echo};
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::{
DEFAULT_TTL, RTNLGRP_NEIGH, GuaKeepaliveEntry, Neigh, RegisteredEntry, nl_mgrp,
RTNLGRP_NEIGH, GuaKeepaliveEntry, Neigh, RegisteredEntry, format_mac, nl_mgrp,
};
#[derive(Debug, Parser)]
@@ -41,54 +41,68 @@ 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")
/// 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)
}
@@ -103,7 +117,6 @@ fn is_failed_state(state: NeighbourState) -> bool {
}
/// Parse an IPv4 CIDR string like "192.168.3.0/24" into (network_addr, prefix_len).
/// Panics with a clear message on invalid input.
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\"")
@@ -112,12 +125,125 @@ fn parse_ipv4_cidr(s: &str) -> (Ipv4Addr, u8) {
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")
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();
@@ -128,8 +254,6 @@ async fn main() -> Result<(), ()> {
.parse()
.expect("invalid log level (use: error, warn, info, debug, trace)"),
)
// Suppress spurious warnings from netlink_packet_route when the kernel
// provides more NLA data than the crate version expects (newer kernel).
.filter_module("netlink_packet_route", log::LevelFilter::Error)
.format_timestamp_secs()
.init();
@@ -140,8 +264,8 @@ async fn main() -> Result<(), ()> {
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 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");
@@ -152,20 +276,24 @@ async fn main() -> Result<(), ()> {
.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();
// Subscribe to neighbour events on the same connection used for queries.
// This avoids a second netlink socket and spawned task.
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
@@ -189,7 +317,6 @@ async fn main() -> Result<(), ()> {
}
// Load DHCP leases (mac -> hostname) from ubus.
// Use spawn_blocking: op::get_lease() makes a synchronous ubus call.
let mut leases: HashMap<String, String> = tokio::task::spawn_blocking(|| {
op::get_lease().unwrap_or_default()
})
@@ -197,17 +324,9 @@ async fn main() -> Result<(), ()> {
.unwrap_or_default();
info!("loaded {} DHCP leases", leases.len());
// State cache: tracks (mac, ip_string) -> registered entry (hostname, last confirmed time, ifindex)
let mut registered: HashMap<(String, String), RegisteredEntry> = HashMap::new();
// GUA keepalive: tracks GUA addresses per MAC for periodic probing (not published to DNS).
// Key = MAC address, value = list of GUA entries for that host.
let mut gua_keepalive: HashMap<String, Vec<GuaKeepaliveEntry>> = HashMap::new();
// Get active LAN prefixes to filter out neighbours from expired/old prefixes.
// If prefix detection fails we log a warning but continue without filtering.
// When keepalive_gua is enabled, also include GUA prefixes so we can filter
// keepalive targets against active prefixes (pass private_subnet_v6=false).
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() {
@@ -223,13 +342,7 @@ async fn main() -> Result<(), ()> {
);
}
// Dump existing neighbours:
// - Skip entries whose IP is not within any active LAN prefix (old-prefix residuals).
// - Route GUA addresses to keepalive map (if enabled) instead of DNS.
// - Register REACHABLE entries immediately.
// - Send a probe to STALE/DELAY/PROBE entries; the event loop will register them
// once the kernel confirms they are REACHABLE.
// - Ignore FAILED and other states.
// Dump existing neighbours
debug!("dumping neighbours");
if let Ok(neighbours) = dump_neighbours(handle.clone(), private_subnet_v4).await {
for neigh in &neighbours {
@@ -237,10 +350,10 @@ async fn main() -> Result<(), ()> {
if should_skip_neigh(neigh) {
continue;
}
// Drop IPv6 neighbours that are not within any active LAN prefix.
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);
debug!("init dump: skipping {} -- not in any active LAN prefix", addr);
continue;
}
}
@@ -265,15 +378,12 @@ async fn main() -> Result<(), ()> {
}
}
} else if matches!(neigh.state, NeighbourState::Stale | NeighbourState::Delay | NeighbourState::Probe) {
// Probe stale GUA so the kernel NUD state machine can confirm
// reachability; the event loop will add it to gua_keepalive on REACHABLE.
match send_icmpv6_echo(*addr, neigh.ifindex) {
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),
}
}
}
// GUA addresses are never published to DNS (unless --publish-gua)
if private_subnet_v6 {
continue;
}
@@ -283,95 +393,53 @@ async fn main() -> Result<(), ()> {
let ip_str = inet_to_string(&neigh.inet);
match neigh.state {
NeighbourState::Reachable => {
// Confirmed online — register in DNS immediately.
if let Some(hostname) = leases.get(&neigh.mac) {
let key = (hostname.clone(), ip_str.clone());
if !registered.contains_key(&key) {
// Enforce per-host ULA limit before adding a new one.
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if if_ipv6_in_private_subnet(addr) {
prune_ula_for_host(hostname, max_ula_per_host, &mut registered, &updater).await;
}
}
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,
_ => Ok(()),
};
match result {
Ok(()) => {
info!("DNS update: added {} -> {:?}", hostname, neigh.inet);
let ip = match &neigh.inet {
NeighbourAddress::Inet6(addr) => Some(IpAddr::V6(*addr)),
NeighbourAddress::Inet(addr) => Some(IpAddr::V4(*addr)),
_ => None,
};
if let Some(ip) = ip {
if let Err(e) = updater.upsert_ptr(ip, hostname, DEFAULT_TTL).await {
warn!("PTR update failed for {}: {}", hostname, e);
}
}
registered.insert(key, RegisteredEntry {
hostname: hostname.clone(),
last_confirmed: Instant::now(),
ifindex: neigh.ifindex,
});
}
Err(e) => error!("DNS update failed for {}: {}", hostname, e),
}
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 => {
// Uncertain — probe to trigger NUD verification. If the device
// is online, the kernel will emit a REACHABLE NewNeighbour event
// which the event loop will pick up and register in DNS.
match &neigh.inet {
NeighbourAddress::Inet6(addr) => {
match send_icmpv6_echo(*addr, neigh.ifindex) {
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) = send_icmpv4_echo(*addr, neigh.ifindex) {
if let Err(e) = prober.send_icmpv4_echo(*addr, neigh.ifindex) {
debug!("init probe failed for {}: {}", addr, e);
}
}
_ => {}
}
}
_ => {
// FAILED and others — skip.
}
_ => {}
}
}
}
// (netlink event socket already set up above — reuse the same connection)
// Start periodic probing task
let probe_interval = args.probe_interval;
let mut probe_timer = if probe_interval > 0 {
time::interval(Duration::from_secs(probe_interval))
} else {
// Create a very long interval that effectively disables probing
time::interval(Duration::from_secs(u64::MAX / 2))
};
probe_timer.tick().await; // consume the immediate first tick
probe_timer.tick().await;
// GUA keepalive timer (separate cadence from DNS probe timer)
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; // consume the immediate first tick
gua_keepalive_timer.tick().await;
let mut lease_refresh_timer = time::interval(Duration::from_secs(60));
lease_refresh_timer.tick().await; // consume the immediate first tick
lease_refresh_timer.tick().await;
loop {
tokio::select! {
@@ -379,7 +447,6 @@ async fn main() -> Result<(), ()> {
let Some((message, _)) = msg_opt else {
break;
};
let payload = message.payload;
if let NetlinkPayload::InnerMessage(msg) = payload {
match msg {
@@ -390,15 +457,14 @@ async fn main() -> Result<(), ()> {
if should_skip_neigh(&neigh) {
continue;
}
// Drop IPv6 events for addresses not in any active LAN prefix.
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);
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;
}
// Also remove from GUA keepalive if present
if let Some(entries) = gua_keepalive.get_mut(&neigh.mac) {
entries.retain(|e| e.addr != *addr);
}
@@ -409,7 +475,6 @@ async fn main() -> Result<(), ()> {
// Route GUA addresses to keepalive map instead of DNS.
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if is_gua_ipv6(addr) && private_subnet_v6 {
// GUA not published to DNS — handle keepalive tracking only
if keepalive_gua {
if is_failed_state(neigh.state) {
if let Some(entries) = gua_keepalive.get_mut(&neigh.mac) {
@@ -435,7 +500,6 @@ async fn main() -> Result<(), ()> {
}
}
} else if matches!(neigh.state, NeighbourState::Stale | NeighbourState::Delay | NeighbourState::Probe) {
// Update ifindex only
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;
@@ -448,20 +512,17 @@ async fn main() -> Result<(), ()> {
}
let ip_str = inet_to_string(&neigh.inet);
if is_failed_state(neigh.state) {
// Neighbour confirmed unreachable — remove DNS record.
// Resolve key by hostname (handles MAC changes: new MAC in leases
// can now evict entries registered under an old MAC).
// Fall back to scanning registered by IP if MAC is no longer in leases.
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 {
// DNS delete failed, put back so we retry
registered.insert(key, entry);
}
} else {
@@ -474,34 +535,16 @@ async fn main() -> Result<(), ()> {
if let Some(hostname) = leases.get(&neigh.mac) {
let key = (hostname.clone(), ip_str.clone());
if let Some(entry) = registered.get_mut(&key) {
// Already registered, just update timestamp and ifindex
entry.last_confirmed = Instant::now();
entry.ifindex = neigh.ifindex;
} else {
// New reachable neighbour — register in DNS
debug!("New neighbour: {:?}", neigh);
// 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, &mut registered, &updater).await;
}
}
if process_new_neigh(&neigh, &updater, &leases, private_subnet_v6).await {
registered.insert(key, RegisteredEntry {
hostname: hostname.clone(),
last_confirmed: Instant::now(),
ifindex: neigh.ifindex,
});
}
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) {
// Uncertain state: update ifindex only so the next probe uses
// the right interface, but do NOT refresh last_confirmed —
// STALE/DELAY/PROBE is not confirmed reachable and refreshing
// the timestamp would suppress the periodic probe.
if let Some(hostname) = leases.get(&neigh.mac) {
let key = (hostname.clone(), ip_str.clone());
if let Some(entry) = registered.get_mut(&key) {
@@ -517,7 +560,7 @@ async fn main() -> Result<(), ()> {
if should_skip_neigh(&neigh) {
continue;
}
// Remove from GUA keepalive if applicable
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if is_gua_ipv6(addr) && keepalive_gua {
if let Some(entries) = gua_keepalive.get_mut(&neigh.mac) {
@@ -529,12 +572,13 @@ async fn main() -> Result<(), ()> {
}
}
}
let ip_str = inet_to_string(&neigh.inet);
// Resolve key by hostname; fall back to scanning by IP if MAC gone from leases.
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);
@@ -549,7 +593,7 @@ async fn main() -> Result<(), ()> {
if probe_interval == 0 {
continue;
}
probe_registered_neighbours(&registered).await;
probe_registered_neighbours(&prober, &registered).await;
reconcile_dns(
&updater,
&mut registered,
@@ -557,14 +601,10 @@ async fn main() -> Result<(), ()> {
&active_prefixes,
private_subnet_v4,
private_subnet_v6,
&prober,
)
.await;
// Recover kernel orphans: neighbours that are REACHABLE in the kernel
// but absent from `registered` and DNS. This happens when a netlink
// NewNeighbour event is lost (receive-buffer overflow) or when a device
// transitions to REACHABLE during the startup race window.
// reconcile_dns's AXFR pass cannot catch these because the record is
// also absent from DNS, so no probe is ever triggered for them.
if let Ok(neighbours) = dump_neighbours(handle.clone(), private_subnet_v4).await {
let all_dump_ips: std::collections::HashSet<String> = neighbours
.iter()
@@ -575,11 +615,7 @@ async fn main() -> Result<(), ()> {
if should_skip_neigh(neigh) {
continue;
}
if is_failed_state(neigh.state) {
// Kernel confirms FAILED — remove from registered + DNS if present.
// Mirrors the real-time NewNeighbour(FAILED) handler but catches
// cases where that event was lost.
let ip_str = inet_to_string(&neigh.inet);
let key_opt: Option<(String, String)> = leases
.get(&neigh.mac)
@@ -600,7 +636,6 @@ async fn main() -> Result<(), ()> {
}
continue;
}
if neigh.state != NeighbourState::Reachable {
continue;
}
@@ -618,43 +653,16 @@ async fn main() -> Result<(), ()> {
let Some(hostname) = leases.get(&neigh.mac) else {
continue;
};
let key = (hostname.clone(), ip_str);
let key = (hostname.clone(), ip_str.clone());
if let Some(entry) = registered.get_mut(&key) {
// Already tracked — refresh to suppress spurious probes.
entry.last_confirmed = Instant::now();
entry.ifindex = neigh.ifindex;
} else {
debug!("reconcile neigh: kernel orphan {} -> {:?}", hostname, neigh.inet);
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if if_ipv6_in_private_subnet(addr) {
prune_ula_for_host(
hostname,
max_ula_per_host,
&mut registered,
&updater,
)
.await;
}
}
if process_new_neigh(neigh, &updater, &leases, private_subnet_v6).await {
registered.insert(
key,
RegisteredEntry {
hostname: hostname.clone(),
last_confirmed: Instant::now(),
ifindex: neigh.ifindex,
},
);
}
try_register_neigh(neigh, hostname, &ip_str, &mut registered, &leases, &updater, max_ula_per_host, private_subnet_v6).await;
}
}
// Sweep registered entries whose IP the kernel has evicted entirely
// (not present in the dump at all, not even as FAILED) AND that haven't
// been confirmed recently. These are devices whose FAILED event was
// lost and which the kernel has since garbage-collected from its table.
// Only act if the entry is old enough (> 2× probe interval) to avoid
// racing with normal STALE→REACHABLE transitions.
let stale_cutoff = Duration::from_secs(probe_interval.saturating_mul(2));
let now = Instant::now();
let to_remove: Vec<(String, String)> = registered
@@ -688,7 +696,7 @@ async fn main() -> Result<(), ()> {
if !keepalive_gua || keepalive_gua_interval == 0 {
continue;
}
probe_gua_keepalive(&gua_keepalive, keepalive_gua_per_host);
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() => {
@@ -705,87 +713,3 @@ async fn main() -> Result<(), ()> {
}
Ok(())
}
fn format_mac(mac: Vec<u8>) -> String {
let mut mac_str = String::new();
for byte in mac {
mac_str.push_str(&format!("{:02x}:", byte));
}
mac_str.pop();
mac_str
}
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_ipv6(addr: &NeighbourAddress) -> bool {
matches!(addr, NeighbourAddress::Inet6(ip) if (ip.segments()[0] & 0xffc0) == 0xfe80)
}
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_ipv6(&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)
}
+15 -16
View File
@@ -4,21 +4,21 @@ 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)
}
/// Convert a hex MAC string like "44237cdcb75b" to colon-separated "44:23:7c:dc:b7:5b"
fn format_mac_from_hex(hex: &str) -> String {
hex.as_bytes()
/// 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(|chunk| std::str::from_utf8(chunk).ok())
.collect::<Vec<&str>>()
.join(":")
.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.
@@ -53,7 +53,9 @@ fn mac_from_duid(duid: &str) -> Option<String> {
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 {
@@ -61,14 +63,9 @@ fn mac_from_duid(duid: &str) -> Option<String> {
3 => 8,
_ => return None,
};
let mac_hex = duid.get(mac_offset..mac_offset + 12)?;
let mac = mac_hex
.as_bytes()
.chunks(2)
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect::<Vec<&str>>()
.join(":");
Some(mac)
Some(hex_to_mac(mac_hex))
}
// get mac to hostname mapping
@@ -77,14 +74,16 @@ pub fn get_lease() -> Result<HashMap<String, String>, Box<dyn std::error::Error>
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 = format_mac_from_hex(raw_mac);
let mac = hex_to_mac(raw_mac);
let hostname = sanitize_hostname(hostname);
if hostname.is_empty() {
continue;
+45 -34
View File
@@ -9,14 +9,6 @@ use tokio::time::Duration;
use crate::types::{GuaKeepaliveEntry, RegisteredEntry};
fn set_ipv6_unicast_if(socket: &Socket, ifindex: u32) -> std::io::Result<()> {
socket.bind_device_by_index_v6(NonZeroU32::new(ifindex))
}
fn set_ip_unicast_if(socket: &Socket, ifindex: u32) -> std::io::Result<()> {
socket.bind_device_by_index_v4(NonZeroU32::new(ifindex))
}
fn compute_icmpv4_checksum(packet: &mut [u8]) {
packet[2] = 0;
packet[3] = 0;
@@ -32,36 +24,54 @@ fn compute_icmpv4_checksum(packet: &mut [u8]) {
packet[2..4].copy_from_slice(&checksum.to_be_bytes());
}
pub(crate) fn send_icmpv6_echo(addr: Ipv6Addr, ifindex: u32) -> std::io::Result<()> {
let socket = Socket::new(Domain::IPV6, Type::RAW, Some(Protocol::ICMPV6))?;
socket.set_nonblocking(true)?;
// Bind outgoing packet to the specific interface via IPV6_UNICAST_IF so the
// kernel NUD state machine updates the correct neighbour entry.
set_ipv6_unicast_if(&socket, ifindex)?;
// ICMPv6 Echo Request: type=128, code=0, checksum=0 (kernel computes for RAW ICMPV6), 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));
socket.send_to(&packet, &dest)?;
Ok(())
/// 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,
}
pub(crate) fn send_icmpv4_echo(addr: Ipv4Addr, ifindex: u32) -> std::io::Result<()> {
let socket = Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::ICMPV4))?;
socket.set_nonblocking(true)?;
// Bind outgoing packet to the specific interface via IP_UNICAST_IF.
set_ip_unicast_if(&socket, 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));
socket.send_to(&packet, &dest)?;
Ok(())
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();
@@ -71,11 +81,11 @@ pub(crate) async fn probe_registered_neighbours(
continue;
}
if let Ok(addr) = ip_str.parse::<Ipv6Addr>() {
if let Err(e) = send_icmpv6_echo(addr, entry.ifindex) {
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) = send_icmpv4_echo(addr, entry.ifindex) {
if let Err(e) = prober.send_icmpv4_echo(addr, entry.ifindex) {
debug!("probe failed for {}: {}", ip_str, e);
}
}
@@ -86,6 +96,7 @@ pub(crate) async fn probe_registered_neighbours(
/// 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,
) {
@@ -101,7 +112,7 @@ pub(crate) fn probe_gua_keepalive(
if now.duration_since(e.last_confirmed) < Duration::from_secs(30) {
continue;
}
match send_icmpv6_echo(e.addr, e.ifindex) {
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),
}
@@ -109,7 +120,7 @@ pub(crate) fn probe_gua_keepalive(
}
}
/// Remove GUA keepalive entries that haven't been confirmed REACHABLE within 3× the
/// 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>>,
+22 -22
View File
@@ -7,7 +7,7 @@ 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::{send_icmpv4_echo, send_icmpv6_echo};
use crate::probe::Prober;
use crate::types::{LanPrefix, Neigh, RegisteredEntry, DEFAULT_TTL};
pub(crate) async fn process_new_neigh(
@@ -20,6 +20,7 @@ pub(crate) async fn process_new_neigh(
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) {
@@ -27,11 +28,13 @@ pub(crate) async fn process_new_neigh(
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);
@@ -63,6 +66,7 @@ pub(crate) async fn do_delete_dns(
NeighbourAddress::Inet(addr) => updater.delete_a(hostname, *addr).await,
_ => return true,
};
match result {
Ok(()) => {
info!("DNS update: removed {} -> {:?}", hostname, inet);
@@ -101,42 +105,40 @@ pub(crate) async fn prune_ula_for_host(
.map(|(k, e)| (k.clone(), e.last_confirmed))
.collect();
if ula_keys.len() < max {
return ula_keys.len();
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 = ula_keys.len() - (max.saturating_sub(1));
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.
registered
.iter()
.filter(|((h, ip_str), _)| {
h == host && ip_str.parse::<Ipv6Addr>().map_or(false, |a| if_ipv6_in_private_subnet(&a))
})
.count()
// 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`.
/// 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.
/// 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(
@@ -146,6 +148,7 @@ pub(crate) async fn reconcile_dns(
active_prefixes: &[LanPrefix],
private_subnet_v4: bool,
private_subnet_v6: bool,
prober: &Prober,
) {
use std::collections::{HashMap as Map, HashSet};
@@ -182,7 +185,7 @@ pub(crate) async fn reconcile_dns(
if passes {
dns_ips.insert(ip.to_string(), hostname.clone());
} else {
// Stale record (wrong prefix / subnet) — remove from DNS.
// 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,
@@ -204,15 +207,12 @@ pub(crate) async fn reconcile_dns(
registered.keys().map(|(_, ip)| ip.as_str()).collect();
// --- DNS orphans (in DNS but not in registered) ---
// Delete the orphan immediately — DNS TTL only controls resolver caches, not
// authoritative record lifetime, so records do NOT auto-expire.
// After deletion we probe the address: if the device is still alive the kernel
// will emit a REACHABLE NewNeighbour event which re-registers it in DNS.
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,
@@ -229,15 +229,16 @@ pub(crate) async fn reconcile_dns(
}
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) = send_icmpv6_echo(addr, 0) {
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) = send_icmpv4_echo(addr, 0) {
if let Err(e) = prober.send_icmpv4_echo(addr, 0) {
debug!("reconcile: probe failed for {}: {}", addr, e);
}
}
@@ -246,14 +247,13 @@ pub(crate) async fn reconcile_dns(
}
// --- Registered orphans (in registered but not in DNS) ---
// hickory-dns may have restarted and lost its journal; re-push the record.
// Use the hostname stored in the entry — independent of the current lease table.
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,
+13 -2
View File
@@ -1,6 +1,6 @@
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;
@@ -14,6 +14,17 @@ pub(crate) const fn nl_mgrp(group: u32) -> u32 {
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,
@@ -41,7 +52,7 @@ 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.
/// 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,
@@ -156,6 +156,7 @@ const inbound_type = [
['shadowsocks', _('Shadowsocks') + ' - ' + _('TCP/UDP')],
['mieru', _('Mieru') + ' - ' + _('TCP/UDP')],
['sudoku', _('Sudoku') + ' - ' + _('TCP')],
['snell', _('Snell') + ' - ' + _('TCP')],
['vmess', _('VMess') + ' - ' + _('TCP')],
['vless', _('VLESS') + ' - ' + _('TCP')],
['trojan', _('Trojan') + ' - ' + _('TCP')],
@@ -193,6 +193,7 @@ function renderListeners(s, uciconfig, isClient) {
o = s.taboption('field_general', form.ListValue, 'hysteria_obfs_type', _('Obfuscate type'));
o.value('', _('Disable'));
o.value('salamander', _('Salamander'));
o.value('gecko', _('Gecko'));
o.depends('type', 'hysteria2');
o.modalonly = true;
@@ -204,6 +205,16 @@ function renderListeners(s, uciconfig, isClient) {
o.depends({type: 'hysteria2', hysteria_obfs_type: /.+/});
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'hysteria_obfs_min_packet_size', _('Obfuscate minimum packet size'));
o.placeholder = '512'
o.depends('hysteria_obfs_type', 'gecko');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'hysteria_obfs_max_packet_size', _('Obfuscate maximum packet size'));
o.placeholder = '1200'
o.depends('hysteria_obfs_type', 'gecko');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'hysteria_masquerade', _('Masquerade'),
_('HTTP3 server behavior when authentication fails.<br/>A 404 page will be returned if empty.'));
o.placeholder = 'file:///var/www or http://127.0.0.1:8080'
@@ -440,6 +451,24 @@ function renderListeners(s, uciconfig, isClient) {
o.depends('sudoku_http_mask', '1');
o.modalonly = true;
/* Snell fields */
o = s.taboption('field_general', hm.GenValue, 'snell_psk', _('Pre-shared key'));
o.password = true;
o.rmempty = false;
o.validate = hm.validateAuthPassword;
o.depends('type', 'snell');
o.modalonly = true;
o = s.taboption('field_general', form.ListValue, 'snell_version', _('Version'));
o.value('1', _('v1'));
o.value('2', _('v2'));
o.value('3', _('v3'));
o.value('4', _('v4'));
o.value('5', _('v5'));
o.default = '4';
o.depends('type', 'snell');
o.modalonly = true;
/* Tuic fields */
o = s.taboption('field_general', hm.GenValue, 'uuid', _('UUID'));
o.rmempty = false;
@@ -539,6 +568,14 @@ function renderListeners(s, uciconfig, isClient) {
o.value('http', _('HTTP'));
o.value('tls', _('TLS'));
o.depends('plugin', 'obfs');
o.depends('type', 'snell');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'plugin_opts_host', _('Plugin: ') + _('Host that supports TLS 1.3'));
o.datatype = 'hostname';
o.placeholder = 'cloud.tencent.com';
o.rmempty = false;
o.depends('type', 'snell');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'plugin_opts_handshake_dest', _('Plugin: ') + _('Handshake target that supports TLS 1.3'));
@@ -605,8 +642,8 @@ function renderListeners(s, uciconfig, isClient) {
o.modalonly = true;
o = s.taboption('field_general', form.Flag, 'udp', _('UDP'));
o.default = o.disabled;
o.depends({type: /^(socks|mixed|shadowsocks)$/});
o.default = o.enabled;
o.depends({type: /^(socks|mixed|shadowsocks|snell)$/});
o.modalonly = true;
/* Vless Encryption fields */
@@ -855,6 +892,12 @@ function renderListeners(s, uciconfig, isClient) {
// @ 下面支持填写针对server-url的TLS配置(sni, skip-cert-verify, fingerprint, certificate, private-key, alpn)
/* TLS fields */
o = s.taboption('field_general', form.Flag, 'allow_insecure', _('Allow insecure connections'),
_('Only applicable when %s are used as a frontend.').format('nginx/caddy'));
o.default = o.disabled;
o.depends({type: /^(vless|trojan|anytls)$/});
o.modalonly = true;
o = s.taboption('field_general', form.Flag, 'tls', _('TLS'));
o.default = o.disabled;
o.validate = function(section_id, value) {
@@ -906,7 +949,7 @@ function renderListeners(s, uciconfig, isClient) {
return true;
}
o.depends({type: /^(http|socks|mixed|vmess|vless|trojan|anytls|tuic|hysteria2|hysteria2-realm|trusttunnel)$/});
o.depends({allow_insecure: '0', type: /^(http|socks|mixed|vmess|vless|trojan|anytls|tuic|hysteria2|hysteria2-realm|trusttunnel)$/});
o.modalonly = true;
o = s.taboption('field_tls', form.DynamicList, 'tls_alpn', _('TLS ALPN'),
@@ -298,6 +298,7 @@ return view.extend({
so = ss.taboption('field_general', form.ListValue, 'hysteria_obfs_type', _('Obfuscate type'));
so.value('', _('Disable'));
so.value('salamander', _('Salamander'));
so.value('gecko', _('Gecko'));
so.depends('type', 'hysteria2');
so.modalonly = true;
@@ -309,6 +310,16 @@ return view.extend({
so.depends({type: 'hysteria2', hysteria_obfs_type: /.+/});
so.modalonly = true;
so = ss.taboption('field_general', form.Value, 'hysteria_obfs_min_packet_size', _('Obfuscate minimum packet size'));
so.placeholder = '512'
so.depends('hysteria_obfs_type', 'gecko');
so.modalonly = true;
so = ss.taboption('field_general', form.Value, 'hysteria_obfs_max_packet_size', _('Obfuscate maximum packet size'));
so.placeholder = '1200'
so.depends('hysteria_obfs_type', 'gecko');
so.modalonly = true;
/* SSH fields */
so = ss.taboption('field_general', form.TextValue, 'ssh_priv_key', _('Priv-key'));
so.depends('type', 'ssh');
@@ -508,10 +519,17 @@ return view.extend({
so.value('1', _('v1'));
so.value('2', _('v2'));
so.value('3', _('v3'));
so.default = '3';
so.value('4', _('v4'));
so.value('5', _('v5'));
so.default = '4';
so.depends('type', 'snell');
so.modalonly = true;
so = ss.taboption('field_general', form.Flag, 'snell_reuse', _('Connection reuse'));
so.default = so.disabled;
so.depends({type: 'snell', snell_version: /^(4|5)$/});
so.modalonly = true;
/* TUIC fields */
so = ss.taboption('field_general', form.Value, 'uuid', _('UUID'));
so.rmempty = false;
@@ -875,6 +893,7 @@ return view.extend({
so = ss.taboption('field_general', form.Flag, 'udp', _('UDP'));
so.default = so.disabled;
so.depends({type: /^(direct|socks5|ss|mieru|vmess|vless|trojan|anytls|trusttunnel|masque|wireguard)$/});
so.depends({type: 'snell', snell_version: /^(3|4|5)$/});
so.modalonly = true;
so = ss.taboption('field_general', form.Flag, 'uot', _('UoT'),
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -511,6 +511,8 @@ uci.foreach(uciconf, ucinode, (cfg) => {
down: cfg.hysteria_down_mbps ? cfg.hysteria_down_mbps + ' Mbps' : null,
obfs: cfg.hysteria_obfs_type,
"obfs-password": cfg.hysteria_obfs_password,
"obfs-min-packet-size": strToInt(cfg.hysteria_obfs_min_packet_size),
"obfs-max-packet-size": strToInt(cfg.hysteria_obfs_max_packet_size),
"realm-opts": cfg.hysteria2_realm === '1' ? {
enable: true,
"server-url": cfg.hysteria2_realm_server_url,
@@ -562,6 +564,7 @@ uci.foreach(uciconf, ucinode, (cfg) => {
/* Snell */
psk: cfg.snell_psk,
version: cfg.snell_version,
reuse: strToBool(cfg.snell_reuse),
"obfs-opts": cfg.type === 'snell' ? {
mode: cfg.plugin_opts_obfsmode,
host: cfg.plugin_opts_host,
+12 -2
View File
@@ -250,6 +250,8 @@ export function parseListener(cfg, isClient, label) {
"ignore-client-bandwidth": strToBool(cfg.hysteria_ignore_client_bandwidth),
obfs: cfg.hysteria_obfs_type,
"obfs-password": cfg.hysteria_obfs_password,
"obfs-min-packet-size": strToInt(cfg.hysteria_obfs_min_packet_size),
"obfs-max-packet-size": strToInt(cfg.hysteria_obfs_max_packet_size),
masquerade: cfg.hysteria_masquerade,
"realm-opts": cfg.hysteria2_realm === '1' ? {
enable: true,
@@ -300,6 +302,14 @@ export function parseListener(cfg, isClient, label) {
} : {}),
fallback: (cfg.sudoku_http_mask === '0') ? null : cfg.sudoku_fallback,
/* Snell */
psk: cfg.snell_psk,
version: cfg.snell_version,
"obfs-opts": cfg.type === 'snell' ? {
mode: cfg.plugin_opts_obfsmode,
host: cfg.plugin_opts_host,
} : null,
/* Tuic */
"max-idle-time": durationToSecond(cfg.tuic_max_idle_time),
"authentication-timeout": durationToSecond(cfg.tuic_authentication_timeout),
@@ -350,10 +360,10 @@ export function parseListener(cfg, isClient, label) {
"congestion-controller": cfg.congestion_controller,
"bbr-profile": cfg.bbr_profile,
network: cfg.network,
udp: strToBool(cfg.udp),
udp: cfg.udp === '0' ? false : true,
/* TLS fields */
...(cfg.tls === '1' ? {
...(cfg.allow_insecure === '1' ? { "allow-insecure": true } : cfg.tls === '1' ? {
alpn: cfg.tls_alpn,
...(cfg.tls_reality === '1' ? {
"reality-config": {
+2 -2
View File
@@ -7,8 +7,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.5.20
PKG_RELEASE:=146
PKG_VERSION:=26.6.2
PKG_RELEASE:=147
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.5.19
PKG_RELEASE:=47
PKG_RELEASE:=48
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -1684,12 +1684,13 @@ function gen_config(var)
blockTypes = (api.compare_versions(xray_version, "<", "26.4.25")) and { 65 } or nil, -- Todo is to remove it
rules = (api.compare_versions(xray_version, ">", "26.4.17")) and {
{
qtype = "1,28",
qType = "1,28",
action = "hijack"
},
{
qtype = 65,
action = "reject",
qType = 65,
action = "return",
rCode = 0
},
{
action = "direct"
@@ -1708,11 +1709,12 @@ function gen_config(var)
nonIPQuery = (api.compare_versions(xray_version, "<", "26.4.25")) and "reject" or nil, -- Todo is to remove it
rules = (api.compare_versions(xray_version, ">", "26.4.17")) and {
{
qtype = "1,28",
qType = "1,28",
action = "hijack"
},
{
action = "reject"
action = "return",
rCode = 0
}
} or nil
}
+1 -1
View File
@@ -7,7 +7,7 @@ LUCI_DEPENDS:=+luci-nginx +quickfile
PKG_LICENSE:=Apache-2.0
PKG_VERSION:=1.0.0
PKG_RELEASE:=5
PKG_RELEASE:=6
PKG_MAINTAINER:=sbwml <admin@cooluc.com>
include $(TOPDIR)/feeds/luci/luci.mk
@@ -5,9 +5,6 @@
"action": {
"type": "view",
"path": "system/quickfile"
},
"depends": {
"acl": [ "luci-app-quickfile" ]
}
}
}
+4 -4
View File
@@ -1,14 +1,14 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=mihomo-alpha
PKG_VERSION:=2026.05.19
PKG_RELEASE:=9
PKG_VERSION:=2026.05.30
PKG_RELEASE:=10
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/MetaCubeX/mihomo.git
PKG_SOURCE_VERSION:=a52a27ed563970b79e50ced79f15100abf0bcc85
PKG_SOURCE_VERSION:=fc8c5a24b16991f98cd736950c17d1aa306a5041
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL3.0+
@@ -18,7 +18,7 @@ PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
PKG_BUILD_VERSION:=alpha-a52a27ed
PKG_BUILD_VERSION:=alpha-fc8c5a24
PKG_BUILD_TIME:=$(shell date -u -Iseconds)
GO_PKG:=github.com/metacubex/mihomo
+4 -4
View File
@@ -1,14 +1,14 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=mihomo-meta
PKG_VERSION:=1.19.25
PKG_RELEASE:=5
PKG_VERSION:=1.19.26
PKG_RELEASE:=6
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/MetaCubeX/mihomo.git
PKG_SOURCE_VERSION:=v1.19.25
PKG_SOURCE_VERSION:=v1.19.26
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL3.0+
@@ -18,7 +18,7 @@ PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
PKG_BUILD_VERSION:=v1.19.25
PKG_BUILD_VERSION:=v1.19.26
PKG_BUILD_TIME:=$(shell date -u -Iseconds)
GO_PKG:=github.com/metacubex/mihomo
+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=mihomo
PKG_VERSION:=1.19.25
PKG_RELEASE:=9
PKG_VERSION:=1.19.26
PKG_RELEASE:=10
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/metacubex/mihomo/tar.gz/v$(PKG_VERSION)?