#!/usr/bin/ucode /* * SPDX-License-Identifier: GPL-2.0-only * * Copyright (C) 2023-2025 ImmortalWrt.org */ 'use strict'; import { access, readfile, writefile } from 'fs'; import { isnan } from 'math'; import { connect } from 'ubus'; import { cursor } from 'uci'; import { isEmpty, parseURL, strToBool, strToInt, strToTime, removeBlankAttrs, validation, HP_DIR, RUN_DIR } from 'homeproxy'; const ubus = connect(); /* const features = ubus.call('luci.homeproxy', 'singbox_get_features') || {}; */ /* UCI config start */ const uci = cursor(); const uciconfig = 'homeproxy'; uci.load(uciconfig); const uciinfra = 'infra', ucimain = 'config', ucicontrol = 'control'; const ucidnssetting = 'dns', ucidnsserver = 'dns_server', ucidnsrule = 'dns_rule'; const uciroutingsetting = 'routing', uciroutingnode = 'routing_node', uciroutingrule = 'routing_rule'; const ucinode = 'node'; const uciruleset = 'ruleset'; const uciserver = 'server'; const ucirurule = 'proxy_ru_rule'; const routing_mode = uci.get(uciconfig, ucimain, 'routing_mode') || 'proxy_banned_ru'; /* Selective routing is ONE engine; the region is data. * forward (blocklist): default direct, lists -> proxy. proxy_banned_ru (RU) * reverse (bypass): default proxy, region -> direct, overrides. bypass_cn / bypass_ir * Adding a country = one REGION row + a routing_mode value + a client.js entry. */ const REGION = { cn: { geosite: 'https://fastly.jsdelivr.net/gh/1715173329/sing-geosite@rule-set-unstable/geosite-geolocation-cn.srs', geoip: 'https://fastly.jsdelivr.net/gh/1715173329/IPCIDR-CHINA@rule-set/cn.srs', geosite_non: 'https://fastly.jsdelivr.net/gh/1715173329/sing-geosite@rule-set-unstable/geosite-geolocation-!cn.srs', local_dns: { type: 'udp', server: '223.5.5.5' }, /* AliDNS */ guard: true /* geoip fallback + !cn guard (China list is non-exhaustive) */ }, ir: { geosite: 'https://fastly.jsdelivr.net/gh/Chocolate4U/Iran-sing-box-rules@rule-set/geosite-ir.srs', geoip: 'https://fastly.jsdelivr.net/gh/Chocolate4U/Iran-sing-box-rules@rule-set/geoip-ir.srs', geosite_non: null, local_dns: { type: 'udp', server: '178.22.122.100' }, /* Shecan (default; overridable via iran_dns_server) — stable public IPs, not ISP-delegated */ guard: false /* Iran configs use the simple scheme, no geoip fallback */ } }; const MODE_REGION = { bypass_cn: 'cn', bypass_ir: 'ir' }; function is_bypass_mode(m) { return m in ['bypass_cn', 'bypass_ir']; } function is_selective_mode(m) { return m === 'proxy_banned_ru' || is_bypass_mode(m); } function region_of(m) { return MODE_REGION[m]; } let wan_dns = ubus.call('network.interface', 'status', {'interface': 'wan'})?.['dns-server']?.[0]; if (!wan_dns) wan_dns = '8.8.8.8'; /* ultimate fallback for the remote/bootstrap resolver; region-local DNS comes from REGION */ const dns_port = uci.get(uciconfig, uciinfra, 'dns_port') || '5333'; const ntp_server = uci.get(uciconfig, uciinfra, 'ntp_server') || 'time.apple.com'; /* Detect active core. Must match the core init.d actually runs, or the generated dialect * won't fit it. Mirror init.d's precedence: honor preferred_core when that core is * installed, otherwise auto-pick (hiddify-core first, then sing-box). Falls back to a * UCI custom path when neither standard binary is present. */ const preferred_core = uci.get(uciconfig, ucimain, 'preferred_core') || 'auto'; const have_hiddify = !!access('/usr/bin/hiddify-core'); const have_singbox = !!access('/usr/bin/sing-box'); let is_hiddify = false, is_singbox = false; if (preferred_core === 'hiddify' && have_hiddify) is_hiddify = true; else if (preferred_core === 'singbox' && have_singbox) is_singbox = true; else if (have_hiddify) is_hiddify = true; else if (have_singbox) is_singbox = true; else { const custom_path = uci.get(uciconfig, ucimain, 'custom_core_path'); const custom_type = uci.get(uciconfig, ucimain, 'custom_core_type'); if (custom_path && access(custom_path)) { is_hiddify = custom_type === 'hiddify'; is_singbox = !is_hiddify; } } const ipv6_support = uci.get(uciconfig, ucimain, 'ipv6_support') || '0'; const byedpi_enabled = uci.get(uciconfig, ucimain, 'byedpi_enabled'); /* zapret: a `direct` outbound stamped with routing_mark; nft catches the mark and * sends the handshake to NFQUEUE where nfqws desyncs it. Separate from byedpi. */ const zapret_enabled = uci.get(uciconfig, ucimain, 'zapret_enabled'); const zapret_mark = uci.get(uciconfig, ucimain, 'zapret_mark') || '110'; /* Opt-in: route call/voice UDP ports (50000-65530) to zapret-out instead of the proxy. * Only honored when zapret_enabled === '1' (zapret-out exists). */ const zapret_voice = uci.get(uciconfig, ucimain, 'zapret_voice') || '0'; let main_node, main_udp_node, dedicated_udp_node, default_outbound, default_outbound_dns, domain_strategy, sniff_override, dns_server, china_dns_server, iran_dns_server, russia_dns_server, secure_dns_server, proxy_calls, no_proxy_torrents, show_advanced_rules, dns_default_strategy, dns_default_server, dns_disable_cache, dns_disable_cache_expire, dns_independent_cache, dns_client_subnet, cache_file_store_rdrc, cache_file_rdrc_timeout, direct_domain_list, proxy_domain_list; if (routing_mode !== 'custom') { main_node = uci.get(uciconfig, ucimain, 'main_node') || 'nil'; if (main_node === 'nil') { warn('homeproxy: no main_node configured, skipping config generation.\n'); exit(0); } main_udp_node = uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil'; dedicated_udp_node = !isEmpty(main_udp_node) && !(main_udp_node in ['same', main_node]); dns_server = uci.get(uciconfig, ucimain, 'dns_server'); if (isEmpty(dns_server) || dns_server === 'wan') dns_server = wan_dns; /* Region local resolver: optional UCI override, else the stable default from REGION * (AliDNS for CN, Shecan for IR). 'wan'/empty falls back to that default. */ if (routing_mode === 'bypass_cn') { china_dns_server = uci.get(uciconfig, ucimain, 'china_dns_server'); if (isEmpty(china_dns_server) || type(china_dns_server) !== 'string' || china_dns_server === 'wan') china_dns_server = REGION.cn.local_dns.server; } if (routing_mode === 'bypass_ir') { iran_dns_server = uci.get(uciconfig, ucimain, 'iran_dns_server'); if (isEmpty(iran_dns_server) || type(iran_dns_server) !== 'string' || iran_dns_server === 'wan') iran_dns_server = REGION.ir.local_dns.server; } /* Shared selective knobs (forward RU + reverse CN/IR): remote DoH + exceptions. * Reverse modes ride the same engine, so they get secure-dns/overrides for free. */ if (is_selective_mode(routing_mode)) { secure_dns_server = uci.get(uciconfig, ucimain, 'secure_dns_server') || 'https://cloudflare-dns.com/dns-query'; domain_strategy = uci.get(uciconfig, ucimain, 'domain_strategy'); proxy_calls = uci.get(uciconfig, ucimain, 'proxy_calls'); no_proxy_torrents = uci.get(uciconfig, ucimain, 'no_proxy_torrents'); show_advanced_rules = uci.get(uciconfig, ucimain, 'show_advanced_rules'); } if (routing_mode === 'proxy_banned_ru') russia_dns_server = uci.get(uciconfig, ucimain, 'russia_dns_server') || '77.88.8.8'; dns_default_strategy = (ipv6_support !== '1') ? 'ipv4_only' : null; direct_domain_list = trim(readfile(HP_DIR + '/resources/direct_list.txt')); if (direct_domain_list) direct_domain_list = split(direct_domain_list, /[\r\n]/); proxy_domain_list = trim(readfile(HP_DIR + '/resources/proxy_list.txt')); if (proxy_domain_list) proxy_domain_list = split(proxy_domain_list, /[\r\n]/); sniff_override = uci.get(uciconfig, uciinfra, 'sniff_override') || '1'; } else { /* DNS settings */ dns_default_strategy = uci.get(uciconfig, ucidnssetting, 'default_strategy'); dns_default_server = uci.get(uciconfig, ucidnssetting, 'default_server'); dns_disable_cache = uci.get(uciconfig, ucidnssetting, 'disable_cache'); dns_disable_cache_expire = uci.get(uciconfig, ucidnssetting, 'disable_cache_expire'); dns_independent_cache = uci.get(uciconfig, ucidnssetting, 'independent_cache'); dns_client_subnet = uci.get(uciconfig, ucidnssetting, 'client_subnet'); cache_file_store_rdrc = uci.get(uciconfig, ucidnssetting, 'cache_file_store_rdrc'), cache_file_rdrc_timeout = uci.get(uciconfig, ucidnssetting, 'cache_file_rdrc_timeout'); /* Routing settings */ default_outbound = uci.get(uciconfig, uciroutingsetting, 'default_outbound') || 'nil'; default_outbound_dns = uci.get(uciconfig, uciroutingsetting, 'default_outbound_dns') || 'default-dns'; domain_strategy = uci.get(uciconfig, uciroutingsetting, 'domain_strategy'); sniff_override = uci.get(uciconfig, uciroutingsetting, 'sniff_override'); } const proxy_mode = uci.get(uciconfig, ucimain, 'proxy_mode') || 'redirect_tproxy', default_interface = uci.get(uciconfig, ucicontrol, 'bind_interface'); const mixed_port = uci.get(uciconfig, uciinfra, 'mixed_port') || '5330'; let self_mark, redirect_port, tproxy_port, tun_name, tun_addr4, tun_addr6, tun_mtu, tcpip_stack, endpoint_independent_nat, udp_timeout; if (routing_mode === 'custom') udp_timeout = uci.get(uciconfig, uciroutingsetting, 'udp_timeout'); else udp_timeout = uci.get(uciconfig, 'infra', 'udp_timeout'); if (match(proxy_mode, /redirect/)) { self_mark = uci.get(uciconfig, 'infra', 'self_mark') || '100'; redirect_port = uci.get(uciconfig, 'infra', 'redirect_port') || '5331'; } if (match(proxy_mode, /tproxy/)) tproxy_port = uci.get(uciconfig, 'infra', 'tproxy_port') || '5332'; if (match(proxy_mode), /tun/) { tun_name = uci.get(uciconfig, uciinfra, 'tun_name') || 'singtun0'; tun_addr4 = uci.get(uciconfig, uciinfra, 'tun_addr4') || '172.19.0.1/30'; tun_addr6 = uci.get(uciconfig, uciinfra, 'tun_addr6') || 'fdfe:dcba:9876::1/126'; tun_mtu = uci.get(uciconfig, uciinfra, 'tun_mtu') || '9000'; tcpip_stack = 'system'; if (routing_mode === 'custom') { tcpip_stack = uci.get(uciconfig, uciroutingsetting, 'tcpip_stack') || 'system'; endpoint_independent_nat = uci.get(uciconfig, uciroutingsetting, 'endpoint_independent_nat'); } } const log_level = uci.get(uciconfig, ucimain, 'log_level') || 'warn'; /* UCI config end */ /* Config helper start */ function parse_port(strport) { if (type(strport) !== 'array' || isEmpty(strport)) return null; let ports = []; for (let i in strport) push(ports, int(i)); return ports; } function parse_dnsserver(server_addr, default_protocol) { if (isEmpty(server_addr)) return null; if (!match(server_addr, /:\/\//)) server_addr = (default_protocol || 'udp') + '://' + (validation('ip6addr', server_addr) ? `[${server_addr}]` : server_addr); server_addr = parseURL(server_addr); return { type: server_addr.protocol, server: server_addr.hostname, server_port: strToInt(server_addr.port), path: (server_addr.pathname !== '/') ? server_addr.pathname : null, } } function parse_dnsquery(strquery) { if (type(strquery) !== 'array' || isEmpty(strquery)) return null; let querys = []; for (let i in strquery) isnan(int(i)) ? push(querys, i) : push(querys, int(i)); return querys; } function generate_endpoint(node) { if (type(node) !== 'object' || isEmpty(node)) return null; const is_wg = node.type in ['wireguard', 'amneziawg']; const addPrefix = (addr) => { if (!addr || match(addr, /\//)) return addr; return match(addr, /:/) ? addr + '/128' : addr + '/32'; }; const raw_addr = node.wireguard_local_address; const endpoint = { type: is_wg ? 'wireguard' : node.type, tag: 'cfg-' + node['.name'] + '-out', address: type(raw_addr) === 'array' ? map(raw_addr, addPrefix) : addPrefix(raw_addr), mtu: strToInt(node.wireguard_mtu), private_key: node.wireguard_private_key, peers: is_wg ? [ { address: node.address, port: strToInt(node.port), allowed_ips: [ '0.0.0.0/0', '::/0' ], persistent_keepalive_interval: strToInt(node.wireguard_persistent_keepalive_interval), public_key: node.wireguard_peer_public_key, pre_shared_key: node.wireguard_pre_shared_key, reserved: parse_port(node.wireguard_reserved), } ] : null, system: is_wg ? false : null, amnezia: (node.type === 'amneziawg') ? { jc: strToInt(node.amnezia_jc), jmin: strToInt(node.amnezia_jmin), jmax: strToInt(node.amnezia_jmax), s1: strToInt(node.amnezia_s1), s2: strToInt(node.amnezia_s2), s3: strToInt(node.amnezia_s3), s4: strToInt(node.amnezia_s4), h1: node.amnezia_h1 || null, h2: node.amnezia_h2 || null, h3: node.amnezia_h3 || null, h4: node.amnezia_h4 || null, i1: node.amnezia_i1 || null, i2: node.amnezia_i2 || null, i3: node.amnezia_i3 || null, i4: node.amnezia_i4 || null, i5: node.amnezia_i5 || null, j1: node.amnezia_j1 || null, j2: node.amnezia_j2 || null, j3: node.amnezia_j3 || null, itime: strToInt(node.amnezia_itime), } : null, tcp_fast_open: strToBool(node.tcp_fast_open), tcp_multi_path: strToBool(node.tcp_multi_path), udp_fragment: strToBool(node.udp_fragment) }; return endpoint; } /* The transport "host" JSON type differs by transport: sing-box's HTTP/2 (`http`) * transport takes an ARRAY of strings, while xhttp and httpupgrade take a single * STRING. The UI's DynamicList (and some share-link parsers) store http_host as a * UCI list, which would emit a JSON array and crash hiddify-core on an xhttp node * ("json: cannot unmarshal array into Go value of type string"). Coerce to the * correct shape per transport here, so every input path (UI edit, share-link, * subscription) produces valid config. */ function transport_host(node) { let h = node.http_host; if (node.transport === 'http') return (type(h) === 'array') ? (length(h) ? h : null) : (isEmpty(h) ? null : [ h ]); if (type(h) === 'array') h = h[0]; return h || node.httpupgrade_host; } /* xhttp "split download" (the "dl=h2"/"dl=h3" feature): the download direction can use * a different host, path, server/port and TLS (commonly a different server_name + ALPN * such as h2/h3) than the upload. sing-box-extended names this nested transport * `download`; hiddify-core uses the xray-style `downloadSettings`. The inner shape is * the same sing-box transport + TLS for both, so build it once and let the caller pick * the key per core. Returns null when the node has no split download configured. An * unset server/port is omitted (removeBlankAttrs strips it) = "reuse the main * connection" — this matches the hiddify reference configs (and works on hiddify-core); * sing-box users should set an explicit download server. */ function xhttp_download(node) { if (node.transport !== 'xhttp') return null; if (isEmpty(node.xhttp_download_host) && isEmpty(node.xhttp_download_sni) && isEmpty(node.xhttp_download_path)) return null; const sec = node.xhttp_download_security; const want_tls = (sec === 'tls' || sec === 'reality' || !isEmpty(node.xhttp_download_sni) || !isEmpty(node.xhttp_download_alpn)); const tls = want_tls ? { enabled: true, server_name: node.xhttp_download_sni, insecure: strToBool(node.xhttp_download_insecure), alpn: node.xhttp_download_alpn ? (type(node.xhttp_download_alpn) === 'array' ? node.xhttp_download_alpn : split(node.xhttp_download_alpn, ',')) : null, /* Reality requires uTLS in sing-box (see the main-TLS block) — default the * fingerprint to 'edge' when reality is on but the node carries none. */ utls: (!isEmpty(node.xhttp_download_fp) || sec === 'reality') ? { enabled: true, fingerprint: !isEmpty(node.xhttp_download_fp) ? node.xhttp_download_fp : 'edge' } : null, reality: (sec === 'reality') ? { enabled: true, public_key: node.xhttp_download_pbk, short_id: node.xhttp_download_sid } : null } : null; return { host: node.xhttp_download_host || transport_host(node), path: node.xhttp_download_path || node.http_path, server: node.xhttp_download_server || null, server_port: strToInt(node.xhttp_download_port) || null, tls: tls }; } /* sing-box-extended FATALs with "x_padding_bytes cannot be disabled" whenever xhttp * padding resolves to empty: an explicit "0"/"0-0" disables it, AND an absent field * decodes to "" in Go which counts as disabled too. So the field must always be present * and non-empty on every xhttp transport — including the nested `download` block, which * is itself a full transport. Coerce any disabling/empty value to the default range. */ function xhttp_padding(v) { return (isEmpty(v) || v === '0' || v === '0-0') ? '100-1000' : v; } function generate_outbound(node) { if (type(node) !== 'object' || isEmpty(node)) return null; const outbound = { type: node.type, tag: 'cfg-' + node['.name'] + '-out', server: (node.type === 'shadowsocks' && node.shadowtls_enabled === '1') ? null : node.address, server_port: (node.type === 'mieru') ? 0 : ((node.type === 'shadowsocks' && node.shadowtls_enabled === '1') ? null : strToInt(node.port)), /* Hysteria(2) / Mieru (sing-box-extended) */ server_ports: (!is_hiddify && node.type === 'mieru' && node.mieru_port_range) ? [node.mieru_port_range] : node.hysteria_hopping_port, username: (node.type !== 'ssh') ? node.username : null, user: (node.type === 'ssh') ? node.username : null, password: node.password, /* Direct */ override_address: node.override_address, override_port: strToInt(node.override_port), proxy_protocol: strToInt(node.proxy_protocol), /* AnyTLS */ idle_session_check_interval: strToTime(node.anytls_idle_session_check_interval), idle_session_timeout: strToTime(node.anytls_idle_session_timeout), min_idle_session: strToInt(node.anytls_min_idle_session), /* Hysteria (2) */ hop_interval: strToTime(node.hysteria_hop_interval), up_mbps: strToInt(node.hysteria_up_mbps), down_mbps: strToInt(node.hysteria_down_mbps), obfs: node.hysteria_obfs_type ? { type: node.hysteria_obfs_type, password: node.hysteria_obfs_password } : node.hysteria_obfs_password, auth: (node.hysteria_auth_type === 'base64') ? node.hysteria_auth_payload : null, auth_str: (node.hysteria_auth_type === 'string') ? node.hysteria_auth_payload : null, recv_window_conn: strToInt(node.hysteria_recv_window_conn), recv_window: strToInt(node.hysteria_revc_window), disable_mtu_discovery: strToBool(node.hysteria_disable_mtu_discovery), /* Shadowsocks */ method: node.shadowsocks_encrypt_method, plugin: node.shadowsocks_plugin, plugin_opts: node.shadowsocks_plugin_opts, /* ShadowTLS / Socks */ version: (node.type === 'shadowtls') ? strToInt(node.shadowtls_version) : ((node.type === 'socks') ? node.socks_version : null), /* Mieru */ portBindings: (is_hiddify && node.type === 'mieru' && node.mieru_protocol && node.mieru_port_range) ? [ { protocol: node.mieru_protocol, portRange: node.mieru_port_range } ] : null, multiplexing: (node.type === 'mieru') ? node.mieru_multiplexing : null, handshake_mode: (is_hiddify && node.type === 'mieru') ? node.mieru_handshake_mode : null, /* SSH */ client_version: node.ssh_client_version, host_key: node.ssh_host_key, host_key_algorithms: node.ssh_host_key_algo, private_key: node.ssh_priv_key, private_key_passphrase: node.ssh_priv_key_pp, /* Tuic */ uuid: node.uuid, congestion_control: node.tuic_congestion_control, udp_relay_mode: node.tuic_udp_relay_mode, udp_over_stream: strToBool(node.tuic_udp_over_stream), zero_rtt_handshake: strToBool(node.tuic_enable_zero_rtt), heartbeat: strToTime(node.tuic_heartbeat), /* VLESS / VMess */ flow: node.vless_flow, alter_id: strToInt(node.vmess_alterid), security: node.vmess_encrypt, global_padding: strToBool(node.vmess_global_padding), authenticated_length: strToBool(node.vmess_authenticated_length), packet_encoding: node.packet_encoding, multiplex: (node.multiplex === '1') ? { enabled: true, protocol: node.multiplex_protocol, max_connections: strToInt(node.multiplex_max_connections), min_streams: strToInt(node.multiplex_min_streams), max_streams: strToInt(node.multiplex_max_streams), padding: strToBool(node.multiplex_padding), brutal: (node.multiplex_brutal === '1') ? { enabled: true, up_mbps: strToInt(node.multiplex_brutal_up), down_mbps: strToInt(node.multiplex_brutal_down) } : null } : null, tls_fragment: (node.tls_fragment === '1') ? { enabled: true, size: node.tls_fragment_size, sleep: node.tls_fragment_sleep } : null, /* Shadowsocks has no top-level tls field in ANY sing-box-based core — for a * ShadowTLS-wrapped Shadowsocks the TLS lives on the separate shadowtls transport * outbound (detour). Both sing-box-extended AND hiddify-core 4.1.0 (HiddifyCli) * strict-reject a stray tls here ("unknown field tls" → FATAL), so suppress it for * shadowsocks unconditionally (the earlier is_singbox-only gate was wrong — hiddify * is not lenient). */ tls: (node.tls === '1' && node.type !== 'shadowsocks') ? { enabled: true, server_name: node.tls_sni, insecure: strToBool(node.tls_insecure), alpn: node.tls_alpn ? (type(node.tls_alpn) === 'array' ? node.tls_alpn : [node.tls_alpn]) : null, min_version: node.tls_min_version, max_version: node.tls_max_version, cipher_suites: node.tls_cipher_suites, certificate_path: node.tls_cert_path, ech: (node.tls_ech === '1') ? { enabled: true, config: node.tls_ech_config, config_path: node.tls_ech_config_path } : null, /* Reality REQUIRES uTLS in sing-box ("uTLS is required by reality client" * is a FATAL that crash-loops the whole service; hiddify-core defaults it * silently). So when reality is on we always emit utls, defaulting the * fingerprint to 'edge' if the node (e.g. a sub-imported reality node) * didn't carry one. */ utls: (!isEmpty(node.tls_utls) || node.tls_reality === '1') ? { enabled: true, fingerprint: !isEmpty(node.tls_utls) ? node.tls_utls : 'edge' } : null, reality: (node.tls_reality === '1') ? { enabled: true, public_key: node.tls_reality_public_key, short_id: node.tls_reality_short_id } : null } : null, transport: (!is_hiddify && node.type === 'mieru') ? node.mieru_protocol : !isEmpty(node.transport) ? { type: node.transport, host: transport_host(node), path: node.http_path || node.ws_path, mode: (node.transport === 'xhttp') ? (node.xhttp_mode || 'auto') : null, /* xhttp transport options differ by core DIALECT: hiddify-core (HiddifyCli, the * format the Hiddify app exports) uses camelCase — xPaddingBytes / * scMaxEachPostBytes / scMinPostsIntervalMs — while sing-box-extended uses * snake_case. Emit both spellings; the wrong-core one is null and removeBlankAttrs * strips it before write. sing-box keeps a forced 100-1000 padding default to * preserve prior behaviour; hiddify only emits what the node actually carries. */ x_padding_bytes: (is_singbox && node.transport === 'xhttp') ? xhttp_padding(node.xhttp_padding_bytes) : null, xPaddingBytes: (is_hiddify && node.transport === 'xhttp' && !isEmpty(node.xhttp_padding_bytes)) ? xhttp_padding(node.xhttp_padding_bytes) : null, sc_max_each_post_bytes: (is_singbox && node.transport === 'xhttp') ? (node.xhttp_sc_max_each_post_bytes || null) : null, scMaxEachPostBytes: (is_hiddify && node.transport === 'xhttp') ? (node.xhttp_sc_max_each_post_bytes || null) : null, sc_min_posts_interval_ms: (is_singbox && node.transport === 'xhttp') ? (node.xhttp_sc_min_posts_interval_ms || null) : null, scMinPostsIntervalMs: (is_hiddify && node.transport === 'xhttp') ? (node.xhttp_sc_min_posts_interval_ms || null) : null, headers: node.xhttp_headers ? json(node.xhttp_headers) : (node.ws_host ? { Host: node.ws_host } : null), method: node.http_method, max_early_data: strToInt(node.websocket_early_data), early_data_header_name: node.websocket_early_data_header, service_name: node.grpc_servicename, idle_timeout: (node.http_idle_timeout), ping_timeout: (node.http_ping_timeout), permit_without_stream: strToBool(node.grpc_permit_without_stream) } : null, /* NaiveProxy */ quic: (node.type === 'naive') ? strToBool(node.naive_quic) : null, extra_headers: (node.type === 'naive') ? (node.naive_extra_headers ? json(node.naive_extra_headers) : null) : null, /* sing-box-extended's ssh outbound has NO udp_over_tcp field → emitting it FATALs * with "unknown field udp_over_tcp" (verified on sing-box check). hiddify-core's * ssh works with it as-is (tested), so keep the ssh branch for hiddify only. * naive/shadowsocks keep udp_over_tcp on both cores. */ udp_over_tcp: (node.type === 'naive') ? strToBool(node.naive_udp_over_tcp) : (is_hiddify && node.type === 'ssh') ? (node.ssh_udp_over_tcp !== '0' ? true : null) : ((is_hiddify && node.udp_over_tcp === '1') ? { enabled: true, version: strToInt(node.udp_over_tcp_version) } : null), tcp_fast_open: strToBool(node.tcp_fast_open), tcp_multi_path: strToBool(node.tcp_multi_path), udp_fragment: strToBool(node.udp_fragment), bind_interface: node.bind_interface || null, detour: (node.type === 'shadowsocks' && node.shadowtls_enabled === '1') ? ('cfg-' + node['.name'] + '-shadowtls-out') : null }; /* xhttp split download: attach under the per-core key (`download` for * sing-box-extended, `downloadSettings` for hiddify-core). The download block is a * full xhttp transport, so sing-box requires it to carry x_padding_bytes too — an * absent field decodes to "" and FATALs ("cannot be disabled"). Mirror the main * transport's per-core padding spelling (sing-box always forces a default; hiddify * only emits what the node carries). */ if (type(outbound.transport) === 'object') { const dl = xhttp_download(node); if (dl) { if (is_singbox && node.transport === 'xhttp') dl.x_padding_bytes = xhttp_padding(node.xhttp_padding_bytes); else if (is_hiddify && node.transport === 'xhttp' && !isEmpty(node.xhttp_padding_bytes)) dl.xPaddingBytes = xhttp_padding(node.xhttp_padding_bytes); outbound.transport[is_hiddify ? 'downloadSettings' : 'download'] = dl; } } return outbound; } /* Push outbound(s) for a node. For ShadowTLS-wrapped Shadowsocks, first pushes the * hidden ShadowTLS transport outbound, then the Shadowsocks outbound with detour set. */ function push_outbound(list, node) { if (node.type === 'shadowsocks' && node.shadowtls_enabled === '1') { push(list, { type: 'shadowtls', tag: 'cfg-' + node['.name'] + '-shadowtls-out', server: node.address, server_port: strToInt(node.port), version: strToInt(node.shadowtls_version) || 3, password: node.shadowtls_password || null, tls: { enabled: true, server_name: node.tls_sni || null, insecure: strToBool(node.tls_insecure), utls: !isEmpty(node.tls_utls) ? { enabled: true, fingerprint: node.tls_utls } : null } }); } push(list, generate_outbound(node)); } function get_outbound(cfg) { if (isEmpty(cfg)) return null; if (type(cfg) === 'array') { if ('any-out' in cfg) return 'any'; let outbounds = []; for (let i in cfg) push(outbounds, get_outbound(i)); return outbounds; } else { switch (cfg) { case 'block-out': case 'direct-out': case 'main-out': return cfg; case 'byedpi-out': /* Fall back to direct if ByeDPI is disabled so the config stays valid */ return (byedpi_enabled === '1') ? 'byedpi-out' : 'direct-out'; case 'zapret-out': /* Fall back to plain direct if zapret is disabled so the config stays valid */ return (zapret_enabled === '1') ? 'zapret-out' : 'direct-out'; default: const node = uci.get(uciconfig, cfg, 'node'); if (isEmpty(node)) die(sprintf("%s's node is missing, please check your configuration.", cfg)); else if (node === 'urltest') return 'cfg-' + cfg + '-out'; else return 'cfg-' + node + '-out'; } } } function get_resolver(cfg) { if (isEmpty(cfg)) return null; switch (cfg) { case 'default-dns': case 'system-dns': /* Built-in selective-mode resolvers — emitted with these literal tags (forward RU: * russia-dns/secure-dns; reverse CN/IR: region-dns/secure-dns; global: main-dns), * so a custom DNS rule may target them directly (don't prefix as a cfg-*-dns). */ case 'russia-dns': case 'secure-dns': case 'region-dns': case 'main-dns': return cfg; default: return 'cfg-' + cfg + '-dns'; } } function get_ruleset(cfg) { if (isEmpty(cfg)) return null; let rules = []; for (let i in cfg) push(rules, isEmpty(i) ? null : 'cfg-' + i + '-rule'); return rules; } /* Config helper end */ const config = {}; const has_outbound = (tag) => { for (let ob in config.outbounds) if (ob?.tag === tag) return true; for (let ep in (config.endpoints || [])) if (ep?.tag === tag) return true; return false; }; /* Log */ config.log = { disabled: false, level: log_level, output: RUN_DIR + '/hiddify-c.log', timestamp: true }; /* NTP */ if (!isEmpty(ntp_server)) config.ntp = { enabled: true, server: ntp_server, detour: 'direct-out', domain_resolver: 'default-dns', }; /* DNS start */ /* Default settings */ config.dns = { servers: [ { tag: 'default-dns', type: 'udp', server: wan_dns, detour: self_mark ? 'direct-out' : null }, { tag: 'system-dns', type: 'local', detour: self_mark ? 'direct-out' : null } ], rules: [], strategy: dns_default_strategy, disable_cache: strToBool(dns_disable_cache), disable_expire: strToBool(dns_disable_cache_expire), independent_cache: strToBool(dns_independent_cache), client_subnet: dns_client_subnet }; if (!isEmpty(main_node)) { if (routing_mode === 'proxy_banned_ru') { /* Russia mode: direct-default routing, russia-dns for all, secure-dns for proxy lists. * * secure-dns goes through main-out for real proxy nodes — tunneling the query hides * it from the ISP and reaches resolvers the ISP might block. But ByeDPI is a DPI-desync, * not a tunnel: routing DNS through it fails every way (DoH/DoT TLS handshake gets * corrupted by the desync; udp:// can't do socks UDP-over-TCP), and it adds no privacy * since ByeDPI egresses direct anyway. So for ByeDPI, secure-dns goes direct — DoH/DoT * is already encrypted/un-poisonable, it just must not pass through the desync. */ /* ByeDPI and Zapret both egress direct (not a tunnel), so secure-dns must go * direct too — a DoH/DoT query can't ride a desync. */ const secure_dns_detour = (main_node === 'byedpi-out' || main_node === 'zapret-out') ? 'direct-out' : 'main-out'; push(config.dns.servers, { tag: 'russia-dns', detour: self_mark ? 'direct-out' : null, ...parse_dnsserver(russia_dns_server) }); push(config.dns.servers, { tag: 'secure-dns', domain_resolver: { server: 'russia-dns', strategy: (ipv6_support !== '1') ? 'ipv4_only' : null }, detour: secure_dns_detour, ...parse_dnsserver(secure_dns_server, 'tcp') }); config.dns.final = 'russia-dns'; /* andrevi.ch always via secure-dns (hardcoded diagnostic anchor) */ push(config.dns.rules, { domain: ['andrevi.ch'], action: 'route', server: 'secure-dns' }); /* Custom proxy list → secure-dns (before ru_domain_rulesets for explicit priority) */ if (length(proxy_domain_list)) push(config.dns.rules, { rule_set: 'proxy-domain', action: 'route', server: 'secure-dns' }); /* Proxy-list domains → secure-dns (Cloudflare DoH via proxy) to prevent DNS leaks */ let ru_domain_rulesets = []; uci.foreach(uciconfig, ucirurule, (cfg) => { if (cfg.enabled !== '1') return; const tag = (cfg.source === 'refilter') ? 'hp-ru-refilter-domain' : ('hp-ru-' + cfg.source); if (index(ru_domain_rulesets, tag) < 0) push(ru_domain_rulesets, tag); }); if (length(ru_domain_rulesets)) push(config.dns.rules, { rule_set: ru_domain_rulesets, action: 'route', server: 'secure-dns' }); } else if (is_bypass_mode(routing_mode)) { /* Reverse (bypass) mode: default route is PROXY; region traffic carved out to direct. * DNS mirrors it -- proxied (default) resolves via DoH-through-tunnel (anti-pollution + * hides queries from the ISP/operator); region domains resolve via the local resolver * (correct domestic CDN, fast, shutdown-resilient). One engine, region is data. */ const r = region_of(routing_mode); const region = REGION[r]; /* Region-local resolver (direct egress): user-chosen server (china_dns_server / * iran_dns_server), defaulting to the REGION baseline (AliDNS CN / Shecan IR). */ const region_dns_server = (r === 'cn') ? china_dns_server : iran_dns_server; push(config.dns.servers, { tag: 'region-dns', detour: self_mark ? 'direct-out' : null, ...parse_dnsserver(region_dns_server) }); /* Remote/default resolver: DoH through the tunnel, bootstrapped via region-dns. * NOTE (Iran validation): bootstrapping a DoH *hostname* via the local resolver can be * poisoned by the ISP -- if confirmed in-country, set secure_dns_server to an IP-literal * DoH (e.g. https://1.1.1.1/dns-query) so no bootstrap is needed. */ push(config.dns.servers, { tag: 'secure-dns', domain_resolver: { server: 'region-dns', strategy: (ipv6_support !== '1') ? 'ipv4_only' : null }, detour: 'main-out', ...parse_dnsserver(secure_dns_server, 'tcp') }); config.dns.final = 'secure-dns'; /* Custom direct list -> region-dns */ if (length(direct_domain_list)) push(config.dns.rules, { rule_set: 'direct-domain', action: 'route', server: 'region-dns' }); /* Filter out SVCB/HTTPS queries for proxied custom domains */ if (length(proxy_domain_list)) push(config.dns.rules, { rule_set: 'proxy-domain', query_type: [64, 65], action: 'reject' }); /* Region domains -> region-dns (local) */ push(config.dns.rules, { rule_set: 'geosite-' + r, action: 'route', server: 'region-dns', strategy: 'prefer_ipv6' }); /* Optional geoip fallback + non-region guard. CN: list non-exhaustive, catch unlisted * domestic by IP. IR: off -- Iran configs use the simple scheme. */ if (region.guard) push(config.dns.rules, { type: 'logical', mode: 'and', rules: [ { rule_set: 'geosite-non' + r, invert: true }, { rule_set: 'geoip-' + r } ], action: 'route', server: 'region-dns', strategy: 'prefer_ipv6' }); } else { /* Global (proxy everything): plain remote DNS through the tunnel. No region carve-out. */ push(config.dns.servers, { tag: 'main-dns', domain_resolver: { server: 'default-dns', strategy: (ipv6_support !== '1') ? 'ipv4_only' : null }, detour: 'main-out', ...parse_dnsserver(dns_server, 'tcp') }); config.dns.final = 'main-dns'; if (length(direct_domain_list)) push(config.dns.rules, { rule_set: 'direct-domain', action: 'route', server: 'default-dns' }); if (length(proxy_domain_list)) push(config.dns.rules, { rule_set: 'proxy-domain', query_type: [64, 65], action: 'reject' }); } } else if (!isEmpty(default_outbound)) { /* DNS servers */ uci.foreach(uciconfig, ucidnsserver, (cfg) => { if (cfg.enabled !== '1') return; let outbound = get_outbound(cfg.outbound); if (outbound === 'direct-out' && isEmpty(self_mark)) outbound = null; push(config.dns.servers, { tag: 'cfg-' + cfg['.name'] + '-dns', type: cfg.type, server: cfg.server, server_port: strToInt(cfg.server_port), path: cfg.path, headers: cfg.headers, tls: cfg.tls_sni ? { enabled: true, server_name: cfg.tls_sni } : null, domain_resolver: (cfg.address_resolver || cfg.address_strategy) ? { server: get_resolver(cfg.address_resolver || dns_default_server), strategy: cfg.address_strategy } : null, detour: outbound }); }); /* DNS rules */ uci.foreach(uciconfig, ucidnsrule, (cfg) => { if (cfg.enabled !== '1') return; push(config.dns.rules, { ip_version: strToInt(cfg.ip_version), query_type: parse_dnsquery(cfg.query_type), network: cfg.network, protocol: cfg.protocol, domain: cfg.domain, domain_suffix: cfg.domain_suffix, domain_keyword: cfg.domain_keyword, domain_regex: cfg.domain_regex, port: parse_port(cfg.port), port_range: cfg.port_range, source_ip_cidr: cfg.source_ip_cidr, source_ip_is_private: strToBool(cfg.source_ip_is_private), ip_cidr: cfg.ip_cidr, ip_is_private: strToBool(cfg.ip_is_private), source_port: parse_port(cfg.source_port), source_port_range: cfg.source_port_range, process_name: cfg.process_name, process_path: cfg.process_path, process_path_regex: cfg.process_path_regex, user: cfg.user, rule_set: get_ruleset(cfg.rule_set), rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source), invert: strToBool(cfg.invert), outbound: get_outbound(cfg.outbound), action: cfg.action, server: get_resolver(cfg.server), strategy: cfg.domain_strategy, disable_cache: strToBool(cfg.dns_disable_cache), rewrite_ttl: strToInt(cfg.rewrite_ttl), client_subnet: cfg.client_subnet, method: cfg.reject_method, no_drop: strToBool(cfg.reject_no_drop), rcode: cfg.predefined_rcode, answer: cfg.predefined_answer, ns: cfg.predefined_ns, extra: cfg.predefined_extra }); }); if (isEmpty(config.dns.rules)) config.dns.rules = null; config.dns.final = get_resolver(dns_default_server); } /* DNS end */ /* Inbound start */ config.inbounds = []; push(config.inbounds, { type: 'direct', tag: 'dns-in', listen: '::', listen_port: int(dns_port) }); push(config.inbounds, { type: 'mixed', tag: 'mixed-in', listen: '::', listen_port: int(mixed_port), udp_timeout: strToTime(udp_timeout), sniff: is_hiddify ? true : null, sniff_override_destination: is_hiddify ? strToBool(sniff_override) : null, set_system_proxy: is_hiddify ? false : null, }); if (match(proxy_mode, /redirect/)) push(config.inbounds, { type: 'redirect', tag: 'redirect-in', listen: '::', listen_port: int(redirect_port), sniff: is_hiddify ? true : null, sniff_override_destination: is_hiddify ? strToBool(sniff_override) : null, }); if (match(proxy_mode, /tproxy/)) push(config.inbounds, { type: 'tproxy', tag: 'tproxy-in', listen: '::', listen_port: int(tproxy_port), network: 'udp', udp_timeout: strToTime(udp_timeout), sniff: is_hiddify ? true : null, sniff_override_destination: is_hiddify ? strToBool(sniff_override) : null, }); if (match(proxy_mode, /tun/)) push(config.inbounds, { type: 'tun', tag: 'tun-in', interface_name: tun_name, address: (ipv6_support === '1') ? [tun_addr4, tun_addr6] : [tun_addr4], mtu: strToInt(tun_mtu), auto_route: false, endpoint_independent_nat: strToBool(endpoint_independent_nat), udp_timeout: strToTime(udp_timeout), stack: tcpip_stack, sniff: is_hiddify ? true : null, sniff_override_destination: is_hiddify ? strToBool(sniff_override) : null, }); /* Server inbounds */ uci.foreach(uciconfig, uciserver, (cfg) => { if (cfg.enabled !== '1') return; push(config.inbounds, { type: cfg.type, tag: 'cfg-' + cfg['.name'] + '-in', listen: cfg.address || '::', listen_port: strToInt(cfg.port), bind_interface: cfg.bind_interface, reuse_addr: strToBool(cfg.reuse_addr), tcp_fast_open: strToBool(cfg.tcp_fast_open), tcp_multi_path: strToBool(cfg.tcp_multi_path), udp_fragment: strToBool(cfg.udp_fragment), udp_timeout: strToTime(cfg.udp_timeout), network: cfg.network, /* AnyTLS */ padding_scheme: cfg.anytls_padding_scheme, /* Hysteria */ up_mbps: strToInt(cfg.hysteria_up_mbps), down_mbps: strToInt(cfg.hysteria_down_mbps), obfs: cfg.hysteria_obfs_type ? { type: cfg.hysteria_obfs_type, password: cfg.hysteria_obfs_password } : cfg.hysteria_obfs_password, recv_window_conn: strToInt(cfg.hysteria_recv_window_conn), recv_window_client: strToInt(cfg.hysteria_revc_window_client), max_conn_client: strToInt(cfg.hysteria_max_conn_client), disable_mtu_discovery: strToBool(cfg.hysteria_disable_mtu_discovery), ignore_client_bandwidth: strToBool(cfg.hysteria_ignore_client_bandwidth), masquerade: cfg.hysteria_masquerade, /* Shadowsocks */ method: (cfg.type === 'shadowsocks') ? cfg.shadowsocks_encrypt_method : null, password: (cfg.type in ['shadowsocks', 'shadowtls']) ? cfg.password : null, /* Tuic */ congestion_control: cfg.tuic_congestion_control, auth_timeout: strToTime(cfg.tuic_auth_timeout), zero_rtt_handshake: strToBool(cfg.tuic_enable_zero_rtt), heartbeat: strToTime(cfg.tuic_heartbeat), /* MTProxy */ concurrency: (cfg.type === 'mtproxy') ? strToInt(cfg.mtproxy_concurrency) : null, idle_timeout: (cfg.type === 'mtproxy') ? (cfg.mtproxy_idle_timeout || null) : null, handshake_timeout: (cfg.type === 'mtproxy') ? (cfg.mtproxy_handshake_timeout || null) : null, domain_fronting_port: (cfg.type === 'mtproxy') ? strToInt(cfg.domain_fronting_port) : null, domain_fronting_host: (cfg.type === 'mtproxy') ? (cfg.domain_fronting_host || null) : null, /* AnyTLS / HTTP / Hysteria (2) / Mixed / MTProxy / Socks / Trojan / Tuic / VLESS / VMess */ users: (cfg.type === 'mtproxy') ? map(cfg.mtproxy_secrets || [], (s, i) => ({ name: 'user' + (i + 1), secret: s })) : (cfg.type !== 'shadowsocks') ? [ { name: !(cfg.type in ['http', 'mixed', 'naive', 'socks']) ? 'cfg-' + cfg['.name'] + '-server' : null, username: cfg.username, password: cfg.password, /* Hysteria */ auth: (cfg.hysteria_auth_type === 'base64') ? cfg.hysteria_auth_payload : null, auth_str: (cfg.hysteria_auth_type === 'string') ? cfg.hysteria_auth_payload : null, /* Tuic */ uuid: cfg.uuid, /* VLESS / VMess */ flow: cfg.vless_flow, alterId: strToInt(cfg.vmess_alterid) } ] : null, multiplex: (cfg.multiplex === '1') ? { enabled: true, padding: strToBool(cfg.multiplex_padding), brutal: (cfg.multiplex_brutal === '1') ? { enabled: true, up_mbps: strToInt(cfg.multiplex_brutal_up), down_mbps: strToInt(cfg.multiplex_brutal_down) } : null } : null, tls: (cfg.tls === '1') ? { enabled: true, server_name: cfg.tls_sni, alpn: cfg.tls_alpn, min_version: cfg.tls_min_version, max_version: cfg.tls_max_version, cipher_suites: cfg.tls_cipher_suites, certificate_path: cfg.tls_cert_path, key_path: cfg.tls_key_path, acme: (cfg.tls_acme === '1') ? { domain: cfg.tls_acme_domain, data_directory: HP_DIR + '/certs', default_server_name: cfg.tls_acme_dsn, email: cfg.tls_acme_email, provider: cfg.tls_acme_provider, disable_http_challenge: strToBool(cfg.tls_acme_dhc), disable_tls_alpn_challenge: (cfg.tls_acme_dtac), alternative_http_port: strToInt(cfg.tls_acme_ahp), alternative_tls_port: strToInt(cfg.tls_acme_atp), external_account: (cfg.tls_acme_external_account === '1') ? { key_id: cfg.tls_acme_ea_keyid, mac_key: cfg.tls_acme_ea_mackey } : null, dns01_challenge: (cfg.tls_dns01_challenge === '1') ? { provider: cfg.tls_dns01_provider, access_key_id: cfg.tls_dns01_ali_akid, access_key_secret: cfg.tls_dns01_ali_aksec, region_id: cfg.tls_dns01_ali_rid, api_token: cfg.tls_dns01_cf_api_token } : null } : null, ech: (cfg.tls_ech_key) ? { enabled: true, key: split(cfg.tls_ech_key, '\n') } : null, reality: (cfg.tls_reality === '1') ? { enabled: true, private_key: cfg.tls_reality_private_key, short_id: cfg.tls_reality_short_id, max_time_difference: strToTime(cfg.tls_reality_max_time_difference), handshake: { server: cfg.tls_reality_server_addr, server_port: strToInt(cfg.tls_reality_server_port) } } : null } : null, transport: !isEmpty(cfg.transport) ? { type: cfg.transport, host: transport_host(cfg), path: cfg.http_path || cfg.ws_path, mode: (cfg.transport === 'xhttp') ? (cfg.xhttp_mode || 'auto') : null, x_padding_bytes: (is_singbox && cfg.transport === 'xhttp') ? xhttp_padding(cfg.xhttp_padding_bytes) : null, headers: cfg.ws_host ? { Host: cfg.ws_host } : null, method: cfg.http_method, max_early_data: strToInt(cfg.websocket_early_data), early_data_header_name: cfg.websocket_early_data_header, service_name: cfg.grpc_servicename, idle_timeout: strToTime(cfg.http_idle_timeout), ping_timeout: strToTime(cfg.http_ping_timeout) } : null }); }); /* Inbound end */ /* Outbound start */ config.endpoints = []; /* Default outbounds */ config.outbounds = [ { type: 'direct', tag: 'direct-out' }, { type: 'block', tag: 'block-out' } ]; /* Main outbounds */ if (!isEmpty(main_node)) { let urltest_nodes = []; if (main_node === 'urltest') { const main_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [], (k) => uci.get_all(uciconfig, k) != null); const main_urltest_interval = uci.get(uciconfig, ucimain, 'main_urltest_interval'); const main_urltest_tolerance = uci.get(uciconfig, ucimain, 'main_urltest_tolerance'); push(config.outbounds, { type: 'urltest', tag: 'main-out', outbounds: map(main_urltest_nodes, (k) => `cfg-${k}-out`), interval: strToTime(main_urltest_interval), tolerance: strToInt(main_urltest_tolerance), idle_timeout: (strToInt(main_urltest_interval) > 1800) ? `${main_urltest_interval * 2}s` : null, }); urltest_nodes = main_urltest_nodes; } else if (main_node === 'byedpi-out') { /* ByeDPI as main node: route through the local ByeDPI socks proxy. * byedpi-out is a synthetic tag, not a real node section, so build the * socks outbound here. Fall back to direct if ByeDPI is disabled. */ if (byedpi_enabled === '1') push(config.outbounds, { type: 'socks', tag: 'main-out', server: '127.0.0.1', server_port: 5335, udp_over_tcp: (uci.get(uciconfig, ucimain, 'byedpi_udp_over_tcp') !== '0') || null }); else push(config.outbounds, { type: 'direct', tag: 'main-out' }); } else if (main_node === 'zapret-out') { /* Zapret as main node: egress direct but stamped with the Zapret routing_mark, so * nft feeds the handshake to nfqws2 (desync everything, no proxy — the Zapret * equivalent of standalone ByeDPI). Synthetic tag; fall back to plain direct if * Zapret is disabled so the config stays valid. */ if (zapret_enabled === '1') push(config.outbounds, { type: 'direct', tag: 'main-out', routing_mark: strToInt(zapret_mark) }); else push(config.outbounds, { type: 'direct', tag: 'main-out' }); } else { const main_node_cfg = uci.get_all(uciconfig, main_node) || {}; if (main_node_cfg.type in ['wireguard', 'amneziawg']) { push(config.endpoints, generate_endpoint(main_node_cfg)); config.endpoints[length(config.endpoints)-1].tag = 'main-out'; } else { push_outbound(config.outbounds, main_node_cfg); config.outbounds[length(config.outbounds)-1].tag = 'main-out'; } } if (main_udp_node === 'urltest') { const main_udp_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || [], (k) => uci.get_all(uciconfig, k) != null); const main_udp_urltest_interval = uci.get(uciconfig, ucimain, 'main_udp_urltest_interval'); const main_udp_urltest_tolerance = uci.get(uciconfig, ucimain, 'main_udp_urltest_tolerance'); push(config.outbounds, { type: 'urltest', tag: 'main-udp-out', outbounds: map(main_udp_urltest_nodes, (k) => `cfg-${k}-out`), interval: strToTime(main_udp_urltest_interval), tolerance: strToInt(main_udp_urltest_tolerance), idle_timeout: (strToInt(main_udp_urltest_interval) > 1800) ? `${main_udp_urltest_interval * 2}s` : null, }); urltest_nodes = [...urltest_nodes, ...filter(main_udp_urltest_nodes, (l) => !~index(urltest_nodes, l))]; } else if (dedicated_udp_node && main_udp_node === 'byedpi-out') { /* ByeDPI as dedicated UDP node — same synthetic-tag handling as above */ if (byedpi_enabled === '1') push(config.outbounds, { type: 'socks', tag: 'main-udp-out', server: '127.0.0.1', server_port: 5335, udp_over_tcp: (uci.get(uciconfig, ucimain, 'byedpi_udp_over_tcp') !== '0') || null }); else push(config.outbounds, { type: 'direct', tag: 'main-udp-out' }); } else if (dedicated_udp_node && main_udp_node === 'zapret-out') { /* Zapret as dedicated UDP node — direct egress + Zapret mark; nfqws2 desyncs * UDP/QUIC too (unlike ByeDPI). Synthetic tag, fall back to plain direct. */ if (zapret_enabled === '1') push(config.outbounds, { type: 'direct', tag: 'main-udp-out', routing_mark: strToInt(zapret_mark) }); else push(config.outbounds, { type: 'direct', tag: 'main-udp-out' }); } else if (dedicated_udp_node) { const main_udp_node_cfg = uci.get_all(uciconfig, main_udp_node) || {}; if (main_udp_node_cfg.type in ['wireguard', 'amneziawg']) { push(config.endpoints, generate_endpoint(main_udp_node_cfg)); config.endpoints[length(config.endpoints)-1].tag = 'main-udp-out'; } else { push_outbound(config.outbounds, main_udp_node_cfg); config.outbounds[length(config.outbounds)-1].tag = 'main-udp-out'; } } for (let i in urltest_nodes) { const urltest_node = uci.get_all(uciconfig, i); if (!urltest_node) continue; if (urltest_node.type in ['wireguard', 'amneziawg']) { push(config.endpoints, generate_endpoint(urltest_node)); config.endpoints[length(config.endpoints)-1].tag = 'cfg-' + i + '-out'; } else { push_outbound(config.outbounds, urltest_node); config.outbounds[length(config.outbounds)-1].tag = 'cfg-' + i + '-out'; } } /* Advanced routing_node outbounds for proxy_banned_ru */ if (routing_mode === 'proxy_banned_ru' && show_advanced_rules === '1') { let adv_urltest_nodes = [], adv_routing_nodes = []; uci.foreach(uciconfig, uciroutingnode, (cfg) => { if (cfg.enabled !== '1') return; if (cfg.node === 'urltest') { const existing_urltest_nodes = filter(cfg.urltest_nodes, (k) => uci.get_all(uciconfig, k) != null); push(config.outbounds, { type: 'urltest', tag: 'cfg-' + cfg['.name'] + '-out', outbounds: map(existing_urltest_nodes, (k) => `cfg-${k}-out`), url: cfg.urltest_url, interval: strToTime(cfg.urltest_interval), tolerance: strToInt(cfg.urltest_tolerance), idle_timeout: strToTime(cfg.urltest_idle_timeout), interrupt_exist_connections: strToBool(cfg.urltest_interrupt_exist_connections) }); adv_urltest_nodes = [...adv_urltest_nodes, ...filter(existing_urltest_nodes, (l) => !~index(adv_urltest_nodes, l))]; } else { const outbound = uci.get_all(uciconfig, cfg.node) || {}; /* Skip a routing node whose target proxy node is empty or dangling — * otherwise push_outbound() appends a null outbound and the next line * dereferences it, crashing config generation (no file is written). */ if (isEmpty(outbound)) return; if (outbound.type in ['wireguard', 'amneziawg']) { push(config.endpoints, generate_endpoint(outbound)); config.endpoints[length(config.endpoints)-1].bind_interface = cfg.bind_interface; config.endpoints[length(config.endpoints)-1].detour = get_outbound(cfg.outbound); } else { push_outbound(config.outbounds, outbound); config.outbounds[length(config.outbounds)-1].bind_interface = cfg.bind_interface; const adv_chain_detour = get_outbound(cfg.outbound); if (adv_chain_detour) config.outbounds[length(config.outbounds)-1].detour = adv_chain_detour; } push(adv_routing_nodes, cfg.node); } }); for (let i in filter(adv_urltest_nodes, (l) => !~index(adv_routing_nodes, l))) { if (has_outbound('cfg-' + i + '-out')) continue; const urltest_node = uci.get_all(uciconfig, i); if (!urltest_node) continue; if (urltest_node.type in ['wireguard', 'amneziawg']) push(config.endpoints, generate_endpoint(urltest_node)); else push_outbound(config.outbounds, urltest_node); } } } else if (!isEmpty(default_outbound)) { let urltest_nodes = [], routing_nodes = []; uci.foreach(uciconfig, uciroutingnode, (cfg) => { if (cfg.enabled !== '1') return; if (cfg.node === 'urltest') { const existing_urltest_nodes = filter(cfg.urltest_nodes, (k) => uci.get_all(uciconfig, k) != null); push(config.outbounds, { type: 'urltest', tag: 'cfg-' + cfg['.name'] + '-out', outbounds: map(existing_urltest_nodes, (k) => `cfg-${k}-out`), url: cfg.urltest_url, interval: strToTime(cfg.urltest_interval), tolerance: strToInt(cfg.urltest_tolerance), idle_timeout: strToTime(cfg.urltest_idle_timeout), interrupt_exist_connections: strToBool(cfg.urltest_interrupt_exist_connections) }); urltest_nodes = [...urltest_nodes, ...filter(existing_urltest_nodes, (l) => !~index(urltest_nodes, l))]; } else { const outbound = uci.get_all(uciconfig, cfg.node) || {}; /* Skip a routing node whose target proxy node is empty or dangling — * otherwise push_outbound() appends a null outbound and the next line * dereferences it, crashing config generation (no file is written). */ if (isEmpty(outbound)) return; if (outbound.type in ['wireguard', 'amneziawg']) { push(config.endpoints, generate_endpoint(outbound)); config.endpoints[length(config.endpoints)-1].bind_interface = cfg.bind_interface; config.endpoints[length(config.endpoints)-1].detour = get_outbound(cfg.outbound); if (cfg.domain_resolver) config.endpoints[length(config.endpoints)-1].domain_resolver = { server: get_resolver(cfg.domain_resolver), strategy: cfg.domain_strategy }; } else { push_outbound(config.outbounds, outbound); config.outbounds[length(config.outbounds)-1].bind_interface = cfg.bind_interface; const chain_detour = get_outbound(cfg.outbound); if (chain_detour) config.outbounds[length(config.outbounds)-1].detour = chain_detour; if (cfg.domain_resolver) config.outbounds[length(config.outbounds)-1].domain_resolver = { server: get_resolver(cfg.domain_resolver), strategy: cfg.domain_strategy }; } push(routing_nodes, cfg.node); } }); for (let i in filter(urltest_nodes, (l) => !~index(routing_nodes, l))) { const urltest_node = uci.get_all(uciconfig, i); if (!urltest_node) continue; if (urltest_node.type in ['wireguard', 'amneziawg']) push(config.endpoints, generate_endpoint(urltest_node)); else push_outbound(config.outbounds, urltest_node); } } if (isEmpty(config.endpoints)) config.endpoints = null; /* Outbound end */ /* Routing rules start */ /* Default settings */ config.route = { rules: [ { inbound: 'dns-in', action: 'hijack-dns' }, { /* Explicit sniff action — emit for BOTH cores. hiddify-core's legacy inbound * `sniff: true` does NOT sniff QUIC (TLS works, QUIC doesn't), so without this * route action QUIC carries no SNI and can't be domain-routed (e.g. YouTube * video → zapret-out fell through to direct). Device-confirmed on hiddify-core * 1.13.1; both cores support the action. */ action: 'sniff' } ], rule_set: [], auto_detect_interface: isEmpty(default_interface) ? true : null, default_interface: default_interface, default_mark: strToInt(self_mark) }; /* Routing rules */ if (!isEmpty(main_node)) { /* Avoid DNS loop */ /* sing-box-extended supports action object; hiddify-core (standard sing-box 1.12) expects a string tag */ const default_resolver_server = is_bypass_mode(routing_mode) ? 'region-dns' : (routing_mode === 'proxy_banned_ru') ? 'russia-dns' : 'default-dns'; config.route.default_domain_resolver = is_singbox ? { action: 'route', server: default_resolver_server, strategy: (ipv6_support !== '1') ? 'prefer_ipv4' : null } : default_resolver_server; /* Direct list (not needed in proxy_banned_ru — direct is the default) */ if (length(direct_domain_list) && routing_mode !== 'proxy_banned_ru') push(config.route.rules, { rule_set: 'direct-domain', action: 'route', outbound: 'direct-out' }); /* Main UDP out — only in `global` here (everything proxied, no carve-outs, order moot). * In selective modes (RU/reverse) the UDP-node rule is emitted LAST, after the region * baseline + per-service overrides, so domestic/override UDP isn't swept into it. */ if (dedicated_udp_node && routing_mode === 'global') push(config.route.rules, { network: 'udp', action: 'route', outbound: 'main-udp-out' }); config.route.final = (routing_mode === 'proxy_banned_ru') ? 'direct-out' : 'main-out'; /* Rule set */ /* Direct list */ if (length(direct_domain_list) && routing_mode !== 'proxy_banned_ru') push(config.route.rule_set, { type: 'inline', tag: 'direct-domain', rules: [ { domain_keyword: direct_domain_list, } ] }); /* Proxy list — also used in proxy_banned_ru for proxy-domain → main-out */ if (length(proxy_domain_list)) push(config.route.rule_set, { type: 'inline', tag: 'proxy-domain', rules: [ { domain_keyword: proxy_domain_list, } ] }); if (is_selective_mode(routing_mode)) { /* Resolve domains before routing — prevents the proxy server from doing its own DNS * resolution, and (reverse) lets the geoip baseline match by IP. */ push(config.route.rules, { action: 'resolve', strategy: (ipv6_support !== '1') ? 'ipv4_only' : null }); /* Advanced custom routing rules (highest priority) */ if (show_advanced_rules === '1') { uci.foreach(uciconfig, uciroutingrule, (cfg) => { if (cfg.enabled !== '1') return; push(config.route.rules, { inbound: cfg.inbound, ip_version: strToInt(cfg.ip_version), protocol: cfg.protocol, network: cfg.network, domain: cfg.domain, domain_suffix: cfg.domain_suffix, domain_keyword: cfg.domain_keyword, domain_regex: cfg.domain_regex, source_ip_cidr: cfg.source_ip_cidr, source_ip_is_private: strToBool(cfg.source_ip_is_private), ip_cidr: cfg.ip_cidr, ip_is_private: strToBool(cfg.ip_is_private), source_port: parse_port(cfg.source_port), source_port_range: cfg.source_port_range, port: parse_port(cfg.port), port_range: cfg.port_range, process_name: cfg.process_name, process_path: cfg.process_path, process_path_regex: cfg.process_path_regex, user: cfg.user, rule_set: get_ruleset(cfg.rule_set), rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source), rule_set_ip_cidr_accept_empty: strToBool(cfg.rule_set_ip_cidr_accept_empty), invert: strToBool(cfg.invert), action: cfg.action, outbound: get_outbound(cfg.outbound), override_address: cfg.override_address, override_port: strToInt(cfg.override_port) }); }); } /* Call proxying rules: UDP media ports + XMPP/SIP ports for VoIP apps */ /* Torrent bypass first — takes priority over proxy_calls port ranges (51413 overlaps 50000:65530) */ if (no_proxy_torrents === '1') { push(config.route.rules, { protocol: ['bittorrent'], action: 'route', outbound: 'direct-out' }); push(config.route.rules, { port_range: ['6881:6889', '51413:51413'], action: 'route', outbound: 'direct-out' }); } /* Zapret Discord voice (opt-in): send Discord's voice-server UDP ranges * (19294-19344, 50000-50100 — from flowseal/zapret-discord-youtube) to zapret-out * BEFORE proxy_calls, so Discord voice is desynced via Zapret instead of proxied. * Emitted ONLY when Zapret is on (so zapret-out exists) — when Zapret is off this * rule disappears and proxy_calls handles these ports as before. Works on either core. */ if (zapret_enabled === '1' && zapret_voice === '1') push(config.route.rules, { network: 'udp', port_range: ['19294:19344', '50000:50100'], action: 'route', outbound: 'zapret-out' }); if (proxy_calls === '1') { push(config.route.rules, { network: 'udp', port: [1400, 8443], port_range: ['50000:65530', '596:599', '3478:3497', '16384:16387', '16393:16402'], action: 'route', outbound: 'main-out' }); push(config.route.rules, { port: [4244, 7985, 5222, 5223, 5242, 5243], action: 'route', outbound: 'main-out' }); } /* andrevi.ch always via proxy (hardcoded diagnostic anchor) */ push(config.route.rules, { domain: ['andrevi.ch'], action: 'route', outbound: 'main-out' }); /* Custom proxy list → main-out */ if (length(proxy_domain_list)) push(config.route.rules, { rule_set: 'proxy-domain', action: 'route', outbound: 'main-out' }); /* Per-rule outbounds and rule sets * Priority order: specific services first → russia-inside → refilter (largest list last) */ const ru_source_priority = (s) => s === 'refilter' ? 2 : s === 'russia-inside' ? 1 : 0; let ru_rules = []; uci.foreach(uciconfig, ucirurule, (cfg) => { if (cfg.enabled === '1') push(ru_rules, cfg); }); ru_rules = sort(ru_rules, (a, b) => ru_source_priority(a.source) - ru_source_priority(b.source)); /* Use direct-out for rule set downloads when the main path isn't startup-safe: * WireGuard/AmneziaWG endpoints aren't ready yet, and ByeDPI resolves hostnames * through sing-box's own DNS inbound — which isn't serving during rule-set init, so * downloading github through it deadlocks (socks5 code 4 "host unreachable") and * FATALs the whole service. Direct download lets sing-box resolve via russia-dns. */ const main_node_type = uci.get(uciconfig, main_node, 'type') || ''; let main_has_wg = (main_node_type in ['wireguard', 'amneziawg']); if (!main_has_wg && main_node === 'urltest') { const ut_nodes = filter(uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [], (k) => uci.get_all(uciconfig, k) != null); for (let n in ut_nodes) { if ((uci.get(uciconfig, n, 'type') || '') in ['wireguard', 'amneziawg']) { main_has_wg = true; break; } } } const ruleset_detour = (main_has_wg || main_node === 'byedpi-out') ? 'direct-out' : 'main-out'; for (let cfg in ru_rules) { /* 'main-out' routes through the main proxy; 'byedpi-out' through the shared ByeDPI * socks outbound (already created when ByeDPI is enabled). Both reuse an existing * outbound, so no per-source outbound is generated. */ let effective_outbound; if (cfg.node === 'main-out' || isEmpty(cfg.node)) effective_outbound = 'main-out'; else if (cfg.node === 'byedpi-out') effective_outbound = (byedpi_enabled === '1') ? 'byedpi-out' : 'direct-out'; else if (cfg.node === 'zapret-out') effective_outbound = (zapret_enabled === '1') ? 'zapret-out' : 'direct-out'; else effective_outbound = 'hp-ru-' + cfg.source + '-out'; if (cfg.node === 'main-out' || isEmpty(cfg.node) || cfg.node === 'byedpi-out' || cfg.node === 'zapret-out') { /* no new outbound needed — main-out / byedpi-out / zapret-out already exist */ } else if (!has_outbound(effective_outbound)) { if (cfg.node === 'urltest') { const ut_nodes = filter(cfg.urltest_nodes || [], (k) => uci.get_all(uciconfig, k) != null); push(config.outbounds, { type: 'urltest', tag: effective_outbound, outbounds: map(ut_nodes, (k) => `cfg-${k}-out`), interval: strToTime(cfg.urltest_interval || '180'), tolerance: strToInt(cfg.urltest_tolerance || '150'), idle_timeout: '1800s' }); /* Generate underlying node outbounds, skipping already-generated tags */ for (let n in ut_nodes) { if (has_outbound('cfg-' + n + '-out')) continue; const nc = uci.get_all(uciconfig, n); if (!nc) continue; if (nc.type in ['wireguard', 'amneziawg']) { push(config.endpoints, generate_endpoint(nc)); config.endpoints[length(config.endpoints)-1].tag = 'cfg-' + n + '-out'; } else { push_outbound(config.outbounds, nc); config.outbounds[length(config.outbounds)-1].tag = 'cfg-' + n + '-out'; } } } else if (!isEmpty(cfg.node)) { const nc = uci.get_all(uciconfig, cfg.node) || {}; if (nc.type in ['wireguard', 'amneziawg']) { push(config.endpoints, generate_endpoint(nc)); config.endpoints[length(config.endpoints)-1].tag = effective_outbound; } else { push_outbound(config.outbounds, nc); config.outbounds[length(config.outbounds)-1].tag = effective_outbound; } } } /* Routing rules */ const rule_sets = (cfg.source === 'refilter') ? ['hp-ru-refilter-domain', 'hp-ru-refilter-ip'] : ['hp-ru-' + cfg.source]; push(config.route.rules, { rule_set: rule_sets, action: 'route', outbound: effective_outbound }); /* Rule sets (remote — core handles download and 1d refresh) */ const has_ruleset = (tag) => filter(config.route.rule_set, (rs) => rs.tag === tag).length > 0; if (cfg.source === 'refilter') { if (!has_ruleset('hp-ru-refilter-domain')) push(config.route.rule_set, { type: 'remote', tag: 'hp-ru-refilter-domain', format: 'binary', url: 'https://github.com/1andrevich/Re-filter-lists/releases/latest/download/ruleset-domain-refilter_domains.srs', download_detour: ruleset_detour, update_interval: '1d' }); if (!has_ruleset('hp-ru-refilter-ip')) push(config.route.rule_set, { type: 'remote', tag: 'hp-ru-refilter-ip', format: 'binary', url: 'https://github.com/1andrevich/Re-filter-lists/releases/latest/download/ruleset-ip-refilter_ipsum.srs', download_detour: ruleset_detour, update_interval: '1d' }); } else { if (!has_ruleset('hp-ru-' + cfg.source)) push(config.route.rule_set, { type: 'remote', tag: 'hp-ru-' + cfg.source, format: 'binary', url: 'https://github.com/itdoginfo/allow-domains/releases/latest/download/' + replace(cfg.source, '-', '_') + '.srs', download_detour: ruleset_detour, update_interval: '1d' }); } } /* Reverse: region baseline -> direct (lowest priority, AFTER per-service overrides). * MUST include geoip (IP) so non-sniffable UDP / no-SNI gets the direct decision. */ if (is_bypass_mode(routing_mode)) { push(config.route.rules, { rule_set: [ 'geosite-' + region_of(routing_mode), 'geoip-' + region_of(routing_mode) ], action: 'route', outbound: 'direct-out' }); /* Proxied-UDP default node, after baseline+overrides so domestic/override UDP isn't swept in. */ if (dedicated_udp_node) push(config.route.rules, { network: 'udp', action: 'route', outbound: 'main-udp-out' }); } } if (is_bypass_mode(routing_mode)) { /* Region rule-sets (.srs). Direct-bootstrap: download direct so a not-yet-up proxy * can't deadlock rule-set init at startup. geosite_non only when guard is on (CN). */ const r = region_of(routing_mode); const region = REGION[r]; push(config.route.rule_set, { type: 'remote', tag: 'geoip-' + r, format: 'binary', url: region.geoip, download_detour: 'direct-out', update_interval: '1d' }); push(config.route.rule_set, { type: 'remote', tag: 'geosite-' + r, format: 'binary', url: region.geosite, download_detour: 'direct-out', update_interval: '1d' }); if (region.guard) push(config.route.rule_set, { type: 'remote', tag: 'geosite-non' + r, format: 'binary', url: region.geosite_non, download_detour: 'direct-out', update_interval: '1d' }); } if (isEmpty(config.route.rule_set)) config.route.rule_set = null; } else if (!isEmpty(default_outbound)) { config.route.default_domain_resolver = is_singbox ? { action: 'resolve', server: get_resolver(default_outbound_dns) } : get_resolver(default_outbound_dns); if (domain_strategy) push(config.route.rules, { action: 'resolve', strategy: domain_strategy }); uci.foreach(uciconfig, uciroutingrule, (cfg) => { if (cfg.enabled !== '1') return null; push(config.route.rules, { inbound: cfg.inbound, ip_version: strToInt(cfg.ip_version), protocol: cfg.protocol, network: cfg.network, domain: cfg.domain, domain_suffix: cfg.domain_suffix, domain_keyword: cfg.domain_keyword, domain_regex: cfg.domain_regex, source_ip_cidr: cfg.source_ip_cidr, source_ip_is_private: strToBool(cfg.source_ip_is_private), ip_cidr: cfg.ip_cidr, ip_is_private: strToBool(cfg.ip_is_private), source_port: parse_port(cfg.source_port), source_port_range: cfg.source_port_range, port: parse_port(cfg.port), port_range: cfg.port_range, process_name: cfg.process_name, process_path: cfg.process_path, process_path_regex: cfg.process_path_regex, user: cfg.user, rule_set: get_ruleset(cfg.rule_set), rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source), rule_set_ip_cidr_accept_empty: strToBool(cfg.rule_set_ip_cidr_accept_empty), invert: strToBool(cfg.invert), action: cfg.action, outbound: get_outbound(cfg.outbound), override_address: cfg.override_address, override_port: strToInt(cfg.override_port), udp_disable_domain_unmapping: strToBool(cfg.udp_disable_domain_unmapping), udp_connect: strToBool(cfg.udp_connect), udp_timeout: strToTime(cfg.udp_timeout), tls_fragment: strToBool(cfg.tls_fragment), tls_fragment_fallback_delay: strToTime(cfg.tls_fragment_fallback_delay), tls_record_fragment: strToBool(cfg.tls_record_fragment) }); }); config.route.final = get_outbound(default_outbound); /* Rule set */ uci.foreach(uciconfig, uciruleset, (cfg) => { if (cfg.enabled !== '1') return null; push(config.route.rule_set, { type: cfg.type, tag: 'cfg-' + cfg['.name'] + '-rule', format: cfg.format, path: cfg.path, url: cfg.url, download_detour: get_outbound(cfg.outbound), update_interval: cfg.update_interval }); }); } /* Routing rules end */ /* ByeDPI outbound */ if (byedpi_enabled === '1') { const byedpi_uot = uci.get(uciconfig, ucimain, 'byedpi_udp_over_tcp') !== '0'; push(config.outbounds, { type: 'socks', tag: 'byedpi-out', server: '127.0.0.1', server_port: 5335, udp_over_tcp: byedpi_uot || null }); } /* ByeDPI outbound end */ /* zapret outbound: a plain direct dialer stamped with routing_mark. sing-box egresses * the selected (e.g. YouTube) flows directly but tagged with zapret_mark; nft catches * that mark and feeds the handshake to NFQUEUE where nfqws (running separately) desyncs * the DPI. routing_mark OVERRIDES the global default_mark, so firewall_post.ut adds a * matching loop-avoidance return for zapret_mark. */ if (zapret_enabled === '1') { push(config.outbounds, { type: 'direct', tag: 'zapret-out', routing_mark: strToInt(zapret_mark) }); } /* zapret outbound end */ /* Experimental start */ config.experimental = { clash_api: { external_controller: '127.0.0.1:9090' } }; if (is_selective_mode(routing_mode) || routing_mode === 'custom') { config.experimental.cache_file = { enabled: true, path: RUN_DIR + '/cache.db', store_rdrc: strToBool(cache_file_store_rdrc), rdrc_timeout: strToTime(cache_file_rdrc_timeout), }; } /* Experimental end */ system('mkdir -p ' + RUN_DIR); writefile(RUN_DIR + '/hiddify-c.json', sprintf('%.J\n', removeBlankAttrs(config)));