🐤 Sync 2026-05-08 20:31:49

This commit is contained in:
github-actions[bot] 2026-05-08 20:31:49 +08:00
parent dc55b8fdfe
commit e2f4c3d0b5
22 changed files with 738 additions and 92 deletions

View File

@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=geoview
PKG_VERSION:=0.2.5
PKG_RELEASE:=1
PKG_VERSION:=0.2.6
PKG_RELEASE:=2
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/snowie2000/geoview/tar.gz/$(PKG_VERSION)?

View File

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

View File

@ -33,6 +33,7 @@ file = "/etc/hickory-dns/0.zone"
[[zones]]
zone = "lan"
zone_type = "Primary"
axfr_policy = "AllowAll"
[zones.stores]
type = "sqlite"

View File

@ -0,0 +1,22 @@
#!/bin/sh
. /lib/functions.sh
IF_TO_WATCH="wan_6"
SERVICE="hickory-dns"
DELAY=5
## Restart service on ifup or ifupdate for the watched interface
if { [ "$ACTION" = "ifup" ] || [ "$ACTION" = "ifupdate" ]; } && [ "$INTERFACE" = "$IF_TO_WATCH" ]; then
logger -t hickory-hotplug "Interface ${IF_TO_WATCH} ${ACTION} - ${SERVICE} restart in ${DELAY}s"
(
sleep ${DELAY}
if /etc/init.d/${SERVICE} enabled; then
/etc/init.d/${SERVICE} restart >/dev/null 2>&1
logger -t hickory-hotplug "${SERVICE} restarted successfully after ${IF_TO_WATCH} ${ACTION}"
else
logger -t hickory-hotplug "${SERVICE} not enabled (skipped)"
fi
) &
fi

View File

