#!/usr/bin/ucode /* * SPDX-License-Identifier: GPL-2.0-only * * Copyright (C) 2023 ImmortalWrt.org */ 'use strict'; import { md5 } from 'digest'; import { open } from 'fs'; import { connect } from 'ubus'; import { cursor } from 'uci'; import { init_action } from 'luci.sys'; import { wGETVerbose, decodeBase64Str, getTime, isEmpty, HP_DIR, RUN_DIR } from 'homeproxy'; import { parse_uri } from 'parse_uri'; /* UCI config start */ const uci = cursor(); const uciconfig = 'homeproxy'; uci.load(uciconfig); const ucimain = 'config', ucinode = 'node', ucisubscription = 'subscription'; const allow_insecure = uci.get(uciconfig, ucisubscription, 'allow_insecure') || '0', filter_mode = uci.get(uciconfig, ucisubscription, 'filter_nodes') || 'disabled', filter_keywords = uci.get(uciconfig, ucisubscription, 'filter_keywords') || [], packet_encoding = uci.get(uciconfig, ucisubscription, 'packet_encoding') || 'xudp', subscription_urls = uci.get(uciconfig, ucisubscription, 'subscription_url') || [], user_agent = uci.get(uciconfig, ucisubscription, 'user_agent'), via_proxy = uci.get(uciconfig, ucisubscription, 'update_via_proxy') || '0'; const routing_mode = uci.get(uciconfig, ucimain, 'routing_mode') || 'bypass_mainland_china'; let main_node, main_udp_node; if (routing_mode !== 'custom') { main_node = uci.get(uciconfig, ucimain, 'main_node') || 'nil'; main_udp_node = uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil'; } /* UCI config end */ /* String helper start */ function filter_check(name) { if (isEmpty(name) || filter_mode === 'disabled' || isEmpty(filter_keywords)) return false; let ret = false; for (let i in filter_keywords) { let patten; try { patten = regexp(i); } catch(e) { log(sprintf('Skipping invalid filter keyword regex: %s.', i)); continue; } if (patten && match(name, patten)) ret = true; } if (filter_mode === 'whitelist') ret = !ret; return ret; } /* String helper end */ /* Common var start */ const node_cache = {}, node_result = []; const ubus = connect(); const sing_features = ubus.call('luci.homeproxy', 'singbox_get_features', {}) || {}; if (isEmpty(sing_features)) log('Warning: Failed to query sing-box features via ubus, assuming defaults.'); /* Common var end */ /* Log */ system(`mkdir -p ${RUN_DIR}`); function log(...args) { const logfile = open(`${RUN_DIR}/homeproxy.log`, 'a'); logfile.write(`${getTime()} [SUBSCRIBE] ${join(' ', args)}\n`); logfile.close(); } function main() { if (via_proxy !== '1') { log('Stopping service...'); init_action('homeproxy', 'stop'); } for (let url in subscription_urls) { url = replace(url, /#.*$/, ''); const groupHash = md5(url); node_cache[groupHash] = {}; const fetched = wGETVerbose(url, user_agent); if (isEmpty(fetched.content)) { log(sprintf('Failed to fetch resources from %s: %s', url, fetched.error || 'empty response')); continue; } const res = fetched.content; let nodes; try { nodes = json(res).servers || json(res); /* Shadowsocks SIP008 format */ if (nodes[0].server && nodes[0].method) map(nodes, (_, i) => nodes[i].nodetype = 'sip008'); } catch(e) { log(sprintf('JSON parse failed for %s, trying base64: %s', url, e.message)); nodes = decodeBase64Str(res); nodes = nodes ? split(trim(nodes), '\n') : []; } let count = 0; for (let node in nodes) { let config; if (!isEmpty(node)) config = parse_uri(node, sing_features, log); if (isEmpty(config)) continue; const label = config.label; config.label = null; const confHash = md5(sprintf('%J', config)), nameHash = md5(groupHash + label); config.label = label; if (filter_check(config.label)) log(sprintf('Skipping blacklist node: %s.', config.label)); else if (node_cache[groupHash][confHash] || node_cache[groupHash][nameHash]) log(sprintf('Skipping duplicate node: %s.', config.label)); else { if (config.tls === '1' && allow_insecure === '1') config.tls_insecure = '1'; if (config.type in ['vless', 'vmess']) config.packet_encoding = packet_encoding; config.grouphash = groupHash; push(node_result, []); push(node_result[length(node_result)-1], config); node_cache[groupHash][confHash] = config; node_cache[groupHash][nameHash] = config; count++; } } if (count === 0) log(sprintf('No valid node found in %s.', url)); else log(sprintf('Successfully fetched %s nodes of total %s from %s.', count, length(nodes), url)); } if (isEmpty(node_result)) { log('Failed to update subscriptions: no valid node found.'); if (via_proxy !== '1') { log('Starting service...'); init_action('homeproxy', 'start'); } return false; } let added = 0, removed = 0; uci.foreach(uciconfig, ucinode, (cfg) => { /* Nodes created by the user */ if (!cfg.grouphash) return null; /* Empty object - failed to fetch nodes, or subscription URL not yet processed */ if (!node_cache[cfg.grouphash] || length(node_cache[cfg.grouphash]) === 0) return null; if (!node_cache[cfg.grouphash][cfg['.name']]) { uci.delete(uciconfig, cfg['.name']); removed++; log(sprintf('Removing node: %s.', cfg.label || cfg['name'])); } else { map(keys(cfg), (v) => { if (v in node_cache[cfg.grouphash][cfg['.name']]) uci.set(uciconfig, cfg['.name'], v, node_cache[cfg.grouphash][cfg['.name']][v]); else uci.delete(uciconfig, cfg['.name'], v); }); node_cache[cfg.grouphash][cfg['.name']].isExisting = true; } }); for (let nodes in node_result) map(nodes, (node) => { if (node.isExisting) return null; const nameHash = md5(node.grouphash + node.label); uci.set(uciconfig, nameHash, 'node'); map(keys(node), (v) => uci.set(uciconfig, nameHash, v, node[v])); added++; log(sprintf('Adding node: %s.', node.label)); }); uci.commit(uciconfig); let need_restart = (via_proxy !== '1'); if (!isEmpty(main_node)) { const first_server = uci.get_first(uciconfig, ucinode); if (first_server) { let main_urltest_nodes; if (main_node === 'urltest') { const old_urltest_nodes = uci.get(uciconfig, ucimain, 'main_urltest_nodes') || []; main_urltest_nodes = filter(old_urltest_nodes, (v) => { if (!uci.get(uciconfig, v)) { log(sprintf('Node %s is gone, removing from urltest list.', v)); return false; } return true; }); if (length(main_urltest_nodes) !== length(old_urltest_nodes)) { uci.set(uciconfig, ucimain, 'main_urltest_nodes', main_urltest_nodes); uci.commit(uciconfig); need_restart = true; } } if ((main_node === 'urltest') ? !length(main_urltest_nodes) : !uci.get(uciconfig, main_node)) { uci.set(uciconfig, ucimain, 'main_node', first_server); uci.commit(uciconfig); need_restart = true; log('Main node is gone, switching to the first node.'); } if (!isEmpty(main_udp_node) && main_udp_node !== 'same') { let main_udp_urltest_nodes; if (main_udp_node === 'urltest') { const old_udp_urltest_nodes = uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || []; main_udp_urltest_nodes = filter(old_udp_urltest_nodes, (v) => { if (!uci.get(uciconfig, v)) { log(sprintf('Node %s is gone, removing from urltest list.', v)); return false; } return true; }); if (length(main_udp_urltest_nodes) !== length(old_udp_urltest_nodes)) { uci.set(uciconfig, ucimain, 'main_udp_urltest_nodes', main_udp_urltest_nodes); uci.commit(uciconfig); need_restart = true; } } if ((main_udp_node === 'urltest') ? !length(main_udp_urltest_nodes) : !uci.get(uciconfig, main_udp_node)) { uci.set(uciconfig, ucimain, 'main_udp_node', first_server); uci.commit(uciconfig); need_restart = true; log('Main UDP node is gone, switching to the first node.'); } } } else { uci.set(uciconfig, ucimain, 'main_node', 'nil'); uci.set(uciconfig, ucimain, 'main_udp_node', 'nil'); uci.commit(uciconfig); need_restart = true; log('No available node, disable tproxy.'); } } /* Scrub stale urltest member references in custom routing nodes */ uci.foreach(uciconfig, 'routing_node', (cfg) => { if (cfg.node !== 'urltest' || isEmpty(cfg.urltest_nodes)) return null; const cleaned_nodes = filter(cfg.urltest_nodes, (v) => uci.get(uciconfig, v)); if (length(cleaned_nodes) !== length(cfg.urltest_nodes)) { uci.set(uciconfig, cfg['.name'], 'urltest_nodes', cleaned_nodes); uci.commit(uciconfig); need_restart = true; log(sprintf('Routing node %s: removed gone nodes from urltest list.', cfg['.name'])); } }); if (need_restart) { log('Restarting service...'); init_action('homeproxy', 'stop'); init_action('homeproxy', 'start'); } log(sprintf('%s nodes added, %s removed.', added, removed)); log('Successfully updated subscriptions.'); } if (!isEmpty(subscription_urls)) try { call(main); } catch(e) { log('[FATAL ERROR] An error occurred during updating subscriptions:'); log(sprintf('%s: %s', e.type, e.message)); log(e.stacktrace[0].context); log('Restarting service...'); init_action('homeproxy', 'stop'); init_action('homeproxy', 'start'); }