🎉 Sync 2026-11-08 01:01:38

This commit is contained in:
github-actions[bot] committed 2026-11-08 01:01:38 +08:00
1 parent f7911ea0fb
commit 3d26c9667a
57 files changed
+4016 -1243

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=7z
PKG_VERSION:=26.05
PKG_RELEASE:=6
PKG_VERSION:=26.06
PKG_RELEASE:=7
PKG_SOURCE:=$(PKG_NAME)$(subst .,,$(PKG_VERSION))-src.tar.xz
PKG_SOURCE_URL:=https://github.com/ip7z/7zip/releases/download/$(PKG_VERSION)/
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=autokick-wiwiz
PKG_VERSION:=20250205
PKG_RELEASE:=1
PKG_VERSION:=20261107
PKG_RELEASE:=2
LUCI_DIR:=/usr/lib/lua/luci
include $(INCLUDE_DIR)/package.mk
+1 -1
View File
@@ -79,7 +79,7 @@ dokick() {
# tells GW I kicked it
_ss=$(curl -m 5 "http://$GW_IP/cgi-bin/kickmac?act=kick&mac=$MAC" 2>/dev/null)
OUTPUT="kick $MAC, GW said: $_ss"
OUTPUT="$(date "+%Y-%m-%d %H:%M:%S") kick $MAC, GW said: $_ss"
echo $OUTPUT
if [ "$SAVE_LOG" = "1" ]; then
echo $OUTPUT>>$LOGFILE
+2 -2
View File
@@ -6,13 +6,13 @@ PKG_UPSTREAM_TAG:=v$(PKG_UPSTREAM_VERSION)
PKG_SHORT_SHA:=3f27ef3
PKG_VERSION:=2026.11.04.$(PKG_UPSTREAM_VERSION)
PKG_RELEASE:=25
PKG_RELEASE:=26
PKG_SOURCE:=$(PKG_NAME)-stable-$(PKG_UPSTREAM_TAG)-$(PKG_SHORT_SHA).tar.gz
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-stable-$(PKG_UPSTREAM_TAG)-$(PKG_SHORT_SHA)
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR)
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src
PKG_SOURCE_VERSION:=3b0c0d7f9e5fa5d086fcd45cc211380ad6ccb077
PKG_SOURCE_VERSION:=7cfb805e17bdb39e47de36296a41660d2027e954
PKG_HASH:=skip
PKG_BUILD_VERSION:=$(PKG_UPSTREAM_TAG)
+2 -2
View File
@@ -9,8 +9,8 @@ include $(TOPDIR)/rules.mk
PKG_ARCH_FASTNET:=$(ARCH)
PKG_NAME:=fastnet
PKG_VERSION:=0.7.8
PKG_RELEASE:=7
PKG_VERSION:=0.8.0
PKG_RELEASE:=8
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/fastnet-runtime-v$(PKG_VERSION)/
+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=gately
PKG_VERSION:=0.6.5
PKG_RELEASE:=27
PKG_VERSION:=0.6.5.1
PKG_RELEASE:=28
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)?
+3 -3
View File
@@ -5,11 +5,11 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=honk
PKG_VERSION:=2026.11.05~9e31cc0
PKG_RELEASE:=31
PKG_VERSION:=2026.11.07~73b1077
PKG_RELEASE:=32
HONK_SRC_URL:=https://github.com/daeuniverse/honk.git
HONK_SRC_VERSION:=9e31cc0bd28825afdc0e384dc989bc3f076e69cf
HONK_SRC_VERSION:=73b1077bf9cdb639899f0447994cf8980abc0752
PKG_LICENSE:=AGPL-3.0-only
PKG_LICENSE_FILES:=LICENSE
+2 -2
View File
@@ -22,11 +22,11 @@ PKG_NAME:=luci-app-amlogic
# DD = day (01 = 1st)
# NN = daily release sequence (01 = first release of the day, 02 = second, ...)
# Example below: 26.10.0101 = October 1, 2026, 1st release of the day
PKG_VERSION:=26.10.2801
PKG_VERSION:=26.11.0801
# PKG_RELEASE marks the LuCI variant; onekey-install.sh relies on it (do NOT bump)
# 1 = Lua version (lua branch) → tag: 26.10.0101
# 2 = JS version (main branch) → tag: 26.10.0101-js
PKG_RELEASE:=27
PKG_RELEASE:=28
PKG_LICENSE:=GPL-2.0 License
PKG_MAINTAINER:=ophub <https://github.com/ophub/luci-app-amlogic>
@@ -146,7 +146,7 @@ check_updated() {
firmware_tags_array+=("${firmware_tags_name}")
fi
done < <(
curl -fsSL -m 10 \
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
https://github.com/${server_firmware_url}/releases?page=${i} |
grep -oE 'releases/tag/([^" ]+)'
)
@@ -177,7 +177,7 @@ check_updated() {
# Retrieve the HTML code of the tags list page
html_code="$(
curl -fsSL -m 10 \
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
https://github.com/${server_firmware_url}/releases/expanded_assets/${firmware_releases_tag}
)"
@@ -268,7 +268,7 @@ download_firmware() {
# Download to OpenWrt file
firmware_download_name="openwrt_${BOARD}_k${main_line_version}_github${firmware_suffix}"
curl -fsSL "${latest_url}" -o "${FIRMWARE_DOWNLOAD_PATH}/${firmware_download_name}"
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error "${latest_url}" -o "${FIRMWARE_DOWNLOAD_PATH}/${firmware_download_name}"
if [[ "$?" -eq "0" && -s "${FIRMWARE_DOWNLOAD_PATH}/${firmware_download_name}" ]]; then
tolog "03.02 OpenWrt downloaded successfully."
else
@@ -173,7 +173,7 @@ check_kernel() {
# Check the version on the server
latest_version="$(
curl -fsSL -m 10 \
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
${kernel_api}/releases/expanded_assets/kernel_${kernel_tag} |
grep -oE "${main_line_version}\.[0-9]+[^\"]*\.tar\.gz" | sed 's/.tar.gz//' |
sort -urV | head -n 1
@@ -185,7 +185,7 @@ check_kernel() {
# Get the sha256 value of the latest version
latest_kernel_sha256="$(
curl -fsSL -m 10 \
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
${kernel_api}/releases/expanded_assets/kernel_${kernel_tag} |
awk -v pattern="${latest_version}\.tar\.gz" -v RS='</li>' '$0 ~ pattern { print $0 "</li>"; exit }' |
grep -o 'value="sha256:[^"]*' | sed 's/value="sha256://'
@@ -225,7 +225,7 @@ download_kernel() {
kernel_down_from="https://github.com/${kernel_repo}/releases/download/kernel_${kernel_tag}/${kernel_file_name}.tar.gz"
curl -fsSL "${kernel_down_from}" -o ${KERNEL_DOWNLOAD_PATH}/${kernel_file_name}.tar.gz
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error "${kernel_down_from}" -o "${KERNEL_DOWNLOAD_PATH}/${kernel_file_name}.tar.gz"
[[ "${?}" -ne "0" ]] && tolog "03.02 The kernel download failed." "1"
# Verify sha256
@@ -132,7 +132,7 @@ check_plugin() {
if [[ "${plugin_branch}" == "main" ]]; then
# JS branch: match tags ending with -js (e.g. 3.1.305-js)
latest_version="$(
curl -fsSL -m 10 \
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
https://github.com/ophub/luci-app-amlogic/releases |
grep -oE 'expanded_assets/[0-9]+\.[0-9]+\.[0-9]+-js' | sed 's|expanded_assets/||g' |
sort -urV | head -n 1
@@ -140,7 +140,7 @@ check_plugin() {
else
# Lua branch: match tags with digits only, no suffix (e.g. 3.1.305)
latest_version="$(
curl -fsSL -m 10 \
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
https://github.com/ophub/luci-app-amlogic/releases |
grep -oE 'expanded_assets/[0-9]+\.[0-9]+\.[0-9]+' | sed 's|expanded_assets/||g' |
grep -v -- '-' |
@@ -212,7 +212,7 @@ download_plugin() {
tolog "03.03 Querying GitHub API for release assets..."
api_url="https://api.github.com/repos/ophub/luci-app-amlogic/releases/tags/${latest_version}"
asset_list="$(curl -fsSL -m 15 "${api_url}" | jq -r '.assets[].name' | xargs)"
asset_list="$(curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error "${api_url}" | jq -r '.assets[].name' | xargs)"
if [[ -z "${asset_list}" ]]; then
tolog "03.03 Failed to fetch release assets from GitHub API." "1"
fi
@@ -229,14 +229,14 @@ download_plugin() {
# Download the main plugin file
plugin_full_url="${download_repo}/${latest_version}/${plugin_file_name}"
tolog "03.05 Downloading main plugin..."
curl -fsSL "${plugin_full_url}" -o "${TMP_CHECK_DIR}/${plugin_file_name}"
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error "${plugin_full_url}" -o "${TMP_CHECK_DIR}/${plugin_file_name}"
[[ "${?}" -ne "0" ]] && tolog "03.05 Plugin [ ${plugin_file_name} ] download failed." "1"
# Download language packs
for langfile in "${lang_file_list[@]}"; do
lang_full_url="${download_repo}/${latest_version}/${langfile}"
tolog "03.06 Downloading language pack [ ${langfile} ]..."
curl -fsSL "${lang_full_url}" -o "${TMP_CHECK_DIR}/${langfile}"
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error "${lang_full_url}" -o "${TMP_CHECK_DIR}/${langfile}"
[[ "${?}" -ne "0" ]] && tolog "03.06 Language pack [ ${langfile} ] download failed." "1"
done
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-easymesh
PKG_VERSION:=2.4.1
PKG_RELEASE:=7
PKG_RELEASE:=8
PKG_LICENSE:=GPL-2.0
PKG_MAINTAINER:=Arafat Rahman Zami Mondol <zamimondol@gmail.com>
@@ -1,24 +1,28 @@
local nixio = require "nixio"
require "nixio.fs"
-- Copyright (C) 2021 dz <dingzhong110@gmail.com>
module("luci.controller.easymesh", package.seeall)
local fs = require "nixio.fs"
function index()
if not fs.access("/etc/config/easymesh") then
return
end
if not nixio.fs.access("/etc/config/easymesh") then return end
-- luci 23.05+ 已通过 menu.d JSON 注册菜单,无需重复
if fs.access("/usr/share/luci/menu.d/luci-app-easymesh.json") then
return
end
local page = entry({"admin", "network", "easymesh"},
firstchild(), _("EasyMesh"), 60)
page.dependent = false
page.acl_depends = { "luci-app-easymesh" }
local page
entry({"admin", "network", "easymesh", "dashboard"},
cbi("easymesh/dashboard"), _("Dashboard"), 5)
page = entry({"admin", "network", "easymesh"}, cbi("easymesh"), _("EASY MESH"), 60)
page.dependent = true
page.acl_depends = { "luci-app-easymesh" }
entry({"admin", "network", "easymesh", "config"},
cbi("easymesh/config"), _("Settings"), 10)
entry({"admin", "network", "easymesh", "ap_mode"},
cbi("easymesh/ap_mode"), _("AP Mode"), 15)
entry({"admin", "network", "easymesh", "client_wifi"},
cbi("easymesh/client_wifi"), _("Client WiFi"), 20)
entry({"admin", "network", "easymesh", "quick"},
cbi("easymesh/quick_setup"), _("Quick Setup"), 25)
entry({"admin", "network", "easymesh", "vlans"},
cbi("easymesh/vlans"), _("VLANs"), 30)
end
@@ -1,685 +0,0 @@
-- Copyright (C) 2021 dz <dingzhong110@gmail.com>
-- Copyright (C) 2026 Arafat Rahman Zami Mondol <zamimondol@gmail.com>
-- v2.8 — wired+wireless priority display, editable priority weights, mesh VLANs
local m, s, o
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
m = Map("easymesh", translate("EasyMesh"),
translate("Configure a Batman-adv mesh network."))
function m.parse(self, ...)
Map.parse(self, ...)
local role = self:get("config", "role")
local ap_mode = self:get("config", "ap_mode")
if role == "server" and ap_mode == "1" then
self:set("config", "ap_mode", "0")
end
local wired_if = self:get("config", "wired_if")
local preferred = self:get("config", "preferred_wired_port")
if (not wired_if or wired_if == "") and preferred and preferred ~= "" then
self:set("config", "wired_if", preferred)
elseif wired_if and wired_if ~= "" and (not preferred or preferred == "") then
self:set("config", "preferred_wired_port", wired_if)
end
end
function m.on_after_commit(self)
luci.sys.call("/etc/init.d/easymesh restart >/tmp/easymesh-apply.log 2>&1 &")
end
-- =========================================================
-- Helpers
-- =========================================================
local function get_preferred_wired_port()
local wired_if = uci:get("easymesh", "config", "wired_if")
local preferred = uci:get("easymesh", "config", "preferred_wired_port")
local pick = ""
if wired_if and wired_if ~= "" then pick = wired_if
elseif preferred and preferred ~= "" then pick = preferred end
if pick ~= "" then
local f = io.open("/sys/class/net/" .. pick, "r")
if f then f:close(); return pick end
end
return ""
end
local function list_eth_ports()
local ports, seen = {}, {}
local fp = io.popen("ls /sys/class/net 2>/dev/null")
if fp then
for d in fp:lines() do
if (d:match("^eth%d+$") or d:match("^lan%d+$")) and not seen[d] then
seen[d] = true; table.insert(ports, d)
end
end
fp:close()
end
uci:foreach("network", "device", function(sect)
local n = sect['.name']
if n and (n:match("^eth%d+$") or n:match("^lan%d+$")) and not seen[n] then
seen[n] = true; table.insert(ports, n)
end
end)
table.sort(ports)
return ports
end
local function port_label(p)
local f = io.open("/sys/class/net/" .. p .. "/brport/bridge", "r")
if f then f:close(); return p .. " (in bridge — will be removed on apply)" end
return p .. " (free)"
end
local function read_bytes(iface)
local rx, tx = 0, 0
local f = io.open("/sys/class/net/" .. iface .. "/statistics/rx_bytes", "r")
if f then rx = tonumber(f:read("*l")) or 0; f:close() end
f = io.open("/sys/class/net/" .. iface .. "/statistics/tx_bytes", "r")
if f then tx = tonumber(f:read("*l")) or 0; f:close() end
return rx, tx
end
local function human_rate(bps)
if bps < 0 then bps = 0 end
if bps < 1024 then return string.format("%d B/s", bps)
elseif bps < 1048576 then return string.format("%.1f KB/s", bps / 1024)
elseif bps < 1073741824 then return string.format("%.1f MB/s", bps / 1048576)
else return string.format("%.2f GB/s", bps / 1073741824) end
end
local function read_override(iface)
-- Try multiple batctl syntaxes — first one that returns a digit wins
local cmds = {
"batctl hardif " .. iface .. " throughput_override",
"batctl hardif " .. iface .. " to",
"batctl meshif bat0 hardif " .. iface .. " throughput_override",
}
for _, cmd in ipairs(cmds) do
local fp = io.popen(cmd .. " 2>/dev/null")
if fp then
local val = fp:read("*l")
fp:close()
if val and val ~= "" and val:match("%d") then
return val
end
end
end
return "?"
end
local function batman_ping(mac)
if not mac or mac == "" or mac == "—" then return "—" end
-- 30-second cache — page reloads within that window are instant
local cachepath = "/tmp/easymesh-ping-" .. mac:gsub(":", "")
local f = io.open(cachepath, "r")
if f then
local ts = tonumber(f:read("*l") or "0")
local val = f:read("*l") or "—"
f:close()
if ts and (os.time() - ts) < 30 then
return val
end
end
local fp = io.popen("batctl ping -c 2 -i 0.2 -t 1 " .. mac .. " 2>/dev/null")
if not fp then return "—" end
local min_ms = nil
for line in fp:lines() do
local t = line:match("time=([%d%.]+)")
if t then
local ms = tonumber(t)
if ms and (not min_ms or ms < min_ms) then min_ms = ms end
end
end
fp:close()
local result = min_ms and string.format("%.1f ms", min_ms) or "—"
local w = io.open(cachepath, "w")
if w then w:write(os.time(), "\n", result, "\n"); w:close() end
return result
end
-- =========================================================
-- Active hardifs + live bandwidth sampling (1s window)
-- =========================================================
local active_ifaces = {}
local afl = io.popen("batctl if 2>/dev/null")
if afl then
for line in afl:lines() do
local iface = line:match("^([^:]+):%s*active")
if iface then table.insert(active_ifaces, iface) end
end
afl:close()
end
local s1, s2 = {}, {}
for _, iface in ipairs(active_ifaces) do
local rx, tx = read_bytes(iface); s1[iface] = { rx = rx, tx = tx }
end
if #active_ifaces > 0 then
os.execute("sleep 1")
for _, iface in ipairs(active_ifaces) do
local rx, tx = read_bytes(iface); s2[iface] = { rx = rx, tx = tx }
end
end
local rates = {}
for _, iface in ipairs(active_ifaces) do
rates[iface] = {
rx = human_rate(s2[iface].rx - s1[iface].rx),
tx = human_rate(s2[iface].tx - s1[iface].tx),
}
end
-- =========================================================
-- Originators (for wired neighbor / last-seen)
-- =========================================================
local function parse_originators()
local by_iface = {}
local fp = io.popen("batctl o 2>/dev/null | tail -n +2")
if not fp then return by_iface end
for line in fp:lines() do
local starred = line:match("^%s*%*") ~= nil
local mac, seen = line:match("([0-9a-fA-F:]+)%s+(%d+%.%d+s)")
local iface = line:match("%[%s*([%w%-_]+)%s*%]")
if mac and iface then
if not by_iface[iface] then by_iface[iface] = {} end
table.insert(by_iface[iface], { mac = mac, seen = seen, starred = starred })
end
end
fp:close()
return by_iface
end
local originators = parse_originators()
-- =========================================================
-- Wireless peers table (with Priority column)
-- =========================================================
local function detect_nodes()
local data = {}
local fp = io.popen("batctl n 2>/dev/null | tail -n +2")
if not fp then return data end
for line in fp:lines() do
local iface, neighbor, lastseen = line:match("^%s*(%S+)%s+(%S+)%s+(%S+)")
if iface and neighbor and neighbor:match("^[0-9a-fA-F:]+$") then
if iface:match("^phy") or iface:match("mesh") then
table.insert(data, { IF = iface, Neighbor = neighbor, lastseen = lastseen })
end
end
end
fp:close()
return data
end
local wireless_count = 0
local wired_count = 0
for _, iface in ipairs(active_ifaces) do
if iface:match("^phy") or iface:match("mesh") then
wireless_count = wireless_count + 1
elseif iface:match("^eth") or iface:match("^lan") then
wired_count = wired_count + 1
end
end
local total_links = wireless_count + wired_count
local status_line
if wired_count > 0 then
status_line = translate("Active mesh links") .. ": " .. total_links ..
" (" .. wireless_count .. " wireless + " .. wired_count .. " wired)"
else
status_line = translate("Active mesh links") .. ": " .. wireless_count .. " (all wireless)"
end
local peers = detect_nodes()
for _, p in ipairs(peers) do
-- Skip ping if lastseen > 30s → no timeout cost for stale entries
local ls = tonumber((p.lastseen or ""):match("([%d%.]+)") or "9999")
if ls and ls < 30 then
p.Latency = batman_ping(p.Neighbor)
else
p.Latency = translate("stale")
end
p.Priority = read_override(p.IF)
local r = rates[p.IF]
if r then p.BW_RX = r.rx; p.BW_TX = r.tx
else p.BW_RX = "—"; p.BW_TX = "—" end
end
local v = m:section(Table, peers, "", "<b>" .. status_line .. "</b>")
v:option(DummyValue, "IF", translate("Interface"))
v:option(DummyValue, "Neighbor", translate("Neighbor MAC"))
v:option(DummyValue, "lastseen", translate("Last seen"))
v:option(DummyValue, "Latency", translate("Latency"))
v:option(DummyValue, "BW_RX", translate("RX rate"))
v:option(DummyValue, "BW_TX", translate("TX rate"))
v:option(DummyValue, "Priority", translate("Priority weight"))
-- =========================================================
-- Wired backhaul details
-- =========================================================
local function detect_wired_details()
local rows = {}
local fp = io.popen("batctl if 2>/dev/null")
if not fp then return rows end
for line in fp:lines() do
local iface, state = line:match("^([^:]+):%s*(%S+)")
if iface and state == "active" and
(iface:match("^eth%d+$") or iface:match("^lan%d+$")) then
local carrier = "?"
local cf = io.open("/sys/class/net/" .. iface .. "/carrier", "r")
if cf then local val = cf:read("*l"); cf:close()
carrier = (val == "1") and "yes" or "no" end
local speed = "?"
local sf = io.open("/sys/class/net/" .. iface .. "/speed", "r")
if sf then local val = sf:read("*l"); sf:close()
if val and val ~= "" and val ~= "-1" then speed = val .. " Mbps" end end
local override = read_override(iface)
local bridged = "no"
local bf = io.open("/sys/class/net/" .. iface .. "/brport/bridge", "r")
if bf then bf:close(); bridged = "yes" end
local entries = originators[iface] or {}
local best = nil
for _, e in ipairs(entries) do
if e.starred then best = e; break end
end
best = best or entries[1]
local neighbor = best and best.mac or "—"
local lastseen = best and best.seen or "—"
local latency = batman_ping(neighbor)
local r = rates[iface]
table.insert(rows, {
IF = iface, State = state, Cable = carrier, Speed = speed,
Neighbor = neighbor, Lastseen = lastseen, Latency = latency,
BW_RX = r and r.rx or "—", BW_TX = r and r.tx or "—",
Override = override, Bridged = bridged,
})
end
end
fp:close()
return rows
end
local wired_rows = detect_wired_details()
if #wired_rows > 0 then
local wv = m:section(Table, wired_rows,
"<b>" .. translate("Wired backhaul details") .. "</b>")
wv:option(DummyValue, "IF", translate("Interface"))
wv:option(DummyValue, "State", translate("State"))
wv:option(DummyValue, "Cable", translate("Cable"))
wv:option(DummyValue, "Speed", translate("Link speed"))
wv:option(DummyValue, "Neighbor", translate("Neighbor MAC"))
wv:option(DummyValue, "Lastseen", translate("Last seen"))
wv:option(DummyValue, "Latency", translate("Latency"))
wv:option(DummyValue, "BW_RX", translate("RX rate"))
wv:option(DummyValue, "BW_TX", translate("TX rate"))
wv:option(DummyValue, "Override", translate("Priority weight"))
wv:option(DummyValue, "Bridged", translate("In LAN bridge"))
end
-- =========================================================
-- TOP: Preferred wired port
-- =========================================================
local wp = m:section(TypedSection, "easymesh", translate("Wired backhaul port"),
translate("Convenience selector. Mirrors 'Wired backhaul interface' below. " ..
"Presets that use wired backhaul will remove this port from the LAN bridge. " ..
"Pick a port NOT used by your uplink or modem. " ..
"Leave empty to disable wired backhaul entirely."))
wp.anonymous = true
o = wp:option(ListValue, "preferred_wired_port", translate("Preferred wired port"))
o:value("", translate("(none — wired presets will skip)"))
for _, p in ipairs(list_eth_ports()) do o:value(p, port_label(p)) end
o.default = ""
o.rmempty = false
-- =========================================================
-- Boot-time reliability (cron toggle)
-- =========================================================
local cf = m:section(TypedSection, "easymesh",
translate("Boot-time reliability"),
translate("The init script applies link priorities at boot and 90s later. " ..
"On some hardware, mesh interfaces appear even later, leaving " ..
"priority weight at 0. Enable cron fallback to reapply priority " ..
"every minute. Safe (idempotent) but runs in the background. " ..
"When disabled, the cron line is commented out in /etc/crontabs/root."))
cf.anonymous = true
o = cf:option(Flag, "cron_fallback", translate("Enable cron fallback"))
o.default = "0"
o.rmempty = false
-- =========================================================
-- Link priority weights (editable)
-- =========================================================
local lp = m:section(TypedSection, "easymesh",
translate("Link priority weights"),
translate("Batman-adv throughput_override values in Kbit/s. " ..
"Higher = preferred. Set to 0 to let batman auto-decide per link. " ..
"Defaults: wired=1000000, 6G=900000, 5G=600000, 2.4G=30000. " ..
"Changes apply on next Save & Apply (or 'Reapply EasyMesh Settings')."))
lp.anonymous = true
o = lp:option(Value, "priority_wired", translate("Wired (Kbit/s)"))
o.default = "1000000"
o.datatype = "uinteger"
o.rmempty = false
o = lp:option(Value, "priority_6g", translate("6 GHz wireless (Kbit/s)"))
o.default = "900000"
o.datatype = "uinteger"
o.rmempty = false
o = lp:option(Value, "priority_5g", translate("5 GHz wireless (Kbit/s)"))
o.default = "600000"
o.datatype = "uinteger"
o.rmempty = false
o = lp:option(Value, "priority_24g", translate("2.4 GHz wireless (Kbit/s)"))
o.default = "30000"
o.datatype = "uinteger"
o.rmempty = false
-- =========================================================
-- Mesh VLANs (extend guest/IoT/hotspot over mesh)
-- =========================================================
local mv = m:section(TypedSection, "easymesh",
translate("Mesh VLANs"),
translate("Extend guest / IoT / hotspot networks across the mesh. " ..
"Enter a comma-separated list of VLAN IDs (e.g. 10,20,30). " ..
"The init script creates bat0.<vid> as an 8021q device for each ID. " ..
"If a bridge named br-<vid>, br-lan<vid>, br-vlan<vid>, br-guest<vid>, " ..
"or br-iot<vid> already exists, it is auto-added to that bridge. " ..
"Otherwise, add the bat0.<vid> device manually in " ..
"Network → Interfaces → Devices. " ..
"Requires 802.1Q-capable switches on the physical wired backhaul."))
mv.anonymous = true
o = mv:option(Value, "mesh_vlans",
translate("VLAN IDs (comma-separated)"),
translate("Example: 10,20,30 — Leave empty to disable"))
o.default = ""
o.datatype = "maxlength(200)"
o.rmempty = false
-- =========================================================
-- QUICK SETUP — 19 presets
-- =========================================================
local q = m:section(TypedSection, "easymesh", translate("Quick Setup"),
translate("Click a preset. A confirmation dialog will appear. " ..
"Current config is backed up automatically before applying. " ..
"Grouped by role: SERVER (has internet) → CLIENT (uses mesh) → NODE (relay only)."))
q.anonymous = true
local presets = {
{ id = "srv_5g", values = { enabled="1", role="server", band_mode="single_5g", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("1. Server — 5 GHz wireless only"), desc = translate("[SERVER] Gateway. Extenders join via 5 GHz mesh only.") },
{ id = "srv_2g", values = { enabled="1", role="server", band_mode="single_2g", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("2. Server — 2.4 GHz wireless only"), desc = translate("[SERVER] Extenders join via 2.4 GHz (longer range).") },
{ id = "srv_dual", values = { enabled="1", role="server", band_mode="dual", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("3. Server — Dual-band wireless only"), desc = translate("[SERVER] Both radios carry backhaul.") },
{ id = "srv_wired", values = { enabled="1", role="server", band_mode="none", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("4. Server — Wired backhaul only"), desc = translate("[SERVER] No wireless mesh — extenders connect by cable.") },
{ id = "srv_5g_wired", values = { enabled="1", role="server", band_mode="single_5g_wired", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("5. Server — 5 GHz + Wired"), desc = translate("[SERVER] 5 GHz mesh primary, Ethernet parallel.") },
{ id = "srv_2g_wired", values = { enabled="1", role="server", band_mode="single_2g_wired", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("6. Server — 2.4 GHz + Wired"), desc = translate("[SERVER] 2.4 GHz mesh plus Ethernet.") },
{ id = "srv_dual_wired", values = { enabled="1", role="server", band_mode="dual_wired", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("7. Server — Dual-band + Wired"), desc = translate("[SERVER] Maximum redundancy. Both radios + Ethernet.") },
{ id = "cli_5g", values = { enabled="1", role="client", band_mode="single_5g", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("8. Client — 5 GHz wireless only"), desc = translate("[CLIENT] Extender via 5 GHz. No cable.") },
{ id = "cli_2g", values = { enabled="1", role="client", band_mode="single_2g", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("9. Client — 2.4 GHz wireless only"), desc = translate("[CLIENT] Extender via 2.4 GHz.") },
{ id = "cli_dual", values = { enabled="1", role="client", band_mode="dual", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("10. Client — Dual-band wireless only"), desc = translate("[CLIENT] Extender via both radios.") },
{ id = "cli_wired", values = { enabled="1", role="client", band_mode="none", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("11. Client — Wired backhaul only"), desc = translate("[CLIENT] Cable only, no wireless mesh.") },
{ id = "cli_5g_wired", values = { enabled="1", role="client", band_mode="single_5g_wired", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("12. Client — 5 GHz + Wired fallback"), desc = translate("[CLIENT] 5 GHz primary, Ethernet fallback.") },
{ id = "cli_2g_wired", values = { enabled="1", role="client", band_mode="single_2g_wired", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("13. Client — 2.4 GHz + Wired fallback"), desc = translate("[CLIENT] 2.4 GHz primary, Ethernet fallback.") },
{ id = "cli_dual_wired", values = { enabled="1", role="client", band_mode="dual_wired", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("14. Client — Dual-band + Wired fallback"), desc = translate("[CLIENT] Both radios + Ethernet. Maximum redundancy.") },
{ id = "node_5g", values = { enabled="1", role="node", band_mode="single_5g", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="0", ap_enabled_5g="0", wired_if="" }, label = translate("15. Node — 5 GHz relay"), desc = translate("[NODE] Relay only, 5 GHz. No client SSID.") },
{ id = "node_dual", values = { enabled="1", role="node", band_mode="dual", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="0", ap_enabled_5g="0", wired_if="" }, label = translate("16. Node — Dual-band relay"), desc = translate("[NODE] Relay only, both radios.") },
{ id = "node_wired", values = { enabled="1", role="node", band_mode="none", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="0", ap_enabled_5g="0", wired_if="AUTO_DETECT" }, label = translate("17. Node — Wired relay only"), desc = translate("[NODE] Relay via cable only. No wireless.") },
{ id = "revert_wired", values = {}, custom = "detach_wired", label = translate("18. Detach wired backhaul"), desc = translate("Removes Ethernet backhaul, restores port to bridge.") },
{ id = "disable", values = { enabled="0" }, label = translate("19. Disable mesh"), desc = translate("Turns off mesh. Radios stay enabled.") },
}
for _, p in ipairs(presets) do
local btn = q:option(Button, "_preset_" .. p.id, p.label)
btn.inputstyle = "apply"
btn.description = p.desc
function btn.write(self, section)
luci.sys.call("/etc/init.d/easymesh backup >/tmp/easymesh-preset.log 2>&1")
if p.custom == "detach_wired" then
luci.sys.call("/etc/init.d/easymesh detach_wired >>/tmp/easymesh-preset.log 2>&1")
else
local cur = luci.model.uci.cursor()
local needs_wired = false
for _, val in pairs(p.values) do
if val == "AUTO_DETECT" then needs_wired = true; break end
end
local wired_port = ""
if needs_wired then
wired_port = get_preferred_wired_port()
if wired_port == "" then
luci.sys.call("logger -t easymesh 'Preset needs wired but no port chosen — wired disabled'")
end
end
for k, val in pairs(p.values) do
if val ~= "AUTO_DETECT" then cur:set("easymesh", "config", k, val) end
end
cur:set("easymesh", "config", "wired_if", wired_port)
cur:set("easymesh", "config", "preferred_wired_port", wired_port)
if needs_wired and wired_port == "" then
local bm = p.values.band_mode or "single_5g"
bm = tostring(bm):gsub("_wired$", "")
cur:set("easymesh", "config", "band_mode", bm)
end
cur:commit("easymesh")
luci.sys.call("/etc/init.d/easymesh restart >>/tmp/easymesh-preset.log 2>&1")
end
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh"))
end
end
-- =========================================================
-- MESH SETTINGS
-- =========================================================
s = m:section(TypedSection, "easymesh", translate("Mesh settings"))
s.anonymous = true
o = s:option(Flag, "enabled", translate("Enable EasyMesh"))
o.default = "0"; o.rmempty = false
o = s:option(ListValue, "role", translate("Mesh role"))
o:value("off", translate("Off"))
o:value("server", translate("Server (gateway - has internet)"))
o:value("client", translate("Client (uses mesh internet)"))
o:value("node", translate("Node (relay only)"))
o.default = "off"; o.rmempty = false
o = s:option(ListValue, "band_mode", translate("Mesh mode"),
translate("Combined wireless band + wired backhaul. Wired variants use the port selected above."))
o:value("single_5g", translate("5 GHz wireless only"))
o:value("single_2g", translate("2.4 GHz wireless only"))
o:value("dual", translate("Dual-band wireless only"))
o:value("single_5g_wired", translate("5 GHz wireless + Wired backhaul"))
o:value("single_2g_wired", translate("2.4 GHz wireless + Wired backhaul"))
o:value("dual_wired", translate("Dual-band wireless + Wired backhaul"))
o:value("none", translate("Wired only (no wireless mesh)"))
o:value("custom", translate("Custom radio"))
o.default = "single_5g"
o:depends("enabled", "1")
local apRadio = s:option(ListValue, "custom_radio", translate("Custom mesh radio"))
uci:foreach("wireless", "wifi-device", function(sect)
local name = sect['.name']
local band = sect.band or "?"
local label = name .. " ("
if band == "2g" then label = label .. "2.4 GHz"
elseif band == "5g" then label = label .. "5 GHz"
elseif band == "6g" then label = label .. "6 GHz"
else label = label .. band end
apRadio:value(name, label .. ")")
end)
apRadio:depends("band_mode", "custom")
o = s:option(Value, "mesh_id", translate("Mesh ID"),
translate("Must match on all nodes. Ignored in wired-only mode."))
o.default = "EasyMesh_Dualband"; o.datatype = "maxlength(32)"; o:depends("enabled", "1")
o = s:option(Flag, "encryption", translate("Require password (WPA3-SAE)"))
o.default = "0"; o.rmempty = false; o:depends("enabled", "1")
o = s:option(Value, "key", translate("Mesh password"),
translate("Must match on all nodes. Only used when 'Require password' is enabled."))
o.default = "changeme123"; o.password = true; o.rmempty = false; o:depends("encryption", "1")
o = s:option(Flag, "kvr", translate("Enable 802.11k/v/r fast roaming"))
o.default = "1"; o.rmempty = false; o:depends("enabled", "1")
o = s:option(Value, "mobility_domain", translate("Mobility domain"))
o.default = "4f57"; o.datatype = "and(hexstring,rangelength(4,4))"; o:depends("kvr", "1")
-- =========================================================
-- CLIENT WIFI — 2.4 GHz
-- =========================================================
local t24 = m:section(TypedSection, "easymesh", translate("Client WiFi — 2.4 GHz"),
translate("Longer range, lower speed. Use a different SSID from 5 GHz."))
t24.anonymous = true
o = t24:option(Flag, "ap_enabled_2g", translate("Broadcast 2.4 GHz"))
o.default = "1"; o.rmempty = false; o:depends("enabled", "1")
o = t24:option(Value, "ap_ssid_2g", translate("2.4 GHz SSID"))
o.default = "EasyMesh_WiFi"; o.datatype = "maxlength(32)"; o:depends("ap_enabled_2g", "1")
o = t24:option(ListValue, "ap_encryption_2g", translate("2.4 GHz security"))
o:value("none", translate("None (open)"))
o:value("psk2+ccmp", translate("WPA2-PSK"))
o:value("sae", translate("WPA3-SAE"))
o:value("sae-mixed", translate("WPA2/WPA3 transition"))
o.default = "sae-mixed"; o:depends("ap_enabled_2g", "1")
o = t24:option(Value, "ap_key_2g", translate("2.4 GHz password"))
o.default = "changeme123"; o.password = true
o:depends("ap_encryption_2g", "psk2+ccmp")
o:depends("ap_encryption_2g", "sae")
o:depends("ap_encryption_2g", "sae-mixed")
-- =========================================================
-- CLIENT WIFI — 5 GHz
-- =========================================================
local t5 = m:section(TypedSection, "easymesh", translate("Client WiFi — 5 GHz"),
translate("Shorter range, much faster. Use a different SSID from 2.4 GHz."))
t5.anonymous = true
o = t5:option(Flag, "ap_enabled_5g", translate("Broadcast 5 GHz"))
o.default = "1"; o.rmempty = false; o:depends("enabled", "1")
o = t5:option(Value, "ap_ssid_5g", translate("5 GHz SSID"))
o.default = "EasyMesh_WiFi_5G"; o.datatype = "maxlength(32)"; o:depends("ap_enabled_5g", "1")
o = t5:option(ListValue, "ap_encryption_5g", translate("5 GHz security"))
o:value("none", translate("None (open)"))
o:value("psk2+ccmp", translate("WPA2-PSK"))
o:value("sae", translate("WPA3-SAE"))
o:value("sae-mixed", translate("WPA2/WPA3 transition"))
o.default = "sae-mixed"; o:depends("ap_enabled_5g", "1")
o = t5:option(Value, "ap_key_5g", translate("5 GHz password"))
o.default = "changeme123"; o.password = true
o:depends("ap_encryption_5g", "psk2+ccmp")
o:depends("ap_encryption_5g", "sae")
o:depends("ap_encryption_5g", "sae-mixed")
local tn = m:section(TypedSection, "easymesh", translate("Client WiFi — shared"))
tn.anonymous = true
o = tn:option(ListValue, "ap_network", translate("Attach client WiFi to network"))
o.default = "lan"
uci:foreach("network", "interface", function(sect)
local name = sect['.name']
if name then o:value(name, name) end
end)
o:depends("enabled", "1")
-- =========================================================
-- AP MODE
-- =========================================================
local u = m:section(TypedSection, "easymesh", translate("AP Mode (Dumb AP)"))
u.anonymous = true
o = u:option(Flag, "ap_mode", translate("Enable AP Mode (Dumb AP)"))
o.default = "0"; o.rmempty = false
o:depends("role", "client"); o:depends("role", "node")
o = u:option(ListValue, "ip_mode", translate("IP mode"))
o:value("dhcp", translate("DHCP (from Server)"))
o:value("static", translate("Static IP"))
o.default = "static"; o:depends("ap_mode", "1")
o = u:option(Value, "ipaddr", translate("IP address"))
o.default = "192.168.10.10"; o.datatype = "ip4addr"; o:depends("ip_mode", "static")
o = u:option(Value, "netmask", translate("Netmask"))
o.default = "255.255.255.0"; o.datatype = "ip4addr"; o:depends("ip_mode", "static")
o = u:option(Value, "gateway", translate("Gateway"))
o.default = "192.168.10.1"; o.datatype = "ip4addr"; o:depends("ip_mode", "static")
o = u:option(Value, "dns", translate("DNS server"))
o.default = "192.168.10.1"; o.datatype = "ip4addr"; o:depends("ip_mode", "static")
-- =========================================================
-- BOTTOM: Wired backhaul (source of truth)
-- =========================================================
local w = m:section(TypedSection, "easymesh", translate("Wired backhaul"),
translate("Optional. Adds a physical Ethernet port as a batman hardif. " ..
"Required on BOTH sides for a wired link. " ..
"The port is auto-removed from the LAN bridge on apply."))
w.anonymous = true
o = w:option(ListValue, "wired_if", translate("Wired backhaul interface"))
o:value("", translate("Disabled"))
for _, p in ipairs(list_eth_ports()) do o:value(p, port_label(p)) end
-- =========================================================
-- Safety and actions
-- =========================================================
local a = m:section(TypedSection, "easymesh", translate("Safety and actions"))
a.anonymous = true
o = a:option(Flag, "safe_apply", translate("Safe apply (60-second rollback)"))
o.default = "0"; o.rmempty = false
local reapply = a:option(Button, "_reapply", translate("Reapply EasyMesh Settings"))
reapply.inputtitle = translate("Reapply EasyMesh Settings")
reapply.inputstyle = "apply"
function reapply.write(self, section)
luci.sys.call("/etc/init.d/easymesh restart >/tmp/easymesh-reapply.log 2>&1")
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh"))
end
local restore = a:option(Button, "_restore", translate("Restore from last backup"))
restore.inputtitle = translate("Restore from last backup")
restore.inputstyle = "reset"
function restore.write(self, section)
luci.sys.call("/etc/init.d/easymesh restore >/tmp/easymesh-restore.log 2>&1")
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh"))
end
local pairbtn = a:option(Button, "_pair", translate("Pair with another router"))
pairbtn.inputtitle = translate("Enter pairing mode (2 minutes)")
pairbtn.inputstyle = "apply"
pairbtn.description = translate("WPS-style pairing. Press the WPS button on both routers within 2 minutes. Auto-reverts on timeout.")
function pairbtn.write(self, section)
luci.sys.call("/etc/init.d/easymesh pair >/tmp/easymesh-pair.log 2>&1 &")
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh"))
end
local confirm_section = m:section(SimpleSection)
confirm_section.template = "easymesh/preset_confirm"
return m
@@ -0,0 +1,55 @@
-- luci-app-easymesh — AP Mode tab
-- Copyright (C) 2026 Arafat Rahman Zami Mondol <zamimondol@gmail.com>
local m, u, o
local uci = require "luci.model.uci".cursor()
local helpers = require "luci.model.cbi.easymesh.helpers"
m = Map("easymesh", translate("EasyMesh — AP Mode (Dumb AP)"),
translate("A Dumb AP broadcasts the client WiFi network without running " ..
"its own DHCP or firewall. Use this on satellite nodes so " ..
"clients on any node get an IP from the master server."))
helpers.attach_common_hooks(m, Map)
-- ─── Gate by role (read from UCI, since role lives on the Settings tab) ───
local role = uci:get("easymesh", "config", "role") or "off"
if role ~= "client" and role ~= "node" then
local notice = m:section(SimpleSection)
notice.template = "easymesh/ap_mode_gate"
notice.data = {
role = role,
}
return m
end
-- ─── AP Mode configuration (client + node only) ───────────
u = m:section(TypedSection, "easymesh", translate("AP Mode"))
u.anonymous = true
o = u:option(Flag, "ap_mode", translate("Enable AP Mode (Dumb AP)"))
o.default = "0"; o.rmempty = false
o = u:option(ListValue, "ip_mode", translate("IP mode"))
o:value("dhcp", translate("DHCP (from Server)"))
o:value("static", translate("Static IP"))
o.default = "static"; o:depends("ap_mode", "1")
o = u:option(Value, "ipaddr", translate("IP address"))
o.default = "192.168.10.10"; o.datatype = "ip4addr"
o:depends("ip_mode", "static")
o = u:option(Value, "netmask", translate("Netmask"))
o.default = "255.255.255.0"; o.datatype = "ip4addr"
o:depends("ip_mode", "static")
o = u:option(Value, "gateway", translate("Gateway"))
o.default = "192.168.10.1"; o.datatype = "ip4addr"
o:depends("ip_mode", "static")
o = u:option(Value, "dns", translate("DNS server"))
o.default = "192.168.10.1"; o.datatype = "ip4addr"
o:depends("ip_mode", "static")
return m
@@ -0,0 +1,85 @@
-- luci-app-easymesh — Client WiFi tab
-- Copyright (C) 2026 Arafat Rahman Zami Mondol <zamimondol@gmail.com>
local m, o, t24, t5, tn
local uci = require "luci.model.uci".cursor()
local helpers = require "luci.model.cbi.easymesh.helpers"
m = Map("easymesh", translate("EasyMesh — Client WiFi"),
translate("SSIDs broadcast by client nodes so end devices can join " ..
"the mesh. Use different SSIDs for 2.4 GHz and 5 GHz to " ..
"prevent sticky-client behaviour."))
helpers.attach_common_hooks(m, Map)
-- ─── Gate by enabled (read from UCI, since enabled lives on Settings) ───
local enabled = uci:get("easymesh", "config", "enabled") or "0"
if enabled ~= "1" then
local notice = m:section(SimpleSection)
notice.template = "easymesh/client_wifi_gate"
return m
end
-- ─── 2.4 GHz ──────────────────────────────────────────────
t24 = m:section(TypedSection, "easymesh", translate("2.4 GHz"),
translate("Longer range, lower speed."))
t24.anonymous = true
o = t24:option(Flag, "ap_enabled_2g", translate("Broadcast 2.4 GHz"))
o.default = "1"; o.rmempty = false
o = t24:option(Value, "ap_ssid_2g", translate("2.4 GHz SSID"))
o.default = "EasyMesh_WiFi"; o.datatype = "maxlength(32)"
o:depends("ap_enabled_2g", "1")
o = t24:option(ListValue, "ap_encryption_2g", translate("2.4 GHz security"))
o:value("none", translate("None (open)"))
o:value("psk2+ccmp", translate("WPA2-PSK"))
o:value("sae", translate("WPA3-SAE"))
o:value("sae-mixed", translate("WPA2/WPA3 transition"))
o.default = "sae-mixed"; o:depends("ap_enabled_2g", "1")
o = t24:option(Value, "ap_key_2g", translate("2.4 GHz password"))
o.default = "changeme123"; o.password = true
o:depends("ap_encryption_2g", "psk2+ccmp")
o:depends("ap_encryption_2g", "sae")
o:depends("ap_encryption_2g", "sae-mixed")
-- ─── 5 GHz ────────────────────────────────────────────────
t5 = m:section(TypedSection, "easymesh", translate("5 GHz"),
translate("Shorter range, much faster."))
t5.anonymous = true
o = t5:option(Flag, "ap_enabled_5g", translate("Broadcast 5 GHz"))
o.default = "1"; o.rmempty = false
o = t5:option(Value, "ap_ssid_5g", translate("5 GHz SSID"))
o.default = "EasyMesh_WiFi_5G"; o.datatype = "maxlength(32)"
o:depends("ap_enabled_5g", "1")
o = t5:option(ListValue, "ap_encryption_5g", translate("5 GHz security"))
o:value("none", translate("None (open)"))
o:value("psk2+ccmp", translate("WPA2-PSK"))
o:value("sae", translate("WPA3-SAE"))
o:value("sae-mixed", translate("WPA2/WPA3 transition"))
o.default = "sae-mixed"; o:depends("ap_enabled_5g", "1")
o = t5:option(Value, "ap_key_5g", translate("5 GHz password"))
o.default = "changeme123"; o.password = true
o:depends("ap_encryption_5g", "psk2+ccmp")
o:depends("ap_encryption_5g", "sae")
o:depends("ap_encryption_5g", "sae-mixed")
-- ─── Shared ───────────────────────────────────────────────
tn = m:section(TypedSection, "easymesh", translate("Shared"))
tn.anonymous = true
o = tn:option(ListValue, "ap_network", translate("Attach client WiFi to network"))
o.default = "lan"
uci:foreach("network", "interface", function(sect)
local name = sect['.name']
if name then o:value(name, name) end
end)
return m
@@ -0,0 +1,154 @@
-- luci-app-easymesh — Settings tab
-- Copyright (C) 2026 Arafat Rahman Zami Mondol <zamimondol@gmail.com>
local m, s, o
local helpers = require "luci.model.cbi.easymesh.helpers"
local uci = require "luci.model.uci".cursor()
m = Map("easymesh", translate("EasyMesh — Settings"),
translate("Mesh role, backhaul interface, link priorities and recovery."))
helpers.attach_common_hooks(m, Map)
-- Safety notice (compact)
local safety = m:section(SimpleSection)
safety.template = "easymesh/safety_notice"
-- ─── Mesh settings ────────────────────────────────────────
s = m:section(TypedSection, "easymesh", translate("Mesh settings"))
s.anonymous = true
o = s:option(Flag, "enabled", translate("Enable EasyMesh"))
o.default = "0"; o.rmempty = false
o = s:option(ListValue, "role", translate("Mesh role"))
o:value("off", translate("Off"))
o:value("server", translate("Server (gateway - has internet)"))
o:value("client", translate("Client (uses mesh internet)"))
o:value("node", translate("Node (relay only)"))
o.default = "off"; o.rmempty = false
o = s:option(ListValue, "band_mode", translate("Mesh mode"),
translate("Combined wireless band + wired backhaul."))
o:value("single_5g", translate("5 GHz wireless only"))
o:value("single_2g", translate("2.4 GHz wireless only"))
o:value("dual", translate("Dual-band wireless only"))
o:value("single_5g_wired", translate("5 GHz wireless + Wired backhaul"))
o:value("single_2g_wired", translate("2.4 GHz wireless + Wired backhaul"))
o:value("dual_wired", translate("Dual-band wireless + Wired backhaul"))
o:value("none", translate("Wired only (no wireless mesh)"))
o:value("custom", translate("Custom radio"))
o.default = "single_5g"
o:depends("enabled", "1")
local apRadio = s:option(ListValue, "custom_radio", translate("Custom mesh radio"))
uci:foreach("wireless", "wifi-device", function(sect)
local name = sect['.name']
local band = sect.band or "?"
local label = name .. " ("
if band == "2g" then label = label .. "2.4 GHz"
elseif band == "5g" then label = label .. "5 GHz"
elseif band == "6g" then label = label .. "6 GHz"
else label = label .. band end
apRadio:value(name, label .. ")")
end)
apRadio:depends("band_mode", "custom")
o = s:option(Value, "mesh_id", translate("Mesh ID"),
translate("Must match on all nodes. Ignored in wired-only mode."))
o.default = "EasyMesh_Dualband"; o.datatype = "maxlength(32)"
o:depends("enabled", "1")
o = s:option(Flag, "encryption", translate("Require password (WPA3-SAE)"))
o.default = "0"; o.rmempty = false; o:depends("enabled", "1")
o = s:option(Value, "key", translate("Mesh password"),
translate("Must match on all nodes."))
o.default = "changeme123"; o.password = true; o.rmempty = false
o:depends("encryption", "1")
o = s:option(Flag, "kvr", translate("Enable 802.11k/v/r fast roaming"))
o.default = "1"; o.rmempty = false; o:depends("enabled", "1")
o = s:option(Value, "mobility_domain", translate("Mobility domain"))
o.default = "4f57"; o.datatype = "and(hexstring,rangelength(4,4))"
o:depends("kvr", "1")
-- ─── Wired backhaul (source of truth) ─────────────────────
local w = m:section(TypedSection, "easymesh", translate("Wired backhaul interface"),
translate("Adds a physical Ethernet port as a batman hardif. Required on " ..
"BOTH sides for a wired link. The port is auto-removed from the " ..
"LAN bridge on apply."))
w.anonymous = true
o = w:option(ListValue, "wired_if", translate("Wired backhaul interface"))
o:value("", translate("Disabled"))
for _, p in ipairs(helpers.list_eth_ports()) do
o:value(p, helpers.port_label(p))
end
-- ─── Link priority weights ────────────────────────────────
local lp = m:section(TypedSection, "easymesh", translate("Link priority weights"),
translate("Batman-adv throughput_override values in Kbit/s. Higher = " ..
"preferred. Set to 0 to let batman auto-decide per link. " ..
"Defaults: wired=1000000, 6G=900000, 5G=600000, 2.4G=30000."))
lp.anonymous = true
o = lp:option(Value, "priority_wired", translate("Wired (Kbit/s)"))
o.default = "1000000"; o.datatype = "uinteger"; o.rmempty = false
o = lp:option(Value, "priority_6g", translate("6 GHz wireless (Kbit/s)"))
o.default = "900000"; o.datatype = "uinteger"; o.rmempty = false
o = lp:option(Value, "priority_5g", translate("5 GHz wireless (Kbit/s)"))
o.default = "600000"; o.datatype = "uinteger"; o.rmempty = false
o = lp:option(Value, "priority_24g", translate("2.4 GHz wireless (Kbit/s)"))
o.default = "30000"; o.datatype = "uinteger"; o.rmempty = false
-- ─── Boot-time reliability ────────────────────────────────
local cf = m:section(TypedSection, "easymesh", translate("Boot-time reliability"),
translate("The init script applies link priorities at boot and 90s later. " ..
"Enable cron fallback to reapply every minute on hardware where " ..
"mesh interfaces appear late. Safe (idempotent)."))
cf.anonymous = true
o = cf:option(Flag, "cron_fallback", translate("Enable cron fallback"))
o.default = "0"; o.rmempty = false
-- ─── Safe Apply & Recovery Actions ────────────────────────────────
local a = m:section(TypedSection, "easymesh", translate("Safe Apply & Recovery Actions"),
translate("Enable rollback before risky changes. Reapply or restore from " ..
"the last automatic backup if something breaks."))
a.anonymous = true
o = a:option(Flag, "safe_apply", translate("Safe apply (60-second rollback)"))
o.default = "0"; o.rmempty = false
local reapply = a:option(Button, "_reapply", translate("Reapply EasyMesh Settings"))
reapply.inputtitle = translate("Reapply EasyMesh Settings")
reapply.inputstyle = "apply"
function reapply.write(self, section)
luci.sys.call("/etc/init.d/easymesh restart >/tmp/easymesh-reapply.log 2>&1")
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh", "config"))
end
local restore = a:option(Button, "_restore", translate("Restore from last backup"))
restore.inputtitle = translate("Restore from last backup")
restore.inputstyle = "reset"
function restore.write(self, section)
luci.sys.call("/etc/init.d/easymesh restore >/tmp/easymesh-restore.log 2>&1")
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh", "config"))
end
local pairbtn = a:option(Button, "_pair", translate("Pair with another router"))
pairbtn.inputtitle = translate("Enter pairing mode (2 minutes)")
pairbtn.inputstyle = "apply"
pairbtn.description = translate("WPS-style pairing. Press the WPS button on both " ..
"routers within 2 minutes. Auto-reverts on timeout.")
function pairbtn.write(self, section)
luci.sys.call("/etc/init.d/easymesh pair >/tmp/easymesh-pair.log 2>&1 &")
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh", "config"))
end
return m
@@ -0,0 +1,124 @@
-- luci-app-easymesh — Dashboard tab
-- Copyright (C) 2026 Arafat Rahman Zami Mondol <zamimondol@gmail.com>
local m, v, wv
local helpers = require "luci.model.cbi.easymesh.helpers"
m = Map("easymesh", translate("EasyMesh — Dashboard"),
translate("Live mesh status. Refreshes on every page load. " ..
"Bandwidth is sampled over 1 second; latency is cached for 30 seconds."))
-- ─── Wireless peers ───────────────────────────────────────
local active = helpers.active_ifaces()
local rates = helpers.sample_rates(active)
local wireless_count, wired_count = 0, 0
for _, iface in ipairs(active) do
if iface:match("^phy") or iface:match("^mesh") then
wireless_count = wireless_count + 1
elseif iface:match("^eth") or iface:match("^lan") then
wired_count = wired_count + 1
end
end
local total_links = wireless_count + wired_count
local status_line
if wired_count > 0 then
status_line = translate("Active mesh links") .. ": " .. total_links ..
" (" .. wireless_count .. " wireless + " .. wired_count .. " wired)"
else
status_line = translate("Active mesh links") .. ": " .. wireless_count .. " (all wireless)"
end
local peers = helpers.detect_nodes()
for _, p in ipairs(peers) do
local ls = tonumber((p.lastseen or ""):match("([%d%.]+)") or "9999")
if ls and ls < 30 then
p.Latency = helpers.batman_ping(p.Neighbor)
else
p.Latency = translate("stale")
end
p.Priority = helpers.read_override(p.IF)
local r = rates[p.IF]
if r then p.BW_RX = r.rx; p.BW_TX = r.tx
else p.BW_RX = "—"; p.BW_TX = "—" end
end
v = m:section(Table, peers, "<b>" .. status_line .. "</b>")
v:option(DummyValue, "IF", translate("Interface"))
v:option(DummyValue, "Neighbor", translate("Neighbor MAC"))
v:option(DummyValue, "lastseen", translate("Last seen"))
v:option(DummyValue, "Latency", translate("Latency"))
v:option(DummyValue, "BW_RX", translate("RX rate"))
v:option(DummyValue, "BW_TX", translate("TX rate"))
v:option(DummyValue, "Priority", translate("Priority weight"))
-- ─── Wired backhaul details ───────────────────────────────
local originators = helpers.parse_originators()
local function detect_wired_details()
local rows = {}
local fp = io.popen("batctl if 2>/dev/null")
if not fp then return rows end
for line in fp:lines() do
local iface, state = line:match("^([^:]+):%s*(%S+)")
if iface and state == "active" and
(iface:match("^eth%d+$") or iface:match("^lan%d+$")) then
local carrier = "?"
local cf = io.open("/sys/class/net/" .. iface .. "/carrier", "r")
if cf then local val = cf:read("*l"); cf:close()
carrier = (val == "1") and "yes" or "no" end
local speed = "?"
local sf = io.open("/sys/class/net/" .. iface .. "/speed", "r")
if sf then local val = sf:read("*l"); sf:close()
if val and val ~= "" and val ~= "-1" then speed = val .. " Mbps" end end
local override = helpers.read_override(iface)
local bridged = "no"
local bf = io.open("/sys/class/net/" .. iface .. "/brport/bridge", "r")
if bf then bf:close(); bridged = "yes" end
local entries = originators[iface] or {}
local best = nil
for _, e in ipairs(entries) do
if e.starred then best = e; break end
end
best = best or entries[1]
local neighbor = best and best.mac or "—"
local lastseen = best and best.seen or "—"
local latency = helpers.batman_ping(neighbor)
local r = rates[iface]
table.insert(rows, {
IF = iface, State = state, Cable = carrier, Speed = speed,
Neighbor = neighbor, Lastseen = lastseen, Latency = latency,
BW_RX = r and r.rx or "—", BW_TX = r and r.tx or "—",
Override = override, Bridged = bridged,
})
end
end
fp:close()
return rows
end
local wired_rows = detect_wired_details()
if #wired_rows > 0 then
wv = m:section(Table, wired_rows,
"<b>" .. translate("Wired backhaul details") .. "</b>")
wv:option(DummyValue, "IF", translate("Interface"))
wv:option(DummyValue, "State", translate("State"))
wv:option(DummyValue, "Cable", translate("Cable"))
wv:option(DummyValue, "Speed", translate("Link speed"))
wv:option(DummyValue, "Neighbor", translate("Neighbor MAC"))
wv:option(DummyValue, "Lastseen", translate("Last seen"))
wv:option(DummyValue, "Latency", translate("Latency"))
wv:option(DummyValue, "BW_RX", translate("RX rate"))
wv:option(DummyValue, "BW_TX", translate("TX rate"))
wv:option(DummyValue, "Override", translate("Priority weight"))
wv:option(DummyValue, "Bridged", translate("In LAN bridge"))
end
return m
@@ -0,0 +1,245 @@
-- luci-app-easymesh — shared helper functions
-- Copyright (C) 2026 Arafat Rahman Zami Mondol <zamimondol@gmail.com>
--
-- Extracted from the original monolithic easymesh.lua. These functions are
-- used by the tabbed CBI views (config, quick_setup, vlans, topology, profiles).
-- None of them write config — they only read state or run batctl/io commands.
local M = {}
local uci = require "luci.model.uci".cursor()
-- ─────────────────────────────────────────────────────────
-- Wired port helpers
-- ─────────────────────────────────────────────────────────
function M.get_preferred_wired_port()
local wired_if = uci:get("easymesh", "config", "wired_if")
local preferred = uci:get("easymesh", "config", "preferred_wired_port")
local pick = ""
if wired_if and wired_if ~= "" then pick = wired_if
elseif preferred and preferred ~= "" then pick = preferred end
if pick ~= "" then
local f = io.open("/sys/class/net/" .. pick, "r")
if f then f:close(); return pick end
end
return ""
end
function M.list_eth_ports()
local ports, seen = {}, {}
local fp = io.popen("ls /sys/class/net 2>/dev/null")
if fp then
for d in fp:lines() do
if (d:match("^eth%d+$") or d:match("^lan%d+$")) and not seen[d] then
seen[d] = true
table.insert(ports, d)
end
end
fp:close()
end
uci:foreach("network", "device", function(sect)
local n = sect['.name']
if n and (n:match("^eth%d+$") or n:match("^lan%d+$")) and not seen[n] then
seen[n] = true
table.insert(ports, n)
end
end)
table.sort(ports)
return ports
end
function M.port_label(p)
local f = io.open("/sys/class/net/" .. p .. "/brport/bridge", "r")
if f then f:close(); return p .. " (in bridge — will be removed on apply)" end
return p .. " (free)"
end
-- ─────────────────────────────────────────────────────────
-- Byte / rate helpers
-- ─────────────────────────────────────────────────────────
function M.read_bytes(iface)
local rx, tx = 0, 0
local f = io.open("/sys/class/net/" .. iface .. "/statistics/rx_bytes", "r")
if f then rx = tonumber(f:read("*l")) or 0; f:close() end
f = io.open("/sys/class/net/" .. iface .. "/statistics/tx_bytes", "r")
if f then tx = tonumber(f:read("*l")) or 0; f:close() end
return rx, tx
end
function M.human_rate(bps)
if bps < 0 then bps = 0 end
if bps < 1024 then return string.format("%d B/s", bps)
elseif bps < 1048576 then return string.format("%.1f KB/s", bps / 1024)
elseif bps < 1073741824 then return string.format("%.1f MB/s", bps / 1048576)
else return string.format("%.2f GB/s", bps / 1073741824) end
end
-- ─────────────────────────────────────────────────────────
-- batctl helpers
-- ─────────────────────────────────────────────────────────
function M.read_override(iface)
-- Try multiple batctl syntaxes — first one that returns a digit wins
local cmds = {
"batctl hardif " .. iface .. " throughput_override",
"batctl hardif " .. iface .. " to",
"batctl meshif bat0 hardif " .. iface .. " throughput_override",
}
for _, cmd in ipairs(cmds) do
local fp = io.popen(cmd .. " 2>/dev/null")
if fp then
local val = fp:read("*l")
fp:close()
if val and val ~= "" and val:match("%d") then
return val
end
end
end
return "?"
end
function M.batman_ping(mac)
if not mac or mac == "" or mac == "—" then return "—" end
-- 30-second cache — page reloads within that window are instant
local cachepath = "/tmp/easymesh-ping-" .. mac:gsub(":", "")
local f = io.open(cachepath, "r")
if f then
local ts = tonumber(f:read("*l") or "0")
local val = f:read("*l") or "—"
f:close()
if ts and (os.time() - ts) < 30 then
return val
end
end
local fp = io.popen("batctl ping -c 2 -i 0.2 -t 1 " .. mac .. " 2>/dev/null")
if not fp then return "—" end
local min_ms = nil
for line in fp:lines() do
local t = line:match("time=([%d%.]+)")
if t then
local ms = tonumber(t)
if ms and (not min_ms or ms < min_ms) then min_ms = ms end
end
end
fp:close()
local result = min_ms and string.format("%.1f ms", min_ms) or "—"
local w = io.open(cachepath, "w")
if w then w:write(os.time(), "\n", result, "\n"); w:close() end
return result
end
-- ─────────────────────────────────────────────────────────
-- Active hardifs + bandwidth sampling
-- ─────────────────────────────────────────────────────────
function M.active_ifaces()
local ifaces = {}
local fp = io.popen("batctl if 2>/dev/null")
if fp then
for line in fp:lines() do
local iface = line:match("^([^:]+):%s*active")
if iface then table.insert(ifaces, iface) end
end
fp:close()
end
return ifaces
end
function M.sample_rates(active_ifaces)
local s1, s2 = {}, {}
for _, iface in ipairs(active_ifaces) do
local rx, tx = M.read_bytes(iface); s1[iface] = { rx = rx, tx = tx }
end
if #active_ifaces > 0 then
os.execute("sleep 1")
for _, iface in ipairs(active_ifaces) do
local rx, tx = M.read_bytes(iface); s2[iface] = { rx = rx, tx = tx }
end
end
local rates = {}
for _, iface in ipairs(active_ifaces) do
rates[iface] = {
rx = M.human_rate(s2[iface].rx - s1[iface].rx),
tx = M.human_rate(s2[iface].tx - s1[iface].tx),
}
end
return rates
end
-- ─────────────────────────────────────────────────────────
-- Originators (for wired neighbor / last-seen)
-- ─────────────────────────────────────────────────────────
function M.parse_originators()
local by_iface = {}
local fp = io.popen("batctl o 2>/dev/null | tail -n +2")
if not fp then return by_iface end
for line in fp:lines() do
local starred = line:match("^%s*%*") ~= nil
local mac, seen = line:match("([0-9a-fA-F:]+)%s+(%d+%.%d+s)")
local iface = line:match("%[%s*([%w%-_]+)%s*%]")
if mac and iface then
if not by_iface[iface] then by_iface[iface] = {} end
table.insert(by_iface[iface], { mac = mac, seen = seen, starred = starred })
end
end
fp:close()
return by_iface
end
-- ─────────────────────────────────────────────────────────
-- Wireless peer detection
-- ─────────────────────────────────────────────────────────
function M.detect_nodes()
local data = {}
local fp = io.popen("batctl n 2>/dev/null | tail -n +2")
if not fp then return data end
for line in fp:lines() do
local iface, neighbor, lastseen = line:match("^%s*(%S+)%s+(%S+)%s+(%S+)")
if iface and neighbor and neighbor:match("^[0-9a-fA-F:]+$") then
if iface:match("^phy") or iface:match("^mesh") then
table.insert(data, { IF = iface, Neighbor = neighbor, lastseen = lastseen })
end
end
end
fp:close()
return data
end
-- ─────────────────────────────────────────────────────────
-- Common CBI hooks — call from every tab that saves config
-- ─────────────────────────────────────────────────────────
function M.attach_common_hooks(m, MapClass)
local sys = require "luci.sys"
function m.parse(self, ...)
MapClass.parse(self, ...)
local role = self:get("config", "role")
local ap_mode = self:get("config", "ap_mode")
if role == "server" and ap_mode == "1" then
self:set("config", "ap_mode", "0")
end
local wired_if = self:get("config", "wired_if")
local preferred = self:get("config", "preferred_wired_port")
if (not wired_if or wired_if == "") and preferred and preferred ~= "" then
self:set("config", "wired_if", preferred)
elseif wired_if and wired_if ~= "" and (not preferred or preferred == "") then
self:set("config", "preferred_wired_port", wired_if)
end
end
function m.on_after_commit(self)
sys.call("/etc/init.d/easymesh restart >/tmp/easymesh-apply.log 2>&1 &")
end
end
return M
@@ -0,0 +1,120 @@
-- luci-app-easymesh — Quick Setup tab
-- Copyright (C) 2026 Arafat Rahman Zami Mondol <zamimondol@gmail.com>
local m, o
local helpers = require "luci.model.cbi.easymesh.helpers"
m = Map("easymesh", translate("EasyMesh — Quick Setup"),
translate("Pick a preset for your router's role. Current config is " ..
"backed up automatically before applying."))
helpers.attach_common_hooks(m, Map)
-- ─── Wired backhaul port (convenience selector) ───────────
local wp = m:section(TypedSection, "easymesh", translate("Wired backhaul port"),
translate("Presets with wired backhaul will remove this port from the LAN " ..
"bridge. Pick a port NOT used by your uplink or modem. " ..
"Leave empty to disable wired backhaul entirely."))
wp.anonymous = true
o = wp:option(ListValue, "preferred_wired_port", translate("Preferred wired port"))
o:value("", translate("(none — wired presets will skip)"))
for _, p in ipairs(helpers.list_eth_ports()) do
o:value(p, helpers.port_label(p))
end
o.default = ""
o.rmempty = false
-- ─── Preset definitions ───────────────────────────────────
local presets = {
{ id = "srv_5g", group = "srv", values = { enabled="1", role="server", band_mode="single_5g", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("1. Server — 5 GHz wireless only"), desc = translate("[SERVER] Gateway. Extenders join via 5 GHz mesh only.") },
{ id = "srv_2g", group = "srv", values = { enabled="1", role="server", band_mode="single_2g", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("2. Server — 2.4 GHz wireless only"), desc = translate("[SERVER] Extenders join via 2.4 GHz (longer range).") },
{ id = "srv_dual", group = "srv", values = { enabled="1", role="server", band_mode="dual", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("3. Server — Dual-band wireless only"), desc = translate("[SERVER] Both radios carry backhaul.") },
{ id = "srv_wired", group = "srv", values = { enabled="1", role="server", band_mode="none", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("4. Server — Wired backhaul only"), desc = translate("[SERVER] No wireless mesh — extenders connect by cable.") },
{ id = "srv_5g_wired", group = "srv", values = { enabled="1", role="server", band_mode="single_5g_wired", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("5. Server — 5 GHz + Wired"), desc = translate("[SERVER] 5 GHz mesh primary, Ethernet parallel.") },
{ id = "srv_2g_wired", group = "srv", values = { enabled="1", role="server", band_mode="single_2g_wired", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("6. Server — 2.4 GHz + Wired"), desc = translate("[SERVER] 2.4 GHz mesh plus Ethernet.") },
{ id = "srv_dual_wired", group = "srv", values = { enabled="1", role="server", band_mode="dual_wired", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("7. Server — Dual-band + Wired"), desc = translate("[SERVER] Maximum redundancy. Both radios + Ethernet.") },
{ id = "cli_5g", group = "cli", values = { enabled="1", role="client", band_mode="single_5g", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("8. Client — 5 GHz wireless only"), desc = translate("[CLIENT] Extender via 5 GHz. No cable.") },
{ id = "cli_2g", group = "cli", values = { enabled="1", role="client", band_mode="single_2g", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("9. Client — 2.4 GHz wireless only"), desc = translate("[CLIENT] Extender via 2.4 GHz.") },
{ id = "cli_dual", group = "cli", values = { enabled="1", role="client", band_mode="dual", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="" }, label = translate("10. Client — Dual-band wireless only"), desc = translate("[CLIENT] Extender via both radios.") },
{ id = "cli_wired", group = "cli", values = { enabled="1", role="client", band_mode="none", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("11. Client — Wired backhaul only"), desc = translate("[CLIENT] Cable only, no wireless mesh.") },
{ id = "cli_5g_wired", group = "cli", values = { enabled="1", role="client", band_mode="single_5g_wired", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("12. Client — 5 GHz + Wired fallback"), desc = translate("[CLIENT] 5 GHz primary, Ethernet fallback.") },
{ id = "cli_2g_wired", group = "cli", values = { enabled="1", role="client", band_mode="single_2g_wired", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("13. Client — 2.4 GHz + Wired fallback"), desc = translate("[CLIENT] 2.4 GHz primary, Ethernet fallback.") },
{ id = "cli_dual_wired", group = "cli", values = { enabled="1", role="client", band_mode="dual_wired", encryption="0", kvr="1", ap_mode="1", ip_mode="dhcp", ap_enabled_2g="1", ap_enabled_5g="1", ap_network="lan", wired_if="AUTO_DETECT" }, label = translate("14. Client — Dual-band + Wired fallback"), desc = translate("[CLIENT] Both radios + Ethernet. Maximum redundancy.") },
{ id = "node_5g", group = "node", values = { enabled="1", role="node", band_mode="single_5g", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="0", ap_enabled_5g="0", wired_if="" }, label = translate("15. Node — 5 GHz relay"), desc = translate("[NODE] Relay only, 5 GHz. No client SSID.") },
{ id = "node_dual", group = "node", values = { enabled="1", role="node", band_mode="dual", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="0", ap_enabled_5g="0", wired_if="" }, label = translate("16. Node — Dual-band relay"), desc = translate("[NODE] Relay only, both radios.") },
{ id = "node_wired", group = "node", values = { enabled="1", role="node", band_mode="none", encryption="0", kvr="1", ap_mode="0", ap_enabled_2g="0", ap_enabled_5g="0", wired_if="AUTO_DETECT" }, label = translate("17. Node — Wired relay only"), desc = translate("[NODE] Relay via cable only. No wireless.") },
{ id = "revert_wired", group = "recover", values = {}, custom = "detach_wired", label = translate("18. Detach wired backhaul"), desc = translate("Removes Ethernet backhaul, restores port to bridge.") },
{ id = "disable", group = "disable", values = { enabled="0" }, label = translate("19. Disable mesh"), desc = translate("Turns off mesh. Radios stay enabled.") },
}
local group_headers = {
srv = translate("SERVER presets — has internet (1–7)"),
cli = translate("CLIENT presets — uses mesh internet (8–14)"),
node = translate("NODE presets — relay only (15–17)"),
recover = translate("Recovery — detach wired backhaul (18)"),
disable = translate("Disable — turn off mesh (19)"),
}
-- ─── Render grouped preset buttons ────────────────────────
local group_order = { "srv", "cli", "node", "recover", "disable" }
for _, grp in ipairs(group_order) do
local gh = m:section(SimpleSection)
gh.template = "easymesh/group_header"
gh.data = group_headers[grp]
for _, p in ipairs(presets) do
if p.group == grp then
local q = m:section(TypedSection, "easymesh")
q.anonymous = true
local btn = q:option(Button, "_preset_" .. p.id, p.label)
btn.inputstyle = "apply"
btn.description = p.desc
function btn.write(self, section)
luci.sys.call("/etc/init.d/easymesh backup >/tmp/easymesh-preset.log 2>&1")
if p.custom == "detach_wired" then
luci.sys.call("/etc/init.d/easymesh detach_wired >>/tmp/easymesh-preset.log 2>&1")
else
local cur = luci.model.uci.cursor()
local needs_wired = false
for _, val in pairs(p.values) do
if val == "AUTO_DETECT" then needs_wired = true; break end
end
local wired_port = ""
if needs_wired then
wired_port = helpers.get_preferred_wired_port()
if wired_port == "" then
luci.sys.call("logger -t easymesh 'Preset needs wired but no port chosen — wired disabled'")
end
end
for k, val in pairs(p.values) do
if val ~= "AUTO_DETECT" then
cur:set("easymesh", "config", k, val)
end
end
cur:set("easymesh", "config", "wired_if", wired_port)
cur:set("easymesh", "config", "preferred_wired_port", wired_port)
if needs_wired and wired_port == "" then
local bm = p.values.band_mode or "single_5g"
bm = tostring(bm):gsub("_wired$", "")
cur:set("easymesh", "config", "band_mode", bm)
end
cur:commit("easymesh")
luci.sys.call("/etc/init.d/easymesh restart >>/tmp/easymesh-preset.log 2>&1")
end
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh", "quick"))
end
end
end
end
-- Confirmation-dialog template
local confirm_section = m:section(SimpleSection)
confirm_section.template = "easymesh/preset_confirm"
return m
@@ -0,0 +1,92 @@
-- luci-app-easymesh — VLANs tab
-- Copyright (C) 2026 Arafat Rahman Zami Mondol <zamimondol@gmail.com>
local m, o
local fs = require "nixio.fs"
local uci = require "luci.model.uci".cursor()
m = Map("easymesh", translate("EasyMesh — VLANs"),
translate("Extend guest / IoT / hotspot networks across the mesh. " ..
"The init script creates bat0.<vid> as an 8021q device for each ID."))
-- ─── Anti-lockout status banner ───────────────────────────────
local has_backup = fs.access("/etc/easymesh/network.bak")
local rollback_pending = fs.access("/tmp/easymesh-vlan-rollback.pending")
local status = m:section(SimpleSection)
status.template = "easymesh/vlan_status"
status.data = {
has_backup = has_backup,
rollback_pending = rollback_pending,
}
-- ─── Mesh VLANs configuration ─────────────────────────────────
local mv = m:section(TypedSection, "easymesh", translate("Mesh VLANs"),
translate("Enter a comma-separated list of VLAN IDs (e.g. 10,20,30). " ..
"If a bridge named br-<vid>, br-lan<vid>, br-vlan<vid>, " ..
"br-guest<vid>, or br-iot<vid> already exists, it is auto-added " ..
"to that bridge. Otherwise, add the bat0.<vid> device manually " ..
"in Network → Interfaces → Devices."))
mv.anonymous = true
o = mv:option(Value, "mesh_vlans",
translate("VLAN IDs (comma-separated)"),
translate("Example: 10,20,30 — Leave empty to disable"))
o.default = ""
o.datatype = "maxlength(200)"
o.rmempty = false
-- ─── Safety & rollback ────────────────────────────────────────
local rollback = m:section(TypedSection, "easymesh",
translate("VLAN Safety & Rollback"),
translate("Applying VLAN changes can lock you out if a port or bridge " ..
"is misconfigured. With Safe Apply enabled, the previous network " ..
"config is snapshotted and auto-restored after 90 seconds unless " ..
"you click 'Confirm VLAN Change' first."))
rollback.anonymous = true
o = rollback:option(Flag, "vlan_safe_apply",
translate("Safe apply (90-second rollback)"))
o.default = "1"
o.rmempty = false
local bkbtn = rollback:option(Button, "_vlan_backup",
translate("Backup network config now"))
bkbtn.inputstyle = "apply"
bkbtn.description = translate("Manually snapshot /etc/config/network to " ..
"/etc/easymesh/network.bak.")
function bkbtn.write(self, section)
luci.sys.call("/usr/bin/easymesh-vlan-backup")
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh", "vlans"))
end
local cfbtn = rollback:option(Button, "_vlan_confirm",
translate("Confirm VLAN Change"))
cfbtn.inputstyle = "apply"
cfbtn.description = translate("Cancel the pending auto-rollback. Click within " ..
"90 seconds of applying to keep your changes.")
function cfbtn.write(self, section)
luci.sys.call("/usr/bin/easymesh-vlan-confirm")
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh", "vlans"))
end
local rsbtn = rollback:option(Button, "_vlan_restore",
translate("Restore last working network config"))
rsbtn.inputstyle = "reset"
rsbtn.description = translate("Emergency restore. Reverts /etc/config/network " ..
"to the last snapshot and reloads the network.")
function rsbtn.write(self, section)
luci.sys.call("/usr/bin/easymesh-vlan-restore")
luci.http.redirect(luci.dispatcher.build_url("admin", "network", "easymesh", "vlans"))
end
-- ─── Auto-arm rollback on commit ──────────────────────────────
function m.on_after_commit(self)
luci.sys.call("/usr/bin/easymesh-vlan-arm 90 >/tmp/easymesh-vlan-arm.log 2>&1 &")
end
-- ─── Educational guide (collapsible) ──────────────────────────
local guide = m:section(SimpleSection)
guide.template = "easymesh/vlan_guide"
return m
@@ -0,0 +1,33 @@
<%
local role = self.data.role or "off"
local role_label = role
if role == "server" then role_label = "Server" end
if role == "off" then role_label = "Off" end
%>
<div class="cbi-section">
<h3><%:AP Mode is not available for this role%></h3>
<div class="alert-message warning"
style="padding: 12px;
background: var(--warning-bg, rgba(255, 193, 7, 0.15));
border-left: 3px solid var(--warning, #ffc107);
color: currentColor;
margin: 12px 0;
border-radius: 0 4px 4px 0;">
<p style="margin: 0;">
<%:Current mesh role:%> <strong><%= role_label %></strong>
</p>
<p style="margin: 8px 0 0 0;">
<%:AP Mode (Dumb AP) only applies to the Client and Node roles. The Server runs DHCP and routing, so it must not become a Dumb AP.%>
</p>
</div>
<p><%:To configure AP Mode:%></p>
<ol>
<li><%:Go to the Settings tab.%></li>
<li><%:Set Mesh role to Client or Node.%></li>
<li><%:Click Save &amp; Apply.%></li>
<li><%:Return to this tab.%></li>
</ol>
</div>
@@ -0,0 +1,22 @@
<div class="cbi-section">
<h3><%:EasyMesh is not enabled%></h3>
<div style="padding: 12px; background: #fff3cd; border-left: 3px solid #ffc107; margin: 12px 0;">
<p style="margin: 0;">
<%:Client WiFi settings only apply when EasyMesh is enabled. It is currently off.%>
</p>
</div>
<p><%:To configure Client WiFi:%></p>
<ol>
<li><%:Go to the Settings tab.%></li>
<li><%:Enable EasyMesh.%></li>
<li><%:Set Mesh role to Server, Client, or Node.%></li>
<li><%:Click Save & Apply.%></li>
<li><%:Return to this tab.%></li>
</ol>
<p style="margin-top: 16px; color: #666; font-size: 90%;">
<%:Tip: Quick Setup presets enable EasyMesh and set the role in one click.%>
</p>
</div>
@@ -0,0 +1,6 @@
<%
local text = self.data or ""
%>
<h4 style="margin: 24px 0 8px 0; padding-top: 12px; border-top: 2px solid #e0e0e0; color: #1976d2;">
<%= text %>
</h4>
@@ -0,0 +1,81 @@
<div class="cbi-section" style="margin-bottom: 0;">
<h3><%:Safety%></h3>
<div class="alert-message warning"
style="padding: 8px 12px;
background: var(--warning-bg, rgba(255, 193, 7, 0.15));
border-left: 3px solid var(--warning, #ffc107);
color: currentColor;
margin-bottom: 8px;
border-radius: 0 4px 4px 0;">
⚠️
<strong><%:Avoid these characters in SSIDs/passwords:%></strong>
<code>"</code> <code>'</code> <code>`</code> <code>$</code> <code>\</code> <code>|</code>
<%:— plus newlines and em/en dashes:%>
(<code>—</code>, <code>–</code>).
<%:They break this shell-based app's sync.%>
</div>
<details>
<summary style="cursor: pointer; font-weight: bold; padding: 4px 0; color: var(--primary, #1976d2);">
<%:Click for full safety rules%>
</summary>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px;">
<div style="border-left: 3px solid var(--success, #4caf50); padding-left: 12px;">
<strong style="color: var(--success, #4caf50);"><%:✅ Do%></strong>
<ul style="margin-top: 6px; padding-left: 18px;">
<li><%:Use only letters, numbers, hyphens, and underscores in SSIDs.%></li>
<li><%:Keep SSIDs 32 characters or fewer.%></li>
<li><%:Use ASCII characters if SSID sync is enabled.%></li>
<li><%:Back up config before every change.%></li>
<li><%:Test one client node at a time when changing settings.%></li>
</ul>
</div>
<div style="border-left: 3px solid var(--error, #f44336); padding-left: 12px;">
<strong style="color: var(--error, #f44336);"><%:❌ Don't%></strong>
<ul style="margin-top: 6px; padding-left: 18px;">
<li><%:Never use quotes, backticks, dollar signs, or backslashes in SSIDs/passwords.%></li>
<li><%:Never change SSID while client nodes are offline.%></li>
<li><%:Never edit a synced node's SSID manually.%></li>
<li><%:Never run luci-app-easymesh alongside meshd (OMM).%></li>
<li><%:Never set sync interval below 10 seconds.%></li>
</ul>
</div>
</div>
<details style="margin-top: 12px;">
<summary style="cursor: pointer; font-weight: bold;">
<%:Field-by-field safety reference%>
</summary>
<table class="table" style="margin-top: 8px;">
<tr><th><%:Field%></th><th><%:Safe%></th><th><%:Unsafe%></th></tr>
<tr>
<td><%:Mesh ID%></td>
<td><code>A-Z a-z 0-9 _ -</code></td>
<td><code>" ' ` $ \ |</code></td>
</tr>
<tr>
<td><%:Password%></td>
<td><code>A-Z a-z 0-9 ! @ # % ^ &amp; * _ - + =</code></td>
<td><code>" ' ` $ \ |</code> + newline</td>
</tr>
<tr>
<td><%:SSID%></td>
<td><code>A-Z a-z 0-9 _ - space</code></td>
<td><code>" ' ` $ \ |</code></td>
</tr>
<tr>
<td><%:VLAN IDs%></td>
<td><code>0-9 ,</code></td>
<td><%:everything else%></td>
</tr>
</table>
</details>
<div style="margin-top: 12px; font-size: 90%; opacity: 0.75;">
<%:Why these limits? A compiled daemon like meshd (6 MB) never passes user input through a shell. Lua and shell scripts necessarily do — the sync protocol uses nc, openssl, and uci as external commands.%>
</div>
</details>
</div>
@@ -0,0 +1,458 @@
<details style="margin-top: 16px; border: 1px solid #ccc; border-radius: 4px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2; font-size: 1.05em; padding: 8px;">
📘 <%:VLAN Guide — click to expand%>
</summary>
<div style="padding: 12px;">
<p><%:VLAN (Virtual Local Area Network) divides one physical LAN into multiple logical networks. Devices on the same switch can be isolated into separate groups — as if they were on entirely different networks.%></p>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">🛠️ <%:Why use VLAN?%></summary>
<ul style="margin-top: 6px;">
<li><strong><%:Security:%></strong> <%:Keep sensitive departments (Finance, HR) separate from the general network.%></li>
<li><strong><%:Traffic management:%></strong> <%:Reduce broadcast traffic and improve speed.%></li>
<li><strong><%:Easy management:%></strong> <%:Move a device without reconfiguring its network.%></li>
<li><strong><%:Cost efficiency:%></strong> <%:No extra switches or routers needed for separate networks.%></li>
</ul>
</details>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">⚙️ <%:How does it work?%></summary>
<p style="margin-top: 6px;"><%:A normal switch broadcasts to every port. With VLANs configured, the switch groups ports by VLAN ID. A device in VLAN 10 can only talk to other devices in VLAN 10 — even on the same physical switch.%></p>
</details>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">🔍 <%:First check: DSA or swconfig?%></summary>
<p style="margin-top: 6px;"><%:OpenWrt has two switch architectures. The VLAN steps below only apply to DSA.%></p>
<ul style="margin-top: 6px;">
<li><%:If your menu shows%> <strong>Network → Switch</strong> — <%:your router uses the older swconfig.%></li>
<li><%:If your menu shows%> <strong>Network → Interfaces → Devices</strong> <%:with "Bridge VLAN filtering" — your router uses DSA.%></li>
</ul>
<p><%:OpenWrt 21.02 and later (e.g. 23.05) typically use DSA and replace swconfig, but some devices still ship with swconfig. Check before proceeding.%></p>
<p><%:Note: some single-port travel routers do not support VLAN filtering at all.%></p>
</details>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">📂 <%:Types of VLAN (detailed)%></summary>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;">1. <%:Port-based VLAN%></summary>
<p style="margin-top: 4px;"><%:The simplest and most common type. Physical switch ports are grouped into VLAN IDs.%></p>
<p><strong><%:How it works:%></strong> <%:Suppose ports 1–5 belong to VLAN 10 (IT) and ports 6–10 belong to VLAN 20 (HR). A computer on port 1 can only reach devices on ports 1–5. It cannot reach anything on ports 6–10.%></p>
<p><strong><%:Benefit:%></strong> <%:Simple and predictable. Every port has a fixed VLAN.%></p>
<p><strong><%:Limitation:%></strong> <%:If a user moves their computer to a different port, the VLAN changes. This is the main drawback.%></p>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;">2. <%:Tagged VLAN (802.1Q)%></summary>
<p style="margin-top: 4px;"><%:Used when multiple VLANs must share one physical cable — typically between two switches, or between a switch and a router. That shared link is called a "Trunk port".%></p>
<p><strong><%:How it works:%></strong> <%:Per IEEE 802.1Q, every packet carries a small tag in its header showing its VLAN ID (e.g. 10 or 20). The receiving switch reads the tag and forwards the packet to the right VLAN.%></p>
<p><strong><%:Benefit:%></strong> <%:One cable carries traffic for many VLANs, saving switch ports and cabling.%></p>
<p><strong><%:Limitation:%></strong> <%:Both ends must support 802.1Q tagging. Unmanaged switches typically strip tags and break trunk links.%></p>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;">3. <%:MAC-based VLAN%></summary>
<p style="margin-top: 4px;"><%:The VLAN is assigned by the device's MAC address instead of by port.%></p>
<p><strong><%:Benefit:%></strong> <%:Whatever port the laptop plugs into, the switch recognizes its MAC address and puts it on the correct VLAN automatically.%></p>
<p><strong><%:Limitation:%></strong> <%:Requires managing a MAC-to-VLAN table. Impractical for networks with many devices or BYOD users.%></p>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;">4. <%:Protocol-based VLAN%></summary>
<p style="margin-top: 4px;"><%:The VLAN is assigned based on the network protocol of the traffic — IPv4, IPv6, IPX, and so on. Each protocol travels over its own VLAN.%></p>
<p><strong><%:Use case:%></strong> <%:Legacy enterprise networks separating IP traffic from non-IP protocols.%></p>
<p><strong><%:Limitation:%></strong> <%:Rarely used today. Modern networks route by subnet instead.%></p>
</details>
</details>
<!-- CUT #1 -->
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">💻 <%:OpenWrt Configuration Steps%></summary>
<p style="margin-top: 6px;"><%:Example: create VLAN 20 for a Guest / IoT network.%></p>
<div style="padding: 8px; background: #fff3cd; border-left: 4px solid #ffc107; margin: 8px 0;">
<strong>⚠️ <%:Before you start:%></strong>
<%:Connect via a LAN port you will NOT modify. Never apply VLAN changes over Wi-Fi or over the port you are changing — you will lock yourself out.%>
</div>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 1 — Enable VLAN filtering on the bridge%></summary>
<ol style="margin-top: 4px;">
<li><%:Go to%> <strong>Network → Interfaces → Devices</strong>.</li>
<li><%:Find%> <code>br-lan</code> <%:and click%> <strong>Configure</strong>.</li>
<li><%:Open the%> <strong>Bridge VLAN filtering</strong> <%:tab and tick%> <strong>Enable VLAN filtering</strong>.</li>
</ol>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 2 — Assign ports%></summary>
<ol style="margin-top: 4px;">
<li><%:Click%> <strong>Add</strong>.</li>
<li><strong>VLAN ID:</strong> <%:enter the ID (e.g.%> <code>20</code>).</li>
<li><strong>Local:</strong> <%:tick — attaches the router's CPU to this VLAN.%></li>
<li><strong>Ports Configuration:</strong>
<ul>
<li><strong><%:Untagged (Access port):%></strong> <%:select%> <code>egress untagged</code> <%:and tick%> <strong>PVID</strong>.</li>
<li><strong><%:Tagged (Trunk):%></strong> <%:select%> <code>egress tagged</code>.</li>
</ul>
</li>
</ol>
<p style="margin-top: 6px;"><strong>⚠️ <%:Default VLAN 1:%></strong> <%:When you enable filtering, VLAN 1 covers every port. To fully isolate a port into VLAN 20, you must set that port's VLAN 1 to%> <code>off</code>. <%:Otherwise the port still belongs to VLAN 1.%></p>
<p><strong>ℹ️ <%:Optional — CPU on VLAN 1:%></strong> <%:If you do not want the router reachable on VLAN 1, set the CPU port's VLAN 1 to%> <code>off</code> <%:as well.%></p>
<p><%:Click%> <strong>Save</strong>.</p>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 3 — Create interface and IP%></summary>
<ol style="margin-top: 4px;">
<li><%:Go to%> <strong>Interfaces → Add new interface</strong>.</li>
<li><strong>Name:</strong> <%:e.g.%> <code>GUEST</code>.</li>
<li><strong>Protocol:</strong> <code>Static address</code>.</li>
<li><strong>Device:</strong> <%:select the VLAN device. Usually%> <code>br-lan.20</code>, <%:but it will match your bridge name (e.g.%> <code>br-guest.20</code>).</li>
<li><%:Click%> <strong>Create interface</strong>.</li>
<li><%:Set an IP block (e.g.%> <code>192.168.20.1 / 255.255.255.0</code>).</li>
<li><%:Open the%> <strong>DHCP Server</strong> <%:tab and click%> <strong>Setup DHCP Server</strong>.</li>
<li><%:Verify the DHCP range, lease time, and that the interface is not ignored.%></li>
</ol>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 3.5 — Test the VLAN before continuing%></summary>
<ol style="margin-top: 4px;">
<li><%:Connect a laptop to an untagged VLAN 20 port.%></li>
<li><%:Check its IP — should be in the%> <code>192.168.20.x</code> <%:range.%></li>
<li><%:If it gets%> <code>192.168.1.x</code> <%:instead, your untagged/PVID settings need fixing.%></li>
</ol>
<p><%:Only after this test passes, proceed to firewall zone configuration.%></p>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 4 — Firewall zone (with DHCP/DNS fix)%></summary>
<ol style="margin-top: 4px;">
<li><%:Inside the interface config, open%> <strong>Firewall Settings</strong>.</li>
<li><%:Create a zone named%> <code>guest_zone</code>.</li>
<li><%:Set%> <strong>Input</strong> <%:to%> <code>REJECT</code>, <strong>Output</strong> <%:to%> <code>ACCEPT</code>, <strong>Forward</strong> <%:to%> <code>REJECT</code>.</li>
<li><%:In%> <strong>Allow forward to destination zones</strong>, <%:tick only%> <code>wan</code>. <strong><%:Never tick%></strong> <code>lan</code>.</li>
</ol>
<p style="margin-top: 6px;"><strong>⚠️ <%:If Input is REJECT, guests get no DHCP/DNS.%></strong> <%:Add two Traffic Rules with these exact fields:%></p>
<ul>
<li><strong><%:Rule 1 (DHCP):%></strong> Source zone <code>guest_zone</code>, Destination zone <code>Device (input)</code>, Protocol <code>UDP</code>, Destination port <code>67-68</code>, Action <code>accept</code>.</li>
<li><strong><%:Rule 2 (DNS):%></strong> Source zone <code>guest_zone</code>, Destination zone <code>Device (input)</code>, Protocol <code>TCP+UDP</code>, Destination port <code>53</code>, Action <code>accept</code>.</li>
</ul>
<p><%:Without%> <code>Device (input)</code> <%:as the destination zone, the rule will not match traffic addressed to the router itself.%></p>
<p><%:Alternatively, set Input to%> <code>ACCEPT</code> <%:— simpler but less secure.%></p>
<p><%:Ensure the WAN zone has%> <strong>Masquerading</strong> <%:and%> <strong>MSS clamping</strong> <%:enabled.%></p>
<p><%:Click%> <strong>Save &amp; Apply</strong>.</p>
</details>
<div style="margin-top: 12px; padding: 10px; background: #ffebee; border-left: 4px solid #f44336;">
<strong>⚠️ <%:Lockout warning:%></strong>
<%:Never assign the port you are connected through to a Tagged or different VLAN. You will lock yourself out.%>
</div>
</details>
<!-- CUT #2 -->
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">📶 <%:Tagging a Wi-Fi SSID to a VLAN%></summary>
<div style="padding: 8px; background: #fff3cd; border-left: 4px solid #ffc107; margin: 8px 0;">
<strong>⚠️ <%:Do this over a wired LAN connection%></strong> <%:— not the Wi-Fi you are modifying. Otherwise you disconnect yourself mid-configuration.%>
</div>
<p style="margin-top: 6px;"><%:You do not tag the SSID directly. You attach the wireless interface to the VLAN's network interface.%></p>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 1 — Create a new Wi-Fi interface%></summary>
<ol style="margin-top: 4px;">
<li><%:Go to%> <strong>Network → Wireless</strong>.</li>
<li><%:Click%> <strong>Add</strong> <%:next to the radio.%></li>
<li><strong>Mode:</strong> <code>Access Point</code>.</li>
<li><strong>ESSID:</strong> <%:e.g.%> <code>My-Guest-WiFi</code>.</li>
<li><%:Set a password (WPA2-PSK or WPA3-SAE).%></li>
</ol>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 2 — Attach Wi-Fi to the VLAN interface%></summary>
<ol style="margin-top: 4px;">
<li><%:Before saving, open%> <strong>Interface Configuration → Interface Settings</strong>.</li>
<li><%:Untick%> <code>lan</code>.</li>
<li><%:Tick your VLAN interface (e.g.%> <code>GUEST</code>).</li>
<li><%:Click%> <strong>Save</strong>.</li>
</ol>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 3 — Apply and test%></summary>
<ol style="margin-top: 4px;">
<li><%:Click%> <strong>Save &amp; Apply</strong>.</li>
<li><%:Connect a phone or laptop to the new Wi-Fi.%></li>
<li><%:Check its IP. If it comes from the VLAN block (e.g.%> <code>192.168.20.X</code>), <%:tagging worked.%></li>
</ol>
</details>
</details>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">🛡️ <%:Client Isolation (Guest Wi-Fi security)%></summary>
<p style="margin-top: 6px;"><%:Prevents devices on the same Guest Wi-Fi from reaching each other. Blocks malware spread between guests.%></p>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 1 — Wi-Fi: isolate clients%></summary>
<ol style="margin-top: 4px;">
<li><%:Go to%> <strong>Network → Wireless</strong>.</li>
<li><%:Edit the Guest SSID.%></li>
<li><%:Open%> <strong>Advanced Settings</strong>.</li>
<li><%:Tick%> <strong>Isolate clients</strong>.</li>
<li><%:Click%> <strong>Save</strong>.</li>
</ol>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 2 — Isolate Ethernet ports (if any)%></summary>
<p style="margin-top: 4px;"><%:Isolation for Wi-Fi clients is handled by the Wi-Fi setting above. Bridge port isolation applies only to Ethernet ports inside the same bridge.%></p>
<p><%:If you have Guest Ethernet ports, isolate them in the Guest bridge (not%> <code>br-lan</code>):</p>
<ol>
<li><%:Go to%> <strong>Network → Interfaces → Devices</strong>.</li>
<li><%:Configure the guest bridge.%></li>
<li><%:Open the%> <strong>Bridge ports</strong> <%:tab and click the gear next to each guest Ethernet port.%></li>
<li><%:Tick%> <strong>Isolate</strong>.</li>
<li><%:Click%> <strong>Save</strong>.</li>
</ol>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Step 3 — Apply and test%></summary>
<ol style="margin-top: 4px;">
<li><%:Click%> <strong>Save &amp; Apply</strong>.</li>
<li><%:Connect two devices to Guest Wi-Fi.%></li>
<li><%:From the second device, try%> <code>ping</code> <%:or%> <code>arp -a</code> <%:to the first.%></li>
<li><%:A failed ping usually means isolation works. Some devices block ping by their own firewall — check with a real connection instead.%></li>
</ol>
</details>
</details>
<!-- CUT #3 -->
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">📖 <%:Term definitions for new users%></summary>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;">VLAN ID</summary>
<p style="margin-top: 4px;"><%:A number from 1 to 4094 identifying which virtual network a device belongs to.%></p>
<p><strong><%:Reserved values:%></strong> <%:VLAN 0 and VLAN 4095 are reserved. Avoid VLAN 1 for user or guest networks.%></p>
<p><strong><%:Analogy:%></strong> <%:Room numbers in a school — Class 5, Class 6, Class 7.%></p>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Access Port%></summary>
<p style="margin-top: 4px;"><%:A switch port dedicated to a single VLAN. Anything plugged in joins that VLAN only.%></p>
<p><strong><%:Analogy:%></strong> <%:One classroom's door — you enter and you are in that room.%></p>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Trunk Port%></summary>
<p style="margin-top: 4px;"><%:A port carrying multiple VLANs over one link. Used for switch-to-switch or switch-to-router connections.%></p>
<p><strong><%:Analogy:%></strong> <%:A highway shared by traffic from several cities.%></p>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Tagged VLAN%></summary>
<p style="margin-top: 4px;"><%:When multiple VLANs share a trunk, each packet carries a digital tag identifying its VLAN.%></p>
<p><strong><%:Analogy:%></strong> <%:Courier parcels labelled Dhaka / Chittagong / Sylhet.%></p>
</details>
<p style="margin-top: 12px;"><strong><%:Comparison table%></strong></p>
<table style="width: 100%; border-collapse: collapse; margin-top: 6px;">
<tr style="background: #e3f2fd;">
<th style="text-align: left; padding: 6px; border: 1px solid #ccc;"><%:Term%></th>
<th style="text-align: left; padding: 6px; border: 1px solid #ccc;"><%:Analogy%></th>
<th style="text-align: left; padding: 6px; border: 1px solid #ccc;"><%:Main function%></th>
</tr>
<tr>
<td style="padding: 6px; border: 1px solid #ccc;">VLAN ID</td>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Classroom number%></td>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Gives the network a separate identity%></td>
</tr>
<tr>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Access Port%></td>
<td style="padding: 6px; border: 1px solid #ccc;"><%:One classroom door%></td>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Connects a single VLAN device%></td>
</tr>
<tr>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Trunk Port%></td>
<td style="padding: 6px; border: 1px solid #ccc;"><%:School main gate / highway%></td>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Carries multiple VLANs over one cable%></td>
</tr>
<tr>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Tagged VLAN%></td>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Parcel address sticker%></td>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Marks which VLAN a packet belongs to%></td>
</tr>
</table>
</details>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">🪵 <%:Inter-VLAN Routing (Router-on-a-Stick)%></summary>
<p style="margin-top: 6px;"><%:Inter-VLAN routing lets devices on different VLANs (e.g. VLAN 10 and VLAN 20) reach each other, with the router acting as the gateway between them.%></p>
<div style="padding: 8px; background: #e3f2fd; border-left: 4px solid #2196f3; margin: 8px 0;">
<strong>ℹ️ <%:OpenWrt vs Cisco:%></strong>
<%:On OpenWrt, inter-VLAN routing happens automatically through firewall zone forwarding. You do NOT create sub-interfaces like%> <code>gigabitethernet 0/1.10</code> <%:as you would on Cisco. The Cisco example below is educational only.%>
</div>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Why the name "Router-on-a-Stick"?%></summary>
<p style="margin-top: 4px;"><%:Normally, connecting two separate networks would need two router ports and two cables. This approach uses only one physical cable — a "stick" — between the router and the switch. Inside the router, that one cable is split into multiple virtual sub-interfaces, one per VLAN. Visually, it looks like a router sitting on the end of a single stick.%></p>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:How it works — step by step%></summary>
<ol style="margin-top: 4px;">
<li><%:A PC on VLAN 10 wants to send data to a PC on VLAN 20.%></li>
<li><%:The switch sees the destination is in another VLAN. It tags the packet with VLAN 10 and sends it over the trunk port.%></li>
<li><%:The router receives the packet on its VLAN 10 sub-interface.%></li>
<li><%:The router checks its routing table and decides to send it to VLAN 20.%></li>
<li><%:The router strips the old tag and applies a new VLAN 20 tag. It sends the packet back over the same cable.%></li>
<li><%:The switch reads the new tag and delivers the packet to the VLAN 20 PC.%></li>
</ol>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Cisco configuration example%></summary>
<p style="margin-top: 4px;"><strong><%:Switch side — make the router-facing port a trunk:%></strong></p>
<pre style="background: #263238; color: #e0e0e0; padding: 8px; border-radius: 4px;">Switch(config)# interface gigabitethernet 0/1
Switch(config-if)# switchport trunk encapsulation dot1q
Switch(config-if)# switchport mode trunk
Switch(config-if)# exit</pre>
<p><strong><%:Router side — create sub-interfaces:%></strong></p>
<pre style="background: #263238; color: #e0e0e0; padding: 8px; border-radius: 4px;">Router(config)# interface gigabitethernet 0/1
Router(config-if)# no shutdown
Router(config-if)# exit
Router(config)# interface gigabitethernet 0/1.10
Router(config-subif)# encapsulation dot1Q 10
Router(config-subif)# ip address 192.168.10.1 255.255.255.0
Router(config-subif)# exit
Router(config)# interface gigabitethernet 0/1.20
Router(config-subif)# encapsulation dot1Q 20
Router(config-subif)# ip address 192.168.20.1 255.255.255.0
Router(config-subif)# exit</pre>
<p><strong><%:End devices:%></strong> <%:set their default gateway to the matching sub-interface IP (e.g.%> <code>192.168.10.1</code> <%:for VLAN 10,%> <code>192.168.20.1</code> <%:for VLAN 20).%></p>
<p><%:On older switches you may need%> <code>switchport trunk encapsulation dot1q</code> <%:before%> <code>switchport mode trunk</code>. <%:All referenced VLANs must already exist on the switch.%></p>
</details>
<details style="margin-top: 8px; margin-left: 12px;">
<summary style="cursor: pointer; color: #1976d2;"><%:Pros and cons%></summary>
<table style="width: 100%; border-collapse: collapse; margin-top: 4px;">
<tr style="background: #e8f5e9;">
<th style="text-align: left; padding: 6px; border: 1px solid #ccc;"><%:Pros%></th>
<th style="text-align: left; padding: 6px; border: 1px solid #ccc;"><%:Cons%></th>
</tr>
<tr>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Lower cost — one router port, one cable.%></td>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Bandwidth bottleneck — all VLANs share the same cable.%></td>
</tr>
<tr>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Easy setup for small and medium networks.%></td>
<td style="padding: 6px; border: 1px solid #ccc;"><%:Single point of failure — a broken cable or port stops all inter-VLAN traffic.%></td>
</tr>
</table>
</details>
</details>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">🔥 <%:Zone Forwarding — LAN to Guest (one-way)%></summary>
<p style="margin-top: 6px;"><%:Allow the LAN to reach the Guest network, but not vice versa.%></p>
<ol style="margin-top: 6px;">
<li><%:Go to%> <strong>Network → Firewall → Zones</strong>.</li>
<li><%:Click%> <strong>Edit</strong> <%:on the%> <code>lan</code> <%:zone.%></li>
<li><%:Scroll to%> <strong>Inter-Zone Forwarding</strong>.</li>
<li><%:In%> <strong>Allow forward to destination zones</strong>, <%:tick%> <code>guest_zone</code>.</li>
<li><%:Click%> <strong>Save &amp; Apply</strong>.</li>
</ol>
<p style="margin-top: 6px;"><strong>⚠️ <%:Never enable Guest → LAN.%></strong> <%:Leaving it disabled keeps the main network protected.%></p>
</details>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">🎯 <%:Allow only one Guest device (Traffic Rules)%></summary>
<p style="margin-top: 6px;"><%:Instead of opening the whole Guest zone, allow only a specific device such as a shared printer.%></p>
<ol style="margin-top: 6px;">
<li><%:Go to%> <strong>Network → Firewall → Traffic Rules</strong>.</li>
<li><%:Click%> <strong>Add new forward rule</strong>.</li>
<li><strong>Name:</strong> <%:e.g.%> <code>Allow-LAN-to-Guest-Printer</code>.</li>
<li><strong>Protocol:</strong> <code>TCP</code>.</li>
<li><strong>Source zone:</strong> <code>lan</code>.</li>
<li><strong>Destination zone:</strong> <code>guest_zone</code>.</li>
<li><strong>Destination IP:</strong> <%:the printer's IP (e.g.%> <code>192.168.20.50</code>).</li>
<li><strong>Destination port:</strong> <code>9100</code>.</li>
<li><strong>Action:</strong> <code>accept</code>.</li>
<li><%:Click%> <strong>Save &amp; Apply</strong>.</li>
</ol>
<div style="padding: 10px; background: #fff3cd; border-left: 4px solid #ffc107; margin-top: 10px;">
<strong>⚠️ <%:Check for conflicts:%></strong>
<%:If you already enabled zone-level%> <code>lan → guest_zone</code>, <%:this traffic rule is redundant. Untick the zone-level forward first.%>
</div>
</details>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">📌 <%:Static Lease (IP reservation)%></summary>
<p style="margin-top: 6px;"><%:If a device's IP changes, firewall rules targeting it stop working. Reserve its IP so it stays fixed.%></p>
<ol style="margin-top: 6px;">
<li><%:Go to%> <strong>Network → DHCP and DNS → Static Leases</strong>.</li>
<li><%:Click%> <strong>Add</strong>.</li>
<li><strong>Hostname:</strong> <%:e.g.%> <code>Guest-Printer</code>.</li>
<li><strong>MAC-Address:</strong> <%:pick from the dropdown.%></li>
<li><strong>IPv4-Address:</strong> <%:the fixed IP (must be inside the VLAN's subnet and outside the dynamic DHCP pool, e.g.%> <code>192.168.20.50</code>).</li>
<li><strong>Lease time:</strong> <%:leave empty or set%> <code>infinite</code>.</li>
<li><%:Click%> <strong>Save &amp; Apply</strong>.</li>
</ol>
<p style="margin-top: 6px;"><strong><%:Pro tip:%></strong> <%:Restart the device (or reconnect it) so it requests a fresh lease and picks up the reserved IP.%></p>
</details>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; color: #1976d2;">🛟 <%:Anti-lockout & rollback%></summary>
<p style="margin-top: 6px;"><%:If a VLAN change locks you out, use these tools.%></p>
<ol style="margin-top: 6px;">
<li><%:LuCI shows a confirmation countdown after applying network changes. If you lose access, do nothing — after about 90 seconds it reverts automatically.%></li>
<li><%:This app has its own safety toggle.%> <strong><%:Safe apply%></strong> <%:above snapshots the network config before every apply, so you can restore with one click.%></li>
<li><%:If you get locked out mid-config, connect via serial console or another VLAN and run the restore command.%></li>
</ol>
<p style="margin-top: 6px;"><strong><%:Restore command:%></strong></p>
<pre style="background: #263238; color: #e0e0e0; padding: 8px; border-radius: 4px;">cp /etc/easymesh/network.bak /etc/config/network
/etc/init.d/network reload</pre>
<p style="margin-top: 6px;"><%:Note: The path%> <code>/etc/easymesh/network.bak</code> <%:is specific to this app. On a plain OpenWrt system with a manual backup, use your own path (e.g.%> <code>/etc/config/network.bak</code>).</p>
<p><%:If reload does not restore access, reboot.%></p>
</details>
</div>
</details>
@@ -0,0 +1,36 @@
<%
local data = self.data or {}
local has_backup = data.has_backup
local rollback_pending = data.rollback_pending
local backup_ts = ""
if has_backup then
local f = io.open("/etc/easymesh/network.bak.timestamp", "r")
if f then
local t = tonumber(f:read("*l") or "0")
f:close()
if t and t > 0 then
backup_ts = os.date("%Y-%m-%d %H:%M:%S", t)
end
end
end
%>
<% if rollback_pending then %>
<div style="padding: 12px; background: rgba(255, 193, 7, 0.15); border-left: 4px solid #ffc107; color: currentColor; margin-bottom: 12px;">
<strong>⚠️ <%:Auto-rollback is armed%></strong><br>
<%:A VLAN change was just applied. If you do not click%>
<strong><%:Confirm VLAN Change%></strong>
<%:within 90 seconds, the previous network config will be restored.%>
</div>
<% end %>
<% if has_backup then %>
<div style="padding: 8px 12px; background: rgba(76, 175, 80, 0.15); border-left: 4px solid #4caf50; color: currentColor; margin-bottom: 12px; font-size: 90%;">
<%:Network backup available:%> <code><%= backup_ts %></code>
</div>
<% else %>
<div style="padding: 8px 12px; background: rgba(158, 158, 158, 0.15); border-left: 4px solid #9e9e9e; color: currentColor; margin-bottom: 12px; font-size: 90%;">
<%:No network backup yet. A snapshot is taken automatically on the next Save & Apply.%>
</div>
<% end %>
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# easymesh-vlan-arm — arm the auto-rollback watchdog after applying VLAN changes
DELAY="${1:-90}"
# Respect the UI toggle
if [ "$(uci -q get easymesh.config.vlan_safe_apply)" != "1" ]; then
logger -t easymesh-vlan "safe apply disabled — not arming"
exit 0
fi
# Snapshot the working config
/usr/bin/easymesh-vlan-backup || exit 1
# Kill any previous watchdog
if [ -f /tmp/easymesh-vlan-rollback.pid ]; then
kill "$(cat /tmp/easymesh-vlan-rollback.pid)" 2>/dev/null
fi
touch /tmp/easymesh-vlan-rollback.pending
(
sleep "$DELAY"
if [ -f /tmp/easymesh-vlan-rollback.pending ]; then
logger -t easymesh-vlan "auto-rollback triggered after ${DELAY}s"
/usr/bin/easymesh-vlan-restore
rm -f /tmp/easymesh-vlan-rollback.pending
else
logger -t easymesh-vlan "auto-rollback cancelled"
fi
) &
echo $! > /tmp/easymesh-vlan-rollback.pid
logger -t easymesh-vlan "armed auto-rollback (${DELAY}s)"
exit 0
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# easymesh-vlan-backup — snapshot /etc/config/network before VLAN changes
BKDIR=/etc/easymesh
BKFILE="$BKDIR/network.bak"
mkdir -p "$BKDIR"
if [ ! -f /etc/config/network ]; then
logger -t easymesh-vlan "no /etc/config/network found, skipping backup"
exit 1
fi
cp /etc/config/network "$BKFILE"
date +%s > "$BKFILE.timestamp"
logger -t easymesh-vlan "backed up network config to $BKFILE"
exit 0
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# easymesh-vlan-confirm — cancel the pending auto-rollback
if [ -f /tmp/easymesh-vlan-rollback.pid ]; then
kill "$(cat /tmp/easymesh-vlan-rollback.pid)" 2>/dev/null
fi
rm -f /tmp/easymesh-vlan-rollback.pending /tmp/easymesh-vlan-rollback.pid
logger -t easymesh-vlan "auto-rollback cancelled by user"
exit 0
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# easymesh-vlan-restore — restore network config from the last backup
BKFILE=/etc/easymesh/network.bak
if [ ! -f "$BKFILE" ]; then
logger -t easymesh-vlan "no backup at $BKFILE — nothing to restore"
exit 1
fi
cp "$BKFILE" /etc/config/network
logger -t easymesh-vlan "restored network from $BKFILE, reloading"
# Reload network in the background so the current shell/SSH isn't killed
( /etc/init.d/network reload ) &
exit 0
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for fastnet
LUCI_DEPENDS:=+fastnet +luci-lib-linkeaseauth +linkease-app-entry
LUCI_PKGARCH:=all
PKG_VERSION:=0.7.8-r5
PKG_RELEASE:=5
PKG_VERSION:=0.8.0-r5
PKG_RELEASE:=6
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
@@ -181,7 +181,7 @@ return view.extend({
o.placeholder = '30';
o.rmempty = false;
o.retain = true;
o.depends('offloading_type', '2');
o.depends('offloading_type', hasMediatekHNAT ? '3' : '2');
}
}
@@ -13,6 +13,10 @@ if [ -f /etc/openwrt_release ]; then
. /etc/openwrt_release
case "$DISTRIB_TARGET" in
airoha/*) [ ! -e /sys/kernel/debug/ppe/bind_rate ] || ppe_bind_rate_available=true ;;
mediatek/mt798x|mediatek/mt79xx)
[ "$hnat_available" != true ] || [ ! -e /sys/kernel/debug/hnat/hnat_setting ] ||
ppe_bind_rate_available=true
;;
esac
fi
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-open-box
PKG_VERSION:=0.1.413
PKG_RELEASE:=68
PKG_VERSION:=0.1.414
PKG_RELEASE:=69
PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.11.3
PKG_RELEASE:=369
PKG_RELEASE:=370
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -124,7 +124,9 @@ end
o = s:option(ListValue, "domain_resolver", translate("Domain DNS Resolve"))
o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "<br>" ..
translate("Supports only Xray or Sing-box node types.") .. "<br>" .. string.format('<font color="red">%s</font>',
translate("Note: For node-specific DNS only. Keep Auto to avoid extra overhead."))
translate("Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."))
o.default = ""
o:value("disable", translate("Disable"))
o:value("", translate("Auto"))
o:value("tcp", "TCP")
o:value("udp", "UDP")
+3
View File
@@ -1763,6 +1763,9 @@ msgstr "如果节点地址是域名,则将使用此 DNS 进行解析。"
msgid "Note: For node-specific DNS only. Keep Auto to avoid extra overhead."
msgstr "注意:仅用于节点专用 DNS,通常请保持自动,以免增加开销。"
msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."
msgstr "注意:自动尝试提取 Clash 订阅中的节点 DNS,禁用则不提取。"
msgid "Enable Happy Eyeballs"
msgstr "启用 Happy Eyeballs"
@@ -624,7 +624,83 @@ local function encode_node(node)
end
end
function parseClashNode(raw, remark)
local function parse_dns_endpoint(value)
if type(value) ~= "string" then return nil end
value = api.trim(value)
-- Clash fragments and URL queries cannot be preserved by both node DNS backends.
if value == "" or value:find("[%s#?@,]") then return nil end
local url = value:find("://", 1, true) and value or "udp://" .. value
local ok, endpoint = pcall(api.parseURL, url)
if not ok or not endpoint then return nil end
local protocol = endpoint.protocol
if protocol ~= "udp" and protocol ~= "tcp" and protocol ~= "https" then return nil end
local host = endpoint.hostname
if not host or not (api.is_ip(host) or api.datatypes.hostname(host)) then return nil end
local bracketed = endpoint.host:sub(1, 1) == "["
local ipv6 = api.datatypes.ip6addr(host)
if bracketed and not ipv6 then return nil end
if protocol == "https" and ipv6 and not bracketed then return nil end
local port = endpoint.port or (protocol == "https" and 443 or 53)
if port < 1 or port > 65535 then return nil end
if protocol ~= "https" and endpoint.pathname then return nil end
return { protocol = protocol, host = host:lower():gsub("%.$", ""), port = port, url = url }
end
local function is_loopback_dns(host)
return host == "localhost" or (api.datatypes.ip4addr(host) and host:match("^127%.")) or host:match("^[0:]+:0*1$")
end
local function is_unspecified_dns(host)
return host == "0.0.0.0" or host:match("^[0:]+$")
end
local function extract_clash_dns(data)
local dns = data.dns
if type(dns) ~= "table" or not dns["proxy-server-nameserver"] then return nil end
-- A subscription-wide resolver cannot represent per-domain node DNS policies.
local node_policy = dns["proxy-server-nameserver-policy"]
if type(node_policy) == "table" and next(node_policy) then return nil end
local listener = parse_dns_endpoint(dns.listen)
local resolving = {}
local policy = dns["nameserver-policy"]
local has_policy = type(policy) == "table" and next(policy)
local function pick(candidates, depth)
if depth > 4 then return nil end
if type(candidates) == "string" then candidates = { candidates } end
if type(candidates) ~= "table" then return nil end
for _, value in ipairs(candidates) do
local endpoint = parse_dns_endpoint(value)
if endpoint and not is_unspecified_dns(endpoint.host) then
if is_loopback_dns(endpoint.host) then
local key = endpoint.protocol .. "://" .. endpoint.host .. ":" .. endpoint.port
local listen_matches = listener and listener.protocol ~= "https" and listener.port == endpoint.port and (
listener.host == endpoint.host or
(listener.host == "0.0.0.0" and not endpoint.host:find(":", 1, true)) or
(is_unspecified_dns(listener.host) and listener.host ~= "0.0.0.0" and endpoint.host:find(":", 1, true)) or
(endpoint.host == "localhost" and is_loopback_dns(listener.host)))
if endpoint.protocol ~= "https" and dns.enable == true and listen_matches and not resolving[key] and not has_policy then
resolving[key] = true
local resolver = pick(dns.nameserver, depth + 1)
resolving[key] = nil
if resolver then return resolver end
end
elseif endpoint.protocol == "https" then
return { domain_resolver = "https", domain_resolver_dns_https = endpoint.url }
elseif api.datatypes.ip4addr(endpoint.host) then
return { domain_resolver = endpoint.protocol, domain_resolver_dns = endpoint.host .. ":" .. endpoint.port }
end
end
end
end
local resolver = pick(dns["proxy-server-nameserver"], 0)
if resolver then
local address = resolver.domain_resolver_dns_https or resolver.domain_resolver .. "://" .. resolver.domain_resolver_dns
api.log("订阅转换 → 已提取节点域名 DNS:" .. address)
end
return resolver
end
function parseClashNode(raw, remark, auto_dns)
if not raw then return "" end
domain_redir = {}
local ok, lyaml = pcall(require, "lyaml")
@@ -662,7 +738,8 @@ function parseClashNode(raw, remark)
end
end
return #links > 0 and table.concat(links, "\n") or ""
local resolver = #links > 0 and auto_dns and extract_clash_dns(data) or nil
return #links > 0 and table.concat(links, "\n") or "", resolver
end
function parse_clash_sub_info(headers)
@@ -1973,12 +1973,12 @@ local function update_node(manual)
end
-- sing-box/xray 节点域名解析
if kkk == "type" and (vvv == "Xray" or vvv == "sing-box") then
if domain_resolver then
if domain_resolver and domain_resolver ~= "" and domain_resolver ~= "disable" then
uci_set(cfgid, "domain_resolver", domain_resolver)
if domain_resolver_dns then
uci_set(cfgid, "domain_resolver_dns", domain_resolver_dns)
elseif domain_resolver_dns_https then
if domain_resolver == "https" and domain_resolver_dns_https then
uci_set(cfgid, "domain_resolver_dns_https", domain_resolver_dns_https)
elseif (domain_resolver == "udp" or domain_resolver == "tcp") and domain_resolver_dns then
uci_set(cfgid, "domain_resolver_dns", domain_resolver_dns)
end
end
if domain_strategy then
@@ -2178,7 +2178,15 @@ local execute = function()
local stdout = f:read("*all")
f:close()
local raw_data = api.trim(stdout)
raw_data = parseClashNode(raw_data)
local auto_dns = value.domain_resolver == nil or value.domain_resolver == ""
local resolver
raw_data, resolver = parseClashNode(raw_data, remark, auto_dns)
if auto_dns and resolver then
-- Only enrich this run's sub_cfg; keep the subscription's Auto setting.
value.domain_resolver = resolver.domain_resolver
value.domain_resolver_dns = resolver.domain_resolver_dns
value.domain_resolver_dns_https = resolver.domain_resolver_dns_https
end
-- 优先提取标准 Clash 订阅响应头中的账户用量与到期时间(不受节点变化与否影响)
if headers and headers ~= "" then
subscribe_info[cfgid] = parse_clash_sub_info(headers)
@@ -2187,8 +2195,15 @@ local execute = function()
log_buffer = {}
local parsed_nodes = parse_link(raw_data, "2", remark, value)
local old_md5 = value.md5 or ""
local new_md5 = #parsed_nodes > 0 and api.md5_string(jsonStringify(parsed_nodes)) or ""
-- 仅当解析出有效节点且节点集合与上次完全一致时,跳过节点重写与服务重启
local md5_content = jsonStringify(parsed_nodes)
if value.domain_resolver and value.domain_resolver ~= "" and value.domain_resolver ~= "disable" then
local protocol = value.domain_resolver
local dns = (protocol == "udp" or protocol == "tcp") and value.domain_resolver_dns or ""
local doh = protocol == "https" and value.domain_resolver_dns_https or ""
md5_content = md5_content .. jsonStringify({protocol, dns or "", doh or ""})
end
local new_md5 = #parsed_nodes > 0 and api.md5_string(md5_content) or ""
-- 节点集合和最终生效的 DNS 都未变化时,跳过节点重写与服务重启。
if not manual_sub and new_md5 ~= "" and old_md5 == new_md5 then
log_buffer = nil -- 无物理变动,直接丢弃暂存的丢弃与计数明细
log('订阅:【' .. remark .. '】没有变化,无需更新。')
@@ -2251,6 +2266,7 @@ elseif arg[1] == "add" then
local f = assert(io.open("/tmp/links.conf", 'r'))
local raw = f:read('*all')
f:close()
raw = parseClashNode(raw, arg[2])
parse_link(raw, "1", arg[2])
if update_node(1) then uci_save(true) end
api.remove("/tmp/links.conf")
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.11.3
PKG_RELEASE:=171
PKG_RELEASE:=172
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -118,7 +118,10 @@ end
o = s:option(ListValue, "domain_resolver", translate("Domain DNS Resolve"))
o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "<br>" ..
translate("Supports only Xray or Sing-box node types.")
translate("Supports only Xray or Sing-box node types.") .. "<br>" .. string.format('<font color="red">%s</font>',
translate("Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."))
o.default = ""
o:value("disable", translate("Disable"))
o:value("", translate("Auto"))
o:value("tcp", "TCP")
o:value("udp", "UDP")
+54
View File
@@ -2584,3 +2584,57 @@ msgstr "نمایش همه ردیف‌ها"
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
msgstr "فیلتر فقط ردیف‌ها را در این صفحه پنهان می‌کند و چیزی را روی روتر تغییر نمی‌دهد. انتخاب همه، ردیف‌های پنهان را رد می‌کند."
msgid "Subscription conversion: discard SS node %s, unsupported cipher: %s"
msgstr "تبدیل اشتراک: گره SS %s کنار گذاشته شد؛ رمز پشتیبانی‌نشده: %s"
msgid "Subscription conversion: discard SS node %s, unsupported plugin: %s"
msgstr "تبدیل اشتراک: گره SS %s کنار گذاشته شد؛ افزونه پشتیبانی‌نشده: %s"
msgid "Subscription conversion: discard Hysteria node %s, unsupported transport: %s"
msgstr "تبدیل اشتراک: گره Hysteria %s کنار گذاشته شد؛ انتقال پشتیبانی‌نشده: %s"
msgid "Subscription conversion: discard TUIC node %s, TUIC v4 is not supported."
msgstr "تبدیل اشتراک: گره TUIC %s کنار گذاشته شد؛ TUIC v4 پشتیبانی نمی‌شود."
msgid "Subscription conversion: discard TUIC node %s, authentication information is incomplete."
msgstr "تبدیل اشتراک: گره TUIC %s کنار گذاشته شد؛ اطلاعات احراز هویت ناقص است."
msgid "Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s"
msgstr "تبدیل اشتراک: گره AnyTLS %s کنار گذاشته شد؛ Sing-Box از AnyTLS + %s پشتیبانی نمی‌کند"
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s"
msgstr "تبدیل اشتراک: گره Snell %s کنار گذاشته شد؛ Sing-Box از Snell + %s پشتیبانی نمی‌کند"
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4."
msgstr "تبدیل اشتراک: گره Snell %s کنار گذاشته شد؛ Sing-Box نسخه‌های Snell پایین‌تر از 4 را پشتیبانی نمی‌کند."
msgid "Subscription conversion: skip a malformed node."
msgstr "تبدیل اشتراک: گره با قالب ناقص رد شد."
msgid "Subscription conversion: discard node %s, node information is incomplete."
msgstr "تبدیل اشتراک: گره %s کنار گذاشته شد؛ اطلاعات گره ناقص است."
msgid "Subscription conversion: discard node %s, invalid bandwidth format."
msgstr "تبدیل اشتراک: گره %s کنار گذاشته شد؛ قالب پهنای باند نامعتبر است."
msgid "Subscription conversion: discard unsupported node %s, node type: %s"
msgstr "تبدیل اشتراک: گره پشتیبانی‌نشده %s کنار گذاشته شد؛ نوع گره: %s"
msgid "Subscription conversion: extracted node domain DNS: %s"
msgstr "تبدیل اشتراک: DNS دامنه گره استخراج شد: %s"
msgid "Subscription conversion: YAML parsing failed: %s"
msgstr "تبدیل اشتراک: تجزیه YAML ناموفق بود: %s"
msgid "Subscription conversion: Clash subscription detected, converting..."
msgstr "تبدیل اشتراک: اشتراک Clash شناسایی شد؛ در حال تبدیل..."
msgid "Subscription conversion: discard node %s, conversion failed."
msgstr "تبدیل اشتراک: گره %s کنار گذاشته شد؛ تبدیل ناموفق بود."
msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."
msgstr "توجه: حالت خودکار تلاش می‌کند DNS گره را از اشتراک Clash استخراج کند. غیرفعال کردن، استخراج را متوقف می‌کند."
msgid "Never expires"
msgstr "بدون انقضا"
+54
View File
@@ -2585,3 +2585,57 @@ msgstr "Показать все строки"
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
msgstr "Фильтр только скрывает строки на этой странице и ничего не меняет на роутере. «Выделить все» пропускает скрытые строки."
msgid "Subscription conversion: discard SS node %s, unsupported cipher: %s"
msgstr "Преобразование подписки: узел SS %s пропущен, неподдерживаемый шифр: %s"
msgid "Subscription conversion: discard SS node %s, unsupported plugin: %s"
msgstr "Преобразование подписки: узел SS %s пропущен, неподдерживаемый плагин: %s"
msgid "Subscription conversion: discard Hysteria node %s, unsupported transport: %s"
msgstr "Преобразование подписки: узел Hysteria %s пропущен, неподдерживаемый транспорт: %s"
msgid "Subscription conversion: discard TUIC node %s, TUIC v4 is not supported."
msgstr "Преобразование подписки: узел TUIC %s пропущен, TUIC v4 не поддерживается."
msgid "Subscription conversion: discard TUIC node %s, authentication information is incomplete."
msgstr "Преобразование подписки: узел TUIC %s пропущен, неполные данные аутентификации."
msgid "Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s"
msgstr "Преобразование подписки: узел AnyTLS %s пропущен, Sing-Box не поддерживает AnyTLS + %s"
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s"
msgstr "Преобразование подписки: узел Snell %s пропущен, Sing-Box не поддерживает Snell + %s"
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4."
msgstr "Преобразование подписки: узел Snell %s пропущен, Sing-Box не поддерживает версии Snell ниже 4."
msgid "Subscription conversion: skip a malformed node."
msgstr "Преобразование подписки: узел с неполным форматом пропущен."
msgid "Subscription conversion: discard node %s, node information is incomplete."
msgstr "Преобразование подписки: узел %s пропущен, неполные данные узла."
msgid "Subscription conversion: discard node %s, invalid bandwidth format."
msgstr "Преобразование подписки: узел %s пропущен, неверный формат пропускной способности."
msgid "Subscription conversion: discard unsupported node %s, node type: %s"
msgstr "Преобразование подписки: неподдерживаемый узел %s пропущен, тип узла: %s"
msgid "Subscription conversion: extracted node domain DNS: %s"
msgstr "Преобразование подписки: получен DNS для доменов узлов: %s"
msgid "Subscription conversion: YAML parsing failed: %s"
msgstr "Преобразование подписки: ошибка разбора YAML: %s"
msgid "Subscription conversion: Clash subscription detected, converting..."
msgstr "Преобразование подписки: обнаружена подписка Clash, выполняется преобразование..."
msgid "Subscription conversion: discard node %s, conversion failed."
msgstr "Преобразование подписки: узел %s пропущен, ошибка преобразования."
msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."
msgstr "Примечание: в режиме «Авто» выполняется извлечение DNS узлов из подписок Clash. «Отключить» отменяет извлечение."
msgid "Never expires"
msgstr "Бессрочно"
+54
View File
@@ -2570,3 +2570,57 @@ msgstr "显示全部行"
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
msgstr "筛选只在本页隐藏行,不会改动路由器上的设置。全选会跳过隐藏行。"
msgid "Subscription conversion: discard SS node %s, unsupported cipher: %s"
msgstr "订阅转换:丢弃 SS 节点 %s,不支持的加密方式:%s"
msgid "Subscription conversion: discard SS node %s, unsupported plugin: %s"
msgstr "订阅转换:丢弃 SS 节点 %s,不支持的插件:%s"
msgid "Subscription conversion: discard Hysteria node %s, unsupported transport: %s"
msgstr "订阅转换:丢弃 Hysteria 节点 %s,不支持的传输:%s"
msgid "Subscription conversion: discard TUIC node %s, TUIC v4 is not supported."
msgstr "订阅转换:丢弃 TUIC 节点 %s,不支持 TUIC v4。"
msgid "Subscription conversion: discard TUIC node %s, authentication information is incomplete."
msgstr "订阅转换:丢弃 TUIC 节点 %s,认证信息不完整。"
msgid "Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s"
msgstr "订阅转换:丢弃 AnyTLS 节点 %s,Sing-Box 不支持 AnyTLS + %s"
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s"
msgstr "订阅转换:丢弃 Snell 节点 %s,Sing-Box 不支持 Snell + %s"
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4."
msgstr "订阅转换:丢弃 Snell 节点 %s,Sing-Box 不支持低于 4 的 Snell 版本。"
msgid "Subscription conversion: skip a malformed node."
msgstr "订阅转换:跳过格式不完整的节点。"
msgid "Subscription conversion: discard node %s, node information is incomplete."
msgstr "订阅转换:丢弃节点 %s,节点信息不完整。"
msgid "Subscription conversion: discard node %s, invalid bandwidth format."
msgstr "订阅转换:丢弃节点 %s,带宽格式不正确。"
msgid "Subscription conversion: discard unsupported node %s, node type: %s"
msgstr "订阅转换:丢弃不支持的节点 %s,节点类型:%s"
msgid "Subscription conversion: extracted node domain DNS: %s"
msgstr "订阅转换:已提取节点域名 DNS:%s"
msgid "Subscription conversion: YAML parsing failed: %s"
msgstr "订阅转换:YAML 解析失败:%s"
msgid "Subscription conversion: Clash subscription detected, converting..."
msgstr "订阅转换:检测到 Clash 订阅,正在转换……"
msgid "Subscription conversion: discard node %s, conversion failed."
msgstr "订阅转换:丢弃节点 %s,转换失败。"
msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."
msgstr "注意:自动尝试提取 Clash 订阅中的节点 DNS,禁用则不提取。"
msgid "Never expires"
msgstr "长期有效"
+54
View File
@@ -2576,3 +2576,57 @@ msgstr "顯示全部列"
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
msgstr "篩選只在本頁隱藏列,不會改動路由器上的設定。全選會跳過隱藏列。"
msgid "Subscription conversion: discard SS node %s, unsupported cipher: %s"
msgstr "訂閱轉換:捨棄 SS 節點 %s,不支援的加密方式:%s"
msgid "Subscription conversion: discard SS node %s, unsupported plugin: %s"
msgstr "訂閱轉換:捨棄 SS 節點 %s,不支援的外掛:%s"
msgid "Subscription conversion: discard Hysteria node %s, unsupported transport: %s"
msgstr "訂閱轉換:捨棄 Hysteria 節點 %s,不支援的傳輸:%s"
msgid "Subscription conversion: discard TUIC node %s, TUIC v4 is not supported."
msgstr "訂閱轉換:捨棄 TUIC 節點 %s,不支援 TUIC v4。"
msgid "Subscription conversion: discard TUIC node %s, authentication information is incomplete."
msgstr "訂閱轉換:捨棄 TUIC 節點 %s,驗證資訊不完整。"
msgid "Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s"
msgstr "訂閱轉換:捨棄 AnyTLS 節點 %s,Sing-Box 不支援 AnyTLS + %s"
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s"
msgstr "訂閱轉換:捨棄 Snell 節點 %s,Sing-Box 不支援 Snell + %s"
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4."
msgstr "訂閱轉換:捨棄 Snell 節點 %s,Sing-Box 不支援低於 4 的 Snell 版本。"
msgid "Subscription conversion: skip a malformed node."
msgstr "訂閱轉換:略過格式不完整的節點。"
msgid "Subscription conversion: discard node %s, node information is incomplete."
msgstr "訂閱轉換:捨棄節點 %s,節點資訊不完整。"
msgid "Subscription conversion: discard node %s, invalid bandwidth format."
msgstr "訂閱轉換:捨棄節點 %s,頻寬格式不正確。"
msgid "Subscription conversion: discard unsupported node %s, node type: %s"
msgstr "訂閱轉換:捨棄不支援的節點 %s,節點類型:%s"
msgid "Subscription conversion: extracted node domain DNS: %s"
msgstr "訂閱轉換:已擷取節點網域 DNS:%s"
msgid "Subscription conversion: YAML parsing failed: %s"
msgstr "訂閱轉換:YAML 解析失敗:%s"
msgid "Subscription conversion: Clash subscription detected, converting..."
msgstr "訂閱轉換:偵測到 Clash 訂閱,正在轉換……"
msgid "Subscription conversion: discard node %s, conversion failed."
msgstr "訂閱轉換:捨棄節點 %s,轉換失敗。"
msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."
msgstr "注意:自動嘗試擷取 Clash 訂閱中的節點 DNS,停用則不擷取。"
msgid "Never expires"
msgstr "長期有效"
@@ -0,0 +1,787 @@
#!/usr/bin/lua
-- Copyright(c): lwb1978 2025–2027
local api = require("luci.passwall2.api")
local base64 = api.base64Encode
local json = api.jsonc
local i18n = api.i18n
local domain_redir = {}
local function urlencode(value)
return api.UrlEncode(value == nil and "" or tostring(value))
end
local function add_param(params, key, value)
if value ~= nil and value ~= "" then
if type(value) == "boolean" then value = value and "1" or "0" end
params[#params + 1] = key .. "=" .. urlencode(value)
end
end
local function build_ech(node)
local opts = node["ech-opts"]
if type(opts) ~= "table" or opts.enable ~= true then return nil end
if opts.config and opts.config ~= "" then return opts.config end
local name = opts["query-server-name"]
if not name or name == "" then name = node.servername or node.sni or node.server end
if name == "" then name = node.server end
return name .. "+https://223.5.5.5/dns-query"
end
local function bandwidth_mbps(value)
if value == nil then return nil end
local number = tonumber(value)
if number then return number end
local amount, prefix, unit = tostring(value):match("^%s*(%d+)%s*([KMGT]?)([Bb])ps%s*$")
local scales = { [""] = 1, K = 1000, M = 1000000, G = 1000000000, T = 1000000000000 }
if not amount then return nil end
return tonumber(amount) * scales[prefix] * (unit == "B" and 8 or 1) / 1000000
end
local function inherit(base, overrides)
local result = {}
for key, value in pairs(base or {}) do result[key] = value end
for key, value in pairs(overrides or {}) do result[key] = value end
return result
end
local function build_xhttp_extra(opts)
local extra = {}
local fields = {
headers = "headers", ["x-padding-bytes"] = "xPaddingBytes",
["no-grpc-header"] = "noGRPCHeader", ["x-padding-obfs-mode"] = "xPaddingObfsMode",
["x-padding-key"] = "xPaddingKey", ["x-padding-header"] = "xPaddingHeader",
["x-padding-placement"] = "xPaddingPlacement", ["x-padding-method"] = "xPaddingMethod",
["uplink-http-method"] = "uplinkHTTPMethod", ["session-placement"] = "sessionIDPlacement",
["session-key"] = "sessionIDKey", ["session-table"] = "sessionIDTable",
["session-length"] = "sessionIDLength", ["seq-placement"] = "seqPlacement", ["seq-key"] = "seqKey",
["uplink-data-placement"] = "uplinkDataPlacement", ["uplink-data-key"] = "uplinkDataKey",
["uplink-chunk-size"] = "uplinkChunkSize", ["sc-max-each-post-bytes"] = "scMaxEachPostBytes",
["sc-min-posts-interval-ms"] = "scMinPostsIntervalMs"
}
for source, target in pairs(fields) do extra[target] = opts[source] end
-- jsonc treats empty tables as arrays and repeated table references as null.
if type(extra.headers) == "table" then
extra.headers = next(extra.headers) and inherit(extra.headers) or nil
end
local reuse = opts["reuse-settings"]
if reuse then
local xmux = {}
local ranges = {
["max-concurrency"] = "maxConcurrency", ["max-connections"] = "maxConnections",
["c-max-reuse-times"] = "cMaxReuseTimes", ["h-max-request-times"] = "hMaxRequestTimes",
["h-max-reusable-secs"] = "hMaxReusableSecs"
}
for source, target in pairs(ranges) do
local value = reuse[source]
if value ~= nil then xmux[target] = tonumber(value) or value end
end
xmux.hKeepAlivePeriod = tonumber(reuse["h-keep-alive-period"])
if next(xmux) then extra.xmux = xmux end
end
return extra
end
local function host_format(host)
if not host then return "" end
local str = host:match("%[(.-)%]") or host
if api.datatypes.ip6addr(str) then
return "[" .. str .. "]"
end
return host
end
local function build_alpn(alpn) -- Sort and remove duplicates.
if not alpn then return nil end
local seen = {}
local order = { "h3", "h2", "http/1.1" }
if type(alpn) == "table" then
for _, v in ipairs(alpn) do
if v then seen[v] = true end
end
else
seen[tostring(alpn)] = true
end
local t = {}
for _, v in ipairs(order) do
if seen[v] then table.insert(t, v) end
end
if #t == 0 then return nil end
return table.concat(t, ",")
end
local function build_common(node)
local o = {}
o.server = host_format(node.server)
o.port = node.port
o.name = node.name
-- ===== TLS =====
o.tls = {}
if node["reality-opts"] then
o.tls.security = "reality"
o.tls.pbk = node["reality-opts"]["public-key"]
o.tls.sid = node["reality-opts"]["short-id"]
elseif node.tls then
o.tls.security = "tls"
end
o.tls.sni = node.servername or node.sni
o.tls.fp = node["client-fingerprint"]
o.tls.pcs = node.fingerprint
o.tls.vcn = node["name-cert-verify"]
o.tls.insecure = node["skip-cert-verify"] == true
o.tls.alpn = build_alpn(node.alpn)
o.tls.ech = build_ech(node)
-- ===== transport =====
o.transport = {}
local net = node.network or "tcp"
o.transport.type = net
local function get_first(v)
if type(v) == "table" then return v[1] end
return v
end
if net == "ws" then
local opts = node["ws-opts"]
if opts then
local path = opts.path or "/"
if opts["v2ray-http-upgrade"] ~= true and opts["max-early-data"] then
path = path .. (path:find("?", 1, true) and "&ed=" or "?ed=") .. opts["max-early-data"]
end
if opts["v2ray-http-upgrade"] ~= true and opts["early-data-header-name"] then
path = path .. (path:find("?", 1, true) and "&eh=" or "?eh=") .. opts["early-data-header-name"]
end
o.transport.path = path
o.transport.host = opts.headers and opts.headers.Host
if opts["v2ray-http-upgrade"] == true then o.transport.type = "httpupgrade" end
end
elseif net == "grpc" then
local opts = node["grpc-opts"]
if opts then
o.transport.serviceName = opts["grpc-service-name"]
end
elseif net == "http" then
-- Use TCP + HTTP camouflage so each core selects its matching transport.
o.transport.type = "tcp"
o.transport.headerType = "http"
o.transport.host = node.server
o.transport.path = "/"
local opts = node["http-opts"]
if opts then
-- Clash http-opts carries the camouflage Host under headers.Host
-- (a list), like ws-opts; opts.host is not standard for http.
o.transport.host = get_first(opts.host) or (opts.headers and get_first(opts.headers.Host)) or o.transport.host
o.transport.path = get_first(opts.path) or o.transport.path
end
elseif net == "h2" then
local opts = node["h2-opts"]
if opts then
o.transport.host = get_first(opts.host)
o.transport.path = opts.path
end
elseif net == "xhttp" then
local opts = node["xhttp-opts"]
if opts then
o.transport.host = opts.host
o.transport.path = opts.path
o.transport.mode = opts.mode
local extra = build_xhttp_extra(opts)
-- download-settings
if opts["download-settings"] then
local d = opts["download-settings"]
local effective = inherit(node, d)
local ds = {
address = domain_redir[effective.server] or effective.server,
port = effective.port,
network = "xhttp",
security = effective.tls and "tls" or "none"
}
local sni = effective.servername or effective.sni or effective.server
local alpn = build_alpn(effective.alpn)
if effective.tls or effective["reality-opts"] then
ds.tlsSettings = {
serverName = sni,
allowInsecure = effective["skip-cert-verify"] == true,
fingerprint = effective["client-fingerprint"],
pinnedPeerCertSha256 = effective.fingerprint and api.sha256_sb_xray(effective.fingerprint) or nil,
verifyPeerCertByName = effective["name-cert-verify"],
alpn = alpn and api.split(alpn, ",") or nil,
echConfigList = build_ech(effective)
}
local reality = effective["reality-opts"]
if reality then
ds.security = "reality"
ds.realitySettings = {
serverName = sni, fingerprint = effective["client-fingerprint"] or "chrome",
publicKey = reality["public-key"], shortId = reality["short-id"] or ""
}
ds.tlsSettings = nil
end
end
local xs = build_xhttp_extra(inherit(opts, d))
xs.path = d.path or opts.path or "/"
xs.host = d.host or opts.host or sni
xs.mode = opts.mode or "auto"
ds.xhttpSettings = xs
extra.downloadSettings = ds
end
if next(extra) then
o.transport.extra = json.stringify(extra)
end
end
end
return o
end
-- VLESS
local function encode_vless(node)
local o = build_common(node)
local link = "vless://" .. node.uuid .. "@" .. o.server .. ":" .. o.port
local p = {}
if type(node.flow) == "string" and node.flow:sub(1, 5) == "xtls-" then add_param(p, "flow", node.flow) end
add_param(p, "encryption", node.encryption)
-- TLS
add_param(p, "security", o.tls.security)
add_param(p, "pbk", o.tls.pbk)
add_param(p, "sid", o.tls.sid)
add_param(p, "sni", o.tls.sni)
add_param(p, "fp", o.tls.fp)
add_param(p, "alpn", o.tls.alpn)
add_param(p, "ech", o.tls.ech)
add_param(p, "pcs", o.tls.pcs)
add_param(p, "vcn", o.tls.vcn)
add_param(p, "allowInsecure", o.tls.insecure)
-- transport
add_param(p, "type", o.transport.type)
add_param(p, "headerType", o.transport.headerType)
add_param(p, "host", o.transport.host)
add_param(p, "path", o.transport.path)
add_param(p, "serviceName", o.transport.serviceName)
add_param(p, "mode", o.transport.mode)
add_param(p, "extra", o.transport.extra)
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(o.name or "")
end
-- Trojan
local function encode_trojan(node)
local o = build_common(node)
local link = "trojan://" .. urlencode(node.password) .. "@" .. o.server .. ":" .. o.port
local p = {}
add_param(p, "security", o.tls.security)
add_param(p, "pbk", o.tls.pbk)
add_param(p, "sid", o.tls.sid)
add_param(p, "sni", o.tls.sni)
add_param(p, "fp", o.tls.fp)
add_param(p, "alpn", o.tls.alpn)
add_param(p, "ech", o.tls.ech)
add_param(p, "pcs", o.tls.pcs)
add_param(p, "vcn", o.tls.vcn)
add_param(p, "allowInsecure", o.tls.insecure)
add_param(p, "type", o.transport.type)
add_param(p, "headerType", o.transport.headerType)
add_param(p, "host", o.transport.host)
add_param(p, "path", o.transport.path)
add_param(p, "serviceName", o.transport.serviceName)
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(o.name or "")
end
-- VMess
local function encode_vmess(node)
local o = build_common(node)
local obj = {
v = "2",
ps = o.name,
add = node.server,
port = tostring(node.port),
id = node.uuid,
aid = tostring(node.alterId or 0),
net = o.transport.type,
security = node.cipher,
scy = node.cipher,
type = o.transport.headerType or "none",
host = o.transport.host or "",
path = urlencode(o.transport.path or ""),
tls = o.tls.security == "tls" and "tls" or "",
sni = o.tls.sni,
alpn = o.tls.alpn,
fp = o.tls.fp,
ech = o.tls.ech,
pcs = o.tls.pcs,
vcn = o.tls.vcn,
insecure = o.tls.insecure and "1" or "0",
tfo = node.tfo and "1" or "0"
}
if o.transport.type == "grpc" then
obj.path = urlencode(o.transport.serviceName or "")
end
return 0, "vmess://" .. base64(json.stringify(obj))
end
-- SS
local function encode_ss(node)
local ss_method = {
["none"]=1, ["aes-128-gcm"]=1, ["aes-192-gcm"]=1, ["aes-256-gcm"]=1, ["chacha20-ietf-poly1305"]=1, ["xchacha20-ietf-poly1305"]=1, ["2022-blake3-aes-128-gcm"]=1, ["2022-blake3-aes-256-gcm"]=1, ["2022-blake3-chacha20-poly1305"]=1,
["aes-128-ctr"]=1, ["aes-192-ctr"]=1, ["aes-256-ctr"]=1, ["aes-128-cfb"]=1, ["aes-192-cfb"]=1, ["aes-256-cfb"]=1, ["rc4-md5"]=1, ["chacha20-ietf"]=1, ["xchacha20"]=1,
}
if not ss_method[node.cipher or ""] then
return 1, i18n.translatef("Subscription conversion: discard SS node %s, unsupported cipher: %s", node.name or "", node.cipher or "")
end
local userinfo = node.cipher .. ":" .. node.password
local base = userinfo .. "@" .. host_format(node.server) .. ":" .. node.port
local link = "ss://" .. base64(base)
local p = {}
if node.udp then add_param(p, "udp", true) end
if node["udp-over-tcp"] then add_param(p, "uot", true) end
if node.plugin then
local plugin = (node.plugin == "obfs") and "obfs-local" or node.plugin
if plugin == "shadow-tls" then
local shadow_tls = base64(json.stringify(node["plugin-opts"] or {}))
add_param(p, "shadow-tls", shadow_tls)
elseif plugin == "obfs-local" or plugin == "v2ray-plugin" then
local opts = {}
for k, v in pairs(node["plugin-opts"] or {}) do
if plugin == "obfs-local" then
if k == "mode" then k = "obfs" end
if k == "host" then k = "obfs-host" end
elseif plugin == "v2ray-plugin" then
if k == "mode" and v == "websocket" then
v = nil
elseif type(v) == "boolean" then
if v == true then
table.insert(opts, k)
end
v = nil
end
end
if v ~= nil then
if type(v) == "boolean" then
v = v and "1" or "0"
end
table.insert(opts, k .. "=" .. v)
end
end
if #opts > 0 then plugin = plugin .. ";" .. table.concat(opts, ";") end
add_param(p, "plugin", plugin)
else
return 1, i18n.translatef("Subscription conversion: discard SS node %s, unsupported plugin: %s", node.name or "", plugin)
end
end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
-- Hysteria
local function encode_hysteria(node)
if node.protocol and node.protocol ~= "udp" then
return 1, i18n.translatef("Subscription conversion: discard Hysteria node %s, unsupported transport: %s", node.name, node.protocol)
end
local link = "hysteria://" .. host_format(node.server) .. ":" .. (node.port or "")
local p = {}
add_param(p, "auth", node["auth-str"])
add_param(p, "mport", node.ports)
add_param(p, "obfsParam", node.obfs)
add_param(p, "sni", node.sni)
add_param(p, "upmbps", bandwidth_mbps(node.up))
add_param(p, "downmbps", bandwidth_mbps(node.down))
add_param(p, "insecure", node["skip-cert-verify"] == true)
add_param(p, "vcn", node["name-cert-verify"])
add_param(p, "pinSHA256", node.fingerprint)
add_param(p, "alpn", build_alpn(node.alpn))
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
-- Hysteria2
local function encode_hysteria2(node)
-- Hopping subscriptions may omit port; keep a valid fallback for all cores.
local port = node.port or tostring(node.ports or ""):match("^%s*(%d+)")
local link = "hysteria2://" .. urlencode(node.password or "") .. "@" .. host_format(node.server) .. ":" .. port
local p = {}
add_param(p, "mport", node.ports)
add_param(p, "hopInterval", node["hop-interval"])
add_param(p, "obfs", node.obfs)
add_param(p, "obfs-password", node["obfs-password"])
add_param(p, "minPacketSize", node["obfs-min-packet-size"])
add_param(p, "maxPacketSize", node["obfs-max-packet-size"])
add_param(p, "upmbps", bandwidth_mbps(node.up))
add_param(p, "downmbps", bandwidth_mbps(node.down))
add_param(p, "ech", build_ech(node))
add_param(p, "vcn", node["name-cert-verify"])
add_param(p, "sni", node.sni)
add_param(p, "insecure", node["skip-cert-verify"] == true)
add_param(p, "pinSHA256", node.fingerprint)
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
-- TUIC
local function encode_tuic(node)
if node.token then
return 1, i18n.translatef("Subscription conversion: discard TUIC node %s, TUIC v4 is not supported.", node.name)
end
if not node.uuid or node.password == nil then
return 1, i18n.translatef("Subscription conversion: discard TUIC node %s, authentication information is incomplete.", node.name)
end
local link = "tuic://" .. urlencode(node.uuid) .. ":" .. urlencode(node.password) .. "@" .. host_format(node.server) .. ":" .. node.port
local p = {}
add_param(p, "congestion_control", node["congestion-controller"])
add_param(p, "alpn", build_alpn(node.alpn))
add_param(p, "sni", node.sni)
if node["disable-sni"] then add_param(p, "disable_sni", true) end
add_param(p, "allowInsecure", node["skip-cert-verify"] == true)
add_param(p, "udp_relay_mode", node["udp-relay-mode"])
add_param(p, "pcs", node.fingerprint)
add_param(p, "vcn", node["name-cert-verify"])
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
-- AnyTLS
local function encode_anytls(node)
local err_msg
if node["shadow-tls-opts"] then
err_msg = "ShadowTLS"
elseif node["restls-opts"] then
err_msg = "ResTLS"
elseif node["jls-opts"] then
err_msg = "JLS"
end
if err_msg then
err_msg = i18n.translatef("Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s", node.name or "", err_msg)
return 1, err_msg
end
local o = build_common(node)
local link = "anytls://" .. urlencode(node.password or "") .. "@" .. host_format(node.server) .. ":" .. node.port
local p = {}
add_param(p, "security", o.tls.security)
add_param(p, "pbk", o.tls.pbk)
add_param(p, "sid", o.tls.sid)
add_param(p, "sni", o.tls.sni)
add_param(p, "alpn", o.tls.alpn)
add_param(p, "fp", o.tls.fp)
add_param(p, "ech", o.tls.ech)
add_param(p, "pcs", o.tls.pcs)
add_param(p, "insecure", o.tls.insecure)
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
-- SSR
local function encode_ssr(node)
local link = host_format(node.server) .. ":" .. node.port .. ":" .. (node.protocol or "") .. ":" ..
(node.cipher or "") .. ":" .. (node.obfs or "") .. ":" .. base64(node.password)
local p = {}
if node["obfs-param"] then table.insert(p, "obfsparam=" .. base64(node["obfs-param"])) end
if node["protocol-param"] then table.insert(p, "protoparam=" .. base64(node["protocol-param"])) end
table.insert(p, "remarks=" .. base64(node.name))
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, "ssr://" .. base64(link)
end
-- snell
local function encode_snell(node)
local err_msg
local obfs = node["obfs-opts"] or {}
if obfs.mode and obfs.mode ~= "http" and obfs.mode ~= "none" then
err_msg = obfs.mode
end
if err_msg then
err_msg = i18n.translatef("Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s", node.name or "", err_msg)
return 1, err_msg
end
local version = node.version and tonumber(node.version) or 4
version = (version == 5) and 4 or version
if version < 4 then
err_msg = i18n.translatef("Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4.", node.name or "")
return 1, err_msg
end
local link = "snell://" .. host_format(node.server) .. ":" .. node.port
local p = {}
add_param(p, "psk", node.psk)
add_param(p, "version", version)
if obfs.mode == "http" then
add_param(p, "obfs", "http")
add_param(p, "obfs-host", obfs.host)
end
add_param(p, "reuse", node.reuse and "1" or "0")
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
local function encode_node(node)
if type(node) ~= "table" or not node.type or not node.name then
return 1, i18n.translate("Subscription conversion: skip a malformed node.")
end
if not node.server or node.server == "" or not (node.port or (node.type == "hysteria2" and node.ports)) then
return 1, i18n.translatef("Subscription conversion: discard node %s, node information is incomplete.", tostring(node.name))
end
node = inherit(node)
local t = node.type
if t == "hysteria" or t == "hysteria2" then
local up, down = bandwidth_mbps(node.up), bandwidth_mbps(node.down)
if (node.up ~= nil and up == nil) or (node.down ~= nil and down == nil) then
return 1, i18n.translatef("Subscription conversion: discard node %s, invalid bandwidth format.", tostring(node.name))
end
node.up, node.down = up, down
end
node.server = domain_redir[node.server] or node.server
if t == "vless" then return encode_vless(node)
elseif t == "trojan" then return encode_trojan(node)
elseif t == "vmess" then return encode_vmess(node)
elseif t == "ss" then return encode_ss(node)
elseif t == "hysteria" then return encode_hysteria(node)
elseif t == "hysteria2" then return encode_hysteria2(node)
elseif t == "tuic" then return encode_tuic(node)
elseif t == "anytls" then return encode_anytls(node)
elseif t == "ssr" then return encode_ssr(node)
elseif t == "snell" then return encode_snell(node)
else api.log(2, i18n.translatef("Subscription conversion: discard unsupported node %s, node type: %s", node.name, t))
end
end
local function parse_dns_endpoint(value)
if type(value) ~= "string" then return nil end
value = api.trim(value)
-- Clash fragments and URL queries cannot be preserved by both node DNS backends.
if value == "" or value:find("[%s#?@,]") then return nil end
local url = value:find("://", 1, true) and value or "udp://" .. value
local ok, endpoint = pcall(api.parseURL, url)
if not ok or not endpoint then return nil end
local protocol = endpoint.protocol
if protocol ~= "udp" and protocol ~= "tcp" and protocol ~= "https" then return nil end
local host = endpoint.hostname
if not host or not (api.is_ip(host) or api.datatypes.hostname(host)) then return nil end
local bracketed = endpoint.host:sub(1, 1) == "["
local ipv6 = api.datatypes.ip6addr(host)
if bracketed and not ipv6 then return nil end
if protocol == "https" and ipv6 and not bracketed then return nil end
local port = endpoint.port or (protocol == "https" and 443 or 53)
if port < 1 or port > 65535 then return nil end
if protocol ~= "https" and endpoint.pathname then return nil end
return { protocol = protocol, host = host:lower():gsub("%.$", ""), port = port, url = url }
end
local function is_loopback_dns(host)
return host == "localhost" or (api.datatypes.ip4addr(host) and host:match("^127%.")) or host:match("^[0:]+:0*1$")
end
local function is_unspecified_dns(host)
return host == "0.0.0.0" or host:match("^[0:]+$")
end
local function extract_clash_dns(data)
local dns = data.dns
if type(dns) ~= "table" or not dns["proxy-server-nameserver"] then return nil end
-- A subscription-wide resolver cannot represent per-domain node DNS policies.
local node_policy = dns["proxy-server-nameserver-policy"]
if type(node_policy) == "table" and next(node_policy) then return nil end
local listener = parse_dns_endpoint(dns.listen)
local resolving = {}
local policy = dns["nameserver-policy"]
local has_policy = type(policy) == "table" and next(policy)
local function pick(candidates, depth)
if depth > 4 then return nil end
if type(candidates) == "string" then candidates = { candidates } end
if type(candidates) ~= "table" then return nil end
for _, value in ipairs(candidates) do
local endpoint = parse_dns_endpoint(value)
if endpoint and not is_unspecified_dns(endpoint.host) then
if is_loopback_dns(endpoint.host) then
local key = endpoint.protocol .. "://" .. endpoint.host .. ":" .. endpoint.port
local listen_matches = listener and listener.protocol ~= "https" and listener.port == endpoint.port and (
listener.host == endpoint.host or
(listener.host == "0.0.0.0" and not endpoint.host:find(":", 1, true)) or
(is_unspecified_dns(listener.host) and listener.host ~= "0.0.0.0" and endpoint.host:find(":", 1, true)) or
(endpoint.host == "localhost" and is_loopback_dns(listener.host)))
if endpoint.protocol ~= "https" and dns.enable == true and listen_matches and not resolving[key] and not has_policy then
resolving[key] = true
local resolver = pick(dns.nameserver, depth + 1)
resolving[key] = nil
if resolver then return resolver end
end
elseif endpoint.protocol == "https" then
return { domain_resolver = "https", domain_resolver_dns_https = endpoint.url }
elseif api.datatypes.ip4addr(endpoint.host) then
return { domain_resolver = endpoint.protocol, domain_resolver_dns = endpoint.host .. ":" .. endpoint.port }
end
end
end
end
local resolver = pick(dns["proxy-server-nameserver"], 0)
if resolver then
local address = resolver.domain_resolver_dns_https or resolver.domain_resolver .. "://" .. resolver.domain_resolver_dns
api.log(1, i18n.translatef("Subscription conversion: extracted node domain DNS: %s", address))
end
return resolver
end
function parseClashNode(raw, remark, auto_dns)
if not raw then return "" end
domain_redir = {}
local ok, lyaml = pcall(require, "lyaml")
if not ok then return raw end
local parsed, data = pcall(lyaml.load, raw)
if not parsed then
api.log(1, i18n.translatef("Subscription conversion: YAML parsing failed: %s", tostring(remark or "")))
return ""
end
if not data or type(data) ~= "table" then return raw end
if type(data.proxies) ~= "table" then return "" end
api.log(1, i18n.translate("Subscription conversion: Clash subscription detected, converting..."))
-- Some airports use hosts domain redirect to disguise the real node domain name.
for k, v in pairs(type(data.hosts) == "table" and data.hosts or {}) do
if type(k) == "string" and type(v) == "string" then
if api.datatypes.hostname(k) and api.datatypes.hostname(v) then
domain_redir[k] = v
end
end
end
local links = {}
for _, node in ipairs(data.proxies) do
local converted, err, link = pcall(encode_node, node)
if not converted then
local name = type(node) == "table" and node.name or ""
api.log(2, i18n.translatef("Subscription conversion: discard node %s, conversion failed.", tostring(name or "")))
elseif err == 1 and link then
api.log(2, link)
elseif link then
table.insert(links, link)
end
end
local resolver = #links > 0 and auto_dns and extract_clash_dns(data) or nil
return #links > 0 and table.concat(links, "\n") or "", resolver
end
function parse_clash_sub_info(headers)
local userinfo = headers:match("[Ss]ubscription%-userinfo:%s*([^\r\n]+)")
if not userinfo then return nil end
local upload = tonumber(userinfo:match("upload=(%d+)")) or 0
local download = tonumber(userinfo:match("download=(%d+)")) or 0
local total = tonumber(userinfo:match("total=(%d+)")) or 0
local expire = tonumber(userinfo:match("expire=(%d+)"))
local remain = total - (upload + download)
if remain < 0 then remain = 0 end
local function format_size(bytes)
local units = { "B", "KB", "MB", "GB", "TB", "PB" }
local i = 1
while bytes >= 1024 and i < #units do
bytes = bytes / 1024
i = i + 1
end
if bytes >= 100 then
return string.format("%.0f%s", bytes, units[i])
elseif bytes >= 10 then
return string.format("%.1f%s", bytes, units[i])
else
return string.format("%.2f%s", bytes, units[i])
end
end
local rem_traffic = format_size(remain)
local expired_date
if expire and expire > 0 then
local t = os.date("*t", expire)
expired_date = string.format("%d-%d-%d", t.year, t.month, t.day)
else
expired_date = i18n.translate("Never expires")
end
return {
rem_traffic = rem_traffic,
expired_date = expired_date
}
end
@@ -9,6 +9,8 @@ require 'luci.sys'
local api = require "luci.passwall2.api"
local c_config = api.c_config
loadfile("/usr/share/passwall2/clash_subconverter.lua")()
local datatypes = api.datatypes
local split = api.split
local base64Decode = api.base64Decode
@@ -67,8 +69,6 @@ uci:revert(c_config)
math.randomseed(os.time() + math.floor(os.clock() * 1000))
local restart_required = false
local lyaml = require "lyaml"
local has_ss_rust = api.is_finded("sslocal")
local has_ssr = api.is_finded("ssr-local") and api.is_finded("ssr-redir")
local has_singbox = api.finded_com("sing-box")
@@ -475,6 +475,7 @@ end
-- Retrieve subscribe information (remaining data allowance, expiration time).
local subscribe_info = {}
local subscribe_info_from_headers = {}
local function get_subscribe_info(cfgid, value)
if type(cfgid) ~= "string" or cfgid == "" or type(value) ~= "string" then
return
@@ -513,346 +514,6 @@ local function set_ss_implementation(ss_type, result)
return result
end
local function parseClashNode(node, add_mode, group, sub_cfg)
local sub_allowinsecure = DEFAULT_ALLOWINSECURE
local sub_ss_type = DEFAULT_SS_TYPE
local sub_trojan_type = DEFAULT_TROJAN_TYPE
local sub_vmess_type = DEFAULT_VMESS_TYPE
local sub_vless_type = DEFAULT_VLESS_TYPE
local sub_hysteria2_type = DEFAULT_HYSTERIA2_TYPE
local sub_hy_up_mbps, sub_hy_down_mbps
if sub_cfg then
if sub_cfg.allowInsecure and sub_cfg.allowInsecure ~= "1" then
sub_allowinsecure = nil
end
local ss_type = sub_cfg.ss_type or "global"
if ss_type ~= "global" and core_has[ss_type] then
sub_ss_type = ss_type
end
local trojan_type = sub_cfg.trojan_type or "global"
if trojan_type ~= "global" and core_has[trojan_type] then
sub_trojan_type = trojan_type
end
local vmess_type = sub_cfg.vmess_type or "global"
if vmess_type ~= "global" and core_has[vmess_type] then
sub_vmess_type = vmess_type
end
local vless_type = sub_cfg.vless_type or "global"
if vless_type ~= "global" and core_has[vless_type] then
sub_vless_type = vless_type
end
local hysteria2_type = sub_cfg.hysteria2_type or "global"
if hysteria2_type ~= "global" and core_has[hysteria2_type] then
sub_hysteria2_type = hysteria2_type
end
sub_hy_up_mbps = sub_cfg.hysteria_up_mbps
sub_hy_down_mbps = sub_cfg.hysteria_down_mbps
end
local result = {
timeout = 60,
add_mode = add_mode, -- `0` for manual configuration, `1` for import, `2` for subscription
group = group
}
result.remarks = node.name
result.address = node.server
result.port = node.port
if node.type == 'ss' then
result = set_ss_implementation(sub_ss_type, result)
if not result then return nil end
result.method = node.cipher
result.ss_method = node.cipher
result.password = node.password
if node.plugin == "obfs" then
result.plugin = "obfs-local"
elseif node.plugin == "v2ray-plugin" then
result.plugin = "v2ray-plugin"
end
if result.plugin then
result.plugin_enabled = "1"
if result.type == 'Xray' then
if result.plugin ~= "obfs-local" then
result.plugin_enabled = nil
result.error_msg = i18n.translatef("%s unsupport SS %s plugin.", "Xray", result.plugin)
end
elseif result.type == 'sing-box' then
if result.plugin ~= "obfs-local" and result.plugin ~= "v2ray-plugin" then
result.plugin_enabled = nil
result.error_msg = i18n.translatef("%s unsupport SS %s plugin.", "Sing-Box", result.plugin)
end
end
if node["plugin-opts"] and result.plugin_enabled == "1" then
if node.plugin == "obfs" then
local plugin_opts = ""
local opts_mode = node["plugin-opts"].mode
if opts_mode then
plugin_opts = plugin_opts .. "obfs=" .. opts_mode .. ";"
end
local opts_host = node["plugin-opts"].host
if opts_host then
plugin_opts = plugin_opts .. "obfs-host=" .. opts_host
end
result.plugin_opts = plugin_opts
elseif node.plugin == "v2ray-plugin" then
local plugin_opts = ""
local opts_mode = node["plugin-opts"].mode
local opts_tls = node["plugin-opts"].tls
if opts_tls then
plugin_opts = plugin_opts .. "tls;"
end
local opts_skip_cert_verify = node["plugin-opts"]["skip-cert-verify"]
local opts_host = node["plugin-opts"].host
if opts_host then
plugin_opts = plugin_opts .. "host=" .. opts_host .. ";"
end
local opts_path = node["plugin-opts"].path
local opts_mux = node["plugin-opts"].mux
if node["plugin-opts"].headers then
--todo
end
result.plugin_opts = plugin_opts
end
end
end
elseif node.type == 'ssr' then
if not has_ssr then
log(2, i18n.translatef("Skipping the %s node is due to incompatibility with the %s core program or incorrect node usage type settings.", "SSR", "shadowsocksr-libev"))
return nil
end
result.type = "SSR"
result.method = node.cipher
result.password = node.password
result.obfs = node.obfs
result.protocol = node.protocol
result.obfs_param = node["obfs-param"]
result.protocol_param = node["protocol-param"]
elseif node.type == 'vmess' then
if sub_vmess_type == "sing-box" and has_singbox then
result.type = 'sing-box'
elseif sub_vmess_type == "xray" and has_xray then
result.type = "Xray"
else
log(2, i18n.translatef("Skipping the %s node is due to incompatibility with the %s core program or incorrect node usage type settings.", "VMess", "VMess"))
return nil
end
result.protocol = 'vmess'
result.uuid = node.uuid
result.alter_id = node.alterId
result.security = node.cipher or "auto"
result.tcp_fast_open = node.tfo
result.tls = "0"
if node.tls then
result.tls = "1"
result.tls_serverName = node.servername or ""
local insecure = node["skip-cert-verify"]
result.tls_allowInsecure = insecure and "1" or "0"
if sub_allowinsecure then
result.tls_allowInsecure = "1"
end
end
result.transport = node.network and string.lower(node.network) or "tcp"
if result.type == "sing-box" and result.transport == "raw" then
result.transport = "tcp"
elseif result.type == "Xray" and result.transport == "tcp" then
result.transport = "raw"
end
if result.transport == 'ws' then
local ws_opts = node["ws-opts"]
if ws_opts then
if ws_opts.headers then
result.ws_host = ws_opts.headers.Host or ws_opts.headers.host
end
if ws_opts.path then
result.ws_path = ws_opts.path
if ws_opts["max-early-data"] then
if result.type == "sing-box" then
result.ws_enableEarlyData = "1"
result.ws_maxEarlyData = tonumber(ws_opts["max-early-data"])
result.ws_earlyDataHeaderName = "Sec-WebSocket-Protocol"
elseif result.type == "Xray" then
result.ws_path = result.ws_path .. "?ed=" .. ws_opts["max-early-data"]
end
end
end
end
elseif result.transport == 'h2' then
local h2_opts = node["h2-opts"]
if h2_opts then
if result.type == "sing-box" then
result.http_path = h2_opts.path
result.http_host = h2_opts.host
elseif result.type == "Xray" then
result.xhttp_mode = "stream-one"
result.xhttp_path = h2_opts.path
result.xhttp_host = h2_opts.host
end
end
elseif result.transport == 'grpc' then
local grpc_opts = node["grpc-opts"]
if grpc_opts then
result.grpc_serviceName = grpc_opts["grpc-service-name"]
end
end
elseif node.type == 'vless' then
if sub_vless_type == "sing-box" and has_singbox then
result.type = 'sing-box'
elseif sub_vless_type == "xray" and has_xray then
result.type = "Xray"
else
log(2, i18n.translatef("Skipping the %s node is due to incompatibility with the %s core program or incorrect node usage type settings.", "VLESS", "VLESS"))
return nil
end
result.protocol = "vless"
result.uuid = node.uuid
result.tcp_fast_open = node.tfo
result.encryption = node.cipher or "none"
result.flow = node.flow
result.tls = "0"
if node.tls then
result.tls = "1"
result.tls_serverName = node.servername or ""
local insecure = node["skip-cert-verify"]
result.tls_allowInsecure = insecure and "1" or "0"
if sub_allowinsecure then
result.tls_allowInsecure = "1"
end
end
if node.tls and node["reality-opts"] and node["reality-opts"]["public-key"] then
result.reality = "1"
result.reality_publicKey = (node["reality-opts"] and node["reality-opts"]["public-key"]) or nil
result.reality_shortId = (node["reality-opts"] and node["reality-opts"]["short-id"]) or nil
end
result.transport = node.network and string.lower(node.network) or "tcp"
if result.type == "sing-box" and result.transport == "raw" then
result.transport = "tcp"
elseif result.type == "Xray" and result.transport == "tcp" then
result.transport = "raw"
end
if result.transport == 'ws' then
local ws_opts = node["ws-opts"]
if ws_opts then
if ws_opts.headers then
result.ws_host = ws_opts.headers.Host or ws_opts.headers.host
end
if ws_opts.path then
result.ws_path = ws_opts.path
if ws_opts["max-early-data"] then
if result.type == "sing-box" then
result.ws_enableEarlyData = "1"
result.ws_maxEarlyData = tonumber(ws_opts["max-early-data"])
result.ws_earlyDataHeaderName = "Sec-WebSocket-Protocol"
elseif result.type == "Xray" then
result.ws_path = result.ws_path .. "?ed=" .. ws_opts["max-early-data"]
end
end
end
end
end
elseif node.type == 'trojan' then
if sub_trojan_type == "sing-box" and has_singbox then
result.type = 'sing-box'
result.protocol = 'trojan'
elseif sub_trojan_type == "xray" and has_xray then
result.type = 'Xray'
result.protocol = 'trojan'
else
log(2, i18n.translatef("Skipping the %s node is due to incompatibility with the %s core program or incorrect node usage type settings.", "Trojan", "Trojan"))
return nil
end
result.password = node.password
result.tls = '1'
result.tls_serverName = node.sni or ""
local insecure = node["skip-cert-verify"]
result.tls_allowInsecure = insecure and "1" or "0"
if sub_allowinsecure then
result.tls_allowInsecure = "1"
end
if node.alpn then
--todo
end
if node.network == "grpc" then
if node["grpc-opts"] then
result.grpc_serviceName = node["grpc-opts"]["grpc-service-name"]
end
elseif node.network == "ws" then
local ws_opts = node["ws-opts"]
if ws_opts then
result.ws_path = ws_opts.path
if ws_opts.headers then
result.ws_host = ws_opts.headers.Host or ws_opts.headers.host
end
end
end
elseif node.type == 'anytls' then
if has_singbox then
result.type = 'sing-box'
result.protocol = "anytls"
else
log(2, i18n.translatef("Skip the %s node because the %s core program is not installed.", "AnyTLS", "Sing-Box 1.12"))
return nil
end
result.password = node.password
result.tls = '1'
result.tls_serverName = node.sni or ""
local insecure = node["skip-cert-verify"]
result.tls_allowInsecure = insecure and "1" or "0"
if sub_allowinsecure then
result.tls_allowInsecure = "1"
end
if node["client-fingerprint"] then
result.utls = "1"
result.fingerprint = node["client-fingerprint"]
end
if node.tls and node["reality-opts"] and node["reality-opts"]["public-key"] then
result.reality = "1"
result.reality_publicKey = node["reality-opts"]["public-key"]
result.reality_shortId = node["reality-opts"]["short-id"]
end
if node["disable-reuse"] then
result.anytls_disable_reuse = "1"
end
elseif node.type == 'snell' then
if has_singbox then
result.type = 'sing-box'
result.protocol = "snell"
else
log(2, i18n.translatef("Skip the %s node because the %s core program is not installed.", "Snell", "Sing-Box 1.14"))
return nil
end
result.password = node.password
result.snell_psk = node.psk
result.snell_version = node.version or "4"
if result.snell_version == "4" then
local obfs_opts = node["obfs-opts"] or {}
result.snell_obfs_mode = obfs_opts.mode or "none"
result.snell_obfs_host = obfs_opts.host
else
result.snell_mode = "default"
end
result.snell_reuse = node.reuse and "1" or "0"
end
if not result.remarks or result.remarks == "" then
if result.address and result.port then
result.remarks = result.address .. ':' .. result.port
else
result.remarks = "NULL"
end
end
return result
end
-- Processing Clash data
local function processClashData(content, add_mode, group, sub_cfg)
local results = {}
for i, node in ipairs(content.proxies or {}) do
local result = parseClashNode(node, add_mode, group, sub_cfg)
if result then
table.insert(results, result)
end
end
return results
end
-- Split URI hosts and ports, support IPv6 without a port, and strip paths.
local function parse_host_port(host_port, default_port)
local authority = (host_port or ""):gsub("/.*$", "")
@@ -2038,7 +1699,8 @@ end
local function curl(url, file, ua, mode, hwid)
if not url or url == "" then return 22, 404 end
local curl_args = {
"-fskL", "-w %{http_code}", "--retry 3", "--connect-timeout 3", "-H 'Accept: */*'", "-H 'Accept-Encoding: identity'"
"-fskL", "--retry 3", "--connect-timeout 3", "-H 'Accept: */*'", "-H 'Accept-Encoding: identity'",
"--dump-header -", "-w '\\n%{http_code}'"
}
if ua and ua ~= "" and ua ~= "curl" then
ua = (ua == "passwall2") and ("passwall2/" .. (api.get_version():match("^([^-]+)") or "")) or ua
@@ -2060,7 +1722,15 @@ local function curl(url, file, ua, mode, hwid)
end
luci.sys.call('rm -f "%s"' % cookie_file)
return return_code, tonumber(result)
local http_code, headers = 0, ""
if result and result ~= "" then
local body, code = result:match("^(.-)%s*([0-9]+)$")
if code then
http_code = tonumber(code) or 0
headers = body:gsub("\r", "")
end
end
return return_code, http_code, headers
end
function get_headers()
@@ -2366,12 +2036,12 @@ local function update_node(manual)
end
-- Sing-Box Node Domain resolver
if kkk == "type" and (vvv == "Xray" or vvv == "sing-box") then
if domain_resolver then
if domain_resolver and domain_resolver ~= "" and domain_resolver ~= "disable" then
uci_set(cfgid, "domain_resolver", domain_resolver)
if domain_resolver_dns then
uci_set(cfgid, "domain_resolver_dns", domain_resolver_dns)
elseif domain_resolver_dns_https then
if domain_resolver == "https" and domain_resolver_dns_https then
uci_set(cfgid, "domain_resolver_dns_https", domain_resolver_dns_https)
elseif (domain_resolver == "udp" or domain_resolver == "tcp") and domain_resolver_dns then
uci_set(cfgid, "domain_resolver_dns", domain_resolver_dns)
end
end
if domain_strategy then
@@ -2432,7 +2102,7 @@ local function parse_link(raw, add_mode, group, sub_cfg)
cfgid = sub_cfg[".name"]
end
local keyword_filter = add_mode == "2" and prepare_keyword_filter(sub_cfg)
local nodes, szType, clashTable
local nodes, szType
local node_list = {}
-- ssd appear to be in this format, starting with ssd://.
if raw:find('ssd://') then
@@ -2453,19 +2123,11 @@ local function parse_link(raw, add_mode, group, sub_cfg)
end
nodes = servers
else
-- Try parseYAML, if success, is clash.
local yamlTable = lyaml.load(raw)
if yamlTable and type(yamlTable) == "table" then
-- clash
szType = "clash"
clashTable = yamlTable
-- Formats other than SSD are handled by the existing URI parser.
if add_mode == "1" then
nodes = split(raw, "\n")
else
-- Formats other than ssd
if add_mode == "1" then
nodes = split(raw, "\n")
else
nodes = split(base64Decode(raw):gsub("\r\n", "\n"), "\n")
end
nodes = split(base64Decode(raw):gsub("\r\n", "\n"), "\n")
end
end
@@ -2481,20 +2143,12 @@ local function parse_link(raw, add_mode, group, sub_cfg)
else
tinsert(node_list, node)
end
if add_mode == "2" then
if add_mode == "2" and not subscribe_info_from_headers[cfgid] then
get_subscribe_info(cfgid, node.remarks)
end
end
end
if szType == "clash" and clashTable then
nodes = {}
local clashNodes = processClashData(clashTable, add_mode, group, sub_cfg)
for _, v in ipairs(clashNodes) do
nodeFilter(v)
end
end
for _, v in ipairs(nodes) do
if v and (szType == 'ssd' or not string.match(v, "^%s*$")) then
xpcall(function ()
@@ -2562,6 +2216,7 @@ local execute = function()
local cfgid = value[".name"]
local remark = value.remark or ""
local url = value.url or ""
local headers
local url_is_local
if fs.access(url) then
@@ -2576,7 +2231,7 @@ local execute = function()
log(1, i18n.translatef("Start subscribing: %s", '【' .. remark .. '】' .. url .. ' [' .. result .. ']'))
tmp_file = "/tmp/" .. cfgid
local return_code
return_code, value.http_code = curl(url, tmp_file, ua, access_mode, value.hwid)
return_code, value.http_code, headers = curl(url, tmp_file, ua, access_mode, value.hwid)
if return_code ~= 0 then
fail_list[#fail_list + 1] = value
luci.sys.call("rm -f " .. tmp_file)
@@ -2588,11 +2243,36 @@ local execute = function()
local stdout = f:read("*all")
f:close()
local raw_data = api.trim(stdout)
local auto_dns = value.domain_resolver == nil or value.domain_resolver == ""
local resolver
raw_data, resolver = parseClashNode(raw_data, remark, auto_dns)
if auto_dns and resolver then
-- Only enrich this run's sub_cfg; keep the subscription's Auto setting.
value.domain_resolver = resolver.domain_resolver
value.domain_resolver_dns = resolver.domain_resolver_dns
value.domain_resolver_dns_https = resolver.domain_resolver_dns_https
end
if headers and headers ~= "" then
local info = parse_clash_sub_info(headers)
if info then
-- Response-header account data takes precedence over node remarks.
subscribe_info[cfgid] = info
subscribe_info_from_headers[cfgid] = true
end
end
-- Buffer parsing details until the parsed nodes are known to have changed.
log_buffer = {}
local parsed_nodes = parse_link(raw_data, "2", remark, value)
local old_md5 = value.md5 or ""
local new_md5 = #parsed_nodes > 0 and api.md5_string(jsonStringify(parsed_nodes)) or ""
local md5_content = jsonStringify(parsed_nodes)
if value.domain_resolver and value.domain_resolver ~= "" and value.domain_resolver ~= "disable" then
local protocol = value.domain_resolver
local dns = (protocol == "udp" or protocol == "tcp") and value.domain_resolver_dns or ""
local doh = protocol == "https" and value.domain_resolver_dns_https or ""
md5_content = md5_content .. jsonStringify({protocol, dns or "", doh or ""})
end
local new_md5 = #parsed_nodes > 0 and api.md5_string(md5_content) or ""
-- Skip rewriting nodes only when both nodes and effective DNS are unchanged.
if not manual_sub and new_md5 ~= "" and old_md5 == new_md5 then
log_buffer = nil
log(1, i18n.translatef("Subscription: [%s] No changes, no update required.", remark))
@@ -2656,6 +2336,7 @@ elseif arg[1] == "add" then
local f = assert(io.open("/tmp/links.conf", 'r'))
local raw = f:read('*all')
f:close()
raw = parseClashNode(raw, arg[2])
parse_link(raw, "1", arg[2])
if update_node(1) then uci_save(true) end
luci.sys.call("rm -f /tmp/links.conf")
+2 -2
View File
@@ -14,8 +14,8 @@ MISE_HASH_x86_64:=3832f39c325e343f81fe3d92b2447c5d1a5eea1bc85092bb7b6c2580622264
MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437
PKG_NAME:=mise
PKG_VERSION:=2026.11.3
PKG_RELEASE:=52
PKG_VERSION:=2026.11.4
PKG_RELEASE:=53
PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz
PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/
PKG_HASH:=skip
+2 -1
View File
@@ -8,7 +8,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=wifidog-wiwiz
PKG_VERSION:=20260214
PKG_VERSION:=20261107
PKG_RELEASE=$(PKG_SOURCE_VERSION)
PKG_FIXUP:=autoreconf
@@ -65,6 +65,7 @@ define Package/wifidog-wiwiz/install
$(INSTALL_BIN) ./files/usr/local/hsbuilder/checkauth.sh $(1)/usr/local/hsbuilder/checkauth.sh
$(INSTALL_BIN) ./files/usr/local/hsbuilder/dhcp_portal.sh $(1)/usr/local/hsbuilder/dhcp_portal.sh
$(INSTALL_BIN) ./files/usr/local/hsbuilder/handle_ipv6.sh $(1)/usr/local/hsbuilder/handle_ipv6.sh
$(INSTALL_BIN) ./files/usr/local/hsbuilder/luci_check.sh $(1)/usr/local/hsbuilder/luci_check.sh
$(INSTALL_DATA) ./files/usr/local/hsbuilder/hsbuilder.conf $(1)/usr/local/hsbuilder/hsbuilder.conf
$(INSTALL_DATA) ./files/usr/local/hsbuilder/ver $(1)/usr/local/hsbuilder/ver
+1
View File
@@ -6,4 +6,5 @@ config wiwiz portal
option lan 'br-lan'
option disable_ipv6 '1'
option dhcp_portal '1'
option luci_keepalive '1'
option ver ''
@@ -35,7 +35,7 @@ dhcp_portal.rmempty = false
ver = portal:option(DummyValue, "ver", translate("Plugin Version"), translate("<a href='http://www.wiwiz.com/pinpinwifi/wiwiz-ipk.htm' target='_blank'>Readme</a>"));
m.on_after_commit = function(self)
luci.util.exec("(sleep 3; /usr/local/hsbuilder/dhcp_portal.sh; /usr/local/hsbuilder/handle_ipv6.sh) &")
luci.util.exec("(sleep 3; /usr/local/hsbuilder/luci_check.sh; /usr/local/hsbuilder/dhcp_portal.sh; /usr/local/hsbuilder/handle_ipv6.sh) &")
end
return m
@@ -38,16 +38,74 @@ NORESOLVE="false"
ENVINFO_SENT='0'
log2() {
echo "$1" >&2
echo "$1" >>$LOGFILE
logger -t 'hsbuilder' "$1"
echo "$(date "+%Y-%m-%d %H:%M:%S") $1" >&2
echo "$(date "+%Y-%m-%d %H:%M:%S") $1" >>$LOGFILE
logger -t 'hsbuilder' "$1"
}
log1() {
echo "$1" >>$LOGFILE
echo "$(date "+%Y-%m-%d %H:%M:%S") $1" >>$LOGFILE
logger -t 'hsbuilder' "$1"
}
shrinkLog() {
FILENAME="$1"
MAXLINE="$2"
if [ -f "$FILENAME" ]; then
LINECOUNT=$(wc -l "$FILENAME" 2>/dev/null | cut -d ' ' -f 1)
if [ "$LINECOUNT" -gt "$MAXLINE" ]; then
tail -n "$MAXLINE" "$FILENAME" >"$FILENAME.tmp"
rm -f "$FILENAME"
mv "$FILENAME.tmp" "$FILENAME"
fi
fi
log1 "shrinkLog done. $FILENAME : $LINECOUNT -> $MAXLINE"
}
luciKeepAlive() {
if ! which curl >/dev/null 2>&1; then
log1 "luciKeepAlive: curl not found, skip"
return 1
fi
LUCI_KEEPALIVE=$(uci get wiwiz.portal.luci_keepalive 2>/dev/null)
if [ "$LUCI_KEEPALIVE" != "1" ]; then
return 1
fi
log1 "luciKeepAlive starts"
# check luci url
CHECK_URL="http://127.0.0.1/cgi-bin/luci"
# 1. luck check
# if ! curl -s -m 5 "$CHECK_URL" -o /dev/null; then
# log1 "luciKeepAlive: LuCI HTTP unreachable, restarting..."
# /etc/init.d/uhttpd restart
# /etc/init.d/rpcd restart
# return
# fi
HTTP_CODE=$(curl -s -o /dev/null -m 5 -w "%{http_code}" "$CHECK_URL")
case "$HTTP_CODE" in
000|5*)
log1 "luciKeepAlive: LuCI check code : $HTTP_CODE, restarting..."
/etc/init.d/uhttpd restart
/etc/init.d/rpcd restart
return
;;
esac
# 2. ubus RPC check
if ! ubus call luci getFeatures >/dev/null 2>&1; then
log1 "luciKeepAlive: LuCI ubus RPC failed, restarting..."
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
return
fi
}
doConfig() {
SURL="$1"
NR="$2"
@@ -297,7 +355,7 @@ do
touch $USERBLOCKPORT
touch $DOMAINNAME
touch $IPLIST.lasttime
log1 "hsbuilder.sh $MY_VERSION: $(date "+%Y-%m-%d %H:%M:%S")"
log1 "hsbuilder.sh $MY_VERSION"
AS_HOSTNAME_X=$AS_HOSTNAME
if [ "$AS_HOSTNAME_X" = "" ]; then
@@ -424,7 +482,7 @@ do
log1 "hsbuilder.sh: done."
sleep 3
sleep 5
rm -f $WD_CONF_TMP
rm -f $ADDRLIST
rm -f $IPLIST
@@ -436,6 +494,11 @@ do
sleep 20
/usr/local/hsbuilder/hsbuilder_helper.sh -os openwrt
MOD=$(( $(date +%s) % 5 ))
[ "$MOD" -eq 0 ] && shrinkLog /tmp/hsbuilder.log 5000 #posibility: 20%
[ "$MOD" -eq 0 ] && shrinkLog /tmp/wiwiz_autokick.log 2000
[ "$MOD" -lt 2 ] && luciKeepAlive # posiblity: 40%
if [ "$ENVINFO_SENT" = "0" ]; then
MODEL=$(/usr/local/hsbuilder/getmodel.sh)
DISTRIB_DSC=$(grep -E "^DISTRIB_DESCRIPTION=" /etc/openwrt_release | cut -d'=' -f2 | sed "s/'//g")
@@ -26,7 +26,7 @@ log1() {
getIP() {
_mac="$1"
s=$(cat /proc/net/arp | grep -F "$_mac" | grep -F '0x2' | grep -F "$LANDEV"| awk '{print $1}')
s=$(cat /proc/net/arp | grep -F "$_mac" | grep -F '0x2' | grep -F "$LANDEV"| awk '{print $1}' | tail -n 1)
echo "$s"
}
@@ -0,0 +1,40 @@
#!/bin/sh
# Wiwiz HotSpot Builder Utility
# Copyright wiwiz.com. All rights reserved.
LOGFILE='/tmp/hsbuilder.log'
log1() {
echo "$(date "+%Y-%m-%d %H:%M:%S") $1" >>$LOGFILE
logger -t 'hsbuilder' "$1"
}
check_luci() {
which curl >/dev/null 2>&1 || return 1
# check luci url
CHECK_URL="http://127.0.0.1/cgi-bin/luci"
# 1. luck check
HTTP_CODE=$(curl -s -o /dev/null -m 5 -w "%{http_code}" "$CHECK_URL")
case "$HTTP_CODE" in
000|404|5*)
log1 "luci_check.sh: LuCI check code : $HTTP_CODE !!!"
return 2
;;
esac
# 2. ubus RPC check
if ! ubus call luci getFeatures >/dev/null 2>&1; then
log1 "luci_check.sh: LuCI ubus RPC failed !!!"
return 3
fi
}
if check_luci; then
uci set wiwiz.portal.luci_keepalive='1'
log1 "luci_check.sh: luci_keepalive enabled."
else
uci set wiwiz.portal.luci_keepalive='0'
fi
uci commit wiwiz
+1 -1
View File
@@ -1 +1 @@
3.6.8
3.6.9