mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-07-27 10:31:38 +08:00
🐤 Sync 2026-05-08 20:31:49
This commit is contained in:
parent
dc55b8fdfe
commit
e2f4c3d0b5
@ -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)?
|
||||
|
||||
@ -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)?
|
||||
|
||||
@ -33,6 +33,7 @@ file = "/etc/hickory-dns/0.zone"
|
||||
[[zones]]
|
||||
zone = "lan"
|
||||
zone_type = "Primary"
|
||||
axfr_policy = "AllowAll"
|
||||
|
||||
[zones.stores]
|
||||
type = "sqlite"
|
||||
|
||||
22
hickory-dns/files/etc/hotplug.d/iface/99-hickory-dns
Normal file
22
hickory-dns/files/etc/hotplug.d/iface/99-hickory-dns
Normal 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
|
||||
@ -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
1
ipv6-neigh/Cargo.lock
generated
@ -558,6 +558,7 @@ dependencies = [
|
||||
"env_logger",
|
||||
"futures",
|
||||
"hickory-proto",
|
||||
"libc",
|
||||
"log",
|
||||
"netlink-packet-core",
|
||||
"netlink-packet-route",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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(®istered).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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:=
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)?
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user