diff --git a/7z/Makefile b/7z/Makefile index 2693c9421..faab4adc1 100644 --- a/7z/Makefile +++ b/7z/Makefile @@ -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)/ diff --git a/autokick-wiwiz/Makefile b/autokick-wiwiz/Makefile index ccdc83507..b328d5fa5 100644 --- a/autokick-wiwiz/Makefile +++ b/autokick-wiwiz/Makefile @@ -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 diff --git a/autokick-wiwiz/files/usr/bin/autokick.sh b/autokick-wiwiz/files/usr/bin/autokick.sh index ca64d7c9f..0a9f9d475 100644 --- a/autokick-wiwiz/files/usr/bin/autokick.sh +++ b/autokick-wiwiz/files/usr/bin/autokick.sh @@ -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 diff --git a/clashoo/Makefile b/clashoo/Makefile index 83140a310..02c797291 100644 --- a/clashoo/Makefile +++ b/clashoo/Makefile @@ -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) diff --git a/fastnet/Makefile b/fastnet/Makefile index abb96f840..5499467be 100644 --- a/fastnet/Makefile +++ b/fastnet/Makefile @@ -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)/ diff --git a/gately/Makefile b/gately/Makefile index 34ae72e51..46e3872cb 100644 --- a/gately/Makefile +++ b/gately/Makefile @@ -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)? diff --git a/honk/Makefile b/honk/Makefile index 7df7d3014..b033c0a11 100644 --- a/honk/Makefile +++ b/honk/Makefile @@ -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 diff --git a/luci-app-amlogic/Makefile b/luci-app-amlogic/Makefile index 0803ecb19..395425d85 100644 --- a/luci-app-amlogic/Makefile +++ b/luci-app-amlogic/Makefile @@ -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 diff --git a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh index e730da90d..cde405efb 100755 --- a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh +++ b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh @@ -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 diff --git a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_kernel.sh b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_kernel.sh index 79e44dce0..b6d9425f6 100755 --- a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_kernel.sh +++ b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_kernel.sh @@ -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='' '$0 ~ pattern { print $0 ""; 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 diff --git a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_plugin.sh b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_plugin.sh index 6ed52bf31..5606f7a01 100755 --- a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_plugin.sh +++ b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_plugin.sh @@ -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 diff --git a/luci-app-easymesh/Makefile b/luci-app-easymesh/Makefile index 318c6cc4d..2e3c9d5c6 100755 --- a/luci-app-easymesh/Makefile +++ b/luci-app-easymesh/Makefile @@ -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 diff --git a/luci-app-easymesh/luasrc/controller/easymesh.lua b/luci-app-easymesh/luasrc/controller/easymesh.lua index 2abe074fa..047c3ae54 100755 --- a/luci-app-easymesh/luasrc/controller/easymesh.lua +++ b/luci-app-easymesh/luasrc/controller/easymesh.lua @@ -1,24 +1,28 @@ -local nixio = require "nixio" -require "nixio.fs" --- Copyright (C) 2021 dz - 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 diff --git a/luci-app-easymesh/luasrc/model/cbi/easymesh.lua b/luci-app-easymesh/luasrc/model/cbi/easymesh.lua deleted file mode 100755 index 921ee1bc8..000000000 --- a/luci-app-easymesh/luasrc/model/cbi/easymesh.lua +++ /dev/null @@ -1,685 +0,0 @@ --- Copyright (C) 2021 dz --- Copyright (C) 2026 Arafat Rahman Zami Mondol --- 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, "", "" .. status_line .. "") -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, - "" .. translate("Wired backhaul details") .. "") - 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. as an 8021q device for each ID. " .. - "If a bridge named br-, br-lan, br-vlan, br-guest, " .. - "or br-iot already exists, it is auto-added to that bridge. " .. - "Otherwise, add the bat0. 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 diff --git a/luci-app-easymesh/luasrc/model/cbi/easymesh/ap_mode.lua b/luci-app-easymesh/luasrc/model/cbi/easymesh/ap_mode.lua new file mode 100644 index 000000000..cb833a169 --- /dev/null +++ b/luci-app-easymesh/luasrc/model/cbi/easymesh/ap_mode.lua @@ -0,0 +1,55 @@ +-- luci-app-easymesh — AP Mode tab +-- Copyright (C) 2026 Arafat Rahman Zami Mondol + +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 diff --git a/luci-app-easymesh/luasrc/model/cbi/easymesh/client_wifi.lua b/luci-app-easymesh/luasrc/model/cbi/easymesh/client_wifi.lua new file mode 100644 index 000000000..e936ad0fd --- /dev/null +++ b/luci-app-easymesh/luasrc/model/cbi/easymesh/client_wifi.lua @@ -0,0 +1,85 @@ +-- luci-app-easymesh — Client WiFi tab +-- Copyright (C) 2026 Arafat Rahman Zami Mondol + +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 diff --git a/luci-app-easymesh/luasrc/model/cbi/easymesh/config.lua b/luci-app-easymesh/luasrc/model/cbi/easymesh/config.lua new file mode 100644 index 000000000..7838d1201 --- /dev/null +++ b/luci-app-easymesh/luasrc/model/cbi/easymesh/config.lua @@ -0,0 +1,154 @@ +-- luci-app-easymesh — Settings tab +-- Copyright (C) 2026 Arafat Rahman Zami Mondol + +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 diff --git a/luci-app-easymesh/luasrc/model/cbi/easymesh/dashboard.lua b/luci-app-easymesh/luasrc/model/cbi/easymesh/dashboard.lua new file mode 100644 index 000000000..ec96e1866 --- /dev/null +++ b/luci-app-easymesh/luasrc/model/cbi/easymesh/dashboard.lua @@ -0,0 +1,124 @@ +-- luci-app-easymesh — Dashboard tab +-- Copyright (C) 2026 Arafat Rahman Zami Mondol + +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, "" .. status_line .. "") +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, + "" .. translate("Wired backhaul details") .. "") + 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 diff --git a/luci-app-easymesh/luasrc/model/cbi/easymesh/helpers.lua b/luci-app-easymesh/luasrc/model/cbi/easymesh/helpers.lua new file mode 100644 index 000000000..fc098e3a1 --- /dev/null +++ b/luci-app-easymesh/luasrc/model/cbi/easymesh/helpers.lua @@ -0,0 +1,245 @@ +-- luci-app-easymesh — shared helper functions +-- Copyright (C) 2026 Arafat Rahman Zami Mondol +-- +-- 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 diff --git a/luci-app-easymesh/luasrc/model/cbi/easymesh/quick_setup.lua b/luci-app-easymesh/luasrc/model/cbi/easymesh/quick_setup.lua new file mode 100644 index 000000000..66335684a --- /dev/null +++ b/luci-app-easymesh/luasrc/model/cbi/easymesh/quick_setup.lua @@ -0,0 +1,120 @@ +-- luci-app-easymesh — Quick Setup tab +-- Copyright (C) 2026 Arafat Rahman Zami Mondol + +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 diff --git a/luci-app-easymesh/luasrc/model/cbi/easymesh/vlans.lua b/luci-app-easymesh/luasrc/model/cbi/easymesh/vlans.lua new file mode 100644 index 000000000..5453e32e9 --- /dev/null +++ b/luci-app-easymesh/luasrc/model/cbi/easymesh/vlans.lua @@ -0,0 +1,92 @@ +-- luci-app-easymesh — VLANs tab +-- Copyright (C) 2026 Arafat Rahman Zami Mondol + +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. 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-, br-lan, br-vlan, " .. + "br-guest, or br-iot already exists, it is auto-added " .. + "to that bridge. Otherwise, add the bat0. 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 diff --git a/luci-app-easymesh/luasrc/view/easymesh/ap_mode_gate.htm b/luci-app-easymesh/luasrc/view/easymesh/ap_mode_gate.htm new file mode 100644 index 000000000..593e7be52 --- /dev/null +++ b/luci-app-easymesh/luasrc/view/easymesh/ap_mode_gate.htm @@ -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 +%> + +
+

<%:AP Mode is not available for this role%>

+ +
+

+ <%:Current mesh role:%> <%= role_label %> +

+

+ <%: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.%> +

+
+ +

<%:To configure AP Mode:%>

+
    +
  1. <%:Go to the Settings tab.%>
  2. +
  3. <%:Set Mesh role to Client or Node.%>
  4. +
  5. <%:Click Save & Apply.%>
  6. +
  7. <%:Return to this tab.%>
  8. +
+
diff --git a/luci-app-easymesh/luasrc/view/easymesh/client_wifi_gate.htm b/luci-app-easymesh/luasrc/view/easymesh/client_wifi_gate.htm new file mode 100644 index 000000000..6271e6a31 --- /dev/null +++ b/luci-app-easymesh/luasrc/view/easymesh/client_wifi_gate.htm @@ -0,0 +1,22 @@ +
+

<%:EasyMesh is not enabled%>

+ +
+

+ <%:Client WiFi settings only apply when EasyMesh is enabled. It is currently off.%> +

+
+ +

<%:To configure Client WiFi:%>

+
    +
  1. <%:Go to the Settings tab.%>
  2. +
  3. <%:Enable EasyMesh.%>
  4. +
  5. <%:Set Mesh role to Server, Client, or Node.%>
  6. +
  7. <%:Click Save & Apply.%>
  8. +
  9. <%:Return to this tab.%>
  10. +
+ +

+ <%:Tip: Quick Setup presets enable EasyMesh and set the role in one click.%> +

+
diff --git a/luci-app-easymesh/luasrc/view/easymesh/group_header.htm b/luci-app-easymesh/luasrc/view/easymesh/group_header.htm new file mode 100644 index 000000000..3b67e1843 --- /dev/null +++ b/luci-app-easymesh/luasrc/view/easymesh/group_header.htm @@ -0,0 +1,6 @@ +<% +local text = self.data or "" +%> +

+ <%= text %> +

diff --git a/luci-app-easymesh/luasrc/view/easymesh/safety_notice.htm b/luci-app-easymesh/luasrc/view/easymesh/safety_notice.htm new file mode 100644 index 000000000..c20686693 --- /dev/null +++ b/luci-app-easymesh/luasrc/view/easymesh/safety_notice.htm @@ -0,0 +1,81 @@ +
+

<%:Safety%>

+ +
+ ⚠️ + <%:Avoid these characters in SSIDs/passwords:%> + " ' ` $ \ | + <%:— plus newlines and em/en dashes:%> + (—, –). + <%:They break this shell-based app's sync.%> +
+ +
+ + <%:Click for full safety rules%> + + +
+
+ <%:✅ Do%> +
    +
  • <%:Use only letters, numbers, hyphens, and underscores in SSIDs.%>
  • +
  • <%:Keep SSIDs 32 characters or fewer.%>
  • +
  • <%:Use ASCII characters if SSID sync is enabled.%>
  • +
  • <%:Back up config before every change.%>
  • +
  • <%:Test one client node at a time when changing settings.%>
  • +
+
+ +
+ <%:❌ Don't%> +
    +
  • <%:Never use quotes, backticks, dollar signs, or backslashes in SSIDs/passwords.%>
  • +
  • <%:Never change SSID while client nodes are offline.%>
  • +
  • <%:Never edit a synced node's SSID manually.%>
  • +
  • <%:Never run luci-app-easymesh alongside meshd (OMM).%>
  • +
  • <%:Never set sync interval below 10 seconds.%>
  • +
+
+
+ +
+ + <%:Field-by-field safety reference%> + + + + + + + + + + + + + + + + + + + + + + + +
<%:Field%><%:Safe%><%:Unsafe%>
<%:Mesh ID%>A-Z a-z 0-9 _ -" ' ` $ \ |
<%:Password%>A-Z a-z 0-9 ! @ # % ^ & * _ - + =" ' ` $ \ | + newline
<%:SSID%>A-Z a-z 0-9 _ - space" ' ` $ \ |
<%:VLAN IDs%>0-9 ,<%:everything else%>
+
+ +
+ <%: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.%> +
+
+
diff --git a/luci-app-easymesh/luasrc/view/easymesh/vlan_guide.htm b/luci-app-easymesh/luasrc/view/easymesh/vlan_guide.htm new file mode 100644 index 000000000..628be8c70 --- /dev/null +++ b/luci-app-easymesh/luasrc/view/easymesh/vlan_guide.htm @@ -0,0 +1,458 @@ +
+ + 📘 <%:VLAN Guide — click to expand%> + + +
+ +

<%: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.%>

+ +
+🛠️ <%:Why use VLAN?%> +
    +
  • <%:Security:%> <%:Keep sensitive departments (Finance, HR) separate from the general network.%>
  • +
  • <%:Traffic management:%> <%:Reduce broadcast traffic and improve speed.%>
  • +
  • <%:Easy management:%> <%:Move a device without reconfiguring its network.%>
  • +
  • <%:Cost efficiency:%> <%:No extra switches or routers needed for separate networks.%>
  • +
+
+ +
+⚙️ <%:How does it work?%> +

<%: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.%>

+
+ +
+🔍 <%:First check: DSA or swconfig?%> +

<%:OpenWrt has two switch architectures. The VLAN steps below only apply to DSA.%>

+
    +
  • <%:If your menu shows%> Network → Switch — <%:your router uses the older swconfig.%>
  • +
  • <%:If your menu shows%> Network → Interfaces → Devices <%:with "Bridge VLAN filtering" — your router uses DSA.%>
  • +
+

<%: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.%>

+

<%:Note: some single-port travel routers do not support VLAN filtering at all.%>

+
+ +
+📂 <%:Types of VLAN (detailed)%> + +
+1. <%:Port-based VLAN%> +

<%:The simplest and most common type. Physical switch ports are grouped into VLAN IDs.%>

+

<%:How it works:%> <%: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.%>

+

<%:Benefit:%> <%:Simple and predictable. Every port has a fixed VLAN.%>

+

<%:Limitation:%> <%:If a user moves their computer to a different port, the VLAN changes. This is the main drawback.%>

+
+ +
+2. <%:Tagged VLAN (802.1Q)%> +

<%: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".%>

+

<%:How it works:%> <%: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.%>

+

<%:Benefit:%> <%:One cable carries traffic for many VLANs, saving switch ports and cabling.%>

+

<%:Limitation:%> <%:Both ends must support 802.1Q tagging. Unmanaged switches typically strip tags and break trunk links.%>

+
+ +
+3. <%:MAC-based VLAN%> +

<%:The VLAN is assigned by the device's MAC address instead of by port.%>

+

<%:Benefit:%> <%:Whatever port the laptop plugs into, the switch recognizes its MAC address and puts it on the correct VLAN automatically.%>

+

<%:Limitation:%> <%:Requires managing a MAC-to-VLAN table. Impractical for networks with many devices or BYOD users.%>

+
+ +
+4. <%:Protocol-based VLAN%> +

<%: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.%>

+

<%:Use case:%> <%:Legacy enterprise networks separating IP traffic from non-IP protocols.%>

+

<%:Limitation:%> <%:Rarely used today. Modern networks route by subnet instead.%>

+
+ +
+ + + +
+💻 <%:OpenWrt Configuration Steps%> + +

<%:Example: create VLAN 20 for a Guest / IoT network.%>

+ +
+⚠️ <%:Before you start:%> +<%: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.%> +
+ +
+<%:Step 1 — Enable VLAN filtering on the bridge%> +
    +
  1. <%:Go to%> Network → Interfaces → Devices.
  2. +
  3. <%:Find%> br-lan <%:and click%> Configure.
  4. +
  5. <%:Open the%> Bridge VLAN filtering <%:tab and tick%> Enable VLAN filtering.
  6. +
+
+ +
+<%:Step 2 — Assign ports%> +
    +
  1. <%:Click%> Add.
  2. +
  3. VLAN ID: <%:enter the ID (e.g.%> 20).
  4. +
  5. Local: <%:tick — attaches the router's CPU to this VLAN.%>
  6. +
  7. Ports Configuration: +
      +
    • <%:Untagged (Access port):%> <%:select%> egress untagged <%:and tick%> PVID.
    • +
    • <%:Tagged (Trunk):%> <%:select%> egress tagged.
    • +
    +
  8. +
