-- Copyright (C) 2017 yushi studio -- Licensed to the public under the GNU General Public License v3. module("luci.controller.shadowsocksr", package.seeall) require "nixio" require "nixio.fs" local uci = require("luci.model.uci").cursor() local function sh_uci_commit(config) luci.sys.call(string.format("uci -q commit %s", config)) end local function is_old_uci() return luci.sys.call("grep -E 'require[ \t]*\"uci\"' /usr/lib/lua/luci/model/uci.lua >/dev/null 2>&1") == 0 end local function uci_save(cursor, config, commit, apply) if is_old_uci() then cursor:save(config) if commit then cursor:commit(config) if apply then luci.sys.call("/etc/init.d/" .. config .. " reload > /dev/null 2>&1 &") end end else commit = true if commit then if apply then cursor:commit(config) else sh_uci_commit(config) end end end end local function url(...) local url = string.format("admin/services/%s", "shadowsocksr") local args = { ... } for i, v in ipairs(args) do if v and v ~= "" then url = url .. "/" .. v end end return require "luci.dispatcher".build_url(url) end function index() if not nixio.fs.access("/etc/config/shadowsocksr") then call("act_reset") end local page page = entry({"admin", "services", "shadowsocksr"}, alias("admin", "services", "shadowsocksr", "client"), _("ShadowSocksR Plus+"), 10) page.dependent = true page.acl_depends = { "luci-app-ssr-plus" } entry({"admin", "services", "shadowsocksr", "client"}, cbi("shadowsocksr/client"), _("SSR Client"), 10).leaf = true entry({"admin", "services", "shadowsocksr", "servers"}, arcombine(cbi("shadowsocksr/servers", {autoapply = true}), cbi("shadowsocksr/client-config")), _("Servers Nodes"), 20).leaf = true entry({"admin", "services", "shadowsocksr", "control"}, cbi("shadowsocksr/control"), _("Access Control"), 30).leaf = true entry({"admin", "services", "shadowsocksr", "advanced"}, cbi("shadowsocksr/advanced"), _("Advanced Settings"), 50).leaf = true entry({"admin", "services", "shadowsocksr", "server"}, arcombine(cbi("shadowsocksr/server"), cbi("shadowsocksr/server-config")), _("SSR Server"), 60).leaf = true entry({"admin", "services", "shadowsocksr", "status"}, form("shadowsocksr/status"), _("Status"), 70).leaf = true entry({"admin", "services", "shadowsocksr", "check"}, call("check_status")) entry({"admin", "services", "shadowsocksr", "refresh"}, call("refresh_data")) entry({"admin", "services", "shadowsocksr", "subscribe"}, call("subscribe")) entry({"admin", "services", "shadowsocksr", "checkport"}, call("check_port")) entry({"admin", "services", "shadowsocksr", "log"}, form("shadowsocksr/log"), _("Log"), 80).leaf = true entry({"admin", "services", "shadowsocksr", "get_log"}, call("get_log")).leaf = true entry({"admin", "services", "shadowsocksr", "clear_log"}, call("clear_log")).leaf = true entry({"admin", "services", "shadowsocksr", "run"}, call("act_status")) entry({"admin", "services", "shadowsocksr", "ping"}, call("act_ping")) entry({"admin", "services", "shadowsocksr", "reset"}, call("act_reset")) entry({"admin", "services", "shadowsocksr", "restart"}, call("act_restart")) entry({"admin", "services", "shadowsocksr", "delete"}, call("act_delete")) --[[ API ]] entry({"admin", "services", "shadowsocksr", "add_node"}, call("act_add_node")).leaf = true entry({"admin", "services", "shadowsocksr", "remove_node"}, call("act_remove")) entry({"admin", "services", "shadowsocksr", "save_node_order"}, call("act_save_order")).leaf = true entry({"admin", "services", "shadowsocksr", "get_now_use_node"}, call("act_get_now_use_node")).leaf = true entry({'admin', 'services', "shadowsocksr", 'ip'}, call('check_ip')) -- 获取ip情况 --[[Backup]] entry({"admin", "services", "shadowsocksr", "backup"}, call("create_backup")).leaf = true end function check_site(host, port) local nixio = require "nixio" local socket = nixio.socket("inet", "stream") socket:setopt("socket", "rcvtimeo", 2) socket:setopt("socket", "sndtimeo", 2) local ret = socket:connect(host, port) socket:close() return ret end function get_ip_geo_info() local result = luci.sys.exec('curl --retry 3 -m 10 -LfsA "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36" http://ip-api.com/json/') local json = require "luci.jsonc" local info = json.parse(result) return { flag = string.lower(info.countryCode) or "un", country = get_country_name(info.countryCode) or "Unknown", ip = info.query, isp = info.isp } end function get_country_name(countryCode) local country_names = { US = "美国", CN = "中国", JP = "日本", GB = "英国", DE = "德国", FR = "法国", BR = "巴西", IT = "意大利", RU = "俄罗斯", CA = "加拿大", KR = "韩国", ES = "西班牙", AU = "澳大利亚", MX = "墨西哥", ID = "印度尼西亚", NL = "荷兰", TR = "土耳其", CH = "瑞士", SA = "沙特阿拉伯", SE = "瑞典", PL = "波兰", BE = "比利时", AR = "阿根廷", NO = "挪威", AT = "奥地利", TW = "台湾", ZA = "南非", TH = "泰国", DK = "丹麦", MY = "马来西亚", PH = "菲律宾", SG = "新加坡", IE = "爱尔兰", HK = "香港", FI = "芬兰", CL = "智利", PT = "葡萄牙", GR = "希腊", IL = "以色列", NZ = "新西兰", CZ = "捷克", RO = "罗马尼亚", VN = "越南", UA = "乌克兰", HU = "匈牙利", AE = "阿联酋", CO = "哥伦比亚", IN = "印度", EG = "埃及", PE = "秘鲁", TW = "台湾" } return country_names[countryCode] end function check_ip() local e = {} local port = 80 local geo_info = get_ip_geo_info(ip) e.ip = geo_info.ip e.flag = geo_info.flag e.country = geo_info.country e.isp = geo_info.isp e.baidu = check_site('www.baidu.com', port) e.taobao = check_site('www.taobao.com', port) e.google = check_site('www.google.com', port) e.youtube = check_site('www.youtube.com', port) luci.http.prepare_content('application/json') luci.http.write_json(e) end function subscribe() luci.sys.call("/usr/bin/lua /usr/share/shadowsocksr/subscribe.lua >>/var/log/ssrplus.log") luci.http.prepare_content("application/json") luci.http.write_json({ret = 1}) end function act_status() local e = {} e.running = luci.sys.call("busybox ps -w | grep ssr-retcp | grep -v grep >/dev/null") == 0 luci.http.prepare_content("application/json") luci.http.write_json(e) end function act_ping() local e = {} local domain = luci.http.formvalue("domain") local port = tonumber(luci.http.formvalue("port") or 0) local transport = (luci.http.formvalue("transport") or ""):lower() local wsPath = luci.http.formvalue("wsPath") or "" local tls = luci.http.formvalue("tls") local host = luci.http.formvalue("host") local type = (luci.http.formvalue("type") or ""):lower() local proto = (luci.http.formvalue("proto") or ""):lower() e.index = luci.http.formvalue("index") local is_ip = domain and domain:match("^%d+%.%d+%.%d+%.%d+$") -- 临时放行防火墙逻辑 local use_nft = luci.sys.call("command -v nft >/dev/null") == 0 local iret = false if domain then if use_nft then iret = luci.sys.call("nft add element inet ss_spec ss_spec_wan_ac { " .. domain .. " } 2>/dev/null") == 0 else iret = luci.sys.call("ipset add ss_spec_wan_ac " .. domain .. " 2>/dev/null") == 0 end end -- Hysteria2 节点检测 if proto:find("hysteria2") or type:find("hysteria2") then local node_id = e.index -- 调用Shell测试脚本 local cmd = string.format( "/usr/share/shadowsocksr/hy2_test.sh url_test_hy2 %s", node_id ) local res = luci.sys.exec(cmd) or "" -- 解析结果 local http_code, time_pre = string.match(res, "(%d+):([%d%.]+)") if http_code == "200" or http_code == "204" then e.socket = true e.ping = math.floor(tonumber(time_pre or 0) * 1000) else e.socket = false e.ping = 0 end elseif transport == "ws" then -- WebSocket 探测 local result = "" local success = false -- WebSocket 探测 (适用于域名,或带 SNI 的 IP) if not is_ip or (host and host ~= "") then local resolve_arg = "" local final_domain = domain if is_ip and host and host ~= "" then -- IP 模式下使用 --resolve 强制指定 SNI,解决 TLS 握手失败 resolve_arg = string.format("--resolve '%s:%d:%s' ", host, port, domain) final_domain = host end local prefix = (tls == '1') and "https://" or "http://" local address = prefix .. final_domain .. ':' .. port .. wsPath local cmd = string.format( "curl --http1.1 -m 2 -ksN -o /dev/null %s" .. "-w 'time_connect=%%{time_connect}\\nhttp_code=%%{http_code}' " .. "-H 'Connection: Upgrade' -H 'Upgrade: websocket' " .. "-H 'Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==' " .. "-H 'Sec-WebSocket-Version: 13' '%s'", resolve_arg, address ) result = luci.sys.exec(cmd) or "" success = (string.match(result, "http_code=(%d+)") == "101") end -- 如果深度探测失败,或是不支持深测的纯 IP if not success then local socket = nixio.socket("inet", "stream") if socket then socket:setopt("socket", "rcvtimeo", 3) socket:setopt("socket", "sndtimeo", 3) success = socket:connect(domain, port) socket:close() end --luci.sys.exec(string.format("echo 'Node %s (ws) failed deep test, using TCP fallback' >> /tmp/ping.log", domain)) end e.socket = success -- 解析延迟 (优先用 curl 数据,失败则回退到 tcping) local ping_time = tonumber(string.match(result, "time_connect=(%d+.%d%d%d)")) if ping_time and ping_time > 0 then e.ping = math.floor(ping_time * 1000) else local tcping_cmd = string.format("tcping -q -c 1 -t 1 -p %d %s 2>/dev/null | grep -o 'time=[0-9]*' | cut -d= -f2", port, domain) e.ping = tonumber(luci.sys.exec(tcping_cmd)) or 0 end else -- 3. 非 WebSocket 节点的探测逻辑 (TCP / ICMP / UDP) local socket = nixio.socket("inet", "stream") if socket then socket:setopt("socket", "rcvtimeo", 3) socket:setopt("socket", "sndtimeo", 3) e.socket = socket:connect(domain, port) socket:close() end -- 延迟:tcping -> ping -> nping(udp) local tcping_cmd = string.format("tcping -q -c 1 -t 1 -p %d %s 2>/dev/null | grep -o 'time=[0-9]*' | cut -d= -f2", port, domain) e.ping = tonumber(luci.sys.exec(tcping_cmd)) if not e.ping then local icmp_cmd = string.format("ping -c 1 -W 1 %s 2>/dev/null | grep -o 'time=[0-9.]*' | cut -d= -f2", domain) e.ping = tonumber(luci.sys.exec(icmp_cmd)) end if not e.ping then local udp_cmd = string.format("nping --udp -c 1 -p %d %s 2>/dev/null | grep -o 'Avg rtt: [0-9.]*ms' | awk '{print $3}' | sed 's/ms//' | head -1", port, domain) local udp_res = luci.sys.exec(udp_cmd) if udp_res and udp_res ~= "" then local ping_num = tonumber(udp_res) if ping_num then e.ping = math.floor(ping_num) end end end end -- 4. 清理防火墙规则 if iret then if use_nft then luci.sys.call("nft delete element inet ss_spec ss_spec_wan_ac { " .. domain .. " } 2>/dev/null") else luci.sys.call("ipset del ss_spec_wan_ac " .. domain .. " 2>/dev/null") end end luci.http.prepare_content("application/json") luci.http.write_json(e) end function check_status() local e = {} e.ret = luci.sys.call("/usr/bin/ssr-check www." .. luci.http.formvalue("set") .. ".com 80 3 1") luci.http.prepare_content("application/json") luci.http.write_json(e) end function refresh_data() local set = luci.http.formvalue("set") local retstring = loadstring("return " .. luci.sys.exec("/usr/bin/lua /usr/share/shadowsocksr/update.lua " .. set))() luci.http.prepare_content("application/json") luci.http.write_json(retstring) end function check_port() local retstring = "

