mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 09:54:06 +08:00
🔥 Sync 2026-09-29 06:58:20
This commit is contained in:
1 parent
498fa20885
commit
37d63c09a5
22 files changed
+459
-121
No files matched your search
@@ -33,19 +33,7 @@
|
||||
<div style="display:flex;gap:0.5em;align-items:center;margin:0.5em 0">
|
||||
<label><%:Format%>:
|
||||
<select name="fmt">
|
||||
<option value="clashmeta" <%=fmt=="clashmeta" and "selected" or ""%>>Clash.Meta / Mihomo</option>
|
||||
<option value="stash" <%=fmt=="stash" and "selected" or ""%>>Stash</option>
|
||||
<option value="surge" <%=fmt=="surge" and "selected" or ""%>>Surge</option>
|
||||
<option value="surfboard" <%=fmt=="surfboard" and "selected" or ""%>>Surfboard</option>
|
||||
<option value="surgemac" <%=fmt=="surgemac" and "selected" or ""%>>SurgeMac</option>
|
||||
<option value="loon" <%=fmt=="loon" and "selected" or ""%>>Loon</option>
|
||||
<option value="egern" <%=fmt=="egern" and "selected" or ""%>>Egern</option>
|
||||
<option value="qx" <%=fmt=="qx" and "selected" or ""%>>Quantumult X</option>
|
||||
<option value="shadowrocket" <%=fmt=="shadowrocket" and "selected" or ""%>>Shadowrocket</option>
|
||||
<option value="singbox" <%=fmt=="singbox" and "selected" or ""%>>sing-box</option>
|
||||
<option value="v2ray" <%=fmt=="v2ray" and "selected" or ""%>>V2Ray</option>
|
||||
<option value="v2rayuri" <%=fmt=="v2rayuri" and "selected" or ""%>>V2Ray URI</option>
|
||||
<option value="plain" <%=fmt=="plain" and "selected" or ""%>>Plain JSON</option>
|
||||
<% for _, fo in ipairs(output.FORMAT_OPTIONS) do %><option value="<%=fo[1]%>" <%=fmt==fo[1] and "selected" or ""%>><%=fo[2]%></option><% end %>
|
||||
</select>
|
||||
</label>
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Generate%>"/>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
<%
|
||||
local core = require("substore.core")
|
||||
local output = require("substore.output")
|
||||
local http_env = require("luci.http")
|
||||
local items = core.list()
|
||||
local base = luci.dispatcher.build_url("substore", "download")
|
||||
@@ -81,19 +82,7 @@
|
||||
<select id="fmt_<%=it.id%>" data-base="<%=luci.util.pcdata(sub_link_base)%>"
|
||||
onchange="substore_setlink(this, '<%=it.id%>')">
|
||||
<option value=""><%:Select format%></option>
|
||||
<option value="clashmeta">Clash.Meta / Mihomo</option>
|
||||
<option value="stash">Stash</option>
|
||||
<option value="surge">Surge</option>
|
||||
<option value="surfboard">Surfboard</option>
|
||||
<option value="surgemac">SurgeMac</option>
|
||||
<option value="loon">Loon</option>
|
||||
<option value="egern">Egern</option>
|
||||
<option value="qx">Quantumult X</option>
|
||||
<option value="shadowrocket">Shadowrocket</option>
|
||||
<option value="singbox">sing-box</option>
|
||||
<option value="v2ray">V2Ray</option>
|
||||
<option value="v2rayuri">V2Ray URI</option>
|
||||
<option value="plain">Plain JSON</option>
|
||||
<% for _, fo in ipairs(output.FORMAT_OPTIONS) do %><option value="<%=fo[1]%>"><%=fo[2]%></option><% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div id="linkwrap_<%=it.id%>" style="display:none;margin-top:0.25em">
|
||||
|
||||
@@ -1,62 +1,23 @@
|
||||
-- output.lua — 订阅输出统一分发(所有 13 种目标格式)
|
||||
-- output.lua — 订阅输出统一分发(所有目标格式的唯一注册点)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
local clash_meta = require("substore.output_clash_meta")
|
||||
local output_uri = require("substore.output_uri")
|
||||
local output_singbox = require("substore.output_singbox")
|
||||
local output_v2ray = require("substore.output_v2ray")
|
||||
local output_formats = require("substore.output_formats")
|
||||
local output_wgconf = require("substore.output_wireguard_conf")
|
||||
|
||||
local M = {}
|
||||
|
||||
local function esc_yaml(s)
|
||||
s = tostring(s or "")
|
||||
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n")
|
||||
if s:find("[ :#{}[\],&*?|>'\"%@`]", 1, true) or s:match("^[-?]*:") then
|
||||
return '"' .. s .. '"'
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
function M.to_clash_yaml(nodes)
|
||||
local out = {}
|
||||
out[#out + 1] = "proxies:"
|
||||
for _, n in ipairs(nodes) do
|
||||
local proto = n.proto or "vmess"
|
||||
local name = esc_yaml(n.name or "")
|
||||
out[#out + 1] = "- name: " .. name
|
||||
out[#out + 1] = " type: " .. proto
|
||||
out[#out + 1] = " server: " .. esc_yaml(n.server or "")
|
||||
out[#out + 1] = " port: " .. tostring(n.port or 0)
|
||||
if n.uuid then out[#out + 1] = " uuid: " .. esc_yaml(n.uuid) end
|
||||
if n.password then out[#out + 1] = " password: " .. esc_yaml(n.password) end
|
||||
if n.method then out[#out + 1] = " cipher: " .. esc_yaml(n.method) end
|
||||
if n.net then out[#out + 1] = " network: " .. esc_yaml(n.net) end
|
||||
if n.security then out[#out + 1] = " tls: " .. tostring(n.security ~= "none") end
|
||||
if n.sni then out[#out + 1] = " sni: " .. esc_yaml(n.sni) end
|
||||
end
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = "proxy-groups:"
|
||||
out[#out + 1] = "- name: ALL"
|
||||
out[#out + 1] = " type: select"
|
||||
out[#out + 1] = " proxies: [REJECT]"
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
function M.to_json(nodes)
|
||||
return util.json_encode(nodes)
|
||||
end
|
||||
|
||||
function M.to_base64(nodes)
|
||||
local yaml = M.to_clash_yaml(nodes)
|
||||
return util.base64_encode(yaml)
|
||||
end
|
||||
|
||||
-- 目标格式别名映射(兼容 ?target=X 的各类写法)
|
||||
M.FORMAT_ALIASES = {
|
||||
clash = "clashmeta",
|
||||
-- Clash 原版:vless/hysteria2/tuic/wireguard 等原版不支持的协议会被过滤
|
||||
clash = "clash",
|
||||
clashclassic = "clash",
|
||||
clashpremium = "clash",
|
||||
-- Clash.Meta / Mihomo
|
||||
yaml = "clashmeta",
|
||||
clashmeta = "clashmeta",
|
||||
mihomo = "clashmeta",
|
||||
@@ -77,6 +38,13 @@ M.FORMAT_ALIASES = {
|
||||
v2rayuri = "v2rayuri",
|
||||
v2ray_uri = "v2rayuri",
|
||||
uri = "v2rayuri",
|
||||
-- wg-quick / AmneziaWG .conf
|
||||
wgconf = "wgconf",
|
||||
wg = "wgconf",
|
||||
wireguard = "wgconf",
|
||||
amneziawg = "wgconf",
|
||||
amnezia = "wgconf",
|
||||
conf = "wgconf",
|
||||
plain = "plain",
|
||||
plainjson = "plain",
|
||||
json = "plain",
|
||||
@@ -85,6 +53,7 @@ M.FORMAT_ALIASES = {
|
||||
|
||||
-- 目标格式的 HTTP Content-Type
|
||||
local CONTENT_TYPES = {
|
||||
clash = "text/plain; charset=utf-8",
|
||||
clashmeta = "text/plain; charset=utf-8",
|
||||
stash = "text/plain; charset=utf-8",
|
||||
surge = "text/plain; charset=utf-8",
|
||||
@@ -97,12 +66,16 @@ local CONTENT_TYPES = {
|
||||
singbox = "application/json; charset=utf-8",
|
||||
v2ray = "application/json; charset=utf-8",
|
||||
v2rayuri = "text/plain; charset=utf-8",
|
||||
wgconf = "text/plain; charset=utf-8",
|
||||
plain = "application/json; charset=utf-8",
|
||||
}
|
||||
|
||||
-- 未指定 target 时的默认格式(保持历史行为:Clash.Meta)
|
||||
local DEFAULT_FORMAT = "clashmeta"
|
||||
|
||||
function M.content_type_for(format)
|
||||
format = (format or "clash") or "clash"
|
||||
if type(format) ~= "string" then format = "clash" end
|
||||
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
|
||||
if type(format) ~= "string" then format = DEFAULT_FORMAT end
|
||||
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
|
||||
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
|
||||
return CONTENT_TYPES[norm]
|
||||
@@ -110,6 +83,7 @@ end
|
||||
|
||||
-- 目标格式的下载文件名后缀
|
||||
local FILENAME_EXT = {
|
||||
clash = "yaml",
|
||||
clashmeta = "yaml",
|
||||
stash = "yaml",
|
||||
surge = "conf",
|
||||
@@ -122,26 +96,47 @@ local FILENAME_EXT = {
|
||||
singbox = "json",
|
||||
v2ray = "json",
|
||||
v2rayuri = "txt",
|
||||
wgconf = "conf",
|
||||
plain = "json",
|
||||
}
|
||||
|
||||
function M.extension_for(format)
|
||||
format = (format or "clash") or "clash"
|
||||
if type(format) ~= "string" then format = "clash" end
|
||||
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
|
||||
if type(format) ~= "string" then format = DEFAULT_FORMAT end
|
||||
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
|
||||
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
|
||||
return FILENAME_EXT[norm] or "txt"
|
||||
end
|
||||
|
||||
-- UI 下拉使用的有序格式列表(单一数据源;模板由此渲染,避免多处硬编码不同步)
|
||||
M.FORMAT_OPTIONS = {
|
||||
{ "clashmeta", "Clash.Meta / Mihomo" },
|
||||
{ "clash", "Clash" },
|
||||
{ "stash", "Stash" },
|
||||
{ "surge", "Surge" },
|
||||
{ "surfboard", "Surfboard" },
|
||||
{ "surgemac", "SurgeMac" },
|
||||
{ "loon", "Loon" },
|
||||
{ "egern", "Egern" },
|
||||
{ "qx", "Quantumult X" },
|
||||
{ "shadowrocket", "Shadowrocket" },
|
||||
{ "singbox", "sing-box" },
|
||||
{ "v2ray", "V2Ray" },
|
||||
{ "v2rayuri", "V2Ray URI" },
|
||||
{ "wgconf", "WireGuard / AmneziaWG .conf" },
|
||||
{ "plain", "Plain JSON" },
|
||||
}
|
||||
|
||||
-- 统一分发:nodes → 目标格式字符串
|
||||
function M.generate(nodes, format, options)
|
||||
format = (format or "clash") or "clash"
|
||||
if type(format) ~= "string" then format = "clash" end
|
||||
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
|
||||
if type(format) ~= "string" then format = DEFAULT_FORMAT end
|
||||
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
|
||||
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
|
||||
if not norm then return nil, "unsupported format: " .. tostring(format) end
|
||||
|
||||
if norm == "clashmeta" then return clash_meta.generate(nodes, options) end
|
||||
if norm == "clash" then return output_formats.to_clash(nodes, options) end
|
||||
if norm == "stash" then return output_formats.to_stash(nodes, options) end
|
||||
if norm == "surge" then return output_formats.to_surge(nodes, options) end
|
||||
if norm == "surfboard" then return output_formats.to_surfboard(nodes, options) end
|
||||
@@ -153,6 +148,7 @@ function M.generate(nodes, format, options)
|
||||
if norm == "singbox" then return output_singbox.generate(nodes) end
|
||||
if norm == "v2ray" then return output_v2ray.generate(nodes) end
|
||||
if norm == "v2rayuri" then return output_uri.to_v2ray_uri(nodes) end
|
||||
if norm == "wgconf" then return output_wgconf.generate(nodes, options) end
|
||||
if norm == "plain" then return output_formats.to_plain(nodes) end
|
||||
|
||||
return nil, "unsupported format: " .. tostring(format)
|
||||
|
||||
@@ -147,6 +147,22 @@ function M.to_stash(nodes, options)
|
||||
return clash_meta.generate(nodes, options)
|
||||
end
|
||||
|
||||
-- Clash 原版(Dreamacro Clash / ClashX / Clash for Windows)不支持的协议类型。
|
||||
-- 只排除"确定不支持"的,不做白名单,避免误丢原版其实支持的类型。
|
||||
local CLASH_LEGACY_UNSUPPORTED = {
|
||||
vless = true, hysteria2 = true, hysteria = true, tuic = true, wireguard = true,
|
||||
}
|
||||
|
||||
-- Clash 原版:过滤掉原版不认识的协议后,复用 Clash.Meta 的 YAML 生成
|
||||
function M.to_clash(nodes, options)
|
||||
local kept = {}
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
local p = props(n)
|
||||
if not CLASH_LEGACY_UNSUPPORTED[p] then kept[#kept + 1] = n end
|
||||
end
|
||||
return clash_meta.generate(kept, options)
|
||||
end
|
||||
|
||||
-- Quantumult X
|
||||
function M.to_qx(nodes, options)
|
||||
options = options or {}
|
||||
@@ -210,6 +226,7 @@ function M.generate(nodes, format, options)
|
||||
if format == "egern" then return M.to_egern(nodes, options) end
|
||||
if format == "qx" then return M.to_qx(nodes, options) end
|
||||
if format == "stash" then return M.to_stash(nodes, options) end
|
||||
if format == "clash" then return M.to_clash(nodes, options) end
|
||||
if format == "plain" then return M.to_plain(nodes) end
|
||||
return nil, "unsupported format: " .. tostring(format)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
-- output_wireguard_conf.lua — wg-quick / AmneziaWG .conf 输出(纯 Lua)
|
||||
-- luci-app-substore
|
||||
-- 与 parser.parse_wireguard_conf 互为逆操作:导入解析 [Interface] / [Peer],此处按同格式写回。
|
||||
|
||||
local M = {}
|
||||
|
||||
-- 仅 wireguard 节点可用 .conf 表达;其余协议无法落到单行/单段,直接丢弃
|
||||
-- (与 output_formats.surge_config 丢弃 wireguard 的处理对称)
|
||||
local function is_wireguard(n)
|
||||
local p = (n.proto or ""):lower()
|
||||
return p == "wireguard" or p == "wg"
|
||||
end
|
||||
|
||||
-- amnezia-wg-option 键 → .conf 键:首字母大写(jc → Jc,jmin → Jmin,itime → Itime)
|
||||
local function conf_key(k)
|
||||
return (k:gsub("^%l", string.upper))
|
||||
end
|
||||
|
||||
-- 值可能是标量或数组,统一转为 "a, b, c"
|
||||
local function join_list(v)
|
||||
if v == nil then return nil end
|
||||
if type(v) == "table" then
|
||||
local t = {}
|
||||
for _, x in ipairs(v) do
|
||||
if x ~= nil and tostring(x) ~= "" then t[#t + 1] = tostring(x) end
|
||||
end
|
||||
if #t == 0 then return nil end
|
||||
return table.concat(t, ", ")
|
||||
end
|
||||
if tostring(v) == "" then return nil end
|
||||
return tostring(v)
|
||||
end
|
||||
|
||||
-- Endpoint:IPv6 主机需加方括号(wg-quick 语法)
|
||||
local function endpoint_of(n)
|
||||
local host = n.server
|
||||
local port = n.port
|
||||
if not host or host == "" or not port then return nil end
|
||||
if host:find(":", 1, true) and host:sub(1, 1) ~= "[" then
|
||||
host = "[" .. host .. "]"
|
||||
end
|
||||
return host .. ":" .. tostring(port)
|
||||
end
|
||||
|
||||
-- 单个节点 → .conf 文本行数组
|
||||
local function build_section(n)
|
||||
local out = {}
|
||||
local function put(k, v)
|
||||
if v ~= nil and v ~= "" then out[#out + 1] = k .. " = " .. v end
|
||||
end
|
||||
|
||||
out[#out + 1] = "[Interface]"
|
||||
put("PrivateKey", n["private-key"] or n.private_key)
|
||||
-- Address 合并 IPv4 / IPv6
|
||||
local addr = {}
|
||||
if n.ip then addr[#addr + 1] = n.ip end
|
||||
if n.ipv6 then addr[#addr + 1] = n.ipv6 end
|
||||
put("Address", join_list(addr))
|
||||
put("ListenPort", n["listen-port"] or n.listen_port)
|
||||
put("MTU", n.mtu)
|
||||
put("DNS", join_list(n.dns))
|
||||
|
||||
-- AmneziaWG 参数:按 .conf 键名排序输出,保证可 diff
|
||||
local opt = n["amnezia-wg-option"]
|
||||
if type(opt) == "table" then
|
||||
local keys = {}
|
||||
for k in pairs(opt) do keys[#keys + 1] = k end
|
||||
table.sort(keys)
|
||||
for _, k in ipairs(keys) do
|
||||
put(conf_key(k), tostring(opt[k]))
|
||||
end
|
||||
end
|
||||
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = "[Peer]"
|
||||
put("PublicKey", n["public-key"] or n.public_key or n["peer-public-key"] or n.peer_public_key)
|
||||
put("PresharedKey", n["pre-shared-key"] or n.pre_shared_key or n["preshared-key"] or n.preshared_key)
|
||||
put("AllowedIPs", join_list(n["allowed-ips"]))
|
||||
put("Endpoint", endpoint_of(n))
|
||||
put("PersistentKeepalive", n["persistent-keepalive"])
|
||||
|
||||
-- 说明:不输出 Reserved —— 它不是 wg-quick 标准键,写进 .conf 可能被客户端拒绝。
|
||||
-- reserved 仍保留在 clash.meta / sing-box / URI 输出中。
|
||||
return out
|
||||
end
|
||||
|
||||
-- nodes → wg-quick .conf 文本
|
||||
-- 注意:wg-quick / AmneziaWG 客户端一个文件导入一条隧道。单节点时输出即为标准单接口
|
||||
-- .conf;多节点时各段以 "# <名称>" 注释分隔,便于查看与手工拆分。
|
||||
function M.generate(nodes, options)
|
||||
local blocks = {}
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
if type(n) == "table" and is_wireguard(n) then
|
||||
local sec = build_section(n)
|
||||
local named = { "# " .. (n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
|
||||
for _, line in ipairs(sec) do named[#named + 1] = line end
|
||||
blocks[#blocks + 1] = table.concat(named, "\n")
|
||||
end
|
||||
end
|
||||
if #blocks == 0 then
|
||||
-- 明确报错优于返回空文件:用户能知道是"没有 WireGuard 节点"而非订阅坏了
|
||||
return nil, "没有可导出的 WireGuard 节点"
|
||||
end
|
||||
return table.concat(blocks, "\n\n") .. "\n"
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in new issue
Block a user