+

⚠️ <%:Default VLAN 1:%> <%: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%> off. <%:Otherwise the port still belongs to VLAN 1.%>

+

ℹ️ <%:Optional — CPU on VLAN 1:%> <%:If you do not want the router reachable on VLAN 1, set the CPU port's VLAN 1 to%> off <%:as well.%>

+

<%:Click%> Save.

+
+ +
+<%:Step 3 — Create interface and IP%> +
    +
  1. <%:Go to%> Interfaces → Add new interface.
  2. +
  3. Name: <%:e.g.%> GUEST.
  4. +
  5. Protocol: Static address.
  6. +
  7. Device: <%:select the VLAN device. Usually%> br-lan.20, <%:but it will match your bridge name (e.g.%> br-guest.20).
  8. +
  9. <%:Click%> Create interface.
  10. +
  11. <%:Set an IP block (e.g.%> 192.168.20.1 / 255.255.255.0).
  12. +
  13. <%:Open the%> DHCP Server <%:tab and click%> Setup DHCP Server.
  14. +
  15. <%:Verify the DHCP range, lease time, and that the interface is not ignored.%>
  16. +
+
+ +
+<%:Step 3.5 — Test the VLAN before continuing%> +
    +
  1. <%:Connect a laptop to an untagged VLAN 20 port.%>
  2. +
  3. <%:Check its IP — should be in the%> 192.168.20.x <%:range.%>
  4. +
  5. <%:If it gets%> 192.168.1.x <%:instead, your untagged/PVID settings need fixing.%>
  6. +
+

<%:Only after this test passes, proceed to firewall zone configuration.%>

+
+ +
+<%:Step 4 — Firewall zone (with DHCP/DNS fix)%> +
    +
  1. <%:Inside the interface config, open%> Firewall Settings.
  2. +
  3. <%:Create a zone named%> guest_zone.
  4. +
  5. <%:Set%> Input <%:to%> REJECT, Output <%:to%> ACCEPT, Forward <%:to%> REJECT.
  6. +
  7. <%:In%> Allow forward to destination zones, <%:tick only%> wan. <%:Never tick%> lan.
  8. +
+

⚠️ <%:If Input is REJECT, guests get no DHCP/DNS.%> <%:Add two Traffic Rules with these exact fields:%>

+
    +
  • <%:Rule 1 (DHCP):%> Source zone guest_zone, Destination zone Device (input), Protocol UDP, Destination port 67-68, Action accept.
  • +
  • <%:Rule 2 (DNS):%> Source zone guest_zone, Destination zone Device (input), Protocol TCP+UDP, Destination port 53, Action accept.
  • +
+

<%:Without%> Device (input) <%:as the destination zone, the rule will not match traffic addressed to the router itself.%>

+

<%:Alternatively, set Input to%> ACCEPT <%:— simpler but less secure.%>

+

<%:Ensure the WAN zone has%> Masquerading <%:and%> MSS clamping <%:enabled.%>

+

<%:Click%> Save & Apply.

+
+ +
+⚠️ <%:Lockout warning:%> +<%:Never assign the port you are connected through to a Tagged or different VLAN. You will lock yourself out.%> +
+ +
+ + + +
+📶 <%:Tagging a Wi-Fi SSID to a VLAN%> + +
+⚠️ <%:Do this over a wired LAN connection%> <%:— not the Wi-Fi you are modifying. Otherwise you disconnect yourself mid-configuration.%> +
+ +

<%:You do not tag the SSID directly. You attach the wireless interface to the VLAN's network interface.%>

+ +
+<%:Step 1 — Create a new Wi-Fi interface%> +
    +
  1. <%:Go to%> Network → Wireless.
  2. +
  3. <%:Click%> Add <%:next to the radio.%>
  4. +
  5. Mode: Access Point.
  6. +
  7. ESSID: <%:e.g.%> My-Guest-WiFi.
  8. +
  9. <%:Set a password (WPA2-PSK or WPA3-SAE).%>
  10. +
+
+ +
+<%:Step 2 — Attach Wi-Fi to the VLAN interface%> +
    +
  1. <%:Before saving, open%> Interface Configuration → Interface Settings.
  2. +
  3. <%:Untick%> lan.
  4. +
  5. <%:Tick your VLAN interface (e.g.%> GUEST).
  6. +
  7. <%:Click%> Save.
  8. +
+
+ +
+<%:Step 3 — Apply and test%> +
    +
  1. <%:Click%> Save & Apply.
  2. +
  3. <%:Connect a phone or laptop to the new Wi-Fi.%>
  4. +
  5. <%:Check its IP. If it comes from the VLAN block (e.g.%> 192.168.20.X), <%:tagging worked.%>
  6. +
+
+ +
+ +
+🛡️ <%:Client Isolation (Guest Wi-Fi security)%> + +

<%:Prevents devices on the same Guest Wi-Fi from reaching each other. Blocks malware spread between guests.%>

+ +
+<%:Step 1 — Wi-Fi: isolate clients%> +
    +
  1. <%:Go to%> Network → Wireless.
  2. +
  3. <%:Edit the Guest SSID.%>
  4. +
  5. <%:Open%> Advanced Settings.
  6. +
  7. <%:Tick%> Isolate clients.
  8. +
  9. <%:Click%> Save.
  10. +
+
+ +
+<%:Step 2 — Isolate Ethernet ports (if any)%> +

<%: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.%>

+

<%:If you have Guest Ethernet ports, isolate them in the Guest bridge (not%> br-lan):

+
    +
  1. <%:Go to%> Network → Interfaces → Devices.
  2. +
  3. <%:Configure the guest bridge.%>
  4. +
  5. <%:Open the%> Bridge ports <%:tab and click the gear next to each guest Ethernet port.%>
  6. +
  7. <%:Tick%> Isolate.
  8. +
  9. <%:Click%> Save.
  10. +
+
+ +
+<%:Step 3 — Apply and test%> +
    +
  1. <%:Click%> Save & Apply.
  2. +
  3. <%:Connect two devices to Guest Wi-Fi.%>
  4. +
  5. <%:From the second device, try%> ping <%:or%> arp -a <%:to the first.%>
  6. +
  7. <%:A failed ping usually means isolation works. Some devices block ping by their own firewall — check with a real connection instead.%>
  8. +
+
+ +
+ + + +
+📖 <%:Term definitions for new users%> + +
+VLAN ID +

<%:A number from 1 to 4094 identifying which virtual network a device belongs to.%>

+

<%:Reserved values:%> <%:VLAN 0 and VLAN 4095 are reserved. Avoid VLAN 1 for user or guest networks.%>

+

<%:Analogy:%> <%:Room numbers in a school — Class 5, Class 6, Class 7.%>

+
+ +
+<%:Access Port%> +

<%:A switch port dedicated to a single VLAN. Anything plugged in joins that VLAN only.%>

+

<%:Analogy:%> <%:One classroom's door — you enter and you are in that room.%>

+
+ +
+<%:Trunk Port%> +

<%:A port carrying multiple VLANs over one link. Used for switch-to-switch or switch-to-router connections.%>

+

<%:Analogy:%> <%:A highway shared by traffic from several cities.%>

+
+ +
+<%:Tagged VLAN%> +

<%:When multiple VLANs share a trunk, each packet carries a digital tag identifying its VLAN.%>

+

<%:Analogy:%> <%:Courier parcels labelled Dhaka / Chittagong / Sylhet.%>

+
+ +

<%:Comparison table%>

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
<%:Term%><%:Analogy%><%:Main function%>
VLAN ID<%:Classroom number%><%:Gives the network a separate identity%>
<%:Access Port%><%:One classroom door%><%:Connects a single VLAN device%>
<%:Trunk Port%><%:School main gate / highway%><%:Carries multiple VLANs over one cable%>
<%:Tagged VLAN%><%:Parcel address sticker%><%:Marks which VLAN a packet belongs to%>
+ +
+ +
+🪵 <%:Inter-VLAN Routing (Router-on-a-Stick)%> + +

<%: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.%>

+ +
+ℹ️ <%:OpenWrt vs Cisco:%> +<%:On OpenWrt, inter-VLAN routing happens automatically through firewall zone forwarding. You do NOT create sub-interfaces like%> gigabitethernet 0/1.10 <%:as you would on Cisco. The Cisco example below is educational only.%> +
+ +
+<%:Why the name "Router-on-a-Stick"?%> +

<%: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.%>

+
+ +
+<%:How it works — step by step%> +
    +
  1. <%:A PC on VLAN 10 wants to send data to a PC on VLAN 20.%>
  2. +
  3. <%:The switch sees the destination is in another VLAN. It tags the packet with VLAN 10 and sends it over the trunk port.%>
  4. +
  5. <%:The router receives the packet on its VLAN 10 sub-interface.%>
  6. +
  7. <%:The router checks its routing table and decides to send it to VLAN 20.%>
  8. +
  9. <%:The router strips the old tag and applies a new VLAN 20 tag. It sends the packet back over the same cable.%>
  10. +
  11. <%:The switch reads the new tag and delivers the packet to the VLAN 20 PC.%>
  12. +
+
+ +
+<%:Cisco configuration example%> +

<%:Switch side — make the router-facing port a trunk:%>

+
Switch(config)# interface gigabitethernet 0/1
+Switch(config-if)# switchport trunk encapsulation dot1q
+Switch(config-if)# switchport mode trunk
+Switch(config-if)# exit
+

<%:Router side — create sub-interfaces:%>

+
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
+

<%:End devices:%> <%:set their default gateway to the matching sub-interface IP (e.g.%> 192.168.10.1 <%:for VLAN 10,%> 192.168.20.1 <%:for VLAN 20).%>

+

<%:On older switches you may need%> switchport trunk encapsulation dot1q <%:before%> switchport mode trunk. <%:All referenced VLANs must already exist on the switch.%>

+
+ +
+<%:Pros and cons%> + + + + + + + + + + + + + +
<%:Pros%><%:Cons%>
<%:Lower cost — one router port, one cable.%><%:Bandwidth bottleneck — all VLANs share the same cable.%>
<%:Easy setup for small and medium networks.%><%:Single point of failure — a broken cable or port stops all inter-VLAN traffic.%>
+
+ +
+ +
+🔥 <%:Zone Forwarding — LAN to Guest (one-way)%> + +

<%:Allow the LAN to reach the Guest network, but not vice versa.%>

+ +
    +
  1. <%:Go to%> Network → Firewall → Zones.
  2. +
  3. <%:Click%> Edit <%:on the%> lan <%:zone.%>
  4. +
  5. <%:Scroll to%> Inter-Zone Forwarding.
  6. +
  7. <%:In%> Allow forward to destination zones, <%:tick%> guest_zone.
  8. +
  9. <%:Click%> Save & Apply.
  10. +
+

⚠️ <%:Never enable Guest → LAN.%> <%:Leaving it disabled keeps the main network protected.%>

+
+ +
+🎯 <%:Allow only one Guest device (Traffic Rules)%> + +

<%:Instead of opening the whole Guest zone, allow only a specific device such as a shared printer.%>

+ +
    +
  1. <%:Go to%> Network → Firewall → Traffic Rules.
  2. +
  3. <%:Click%> Add new forward rule.
  4. +
  5. Name: <%:e.g.%> Allow-LAN-to-Guest-Printer.
  6. +
  7. Protocol: TCP.
  8. +
  9. Source zone: lan.
  10. +
  11. Destination zone: guest_zone.
  12. +
  13. Destination IP: <%:the printer's IP (e.g.%> 192.168.20.50).
  14. +
  15. Destination port: 9100.
  16. +
  17. Action: accept.
  18. +
  19. <%:Click%> Save & Apply.
  20. +
+ +
+⚠️ <%:Check for conflicts:%> +<%:If you already enabled zone-level%> lan → guest_zone, <%:this traffic rule is redundant. Untick the zone-level forward first.%> +
+ +
+ +
+📌 <%:Static Lease (IP reservation)%> + +

<%:If a device's IP changes, firewall rules targeting it stop working. Reserve its IP so it stays fixed.%>

