🚀 Sync 2026-05-14 20:40:35

This commit is contained in:
github-actions[bot] 2026-05-14 20:40:35 +08:00
parent 4a400776b3
commit 903d80abd4
35 changed files with 710 additions and 611 deletions

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=3proxy
PKG_VERSION:=0.9.5
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/3proxy/3proxy/tar.gz/$(PKG_VERSION)?

View File

@ -1,5 +1,5 @@
config 3proxy 'config'
config 3proxy
option enabled '0'
option conffile '/etc/3proxy/3proxy.cfg'

View File

@ -6,17 +6,21 @@ USE_PROCD=1
CONF="3proxy"
PROG="/usr/bin/3proxy"
start_service() {
config_load "$CONF"
start_instance() {
local cfg="$1"
local enabled conffile
config_get_bool enabled "config" "enabled" "0"
config_get_bool enabled "$cfg" "enabled" "0"
[ "$enabled" -eq "1" ] || return 0
config_get conffile "config" "conffile" "/etc/3proxy/3proxy.cfg"
config_get conffile "$cfg" "conffile"
if [ -z "$conffile" ] || [ ! -e "$conffile" ]; then
logger -p daemon.err -t "$NAME" "config for $cfg does not exists."
return 1
fi
procd_open_instance
procd_open_instance "$cfg"
procd_set_param command "$PROG" "$conffile"
procd_set_param stdout "1"
@ -28,6 +32,12 @@ start_service() {
procd_close_instance
}
start_service() {
config_load "$CONF"
config_foreach start_instance 3proxy
}
service_triggers() {
procd_add_reload_trigger "$CONF"
}

View File

@ -7,7 +7,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=filebrowser
PKG_VERSION:=1.3.1-stable
PKG_VERSION:=1.3.2-stable
PKG_RELEASE=1
ifeq ($(ARCH),aarch64)

View File

@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=hickory-dns
PKG_VERSION:=0.26.1
PKG_RELEASE:=26
PKG_RELEASE:=27
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

@ -11,7 +11,7 @@ i=0
while [ "$i" -lt "$RETRY" ]; do
if ss -lnt | grep -q ':53'; then
logger -t ipv6-neigh-wrapper "DNS port 53 ready, starting ipv6-neigh"
exec "$NEIGH_PROG" --private-subnet-v4 -d "[::1]:53" -z lan --router-alias router
exec "$NEIGH_PROG" --private-subnet-v4 -d "[::1]:53" -z lan --router-alias router --keepalive-gua
fi
i=$((i + 1))
sleep 1

186
ipv6-neigh/Cargo.lock generated
View File

@ -73,12 +73,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@ -163,16 +157,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
@ -410,14 +394,13 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hickory-proto"
version = "0.26.0-beta.3"
source = "git+https://github.com/hickory-dns/hickory-dns.git?rev=34a4cca1a707b642052b427938b7d1e8aaa71e3a#34a4cca1a707b642052b427938b7d1e8aaa71e3a"
version = "0.27.0-alpha.1"
source = "git+https://github.com/hickory-dns/hickory-dns.git?rev=c5e29b9da8334a14961a6326ad04e25397205430#c5e29b9da8334a14961a6326ad04e25397205430"
dependencies = [
"bitflags",
"data-encoding",
"idna",
"ipnet",
"jni",
"once_cell",
"rand",
"ring",
@ -558,14 +541,13 @@ dependencies = [
"env_logger",
"futures",
"hickory-proto",
"libc",
"log",
"netlink-packet-core",
"netlink-packet-route",
"netlink-sys",
"rtnetlink",
"serde_json",
"socket2 0.5.10",
"socket2",
"tokio",
"ubus",
]
@ -612,55 +594,6 @@ dependencies = [
"syn",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
@ -713,47 +646,30 @@ dependencies = [
[[package]]
name = "netlink-packet-core"
version = "0.7.0"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4"
checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4"
dependencies = [
"anyhow",
"byteorder",
"netlink-packet-utils",
"paste",
]
[[package]]
name = "netlink-packet-route"
version = "0.21.0"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "483325d4bfef65699214858f097d504eb812c38ce7077d165f301ec406c3066e"
checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092"
dependencies = [
"anyhow",
"bitflags",
"byteorder",
"libc",
"log",
"netlink-packet-core",
"netlink-packet-utils",
]
[[package]]
name = "netlink-packet-utils"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34"
dependencies = [
"anyhow",
"byteorder",
"paste",
"thiserror 1.0.69",
]
[[package]]
name = "netlink-proto"
version = "0.11.5"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72452e012c2f8d612410d89eea01e2d9b56205274abb35d53f60200b2ec41d60"
checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128"
dependencies = [
"bytes",
"futures",
@ -778,9 +694,9 @@ dependencies = [
[[package]]
name = "nix"
version = "0.29.0"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"cfg-if",
@ -986,14 +902,15 @@ dependencies = [
[[package]]
name = "rtnetlink"
version = "0.14.1"
source = "git+https://github.com/rust-netlink/rtnetlink.git?rev=515471f#515471fa0d759316ab249b7c265f3073b3dc1ffb"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc19f84f710fa2f337617f9bc0400260a94224bde7bae28fd8879f3771ca5784"
dependencies = [
"futures",
"futures-channel",
"futures-util",
"log",
"netlink-packet-core",
"netlink-packet-route",
"netlink-packet-utils",
"netlink-proto",
"netlink-sys",
"nix",
@ -1001,15 +918,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@ -1019,15 +927,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -1099,22 +998,6 @@ dependencies = [
"libc",
]
[[package]]
name = "simd_cesu8"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "slab"
version = "0.4.12"
@ -1127,16 +1010,6 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.3"
@ -1283,7 +1156,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.3",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
@ -1372,16 +1245,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@ -1440,15 +1303,6 @@ dependencies = [
"semver",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-link"
version = "0.2.1"
@ -1662,9 +1516,9 @@ dependencies = [
[[package]]
name = "zerofrom"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]

View File

@ -6,20 +6,19 @@ edition = "2024"
[workspace]
[dependencies]
rtnetlink = { git = "https://github.com/rust-netlink/rtnetlink.git", rev = "515471f" }
rtnetlink = { version = "0.21.0" }
netlink-sys = { version = "0.8" }
tokio = { version = "1.0.1", features = ["full"]}
futures = "0.3.11"
netlink-packet-route = {version = "0.21" }
netlink-packet-core= {version ="0.7"}
clap = { version = "4.0", default-features = false, features = ["cargo", "derive", "help", "std", "suggestions"] }
hickory-proto = { git = "https://github.com/hickory-dns/hickory-dns.git", rev = "34a4cca1a707b642052b427938b7d1e8aaa71e3a", features = ["dnssec-ring"] }
ubus = "0.1.6"
serde_json = "1.0.108"
tokio = { version = "1.52.3", features = ["full"]}
futures = "0.3.32"
netlink-packet-route = {version = "0.30" }
netlink-packet-core= {version ="0.8"}
clap = { version = "4.6", default-features = false, features = ["cargo", "derive", "help", "std", "suggestions"] }
hickory-proto = { git = "https://github.com/hickory-dns/hickory-dns.git", rev = "c5e29b9da8334a14961a6326ad04e25397205430", features = ["dnssec-ring"] }
ubus = "0.1.7"
serde_json = "1.0.149"
log = "0.4"
env_logger = "0.11"
socket2 = "0.5"
libc = "0.2"
socket2 = { version = "0.6", features = ["all"] }
[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:=9
PKG_RELEASE:=10
PKG_BUILD_DEPENDS:=rust/host
PKG_BUILD_PARALLEL:=1

View File

@ -148,7 +148,7 @@ impl DnsUpdater {
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 query = Query::new(self.zone.clone(), RecordType::AXFR);
let mut msg = Message::query();
msg.add_query(query);

View File

@ -13,7 +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 std::num::NonZeroU32;
use tokio::time::{self, Duration};
use clap::Parser;
@ -67,6 +67,18 @@ struct Cli {
/// 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 = "1")]
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,
}
#[derive(Debug)]
@ -91,12 +103,28 @@ struct RegisteredEntry {
ifindex: u32,
}
/// A GUA address tracked for keepalive probing only (not published to DNS).
struct GuaKeepaliveEntry {
hostname: String,
addr: Ipv6Addr,
ifindex: u32,
/// When this GUA was first observed — used to select the "newest" address per host.
first_seen: Instant,
/// Last time this address was confirmed REACHABLE by the kernel.
last_confirmed: Instant,
}
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
}
/// Returns true if `addr` is a Global Unicast Address (2000::/3).
fn is_gua_ipv6(addr: &Ipv6Addr) -> bool {
(addr.segments()[0] & 0xe000) == 0x2000
}
fn if_ipv4_in_private_subnet(ip: &Ipv4Addr) -> bool {
// Check for private network ranges
let octets = ip.octets();
@ -142,11 +170,18 @@ fn ipv6_passes_active_prefix(addr: Ipv6Addr, active_prefixes: &[LanPrefix]) -> b
active_prefixes.is_empty() || active_prefixes.iter().any(|p| ipv6_in_prefix(addr, p))
}
async fn process_new_neigh(neigh: &Neigh, updater: &db::DnsUpdater, leases: &HashMap<String, String>) -> bool {
async fn process_new_neigh(neigh: &Neigh, updater: &db::DnsUpdater, leases: &HashMap<String, String>, private_subnet_v6: bool) -> bool {
let Some(hostname) = leases.get(&neigh.mac) else {
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) {
debug!("skipping GUA DNS publish for {} (private_subnet_v6)", hostname);
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,
@ -196,6 +231,51 @@ fn is_failed_state(state: NeighbourState) -> bool {
matches!(state, NeighbourState::Failed)
}
/// Enforce the per-host ULA AAAA limit. If `mac` already has `max` or more ULA AAAA
/// records in `registered`, remove the oldest (by `last_confirmed`) and delete from DNS.
/// Returns the number of existing ULA AAAA records for this MAC (after pruning).
async fn prune_ula_for_mac(
mac: &str,
max: usize,
registered: &mut HashMap<(String, String), RegisteredEntry>,
updater: &db::DnsUpdater,
) -> usize {
// Collect existing ULA AAAA keys for this MAC.
let mut ula_keys: Vec<((String, String), Instant)> = registered
.iter()
.filter(|((m, ip_str), _)| {
m == mac && ip_str.parse::<Ipv6Addr>().map_or(false, |a| if_ipv6_in_private_subnet(&a))
})
.map(|(k, e)| (k.clone(), e.last_confirmed))
.collect();
if ula_keys.len() < max {
return ula_keys.len();
}
// 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));
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 mac {}", entry.hostname, key.1, mac);
}
}
// Return count after pruning.
registered
.iter()
.filter(|((m, ip_str), _)| {
m == mac && ip_str.parse::<Ipv6Addr>().map_or(false, |a| if_ipv6_in_private_subnet(&a))
})
.count()
}
#[tokio::main]
async fn main() -> Result<(), ()> {
let args = Cli::parse();
@ -214,6 +294,10 @@ async fn main() -> Result<(), ()> {
let private_subnet_v4 = args.private_subnet_v4;
let private_subnet_v6 = !args.publish_gua;
let keepalive_gua = args.keepalive_gua;
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 key_data = std::fs::read(&args.key_file)
@ -257,9 +341,16 @@ async fn main() -> Result<(), ()> {
// 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.
let active_prefixes = get_active_lan_prefixes(handle.clone(), &args.router_iface, private_subnet_v6).await;
// 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() {
warn!("no active IPv6 prefixes found on {}, neighbour prefix-filtering disabled", args.router_iface);
} else {
@ -275,12 +366,13 @@ 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.
debug!("dumping neighbours");
if let Ok(neighbours) = dump_neighbours(handle.clone(), private_subnet_v4, private_subnet_v6).await {
if let Ok(neighbours) = dump_neighbours(handle.clone(), private_subnet_v4).await {
for neigh in &neighbours {
debug!("{:?}", neigh);
if should_skip_neigh(neigh) {
@ -293,12 +385,54 @@ async fn main() -> Result<(), ()> {
continue;
}
}
// Route GUA addresses to keepalive map instead of DNS.
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if is_gua_ipv6(addr) {
if keepalive_gua {
if neigh.state == NeighbourState::Reachable {
if let Some(hostname) = leases.get(&neigh.mac) {
let entries = gua_keepalive.entry(neigh.mac.clone()).or_default();
if !entries.iter().any(|e| e.addr == *addr) {
let now = Instant::now();
entries.push(GuaKeepaliveEntry {
hostname: hostname.clone(),
addr: *addr,
ifindex: neigh.ifindex,
first_seen: now,
last_confirmed: now,
});
debug!("init dump: GUA keepalive tracked {} -> {}", hostname, addr);
}
}
} 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) {
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;
}
}
}
let key = (neigh.mac.clone(), inet_to_string(&neigh.inet));
match neigh.state {
NeighbourState::Reachable => {
// Confirmed online — register in DNS immediately.
if !registered.contains_key(&key) {
if let Some(hostname) = leases.get(&neigh.mac) {
// 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_mac(&neigh.mac, 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,
@ -369,6 +503,14 @@ async fn main() -> Result<(), ()> {
};
probe_timer.tick().await; // consume the immediate first tick
// 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
let mut lease_refresh_timer = time::interval(Duration::from_secs(60));
lease_refresh_timer.tick().await; // consume the immediate first tick
@ -383,24 +525,70 @@ async fn main() -> Result<(), ()> {
if let NetlinkPayload::InnerMessage(msg) = payload {
match msg {
RouteNetlinkMessage::NewNeighbour(new_neigh) => {
let Some(neigh) = parse_neighbour_message(new_neigh, private_subnet_v4, private_subnet_v6) else {
let Some(neigh) = parse_neighbour_message(new_neigh, private_subnet_v4) else {
continue;
};
if should_skip_neigh(&neigh) {
continue;
}
let key = (neigh.mac.clone(), inet_to_string(&neigh.inet));
// 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);
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);
}
continue;
}
}
// 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) {
entries.retain(|e| e.addr != *addr);
debug!("GUA keepalive: removed failed {} for mac {}", addr, neigh.mac);
}
} else if neigh.state == NeighbourState::Reachable {
if let Some(hostname) = leases.get(&neigh.mac) {
let entries = gua_keepalive.entry(neigh.mac.clone()).or_default();
if let Some(e) = entries.iter_mut().find(|e| e.addr == *addr) {
e.last_confirmed = Instant::now();
e.ifindex = neigh.ifindex;
} else {
let now = Instant::now();
entries.push(GuaKeepaliveEntry {
hostname: hostname.clone(),
addr: *addr,
ifindex: neigh.ifindex,
first_seen: now,
last_confirmed: now,
});
debug!("GUA keepalive: tracking {} -> {}", hostname, addr);
}
}
} 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;
}
}
}
}
continue;
}
}
let key = (neigh.mac.clone(), inet_to_string(&neigh.inet));
if is_failed_state(neigh.state) {
// Neighbour confirmed unreachable — remove DNS record
if let Some(entry) = registered.remove(&key) {
@ -419,7 +607,13 @@ async fn main() -> Result<(), ()> {
// New reachable neighbour — register in DNS
debug!("New neighbour: {:?}", neigh);
if let Some(hostname) = leases.get(&neigh.mac) {
if process_new_neigh(&neigh, &updater, &leases).await {
// Enforce per-host ULA limit before adding.
if let NeighbourAddress::Inet6(addr) = &neigh.inet {
if if_ipv6_in_private_subnet(addr) {
prune_ula_for_mac(&neigh.mac, 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(),
@ -441,12 +635,24 @@ async fn main() -> Result<(), ()> {
}
}
RouteNetlinkMessage::DelNeighbour(del_neigh) => {
let Some(neigh) = parse_neighbour_message(del_neigh, private_subnet_v4, private_subnet_v6) else {
let Some(neigh) = parse_neighbour_message(del_neigh, private_subnet_v4) else {
continue;
};
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) {
entries.retain(|e| e.addr != *addr);
debug!("GUA keepalive: removed deleted {} for mac {}", addr, neigh.mac);
}
if private_subnet_v6 {
continue;
}
}
}
let key = (neigh.mac.clone(), inet_to_string(&neigh.inet));
let stored_hostname = registered.remove(&key).map(|e| e.hostname);
debug!("Del neighbour: {:?}", neigh);
@ -477,10 +683,21 @@ async fn main() -> Result<(), ()> {
)
.await;
}
_ = gua_keepalive_timer.tick() => {
if !keepalive_gua || keepalive_gua_interval == 0 {
continue;
}
probe_gua_keepalive(&gua_keepalive, keepalive_gua_per_host);
prune_gua_keepalive(&mut gua_keepalive, keepalive_gua_interval, keepalive_gua_per_host);
}
_ = lease_refresh_timer.tick() => {
if let Ok(new_leases) = op::get_lease() {
leases = new_leases;
debug!("refreshed {} DHCP leases", leases.len());
match tokio::task::spawn_blocking(|| op::get_lease().map_err(|e| e.to_string())).await {
Ok(Ok(new_leases)) => {
leases = new_leases;
debug!("refreshed {} DHCP leases", leases.len());
}
Ok(Err(e)) => warn!("failed to refresh DHCP leases from ubus: {}", e),
Err(e) => warn!("DHCP lease refresh task panicked: {}", e),
}
}
}
@ -640,26 +857,83 @@ async fn probe_registered_neighbours(registered: &HashMap<(String, String), Regi
}
}
/// Send ICMPv6 Echo Request to the newest GUA addresses per host for NUD keepalive.
/// Only the top `per_host` addresses (by most-recently-seen) are probed per MAC.
/// This does NOT publish any records to DNS.
fn probe_gua_keepalive(
gua_keepalive: &HashMap<String, Vec<GuaKeepaliveEntry>>,
per_host: usize,
) {
let now = Instant::now();
for (mac, entries) in gua_keepalive.iter() {
// Sort indices by first_seen descending to pick the newest GUAs.
let mut indices: Vec<usize> = (0..entries.len()).collect();
indices.sort_by(|a, b| entries[*b].first_seen.cmp(&entries[*a].first_seen));
for &idx in indices.iter().take(per_host) {
let e = &entries[idx];
// Skip entries confirmed recently (within 30s)
if now.duration_since(e.last_confirmed) < Duration::from_secs(30) {
continue;
}
match 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),
}
}
}
}
/// Remove GUA keepalive entries that haven't been confirmed REACHABLE within 3× the
/// keepalive interval, and drop excess entries beyond `per_host` (oldest first).
fn prune_gua_keepalive(
gua_keepalive: &mut HashMap<String, Vec<GuaKeepaliveEntry>>,
keepalive_interval: u64,
per_host: usize,
) {
let timeout = Duration::from_secs(keepalive_interval.saturating_mul(3));
let now = Instant::now();
gua_keepalive.retain(|_, entries| {
// Remove timed-out entries first.
entries.retain(|e| now.duration_since(e.last_confirmed) < timeout);
// Then keep only the newest `per_host` entries (by first_seen desc).
if entries.len() > per_host {
entries.sort_by(|a, b| b.first_seen.cmp(&a.first_seen));
entries.truncate(per_host);
}
!entries.is_empty()
});
}
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;
let mut sum = 0u32;
for chunk in packet.chunks(2) {
let word = u16::from_be_bytes([chunk[0], *chunk.get(1).unwrap_or(&0)]);
sum = sum.wrapping_add(word as u32);
}
while (sum >> 16) > 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
let checksum = !(sum as u16);
packet[2..4].copy_from_slice(&checksum.to_be_bytes());
}
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 {
let ret = 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,
)
};
if ret != 0 {
return Err(std::io::Error::last_os_error());
}
}
set_ipv6_unicast_if(&socket, 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));
@ -671,24 +945,10 @@ 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 {
let ret = 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,
)
};
if ret != 0 {
return Err(std::io::Error::last_os_error());
}
}
// 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];
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(())
@ -715,7 +975,7 @@ 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, private_subnet_v6: bool) -> Option<Neigh> {
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) {
@ -729,23 +989,16 @@ fn parse_neighbour_message(neigh: NeighbourMessage, private_subnet_v4: bool, pri
if is_link_local_ipv6(&addr) {
return None;
}
match addr {
NeighbourAddress::Inet(addr) => {
if private_subnet_v4 && !if_ipv4_in_private_subnet(&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;
}
NeighbourAddress::Inet6(addr) => {
if private_subnet_v6 && !if_ipv6_in_private_subnet(&addr) {
return None;
}
}
_ => {}
};
}
let kind = neigh.header.kind;
let ifindex = neigh.header.ifindex;
let mac = neigh.attributes.iter().find_map(|attr| match attr {
NeighbourAttribute::LinkLocalAddress(mac) => Some(mac.to_owned()),
NeighbourAttribute::LinkLayerAddress(mac) => Some(mac.to_owned()),
_ => None,
})?;
let mac_str = format_mac(mac);
@ -762,11 +1015,11 @@ fn parse_neighbour_message(neigh: NeighbourMessage, private_subnet_v4: bool, pri
})
}
async fn dump_neighbours(handle: Handle, private_subnet_v4: bool, private_subnet_v6: bool) -> Result<Vec<Neigh>, Error> {
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, private_subnet_v6) {
if let Some(neigh) = parse_neighbour_message(route, private_subnet_v4) {
if !should_skip_neigh(&neigh) {
vec.push(neigh);
}

View File

@ -16,7 +16,7 @@ pub fn call_ubus(obj_path: &str, method: &str) -> Result<Value, Box<dyn std::err
fn format_mac_from_hex(hex: &str) -> String {
hex.as_bytes()
.chunks(2)
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.filter_map(|chunk| std::str::from_utf8(chunk).ok())
.collect::<Vec<&str>>()
.join(":")
}

View File

@ -16,8 +16,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-amlogic
PKG_VERSION:=3.1.312
PKG_RELEASE:=10
PKG_VERSION:=3.1.313
PKG_RELEASE:=11
PKG_LICENSE:=GPL-2.0 License
PKG_MAINTAINER:=ophub <https://github.com/ophub/luci-app-amlogic>

View File

@ -27,7 +27,9 @@ return view.extend({
]);
},
render: function (data) {
// Render a tabbed form section per CPU policy (cluster), with options to
// set the governor and min/max frequency. The form is bound to the amlogic.armcpu UCI section.
render: function (data) {
const policies = data[0] || [];
// Auto-create the armcpu section when missing, so that on a fresh

View File

@ -1,13 +1,10 @@
// SPDX-License-Identifier: GPL-2.0
// Backup Firmware Config + Snapshot management + (optional) KVM switch
//
// This page exposes three groups of capabilities:
// 1. Pack the current config into openwrt_config.tar.gz and let the user
// download it;
// 2. List / create / restore / delete etc snapshots (etc-000 / etc-001 are
// the read-only initial / update snapshots and cannot be deleted);
// 3. If the backend reports has_kvm, render the KVM dual-system switch
// button.
// Purpose: pack current /etc config into openwrt_config.tar.gz and download it;
// manage system snapshots (list / create / restore / delete); optionally show
// KVM dual-partition switch when the platform reports has_kvm.
// Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (backup_create, snapshot_*, platform_info, kvm_switch).
'use strict';
'require view';
@ -36,17 +33,19 @@ const callPlatform = rpc.declare({ object: 'luci.amlogic', method: 'platform_i
// Switch active KVM partition.
const callKvmSwitch = rpc.declare({ object: 'luci.amlogic', method: 'kvm_switch' });
// The view object for the Backup & Snapshot page.
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
// Load in parallel: platform info + current snapshot list.
load: function () {
// Load in parallel: platform info + current snapshot list.
amlogicShared.ensureCss();
return Promise.all([callPlatform(), callSnapList()]);
},
// Render the page: backup config section + snapshot management section + (optional) KVM switch section.
render: function (data) {
const platform = data[0] || {};
const snapNames = data[1] || [];
@ -61,8 +60,7 @@ return view.extend({
}
});
// Download status hint: uses the warning color; replaced by the
// success message after a successful generation.
// Download status hint; updated to success color after generation completes.
const downloadStatus = E('span', { class: 'amlogic-status-err' });
const downloadBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
@ -80,10 +78,7 @@ return view.extend({
// Switch to success color before streaming the blob
downloadStatus.className = 'amlogic-status-ok';
downloadStatus.textContent = _('The file Will download automatically.') + ' ' + r.path;
// Step 2: fetch the file as a Blob via cgi-download (bypasses
// ubus message-size limits) and trigger a browser download.
// We use fs.read_direct which internally POST to cgi-download
// with the correct sessionid.
// Fetch the file blob via fs.read_direct and trigger browser download.
return fs.read_direct(r.path, 'blob').then(function (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@ -104,7 +99,8 @@ return view.extend({
})
});
const restoreBtn = E('input', {
// Navigate to the upload page where the user can upload a backup file to restore.
const restoreBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Upload Backup'),
click: function () {
@ -112,16 +108,11 @@ return view.extend({
}
});
// Snapshots
// Snapshot grid: uses amlogic-snap-list / amlogic-snap-item /
// amlogic-snap-line classes whose styles adapt to the current theme
// (see amlogic.css).
// Snapshot grid; styles adapt to the current theme via amlogic-snap-* classes.
const snapDiv = E('div', { class: 'amlogic-snap-list' });
// Rebuild the snapshot cards from a list of names:
// - etc-000 / etc-001 are read-only and do not get a Delete button;
// - other snapshots get both Restore and Delete buttons.
// The backend takes the short name (without the "etc-" prefix).
// Render snapshot cards; etc-000/001 are read-only (no Delete button).
// Backend expects the short name without the "etc-" prefix.
function renderSnapshots(names) {
snapDiv.innerHTML = '';
if (!names || !names.length) {
@ -143,8 +134,7 @@ return view.extend({
if (!confirm(_('You selected a snapshot:') + ' [ ' + n + ' ] , ' +
_('Confirm recovery and restart OpenWrt?')))
return;
// Capture the button now: ev.currentTarget is null once the
// promise chain resumes asynchronously.
// Capture btn before entering async chain; ev.currentTarget becomes null after await.
const btn = ev.currentTarget;
btn.disabled = true;
btn.value = _('Restoring...');
@ -176,8 +166,7 @@ return view.extend({
if (!confirm(_('You selected a snapshot:') + ' [ ' + n + ' ] , ' +
_('Confirm delete?')))
return;
// Capture the button: ev.currentTarget is null in the
// async then() that runs after the RPC resolves.
// Capture btn before entering async chain; ev.currentTarget becomes null after await.
const btn = ev.currentTarget;
btn.disabled = true;
btn.value = _('Deleting...');
@ -198,7 +187,8 @@ return view.extend({
}
renderSnapshots(snapNames);
const createSnapBtn = E('input', {
// Create snapshot button above the grid.
const createSnapBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Create Snapshot'),
click: ui.createHandlerFn(view, function (ev) {
@ -212,7 +202,8 @@ return view.extend({
})
});
const sections = [
// If the platform supports KVM dual partitions, show a button to trigger partition switch.
const sections = [
E('h2', _('Backup Firmware Config')),
E('p', _('Backup OpenWrt config (openwrt_config.tar.gz). Use this file to restore the config in [Manually Upload Update].')),
E('div', { class: 'cbi-section' }, [
@ -239,7 +230,8 @@ return view.extend({
])
];
if (platform.has_kvm) {
// If the platform supports KVM dual partitions, show a button to trigger partition switch.
if (platform.has_kvm) {
const kvmBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Switch System'),

View File

@ -1,10 +1,10 @@
// SPDX-License-Identifier: GPL-2.0
// Backup file list editor
//
// Edits /etc/amlogic_backup_list.conf, which controls which files / directories
// the openwrt-backup script archives. On load, if the file does not exist or
// is empty, the backend prime_backup_list extracts the default BACKUP_LIST
// from /usr/sbin/openwrt-backup as a starting template.
// Purpose: edit /etc/amlogic_backup_list.conf which controls which files/dirs
// the openwrt-backup script archives. If the file is missing or empty, the
// backend seeds it from the default BACKUP_LIST in /usr/sbin/openwrt-backup.
// Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (save_backup_list, prime_backup_list).
'use strict';
'require view';
@ -15,24 +15,27 @@
// Persist the user-edited content back to /etc/amlogic_backup_list.conf.
const callSave = rpc.declare({ object: 'luci.amlogic', method: 'save_backup_list',
params: ['content'] });
// Pre-fill: if the list file is missing or empty, seed it from the defaults
// embedded in the openwrt-backup script (idempotent: existing content stays).
// Seed the list file from defaults embedded in openwrt-backup if missing or empty.
const callPrime = rpc.declare({ object: 'luci.amlogic', method: 'prime_backup_list' });
// This page uses its own Save button; hide LuCI's default Save/Apply/Reset.
return view.extend({
// This page uses its own Save button; hide LuCI's default Save/Apply/Reset.
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function () {
// Load the existing backup list content to pre-fill the editor. If the file is
load: function () {
// Run prime first (idempotent), then read the file content.
return callPrime().then(function () {
return fs.read('/etc/amlogic_backup_list.conf').catch(function () { return ''; });
});
},
render: function (text) {
// Render a full-width textarea pre-filled with the existing content, and a Save button
// that persists the changes via RPC. Also include a Back button to return to the main Backup page.
render: function (text) {
// Full-width multi-line editor pre-filled with the existing list.
const ta = E('textarea', {
rows: '30',
@ -50,7 +53,7 @@ return view.extend({
return callSave(ta.value).then(function (r) {
if (r && (r.ok || r.code == 0)) {
status.textContent = _('Successfully saved.');
// Wait 0.7s before navigating back so the success text is visible.
// Navigate back after a short delay so the success message is visible.
setTimeout(function () {
location.href = L.url('admin/system/amlogic/backup');
}, 700);

View File

@ -1,9 +1,11 @@
// SPDX-License-Identifier: GPL-2.0
// Online Download Update + Rescue Kernel
//
// Flow matches the Lua version: check_* RPCs write progress logs; the final
// log line is a raw HTML <input> button string that we parse and render as a
// real DOM button so the user can explicitly click Download → then Update.
// Purpose: check / download / install plugin, kernel, and firmware updates online.
// Log tail is polled at 1 Hz; the last log line drives a state machine
// (idle → checking → button → installing → done). Terminal log lines contain
// raw HTML <input> button strings which are parsed and rendered as DOM buttons.
// Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (state, check_*, start_*, read_log_tail).
'use strict';
'require view';
@ -13,8 +15,10 @@
'require poll';
'require view.amlogic.shared as amlogicShared';
// ── RPCs ────────────────────────────────────────────────────────────────────
// ── RPCs ─────────────────────────────────────────────────────────────────────
// Query system state (firmware / plugin / kernel versions).
const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' });
// Run plugin / kernel / firmware version check and write progress to the log.
const callCheckPlugin = rpc.declare({ object: 'luci.amlogic', method: 'check_plugin', params: ['options'] });
const callCheckKernel = rpc.declare({ object: 'luci.amlogic', method: 'check_kernel', params: ['options'] });
const callCheckFirmware = rpc.declare({ object: 'luci.amlogic', method: 'check_firmware', params: ['options'] });
@ -22,14 +26,12 @@ const callStartPlugin = rpc.declare({ object: 'luci.amlogic', method: 'start_p
const callStartKernel = rpc.declare({ object: 'luci.amlogic', method: 'start_kernel' });
const callStartUpdate = rpc.declare({ object: 'luci.amlogic', method: 'start_update', params: ['amlogic_update_sel'] });
const callStartRescue = rpc.declare({ object: 'luci.amlogic', method: 'start_rescue' });
// Read the last line (up to 4096 bytes) of the named log file.
const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail', params: ['name'], expect: { line: '' } });
// ── Log-line HTML button parser ──────────────────────────────────────────────
// Returns null when the line is plain text, or an object when it is a button:
// { type: 'firmware-download'|'kernel-download'|'firmware-update'|
// 'kernel-update'|'plugin-update',
// param: string|null, // extracted onclick argument (if any)
// label: string } // text after the closing />
// Parse an <input> button string from the log; return null for plain-text lines.
// Returned object: { type, param, label } where type identifies the action.
function parseButtonLine(line) {
if (!line || line.indexOf('<input') === -1) return null;
@ -82,12 +84,10 @@ function parseButtonLine(line) {
return null;
}
// ── Per-action state machine ─────────────────────────────────────────────────
// ── Per-action state machine ─────────────────────────────────────────────────
// States: 'idle' | 'checking' | 'button' | 'installing' | 'done'
//
// statusEl the span shown in the right column
// Each machine is a plain object; we keep one per action.
// Action object factory: { name, statusEl, state, subBtn }
function makeAction(name, statusEl) {
return { name: name, statusEl: statusEl, state: 'idle', subBtn: null };
}
@ -123,16 +123,13 @@ function showSubButton(el, btnLabel, labelText, onClick) {
}
// ── Poll handler (called at 1 Hz) ────────────────────────────────────────────
// Inspect the last non-empty log line and advance the action state machine.
function handlePoll(action) {
if (action.state === 'done') return;
return callLogTail(action.name).then(function (raw) {
// read_log_tail returns up to 4096 bytes (multi-line). Only inspect
// the last non-empty line so that intermediate opkg/apk output
// (e.g. "Collected errors:", dependency warnings) cannot trigger a
// false-positive 'Failed'/'error' match before the final result is
// written. The terminal keywords ("Successful Update", "FAILED",
// etc.) are always written as the very last log line.
// Only inspect the last non-empty line to avoid false-positive
// 'Failed'/'error' matches from intermediate opkg/apk output.
var lines = (raw || '').split('\n');
var line = '';
for (var i = lines.length - 1; i >= 0; i--) {
@ -182,8 +179,7 @@ function handlePoll(action) {
});
}
// Render the sub-button extracted from the log line. The onClick triggers the
// appropriate second-phase RPC and transitions to 'installing'.
// Render the sub-button parsed from the log line and wire up the second-phase RPC.
function renderParsedButton(action) {
var parsed = action._parsed;
var el = action.statusEl;
@ -194,8 +190,7 @@ function renderParsedButton(action) {
showSubButton(el, _('Download'), parsed.label, function (ev) {
ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Downloading...');
action.state = 'checking'; // re-enter checking loop, wait for Update button
// parsed.param is e.g. "download_3.1.290" — pass directly to the script
action.state = 'checking';
callCheckPlugin(parsed.param).then(function () {});
});
break;
@ -204,10 +199,8 @@ function renderParsedButton(action) {
showSubButton(el, _('Download'), parsed.label, function (ev) {
ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Downloading...');
action.state = 'checking'; // will re-enter checking loop
callCheckFirmware(parsed.param).then(function () {
// After download, keep polling; log will emit Update button
});
action.state = 'checking';
callCheckFirmware(parsed.param).then(function () {});
});
break;
@ -224,10 +217,7 @@ function renderParsedButton(action) {
showSubButton(el, _('Update'), parsed.label, function (ev) {
ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Updating...');
// Transition to 'installing': poll will show log lines and detect
// the terminal result. callStartUpdate fires the background script
// and returns {code:0} immediately — do NOT use its return value
// to judge success/failure.
// Transition to 'installing'; terminal result comes via log polling.
action.state = 'installing';
dom.content(el, E('span', { class: 'amlogic-status-info' }, _('Starting update...')));
callStartUpdate(parsed.param).catch(function () {
@ -307,7 +297,9 @@ return view.extend({
btn.value = idleLabel;
}
var btnPlugin = E('input', {
// Each button triggers the first-phase RPC to start the check/download/install process,
// then the poll handler picks up log lines to advance the state machine and render sub-buttons as needed.
var btnPlugin = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Only update Amlogic Service'),
click: ui.createHandlerFn(this, function (ev) {
@ -332,7 +324,8 @@ return view.extend({
})
});
var btnKernel = E('input', {
// The kernel and firmware buttons have similar logic, just different RPCs and labels.
var btnKernel = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Update system kernel only'),
click: ui.createHandlerFn(this, function (ev) {
@ -356,7 +349,8 @@ return view.extend({
})
});
var btnFirmware = E('input', {
// Firmware update may include both kernel and plugin updates, so it has a more generic label.
var btnFirmware = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Complete system update'),
click: ui.createHandlerFn(this, function (ev) {
@ -380,7 +374,8 @@ return view.extend({
})
});
var btnRescue = E('input', {
// Rescue button: triggers the rescue RPC which starts mutual recovery; the log will show progress and terminal status.
var btnRescue = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Rescue the original system kernel'),
click: ui.createHandlerFn(this, function (ev) {
@ -390,16 +385,10 @@ return view.extend({
if (actRescue.state !== 'idle') return;
btn.disabled = true;
btn.value = _('Rescuing...');
// Set installing state BEFORE the call so the poller picks up
// log lines. The script runs in the background (shbg); the RPC
// returns {code:0} immediately and we do NOT use it to judge
// success/failure — the poll loop reads LOG_RESCUE and detects
// the terminal line.
// Set installing state before the call so the poller picks up log lines.
actRescue.state = 'installing';
showInfo(actRescue.statusEl, _('Starting rescue...'));
return callStartRescue().then(function () {
// RPC triggered OK; result comes via log polling.
// Re-enable the button so the user can retry if needed.
btn.disabled = false;
btn.value = _('Rescue the original system kernel');
}).catch(function () {

View File

@ -1,14 +1,10 @@
// SPDX-License-Identifier: GPL-2.0
// Plugin Settings
//
// Uses form.Map to maintain the `config` section in /etc/config/amlogic,
// covering:
// - OpenWrt firmware GitHub repo, tag keyword, file suffix
// - Kernel download repo / tag / version branch
// - Keep config, auto-write bootloader, shared partition fs type
// This page uses the default form.Map Save / Apply buttons; saving only
// writes UCI, while other pages (Online Update / Manual Upload) read these
// values to drive the actual operations.
// Purpose: configure the `config` section in /etc/config/amlogic via form.Map,
// covering firmware/kernel GitHub repos, tags, version branch, plugin branch,
// keep-config flag, bootloader write flag, and shared partition fs type.
// Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (platform_info, state).
'use strict';
'require view';
@ -18,12 +14,13 @@
// Get platform info; only used to display the device PLATFORM tag.
const callPlatform = rpc.declare({ object: 'luci.amlogic', method: 'platform_info' });
// Get runtime state; only kernel_release is used here, to derive default
// values for kernel tags / version branch.
// Query runtime state; kernel_release is used to derive default kernel tag and branch.
const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' });
// This page uses its own Save button; hide LuCI's default Save/Apply/Reset.
return view.extend({
load: function () {
// Load platform info + state in parallel, along with the UCI config (we will read/write it).
load: function () {
return Promise.all([
callPlatform(),
callState(),
@ -31,7 +28,8 @@ return view.extend({
]);
},
render: function (data) {
// Render form options bound to the amlogic.config NamedSection, and pre-fill some defaults based on platform/state.
render: function (data) {
const platform = data[0] || {};
const state = data[1] || {};
@ -40,7 +38,8 @@ return view.extend({
uci.add('amlogic', 'amlogic', 'config');
}
const m = new form.Map('amlogic', _('Plugin Settings'),
// Build a form.Map bound to the amlogic.config NamedSection, with options for various plugin settings.
const m = new form.Map('amlogic', _('Plugin Settings'),
_('You can customize the github.com download repository of OpenWrt files and kernels in [Online Download Update].') +
'<br />' + _('Tip: The same files as the current OpenWrt system\'s BOARD (such as rock5b) and kernel (such as 5.10) will be downloaded.'));
const o = m.section(form.NamedSection, 'config', 'amlogic');
@ -82,11 +81,8 @@ return view.extend({
kpath.default = 'https://github.com/breakingbadboy/OpenWrt';
kpath.rmempty = false;
// 6. Kernel tags - the list depends on currently saved kpath value.
// The breakingbadboy/OpenWrt repo only ships rk3588/rk35xx/stable;
// ophub/kernel additionally ships flippy/h6/beta. The default value is
// auto-derived from -rk3588 / -rk35xx / -h6 keywords in `uname` so the
// user does not have to pick the wrong tag.
// 6. Kernel tags; available tags depend on the selected kernel repo.
// Default is auto-derived from kernel_release suffixes (-rk3588/-rk35xx/-h6).
const currentKpath = uci.get('amlogic', 'config', 'amlogic_kernel_path') ||
'https://github.com/breakingbadboy/OpenWrt';
const knownTags = {
@ -99,7 +95,7 @@ return view.extend({
knownTags.kernel_h6 = 'kernel_h6 [Allwinner H6 Kernel]';
knownTags.kernel_beta = 'kernel_beta [Beta Kernel]';
}
// Determine default tag from existing config or uname
// Determine default tag from saved config or kernel_release uname string.
let kernelTagDefault = uci.get('amlogic', 'config', 'amlogic_kernel_tags') || '';
if (!kernelTagDefault) {
const u = state.kernel_release || '';

View File

@ -1,18 +1,16 @@
// SPDX-License-Identifier: GPL-2.0
// Plugin landing / info page
//
// Renders the plugin LOGO, three quick-link badges (packit project, author
// homepage, plugin repository), the feature summary and the list of
// supported boxes. The page does not read or write any UCI config; it only
// calls the author RPC to obtain the author's repository URL.
// Purpose: render the plugin logo, badge links, feature summary, and supported-box list.
// Calls sync_menu on load to update sidebar menu flags from runtime platform detection.
// Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (author, sync_menu, state).
'use strict';
'require view';
'require rpc';
'require view.amlogic.shared as amlogicShared';
// Extract OPENWRT_AUTHOR from /usr/sbin/openwrt-update-amlogic and return the
// author's GitHub repo URL, used when the user clicks author.svg.
// Extract OPENWRT_AUTHOR from /usr/sbin/openwrt-update-amlogic and return the author's GitHub repo URL.
const callAuthor = rpc.declare({
object: 'luci.amlogic',
method: 'author',
@ -20,7 +18,6 @@ const callAuthor = rpc.declare({
});
// Sync menu_install / menu_armcpu UCI flags from runtime platform detection.
// Called on every info page load so the sidebar menu is correct from first visit.
const callSyncMenu = rpc.declare({ object: 'luci.amlogic', method: 'sync_menu' });
// Get runtime state to determine plugin_branch for the language badge.
@ -53,20 +50,18 @@ function openExternal(url) {
if (!w) window.location.href = url;
}
// This is a read-only info page, so we disable the top Save / Apply / Reset buttons by setting the handlers to null.
return view.extend({
// Read-only info page: disable the top Save / Apply / Reset buttons.
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function () {
// Call sync_menu and wait for it. sync_menu writes menu_install /
// menu_armcpu into UCI and clears the LuCI index cache on the server.
// On first boot from USB the install menu may be missing from the
// browser-side navigation (built before sync_menu ran). After the RPC
// returns we check if the sidebar already has an install link; if not,
// reload once so the browser re-fetches the updated menu tree.
// A URL hash flag (#menu-synced) prevents an infinite reload loop.
// On load, call sync_menu to update sidebar menu flags from runtime platform detection. If the install menu becomes
// visible but is not in the current nav, reload once to pick up the new menu (guarded by #menu-synced hash).
load: function () {
// Call sync_menu; if the install menu is now visible on the server but
// absent in the browser nav, reload once (guarded by #menu-synced hash).
const alreadyReloaded = window.location.hash === '#menu-synced';
const syncMenuPromise = callSyncMenu().then(function (res) {
if (alreadyReloaded) return;
@ -74,7 +69,7 @@ return view.extend({
// Check if the sidebar navigation already contains the install link.
const installLink = document.querySelector('a[href*="amlogic/install"]');
if (!installLink) {
// Server has updated the index cache; reload to pick up new nav.
// Server-side index cache updated; reload to pick up new nav.
window.location.replace(window.location.pathname + '#menu-synced');
window.location.reload();
}
@ -83,7 +78,8 @@ return view.extend({
return Promise.all([callAuthor(), syncMenuPromise]).then(function (r) { return r[0]; });
},
render: function (authorUrl) {
// Render the plugin info: logo, badge links, feature summary, and supported-box list.
render: function (authorUrl) {
// Inject the theme stylesheet on first entry so amlogic-* classes work.
amlogicShared.ensureCss();
const res = L.resource('amlogic');

View File

@ -1,12 +1,9 @@
// SPDX-License-Identifier: GPL-2.0
// Install OpenWrt to EMMC
//
// The dropdown lists common devices from the backend model database, plus an
// "Enter the dtb file name" option. When the user picks the manual option and
// fills in dtb / soc / uboot_overload, the frontend assembles the string
// "id@dtb:soc:uboot" and passes it to the start_install RPC, which invokes
// openwrt-install-amlogic to perform the actual installation. The install log
// tail is polled while the work runs.
// Purpose: let the user select a device model (or enter dtb/soc/uboot manually),
// then invoke start_install and poll the log tail until success or failure.
// Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (model_database, start_install, read_log_tail).
'use strict';
'require view';
@ -16,18 +13,17 @@
'require poll';
'require view.amlogic.shared as amlogicShared';
// Read the model database (parsed by the backend from
// /usr/share/amlogic/model_database.txt).
// Read the model database (parsed by the backend from /usr/share/amlogic/model_database.txt).
const callDB = rpc.declare({ object: 'luci.amlogic', method: 'model_database',
expect: { entries: [] } });
// Start install; the parameter is the composed string "id@dtb:soc:uboot",
// kept compatible with the original Lua plugin.
// Start install; parameter is the composed string "id@dtb:soc:uboot".
const callStartInst = rpc.declare({ object: 'luci.amlogic', method: 'start_install',
params: ['amlogic_install_sel'] });
// Read the last line of the named log (here we use the 'install' log).
const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail',
params: ['name'], expect: { line: '' } });
// This page uses its own Install button; hide LuCI's default Save/Apply/Reset.
return view.extend({
handleSave: null,
handleSaveApply: null,
@ -37,10 +33,10 @@ return view.extend({
return callDB();
},
render: function (entries) {
// Render the plugin info: logo, badge links, feature summary, and supported-box list.
render: function (entries) {
amlogicShared.ensureCss();
// Model dropdown: 0 = placeholder, 99 = manual entry, anything in between
// is a model from the database.
// Model dropdown: 0 = placeholder, 99 = manual entry, others from database.
const sel = E('select', { style: 'width:auto', name: 'amlogic_soc', id: 'amlogic_soc' });
sel.appendChild(E('option', { value: '0' }, _('Select List')));
(entries || []).forEach(function (e) {
@ -89,12 +85,10 @@ return view.extend({
click: ui.createHandlerFn(this, function (ev) {
if (installing) return;
const text = sel.options[sel.selectedIndex].text;
// Confirm twice: install only proceeds after the user clicks
// "Start install?".
// Confirm dialog; install proceeds only after user confirms.
if (!confirm(_('You have chosen:') + ' ' + text + ', ' + _('Start install?')))
return;
// When manual entries are blank, dtb falls back to auto_dtb so the
// backend can autodetect.
// dtb falls back to auto_dtb when the manual field is blank.
const dtbVal = dtbInput.value || 'auto_dtb';
const socVal = socInput.value || '';
const ubVal = ubootInput.value || '';
@ -114,8 +108,7 @@ return view.extend({
})
});
// Poll the install log tail once per second to surface backend progress
// and detect terminal success/failure keywords.
// Poll the install log once per second; detect success/failure keywords.
poll.add(function () {
return callLogTail('install').then(function (line) {
if (!line || line === '\n') {

View File

@ -1,10 +1,10 @@
// SPDX-License-Identifier: GPL-2.0
// Service log viewer
//
// Reads the full content of the amlogic service log (name=main) into a
// read-only textarea and refreshes it every 2 seconds. Provides controls to
// stop / restart polling, clear the log, and download the current contents
// as a Blob in the browser.
// Purpose: read the full amlogic service log into a read-only textarea and
// refresh it every 2 seconds; provide controls to pause/resume polling, clear
// the log, and download the current contents as a plain-text file.
// Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (read_log_full, del_log).
'use strict';
'require view';
@ -19,6 +19,8 @@ const callLogFull = rpc.declare({ object: 'luci.amlogic', method: 'read_log_full
// Clear the service main log.
const callDelLog = rpc.declare({ object: 'luci.amlogic', method: 'del_log' });
// This page uses its own Start/Stop Refresh buttons and does not need the default Save/Apply/Reset buttons,
// so we disable them by setting the handlers to null.
return view.extend({
handleSave: null,
handleSaveApply: null,
@ -66,8 +68,7 @@ return view.extend({
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Download Log'),
click: function () {
// Wrap the textarea content in a Blob and trigger a browser
// download via a temporary <a>; avoids a second server fetch.
// Wrap textarea content in a Blob and trigger a browser download via a temporary <a>.
const blob = new Blob([ta.value || ''], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');

View File

@ -1,10 +1,9 @@
// SPDX-License-Identifier: GPL-2.0
// Safe Power Off page
//
// On click, calls the backend poweroff RPC (which finally executes the system
// `poweroff` command) and runs a 5-second on-page countdown so the user knows
// the device is shutting down. When the countdown ends, the status switches
// to "Powered off" and a power-off icon is shown.
// Purpose: call the backend poweroff RPC then run a 5-second on-page countdown;
// switch status to "Powered off" and show a power-off icon when done.
// Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (poweroff).
'use strict';
'require view';
@ -15,15 +14,16 @@
// Trigger the backend poweroff RPC.
const callPoweroff = rpc.declare({ object: 'luci.amlogic', method: 'poweroff' });
// This page uses its own PowerOff button and does not need the default Save/Apply/Reset buttons,
// so we disable them by setting the handlers to null.
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
handleSave: null,
handleSaveApply: null,
handleReset: null,
render: function () {
amlogicShared.ensureCss();
// Status text: uses the warning color amlogic-status-err which adapts
// to dark mode automatically.
// Status text; amlogic-status-err color adapts to dark mode automatically.
const status = E('span', { class: 'amlogic-status-err', style: 'margin-left:1em' });
// Power-off icon, hidden by default; shown only after the countdown ends.
const icon = E('img', {
@ -32,7 +32,8 @@ return view.extend({
style: 'width:32px; height:32px; max-width:32px; display:none; margin-left:1em; vertical-align:middle'
});
const btn = E('input', {
// PowerOff button click handler: confirm twice, then call RPC and start countdown on success.
const btn = E('input', {
type: 'button', class: 'cbi-button cbi-button-remove',
value: _('Perform PowerOff'),
click: ui.createHandlerFn(this, function (ev) {
@ -41,7 +42,7 @@ return view.extend({
return;
ev.currentTarget.disabled = true;
return callPoweroff().then(function () {
// Backend has fired poweroff; show a 5-second countdown to the user.
// Show a 5-second countdown; replace with "Powered off" and icon when done.
status.textContent = _('Powering off, please wait...');
let n = 5;
const t = setInterval(function () {

View File

@ -1,20 +1,15 @@
// SPDX-License-Identifier: GPL-2.0
//
// Shared helper module for all luci-app-amlogic JS views.
// Purpose: on first page load, inject the theme-aware stylesheet amlogic.css
// into <head> (which contains .amlogic-status-* status colors, .amlogic-snap-*
// snapshot cards, and prefers-color-scheme dark mode overrides) so every view
// can rely on the same set of classes instead of duplicating inline styles.
//
// Purpose: inject the theme-aware stylesheet amlogic.css into <head> on first
// page load so every view can use .amlogic-status-* / .amlogic-snap-* classes
// and prefers-color-scheme dark mode overrides without duplicating inline styles.
'use strict';
'require baseclass';
return baseclass.extend({
// Inject the shared stylesheet (idempotent).
// We tag the <link> element with a fixed id 'amlogic-shared-css' so that
// subsequent calls return immediately and avoid appending it twice. The
// stylesheet path is resolved via L.resource() to
// /luci-static/resources/view/amlogic/amlogic.css.
// Inject amlogic.css once; guarded by element id to prevent duplicates.
ensureCss: function () {
if (document.getElementById('amlogic-shared-css')) return;
const link = document.createElement('link');

View File

@ -1,17 +1,11 @@
// SPDX-License-Identifier: GPL-2.0
// Manually Upload Update
//
// Features:
// 1. Use ui.uploadFile() to open the browser-native file picker and upload
// to the path returned by the backend (default /tmp/upload/);
// 2. Show a table of files in the upload directory (name / mtime / mode /
// size) with Remove, Install (ipk) and Restore (config backup) actions;
// 3. Dynamically reveal "Update firmware" / "Replace kernel" buttons based
// on which file types are currently present in the upload dir; all
// buttons share doButton() to keep disabled / text / status transitions
// consistent;
// 4. Concurrently poll the firmware/kernel log tails and display the
// current firmware version.
// Purpose: upload local files (firmware / kernel / ipk / config backup) to the server,
// list them, and trigger install / restore / update actions. Chunked upload uses
// a custom ubus RPC instead of cgi-upload to avoid session ACL issues.
// Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (upload_path, list_uploads, upload_chunk,
// delete_upload, install_upload, start_update, start_kernel, read_log_tail, state).
'use strict';
'require view';
@ -22,43 +16,40 @@
'require fs';
'require view.amlogic.shared as amlogicShared';
// Query the upload directory the backend allows (default /tmp/upload/, the
// backend ensures it exists).
const callUploadPath = rpc.declare({ object: 'luci.amlogic', method: 'upload_path',
expect: { path: '/tmp/upload/' } });
// Query the upload directory the backend allows (default /tmp/upload/, the backend ensures it exists).
const callUploadPath = rpc.declare({ object: 'luci.amlogic', method: 'upload_path', expect: { path: '/tmp/upload/' } });
// List files in the upload dir plus flags about installable firmware/kernel/cfg.
const callList = rpc.declare({ object: 'luci.amlogic', method: 'list_uploads' });
// Delete a named file in the upload dir.
const callDelete = rpc.declare({ object: 'luci.amlogic', method: 'delete_upload',
params: ['name'] });
// Install one uploaded file (ipk) or restore a config backup; the backend
// dispatches by suffix.
const callInstall = rpc.declare({ object: 'luci.amlogic', method: 'install_upload',
params: ['name'] });
const callDelete = rpc.declare({ object: 'luci.amlogic', method: 'delete_upload', params: ['name'] });
// Install one uploaded file (ipk) or restore a config backup; the backend dispatches by suffix.
const callInstall = rpc.declare({ object: 'luci.amlogic', method: 'install_upload', params: ['name'] });
// Chunked upload via our own RPC (sidesteps cgi-upload's session ACL flow).
const callUploadChunk = rpc.declare({ object: 'luci.amlogic', method: 'upload_chunk',
params: ['name', 'data', 'append'] });
const callUploadChunk = rpc.declare({ object: 'luci.amlogic', method: 'upload_chunk', params: ['name', 'data', 'append'] });
// Run the full firmware update flow with parameters auto@updated@/tmp.
const callStartUpd = rpc.declare({ object: 'luci.amlogic', method: 'start_update',
params: ['amlogic_update_sel'] });
const callStartUpd = rpc.declare({ object: 'luci.amlogic', method: 'start_update', params: ['amlogic_update_sel'] });
// Run the kernel-only replace flow.
const callStartKnl = rpc.declare({ object: 'luci.amlogic', method: 'start_kernel' });
// Tail a named log to surface progress during install/update.
const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail',
params: ['name'], expect: { line: '' } });
const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail', params: ['name'], expect: { line: '' } });
// Read system state (this page only uses current_firmware_version).
const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' });
// This page uses its own Upload/Install buttons and does not need the default Save/Apply/Reset buttons,
// so we disable them by setting the handlers to null.
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
// On load, ensure the CSS is injected and load the upload path from the backend.
load: function () {
amlogicShared.ensureCss();
return callUploadPath();
},
// Render the upload page: file input + upload button, table of existing uploads with Remove + Install/Restore buttons,
// and firmware/kernel update buttons if applicable. Also start polling the install/update log tails.
render: function (path) {
const view = this;
@ -87,11 +78,9 @@ return view.extend({
const fwVer = E('span', _('Collecting data...'));
function doButton(btn, busyText, fail, fn) {
// Shared button state machine: disabled -> RPC -> updating/fail label.
// On success (code==0) the button stays in busyText state and
// remains disabled — the device is about to reboot, so there is
// nothing to restore. Only on failure do we surface an error and
// re-enable the button so the user can retry.
// Shared button state machine: disabled → RPC → busyText or fail label.
// On success (code==0) the button stays in busyText state (device rebooting);
// on failure, re-enable so the user can retry.
btn.disabled = true; btn.value = busyText;
return Promise.resolve(fn()).then(function (r) {
if (!r || r.code !== 0) {
@ -106,9 +95,8 @@ return view.extend({
}
function refreshList() {
// Refresh the file table and the hint text; the firmware / kernel
// buttons are shown or hidden based on the has_firmware / has_kernel
// flags reported by the backend.
// Refresh the file table; show/hide firmware/kernel update buttons
// based on has_firmware / has_kernel flags from the backend.
return callList().then(function (info) {
dom.content(tableContainer, buildTable(info.items || [], info.path));
let parts = [];
@ -124,10 +112,9 @@ return view.extend({
});
}
// Helper to build the file table with Remove + Install/Restore buttons per row.
function buildTable(items, dir) {
// We expose three actions per row: remove any file, install ipk, and
// restore a config backup. Other types (firmware, kernel archive)
// are listed but have no individual install entry.
// File table: expose Remove + Install (ipk) / Restore (config backup) per row.
const tbl = E('table', { class: 'table cbi-section-table' }, [
E('tr', { class: 'tr cbi-section-table-titles' }, [
E('th', { class: 'th' }, _('File name')),
@ -200,27 +187,13 @@ return view.extend({
return tbl;
}
// File upload widget. We deliberately avoid /cgi-bin/cgi-upload here
// because it requires the LuCI session to carry a `file` ACL entry
// for the chosen target path, which depends on rpcd ACL bookkeeping
// being live for this session — historically a fragile path.
// Instead we slice the picked file into ~256 KB chunks, base64-encode
// each chunk and POST them through our own ubus method
// `luci.amlogic.upload_chunk`. The ucode handler runs as root and
// owns the upload directory, so no separate file-write ACL is
// involved. Permission is governed solely by the existing
// `upload_chunk` entry in the rpcd ACL `write.ubus` block.
// Chunk size for ubus payload. ubus has a per-request size limit
// (~64 KB) — at 256 KB raw → ~344 KB base64 the request was silently
// dropped and luci returned "No related RPC reply". 32 KB raw →
// ~44 KB base64 leaves comfortable headroom.
// Chunked file upload via luci.amlogic.upload_chunk RPC (avoids cgi-upload session ACL).
// 32 KB raw per chunk → ~44 KB base64, safely under the ubus per-request size limit.
const CHUNK_SIZE = 32 * 1024;
const uploadStatus = E('span');
const uploadFileName = E('span', { style: 'margin-right:1em' });
// Encode a Uint8Array as base64. btoa() expects a binary string, so
// we walk through the bytes in 32k slices to avoid argument-list
// limits in browsers.
// Encode a Uint8Array as base64 in 32 KB slices to avoid call-stack limits.
function bytesToB64(buf) {
let s = '';
for (let i = 0; i < buf.length; i += 0x8000)
@ -228,6 +201,7 @@ return view.extend({
return btoa(s);
}
// Upload a file in chunks; on success, refresh the file list to show the new upload.
function uploadFile(file, target, basename) {
const total = file.size;
let offset = 0;
@ -256,6 +230,7 @@ return view.extend({
return step();
}
// Hidden file input to trigger the file picker dialog; on file selection, start the chunked upload.
const hiddenFileInput = E('input', {
type: 'file', style: 'display:none',
change: ui.createHandlerFn(this, function (ev) {
@ -279,8 +254,7 @@ return view.extend({
click: function () { hiddenFileInput.click(); }
});
// Polling: log tails + version
// Pull the last line of both firmware and kernel logs once per second.
// Polling: pull firmware and kernel log tails once per second.
poll.add(function () {
return Promise.all([
callLogTail('firmware').then(function (l) {
@ -315,8 +289,7 @@ return view.extend({
E('div', { class: 'cbi-section' }, [
E('p', { style: 'text-align:center' },
_('After uploading firmware (.img/.img.gz/.img.xz/.7z suffix) or kernel files (3 kernel files), the update button will be displayed.')),
// Single centered cell: when fwBtn / knBtn are hidden the row
// would otherwise show an empty left column.
// Single centered cell: when fwBtn / knBtn are hidden the row would otherwise show an empty left column.
E('div', { style: 'text-align:center' },
[fwBtn, knBtn, ' ', fwVer, ' ', fwLog, knLog])
])

View File

@ -1,16 +1,10 @@
#!/bin/bash
#==================================================================
# This file is licensed under the terms of the GNU General Public
# License version 2. This program is licensed "as is" without any
# warranty of any kind, whether express or implied.
# SPDX-License-Identifier: GPL-2.0
# amlogic_check_firmware.sh — check and download OpenWrt firmware updates.
#
# This file is a part of the luci-app-amlogic plugin
# https://github.com/ophub/luci-app-amlogic
#
# Description: Check and update OpenWrt firmware
# Copyright (C) 2021- https://github.com/unifreq/openwrt_packit
# Copyright (C) 2021- https://github.com/ophub/luci-app-amlogic
#==================================================================
# Purpose: query the configured GitHub repository for the latest firmware
# release, compare with the local version, and download to /tmp/amlogic
# if a newer version is available.
# Set a fixed value
check_option="${1}"

View File

@ -1,16 +1,10 @@
#!/bin/bash
#==================================================================
# This file is licensed under the terms of the GNU General Public
# License version 2. This program is licensed "as is" without any
# warranty of any kind, whether express or implied.
# SPDX-License-Identifier: GPL-2.0
# amlogic_check_kernel.sh — check and download OpenWrt kernel updates.
#
# This file is a part of the luci-app-amlogic plugin
# https://github.com/ophub/luci-app-amlogic
#
# Description: Check and update OpenWrt Kernel
# Copyright (C) 2021- https://github.com/unifreq/openwrt_packit
# Copyright (C) 2021- https://github.com/ophub/luci-app-amlogic
#==================================================================
# Purpose: query the configured GitHub repository for the latest kernel
# packages, compare with the running kernel version, and download to
# /tmp/amlogic if a newer version is available.
# Set a fixed value
check_option="${1}"

View File

@ -1,16 +1,10 @@
#!/bin/bash
#==================================================================
# This file is licensed under the terms of the GNU General Public
# License version 2. This program is licensed "as is" without any
# warranty of any kind, whether express or implied.
# SPDX-License-Identifier: GPL-2.0
# amlogic_check_plugin.sh — check and download luci-app-amlogic plugin updates.
#
# This file is a part of the luci-app-amlogic plugin
# https://github.com/ophub/luci-app-amlogic
#
# Description: Check and update luci-app-amlogic plugin
# Copyright (C) 2021- https://github.com/unifreq/openwrt_packit
# Copyright (C) 2021- https://github.com/ophub/luci-app-amlogic
#==================================================================
# Purpose: query the configured GitHub repository for the latest plugin
# release (JS or Lua branch), compare with the installed version, and
# download the IPK/APK package to /tmp/amlogic if a newer version is available.
# Set a fixed value
check_option="${1}"

View File

@ -1,8 +1,9 @@
// SPDX-License-Identifier: GPL-2.0
// luci.amlogic — ucode RPC bridge for luci-app-amlogic (JS branch).
//
// luci.amlogic - ucode RPC bridge for the JS-based luci-app-amlogic.
//
// Helpers
// Purpose: expose all plugin operations (install, update, backup, snapshot,
// CPU policy, log read, upload) as ubus methods consumed by the JS views.
// Each method is registered in the `methods` table at the bottom of this file.
'use strict';
import { popen, readfile, writefile, mkstemp, stat, glob, error as fserror,
@ -44,19 +45,14 @@ function sh(cmd) {
// Run a shell command, return exit code (0 on success)
function shcall(cmd) {
// `sh -c "...; echo $?"` — last line is exit code.
const out = sh('(' + cmd + ') >/dev/null 2>&1; echo $?');
const lines = split(out, '\n');
const last = lines[length(lines) - 1] ?? '1';
return int(last);
}
// Run a shell command in the background (fire-and-forget).
// Returns immediately so rpcd is not blocked, allowing concurrent
// read_log_tail / read_log_full polls to succeed.
// Run cmd in the background; close stdin so shbg does not block rpcd.
function shbg(cmd) {
// Detach from rpcd: close stdin so interactive prompts don't block,
// but let the command manage its own stdout/stderr (e.g. redirect to log).
const wrapper = '(' + cmd + ') </dev/null &';
const p = popen(wrapper, 'r');
if (p) p.close();
@ -67,8 +63,7 @@ function shq(s) {
return "'" + replace(s ?? '', "'", "'\\''") + "'";
}
// Ensure the runtime tmp dirs exist (mirrors the luci.sys.exec calls in the
// original controller's index() function).
// Ensure runtime tmp dirs exist.
function ensure_dirs() {
mkdir(LOG_DIR);
mkdir(UPLOAD_TMP);
@ -92,13 +87,8 @@ function show_install_menu() {
return m ? m[1] : 'Unknown';
}
// Detect whether OpenWrt's root fs is on internal storage (eMMC/NVMe/fixed disk).
// Returns true → already installed, hide the Install menu.
// Returns false → running from USB/SD, show the Install menu.
//
// sd* devices can be either USB (removable=1) or internal SATA (removable=0);
// we read /sys/block/<base_dev>/removable to distinguish them.
// mmcblk* and nvme* are always internal storage on these platforms.
// Detect whether the root fs is on internal storage (eMMC/NVMe/fixed disk).
// Returns true if already installed; sd* removable flag distinguishes USB vs SATA.
function root_on_internal_storage() {
const root_pt = trim(sh("df / | tail -n1 | awk '{print $1}' | awk -F '/' '{print $3}'"));
if (!root_pt) return false;
@ -108,17 +98,15 @@ function root_on_internal_storage() {
return true;
}
if (match(root_pt, /^[hsv]d[a-z][0-9]+$/)) {
// Strip trailing partition digit(s) to get base device (e.g. sda2 → sda)
// Strip partition digit(s) to get base device (e.g. sda2 → sda); check removable flag.
const base_dev = replace(root_pt, /[0-9]+$/, '');
const removable = trim(sh('cat /sys/block/' + shq(base_dev) + '/removable 2>/dev/null'));
// removable=0 → internal SATA/fixed disk; removable=1 → USB/removable
return (removable == '0');
return (removable == '0'); // 0 = internal SATA; 1 = USB/removable
}
return false;
}
// Pick the helper script names for the current platform (as the original
// controller did).
// Return platform-specific helper script names for install/update/kernel operations.
function platform_scripts() {
const p = platform();
if (index(p, 'rockchip') >= 0)
@ -192,8 +180,7 @@ function read_log_tail(name) {
function read_log_full(name) {
const p = _safe_log_path(name ?? 'main');
if (!p) return '';
// Use popen+cat for consistency with read_log_tail; readfile() may be
// stale inside the long-lived rpcd ucode process.
// Use popen+cat to bypass rpcd process-level file cache.
const pipe = popen('cat ' + p + ' 2>/dev/null', 'r');
if (!pipe) return '';
const t = pipe.read('all') ?? '';
@ -268,9 +255,7 @@ function m_platform_info() {
const can_install = (index(p, 'amlogic') >= 0 || index(p, 'allwinner') >= 0 || index(m, 'yes') >= 0);
// can_armcpu: all platforms except qemu support CPU frequency settings
const can_armcpu = (index(p, 'qemu') < 0);
// is_installed: root fs is on an internal storage device (eMMC/NVMe/fixed disk).
// sd* devices may be USB (removable=1) or internal SATA (removable=0); check
// /sys/block/<dev>/removable to distinguish. When true the Install menu is hidden.
// is_installed: root fs on internal storage means already installed; hide Install menu.
const is_installed = root_on_internal_storage();
return {
platform: p,
@ -312,16 +297,13 @@ function m_snapshot_list() {
return { names };
}
// Mirrors the Lua "Edit List" button's side-effect: if the backup-list file is
// missing or empty, pre-populate it from the BACKUP_LIST shell variable inside
// /usr/sbin/openwrt-backup.
// Seed /etc/amlogic_backup_list.conf from the default BACKUP_LIST in openwrt-backup if absent.
function m_prime_backup_list() {
shcall("[ -s " + BACKUP_LIST + " ] || sed -n \"/BACKUP_LIST='/,/.*'$/p\" /usr/sbin/openwrt-backup | sed -e \"s/BACKUP_LIST=\\(.*\\)/\\1/; s/'//g; s/\\\\\\\\//g; s/ //g\" > " + BACKUP_LIST + " 2>/dev/null");
return { code: 0 };
}
// Create a read-only snapshot of /etc into /.snapshots/etc-<MM.DD.HHMMSS>.
// Mirrors the `Create Snapshot` button in amlogic_backup.lua.
// Create a read-only btrfs snapshot of /etc into /.snapshots/.
function m_snapshot_create() {
shcall("btrfs subvolume snapshot -r /etc /.snapshots/etc-$(date +%m.%d.%H%M%S) && sync");
return { code: 0 };
@ -368,9 +350,7 @@ function m_check_kernel(req) {
shbg('/usr/share/amlogic/amlogic_check_kernel.sh -check');
return { code: 0 };
}
// download option: pass the full param string directly to the script
// (it may contain underscores, dots, and digits — safe to pass verbatim
// since shbg wraps the whole command in single-quoted sh -c)
// download option: pass the full param string verbatim to the script.
shbg('/usr/share/amlogic/amlogic_check_kernel.sh -download ' + shq(opt));
return { code: 0 };
}
@ -396,9 +376,7 @@ function m_start_plugin() {
const script_path = LOG_DIR + '/amlogic_install_plugin.sh';
writefile(LOG_RUNNING, '1@Plugin update in progress, try again later!\n');
writefile(LOG_PLUGIN, '');
// Write install script to a file so shbg (which redirects stdin from
// /dev/null) can execute it — heredoc via shbg does not work because
// shbg closes stdin before sh can read the here-document.
// Write install script to a temp file; shbg closes stdin so heredoc cannot be used.
const script = '#!/bin/sh\n'
+ 'LOG=' + shq(LOG_PLUGIN) + '\n'
+ 'LOG_RUNNING=' + shq(LOG_RUNNING) + '\n'
@ -643,22 +621,16 @@ function m_list_uploads() {
has_config: cfg };
}
// Strip directory prefix; reject empty, dot/dotdot, or paths still containing separators.
function _safe_basename(s) {
const b = replace(s ?? '', /^.*[\\\/]/, '');
// Permissive basename check: only reject empty, dot/dotdot and anything
// still containing path separators after the strip. Strict allowlists
// rejected legitimate filenames (e.g. some i18n packages) on certain
// runtimes where the regex character class behaved unexpectedly.
if (b == '' || b == '.' || b == '..') return null;
if (index(b, '/') >= 0 || index(b, '\\') >= 0) return null;
return b;
}
// Chunked upload helper. The browser-side view splits the file into base64
// chunks and calls this method N times. The first call (append=false)
// truncates / creates the file; later calls append. Implemented through our
// own ubus method so it bypasses the cgi-upload ACL flow entirely (the
// ucode runs as root and owns the upload dir).
// Receive one base64-encoded chunk and write (or append) it to the upload file.
// First call (append=false) truncates/creates; subsequent calls append.
function m_upload_chunk(req) {
const dir = compute_upload_path();
mkdir(dir);
@ -739,9 +711,7 @@ function m_reload_cpu() {
return { code: 0 };
}
// Sync menu visibility UCI flags from runtime platform detection.
// Called from info.js on every page load so the sidebar reflects the
// current hardware state without requiring the user to visit Plugin Settings.
// Sync menu_install and menu_armcpu UCI flags based on runtime platform detection.
function m_sync_menu() {
const p = platform();
const m = show_install_menu();
@ -757,9 +727,7 @@ function m_sync_menu() {
return { code: 0, show_install, show_armcpu };
}
// Enumerate cpufreq policies + their available freqs/governors so the armcpu
// view can render a tab per CPU policy (mirrors the Lua dynamic taboption
// loop in amlogic_armcpu.lua).
// Enumerate cpufreq policies and their available frequencies and governors.
function m_cpu_policies() {
const policies_str = sh("ls /sys/devices/system/cpu/cpufreq 2>/dev/null | grep -E 'policy[0-9]{1,3}' | xargs");
let names = split(policies_str, ' ');

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.5.11
PKG_RELEASE:=121
PKG_RELEASE:=122
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \

View File

@ -35,22 +35,25 @@ local appname = api.appname
</style>
<script type="text/javascript">
//<![CDATA[
var shunt_list = JSON.parse('<%=self.shunt_list%>');
const shunt_list = JSON.parse('<%=self.shunt_list%>');
function to_edit_node(btn) {
if (!btn) return;
const idReg = /^cbid\.<%=appname%>\..*node$/;
const idReg = /^cbid\..*\..*node$/;
let hidden_select = null;
const container = btn.closest('#cbi-<%=appname%>-global') || btn.closest('#cbi-<%=appname%>-socks');
if (!container) return null;
const selects = container.querySelectorAll('select[id^="cbid.<%=appname%>."]');
const selects = container.querySelectorAll('select[id^="cbid."][id*="."][id$="node"]');
for (const sel of selects) {
if ( idReg.test(sel.id) && getComputedStyle(sel).display === "none" && (sel.compareDocumentPosition(btn) & Node.DOCUMENT_POSITION_FOLLOWING)) {
hidden_select = sel;
}
}
if (!hidden_select) return;
let node_select_value = hidden_select ? hidden_select.options[0].value : "";
let node_select_value = hidden_select?.options[0]?.value;
if (!node_select_value || node_select_value.indexOf("_default") === 0 || node_select_value.indexOf("_direct") === 0 || node_select_value.indexOf("_blackhole") === 0) {
return;
}
let to_url = '<%=api.url("node_config")%>/' + node_select_value;
if (node_select_value.indexOf("Socks_") === 0) {
to_url = '<%=api.url("socks_config")%>/' + node_select_value.substring("Socks_".length);
@ -58,19 +61,21 @@ local appname = api.appname
location.href = to_url;
}
function go() {
var _status = document.getElementsByClassName('_status');
for (var i = 0; i < _status.length; i++) {
var id = _status[i].getAttribute("socks_id");
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
async function go() {
const _status = document.getElementsByClassName('_status');
for (let i = 0; i < _status.length; i++) {
const id = _status[i].getAttribute("socks_id");
XHR.get('<%=api.url("socks_status")%>', {
index: i,
id: id
},
function(x, result) {
var index = result.index;
var div = '';
var div1 = '<font style="font-weight:bold;" color="green"></font>&nbsp';
var div2 = '<font style="font-weight:bold;" color="red">X</font>&nbsp';
const index = result.index;
let div = '';
const div1 = '<font style="font-weight:bold;" color="green"></font>&nbsp';
const div2 = '<font style="font-weight:bold;" color="red">X</font>&nbsp';
if (result.socks_status) {
div += div1;
@ -89,62 +94,77 @@ local appname = api.appname
);
}
var global_id = null;
var global = document.getElementById("cbi-<%=appname%>-global");
let global_id = null;
const global = document.getElementById("cbi-<%=appname%>-global");
if (global) {
var node = global.getElementsByClassName("cbi-section-node")[0];
var node_id = node.getAttribute("id");
global_id = node_id;
var all_node = node.querySelectorAll("[id]");
//var reg1 = /^cbid\..*node\.main$/;
var reg1 = /^cbid\..*\.(tcp_node|udp_node)\.main$/;
// 分流规则节点提前加入 “编辑” 占位符,以保证节点列表左对齐
document.querySelectorAll('.td[data-widget="nodes_listvalue"][data-name="_node"] > div').forEach(container => {
if (!container.querySelector('.shunt-node-placeholder')) {
const placeholder = document.createElement('div');
placeholder.className = 'shunt-node-placeholder';
placeholder.style.cssText = 'display:inline-flex; width:40px; flex-shrink:0;';
container.appendChild(placeholder);
}
});
for (var i = 0; i < all_node.length; i++) {
var el = all_node[i];
await delay(100);
const node = global.getElementsByClassName("cbi-section-node")[0];
const node_id = node.getAttribute("id");
global_id = node_id;
const all_node = node.querySelectorAll("[id]");
const reg1 = /^cbid\..*node\.main$/;
//const reg1 = /^cbid\..*\.(tcp_node|udp_node)\.main$/;
for (let i = 0; i < all_node.length; i++) {
const el = all_node[i];
if (!reg1.test(el.id)) continue;
var node_select = el;
const node_select = el;
if (!node_select) continue;
var cbid = el.id.replace(/\.main$/, "");
var hidden_select = document.getElementById(cbid);
var node_select_value = hidden_select ? hidden_select.options[0].value : "";
if (!node_select_value || node_select_value === "" || node_select_value.indexOf("tcp") === 0) {
const cbid = el.id.replace(/\.main$/, "");
const hidden_select = document.getElementById(cbid);
const node_select_value = hidden_select?.options[0]?.value;
if (!node_select_value || node_select_value.indexOf("tcp") === 0 || node_select_value.indexOf("_default") === 0 || node_select_value.indexOf("_direct") === 0 || node_select_value.indexOf("_blackhole") === 0) {
continue;
}
var html = '<a href="#" onclick="return to_edit_node(this);"><%:Edit%></a>';
let html = '<a href="#" onclick="return to_edit_node(this);"><%:Edit%></a>';
var m = cbid.match(/\.(tcp|udp)_node$/);
const m = cbid.match(/\.(tcp|udp)_node$/);
if (m && (m[1] === "tcp" || m[1] === "udp")) {
html += '<a href="#" onclick="window.open(\'' + '<%=api.url("get_redir_log")%>?name=default&proto=' + m[1] + '\', \'_blank\')"><%:Log%></a>';
}
node_select.insertAdjacentHTML("beforeend",
'<div class="node-actions" style="display:inline-flex; align-items:center; gap:4px; flex-wrap:wrap; margin-left:4px;">'
+ html + '</div>'
);
html = '<div class="node-actions" style="display:inline-flex; align-items:center; gap:4px; flex-wrap:wrap; margin-left:4px;">' + html + '</div>'
const placeholder = node_select.parentElement.querySelector('.shunt-node-placeholder');
if (placeholder) {
placeholder.innerHTML = html;
} else {
node_select.insertAdjacentHTML("beforeend", html);
}
}
}
var socks = document.getElementById("cbi-<%=appname%>-socks");
const socks = document.getElementById("cbi-<%=appname%>-socks");
if (socks) {
var socks_enabled_dom = document.getElementById(global_id + "-socks_enabled");
socks_enabled_dom.parentNode.removeChild(socks_enabled_dom);
var descr = socks.getElementsByClassName("cbi-section-descr")[0];
descr.outerHTML = socks_enabled_dom.outerHTML;
rows = socks.getElementsByClassName("cbi-section-table-row");
for (var i = 0; i < rows.length; i++) {
const socks_enabled_dom = document.getElementById(global_id + "-socks_enabled");
const descr = socks.getElementsByClassName("cbi-section-descr")[0];
descr.replaceWith(socks_enabled_dom);
const rows = socks.getElementsByClassName("cbi-section-table-row");
for (let i = 0; i < rows.length; i++) {
try {
var row = rows[i];
var id = row.id;
const row = rows[i];
const id = row.id;
if (!id) continue;
var dom_id = id + "-node";
var cbid = dom_id.replace("cbi-", "cbid-").replace(new RegExp("-", 'g'), ".");
let dom_id = id + "-node";
const cbid = dom_id.replace("cbi-", "cbid-").replace(new RegExp("-", 'g'), ".");
dom_id = cbid + ".main";
var node_select = document.getElementById(dom_id);
const node_select = document.getElementById(dom_id);
if (!node_select) continue;
var html = '<a href="#" onclick="return to_edit_node(this);"><%:Edit%></a>';
let html = '<a href="#" onclick="return to_edit_node(this);"><%:Edit%></a>';
html += '<a href="#" onclick="window.open(\'' + '<%=api.url("get_socks_log")%>?name=' + id.replace("cbi-<%=appname%>-", "") + '\', \'_blank\')"><%:Log%></a>';
node_select.insertAdjacentHTML("afterend",

View File

@ -2592,3 +2592,6 @@ html body.apply-overlay-active {
word-break: normal;
}
img[src*="signal-0"] {
filter: sepia(1) saturate(6) hue-rotate(80deg) brightness(0.65) contrast(1.1);
}

View File

@ -2561,3 +2561,7 @@ html body.apply-overlay-active {
.assoclist .td:nth-of-type(6) button {
word-break: normal;
}
img[src*="signal-0"] {
filter: sepia(1) saturate(8) hue-rotate(315deg) brightness(0.65) contrast(1.1);
}

View File

@ -0,0 +1,65 @@
/* Custom checkbox and radio styles for teleofis theme */
input[type="checkbox"],
input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 14px;
height: 14px;
border: 1px solid #ccc;
background: #fff;
position: relative;
cursor: pointer;
vertical-align: middle;
}
input[type="checkbox"] {
border-radius: 2px;
}
input[type="checkbox"]:checked {
background-color: #660000;
border-color: #660000;
}
input[type="checkbox"]:checked::before {
content: "✓";
position: absolute;
color: white;
font-size: 12px;
font-weight: bold;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
input[type="radio"] {
border-radius: 50%;
}
input[type="radio"]:checked {
background-color: #660000;
border-color: #660000;
}
input[type="radio"]:checked::after {
content: "";
position: absolute;
width: 6px;
height: 6px;
background: white;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
input[type="checkbox"]:hover,
input[type="radio"]:hover {
border-color: #660000;
}
input[type="checkbox"]:focus,
input[type="radio"]:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(46, 125, 50, 0.0);
}

View File

@ -25,6 +25,7 @@
<title><%=striptags( (boardinfo.hostname or "?") .. ( (node and node.title) and ' - ' .. translate(node.title) or '')) %> - LuCI</title>
<meta name="viewport" content="initial-scale=1.0">
<link rel="stylesheet" href="<%=media%>/cascade.css">
<link rel="stylesheet" href="<%=media%>/checkbox.css?v=0">
<link rel="stylesheet" media="only screen and (max-device-width: 854px)" href="<%=media%>/mobile.css" type="text/css" />
<link rel="shortcut icon" href="<%=media%>/favicon.ico">
<% if node and node.css then %>

View File

@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=qBittorrent-Enhanced-Edition
PKG_VERSION:=5.1.3.10
PKG_RELEASE:=6
PKG_RELEASE:=7
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/c0re100/qBittorrent-Enhanced-Edition/tar.gz/release-$(PKG_VERSION)?
@ -27,7 +27,6 @@ define Package/qbittorrent-enhanced-edition
URL:=https://github.com/c0re100/qBittorrent-Enhanced-Edition
DEPENDS:=+libtorrent-rasterbar +libQt6Core +libQt6Network +libQt6Sql \
+libQt6Xml +qt6-plugin-libqopensslbackend +qt6-plugin-libqsqlite
PROVIDES:=qbittorrent
CONFLICTS:=qbittorrent
endef