" local s local server_name = "" local uci = require "luci.model.uci".cursor() local use_nft = luci.sys.call("command -v nft >/dev/null") == 0 uci:foreach("shadowsocksr", "servers", function(s) if s.alias then server_name = s.alias elseif s.server and s.server_port then server_name = s.server .. ":" .. s.server_port end -- 临时加入 set local iret = false if use_nft then iret = luci.sys.call("nft add element inet ss_spec ss_spec_wan_ac { " .. s.server .. " } 2>/dev/null") == 0 else iret = luci.sys.call("ipset add ss_spec_wan_ac " .. s.server .. " 2>/dev/null") == 0 end -- TCP 测试 local socket = nixio.socket("inet", "stream") socket:setopt("socket", "rcvtimeo", 3) socket:setopt("socket", "sndtimeo", 3) local ret = socket:connect(s.server, s.server_port) socket:close() if ret then retstring = retstring .. string.format("[%s] OK.
", server_name) else retstring = retstring .. string.format("[%s] Error.
", server_name) end -- 删除临时 set if iret then if use_nft then luci.sys.call("nft delete element inet ss_spec ss_spec_wan_ac { " .. s.server .. " } 2>/dev/null") else luci.sys.call("ipset del ss_spec_wan_ac " .. s.server) end end end) luci.http.prepare_content("application/json") luci.http.write_json({ret = retstring}) end function act_reset() luci.sys.call("/etc/init.d/shadowsocksr reset >/dev/null 2>&1") luci.http.redirect(luci.dispatcher.build_url("admin", "services", "shadowsocksr")) end function act_restart() luci.http.redirect(luci.dispatcher.build_url("admin", "services", "shadowsocksr")) end function act_delete() uci:delete_all("shadowsocksr", "servers", function(s) if s.hashkey or s.isSubscribe then return true else return false end end) uci:commit("shadowsocksr") for file in nixio.fs.glob("/tmp/sub_md5_*") do nixio.fs.remove(file) end luci.sys.call("/etc/init.d/shadowsocksr restart >/dev/null 2>&1 &") luci.http.redirect(luci.dispatcher.build_url("admin", "services", "shadowsocksr", "servers")) end function act_add_node() local redirect = luci.http.formvalue("redirect") local used_sid = {} local next_sid = 1 uci:foreach("shadowsocksr", "servers", function(s) local num = s[".name"]:match("^cfg(%x%x)") if num then local n = tonumber(num, 16) used_sid[n] = true end end) local function get_next_sid() while used_sid[next_sid] do next_sid = next_sid + 1 end used_sid[next_sid] = true return next_sid end local sid = uci:section("shadowsocksr", "servers", nil) local suffix = sid:sub(-4) uci:delete("shadowsocksr", sid) local id = get_next_sid() local cfgid = string.format("cfg%02x%s", id, suffix) uci:section("shadowsocksr", "servers", cfgid) uci_save(uci, "shadowsocksr") if redirect == "1" then luci.http.redirect(url("servers", cfgid)) else luci.http.write_json({ result = cfgid }) end end function act_remove() local id = luci.http.formvalue("id") if id then uci:delete("shadowsocksr", id) uci:commit("shadowsocksr") end luci.http.redirect(luci.dispatcher.build_url("admin", "services", "shadowsocksr", "servers")) end function act_save_order() local ids = luci.http.formvalue("ids") or "" local new_order = {} for id in ids:gmatch("([^,]+)") do new_order[#new_order + 1] = id end for idx, name in ipairs(new_order) do luci.sys.call(string.format("uci -q reorder %s.%s=%d", "shadowsocksr", name, idx - 1)) end sh_uci_commit("shadowsocksr") luci.http.write_json({ status = "ok" }) end function act_get_now_use_node() local result = {} local tcp_node = uci:get_first("shadowsocksr", "global", "global_server") if tcp_node then result["TCP"] = tcp_node end local udp_node = uci:get_first("shadowsocksr", "global", "udp_relay_server") if udp_node then result["UDP"] = udp_node end local netflix_node = uci:get_first("shadowsocksr", "global", "netflix_server") if netflix_node then result["netflix"] = netflix_node end local socks5_node = uci:get_first("shadowsocksr", "socks5_proxy", "server") if socks5_node then result["socks5"] = socks5_node end luci.http.prepare_content("application/json") luci.http.write_json(result) end function get_log() luci.http.write(luci.sys.exec("[ -f '/var/log/ssrplus.log' ] && cat /var/log/ssrplus.log")) end function clear_log() luci.sys.call("echo '' > /var/log/ssrplus.log") end function create_backup() local backup_files = { "/etc/config/shadowsocksr", "/etc/ssrplus/*" } local date = os.date("%Y-%m-%d-%H-%M-%S") local tar_file = "/tmp/shadowsocksr-" .. date .. "-backup.tar.gz" nixio.fs.remove(tar_file) local cmd = "tar -czf " .. tar_file .. " " .. table.concat(backup_files, " ") luci.sys.call(cmd) luci.http.header("Content-Disposition", "attachment; filename=shadowsocksr-" .. date .. "-backup.tar.gz") luci.http.header("X-Backup-Filename", "shadowsocksr-" .. date .. "-backup.tar.gz") luci.http.prepare_content("application/octet-stream") luci.http.write(nixio.fs.readfile(tar_file)) nixio.fs.remove(tar_file) end