-- output_singbox.lua — sing-box JSON 配置输出(纯 Lua) -- luci-app-substore local util = require("substore.util") local M = {} local function bool(v) if v == nil then return nil end if v == false or v == "false" or v == 0 or v == "0" then return false end return true end -- 把「数组或逗号分隔字符串」统一成数组(空项丢弃)。 -- 节点模型里 wireguard 的 allowed-ips / reserved / dns 形态取决于来源:Clash YAML -- 的嵌套列表解析后是 table,表单导入 / URI 导入后是 "0.0.0.0/0, ::/0" 这样的 -- 字符串。字符串原样写进 JSON 会得到 `"allowed_ips":"0.0.0.0/0"`,而 sing-box -- 这几个字段是 []string / []uint8,反序列化失败 → 整份配置拒绝启动。 local function as_list(v) local out = {} if type(v) == "table" then for _, x in ipairs(v) do if x ~= nil and tostring(x) ~= "" then out[#out + 1] = x end end else for x in tostring(v or ""):gmatch("[^,]+") do x = x:match("^%s*(.-)%s*$") if x ~= "" then out[#out + 1] = x end end end return out end -- 同 as_list,但把每项转成数字(非数字项丢弃)。 -- reserved 在 sing-box 里是 []uint8:JSON 里写成 ["1","2","3"](字符串)同样 -- 反序列化失败,必须输出 [1,2,3]。 local function as_num_list(v) local out = {} for _, x in ipairs(as_list(v)) do local num = tonumber(x) if num then out[#out + 1] = num end end return out end -- sing-box type 映射 local TYPE_MAP = { vmess = "vmess", vless = "vless", trojan = "trojan", shadowsocks = "shadowsocks", ss = "shadowsocks", hysteria2 = "hysteria2", hysteria = "hysteria", tuic = "tuic", wireguard = "wireguard", socks = "socks", socks5 = "socks", http = "http", -- anytls:sing-box 自 1.12.0 起支持(option/anytls.go,type 字面量 "anytls")。 anytls = "anytls", } -- 该协议能否落到 sing-box outbound(SSR 无法表达,跳过) local function supported(proto) return (TYPE_MAP[proto] or proto) ~= "ssr" end -- 构建 TLS 字段 -- -- enabled 必须显式写 true:sing-box 的 OutboundTLSOptions.Enabled 是 -- `bool` + `json:"enabled,omitempty"`(option/tls.go),缺省值即 false。 -- 上游 common/tls/client.go 里 `if !options.Enabled { return dialer, nil }` -- 会直接按明文拨号,hysteria2 / tuic 更会因为 `options.TLS == nil || -- !options.TLS.Enabled` 返回 C.ErrTLSRequired 而拒绝启动。只输出 -- server_name / insecure 而不带 enabled 等于没配 TLS。 local function build_tls(n) if not (n.security and n.security ~= "none") then return nil end local tls = { enabled = true } if n.sni or n.servername then tls.server_name = n.sni or n.servername end if n.alpn then if type(n.alpn) == "string" then local list = {} for p in n.alpn:gmatch("[^,]+") do list[#list + 1] = p:match("^%s*(.-)%s*$") end tls.alpn = list else tls.alpn = n.alpn end end if n["skip-cert-verify"] ~= nil then tls.insecure = bool(n["skip-cert-verify"]) elseif n.skip_cert_verify ~= nil then tls.insecure = bool(n.skip_cert_verify) end -- uTLS 指纹与 Reality。键名与 mihomo 完全不同:sing-box 用 snake_case 且 -- 都嵌在 tls 里(option/tls.go 的 OutboundUTLSOptions / OutboundRealityOptions)。 -- Reality 下 uTLS 是**强制**的:上游 common/tls/reality_client.go 里 -- `if options.UTLS == nil || !options.UTLS.Enabled { return nil, -- E.New("uTLS is required by reality client") }` —— 只写 reality 不写 utls -- 会让整份配置起不来。 if n.fp then tls.utls = { enabled = true, fingerprint = n.fp } end if n.security == "reality" and n["public-key"] then tls.reality = { enabled = true, public_key = n["public-key"] } if n["short-id"] then tls.reality.short_id = n["short-id"] end -- Reality 规范里 fp 不可省略(缺省 chrome)。节点没带 fp 时补默认值, -- 否则上面那条 uTLS 强制检查会让 sing-box 拒绝加载整份配置。 if not tls.utls then tls.utls = { enabled = true, fingerprint = "chrome" } end end return tls end -- 构建传输(transport)字段 local function build_transport(n) local net = n.net or n.network if not net or net == "tcp" then return nil end if net == "ws" then local t = { type = "ws" } if n.path then t.path = n.path end if n.host then t.headers = { Host = n.host } end return t end if net == "grpc" then local t = { type = "grpc" } if n.path then t.service_name = n.path end return t end if net == "http" or net == "h2" then local t = { type = "http" } if n.host then t.host = { n.host } end if n.path then t.path = n.path end return t end return nil end -- 单节点 → sing-box outbound 表 -- tag 可选:完整配置里由 util.unique_tags 统一分配,避免节点重名导致 tag 冲突 function M.to_outbound(n, tag) local stype = TYPE_MAP[n.proto] or n.proto if stype == "ssr" then return nil end -- sing-box 不支持 SSR,跳过 local o = { type = stype, tag = tag or n.name or ((n.server or "") .. ":" .. tostring(n.port or "")), server = n.server or "", server_port = tonumber(n.port) or 0, } if stype == "vmess" then o.uuid = n.uuid or "" if n.alterId ~= nil then o.alter_id = tonumber(n.alterId) end -- 此处的 security 是 vmess 加密方式,取 cipher 而非 TLS 层 o.security = n.cipher or "auto" if n.flow then o.flow = n.flow end elseif stype == "vless" then o.uuid = n.uuid or "" if n.flow then o.flow = n.flow end elseif stype == "trojan" then o.password = n.password or "" elseif stype == "shadowsocks" then o.method = n.method or n.cipher or "aes-256-gcm" o.password = n.password or "" -- SIP003 插件。sing-box 的 shadowsocks 出站只有 plugin(字符串)与 -- plugin_opts(字符串)两个字段,plugin_opts 就是 SIP003 的原始参数串 -- (`obfs=http;obfs-host=x`),不做任何翻译 —— 与 mihomo 的映射式 -- plugin-opts 正好相反。 -- 官方文档明确「Only two are supported: obfs-local and v2ray-plugin」, -- 其余名字(kcptun / shadow-tls / restls / gost-plugin …)sing-box -- 认不出来,会拒绝加载整份配置,所以按白名单过滤。 -- 丢掉这一项时服务端只接受带插件的握手,导出的配置必然连不上且不报错。 local pname, popts = util.parse_sip003_plugin(n.plugin) if pname == "obfs-local" or pname == "v2ray-plugin" then o.plugin = pname if popts and popts ~= "" then o.plugin_opts = popts end end elseif stype == "hysteria2" then o.password = n.password or "" -- 混淆(salamander):hysteria2 的 obfs 是 { type, password } 对象 if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then o.obfs = { type = n.obfs, password = n["obfs-password"] or n.obfs_password or "" } end elseif stype == "hysteria" then -- hysteria(v1) 与 hysteria2 在 sing-box 里的字段名并不相同(已对照上游文档确认): -- * 认证字段是 auth_str,不是 password(parser_json_config 也是按 -- outbound.password or outbound.auth_str or outbound.auth 回读的) -- * obfs 是普通字符串,不是 { type, password } 对象 -- 注意:sing-box 的 hysteria 出站还要求 up/down(带宽),本项目的节点模型 -- 不承载该字段,需用户在自己的配置里补上(见 CHANGELOG 已知限制)。 o.auth_str = n.password or "" if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then o.obfs = n.obfs end elseif stype == "tuic" then o.uuid = n.uuid or "" o.password = n.password or "" if n.congestion_control then o.congestion_control = n.congestion_control end elseif stype == "anytls" then -- anytls 出站的认证字段就是 password,TLS 走通用的 tls 块 -- (parse_anytls 已补 security="tls",build_tls 因此会写出 tls 对象)。 o.password = n.password or "" elseif stype == "wireguard" then local pk = n["private-key"] or n.private_key local ppk = n["peer-public-key"] or n.peer_public_key or n["public-key"] or n.public_key local psk = n["pre-shared-key"] or n.pre_shared_key or n["preshared-key"] or n.preshared_key if pk then o.private_key = pk end if ppk then o.peer_public_key = ppk end if psk then o.pre_shared_key = psk end -- sing-box 的 local_address 接受字符串或字符串数组;同时有 IPv4/IPv6 时必须用数组, -- 逗号拼接("a,b")不是合法值 local addr = {} if n.ip then addr[#addr + 1] = n.ip end if n.ipv6 then addr[#addr + 1] = n.ipv6 end if #addr == 1 then o.local_address = addr[1] elseif #addr > 1 then o.local_address = addr end -- 三个字段在 sing-box 里都是列表(allowed_ips/dns 是 []string,reserved 是 -- []uint8),字符串形态必须归一后再写,否则整份配置起不来 local aips = as_list(n["allowed-ips"]) if #aips > 0 then o.allowed_ips = aips end local rsv = as_num_list(n.reserved) if #rsv > 0 then o.reserved = rsv end if n["persistent-keepalive"] then o.persistent_keepalive_interval = tonumber(n["persistent-keepalive"]) end if n["listen-port"] then o.listen_port = tonumber(n["listen-port"]) end if n.mtu then o.mtu = tonumber(n.mtu) end local dns = as_list(n.dns) if #dns > 0 then o.dns = dns end elseif stype == "socks" or stype == "http" then -- socks 与 http 出站的认证字段相同(username / password) if n.username then o.username = n.username end if n.password then o.password = n.password end end local tls = build_tls(n) if tls then o.tls = tls end local transport = build_transport(n) if transport then o.transport = transport end return o end -- 保留 tag:节点名不得与之重名(util.unique_tags 负责消解) local RESERVED_TAGS = { select = true, auto = true, direct = true, block = true } -- 生成完整 sing-box 配置(outbounds + route)。 -- 不含 inbounds / dns:这两项会绑定本地监听端口、覆盖用户既有 DNS 设置, -- 由用户在自己的配置里维护,本输出只负责节点与分流。 -- -- 版本兼容说明:route 规则里的 action 字段自 1.11.0 起才存在,其默认值即 "route", -- 因此这里刻意省略 action —— 未知字段会被 sing-box 拒绝,而省略默认值在 -- 1.10 与 1.11+ 上都能工作。 function M.generate(nodes, options) local usable = {} for _, n in ipairs(nodes or {}) do if type(n) == "table" and supported(n.proto) then usable[#usable + 1] = n end end local tags = util.unique_tags(usable, RESERVED_TAGS) local outbounds, proxy_tags = {}, {} for i, n in ipairs(usable) do outbounds[#outbounds + 1] = M.to_outbound(n, tags[i]) proxy_tags[#proxy_tags + 1] = tags[i] end local final if #proxy_tags > 0 then -- select 供手工切换,auto 自动测速选优 local sel = { type = "selector", tag = "select", outbounds = { "auto", "direct" } } for _, t in ipairs(proxy_tags) do sel.outbounds[#sel.outbounds + 1] = t end sel.default = "auto" outbounds[#outbounds + 1] = sel outbounds[#outbounds + 1] = { type = "urltest", tag = "auto", outbounds = proxy_tags } final = "select" else -- 无可用节点时不能生成 selector / urltest(outbounds 不允许为空) final = "direct" end outbounds[#outbounds + 1] = { type = "direct", tag = "direct" } outbounds[#outbounds + 1] = { type = "block", tag = "block" } return util.json_encode({ outbounds = outbounds, route = { rules = { { ip_is_private = true, outbound = "direct" } }, final = final, auto_detect_interface = true, }, }) end return M