mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-07-27 18:41:15 +08:00
🌴 Sync 2026-06-02 21:18:52
This commit is contained in:
parent
ba4ce217ec
commit
f4c336b200
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(®istered).await;
|
||||
probe_registered_neighbours(&prober, ®istered).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)
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>>,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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:= \
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)?
|
||||
|
||||
Loading…
Reference in New Issue
Block a user