Files

199 lines
6.2 KiB
Lua

#!/usr/bin/lua
-- Prints one server address per node of a subscription file, the fallback of the watchdog
-- for provider files that are not YAML.
--
-- The share links are the ones mihomo accepts (common/convert/converter.go), they follow
-- the schemes of the clients themselves:
-- ss:// SIP002, with the plugin query, or the legacy fully encoded form
-- ssr:// base64 of host:port:protocol:method:obfs:password/?params
-- vmess:// V2RayN base64 json, or the Xray VMessAEAD uri (XTLS/Xray-core#716)
-- vless:// Xray uri, the host of the uri may be base64 encoded
-- trojan:// password as userinfo
-- hysteria:// github.com/apernet/hysteria (docs/developers/URI-Scheme)
-- hysteria2:// and hy2://, also with the +realm suffix and with port hopping
-- tuic:// temporary unofficial standard, uuid:password or the v5 token as userinfo
-- anytls:// github.com/anytls/anytls-go (docs/uri_scheme.md)
-- mierus:// one uri per profile, the port and protocol query keys are repeated
-- socks5://, socks5h://, http://, https://
--
-- Only the server address is needed, every other part of the uri is ignored.
local util = require "luci.util"
local jsonc = require "luci.jsonc"
local BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local BASE64_PAD = 61
-- Decodes base64 in every form a subscription may use: standard or url safe, padded or not.
-- A string that is not base64 at all is not a link payload, the caller gets nil.
local function decode64(text)
if not text then
return nil
end
text = text:gsub("[%s]", ""):gsub("%-", "+"):gsub("_", "/"):gsub("=+$", "")
if text == "" or #text % 4 == 1 or not text:match("^[A-Za-z0-9+/]+$") then
return nil
end
text = text .. ("===="):sub(1, (4 - #text % 4) % 4)
local result = {}
for group in text:gmatch("....") do
local c3, c4 = group:byte(3, 4)
local i1 = BASE64:find(group:sub(1, 1), 1, true)
local i2 = BASE64:find(group:sub(2, 2), 1, true)
local i3 = c3 == BASE64_PAD and 0 or BASE64:find(group:sub(3, 3), 1, true)
local i4 = c4 == BASE64_PAD and 0 or BASE64:find(group:sub(4, 4), 1, true)
if not (i1 and i2 and i3 and i4) then
return nil
end
i1, i2, i3, i4 = i1 - 1, i2 - 1, i3 - 1, i4 - 1
result[#result + 1] = string.char(i1 * 4 + math.floor(i2 / 16))
if c3 ~= BASE64_PAD then
result[#result + 1] = string.char((i2 % 16) * 16 + math.floor(i3 / 4))
end
if c4 ~= BASE64_PAD then
result[#result + 1] = string.char((i3 % 4) * 64 + i4)
end
end
return table.concat(result)
end
-- scheme, userinfo, host, port, query and fragment of a uri. A host without a port and an
-- IPv6 host in brackets are accepted, the port of a hop link is a list of ports.
local function parse_uri(uri)
local scheme, body = uri:match("^([%w%+%-%.]+)://(.*)$")
if not scheme then
return nil
end
local authority = body:match("^([^/%?#]*)") or ""
local remainder = body:sub(#authority + 1)
local userinfo, hostport = authority:match("^(.*)@([^@]+)$")
if not hostport then
userinfo, hostport = nil, authority
end
local host, port
if hostport:match("^%[") then
host, port = hostport:match("^%[([^%]]+)%]:?([%d,%-]*)$")
else
host, port = hostport:match("^([^:]*):([%d,%-]*)$")
end
return {
scheme = scheme:lower(),
userinfo = userinfo,
host = host or hostport,
port = port,
path = remainder:match("^[^%?#]*"),
query = remainder:match("%?([^#]*)"),
fragment = remainder:match("#(.*)$")
}
end
-- mihomo decodes the host of a vless uri when it is base64 encoded, the watchdog can only
-- use it when the result looks like an address
local function decoded_host(host)
local decoded = decode64(host)
if decoded and decoded:match("^[%w%.%-%_]+$") and decoded:find("%.") then
return decoded
end
return host
end
local function without_brackets(host)
if not host then
return nil
end
return host:match("^%[(.-)%]$") or host
end
-- The server address of one share link, nil when the line is not a link the plugin knows
local function server_of(uri)
local parsed = parse_uri(uri)
if not parsed then
return nil
end
local scheme = parsed.scheme
local host = parsed.host
if scheme == "vmess" then
-- V2RayN: the whole link body is the base64 of a json document
local decoded = decode64((uri:match("^%w+://([^#]+)") or ""))
if decoded and decoded:sub(1, 1) == "{" then
local ok, node = pcall(jsonc.parse, decoded)
if ok and type(node) == "table" and type(node.add) == "string" and node.add ~= "" then
return without_brackets(node.add)
end
return nil
end
-- Xray VMessAEAD: uuid@host:port
return host
end
if scheme == "ss" then
if parsed.port and parsed.port ~= "" then
return host
end
-- the legacy link is the base64 of method:password@host:port
local decoded = decode64(host)
if decoded then
return decoded:match("@%[([^%]]+)%]") or decoded:match("@([^:/]+)")
end
return nil
end
if scheme == "ssr" then
local decoded = decode64((uri:match("^%w+://([^#]+)") or ""))
if not decoded then
return nil
end
return decoded:match("^([^:]+):")
end
if scheme == "hysteria2" or scheme == "hy2" or scheme == "hysteria2+realm" or scheme == "hy2+realm" then
return without_brackets(host)
end
if scheme == "vless" then
return decoded_host(host)
end
if scheme == "hysteria" or scheme == "tuic" or scheme == "trojan" or scheme == "mierus"
or scheme == "anytls" or scheme == "socks" or scheme == "socks5" or scheme == "socks5h"
or scheme == "http" or scheme == "https" then
return without_brackets(host)
end
return nil
end
-- A provider file that is a broken yaml still names the address of its nodes
local function server_of_yaml(line)
local value = line:match("^%s*server%s*:%s*([^%s#%]]+)")
if not value then
return nil
end
return value:gsub("^[\"']", ""):gsub("[\"']$", "")
end
local path = arg[1]
local file = path and io.open(path, "r")
if not file then
os.exit(1)
end
local content = file:read("*a")
file:close()
local decoded = decode64(content:gsub("%s+", ""))
if decoded and decoded:find("://", 1, true) then
content = decoded
end
for line in content:gmatch("[^\r\n]+") do
local text = util.trim(line)
local ok, server = pcall(server_of, text)
if not ok or not server or server == "" then
server = server_of_yaml(text)
end
if server and server ~= "" then
print(server)
end
end