+ +
    +
  1. <%:Go to%> Network → DHCP and DNS → Static Leases.
  2. +
  3. <%:Click%> Add.
  4. +
  5. Hostname: <%:e.g.%> Guest-Printer.
  6. +
  7. MAC-Address: <%:pick from the dropdown.%>
  8. +
  9. IPv4-Address: <%:the fixed IP (must be inside the VLAN's subnet and outside the dynamic DHCP pool, e.g.%> 192.168.20.50).
  10. +
  11. Lease time: <%:leave empty or set%> infinite.
  12. +
  13. <%:Click%> Save & Apply.
  14. +
+ +

<%:Pro tip:%> <%:Restart the device (or reconnect it) so it requests a fresh lease and picks up the reserved IP.%>

+ +
+ +
+🛟 <%:Anti-lockout & rollback%> + +

<%:If a VLAN change locks you out, use these tools.%>

+ +
    +
  1. <%:LuCI shows a confirmation countdown after applying network changes. If you lose access, do nothing — after about 90 seconds it reverts automatically.%>
  2. +
  3. <%:This app has its own safety toggle.%> <%:Safe apply%> <%:above snapshots the network config before every apply, so you can restore with one click.%>
  4. +
  5. <%:If you get locked out mid-config, connect via serial console or another VLAN and run the restore command.%>
  6. +
+ +

<%:Restore command:%>

+
cp /etc/easymesh/network.bak /etc/config/network
+/etc/init.d/network reload
+ +

<%:Note: The path%> /etc/easymesh/network.bak <%:is specific to this app. On a plain OpenWrt system with a manual backup, use your own path (e.g.%> /etc/config/network.bak).

+ +

<%:If reload does not restore access, reboot.%>

+ +
+ +
+
diff --git a/luci-app-easymesh/luasrc/view/easymesh/vlan_status.htm b/luci-app-easymesh/luasrc/view/easymesh/vlan_status.htm new file mode 100644 index 000000000..791fc17bb --- /dev/null +++ b/luci-app-easymesh/luasrc/view/easymesh/vlan_status.htm @@ -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 %> +
+ ⚠️ <%:Auto-rollback is armed%>
+ <%:A VLAN change was just applied. If you do not click%> + <%:Confirm VLAN Change%> + <%:within 90 seconds, the previous network config will be restored.%> +
+<% end %> + +<% if has_backup then %> +
+ <%:Network backup available:%> <%= backup_ts %> +
+<% else %> +
+ <%:No network backup yet. A snapshot is taken automatically on the next Save & Apply.%> +
+<% end %> diff --git a/luci-app-easymesh/po/bn_BD/easymesh.po b/luci-app-easymesh/po/bn_BD/easymesh.po index 98a4f757b..f948b572f 100644 --- a/luci-app-easymesh/po/bn_BD/easymesh.po +++ b/luci-app-easymesh/po/bn_BD/easymesh.po @@ -16,10 +16,13 @@ msgstr "" # Main app title & description # ========================================================= msgid "EasyMesh" -msgstr "EasyMesh" +msgstr "ইজিমেশ" msgid "Configure a Batman-adv mesh network." -msgstr "একটি Batman-adv মেশ নেটওয়ার্ক কনফিগার করুন।" +msgstr "একটি Batman-adv মেশ সংযোগ বিন্যস্ত করুন।" + +msgid "configure" +msgstr "বিন্যস্ত" # ========================================================= # Dashboard status @@ -28,7 +31,7 @@ msgid "Active mesh links" msgstr "সক্রিয় মেশ সংযোগ" msgid "Interface" -msgstr "ইন্টারফেস" +msgstr "সংযোগ বিন্দু" msgid "Neighbor MAC" msgstr "প্রতিবেশী MAC" @@ -37,7 +40,7 @@ msgid "Last seen" msgstr "সর্বশেষ দেখা" msgid "Latency" -msgstr "লেটেন্সি" +msgstr "বিলম্বকাল" msgid "RX rate" msgstr "RX হার" @@ -49,7 +52,7 @@ msgstr "TX হার" # Wired backhaul details # ========================================================= msgid "Wired backhaul details" -msgstr "তারযুক্ত ব্যাকহল বিস্তারিত" +msgstr "তারযুক্ত সংযোগ-ভিত্তি বিস্তারিত" msgid "State" msgstr "অবস্থা" @@ -61,37 +64,37 @@ msgid "Link speed" msgstr "সংযোগ গতি" msgid "Priority weight" -msgstr "অগ্রাধিকার ওজন" +msgstr "অগ্রাধিকার ওজন সমন্বয়" msgid "In LAN bridge" -msgstr "LAN ব্রিজে" +msgstr "LAN সংযোগসেতু" # ========================================================= # Wired port picker # ========================================================= msgid "Wired backhaul port" -msgstr "তারযুক্ত ব্যাকহল পোর্ট" +msgstr "তারযুক্ত সংযোগ-ভিত্তি পোর্ট" msgid "Preferred wired port" msgstr "পছন্দের তারযুক্ত পোর্ট" msgid "(none — wired presets will skip)" -msgstr "(কোনোটি নয় — তারযুক্ত প্রিসেট এড়িয়ে যাবে)" +msgstr "(কোনোটি নয় — তারযুক্ত পূর্ববিন্যাস এড়িয়ে যাবে)" # ========================================================= # Boot-time reliability / Cron # ========================================================= msgid "Boot-time reliability" -msgstr "বুট-সময় নির্ভরযোগ্যতা" +msgstr "চালু-র সময় নির্ভরযোগ্যতা" msgid "Enable cron fallback" -msgstr "cron ফলব্যাক সক্রিয় করুন" +msgstr "সঙ্কল্পিত কাজ পিছু হটা সক্রিয় করুন" # ========================================================= -# Link priority weights (new) +# Link priority weights # ========================================================= msgid "Link priority weights" -msgstr "সংযোগ অগ্রাধিকার ওজন" +msgstr "সংযোগ অগ্রাধিকার ওজন সমন্বয়" msgid "Wired (Kbit/s)" msgstr "তারযুক্ত (Kbit/s)" @@ -106,13 +109,13 @@ msgid "2.4 GHz wireless (Kbit/s)" msgstr "2.4 GHz বেতার (Kbit/s)" # ========================================================= -# Mesh VLANs (new) +# Mesh VLANs # ========================================================= msgid "Mesh VLANs" -msgstr "মেশ VLAN" +msgstr "মেশ VLAN-আভাসী স্থানীয় সংযোগ" msgid "VLAN IDs (comma-separated)" -msgstr "VLAN ID (কমা-বিভক্ত)" +msgstr "VLAN ID-আভাসী স্থানীয় সংযোগ শনাক্তকারী (কমা-বিভক্ত)" # ========================================================= # Quick Setup @@ -121,116 +124,116 @@ msgid "Quick Setup" msgstr "দ্রুত সেটআপ" msgid "1. Server — 5 GHz wireless only" -msgstr "১. সার্ভার — শুধু 5 GHz বেতার" +msgstr "১. প্রদানকারী — শুধু 5 GHz বেতার" msgid "2. Server — 2.4 GHz wireless only" -msgstr "২. সার্ভার — শুধু 2.4 GHz বেতার" +msgstr "২. প্রদানকারী — শুধু 2.4 GHz বেতার" msgid "3. Server — Dual-band wireless only" -msgstr "৩. সার্ভার — শুধু দ্বি-ব্যান্ড বেতার" +msgstr "৩. প্রদানকারী — শুধু দ্বি তরঙ্গ বেতার" msgid "4. Server — Wired backhaul only" -msgstr "৪. সার্ভার — শুধু তারযুক্ত ব্যাকহল" +msgstr "৪. প্রদানকারী — শুধু তারযুক্ত সংযোগ-ভিত্তি" msgid "5. Server — 5 GHz + Wired" -msgstr "৫. সার্ভার — 5 GHz + তারযুক্ত" +msgstr "৫. প্রদানকারী — 5 GHz + তারযুক্ত" msgid "6. Server — 2.4 GHz + Wired" -msgstr "৬. সার্ভার — 2.4 GHz + তারযুক্ত" +msgstr "৬. প্রদানকারী — 2.4 GHz + তারযুক্ত" msgid "7. Server — Dual-band + Wired" -msgstr "৭. সার্ভার — দ্বি-ব্যান্ড + তারযুক্ত" +msgstr "৭. প্রদানকারী — দ্বি তরঙ্গ + তারযুক্ত" msgid "8. Client — 5 GHz wireless only" -msgstr "৮. ক্লায়েন্ট — শুধু 5 GHz বেতার" +msgstr "৮. গ্রাহক — শুধু 5 GHz বেতার" msgid "9. Client — 2.4 GHz wireless only" -msgstr "৯. ক্লায়েন্ট — শুধু 2.4 GHz বেতার" +msgstr "৯. গ্রাহক — শুধু 2.4 GHz বেতার" msgid "10. Client — Dual-band wireless only" -msgstr "১০. ক্লায়েন্ট — শুধু দ্বি-ব্যান্ড বেতার" +msgstr "১০. গ্রাহক — শুধু দ্বি তরঙ্গ বেতার" msgid "11. Client — Wired backhaul only" -msgstr "১১. ক্লায়েন্ট — শুধু তারযুক্ত ব্যাকহল" +msgstr "১১. গ্রাহক — শুধু তারযুক্ত সংযোগ-ভিত্তি" msgid "12. Client — 5 GHz + Wired fallback" -msgstr "১২. ক্লায়েন্ট — 5 GHz + তারযুক্ত ফলব্যাক" +msgstr "১২. গ্রাহক — 5 GHz + তারযুক্ত ফলব্যাক" msgid "13. Client — 2.4 GHz + Wired fallback" -msgstr "১৩. ক্লায়েন্ট — 2.4 GHz + তারযুক্ত ফলব্যাক" +msgstr "১৩. গ্রাহক — 2.4 GHz + তারযুক্ত ফলব্যাক" msgid "14. Client — Dual-band + Wired fallback" -msgstr "১৪. ক্লায়েন্ট — দ্বি-ব্যান্ড + তারযুক্ত ফলব্যাক" +msgstr "১৪. গ্রাহক — দ্বি তরঙ্গ + তারযুক্ত ফলব্যাক" msgid "15. Node — 5 GHz relay" -msgstr "১৫. নোড — 5 GHz রিলে" +msgstr "১৫. মধ্যবর্তী-সংযোগ — 5 GHz পুনর্প্রেরণ" msgid "16. Node — Dual-band relay" -msgstr "১৬. নোড — দ্বি-ব্যান্ড রিলে" +msgstr "১৬. মধ্যবর্তী-সংযোগ — দ্বি তরঙ্গ পুনর্প্রেরণ" msgid "17. Node — Wired relay only" -msgstr "১৭. নোড — শুধু তারযুক্ত রিলে" +msgstr "১৭. মধ্যবর্তী-সংযোগ — শুধু তারযুক্ত পুনর্প্রেরণ" msgid "18. Detach wired backhaul" -msgstr "১৮. তারযুক্ত ব্যাকহল বিচ্ছিন্ন করুন" +msgstr "১৮. তারযুক্ত সংযোগ-ভিত্তি বিচ্ছিন্ন করুন" msgid "19. Disable mesh" msgstr "১৯. মেশ বন্ধ করুন" # Preset descriptions msgid "[SERVER] Gateway. Extenders join via 5 GHz mesh only." -msgstr "[সার্ভার] গেটওয়ে। এক্সটেন্ডারগুলি শুধু 5 GHz মেশ দিয়ে যুক্ত হবে।" +msgstr "[প্রদানকারী] গেটওয়ে। এক্সটেন্ডারগুলি শুধু 5 GHz মেশ দিয়ে যুক্ত হবে।" msgid "[SERVER] Extenders join via 2.4 GHz (longer range)." -msgstr "[সার্ভার] এক্সটেন্ডারগুলি 2.4 GHz দিয়ে যুক্ত হবে (দীর্ঘ পরিসর)।" +msgstr "[প্রদানকারী] এক্সটেন্ডারগুলি 2.4 GHz দিয়ে যুক্ত হবে (দীর্ঘ পরিসর)।" msgid "[SERVER] Both radios carry backhaul." -msgstr "[সার্ভার] উভয় রেডিও ব্যাকহল বহন করবে।" +msgstr "[প্রদানকারী] উভয় রেডিও সংযোগ-ভিত্তি বহন করবে।" msgid "[SERVER] No wireless mesh — extenders connect by cable." -msgstr "[সার্ভার] কোনো বেতার মেশ নেই — এক্সটেন্ডারগুলি কেবল দিয়ে যুক্ত হবে।" +msgstr "[প্রদানকারী] কোনো বেতার মেশ নেই — এক্সটেন্ডারগুলি কেবল দিয়ে যুক্ত হবে।" msgid "[SERVER] 5 GHz mesh primary, Ethernet parallel." -msgstr "[সার্ভার] 5 GHz মেশ প্রাথমিক, ইথারনেট সমান্তরাল।" +msgstr "[প্রদানকারী] 5 GHz মেশ প্রাথমিক, ইথারনেট সমান্তরাল।" msgid "[SERVER] 2.4 GHz mesh plus Ethernet." -msgstr "[সার্ভার] 2.4 GHz মেশ এবং ইথারনেট।" +msgstr "[প্রদানকারী] 2.4 GHz মেশ এবং ইথারনেট।" msgid "[SERVER] Maximum redundancy. Both radios + Ethernet." -msgstr "[সার্ভার] সর্বোচ্চ রিডান্ডেন্সি। উভয় রেডিও + ইথারনেট।" +msgstr "[প্রদানকারী] সর্বোচ্চ উদ্বৃত্ততা । উভয় রেডিও + ইথারনেট।" msgid "[CLIENT] Extender via 5 GHz. No cable." -msgstr "[ক্লায়েন্ট] 5 GHz দিয়ে এক্সটেন্ডার। কেবল নেই।" +msgstr "[গ্রাহক] 5 GHz দিয়ে এক্সটেন্ডার। কেবল নেই।" msgid "[CLIENT] Extender via 2.4 GHz." -msgstr "[ক্লায়েন্ট] 2.4 GHz দিয়ে এক্সটেন্ডার।" +msgstr "[গ্রাহক] 2.4 GHz দিয়ে এক্সটেন্ডার।" msgid "[CLIENT] Extender via both radios." -msgstr "[ক্লায়েন্ট] উভয় রেডিও দিয়ে এক্সটেন্ডার।" +msgstr "[গ্রাহক] উভয় রেডিও দিয়ে এক্সটেন্ডার।" msgid "[CLIENT] Cable only, no wireless mesh." -msgstr "[ক্লায়েন্ট] শুধু কেবল, কোনো বেতার মেশ নেই।" +msgstr "[গ্রাহক] শুধু কেবল, কোনো বেতার মেশ নেই।" msgid "[CLIENT] 5 GHz primary, Ethernet fallback." -msgstr "[ক্লায়েন্ট] 5 GHz প্রাথমিক, ইথারনেট ফলব্যাক।" +msgstr "[গ্রাহক] 5 GHz প্রাথমিক, ইথারনেট ফলব্যাক।" msgid "[CLIENT] 2.4 GHz primary, Ethernet fallback." -msgstr "[ক্লায়েন্ট] 2.4 GHz প্রাথমিক, ইথারনেট ফলব্যাক।" +msgstr "[গ্রাহক] 2.4 GHz প্রাথমিক, ইথারনেট ফলব্যাক।" msgid "[CLIENT] Both radios + Ethernet. Maximum redundancy." -msgstr "[ক্লায়েন্ট] উভয় রেডিও + ইথারনেট। সর্বোচ্চ রিডান্ডেন্সি।" +msgstr "[গ্রাহক] উভয় রেডিও + ইথারনেট। সর্বোচ্চ রিডান্ডেন্সি।" msgid "[NODE] Relay only, 5 GHz. No client SSID." -msgstr "[নোড] শুধু রিলে, 5 GHz। কোনো ক্লায়েন্ট SSID নেই।" +msgstr "[মধ্যবর্তী-সংযোগ] শুধু পুনর্প্রেরণ, 5 GHz। কোনো গ্রাহক SSID-বেতার নাম নেই।" msgid "[NODE] Relay only, both radios." -msgstr "[নোড] শুধু রিলে, উভয় রেডিও।" +msgstr "[মধ্যবর্তী-সংযোগ] শুধু পুনর্প্রেরণ, উভয় রেডিও।" msgid "[NODE] Relay via cable only. No wireless." -msgstr "[নোড] শুধু কেবল দিয়ে রিলে। বেতার নেই।" +msgstr "[মধ্যবর্তী-সংযোগ] শুধু কেবল দিয়ে পুনর্প্রেরণ। বেতার নেই।" msgid "Removes Ethernet backhaul, restores port to bridge." -msgstr "ইথারনেট ব্যাকহল সরায়, পোর্ট ব্রিজে ফিরিয়ে দেয়।" +msgstr "ইথারনেট সংযোগ-ভিত্তি সরায়, পোর্ট ব্রিজে ফিরিয়ে দেয়।" msgid "Turns off mesh. Radios stay enabled." msgstr "মেশ বন্ধ করে। রেডিও চালু থাকে।" @@ -239,10 +242,13 @@ msgstr "মেশ বন্ধ করে। রেডিও চালু থা # Mesh settings # ========================================================= msgid "Mesh settings" -msgstr "মেশ সেটিংস" +msgstr "মেশ পছন্দসমূহ" + +msgid "Dual-band wireless" +msgstr "দ্বি-তরঙ্গ বেতার" msgid "Enable EasyMesh" -msgstr "EasyMesh সক্রিয় করুন" +msgstr "ইজিমেশ সক্রিয় করুন" msgid "Mesh role" msgstr "মেশ ভূমিকা" @@ -251,16 +257,16 @@ msgid "Off" msgstr "বন্ধ" msgid "Server (gateway - has internet)" -msgstr "সার্ভার (গেটওয়ে - ইন্টারনেট আছে)" +msgstr "প্রদানকারী (গেটওয়ে - ইন্টারনেট আছে)" msgid "Client (uses mesh internet)" -msgstr "ক্লায়েন্ট (মেশ ইন্টারনেট ব্যবহার করে)" +msgstr "গ্রাহক (মেশ ইন্টারনেট ব্যবহার করে)" msgid "Node (relay only)" -msgstr "নোড (শুধু রিলে)" +msgstr "মধ্যবর্তী-সংযোগ (শুধু পুনর্প্রেরণ)" msgid "Mesh mode" -msgstr "মেশ মোড" +msgstr "মেশ কার্যপদ্ধতি" msgid "5 GHz wireless only" msgstr "শুধু 5 GHz বেতার" @@ -269,106 +275,103 @@ msgid "2.4 GHz wireless only" msgstr "শুধু 2.4 GHz বেতার" msgid "Dual-band wireless only" -msgstr "শুধু দ্বি-ব্যান্ড বেতার" +msgstr "শুধু দ্বি তরঙ্গ বেতার" msgid "5 GHz wireless + Wired backhaul" -msgstr "5 GHz বেতার + তারযুক্ত ব্যাকহল" +msgstr "5 GHz বেতার + তারযুক্ত সংযোগ-ভিত্তি" msgid "2.4 GHz wireless + Wired backhaul" -msgstr "2.4 GHz বেতার + তারযুক্ত ব্যাকহল" +msgstr "2.4 GHz বেতার + তারযুক্ত সংযোগ-ভিত্তি" msgid "Dual-band wireless + Wired backhaul" -msgstr "দ্বি-ব্যান্ড বেতার + তারযুক্ত ব্যাকহল" +msgstr "দ্বি তরঙ্গ বেতার + তারযুক্ত সংযোগ-ভিত্তি" msgid "Wired only (no wireless mesh)" msgstr "শুধু তারযুক্ত (কোনো বেতার মেশ নেই)" msgid "Custom radio" -msgstr "কাস্টম রেডিও" +msgstr "স্বকীয় বেতার" msgid "Custom mesh radio" -msgstr "কাস্টম মেশ রেডিও" +msgstr "স্বকীয় মেশ-বেতার" msgid "Mesh ID" msgstr "মেশ ID" msgid "Require password (WPA3-SAE)" -msgstr "পাসওয়ার্ড আবশ্যক (WPA3-SAE)" +msgstr "গুপ্তশব্দ আবশ্যক (WPA3-SAE)" msgid "Mesh password" -msgstr "মেশ পাসওয়ার্ড" +msgstr "মেশ গুপ্তশব্দ" msgid "Enable 802.11k/v/r fast roaming" -msgstr "802.11k/v/r দ্রুত রোমিং সক্রিয় করুন" +msgstr "802.11k/v/r দ্রুত ভ্রমণশীলতা সক্রিয় করুন" msgid "Mobility domain" -msgstr "মোবিলিটি ডোমেইন" +msgstr "গতিশীলতা-ক্ষেত্র" + +msgid "settings" +msgstr "পছন্দসমূহ" # ========================================================= # Client WiFi # ========================================================= msgid "Client WiFi — 2.4 GHz" -msgstr "ক্লায়েন্ট WiFi — 2.4 GHz" +msgstr "গ্রাহক WiFi — 2.4 GHz" msgid "Broadcast 2.4 GHz" msgstr "2.4 GHz সম্প্রচার করুন" msgid "2.4 GHz SSID" -msgstr "2.4 GHz SSID" +msgstr "2.4 GHz SSID-বেতার নাম" msgid "2.4 GHz security" msgstr "2.4 GHz নিরাপত্তা" msgid "None (open)" -msgstr "কোনোটি নয় (খোলা)" - -msgid "WPA2-PSK" -msgstr "WPA2-PSK" - -msgid "WPA3-SAE" -msgstr "WPA3-SAE" +msgstr "কোনোটিই নয় (খোলা)" msgid "WPA2/WPA3 transition" -msgstr "WPA2/WPA3 ট্রানজিশন" +msgstr "WPA2/WPA3 পরিবর্তনকাল" msgid "2.4 GHz password" -msgstr "2.4 GHz পাসওয়ার্ড" +msgstr "2.4 GHz গুপ্তশব্দ" msgid "Client WiFi — 5 GHz" -msgstr "ক্লায়েন্ট WiFi — 5 GHz" +msgstr "গ্রাহক WiFi — 5 GHz" msgid "Broadcast 5 GHz" msgstr "5 GHz সম্প্রচার করুন" msgid "5 GHz SSID" -msgstr "5 GHz SSID" +msgstr "5 GHz SSID-বেতার নাম" msgid "5 GHz security" msgstr "5 GHz নিরাপত্তা" msgid "5 GHz password" -msgstr "5 GHz পাসওয়ার্ড" +msgstr "5 GHz গুপ্তশব্দ" msgid "Client WiFi — shared" -msgstr "ক্লায়েন্ট WiFi — শেয়ার্ড" +msgstr "গ্রাহক WiFi — শেয়ার্ড" msgid "Attach client WiFi to network" -msgstr "ক্লায়েন্ট WiFi নেটওয়ার্কে সংযুক্ত করুন" +msgstr "গ্রাহক WiFi নেটওয়ার্কে যুক্ত করুন" # ========================================================= # AP Mode # ========================================================= msgid "AP Mode (Dumb AP)" -msgstr "AP মোড (Dumb AP)" +msgstr "AP কার্যপদ্ধতি (নিষ্ক্রিয় AP)" msgid "Enable AP Mode (Dumb AP)" -msgstr "AP মোড সক্রিয় করুন (Dumb AP)" +msgstr "AP কার্যপদ্ধতি সক্রিয় করুন (নিষ্ক্রিয় AP)" msgid "IP mode" -msgstr "IP মোড" +msgstr "IP কার্যপদ্ধতি" msgid "DHCP (from Server)" -msgstr "DHCP (সার্ভার থেকে)" +msgstr "DHCP (প্রদানকারী থেকে)" msgid "Static IP" msgstr "স্থির IP" @@ -383,40 +386,43 @@ msgid "Gateway" msgstr "গেটওয়ে" msgid "DNS server" -msgstr "DNS সার্ভার" +msgstr "DNS প্রদানকারী" + +msgid "server" +msgstr "প্রদানকারী" # ========================================================= # Wired backhaul # ========================================================= msgid "Wired backhaul" -msgstr "তারযুক্ত ব্যাকহল" +msgstr "তারযুক্ত সংযোগ-ভিত্তি" msgid "Wired backhaul interface" -msgstr "তারযুক্ত ব্যাকহল ইন্টারফেস" +msgstr "তারযুক্ত সংযোগ-ভিত্তি ইন্টারফেস" msgid "Disabled" msgstr "নিষ্ক্রিয়" +msgid "backhaul" +msgstr "সংযোগ-ভিত্তি" + # ========================================================= # Safety and actions # ========================================================= -msgid "Safety and actions" -msgstr "নিরাপত্তা ও ক্রিয়া" - msgid "Safe apply (60-second rollback)" msgstr "নিরাপদ প্রয়োগ (৬০-সেকেন্ড রোলব্যাক)" msgid "Reapply EasyMesh Settings" -msgstr "EasyMesh সেটিংস পুনঃপ্রয়োগ করুন" +msgstr "ইজিমেশ পছন্দসমূহ পুনঃপ্রয়োগ করুন" msgid "Restore from last backup" -msgstr "সর্বশেষ ব্যাকআপ থেকে পুনরুদ্ধার করুন" +msgstr "সর্বশেষ বিকল্প ব্যবস্থা থেকে পুনরুদ্ধার করুন" msgid "Pair with another router" -msgstr "অন্য রাউটারের সাথে পেয়ার করুন" +msgstr "অন্য রাউটারের সাথে যুগলবন্দী করুন" msgid "Enter pairing mode (2 minutes)" -msgstr "পেয়ারিং মোডে প্রবেশ করুন (২ মিনিট)" +msgstr "যুগলবন্দীকরণ কার্যপদ্ধতি-তে প্রবেশ করুন (২ মিনিট)" # ========================================================= # Long descriptions @@ -428,40 +434,1004 @@ msgid "WPS-style pairing. Press the WPS button on both routers within 2 minutes. msgstr "WPS-স্টাইল পেয়ারিং। ২ মিনিটের মধ্যে উভয় রাউটারে WPS বোতাম চাপুন। সময় শেষ হলে স্বয়ংক্রিয়ভাবে ফিরে যায়।" msgid "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')." -msgstr "Batman-adv throughput_override মান Kbit/s-এ। উচ্চতর = পছন্দের। প্রতিটি লিংকে batman-কে স্বয়ংক্রিয় সিদ্ধান্ত নিতে দিতে 0 সেট করুন। ডিফল্ট: তারযুক্ত=1000000, 6G=900000, 5G=600000, 2.4G=30000। পরিবর্তনগুলি পরবর্তী Save & Apply-এ (বা 'EasyMesh সেটিংস পুনঃপ্রয়োগ করুন') কার্যকর হয়।" +msgstr "Batman-adv throughput_override মান Kbit/s-এ। উচ্চতর = পছন্দের। প্রতিটি লিংকে batman-কে স্বয়ংক্রিয় সিদ্ধান্ত নিতে দিতে 0 সেট করুন। ডিফল্ট: তারযুক্ত=1000000, 6G=900000, 5G=600000, 2.4G=30000। পরিবর্তনগুলি পরবর্তী Save & Apply-এ (বা 'ইজিমেশ পছন্দসমূহ পুনঃপ্রয়োগ করুন') কার্যকর হয়।" msgid "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. as an 8021q device for each ID. If a bridge named br-, br-lan, br-vlan, br-guest, or br-iot already exists, it is auto-added to that bridge. Otherwise, add the bat0. device manually in Network → Interfaces → Devices. Requires 802.1Q-capable switches on the physical wired backhaul." -msgstr "মেশ জুড়ে অতিথি / IoT / হটস্পট নেটওয়ার্ক প্রসারিত করুন। কমা-বিভক্ত VLAN ID তালিকা লিখুন (যেমন 10,20,30)। init লিপি প্রতিটি ID-এর জন্য bat0. কে একটি 8021q ডিভাইস হিসেবে তৈরি করে। যদি br-, br-lan, br-vlan, br-guest, বা br-iot নামের একটি ব্রিজ ইতিমধ্যে বিদ্যমান থাকে, তবে সেটি স্বয়ংক্রিয়ভাবে সেই ব্রিজে যোগ করা হয়। অন্যথায়, Network → Interfaces → Devices-এ ম্যানুয়ালি bat0. ডিভাইস যোগ করুন। ফিজিক্যাল তারযুক্ত ব্যাকহলে 802.1Q-সক্ষম সুইচ প্রয়োজন।" +msgstr "মেশ জুড়ে অতিথি / IoT-যুক্ত যন্ত্রপাতির ইন্টারনেট / সন্ধিবিন্দু পারস্পরিক-সংযোগ-গুলি প্রসারিত করুন। কমা-বিভক্ত VLAN ID-VLAN ID-আভাসী স্থানীয় সংযোগ শনাক্তকারী তালিকা লিখুন (যেমন 10,20,30)। init লিপি প্রতিটি ID-এর জন্য bat0. কে একটি 8021q ডিভাইস হিসেবে তৈরি করে। যদি br-, br-lan, br-vlan, br-guest, বা br-iot নামের একটি ব্রিজ ইতিমধ্যে বিদ্যমান থাকে, তবে সেটি স্বয়ংক্রিয়ভাবে সেই ব্রিজে যোগ করা হয়। অন্যথায়, Network → Interfaces → Devices-এ ম্যানুয়ালি bat0. ডিভাইস যোগ করুন। ফিজিক্যাল তারযুক্ত সংযোগ-ভিত্তিতে 802.1Q-সক্ষম সুইচ প্রয়োজন।" msgid "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." -msgstr "init লিপি বুটে এবং 90 সেকেন্ড পরে সংযোগ অগ্রাধিকার প্রয়োগ করে। কিছু হার্ডওয়্যারে, মেশ ইন্টারফেস আরও পরে দেখা দেয়, ফলে অগ্রাধিকার ওজন 0-এ থাকে। প্রতি মিনিটে অগ্রাধিকার পুনঃপ্রয়োগ করতে cron ফলব্যাক সক্রিয় করুন। নিরাপদ (idempotent) কিন্তু ব্যাকগ্রাউন্ডে চলে। নিষ্ক্রিয় থাকলে, /etc/crontabs/root-এ cron লাইনটি মন্তব্য করা থাকে।" +msgstr "init লিপি বুটে এবং 90 সেকেন্ড পরে সংযোগ অগ্রাধিকার প্রয়োগ করে। কিছু হার্ডওয়্যারে, মেশ ইন্টারফেস আরও পরে দেখা দেয়, ফলে অগ্রাধিকার ওজন সমন্বয় 0-এ থাকে। প্রতি মিনিটে অগ্রাধিকার পুনঃপ্রয়োগ করতে cron ফলব্যাক সক্রিয় করুন। নিরাপদ (idempotent) কিন্তু ব্যাকগ্রাউন্ডে চলে। নিষ্ক্রিয় থাকলে, /etc/crontabs/root-এ cron লাইনটি মন্তব্য করা থাকে।" msgid "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." -msgstr "সুবিধাজনক নির্বাচক। নিচের 'তারযুক্ত ব্যাকহল ইন্টারফেস' এর সাথে মিলে যায়। তারযুক্ত ব্যাকহল ব্যবহার করে এমন প্রিসেট এই পোর্টটি LAN ব্রিজ থেকে সরিয়ে দেবে। আপনার আপলিংক বা মডেম দ্বারা ব্যবহৃত নয় এমন একটি পোর্ট বাছুন। তারযুক্ত ব্যাকহল সম্পূর্ণভাবে নিষ্ক্রিয় করতে খালি রাখুন।" +msgstr "সুবিধাজনক নির্বাচক। নিচের 'তারযুক্ত সংযোগ-ভিত্তি ইন্টারফেস' এর সাথে মিলে যায়। তারযুক্ত সংযোগ-ভিত্তি ব্যবহার করে এমন পূর্ববিন্যাস এই পোর্টটি LAN ব্রিজ থেকে সরিয়ে দেবে। আপনার আপলিংক বা মডেম দ্বারা ব্যবহৃত নয় এমন একটি পোর্ট বাছুন। তারযুক্ত সংযোগ-ভিত্তি সম্পূর্ণভাবে নিষ্ক্রিয় করতে খালি রাখুন।" msgid "Must match on all nodes. Ignored in wired-only mode." -msgstr "সমস্ত নোডে অবশ্যই মিলতে হবে। শুধু তারযুক্ত মোডে উপেক্ষা করা হয়।" +msgstr "সমস্ত মধ্যবর্তী-সংযোগ-এ অবশ্যই মিলতে হবে। শুধু তারযুক্ত কার্যপদ্ধতি-তে উপেক্ষা করা হয়।" msgid "Must match on all nodes. Only used when 'Require password' is enabled." -msgstr "সমস্ত নোডে অবশ্যই মিলতে হবে। শুধু 'পাসওয়ার্ড আবশ্যক' সক্রিয় থাকলে ব্যবহৃত হয়।" +msgstr "সমস্ত মধ্যবর্তী-সংযোগ-এ অবশ্যই মিলতে হবে। শুধু 'গুপ্তশব্দ আবশ্যক' সক্রিয় থাকলে ব্যবহৃত হয়।" msgid "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)." -msgstr "একটি প্রিসেট ক্লিক করুন। একটি নিশ্চিতকরণ ডায়ালগ আসবে। প্রয়োগের আগে বর্তমান কনফিগার স্বয়ংক্রিয়ভাবে ব্যাকআপ করা হয়। ভূমিকা অনুযায়ী গোষ্ঠীভুক্ত: সার্ভার (ইন্টারনেট আছে) → ক্লায়েন্ট (মেশ ব্যবহার করে) → নোড (শুধু রিলে)।" +msgstr "একটি পূর্ববিন্যাস ক্লিক করুন। একটি নিশ্চিতকরণ ডায়ালগ আসবে। প্রয়োগের আগে বর্তমান বিন্যাসকরণ স্বয়ংক্রিয়ভাবে বিকল্প ব্যবস্থা করা হয়। ভূমিকা অনুযায়ী গোষ্ঠীভুক্ত: প্রদানকারী (ইন্টারনেট আছে) → গ্রাহক (মেশ ব্যবহার করে) → মধ্যবর্তী-সংযোগ (শুধু পুনর্প্রেরণ)।" msgid "Longer range, lower speed. Use a different SSID from 5 GHz." -msgstr "দীর্ঘ পরিসর, কম গতি। 5 GHz থেকে একটি ভিন্ন SSID ব্যবহার করুন।" +msgstr "দীর্ঘ পরিসর, কম গতি। 5 GHz থেকে একটি ভিন্ন SSID-বেতার নাম ব্যবহার করুন।" msgid "Shorter range, much faster. Use a different SSID from 2.4 GHz." -msgstr "ছোট পরিসর, অনেক দ্রুত। 2.4 GHz থেকে একটি ভিন্ন SSID ব্যবহার করুন।" - - - +msgstr "ছোট পরিসর, অনেক দ্রুত। 2.4 GHz থেকে একটি ভিন্ন SSID-বেতার নাম ব্যবহার করুন।" # ========================================================= # Additional descriptions # ========================================================= msgid "Combined wireless band + wired backhaul. Wired variants use the port selected above." -msgstr "সম্মিলিত বেতার ব্যান্ড + তারযুক্ত ব্যাকহল। তারযুক্ত ভেরিয়েন্ট উপরে নির্বাচিত পোর্ট ব্যবহার করে।" +msgstr "সম্মিলিত বেতার তরঙ্গ + তারযুক্ত সংযোগ-ভিত্তি। তারযুক্ত ভেরিয়েন্ট উপরে নির্বাচিত পোর্ট ব্যবহার করে।" msgid "Example: 10,20,30 — Leave empty to disable" msgstr "উদাহরণ: 10,20,30 — নিষ্ক্রিয় করতে খালি রাখুন" + +# ========================================================= +# Safety notice +# ========================================================= +msgid "Safety" +msgstr "নিরাপত্তা" + +msgid "Avoid these characters in SSIDs/passwords:" +msgstr "SSID-বেতার নাম /গুপ্তশব্দ-তে এই অক্ষরগুলি এড়িয়ে চলুন:" + +msgid "✅ Do" +msgstr "✅ করুন" + +msgid "❌ Don't" +msgstr "❌ করবেন না" + +msgid "Click for full safety rules" +msgstr "বিস্তারিত নিরাপত্তা নিয়ম দেখতে ক্লিক করুন" + +msgid "Use only letters, numbers, hyphens, and underscores in SSIDs." +msgstr "SSID-বেতার নাম-এ শুধুমাত্র অক্ষর, সংখ্যা, হাইফেন এবং আন্ডারস্কোর ব্যবহার করুন।" + +msgid "Keep SSIDs 32 characters or fewer." +msgstr "SSID-বেতার নাম ৩২ অক্ষর বা কম রাখুন।" + +msgid "Use ASCII characters if SSID sync is enabled." +msgstr "SSID-বেতার নাম সামঞ্জস্যকরণ সক্রিয় থাকলে ASCII অক্ষর ব্যবহার করুন।" + +msgid "Back up config before every change." +msgstr "প্রতিটি পরিবর্তনের আগে বিন্যাসকরণ এর বিকল্প ব্যবস্থা করুন।" + +msgid "Test one client node at a time when changing settings." +msgstr "পছন্দ পরিবর্তনের সময় একবারে একটি গ্রাহক মধ্যবর্তী-সংযোগ পরীক্ষা করুন।" + +msgid "Never use quotes, backticks, dollar signs, or backslashes in SSIDs/passwords." +msgstr "SSID-বেতার নাম /গুপ্তশব্দ-তে কখনও উদ্ধৃতিচিহ্ন, বিপরীত উদ্ধৃতিচিহ্ন, ডলার চিহ্ন বা বিপরীত হেলানো রেখা ব্যবহার করবেন না।" + +msgid "Never change SSID while client nodes are offline." +msgstr "গ্রাহক মধ্যবর্তী-সংযোগ অফলাইন থাকলে SSID-বেতার নাম পরিবর্তন করবেন না।" + +msgid "Never edit a synced node's SSID manually." +msgstr "সামঞ্জস্যকরণ করা মধ্যবর্তী-সংযোগ-এর SSID-বেতার নাম ম্যানুয়ালি সম্পাদনা করবেন না।" + +msgid "Never run luci-app-easymesh alongside meshd (OMM)." +msgstr "luci-app-easymesh এবং meshd (OMM-OpenWrt মেশ পরিচালক) একই সাথে চালাবেন না।" + +msgid "Never set sync interval below 10 seconds." +msgstr "সামঞ্জস্যকরণ অন্তরকাল ১০ মুহূর্ত-এর নিচে নির্ধারণ করবেন না।" + +msgid "Field-by-field safety reference" +msgstr "ক্ষেত্রভিত্তিক নিরাপত্তা উদ্ধৃতি" + +msgid "Field" +msgstr "ক্ষেত্র" + +msgid "Safe" +msgstr "নিরাপদ" + +msgid "Unsafe" +msgstr "অনিরাপদ" + +msgid "Password" +msgstr "গুপ্তশব্দ" + +msgid "SSID" +msgstr "SSID-বেতার নাম" + +msgid "VLAN IDs" +msgstr "VLAN ID-আভাসী স্থানীয় সংযোগ শনাক্তকারী" + +msgid "everything else" +msgstr "অন্য সব" + +msgid "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." +msgstr "কেন এই সীমাবদ্ধতা? meshd (৬ MB) এর মতো একটি কম্পাইল করা ডেমন কখনও শেলের মাধ্যমে ব্যবহারকারীর ইনপুট পাঠায় না। Lua এবং শেল স্ক্রিপ্ট অবশ্যই পাঠায় — সামঞ্জস্যকরণ নীতিমালা বাহ্যিক আদেশ হিসেবে nc, openssl, এবং uci ব্যবহার করে।" + +msgid "Enable rollback before risky changes. Reapply or restore from the last automatic backup if something breaks." +msgstr "ঝুঁকিপূর্ণ পরিবর্তনের আগে রোলব্যাক সক্রিয় করুন। কিছু ভেঙে গেলে পুনঃপ্রয়োগ করুন বা শেষ স্বয়ংক্রিয় বিকল্প ব্যবস্থা থেকে পুনরুদ্ধার করুন।" + +# ========================================================= +# AP Mode role-gate notice +# ========================================================= +msgid "AP Mode is not available for this role" +msgstr "এই ভূমিকার জন্য AP (সংযোগস্থল) কার্যপদ্ধতি উপলব্ধ নয়" + +msgid "Current mesh role:" +msgstr "বর্তমান মেশ ভূমিকা:" + +msgid "The Server runs DHCP and routing, so it must not become a Dumb AP." +msgstr "প্রদানকারী DHCP এবং রাউটিং চালায়, তাই এটি Dumb AP হওয়া উচিত নয়।" + +msgid "Server" +msgstr "প্রদানকারী" + +msgid "Client" +msgstr "গ্রাহক" + +msgid "Node" +msgstr "মধ্যবর্তী-সংযোগ" + +msgid "Save & Apply" +msgstr "সংরক্ষণ ও প্রয়োগ করুন" + +# ========================================================= +# New tab headers + descriptions +# ========================================================= +msgid "EasyMesh — Dashboard" +msgstr "ইজিমেশ — তথ্যফলক" + +msgid "EasyMesh — Settings" +msgstr "ইজিমেশ — পছন্দসমূহ" + +msgid "EasyMesh — AP Mode (Dumb AP)" +msgstr "ইজিমেশ — AP কার্যপদ্ধতি (নিষ্ক্রিয় AP)" + +msgid "EasyMesh — Client WiFi" +msgstr "ইজিমেশ — গ্রাহক WiFi" + +msgid "EasyMesh — Quick Setup" +msgstr "ইজিমেশ — দ্রুত সেটআপ" + +msgid "EasyMesh — VLANs" +msgstr "ইজিমেশ — VLAN-আভাসী স্থানীয় সংযোগ" + +msgid "Live mesh status. Refreshes on every page load. Bandwidth is sampled over 1 second; latency is cached for 30 seconds." +msgstr "লাইভ মেশ অবস্থা। প্রতিবার পাতা চালুর সময় নবায়ন হয়। আদান-প্রদানের গতি ১ সেকেন্ডের উপর নমুনা করা হয়; লেটেন্সি ৩০ সেকেন্ডের জন্য সাময়িক-সংরক্ষণ করা হয়।" + +msgid "Mesh role, backhaul interface, link priorities and recovery." +msgstr "মেশ ভূমিকা, সংযোগ-ভিত্তি ইন্টারফেস, সংযোগ অগ্রাধিকার এবং পুনরুদ্ধার।" + +msgid "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." +msgstr "Dumb AP নিজস্ব DHCP বা ফায়ারওয়াল না চালিয়ে গ্রাহক WiFi নেটওয়ার্ক সম্প্রচার করে। স্যাটেলাইট নোডে এটি ব্যবহার করুন যাতে যেকোনো মধ্যবর্তী-সংযোগ-এর গ্রাহকরা মূল প্রদানকারী থেকে IP পায়।" + +msgid "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." +msgstr "শেষ ডিভাইসগুলি মেশে যোগ দিতে পারে এই জন্য গ্রাহক মধ্যবর্তী-সংযোগ-গুলি SSID-বেতার নাম সম্প্রচার করে। স্টিকি-গ্রাহক আচরণ প্রতিরোধ করতে 2.4 GHz এবং 5 GHz এর জন্য আলাদা SSID-বেতার নাম ব্যবহার করুন।" + +msgid "Pick a preset for your router's role. Current config is backed up automatically before applying." +msgstr "আপনার রাউটারের ভূমিকার জন্য একটি পূর্ববিন্যাস বাছুন। প্রয়োগের আগে বর্তমান বিন্যাসকরণ এ স্বয়ংক্রিয়ভাবে বিকল্প ব্যবস্থা করা হয়।" + +msgid "Extend guest / IoT / hotspot networks across the mesh. The init script creates bat0. as an 8021q device for each ID." +msgstr "মেশ জুড়ে অতিথি / IoT-যুক্ত যন্ত্রপাতির ইন্টারনেট / সন্ধিবিন্দু পারস্পরিক-সংযোগ-গুলি প্রসারিত করুন। init লিপি প্রতিটি ID-এর জন্য bat0. কে একটি 8021q ডিভাইস হিসেবে তৈরি করে।" + +# ========================================================= +# Section headers + descriptions +# ========================================================= +msgid "Combined wireless band + wired backhaul." +msgstr "সম্মিলিত বেতার তরঙ্গ + তারযুক্ত সংযোগ-ভিত্তি।" + +msgid "Longer range, lower speed." +msgstr "দীর্ঘ পরিসর, কম গতি।" + +msgid "Shorter range, much faster." +msgstr "ছোট পরিসর, অনেক দ্রুত।" + +msgid "Shared" +msgstr "শেয়ার্ড" + +msgid "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." +msgstr "তারযুক্ত সংযোগ-ভিত্তি সহ পূর্ববিন্যাস গুলি এই পোর্টটি LAN ব্রিজ থেকে সরিয়ে দেবে। আপনার আপলিংক বা মডেম দ্বারা ব্যবহৃত নয় এমন একটি পোর্ট বাছুন। তারযুক্ত সংযোগ-ভিত্তি সম্পূর্ণভাবে নিষ্ক্রিয় করতে খালি রাখুন।" + +msgid "Enter a comma-separated list of VLAN IDs (e.g. 10,20,30). If a bridge named br-, br-lan, br-vlan, br-guest, or br-iot already exists, it is auto-added to that bridge. Otherwise, add the bat0. device manually in Network → Interfaces → Devices. Requires 802.1Q-capable switches on the physical wired backhaul." +msgstr "কমা-বিভক্ত VLAN ID-আভাসী স্থানীয় সংযোগ শনাক্তকারী তালিকা লিখুন (যেমন 10,20,30)। যদি br-, br-lan, br-vlan, br-guest, বা br-iot নামের একটি ব্রিজ ইতিমধ্যে বিদ্যমান থাকে, তবে সেটি স্বয়ংক্রিয়ভাবে সেই ব্রিজে যোগ করা হয়। অন্যথায়, Network → Interfaces → Devices-এ ম্যানুয়ালি bat0. ডিভাইস যোগ করুন। ফিজিক্যাল তারযুক্ত সংযোগ-ভিত্তিতে 802.1Q-সক্ষম সুইচ প্রয়োজন।" + +# ========================================================= +# Quick Setup group headers +# ========================================================= +msgid "SERVER presets — has internet (1–7)" +msgstr "প্রদানকারী পূর্ববিন্যাস — ইন্টারনেট আছে (১–৭)" + +msgid "CLIENT presets — uses mesh internet (8–14)" +msgstr "গ্রাহক পূর্ববিন্যাস — মেশ ইন্টারনেট ব্যবহার করে (৮–১৪)" + +msgid "NODE presets — relay only (15–17)" +msgstr "মধ্যবর্তী-সংযোগ পূর্ববিন্যাস — শুধু পুনর্প্রেরণ (১৫–১৭)" + +msgid "Recovery — detach wired backhaul (18)" +msgstr "পুনরুদ্ধার — তারযুক্ত সংযোগ-ভিত্তি বিচ্ছিন্ন করুন (১৮)" + +msgid "Disable — turn off mesh (19)" +msgstr "নিষ্ক্রিয় — মেশ বন্ধ করুন (১৯)" + +# ========================================================= +# Controller tab labels +# ========================================================= +msgid "Dashboard" +msgstr "তথ্যফলক" + +msgid "Settings" +msgstr "পছন্দসমূহ" + +msgid "AP Mode" +msgstr "AP কার্যপদ্ধতি" + +msgid "Client WiFi" +msgstr "গ্রাহক WiFi" + +msgid "VLANs" +msgstr "VLAN-আভাসী স্থানীয় সংযোগ" + +# ========================================================= +# Dashboard short labels +# ========================================================= +msgid "stale" +msgstr "পুরাতন" + +# ========================================================= +# Safety notice — full sentence +# ========================================================= +msgid "— plus newlines and em/en dashes (—, –). They break this shell-based app's sync." +msgstr "— এবং নিউলাইন, বড় হাইফেন (—, –)। এগুলি শেল-ভিত্তিক সামঞ্জস্যকরণ ভেঙে দেয়।" + +# ========================================================= +# AP Mode gate (full sentences) +# ========================================================= +msgid "To configure AP Mode:" +msgstr "AP কার্যপদ্ধতি বিন্যাসকরণ করতে:" + +msgid "Go to the Settings tab." +msgstr "পছন্দসমূহ পত্রক-এ যান।" + +msgid "Set Mesh role to Client or Node." +msgstr "মেশ ভূমিকা গ্রাহক বা মধ্যবর্তী-সংযোগ নির্ধারণ করুন।" + +msgid "Click Save & Apply." +msgstr "সংরক্ষণ ও প্রয়োগ করুন ক্লিক করুন।" + +msgid "Return to this tab." +msgstr "এই পত্রকে ফিরে যান।" + +# ========================================================= +# Client WiFi gate (full sentences) +# ========================================================= +msgid "EasyMesh is not enabled" +msgstr "ইজিমেশ সক্রিয় নয়" + +msgid "Client WiFi settings only apply when EasyMesh is enabled. It is currently off." +msgstr "গ্রাহক WiFi পছন্দসমূহ শুধুমাত্র ইজিমেশ সক্রিয় থাকলে প্রযোজ্য। এটি বর্তমানে বন্ধ।" + +msgid "To configure Client WiFi:" +msgstr "গ্রাহক WiFi বিন্যাসকরণ করতে:" + +msgid "Enable EasyMesh." +msgstr "ইজিমেশ সক্রিয় করুন।" + +msgid "Set Mesh role to Server, Client, or Node." +msgstr "মেশ ভূমিকা প্রদানকারী, গ্রাহক, বা মধ্যবর্তী-সংযোগ নির্ধারণ করুন।" + +msgid "Tip: Quick Setup presets enable EasyMesh and set the role in one click." +msgstr "টিপ: দ্রুত সপ্রস্তুতকরণ পূর্ববিন্যাস এক ক্লিকে ইজিমেশ সক্রিয় করে এবং ভূমিকা সেট করে।" + +# ========================================================= +# VLAN Guide + Anti-lockout (2.5.0) +# ========================================================= + +msgid "VLAN Safety & Rollback" +msgstr "VLAN নিরাপত্তা ও রোলব্যাক" + +msgid "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." +msgstr "VLAN পরিবর্তন প্রয়োগ করলে পোর্ট বা ব্রিজ ভুলভাবে কনফিগার থাকলে আপনি লকআউট হয়ে যেতে পারেন। Safe Apply সক্রিয় থাকলে পূর্বের নেটওয়ার্ক বিন্যাসকরণ স্ন্যাপশট নেওয়া হয় এবং ৯০ সেকেন্ড পরে স্বয়ংক্রিয়ভাবে পুনরুদ্ধার হয় — যদি না আপনি আগে 'Confirm VLAN Change' ক্লিক করেন।" + +msgid "Safe apply (90-second rollback)" +msgstr "নিরাপদ প্রয়োগ (৯০-সেকেন্ড রোলব্যাক)" + +msgid "Backup network config now" +msgstr "এখনই নেটওয়ার্ক বিন্যাসকরণ ব্যাকআপ করুন" + +msgid "Manually snapshot /etc/config/network to /etc/easymesh/network.bak." +msgstr "ম্যানুয়ালি /etc/config/network কে /etc/easymesh/network.bak-এ স্ন্যাপশট করুন।" + +msgid "Confirm VLAN Change" +msgstr "VLAN পরিবর্তন নিশ্চিত করুন" + +msgid "Cancel the pending auto-rollback. Click within 90 seconds of applying to keep your changes." +msgstr "অপেক্ষমাণ স্বয়ংক্রিয় রোলব্যাক বাতিল করুন। পরিবর্তন ধরে রাখতে প্রয়োগের ৯০ সেকেন্ডের মধ্যে ক্লিক করুন।" + +msgid "Restore last working network config" +msgstr "সর্বশেষ কার্যকর নেটওয়ার্ক বিন্যাসকরণ পুনরুদ্ধার করুন" + +msgid "Emergency restore. Reverts /etc/config/network to the last snapshot and reloads the network." +msgstr "জরুরি পুনরুদ্ধার। /etc/config/network কে শেষ স্ন্যাপশটে ফিরিয়ে দেয় এবং নেটওয়ার্ক রিলোড করে।" + +msgid "Auto-rollback is armed" +msgstr "স্বয়ংক্রিয় রোলব্যাক সক্রিয় আছে" + +msgid "A VLAN change was just applied. If you do not click" +msgstr "একটি VLAN পরিবর্তন এইমাত্র প্রয়োগ করা হয়েছে। যদি আপনি ক্লিক না করেন" + +msgid "within 90 seconds, the previous network config will be restored." +msgstr "৯০ সেকেন্ডের মধ্যে, পূর্বের নেটওয়ার্ক বিন্যাসকরণ পুনরুদ্ধার করা হবে।" + +msgid "Network backup available:" +msgstr "নেটওয়ার্ক ব্যাকআপ উপলব্ধ:" + +msgid "No network backup yet. A snapshot is taken automatically on the next Save & Apply." +msgstr "এখনও কোনো নেটওয়ার্ক ব্যাকআপ নেই। পরবর্তী Save & Apply-এ স্বয়ংক্রিয়ভাবে স্ন্যাপশট নেওয়া হবে।" + +msgid "VLAN Guide — click to expand" +msgstr "VLAN গাইড — দেখতে ক্লিক করুন" + +msgid "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." +msgstr "VLAN (Virtual Local Area Network) একটি ফিজিক্যাল LAN-কে একাধিক লজিক্যাল নেটওয়ার্কে বিভক্ত করে। একই সুইচের ডিভাইসগুলিকে আলাদা গোষ্ঠীতে বিচ্ছিন্ন করা যায় — যেন তারা সম্পূর্ণ ভিন্ন নেটওয়ার্কে আছে।" + +msgid "Why use VLAN?" +msgstr "VLAN কেন ব্যবহার করবেন?" + +msgid "Security:" +msgstr "নিরাপত্তা:" + +msgid "Keep sensitive departments (Finance, HR) separate from the general network." +msgstr "সংবেদনশীল বিভাগ (ফাইন্যান্স, এইচআর) সাধারণ নেটওয়ার্ক থেকে আলাদা রাখুন।" + +msgid "Traffic management:" +msgstr "ট্র্যাফিক ব্যবস্থাপনা:" + +msgid "Reduce broadcast traffic and improve speed." +msgstr "ব্রডকাস্ট ট্র্যাফিক কমিয়ে গতি বৃদ্ধি করুন।" + +msgid "Easy management:" +msgstr "সহজ ব্যবস্থাপনা:" + +msgid "Move a device without reconfiguring its network." +msgstr "নেটওয়ার্ক পুনঃকনফিগার না করে ডিভাইস সরান।" + +msgid "Cost efficiency:" +msgstr "খরচ সাশ্রয়:" + +msgid "No extra switches or routers needed for separate networks." +msgstr "আলাদা নেটওয়ার্কের জন্য অতিরিক্ত সুইচ বা রাউটার প্রয়োজন নেই।" + +msgid "How does it work?" +msgstr "এটি কীভাবে কাজ করে?" + +msgid "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." +msgstr "সাধারণ সুইচ প্রতিটি পোর্টে ব্রডকাস্ট করে। VLAN কনফিগার করা থাকলে, সুইচ পোর্টগুলিকে VLAN ID অনুযায়ী গোষ্ঠীবদ্ধ করে। VLAN 10-এর একটি ডিভাইস কেবল VLAN 10-এর ডিভাইসের সাথে কথা বলতে পারে — একই ফিজিক্যাল সুইচে থাকলেও।" + +msgid "First check: DSA or swconfig?" +msgstr "প্রথমে যাচাই: DSA নাকি swconfig?" + +msgid "OpenWrt has two switch architectures. The VLAN steps below only apply to DSA." +msgstr "OpenWrt-এ দুটি সুইচ আর্কিটেকচার আছে। নিচের VLAN ধাপগুলি শুধুমাত্র DSA-এর জন্য প্রযোজ্য।" + +msgid "If your menu shows" +msgstr "আপনার মেনুতে যদি দেখায়" + +msgid "— your router uses the older swconfig." +msgstr "— আপনার রাউটার পুরানো swconfig ব্যবহার করে।" + +msgid "with \"Bridge VLAN filtering\" — your router uses DSA." +msgstr "\"Bridge VLAN filtering\" সহ — আপনার রাউটার DSA ব্যবহার করে।" + +msgid "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." +msgstr "OpenWrt 21.02 এবং পরবর্তী (যেমন 23.05) সাধারণত DSA ব্যবহার করে এবং swconfig প্রতিস্থাপন করে, তবে কিছু ডিভাইস এখনও swconfig সহ আসে। এগিয়ে যাওয়ার আগে যাচাই করুন।" + +msgid "Note: some single-port travel routers do not support VLAN filtering at all." +msgstr "দ্রষ্টব্য: কিছু একক-পোর্ট ট্রাভেল রাউটার VLAN ফিল্টারিং সমর্থন করে না।" + +msgid "Types of VLAN (detailed)" +msgstr "VLAN-এর প্রকারভেদ (বিস্তারিত)" + +msgid "1. Port-based VLAN" +msgstr "১. পোর্ট-ভিত্তিক VLAN" + +msgid "The simplest and most common type. Physical switch ports are grouped into VLAN IDs." +msgstr "সবচেয়ে সাধারণ এবং সহজ প্রকার। ফিজিক্যাল সুইচ পোর্টগুলি VLAN ID-তে গোষ্ঠীবদ্ধ করা হয়।" + +msgid "How it works:" +msgstr "কীভাবে কাজ করে:" + +msgid "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." +msgstr "ধরুন পোর্ট ১–৫ VLAN 10 (IT) এবং পোর্ট ৬–১০ VLAN 20 (HR)। পোর্ট ১-এর কম্পিউটার কেবল পোর্ট ১–৫-এর ডিভাইসে পৌঁছাতে পারে। পোর্ট ৬–১০-এ কিছুই পৌঁছাতে পারে না।" + +msgid "Benefit:" +msgstr "সুবিধা:" + +msgid "Simple and predictable. Every port has a fixed VLAN." +msgstr "সহজ এবং পূর্বানুমেয়। প্রতিটি পোর্টের একটি নির্দিষ্ট VLAN আছে।" + +msgid "Limitation:" +msgstr "সীমাবদ্ধতা:" + +msgid "If a user moves their computer to a different port, the VLAN changes. This is the main drawback." +msgstr "ব্যবহারকারী তার কম্পিউটার অন্য পোর্টে সরালে VLAN পরিবর্তন হয়ে যায়। এটি প্রধান অসুবিধা।" + +msgid "2. Tagged VLAN (802.1Q)" +msgstr "২. ট্যাগড VLAN (802.1Q)" + +msgid "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\"." +msgstr "যখন একাধিক VLAN-কে একটি ফিজিক্যাল কেবল শেয়ার করতে হয় — সাধারণত দুটি সুইচের মধ্যে, বা সুইচ ও রাউটারের মধ্যে। সেই শেয়ার করা সংযোগকে \"Trunk port\" বলে।" + +msgid "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." +msgstr "IEEE 802.1Q অনুযায়ী, প্রতিটি প্যাকেট তার হেডারে একটি ছোট ট্যাগ বহন করে যা VLAN ID দেখায় (যেমন 10 বা 20)। প্রাপক সুইচ ট্যাগ পড়ে সঠিক VLAN-এ প্যাকেট পাঠায়।" + +msgid "One cable carries traffic for many VLANs, saving switch ports and cabling." +msgstr "একটি কেবল অনেক VLAN-এর ট্র্যাফিক বহন করে, সুইচ পোর্ট ও কেবল সাশ্রয় করে।" + +msgid "Both ends must support 802.1Q tagging. Unmanaged switches typically strip tags and break trunk links." +msgstr "উভয় প্রান্তে 802.1Q ট্যাগিং সমর্থন থাকতে হবে। আনম্যানেজড সুইচ সাধারণত ট্যাগ সরিয়ে দেয় এবং ট্রাঙ্ক সংযোগ ভেঙে দেয়।" + +msgid "3. MAC-based VLAN" +msgstr "৩. MAC-ভিত্তিক VLAN" + +msgid "The VLAN is assigned by the device's MAC address instead of by port." +msgstr "VLAN পোর্টের পরিবর্তে ডিভাইসের MAC ঠিকানা দ্বারা নির্ধারিত হয়।" + +msgid "Whatever port the laptop plugs into, the switch recognizes its MAC address and puts it on the correct VLAN automatically." +msgstr "ল্যাপটপ যে পোর্টেই যুক্ত হোক, সুইচ তার MAC ঠিকানা চিনে স্বয়ংক্রিয়ভাবে সঠিক VLAN-এ রাখে।" + +msgid "Requires managing a MAC-to-VLAN table. Impractical for networks with many devices or BYOD users." +msgstr "একটি MAC-থেকে-VLAN টেবিল পরিচালনার প্রয়োজন। অনেক ডিভাইস বা BYOD ব্যবহারকারীর নেটওয়ার্কের জন্য অবাস্তব।" + +msgid "4. Protocol-based VLAN" +msgstr "৪. প্রোটোকল-ভিত্তিক VLAN" + +msgid "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." +msgstr "VLAN ট্র্যাফিকের নেটওয়ার্ক প্রোটোকলের ভিত্তিতে নির্ধারিত হয় — IPv4, IPv6, IPX ইত্যাদি। প্রতিটি প্রোটোকল নিজস্ব VLAN-এর উপর দিয়ে যায়।" + +msgid "Use case:" +msgstr "ব্যবহারের ক্ষেত্র:" + +msgid "Legacy enterprise networks separating IP traffic from non-IP protocols." +msgstr "IP ট্র্যাফিককে অ-IP প্রোটোকল থেকে আলাদা করা লিগ্যাসি এন্টারপ্রাইজ নেটওয়ার্ক।" + +msgid "Rarely used today. Modern networks route by subnet instead." +msgstr "আজকাল বিরল। আধুনিক নেটওয়ার্ক সাবনেট দ্বারা রাউটিং করে।" + +msgid "OpenWrt Configuration Steps" +msgstr "OpenWrt কনফিগারেশনের ধাপ" + +msgid "Example: create VLAN 20 for a Guest / IoT network." +msgstr "উদাহরণ: অতিথি / IoT-যুক্ত যন্ত্রপাতির ইন্টারনেট নেটওয়ার্কের জন্য VLAN 20 তৈরি করুন।" + +msgid "Before you start:" +msgstr "শুরু করার আগে:" + +msgid "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." +msgstr "এমন একটি LAN পোর্ট দিয়ে সংযোগ করুন যা আপনি পরিবর্তন করবেন না। কখনও Wi-Fi-এর উপর বা যে পোর্ট পরিবর্তন করছেন তার উপর VLAN পরিবর্তন প্রয়োগ করবেন না — আপনি লকআউট হয়ে যাবেন।" + +msgid "Step 1 — Enable VLAN filtering on the bridge" +msgstr "ধাপ ১ — ব্রিজে VLAN ফিল্টারিং সক্রিয় করুন" + +msgid "Go to" +msgstr "যান" + +msgid "Find" +msgstr "খুঁজুন" + +msgid "and click" +msgstr "এবং ক্লিক করুন" + +msgid "Configure" +msgstr "কনফিগার করুন" + +msgid "Open the" +msgstr "খুলুন" + +msgid "Bridge VLAN filtering" +msgstr "Bridge VLAN ফিল্টারিং" + +msgid "tab and tick" +msgstr "ট্যাব এবং টিক দিন" + +msgid "Enable VLAN filtering" +msgstr "VLAN ফিল্টারিং সক্রিয় করুন" + +msgid "Step 2 — Assign ports" +msgstr "ধাপ ২ — পোর্ট বরাদ্দ করুন" + +msgid "Click" +msgstr "ক্লিক করুন" + +msgid "enter the ID (e.g." +msgstr "ID লিখুন (যেমন" + +msgid "tick — attaches the router's CPU to this VLAN." +msgstr "টিক দিন — রাউটারের CPU এই VLAN-এ সংযুক্ত করে।" + +msgid "Ports Configuration:" +msgstr "পোর্ট কনফিগারেশন:" + +msgid "Untagged (Access port):" +msgstr "Untagged (অ্যাক্সেস পোর্ট):" + +msgid "select" +msgstr "নির্বাচন করুন" + +msgid "and tick" +msgstr "এবং টিক দিন" + +msgid "Tagged (Trunk):" +msgstr "Tagged (ট্রাঙ্ক):" + +msgid "Default VLAN 1:" +msgstr "ডিফল্ট VLAN 1:" + +msgid "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" +msgstr "ফিল্টারিং সক্রিয় করলে, VLAN 1 প্রতিটি পোর্টে থাকে। একটি পোর্টকে সম্পূর্ণভাবে VLAN 20-এ বিচ্ছিন্ন করতে, সেই পোর্টের VLAN 1 সেট করতে হবে" + +msgid "Otherwise the port still belongs to VLAN 1." +msgstr "অন্যথায় পোর্টটি এখনও VLAN 1-এর অন্তর্ভুক্ত থাকবে।" + +msgid "Optional — CPU on VLAN 1:" +msgstr "ঐচ্ছিক — VLAN 1-এ CPU:" + +msgid "If you do not want the router reachable on VLAN 1, set the CPU port's VLAN 1 to" +msgstr "VLAN 1-এ রাউটার পৌঁছনীয় না চাইলে, CPU পোর্টের VLAN 1 সেট করুন" + +msgid "as well." +msgstr "-এও।" + +msgid "Save" +msgstr "সংরক্ষণ করুন" + +msgid "Step 3 — Create interface and IP" +msgstr "ধাপ ৩ — ইন্টারফেস এবং IP তৈরি করুন" + +msgid "e.g." +msgstr "যেমন" + +msgid "select the VLAN device. Usually" +msgstr "VLAN ডিভাইস নির্বাচন করুন। সাধারণত" + +msgid "but it will match your bridge name (e.g." +msgstr "তবে এটি আপনার ব্রিজ নামের সাথে মিলবে (যেমন" + +msgid ")." +msgstr ")।" + +msgid "Set an IP block (e.g." +msgstr "IP ব্লক সেট করুন (যেমন" + +msgid "DHCP Server" +msgstr "DHCP সার্ভার" + +msgid "Setup DHCP Server" +msgstr "DHCP সার্ভার সেটআপ করুন" + +msgid "Verify the DHCP range, lease time, and that the interface is not ignored." +msgstr "DHCP রেঞ্জ, লিজ সময় এবং ইন্টারফেস উপেক্ষিত না হয়েছে তা যাচাই করুন।" + +msgid "Step 3.5 — Test the VLAN before continuing" +msgstr "ধাপ ৩.৫ — এগিয়ে যাওয়ার আগে VLAN পরীক্ষা করুন" + +msgid "Connect a laptop to an untagged VLAN 20 port." +msgstr "একটি ল্যাপটপ untagged VLAN 20 পোর্টে সংযুক্ত করুন।" + +msgid "Check its IP — should be in the" +msgstr "এর IP পরীক্ষা করুন — থাকা উচিত" + +msgid "range." +msgstr "রেঞ্জে।" + +msgid "If it gets" +msgstr "যদি এটি পায়" + +msgid "instead, your untagged/PVID settings need fixing." +msgstr "পরিবর্তে, আপনার untagged/PVID সেটিংস ঠিক করতে হবে।" + +msgid "Only after this test passes, proceed to firewall zone configuration." +msgstr "এই পরীক্ষা পাস হওয়ার পরেই, ফায়ারওয়াল জোন কনফিগারেশনে এগিয়ে যান।" + +msgid "Step 4 — Firewall zone (with DHCP/DNS fix)" +msgstr "ধাপ ৪ — ফায়ারওয়াল জোন (DHCP/DNS সংশোধন সহ)" + +msgid "Firewall Settings" +msgstr "ফায়ারওয়াল সেটিংস" + +msgid "Create a zone named" +msgstr "নামে একটি জোন তৈরি করুন" + +msgid "Set" +msgstr "সেট করুন" + +msgid "to" +msgstr "হলো" + +msgid "tick only" +msgstr "শুধুমাত্র টিক দিন" + +msgid "Never tick" +msgstr "কখনও টিক দেবেন না" + +msgid "If Input is REJECT, guests get no DHCP/DNS." +msgstr "Input REJECT থাকলে, অতিথিরা DHCP/DNS পায় না।" + +msgid "Add two Traffic Rules with these exact fields:" +msgstr "এই সঠিক ক্ষেত্রগুলি সহ দুটি Traffic Rule যোগ করুন:" + +msgid "Rule 1 (DHCP):" +msgstr "নিয়ম ১ (DHCP):" + +msgid "Rule 2 (DNS):" +msgstr "নিয়ম ২ (DNS):" + +msgid "Without" +msgstr "ছাড়া" + +msgid "as the destination zone, the rule will not match traffic addressed to the router itself." +msgstr "গন্তব্য জোন হিসেবে, রাউটার নিজেই সম্বোধন করা ট্র্যাফিকের সাথে নিয়মটি মিলবে না।" + +msgid "Alternatively, set Input to" +msgstr "বিকল্পভাবে, Input সেট করুন" + +msgid "— simpler but less secure." +msgstr "-এ — সহজ কিন্তু কম নিরাপদ।" + +msgid "Ensure the WAN zone has" +msgstr "নিশ্চিত করুন WAN জোনে আছে" + +msgid "and" +msgstr "এবং" + +msgid "enabled." +msgstr "সক্রিয়।" + +msgid "Lockout warning:" +msgstr "লকআউট সতর্কতা:" + +msgid "Never assign the port you are connected through to a Tagged or different VLAN. You will lock yourself out." +msgstr "যে পোর্ট দিয়ে আপনি সংযুক্ত সেই পোর্ট কখনও Tagged বা ভিন্ন VLAN-এ বরাদ্দ করবেন না। আপনি লকআউট হয়ে যাবেন।" + +msgid "Tagging a Wi-Fi SSID to a VLAN" +msgstr "একটি Wi-Fi SSID-কে VLAN-এ ট্যাগ করা" + +msgid "Do this over a wired LAN connection" +msgstr "এটি একটি তারযুক্ত LAN সংযোগের উপর করুন" + +msgid "— not the Wi-Fi you are modifying. Otherwise you disconnect yourself mid-configuration." +msgstr "— যে Wi-Fi পরিবর্তন করছেন সেটি নয়। অন্যথায় কনফিগারেশনের মাঝখানে আপনি নিজেই সংযোগ বিচ্ছিন্ন হবেন।" + +msgid "You do not tag the SSID directly. You attach the wireless interface to the VLAN's network interface." +msgstr "আপনি সরাসরি SSID ট্যাগ করেন না। আপনি ওয়্যারলেস ইন্টারফেসকে VLAN-এর নেটওয়ার্ক ইন্টারফেসে সংযুক্ত করেন।" + +msgid "Step 1 — Create a new Wi-Fi interface" +msgstr "ধাপ ১ — একটি নতুন Wi-Fi ইন্টারফেস তৈরি করুন" + +msgid "next to the radio." +msgstr "রেডিওর পাশে।" + +msgid "Set a password (WPA2-PSK or WPA3-SAE)." +msgstr "একটি পাসওয়ার্ড সেট করুন (WPA2-PSK বা WPA3-SAE)।" + +msgid "Step 2 — Attach Wi-Fi to the VLAN interface" +msgstr "ধাপ ২ — Wi-Fi কে VLAN ইন্টারফেসে সংযুক্ত করুন" + +msgid "Before saving, open" +msgstr "সংরক্ষণের আগে, খুলুন" + +msgid "Untick" +msgstr "আনটিক করুন" + +msgid "Tick your VLAN interface (e.g." +msgstr "আপনার VLAN ইন্টারফেস টিক করুন (যেমন" + +msgid "Step 3 — Apply and test" +msgstr "ধাপ ৩ — প্রয়োগ এবং পরীক্ষা" + +msgid "Connect a phone or laptop to the new Wi-Fi." +msgstr "একটি ফোন বা ল্যাপটপ নতুন Wi-Fi-এ সংযুক্ত করুন।" + +msgid "Check its IP. If it comes from the VLAN block (e.g." +msgstr "এর IP পরীক্ষা করুন। যদি VLAN ব্লক থেকে আসে (যেমন" + +msgid "tagging worked." +msgstr "ট্যাগিং কাজ করেছে।" + +msgid "Client Isolation (Guest Wi-Fi security)" +msgstr "Client Isolation (অতিথি Wi-Fi নিরাপত্তা)" + +msgid "Prevents devices on the same Guest Wi-Fi from reaching each other. Blocks malware spread between guests." +msgstr "একই অতিথি Wi-Fi-এর ডিভাইসগুলিকে একে অপরের কাছে পৌঁছাতে বাধা দেয়। অতিথিদের মধ্যে ম্যালওয়্যার বিস্তার ব্লক করে।" + +msgid "Step 1 — Wi-Fi: isolate clients" +msgstr "ধাপ ১ — Wi-Fi: ক্লায়েন্ট বিচ্ছিন্ন করুন" + +msgid "Edit the Guest SSID." +msgstr "অতিথি SSID সম্পাদনা করুন।" + +msgid "Step 2 — Isolate Ethernet ports (if any)" +msgstr "ধাপ ২ — ইথারনেট পোর্ট বিচ্ছিন্ন করুন (যদি থাকে)" + +msgid "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." +msgstr "Wi-Fi ক্লায়েন্টদের জন্য বিচ্ছিন্নতা উপরের Wi-Fi সেটিং দ্বারা পরিচালিত হয়। ব্রিজ পোর্ট বিচ্ছিন্নতা শুধুমাত্র একই ব্রিজের ভিতরের ইথারনেট পোর্টে প্রযোজ্য।" + +msgid "If you have Guest Ethernet ports, isolate them in the Guest bridge (not" +msgstr "আপনার অতিথি ইথারনেট পোর্ট থাকলে, সেগুলি অতিথি ব্রিজে বিচ্ছিন্ন করুন (নয়" + +msgid "Configure the guest bridge." +msgstr "অতিথি ব্রিজ কনফিগার করুন।" + +msgid "tab and click the gear next to each guest Ethernet port." +msgstr "ট্যাব এবং প্রতিটি অতিথি ইথারনেট পোর্টের পাশের গিয়ারে ক্লিক করুন।" + +msgid "Connect two devices to Guest Wi-Fi." +msgstr "দুটি ডিভাইস অতিথি Wi-Fi-এ সংযুক্ত করুন।" + +msgid "From the second device, try" +msgstr "দ্বিতীয় ডিভাইস থেকে চেষ্টা করুন" + +msgid "to the first." +msgstr "প্রথমটির সাথে।" + +msgid "A failed ping usually means isolation works. Some devices block ping by their own firewall — check with a real connection instead." +msgstr "একটি ব্যর্থ পিং সাধারণত বিচ্ছিন্নতা কাজ করছে বোঝায়। কিছু ডিভাইস নিজেদের ফায়ারওয়াল দিয়ে পিং ব্লক করে — পরিবর্তে একটি আসল সংযোগ দিয়ে পরীক্ষা করুন।" + +msgid "Term definitions for new users" +msgstr "নতুন ব্যবহারকারীদের জন্য পদ সংজ্ঞা" + +msgid "A number from 1 to 4094 identifying which virtual network a device belongs to." +msgstr "১ থেকে ৪০৯৪ পর্যন্ত একটি সংখ্যা যা চিহ্নিত করে কোন ভার্চুয়াল নেটওয়ার্কে একটি ডিভাইস অন্তর্ভুক্ত।" + +msgid "Reserved values:" +msgstr "সংরক্ষিত মান:" + +msgid "VLAN 0 and VLAN 4095 are reserved. Avoid VLAN 1 for user or guest networks." +msgstr "VLAN 0 এবং VLAN 4095 সংরক্ষিত। ব্যবহারকারী বা অতিথি নেটওয়ার্কের জন্য VLAN 1 এড়িয়ে চলুন।" + +msgid "Analogy:" +msgstr "উপমা:" + +msgid "Room numbers in a school — Class 5, Class 6, Class 7." +msgstr "স্কুলে ক্লাসরুমের নম্বর — ক্লাস ৫, ক্লাস ৬, ক্লাস ৭।" + +msgid "A switch port dedicated to a single VLAN. Anything plugged in joins that VLAN only." +msgstr "একটি সুইচ পোর্ট যা কেবল একটি VLAN-এর জন্য বরাদ্দ। যা-ই যুক্ত হোক না কেন, কেবল সেই VLAN-এ যোগ দেয়।" + +msgid "One classroom's door — you enter and you are in that room." +msgstr "একটি ক্লাসরুমের দরজা — আপনি ঢোকেন এবং সেই ঘরে থাকেন।" + +msgid "A port carrying multiple VLANs over one link. Used for switch-to-switch or switch-to-router connections." +msgstr "একটি পোর্ট যা একটি লিংকের উপর একাধিক VLAN বহন করে। সুইচ-থেকে-সুইচ বা সুইচ-থেকে-রাউটার সংযোগের জন্য ব্যবহৃত।" + +msgid "A highway shared by traffic from several cities." +msgstr "একটি মহাসড়ক যা কয়েকটি শহরের ট্র্যাফিক শেয়ার করে।" + +msgid "When multiple VLANs share a trunk, each packet carries a digital tag identifying its VLAN." +msgstr "একাধিক VLAN একটি ট্রাঙ্ক শেয়ার করলে, প্রতিটি প্যাকেট তার VLAN চিহ্নিত করে একটি ডিজিটাল ট্যাগ বহন করে।" + +msgid "Courier parcels labelled Dhaka / Chittagong / Sylhet." +msgstr "কুরিয়ার পার্সেল যাতে ঢাকা / চট্টগ্রাম / সিলেট লেবেল লাগানো।" + +msgid "Comparison table" +msgstr "তুলনা টেবিল" + +msgid "Term" +msgstr "পদ" + +msgid "Analogy" +msgstr "উপমা" + +msgid "Main function" +msgstr "মূল কাজ" + +msgid "Classroom number" +msgstr "ক্লাসরুম নম্বর" + +msgid "Gives the network a separate identity" +msgstr "নেটওয়ার্ককে আলাদা পরিচয় দেয়" + +msgid "One classroom door" +msgstr "একটি ক্লাসরুমের দরজা" + +msgid "Connects a single VLAN device" +msgstr "একটি একক VLAN ডিভাইস সংযুক্ত করে" + +msgid "School main gate / highway" +msgstr "স্কুলের প্রধান গেট / মহাসড়ক" + +msgid "Carries multiple VLANs over one cable" +msgstr "একটি কেবলের উপর একাধিক VLAN বহন করে" + +msgid "Parcel address sticker" +msgstr "পার্সেল ঠিকানার স্টিকার" + +msgid "Marks which VLAN a packet belongs to" +msgstr "চিহ্নিত করে কোন VLAN-এ একটি প্যাকেট অন্তর্ভুক্ত" + +msgid "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." +msgstr "Inter-VLAN রাউটিং বিভিন্ন VLAN-এর ডিভাইসগুলিকে (যেমন VLAN 10 এবং VLAN 20) একে অপরের কাছে পৌঁছাতে দেয়, রাউটার তাদের মধ্যে গেটওয়ে হিসেবে কাজ করে।" + +msgid "OpenWrt vs Cisco:" +msgstr "OpenWrt বনাম Cisco:" + +msgid "On OpenWrt, inter-VLAN routing happens automatically through firewall zone forwarding. You do NOT create sub-interfaces like" +msgstr "OpenWrt-এ, ইন্টার-VLAN রাউটিং ফায়ারওয়াল জোন ফরওয়ার্ডিংয়ের মাধ্যমে স্বয়ংক্রিয়ভাবে ঘটে। আপনি সাব-ইন্টারফেস তৈরি করবেন না যেমন" + +msgid "as you would on Cisco. The Cisco example below is educational only." +msgstr "যেমন আপনি Cisco-তে করতেন। নিচের Cisco উদাহরণ শুধুমাত্র শিক্ষামূলক।" + +msgid "Why the name \"Router-on-a-Stick\"?" +msgstr "\"Router-on-a-Stick\" নাম কেন?" + +msgid "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." +msgstr "সাধারণত, দুটি আলাদা নেটওয়ার্ক সংযুক্ত করতে দুটি রাউটার পোর্ট এবং দুটি কেবল লাগত। এই পদ্ধতিতে রাউটার এবং সুইচের মধ্যে শুধুমাত্র একটি ফিজিক্যাল কেবল — একটি \"লাঠি\" — ব্যবহৃত হয়। রাউটারের ভিতরে, সেই এক কেবলকে একাধিক ভার্চুয়াল সাব-ইন্টারফেসে ভাগ করা হয়, প্রতিটি VLAN-এর জন্য একটি। দেখতে একটি লাঠির মাথায় রাউটার বসে আছে বলে মনে হয়।" + +msgid "How it works — step by step" +msgstr "এটি কীভাবে কাজ করে — ধাপে ধাপে" + +msgid "A PC on VLAN 10 wants to send data to a PC on VLAN 20." +msgstr "VLAN 10-এর একটি PC VLAN 20-এর একটি PC-তে ডেটা পাঠাতে চায়।" + +msgid "The switch sees the destination is in another VLAN. It tags the packet with VLAN 10 and sends it over the trunk port." +msgstr "সুইচ দেখে গন্তব্য অন্য VLAN-এ। এটি প্যাকেটকে VLAN 10 দিয়ে ট্যাগ করে এবং ট্রাঙ্ক পোর্টের উপর পাঠায়।" + +msgid "The router receives the packet on its VLAN 10 sub-interface." +msgstr "রাউটার তার VLAN 10 সাব-ইন্টারফেসে প্যাকেট গ্রহণ করে।" + +msgid "The router checks its routing table and decides to send it to VLAN 20." +msgstr "রাউটার তার রাউটিং টেবিল চেক করে এবং VLAN 20-এ পাঠানোর সিদ্ধান্ত নেয়।" + +msgid "The router strips the old tag and applies a new VLAN 20 tag. It sends the packet back over the same cable." +msgstr "রাউটার পুরানো ট্যাগ সরিয়ে একটি নতুন VLAN 20 ট্যাগ লাগায়। এটি একই কেবলের উপর প্যাকেট ফেরত পাঠায়।" + +msgid "The switch reads the new tag and delivers the packet to the VLAN 20 PC." +msgstr "সুইচ নতুন ট্যাগ পড়ে এবং VLAN 20 PC-তে প্যাকেট পৌঁছে দেয়।" + +msgid "Cisco configuration example" +msgstr "Cisco কনফিগারেশনের উদাহরণ" + +msgid "Switch side — make the router-facing port a trunk:" +msgstr "সুইচ দিক — রাউটার-মুখী পোর্টকে ট্রাঙ্ক করুন:" + +msgid "Router side — create sub-interfaces:" +msgstr "রাউটার দিক — সাব-ইন্টারফেস তৈরি করুন:" + +msgid "End devices:" +msgstr "শেষ ডিভাইসগুলি:" + +msgid "set their default gateway to the matching sub-interface IP (e.g." +msgstr "সেগুলির ডিফল্ট গেটওয়ে মিলিত সাব-ইন্টারফেস IP-এ সেট করুন (যেমন" + +msgid "for VLAN 10," +msgstr "VLAN 10-এর জন্য," + +msgid "for VLAN 20)." +msgstr "VLAN 20-এর জন্য)।" + +msgid "On older switches you may need" +msgstr "পুরানো সুইচে প্রয়োজন হতে পারে" + +msgid "before" +msgstr "আগে" + +msgid "All referenced VLANs must already exist on the switch." +msgstr "সমস্ত উল্লিখিত VLAN ইতিমধ্যে সুইচে থাকতে হবে।" + +msgid "Pros and cons" +msgstr "সুবিধা এবং অসুবিধা" + +msgid "Pros" +msgstr "সুবিধা" + +msgid "Cons" +msgstr "অসুবিধা" + +msgid "Lower cost — one router port, one cable." +msgstr "কম খরচ — একটি রাউটার পোর্ট, একটি কেবল।" + +msgid "Bandwidth bottleneck — all VLANs share the same cable." +msgstr "ব্যান্ডউইথ বাধা — সমস্ত VLAN একই কেবল শেয়ার করে।" + +msgid "Easy setup for small and medium networks." +msgstr "ছোট এবং মাঝারি নেটওয়ার্কের জন্য সহজ সেটআপ।" + +msgid "Single point of failure — a broken cable or port stops all inter-VLAN traffic." +msgstr "একক ব্যর্থতার বিন্দু — একটি ভাঙা কেবল বা পোর্ট সমস্ত ইন্টার-VLAN ট্র্যাফিক বন্ধ করে দেয়।" + +msgid "Zone Forwarding — LAN to Guest (one-way)" +msgstr "জোন ফরওয়ার্ডিং — LAN থেকে অতিথি (একমুখী)" + +msgid "Allow the LAN to reach the Guest network, but not vice versa." +msgstr "LAN-কে অতিথি নেটওয়ার্কে পৌঁছাতে দিন, কিন্তু উল্টোটা নয়।" + +msgid "on the" +msgstr "-এ" + +msgid "zone." +msgstr "জোন।" + +msgid "Scroll to" +msgstr "স্ক্রল করুন" + +msgid "Inter-Zone Forwarding" +msgstr "ইন্টার-জোন ফরওয়ার্ডিং" + +msgid "tick" +msgstr "টিক দিন" + +msgid "Never enable Guest → LAN." +msgstr "কখনও অতিথি → LAN সক্রিয় করবেন না।" + +msgid "Leaving it disabled keeps the main network protected." +msgstr "এটি নিষ্ক্রিয় রাখলে মূল নেটওয়ার্ক সুরক্ষিত থাকে।" + +msgid "Allow only one Guest device (Traffic Rules)" +msgstr "শুধুমাত্র একটি অতিথি ডিভাইস অনুমোদন করুন (Traffic Rules)" + +msgid "Instead of opening the whole Guest zone, allow only a specific device such as a shared printer." +msgstr "সম্পূর্ণ অতিথি জোন খোলার পরিবর্তে, একটি নির্দিষ্ট ডিভাইস (যেমন একটি শেয়ার্ড প্রিন্টার) অনুমোদন করুন।" + +msgid "Add new forward rule" +msgstr "নতুন forward rule যোগ করুন" + +msgid "the printer's IP (e.g." +msgstr "প্রিন্টারের IP (যেমন" + +msgid "Check for conflicts:" +msgstr "দ্বন্দ্ব যাচাই করুন:" + +msgid "If you already enabled zone-level" +msgstr "যদি আপনি ইতিমধ্যে জোন-স্তরে সক্রিয় করে থাকেন" + +msgid ", this traffic rule is redundant. Untick the zone-level forward first." +msgstr ", তবে এই traffic rule অপ্রয়োজনীয়। প্রথমে জোন-স্তরের forward আনটিক করুন।" + +msgid "Static Lease (IP reservation)" +msgstr "Static Lease (IP সংরক্ষণ)" + +msgid "If a device's IP changes, firewall rules targeting it stop working. Reserve its IP so it stays fixed." +msgstr "ডিভাইসের IP পরিবর্তন হলে, এটিকে লক্ষ্য করে ফায়ারওয়াল নিয়ম কাজ করা বন্ধ করে। এর IP সংরক্ষণ করুন যাতে এটি স্থির থাকে।" + +msgid "pick from the dropdown." +msgstr "ড্রপডাউন থেকে বাছুন।" + +msgid "the fixed IP (must be inside the VLAN's subnet and outside the dynamic DHCP pool, e.g." +msgstr "নির্দিষ্ট IP (VLAN-এর সাবনেটের ভিতরে এবং গতিশীল DHCP পুলের বাইরে হতে হবে, যেমন" + +msgid "leave empty or set" +msgstr "খালি রাখুন বা সেট করুন" + +msgid "Pro tip:" +msgstr "প্রো টিপ:" + +msgid "Restart the device (or reconnect it) so it requests a fresh lease and picks up the reserved IP." +msgstr "ডিভাইসটি রিস্টার্ট করুন (বা পুনঃসংযুক্ত করুন) যাতে এটি একটি নতুন লিজ চায় এবং সংরক্ষিত IP পায়।" + +msgid "Anti-lockout & rollback" +msgstr "অ্যান্টি-লকআউট ও রোলব্যাক" + +msgid "If a VLAN change locks you out, use these tools." +msgstr "একটি VLAN পরিবর্তন আপনাকে লকআউট করলে, এই সরঞ্জামগুলি ব্যবহার করুন।" + +msgid "LuCI shows a confirmation countdown after applying network changes. If you lose access, do nothing — after about 90 seconds it reverts automatically." +msgstr "নেটওয়ার্ক পরিবর্তন প্রয়োগের পর LuCI একটি নিশ্চিতকরণ কাউন্টডাউন দেখায়। অ্যাক্সেস হারালে, কিছু করবেন না — প্রায় ৯০ সেকেন্ড পরে স্বয়ংক্রিয়ভাবে ফিরে যায়।" + +msgid "This app has its own safety toggle." +msgstr "এই অ্যাপের নিজস্ব নিরাপত্তা টগল আছে।" + +msgid "Safe apply" +msgstr "নিরাপদ প্রয়োগ" + +msgid "above snapshots the network config before every apply, so you can restore with one click." +msgstr "উপরে প্রতিটি প্রয়োগের আগে নেটওয়ার্ক বিন্যাসকরণের স্ন্যাপশট নেয়, তাই এক ক্লিকে পুনরুদ্ধার করা যায়।" + +msgid "If you get locked out mid-config, connect via serial console or another VLAN and run the restore command." +msgstr "কনফিগারেশনের মাঝখানে লকআউট হলে, সিরিয়াল কনসোল বা অন্য VLAN দিয়ে সংযুক্ত হন এবং পুনরুদ্ধার আদেশ চালান।" + +msgid "Restore command:" +msgstr "পুনরুদ্ধার আদেশ:" + +msgid "Note: The path" +msgstr "দ্রষ্টব্য: পথটি" + +msgid "is specific to this app. On a plain OpenWrt system with a manual backup, use your own path (e.g." +msgstr "এই অ্যাপের জন্য নির্দিষ্ট। ম্যানুয়াল ব্যাকআপ সহ একটি সাধারণ OpenWrt সিস্টেমে, আপনার নিজের পথ ব্যবহার করুন (যেমন" + +msgid "If reload does not restore access, reboot." +msgstr "রিলোড অ্যাক্সেস পুনরুদ্ধার না করলে, রিবুট করুন।" diff --git a/luci-app-easymesh/root/usr/bin/easymesh-vlan-arm b/luci-app-easymesh/root/usr/bin/easymesh-vlan-arm new file mode 100755 index 000000000..d4ce00a31 --- /dev/null +++ b/luci-app-easymesh/root/usr/bin/easymesh-vlan-arm @@ -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 diff --git a/luci-app-easymesh/root/usr/bin/easymesh-vlan-backup b/luci-app-easymesh/root/usr/bin/easymesh-vlan-backup new file mode 100755 index 000000000..99aaf7907 --- /dev/null +++ b/luci-app-easymesh/root/usr/bin/easymesh-vlan-backup @@ -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 diff --git a/luci-app-easymesh/root/usr/bin/easymesh-vlan-confirm b/luci-app-easymesh/root/usr/bin/easymesh-vlan-confirm new file mode 100755 index 000000000..526bf476c --- /dev/null +++ b/luci-app-easymesh/root/usr/bin/easymesh-vlan-confirm @@ -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 diff --git a/luci-app-easymesh/root/usr/bin/easymesh-vlan-restore b/luci-app-easymesh/root/usr/bin/easymesh-vlan-restore new file mode 100755 index 000000000..b363bbe8d --- /dev/null +++ b/luci-app-easymesh/root/usr/bin/easymesh-vlan-restore @@ -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 diff --git a/luci-app-fastnet/Makefile b/luci-app-fastnet/Makefile index f5bcfc05f..ce5764f0c 100644 --- a/luci-app-fastnet/Makefile +++ b/luci-app-fastnet/Makefile @@ -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 diff --git a/luci-app-firewall/htdocs/luci-static/resources/view/firewall/zones.js b/luci-app-firewall/htdocs/luci-static/resources/view/firewall/zones.js index 867c64c04..532ba11e1 100644 --- a/luci-app-firewall/htdocs/luci-static/resources/view/firewall/zones.js +++ b/luci-app-firewall/htdocs/luci-static/resources/view/firewall/zones.js @@ -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'); } } diff --git a/luci-app-firewall/root/usr/libexec/luci-firewall-status b/luci-app-firewall/root/usr/libexec/luci-firewall-status index eab4923c3..b3e3e23f8 100755 --- a/luci-app-firewall/root/usr/libexec/luci-firewall-status +++ b/luci-app-firewall/root/usr/libexec/luci-firewall-status @@ -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 diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index 57a817795..ce49bd8e2 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -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 diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index ec2065203..89e466c6d 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -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:= \ diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua index 8d85cf492..f504e826a 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua @@ -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.") .. "
" .. translate("Supports only Xray or Sing-box node types.") .. "
" .. string.format('%s', - 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") diff --git a/luci-app-passwall/po/zh-cn/passwall.po b/luci-app-passwall/po/zh-cn/passwall.po index f2b86ccaf..dafc1bc62 100644 --- a/luci-app-passwall/po/zh-cn/passwall.po +++ b/luci-app-passwall/po/zh-cn/passwall.po @@ -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" diff --git a/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua b/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua index 8a316c13a..3eb86b45f 100644 --- a/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua +++ b/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua @@ -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) diff --git a/luci-app-passwall/root/usr/share/passwall/subscribe.lua b/luci-app-passwall/root/usr/share/passwall/subscribe.lua index f43c47239..356d6f232 100755 --- a/luci-app-passwall/root/usr/share/passwall/subscribe.lua +++ b/luci-app-passwall/root/usr/share/passwall/subscribe.lua @@ -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") diff --git a/luci-app-passwall2/Makefile b/luci-app-passwall2/Makefile index f8acab118..2393e2c0c 100644 --- a/luci-app-passwall2/Makefile +++ b/luci-app-passwall2/Makefile @@ -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:= \ diff --git a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe_config.lua b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe_config.lua index e6fc4c9bc..36407bdc2 100644 --- a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe_config.lua +++ b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe_config.lua @@ -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.") .. "
" .. - translate("Supports only Xray or Sing-box node types.") + translate("Supports only Xray or Sing-box node types.") .. "
" .. string.format('%s', + 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") diff --git a/luci-app-passwall2/po/fa/passwall2.po b/luci-app-passwall2/po/fa/passwall2.po index b5923cea5..2f2644743 100644 --- a/luci-app-passwall2/po/fa/passwall2.po +++ b/luci-app-passwall2/po/fa/passwall2.po @@ -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 "بدون انقضا" diff --git a/luci-app-passwall2/po/ru/passwall2.po b/luci-app-passwall2/po/ru/passwall2.po index 63fb7a088..700bedd5e 100644 --- a/luci-app-passwall2/po/ru/passwall2.po +++ b/luci-app-passwall2/po/ru/passwall2.po @@ -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 "Бессрочно" diff --git a/luci-app-passwall2/po/zh-cn/passwall2.po b/luci-app-passwall2/po/zh-cn/passwall2.po index 2982c732b..0017efa44 100644 --- a/luci-app-passwall2/po/zh-cn/passwall2.po +++ b/luci-app-passwall2/po/zh-cn/passwall2.po @@ -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 "长期有效" diff --git a/luci-app-passwall2/po/zh-tw/passwall2.po b/luci-app-passwall2/po/zh-tw/passwall2.po index 0d38f8191..1d993c711 100644 --- a/luci-app-passwall2/po/zh-tw/passwall2.po +++ b/luci-app-passwall2/po/zh-tw/passwall2.po @@ -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 "長期有效" diff --git a/luci-app-passwall2/root/usr/share/passwall2/clash_subconverter.lua b/luci-app-passwall2/root/usr/share/passwall2/clash_subconverter.lua new file mode 100644 index 000000000..704a05c8c --- /dev/null +++ b/luci-app-passwall2/root/usr/share/passwall2/clash_subconverter.lua @@ -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 diff --git a/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua b/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua index cab33c561..69ec808a1 100755 --- a/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua +++ b/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua @@ -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") diff --git a/mise/Makefile b/mise/Makefile index 302c0e7ce..a0b10f92c 100644 --- a/mise/Makefile +++ b/mise/Makefile @@ -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 diff --git a/wifidog-wiwiz/Makefile b/wifidog-wiwiz/Makefile index 1895fd8ad..2d913d2ee 100644 --- a/wifidog-wiwiz/Makefile +++ b/wifidog-wiwiz/Makefile @@ -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 diff --git a/wifidog-wiwiz/files/etc/config/wiwiz b/wifidog-wiwiz/files/etc/config/wiwiz index 85697c2af..4eb29d191 100644 --- a/wifidog-wiwiz/files/etc/config/wiwiz +++ b/wifidog-wiwiz/files/etc/config/wiwiz @@ -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 '' \ No newline at end of file diff --git a/wifidog-wiwiz/files/usr/lib/lua/luci/model/cbi/wiwiz.lua b/wifidog-wiwiz/files/usr/lib/lua/luci/model/cbi/wiwiz.lua index 8cd368860..9f2ba8ba2 100644 --- a/wifidog-wiwiz/files/usr/lib/lua/luci/model/cbi/wiwiz.lua +++ b/wifidog-wiwiz/files/usr/lib/lua/luci/model/cbi/wiwiz.lua @@ -35,7 +35,7 @@ dhcp_portal.rmempty = false ver = portal:option(DummyValue, "ver", translate("Plugin Version"), translate("Readme")); 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 diff --git a/wifidog-wiwiz/files/usr/local/hsbuilder/hsbuilder.sh b/wifidog-wiwiz/files/usr/local/hsbuilder/hsbuilder.sh index a5743b986..19e65e7e9 100644 --- a/wifidog-wiwiz/files/usr/local/hsbuilder/hsbuilder.sh +++ b/wifidog-wiwiz/files/usr/local/hsbuilder/hsbuilder.sh @@ -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") diff --git a/wifidog-wiwiz/files/usr/local/hsbuilder/hsbuilder_helper.sh b/wifidog-wiwiz/files/usr/local/hsbuilder/hsbuilder_helper.sh index 11c0012c0..f607295a1 100644 --- a/wifidog-wiwiz/files/usr/local/hsbuilder/hsbuilder_helper.sh +++ b/wifidog-wiwiz/files/usr/local/hsbuilder/hsbuilder_helper.sh @@ -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" } diff --git a/wifidog-wiwiz/files/usr/local/hsbuilder/luci_check.sh b/wifidog-wiwiz/files/usr/local/hsbuilder/luci_check.sh new file mode 100644 index 000000000..19fcd2c5e --- /dev/null +++ b/wifidog-wiwiz/files/usr/local/hsbuilder/luci_check.sh @@ -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 diff --git a/wifidog-wiwiz/files/usr/local/hsbuilder/ver b/wifidog-wiwiz/files/usr/local/hsbuilder/ver index 0da667e1e..6cbdc99a2 100644 --- a/wifidog-wiwiz/files/usr/local/hsbuilder/ver +++ b/wifidog-wiwiz/files/usr/local/hsbuilder/ver @@ -1 +1 @@ -3.6.8 \ No newline at end of file +3.6.9 \ No newline at end of file