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

This commit is contained in:
github-actions[bot] 2026-06-02 21:18:52 +08:00
parent ba4ce217ec
commit f4c336b200
25 changed files with 3094 additions and 2893 deletions

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

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();

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
}

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) {

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 032")
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)
}

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;

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>>,

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,

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,

View File

@ -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')],

View File

@ -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'),

View File

@ -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

View File

@ -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,

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": {

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:= \

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:= \

View File

@ -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
}

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

View File

@ -5,9 +5,6 @@
"action": {
"type": "view",
"path": "system/quickfile"
},
"depends": {
"acl": [ "luci-app-quickfile" ]
}
}
}

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

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

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)?