@ -7,6 +7,7 @@ STOP=51
PROG=/usr/bin/hickory-dns
CONF=/etc/hickory-dns/forwarder.toml
TSIG_KEY=/etc/hickory-dns/update.key
NEIGH_PROG=/usr/bin/ipv6-neigh
generate_tsig_key() {
[ -f "$TSIG_KEY" ] && return 0
@ -27,7 +28,11 @@ start_service() {
procd_set_param stderr 1
procd_set_param respawn "${respawn_threshold:-3600}" "${respawn_timeout:-5}" "${respawn_retry:-5}"
procd_close_instance hickory-dns
# Journal was wiped above; restart ipv6-neigh so it re-registers all
# dynamic DNS entries from the current neighbour table.
/etc/init.d/ipv6-neigh restart
procd_open_instance ipv6-neigh
procd_set_param command $NEIGH_PROG --private-subnet-v4 -d "[::1]:53" -z lan
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn "${respawn_threshold:-3600}" "${respawn_timeout:-5}" "${respawn_retry:-5}"
procd_close_instance ipv6-neigh
}

1
ipv6-neigh/Cargo.lock generated
View File

@ -558,6 +558,7 @@ dependencies = [
"env_logger",
"futures",
"hickory-proto",
"libc",
"log",
"netlink-packet-core",
"netlink-packet-route",

View File

@ -19,6 +19,7 @@ serde_json = "1.0.108"
log = "0.4"
env_logger = "0.11"
socket2 = "0.5"
libc = "0.2"
[profile.release]
opt-level = 3

View File

@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=ipv6-neigh
PKG_VERSION:=0.1.0
PKG_RELEASE:=6
PKG_RELEASE:=7
PKG_BUILD_DEPENDS:=rust/host
PKG_BUILD_PARALLEL:=1
@ -50,8 +50,6 @@ endef
define Package/ipv6-neigh/install
$(INSTALL_DIR) $(1)/usr/bin/
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/bin/ip-neigh $(1)/usr/bin/ipv6-neigh
$(INSTALL_DIR) $(1)/etc/init.d/
$(INSTALL_BIN) ./files/etc/init.d/ipv6-neigh $(1)/etc/init.d/ipv6-neigh
endef
$(eval $(call RustBinPackage,ipv6-neigh))

View File

@ -1,17 +0,0 @@
#!/bin/sh /etc/rc.common
USE_PROCD=1
START=19
STOP=50
PROG=/usr/bin/ipv6-neigh
start_service() {
procd_open_instance ipv6-neigh
# 默认: IPv4 仅私网IPv6 允许公网(GUA)
procd_set_param command $PROG --private-subnet-v4 -d "[::1]:53" -z lan
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn "${respawn_threshold:-3600}" "${respawn_timeout:-5}" "${respawn_retry:-5}"
procd_close_instance ipv6-neigh
}

View File

@ -1,7 +1,7 @@
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hickory_proto::op::{Message, ResponseCode, update_message};
use hickory_proto::op::{Message, Query, ResponseCode, update_message};
use hickory_proto::rr::{Name, RData, RecordSet, RecordType, TSigner};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
@ -137,4 +137,95 @@ impl DnsUpdater {
Ok(result)
}
/// Fetch all A and AAAA records in the zone via AXFR (RFC 5936).
///
/// Returns a list of `(hostname_label, IpAddr)` pairs — only records whose owner
/// name is directly under the zone apex (e.g. `foo.lan.` → `"foo"`).
/// SOA, NS, and apex records are excluded.
///
/// Requires `axfr_policy = "AllowAll"` (or `"AllowSigned"`) in the server config.
pub async fn axfr_records(&self) -> Result<Vec<(String, IpAddr)>, Box<dyn std::error::Error>> {
const AXFR_TIMEOUT: Duration = Duration::from_secs(10);
let query = Query::query(self.zone.clone(), RecordType::AXFR);
let mut msg = Message::query();
msg.add_query(query);
let bytes = msg.to_vec()?;
let len = u16::try_from(bytes.len())?;
let zone = self.zone.clone();
let addr = self.server_addr;
let records = timeout(AXFR_TIMEOUT, async move {
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(&len.to_be_bytes()).await?;
stream.write_all(&bytes).await?;
stream.flush().await?;
let mut records: Vec<(String, IpAddr)> = Vec::new();
let mut soa_count = 0u32;
loop {
let resp_len = stream.read_u16().await? as usize;
if resp_len == 0 {
break;
}
let mut buf = vec![0u8; resp_len];
stream.read_exact(&mut buf).await?;
let response = Message::from_vec(&buf)?;
if response.metadata.response_code != ResponseCode::NoError {
return Err(format!("AXFR error: {:?}", response.metadata.response_code).into());
}
for record in &response.answers {
match &record.data {
RData::SOA(_) => {
soa_count += 1;
}
RData::A(a) => {
if let Some(host) = extract_hostname(&record.name, &zone) {
records.push((host, IpAddr::V4(a.0)));
}
}
RData::AAAA(aaaa) => {
if let Some(host) = extract_hostname(&record.name, &zone) {
records.push((host, IpAddr::V6(aaaa.0)));
}
}
_ => {}
}
}
if soa_count >= 2 {
break;
}
}
Ok::<_, Box<dyn std::error::Error>>(records)
})
.await
.map_err(|_| -> Box<dyn std::error::Error> {
format!("AXFR timeout after {}s connecting to {}", AXFR_TIMEOUT.as_secs(), self.server_addr).into()
})??;
Ok(records)
}
}
/// Extract the single label that precedes the zone apex from a fully-qualified record name.
///
/// e.g. `"foo.lan."` with zone `"lan."` → `Some("foo")`.
/// Returns `None` for the apex itself or for names not directly under the zone.
fn extract_hostname(name: &Name, zone: &Name) -> Option<String> {
let n = name.to_ascii().to_lowercase();
let z = zone.to_ascii().to_lowercase();
let n = n.trim_end_matches('.');
let z = z.trim_end_matches('.');
if n == z {
return None; // apex record
}
let suffix = format!(".{z}");
n.strip_suffix(&suffix).map(|s| s.to_string())
}

View File

@ -13,6 +13,7 @@ use netlink_packet_route::neighbour::{NeighbourAddress, NeighbourAttribute, Neig
use netlink_packet_route::route::RouteType;
use rtnetlink::{Error, Handle, new_connection};
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
use std::os::unix::io::AsRawFd;
use tokio::time::{self, Duration};
use clap::Parser;
@ -74,6 +75,12 @@ struct Neigh {
mac: String,
}
/// An IPv6 prefix (address + length) representing an active LAN prefix on the router interface.
struct LanPrefix {
addr: Ipv6Addr,
prefix_len: u8,
}
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()
@ -107,6 +114,18 @@ fn if_ipv4_in_private_subnet(ip: &Ipv4Addr) -> bool {
false
}
/// Returns true if `addr` falls within the given IPv6 prefix.
fn ipv6_in_prefix(addr: Ipv6Addr, prefix: &LanPrefix) -> bool {
let mask: u128 = if prefix.prefix_len == 0 {
0
} else if prefix.prefix_len >= 128 {
u128::MAX
} else {
!0u128 << (128 - prefix.prefix_len)
};
(u128::from(addr) & mask) == (u128::from(prefix.addr) & mask)
}
async fn process_new_neigh(neigh: &Neigh, updater: &db::DnsUpdater, leases: &HashMap<String, String>) -> bool {
let Some(hostname) = leases.get(&neigh.mac) else {
debug!("no lease for mac {}, skipping DNS update", neigh.mac);
@ -158,11 +177,6 @@ fn should_skip_neigh(neigh: &Neigh) -> bool {
should_skip_route_type(neigh.kind)
}
/// Whether this NUD state indicates the neighbour is likely reachable (register in DNS).
fn is_reachable_state(state: NeighbourState) -> bool {
matches!(state, NeighbourState::Reachable | NeighbourState::Stale | NeighbourState::Delay | NeighbourState::Probe)
}
/// Whether this NUD state indicates the neighbour is definitely gone (remove from DNS).
fn is_failed_state(state: NeighbourState) -> bool {
matches!(state, NeighbourState::Failed)
@ -178,6 +192,9 @@ 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();
@ -193,6 +210,9 @@ async fn main() -> Result<(), ()> {
let updater = db::DnsUpdater::new(args.dns_server, zone, signer);
// Wait for the DNS server to become available before sending any updates.
wait_for_dns_server(args.dns_server).await;
let (connection, handle, _) = new_connection().unwrap();
tokio::spawn(connection);
@ -218,21 +238,76 @@ async fn main() -> Result<(), ()> {
let mut leases = op::get_lease().unwrap_or_default();
info!("loaded {} DHCP leases", leases.len());
// State cache: tracks (mac, ip_string) -> last confirmed time
let mut registered: HashMap<(String, String), Instant> = HashMap::new();
// State cache: tracks (mac, ip_string) -> (last confirmed time, ifindex)
let mut registered: HashMap<(String, String), (Instant, u32)> = HashMap::new();
// Dump existing neighbours and register only reachable/stale ones
// Get active LAN prefixes to filter out neighbours from expired/old prefixes.
// If prefix detection fails we log a warning but continue without filtering.
let active_prefixes = get_active_lan_prefixes(handle.clone(), &args.router_iface, private_subnet_v6).await;
if active_prefixes.is_empty() {
warn!("no active IPv6 prefixes found on {}, neighbour prefix-filtering disabled", args.router_iface);
} else {
info!(
"active LAN IPv6 prefixes: {}",
active_prefixes
.iter()
.map(|p| format!("{}/{}", p.addr, p.prefix_len))
.collect::<Vec<_>>()
.join(", ")
);
}
// Dump existing neighbours:
// - Skip entries whose IP is not within any active LAN prefix (old-prefix residuals).
// - 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.
debug!("dumping neighbours");
if let Ok(neighbours) = dump_neighbours(handle.clone(), private_subnet_v4, private_subnet_v6).await {
for neigh in &neighbours {
debug!("{:?}", neigh);
if should_skip_neigh(neigh) || !is_reachable_state(neigh.state) {
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);
continue;
}
}
let key = (neigh.mac.clone(), inet_to_string(&neigh.inet));
if !registered.contains_key(&key) {
if process_new_neigh(neigh, &updater, &leases).await {
registered.insert(key, Instant::now());
match neigh.state {
NeighbourState::Reachable => {
// Confirmed online — register in DNS immediately.
if !registered.contains_key(&key) {
if process_new_neigh(neigh, &updater, &leases).await {
registered.insert(key, (Instant::now(), neigh.ifindex));
}
}
}
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) {
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) {
debug!("init probe failed for {}: {}", addr, e);
}
}
_ => {}
}
}
_ => {
// FAILED and others — skip.
}
}
}
@ -297,20 +372,26 @@ async fn main() -> Result<(), ()> {
debug!("Neighbour failed: {:?}", neigh);
if !process_del_neigh(&neigh, &updater, &leases).await {
// DNS delete failed, put back so we retry
registered.insert(key, Instant::now());
registered.insert(key, (Instant::now(), neigh.ifindex));
}
}
} else if is_reachable_state(neigh.state) {
} else if neigh.state == NeighbourState::Reachable {
if registered.contains_key(&key) {
// Already registered, just update timestamp
registered.insert(key, Instant::now());
registered.insert(key, (Instant::now(), neigh.ifindex));
} else {
// New reachable neighbour — register in DNS
debug!("New neighbour: {:?}", neigh);
if process_new_neigh(&neigh, &updater, &leases).await {
registered.insert(key, Instant::now());
registered.insert(key, (Instant::now(), neigh.ifindex));
}
}
} else if matches!(neigh.state, NeighbourState::Stale | NeighbourState::Delay | NeighbourState::Probe) {
// Uncertain state: keep existing registrations alive but do
// not create new DNS records without REACHABLE confirmation.
if registered.contains_key(&key) {
registered.insert(key, (Instant::now(), neigh.ifindex));
}
}
}
RouteNetlinkMessage::DelNeighbour(del_neigh) => {
@ -321,15 +402,11 @@ async fn main() -> Result<(), ()> {
continue;
}
let key = (neigh.mac.clone(), inet_to_string(&neigh.inet));
if registered.remove(&key).is_none() {
// Was not registered, skip
continue;
}
registered.remove(&key);
debug!("Del neighbour: {:?}", neigh);
if !process_del_neigh(&neigh, &updater, &leases).await {
// DNS delete failed, put back so we retry delete next time
registered.insert(key, Instant::now());
}
// Always attempt DNS deletion even if not in registered map,
// to clean up records that survived a program restart.
process_del_neigh(&neigh, &updater, &leases).await;
}
_ => {}
}
@ -340,37 +417,176 @@ async fn main() -> Result<(), ()> {
continue;
}
probe_registered_neighbours(&registered).await;
reconcile_dns(
&updater,
&mut registered,
&leases,
&active_prefixes,
private_subnet_v4,
private_subnet_v6,
)
.await;
}
}
}
Ok(())
}
/// Reconcile the in-memory `registered` map against the live DNS zone obtained via AXFR.
///
/// Two corrections are made on each call:
/// 1. **DNS orphans** records present in DNS but absent from `registered`.
/// These are either stale leftovers from a previous run or records from a prefix that
/// is no longer active. Records that fail prefix/subnet filtering are deleted from DNS
/// immediately; records that pass filtering are probed with ICMP so the kernel NUD
/// state machine can confirm reachability and re-populate `registered` via the event loop.
/// 2. **Registered orphans** entries in `registered` that are missing from DNS (e.g.
/// because hickory-dns restarted and lost its in-memory state). These are re-pushed
/// via DNS UPDATE so the zone stays consistent.
async fn reconcile_dns(
updater: &db::DnsUpdater,
registered: &mut HashMap<(String, String), (Instant, u32)>,
leases: &HashMap<String, String>,
active_prefixes: &[LanPrefix],
private_subnet_v4: bool,
private_subnet_v6: bool,
) {
use std::collections::{HashMap as Map, HashSet};
use std::net::IpAddr;
let dns_records = match updater.axfr_records().await {
Ok(r) => r,
Err(e) => {
warn!("AXFR reconciliation failed: {}", e);
return;
}
};
// Only consider records whose hostname appears in the lease table; this avoids
// accidentally touching manually-added records or the router's own addresses.
let lease_hostnames: HashSet<&str> = leases.values().map(String::as_str).collect();
// Map: ip_string -> hostname, for DNS records that pass all filters.
// Records that fail filtering are deleted from DNS here.
let mut dns_ips: Map<String, String> = Map::new();
for (hostname, ip) in &dns_records {
if !lease_hostnames.contains(hostname.as_str()) {
continue;
}
let passes = match ip {
IpAddr::V6(addr) => {
let subnet_ok = !private_subnet_v6 || if_ipv6_in_private_subnet(addr);
let prefix_ok = active_prefixes.is_empty()
|| active_prefixes.iter().any(|p| ipv6_in_prefix(*addr, p));
subnet_ok && prefix_ok
}
IpAddr::V4(addr) => !private_subnet_v4 || if_ipv4_in_private_subnet(addr),
};
if passes {
dns_ips.insert(ip.to_string(), hostname.clone());
} else {
// Stale record (wrong prefix / subnet) — remove from DNS.
let result = match ip {
IpAddr::V6(addr) => updater.delete_aaaa(hostname, *addr).await,
IpAddr::V4(addr) => updater.delete_a(hostname, *addr).await,
};
match result {
Ok(()) => info!("reconcile: deleted stale DNS {} -> {}", hostname, ip),
Err(e) => warn!("reconcile: failed to delete stale DNS {} {}: {}", hostname, ip, e),
}
}
}
// Registered ip set for quick lookup.
let registered_ips: HashSet<&str> =
registered.keys().map(|(_, ip)| ip.as_str()).collect();
// --- DNS orphans (in DNS but not in registered) ---
// Probe with ifindex=0 so the kernel routes via the default LAN route.
// If the host is alive the resulting REACHABLE event will re-populate `registered`.
// If gone, the record's TTL (60 s) will expire and it won't be renewed.
for (ip_str, hostname) in &dns_ips {
if registered_ips.contains(ip_str.as_str()) {
continue;
}
info!("reconcile: DNS orphan {} -> {}, probing", hostname, ip_str);
match ip_str.parse::<IpAddr>() {
Ok(IpAddr::V6(addr)) => {
if let Err(e) = send_icmpv6_echo(addr, 0) {
debug!("reconcile: probe failed for {}: {}", addr, e);
}
}
Ok(IpAddr::V4(addr)) => {
let _ = send_icmpv4_echo(addr, 0);
}
_ => {}
}
}
// --- Registered orphans (in registered but not in DNS) ---
// hickory-dns may have restarted and lost its journal; re-push the record.
for ((mac, ip_str), (last_confirmed, _)) in registered.iter_mut() {
if dns_ips.contains_key(ip_str.as_str()) {
continue;
}
let Some(hostname) = leases.get(mac) else { continue };
debug!("reconcile: registered orphan {} -> {}, re-pushing", hostname, ip_str);
let result = match ip_str.parse::<IpAddr>() {
Ok(IpAddr::V6(addr)) => updater.upsert_aaaa(hostname, addr, DEFAULT_TTL).await,
Ok(IpAddr::V4(addr)) => updater.upsert_a(hostname, addr, DEFAULT_TTL).await,
_ => continue,
};
match result {
Ok(()) => {
info!("reconcile: re-pushed {} -> {}", hostname, ip_str);
*last_confirmed = Instant::now();
}
Err(e) => warn!("reconcile: failed to re-push {} {}: {}", hostname, ip_str, e),
}
}
}
/// Send ICMPv6 Echo Request / ICMPv4 Echo Request to all registered neighbours.
/// This forces the kernel NUD state machine to verify reachability, generating
/// NewNeighbour events with the resulting state (Reachable or Failed).
async fn probe_registered_neighbours(registered: &HashMap<(String, String), Instant>) {
async fn probe_registered_neighbours(registered: &HashMap<(String, String), (Instant, u32)>) {
let now = Instant::now();
for ((_, ip_str), last_confirmed) in registered.iter() {
for ((_, ip_str), (last_confirmed, ifindex)) in registered.iter() {
// Only probe entries not confirmed recently (older than 30s)
if now.duration_since(*last_confirmed) < Duration::from_secs(30) {
continue;
}
if let Ok(addr) = ip_str.parse::<Ipv6Addr>() {
if let Err(e) = send_icmpv6_echo(addr) {
if let Err(e) = send_icmpv6_echo(addr, *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) {
if let Err(e) = send_icmpv4_echo(addr, *ifindex) {
debug!("probe failed for {}: {}", ip_str, e);
}
}
}
}
fn send_icmpv6_echo(addr: Ipv6Addr) -> std::io::Result<()> {
fn send_icmpv6_echo(addr: Ipv6Addr, ifindex: u32) -> std::io::Result<()> {
let socket = Socket::new(Domain::IPV6, Type::DGRAM, 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.
if ifindex != 0 {
unsafe {
let idx = ifindex as libc::c_int;
libc::setsockopt(
socket.as_raw_fd(),
libc::IPPROTO_IPV6,
libc::IPV6_UNICAST_IF,
&idx as *const libc::c_int as *const libc::c_void,
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
);
}
}
// 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));
@ -378,9 +594,22 @@ fn send_icmpv6_echo(addr: Ipv6Addr) -> std::io::Result<()> {
Ok(())
}
fn send_icmpv4_echo(addr: Ipv4Addr) -> std::io::Result<()> {
fn send_icmpv4_echo(addr: Ipv4Addr, ifindex: u32) -> std::io::Result<()> {
let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::ICMPV4))?;
socket.set_nonblocking(true)?;
// Bind outgoing packet to the specific interface via IP_UNICAST_IF.
if ifindex != 0 {
unsafe {
let idx = ifindex as libc::c_int;
libc::setsockopt(
socket.as_raw_fd(),
libc::IPPROTO_IP,
libc::IP_UNICAST_IF,
&idx as *const libc::c_int as *const libc::c_void,
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
);
}
}
// ICMPv4 Echo Request: type=8, code=0, checksum (simple for 8 bytes), id=0, seq=1
// Checksum for [08,00,00,00,00,00,00,01]: ~(0x0800 + 0x0001) = 0xf7fe
let packet: [u8; 8] = [8, 0, 0xf7, 0xfe, 0, 0, 0, 1];
@ -470,6 +699,36 @@ async fn dump_neighbours(handle: Handle, private_subnet_v4: bool, private_subnet
Ok(vec)
}
/// Enumerate non-link-local IPv6 prefixes currently assigned to `iface`.
/// Used during startup to filter out neighbour entries from expired/old prefixes.
async fn get_active_lan_prefixes(handle: Handle, 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 = 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 {
let prefix_len = msg.header.prefix_len;
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 {
continue;
}
prefixes.push(LanPrefix { addr: *addr, prefix_len });
}
}
}
prefixes
}
/// Enumerate addresses on `iface`, register A/AAAA records for the router itself.
async fn register_router_addresses(
handle: Handle,
@ -524,3 +783,21 @@ async fn register_router_addresses(
}
}
}
/// Block until a TCP connection to `addr` succeeds, retrying every 2 seconds.
/// This ensures the DNS server is ready before we attempt any dynamic updates.
async fn wait_for_dns_server(addr: StdSocketAddr) {
use tokio::net::TcpStream;
loop {
match TcpStream::connect(addr).await {
Ok(_) => {
info!("DNS server at {} is reachable, proceeding", addr);
return;
}
Err(e) => {
warn!("DNS server at {} not ready ({}), retrying in 2s...", addr, e);
time::sleep(Duration::from_secs(2)).await;
}
}
}
}

View File

@ -45,6 +45,32 @@ fn sanitize_hostname(raw: &str) -> String {
collapsed.chars().take(63).collect()
}
/// Extract a MAC address from a DHCPv6 DUID string.
/// Supports DUID-LLT (type 1) and DUID-LL (type 3) with any hardware type.
/// Returns the colon-separated MAC, e.g. "30:c5:99:7d:d5:ae".
fn mac_from_duid(duid: &str) -> Option<String> {
// All chars must be hex digits
if !duid.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let duid_type = u16::from_str_radix(duid.get(0..4)?, 16).ok()?;
// DUID-LLT (1): type(4) + hw_type(4) + time(8) = 16 hex chars before MAC
// DUID-LL (3): type(4) + hw_type(4) = 8 hex chars before MAC
let mac_offset = match duid_type {
1 => 16,
3 => 8,
_ => return None,
};
let mac_hex = duid.get(mac_offset..mac_offset + 12)?;
let mac = mac_hex
.as_bytes()
.chunks(2)
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect::<Vec<&str>>()
.join(":");
Some(mac)
}
// get mac to hostname mapping
pub fn get_lease() -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
// dhcpv6 does not use mac address, so we only need to get ipv4 leases
@ -68,5 +94,26 @@ pub fn get_lease() -> Result<HashMap<String, String>, Box<dyn std::error::Error>
}
}
// Supplement with IPv6 leases: extract MAC from DUID when available.
// IPv4 entries take precedence; only fill in missing MACs here.
if let Ok(v6leases) = call_ubus("dhcp", "ipv6leases") {
if let Some(devices) = v6leases["device"].as_object() {
for (_device, device_data) in devices {
let Some(leases) = device_data["leases"].as_array() else { continue };
for lease in leases {
let Some(duid) = lease["duid"].as_str() else { continue };
let Some(hostname) = lease["hostname"].as_str() else { continue };
let hostname = sanitize_hostname(hostname);
if hostname.is_empty() {
continue;
}
let Some(mac) = mac_from_duid(duid) else { continue };
// IPv4 mapping takes precedence
result.entry(mac).or_insert(hostname);
}
}
}
}
Ok(result)
}

View File

@ -32,22 +32,13 @@ application/json-seq: ␊ = \n | ^J | 0xa, ␞ = ␞ | ^^ | 0x1e
return dm2.dv.extend({
load() {
const now = Math.floor(Date.now() / 1000);
this.js_api = false;
return Promise.all([
dm2.docker_events({ query: { since: `0`, until: `${now}` } }),
dm2.js_api_ready.then(([ok, ]) => this.js_api = ok),
]);
return Promise.resolve([]);
},
render([events, js_api_available]) {
if (events?.code !== 200) {
return E('div', {}, [ events?.body?.message ]);
}
this.outputText = events?.body ? JSON.stringify(events?.body, null, 2) + '\n' : '';
const event_list = events?.body || [];
render() {
this.outputText = '';
const event_list = [];
const view = this;
const mainContainer = E('div', { 'class': 'cbi-map' }, [
@ -98,7 +89,7 @@ return dm2.dv.extend({
E('input', {
'id': 'event-from-date',
'type': 'datetime-local',
'value': '1970-01-01T00:00',
'value': new Date(Date.now() - (60 * 60 * 1000)).toISOString().slice(0, 16),
'step': 60,
'style': 'width: 180px;',
'change': () => { view.renderEventsTable(event_list); }
@ -150,6 +141,16 @@ return dm2.dv.extend({
}
}, _('Now'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, '\u00a0'),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'type': 'button',
'class': 'cbi-button cbi-button-action',
'click': () => { view.renderEventsTable(event_list); }
}, _('Load Events'))
])
])
])
]);
@ -158,10 +159,18 @@ return dm2.dv.extend({
this.tableSection = E('div', { 'class': 'cbi-section', 'id': 'events-section' });
mainContainer.appendChild(this.tableSection);
this.renderEventsTable(event_list);
this.tableSection.appendChild(E('em', {}, _('Loading recent events…')));
mainContainer.appendChild(this.insertOutputFrame(E('div', {}), null));
dm2.js_api_ready.then(([ok, ]) => {
view.js_api = ok;
}).catch(() => {
view.js_api = false;
});
window.setTimeout(() => view.renderEventsTable(event_list), 0);
return mainContainer;
},

View File

@ -63,6 +63,154 @@ function getVolumesInUseByContainers(containers) {
return inUse;
}
function formatScalar(value) {
if (value == null || value === '')
return '-';
if (Array.isArray(value))
return value.length ? value.join(', ') : '-';
if (typeof value === 'boolean')
return value ? 'true' : 'false';
return String(value);
}
function formatKeyValueObject(obj) {
if (!obj || typeof obj !== 'object')
return formatScalar(obj);
const pairs = [];
for (const [key, value] of Object.entries(obj)) {
if (value == null || value === '' || value === false)
continue;
pairs.push(`${key}: ${formatScalar(value)}`);
}
return pairs.length ? pairs.join('; ') : '-';
}
function formatPlugins(plugins) {
if (!plugins || typeof plugins !== 'object')
return '-';
const parts = [];
for (const [type, entries] of Object.entries(plugins)) {
if (!entries)
continue;
if (Array.isArray(entries))
parts.push(`${type}: ${entries.length ? entries.join(', ') : '-'}`);
else
parts.push(`${type}: ${formatScalar(entries)}`);
}
return parts.length ? parts.join('; ') : '-';
}
function formatRegistryConfig(registryConfig) {
if (!registryConfig || typeof registryConfig !== 'object')
return '-';
const parts = [];
if (Array.isArray(registryConfig.Mirrors) && registryConfig.Mirrors.length)
parts.push(`Mirrors: ${registryConfig.Mirrors.join(', ')}`);
if (Array.isArray(registryConfig.InsecureRegistryCIDRs) && registryConfig.InsecureRegistryCIDRs.length)
parts.push(`Insecure CIDRs: ${registryConfig.InsecureRegistryCIDRs.join(', ')}`);
return parts.length ? parts.join('; ') : '-';
}
function formatRuntimes(runtimes) {
if (!runtimes || typeof runtimes !== 'object')
return '-';
const parts = [];
for (const [name, runtime] of Object.entries(runtimes)) {
const path = runtime?.path ? ` (${runtime.path})` : '';
parts.push(`${name}${path}`);
}
return parts.length ? parts.join(', ') : '-';
}
function formatSwarm(swarm) {
if (!swarm || typeof swarm !== 'object')
return '-';
const parts = [];
if (swarm.LocalNodeState)
parts.push(`State: ${swarm.LocalNodeState}`);
if (swarm.NodeID)
parts.push(`NodeID: ${swarm.NodeID}`);
if (swarm.NodeAddr)
parts.push(`NodeAddr: ${swarm.NodeAddr}`);
if (swarm.ControlAvailable != null)
parts.push(`Control: ${swarm.ControlAvailable ? 'yes' : 'no'}`);
return parts.length ? parts.join('; ') : '-';
}
function formatContainerd(containerd) {
if (!containerd || typeof containerd !== 'object')
return '-';
const parts = [];
if (containerd.Address)
parts.push(`Address: ${containerd.Address}`);
if (containerd.Namespaces && typeof containerd.Namespaces === 'object')
parts.push(`Namespaces: ${formatKeyValueObject(containerd.Namespaces)}`);
return parts.length ? parts.join('; ') : '-';
}
function formatInfoBody(body) {
const rows = [];
for (const [key, value] of Object.entries(body || {})) {
if (!value)
continue;
let formatted = null;
switch (key) {
case 'Plugins':
formatted = formatPlugins(value);
break;
case 'RegistryConfig':
formatted = formatRegistryConfig(value);
break;
case 'Runtimes':
formatted = formatRuntimes(value);
break;
case 'Swarm':
formatted = formatSwarm(value);
break;
case 'Containerd':
formatted = formatContainerd(value);
break;
case 'Warnings':
case 'Labels':
case 'SecurityOptions':
case 'CDISpecDirs':
formatted = formatScalar(value);
break;
case 'DriverStatus':
formatted = Array.isArray(value) ? value.map(v => Array.isArray(v) ? `${v[0]}: ${v[1]}` : formatScalar(v)).join('; ') : formatScalar(value);
break;
default:
if (typeof value === 'object')
formatted = formatKeyValueObject(value);
else
formatted = formatScalar(value);
}
rows.push({ entry: key, value: formatted });
}
return rows;
}
return dm2.dv.extend({
load() {
@ -115,7 +263,7 @@ return dm2.dv.extend({
this.parseHeaders(version_response.headers, version_headers);
this.parseBody(version_response.body, version_body);
this.parseBody(info_response.body, info_body);
info_body.push(...formatInfoBody(info_response.body));
// this.parseBody(df_response.body, df_body);
const view = this;
const info = info_response.body;

View File

@ -9,7 +9,7 @@ LUCI_TITLE:=Design Theme
LUCI_DEPENDS:=
PKG_LICENSE:=Apache-2.0
PKG_VERSION:=7.1
PKG_RELEASE:=5
PKG_RELEASE:=6
LUCI_MINIFY_CSS:=
CONFIG_LUCI_CSSTIDY:=

View File

@ -4735,6 +4735,45 @@ body.modal-overlay-active #modal_overlay .modal.cbi-modal .cbi-value-field.cbi-d
margin-bottom: 1rem;
}
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table {
width: 100% !important;
table-layout: fixed !important;
}
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table .tr > .td,
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table .tr > .th {
display: table-cell !important;
vertical-align: top !important;
}
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table .tr > .td:first-child,
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table .tr > .th:first-child {
width: 14rem !important;
}
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table .td[data-title="Entry"],
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table .th[data-title="Entry"] {
width: 14rem !important;
}
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table .td[data-title="Value"],
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table .td[data-title="值"] {
text-align: center !important;
white-space: normal !important;
}
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table .td[data-title="Value"] output,
body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"] .table.cbi-section-table .td[data-title="值"] output {
display: block !important;
width: min(100%, 72rem);
max-width: 100%;
margin: 0 auto;
white-space: normal !important;
overflow-wrap: anywhere !important;
word-break: break-word !important;
text-align: center !important;
}
[data-page="admin-network-network"] .ifacebox {
margin: 0.5rem;
border-radius: 8px;

View File

@ -77,12 +77,32 @@ return baseclass.extend({
window.location.replace(url.toString());
},
getFileListStamp: function(path, suffix) {
return L.resolveDefault(fs.list(path), []).then(function(entries) {
return entries.filter(function(entry) {
return entry && entry.type === 'file' &&
(suffix == null || entry.name.endsWith(suffix));
}).map(function(entry) {
return [ entry.name, entry.mtime || 0, entry.size || 0 ].join(':');
}).sort().join('|');
});
},
getConditionalMenuStamp: function() {
var googleFuMode = !!document.querySelector('.navbar a[href*="/admin/services/openclash"]');
return Promise.resolve(JSON.stringify({
google_fu_mode: googleFuMode
}));
return Promise.all([
this.getFileListStamp('/usr/share/luci/menu.d', '.json'),
this.getFileListStamp('/usr/share/rpcd/acl.d', '.json'),
L.resolveDefault(fs.stat('/etc/config/wireless'), null)
]).then(function(stamps) {
return JSON.stringify({
google_fu_mode: googleFuMode,
menu_definitions: stamps[0],
acl_definitions: stamps[1],
wireless_config: stamps[2] ? [ stamps[2].mtime || 0, stamps[2].size || 0 ].join(':') : 'absent'
});
});
},
flushBackendMenuCache: function() {

View File

@ -4,10 +4,13 @@ import { glob, unlink } from 'fs';
return {
menu_flush: function() {
for (let path in glob('/tmp/luci-indexcache', '/tmp/luci-indexcache.*'))
for (let path in glob(
'/tmp/luci-indexcache', '/tmp/luci-indexcache.*',
'/var/luci-indexcache', '/var/luci-indexcache.*'
))
unlink(path);
system('rm -rf /tmp/luci-modulecache 2>/dev/null');
system('rm -rf /tmp/luci-modulecache /var/luci-modulecache 2>/dev/null');
http.prepare_content('application/json');
http.write_json({ flushed: true });

View File

@ -1,14 +1,14 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=mihomo-alpha
PKG_VERSION:=2026.04.20
PKG_RELEASE:=5
PKG_VERSION:=2026.05.08
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:=c59c99a051c5c4fb3c2a7454d7c00fea9a8ee4bb
PKG_SOURCE_VERSION:=35d5d4e44d7a7b8352617b1fec7faf0ba26efc8a
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-c59c99a0
PKG_BUILD_VERSION:=alpha-35d5d4e4
PKG_BUILD_TIME:=$(shell date -u -Iseconds)
GO_PKG:=github.com/metacubex/mihomo

View File

@ -8,8 +8,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=ooniprobe
PKG_VERSION:=3.29.0
PKG_RELEASE:=1
PKG_VERSION:=3.29.1
PKG_RELEASE:=2
PKG_SOURCE:=probe-cli-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/ooni/probe-cli/tar.gz/v$(PKG_VERSION)?

View File

@ -8,7 +8,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=qminfo
PKG_RELEASE:=1
PKG_VERSION=0.0.1
PKG_RELEASE:=2
include $(INCLUDE_DIR)/package.mk

View File

@ -348,17 +348,17 @@ static void emit_results(void)
printf(" CID : %llu\n",(unsigned long long)info.cid);
printf(" eNB ID : %u\n", info.enb_id);
printf(" Sector : %u\n", info.cell_sector);
if (info.has_arfcn) printf(" ARFCN : %u\n", info.arfcn);
if (info.has_arfcn) printf(" RF Chan. : %u\n", info.arfcn);
if (info.has_pci) printf(" PCI : %u\n", info.pci);
if (dist_km >= 0) printf(" Distance : ~%.2f km (TA=%u)\n", dist_km, info.ta);
if (info.has_bw_dl && strcmp(info.mode, "LTE") == 0)
printf(" BW DL : %.1f MHz\n", bw_index_to_khz(info.bw_dl) / 1000.0f);
printf("-------------------------------------------------\n");
if (info.has_csq) printf(" Strength : %d%% (CSQ %d)\n", csq_pct, info.csq);
printf(" RSSI : %d dBm\n", info.rssi);
if (info.has_rsrp) printf(" RSRP : %d dBm\n", (int)roundf(info.rsrp));
if (info.has_rsrq) printf(" RSRQ : %d dB\n", (int)roundf(info.rsrq));
if (info.has_sinr) printf(" %s : %d dB\n", siname, (int)roundf(info.sinr / 10.0f));
if (info.has_csq) printf(" CSQ : %d (%d%%)\n", info.csq, csq_pct);
if (info.has_ca) {
printf(" LTE-A SCC : %d — %s\n", info.lte_ca, info.scc_bands);
printf(" BW CA : %.1f MHz\n", info.bw_ca_total / 1000.0f);
@ -380,7 +380,7 @@ static void emit_results(void)
const char *csq_col = "";
if (info.has_csq && csq_pct >= 0)
csq_col = (info.csq > 20) ? "green" : (info.csq > 10) ? "yellow" : "red";
csq_col = (info.csq > 20) ? "green" : (info.csq > 10) ? "orange" : "red";
char csq_per_str[16] = "";
if (csq_pct >= 0) snprintf(csq_per_str, sizeof(csq_per_str), "%d", csq_pct);
@ -967,10 +967,10 @@ static void on_model(QmiClientDms *client, GAsyncResult *res, gpointer ud)
}
info.has_model = TRUE;
} else if (usb_product[0]) {
strncpy(info.model, usb_product, sizeof(info.model) - 1);
g_strlcpy(info.model, usb_product, sizeof(info.model));
info.has_model = TRUE;
} else if (usb_manufacturer[0]) {
strncpy(info.model, usb_manufacturer, sizeof(info.model) - 1);
g_strlcpy(info.model, usb_manufacturer, sizeof(info.model));
info.has_model = TRUE;
}