mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-08 05:04:21 +08:00
🎉 Sync 2026-11-08 01:01:38
This commit is contained in:
1 parent
f7911ea0fb
commit
3d26c9667a
57 files changed
+4016
-1243
No files matched your search
+2
-2
@@ -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)/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 & 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 ! @ # % ^ & * _ - + =</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 & 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 & 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 & 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 & 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 & 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 & 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 %>
|
||||
+1081
-111
File diff suppressed because it is too large.
Load diff
+35
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 "بدون انقضا"
|
||||
@@ -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 "Бессрочно"
|
||||
@@ -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 "长期有效"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
3.6.8
|
||||
3.6.9
|
||||
Reference in new issue
Block a user