#!/usr/bin/ucode 'use strict'; import { access, popen, glob, readfile, writefile, stat, unlink, open } from 'fs'; import { cursor } from 'uci'; const BINARY_PATHS = [ '/usr/bin/mihomo-stable', '/usr/bin/mihomo', '/usr/bin/clash-meta', '/usr/bin/clash', '/etc/clashoo/clash' ]; const SMART_BINARY_PATHS = [ '/usr/bin/smart', '/etc/clashoo/clash', '/usr/bin/clash' ]; const SINGBOX_BINARY_PATHS = [ '/usr/bin/sing-box', '/usr/local/bin/sing-box' ]; const LOG_PATHS = [ '/usr/share/clashoo/clashoo.txt', '/var/log/clash.log', '/var/log/clash/clash.log', '/tmp/clash.log' ]; const CORE_LOG_PATH = '/var/log/clashoo/core.log'; const CONFIG_DIRS = [ { path: '/usr/share/clashoo/config/sub', type: '1' }, { path: '/usr/share/clashoo/config/upload', type: '2' }, { path: '/usr/share/clashoo/config/custom', type: '3' } ]; const LIST_FILE = '/usr/share/clashbackup/confit_list.conf'; const SUB_DIR = '/usr/share/clashoo/config/sub'; const TEMPLATE_DIR = '/usr/share/clashoo/config/custom'; const TEMPLATE_USER_DIR = '/etc/clashoo/templates'; const TEMPLATE_BIND_FILE = '/usr/share/clashbackup/template_bindings.conf'; const SINGBOX_DIR = '/usr/share/clashoo/config/singbox'; const ACCESS_CACHE_FILE = '/tmp/clashoo_check_cache'; const ACCESS_UPDATING_FLAG = '/tmp/clashoo/access_check_updating'; const ACCESS_DAEMON_PID_FILE = '/tmp/clashoo/access_check_daemon.pid'; const ACCESS_CACHE_TTL = 30; const ACCESS_UPDATING_TTL = 60; const OVERVIEW_STATS_CACHE_FILE = '/tmp/clashoo_overview_stats_cache.json'; const COMPONENT_UPDATE_SCRIPT = '/usr/share/clashoo/update/component_update.sh'; const COMPONENT_UPDATE_RUNNER = '/tmp/clashoo_component_update_runner.sh'; const COMPONENT_UPDATE_RUN_FILE = '/var/run/clashoo_component_update'; const COMPONENT_UPDATE_LOG_FILE = '/tmp/clashoo_component_update.log'; const COMPONENT_UPDATE_STATE_FILE = '/tmp/clashoo_component_update_state'; const SUBSCRIPTION_UPDATE_SCRIPT = '/usr/share/clashoo/update/subscription_update.sh'; const SUBSCRIPTION_UPDATE_STATUS_FILE = '/usr/share/clashbackup/subscription_update.status'; const SUBSCRIPTION_UPDATE_LOCK_DIR = '/tmp/clashoo_subscription_update.lock'; const BACKUP_TMP_DIR = '/tmp/clashoo-backup'; const BACKUP_IMPORT_LOCK_DIR = BACKUP_TMP_DIR + '/import.lock'; const BACKUP_IMPORT_OWNER_FILE = BACKUP_IMPORT_LOCK_DIR + '/path'; const BACKUP_JSON_ROLLBACK_FILE = BACKUP_TMP_DIR + '/json-rollback.tar.gz'; const BACKUP_ARCHIVE_SCRIPT = '/usr/share/clashoo/rpc/backup_archive.sh'; const BACKUP_MAX_ARCHIVE_SIZE = 32 * 1024 * 1024; const BACKUP_MAX_JSON_SIZE = 8 * 1024 * 1024; const BACKUP_MAX_CHUNK_SIZE = 40 * 1024; const BACKUP_MAX_CHUNK_BASE64_SIZE = 55000; const BACKUP_MAX_ENTRIES = 2048; function uci_get(pkg, sec, opt) { let c = cursor(); c.load(pkg); return c.get(pkg, sec, opt) || ''; } function shell_quote(s) { return "'" + replace(s || '', /'/g, "'\\''") + "'"; } function backup_tmp_ready() { return system('sh ' + shell_quote(BACKUP_ARCHIVE_SCRIPT) + ' prepare >/dev/null 2>&1') == 0; } function safe_backup_temp_path(path, kind) { if (kind != 'import' && kind != 'export') return ''; let prefix = BACKUP_TMP_DIR + '/' + kind + '.'; if (substr(path || '', 0, length(prefix)) != prefix) return ''; let suffix = substr(path, length(prefix)); if (length(suffix) != 6 || match(suffix, /[^A-Za-z0-9]/)) return ''; return path; } function safe_backup_upload_path(path) { return safe_backup_temp_path(path, 'import'); } function safe_backup_export_path(path) { return safe_backup_temp_path(path, 'export'); } function new_backup_temp_path(kind) { if (kind != 'import' && kind != 'export') return ''; let command = kind == 'import' ? 'new-upload' : 'new-export'; let pp = popen('sh ' + shell_quote(BACKUP_ARCHIVE_SCRIPT) + ' ' + command + ' 2>/dev/null'); if (!pp) return ''; let path = trim(pp.read('all')); pp.close(); return safe_backup_temp_path(path, kind); } function active_backup_upload(path) { return !!path && trim(readfile(BACKUP_IMPORT_OWNER_FILE) || '') == path; } function refresh_backup_upload(path) { return active_backup_upload(path) && writefile(BACKUP_IMPORT_OWNER_FILE, path + '\n') !== null; } function release_backup_upload(path) { if (!path) return; unlink(path); unlink(path + '.result'); if (active_backup_upload(path)) { unlink(BACKUP_IMPORT_OWNER_FILE); system('rmdir ' + shell_quote(BACKUP_IMPORT_LOCK_DIR) + ' >/dev/null 2>&1'); } } function subscription_update_status_data() { let out = { running: !!access(SUBSCRIPTION_UPDATE_LOCK_DIR, 'r') }; if (!access(SUBSCRIPTION_UPDATE_STATUS_FILE, 'r')) return out; let raw = readfile(SUBSCRIPTION_UPDATE_STATUS_FILE) || ''; for (let line in split(raw, '\n')) { let pos = index(line, '='); if (pos <= 0) continue; let key = substr(line, 0, pos); let value = substr(line, pos + 1); if (key == 'last_run' || key == 'finished_at' || key == 'updated' || key == 'unchanged' || key == 'failed' || key == 'skipped') out[key] = int(value) || 0; else if (key == 'message') out.message = value; } return out; } function dashboard_panel_dir(name) { if (name == 'metacubexd') return '/etc/clashoo/dashboard-metacubexd'; if (name == 'yacd') return '/etc/clashoo/dashboard-yacd'; if (name == 'zashboard') return '/etc/clashoo/dashboard-zashboard'; if (name == 'razord') return '/etc/clashoo/dashboard-razord'; return '/etc/clashoo/dashboard-zashboard'; } function activate_dashboard_panel(name) { let dir = dashboard_panel_dir(name); if (!access(dir + '/index.html', 'r')) return false; system('rm -rf /etc/clashoo/dashboard /www/luci-static/yacd >/dev/null 2>&1'); system('ln -s ' + shell_quote(dir) + ' /etc/clashoo/dashboard >/dev/null 2>&1 || cp -a ' + shell_quote(dir) + ' /etc/clashoo/dashboard >/dev/null 2>&1'); if (name == 'yacd') system('ln -s ' + shell_quote(dir) + ' /www/luci-static/yacd >/dev/null 2>&1'); return true; } function get_core_type() { let core_type = uci_get('clashoo', 'config', 'core_type'); if (core_type == 'singbox') return 'singbox'; return 'mihomo'; } function get_core_channel(family) { let dcore = uci_get('clashoo', 'config', 'dcore'); let legacy = uci_get('clashoo', 'config', 'core'); family = family || get_core_type(); if (family == 'singbox') return dcore == '5' ? 'alpha' : 'stable'; if (dcore == '1' || legacy == '1') return 'smart'; if (dcore == '3' || legacy == '3') return 'alpha'; return 'stable'; } function get_core_label(family, channel) { family = family || get_core_type(); channel = channel || get_core_channel(family); let name = family == 'singbox' ? 'sing-box' : 'mihomo'; return name + ' ' + (channel == 'smart' ? 'smart' : channel == 'alpha' ? 'alpha' : 'stable'); } function get_mihomo_service_name() { for (let name in ['clashoo', 'clash']) { if (access('/etc/init.d/' + name, 'x')) return name; } return 'clash'; } function get_service_name() { return get_core_type() === 'singbox' ? 'sing-box' : get_mihomo_service_name(); } // 只看 procd 自家实例 PID。pidof 会撞 openclash / nikki / passwall 的同名进程 // (mihomo / clash / sing-box / smart),用 ubus 走 procd 真相来源 —— 学 nikki。 function procd_instance_pid(service) { // outer "double" lets ucode unescape \" → ", shell then sees JSON wrapped in // '...' so the inner double-quotes survive. jsonfilter pattern also wrapped // in shell single-quotes. let p = popen("ubus call service list '{\"name\":\"" + service + "\",\"verbose\":true}' 2>/dev/null | jsonfilter -e '@[\"" + service + "\"].instances.*.pid' 2>/dev/null | head -n1"); if (!p) return 0; let s = trim(p.read('all')); p.close(); let pid = int(s) || 0; if (pid > 0 && !access('/proc/' + pid, 'r')) pid = 0; return pid; } function clashoo_running() { return procd_instance_pid('clashoo') > 0; } function singbox_running() { return procd_instance_pid('sing-box') > 0; } function running_core_family() { // clashoo 跑 singbox 时通过 /etc/init.d/sing-box 委托给 sing-box procd 实例; // 跑 mihomo / smart 时 clashoo 自身 procd 实例直接持有进程。 if (singbox_running()) return 'singbox'; if (clashoo_running()) return 'mihomo'; return ''; } function find_binary_for_family(family) { if (family === 'mihomo') { let pid = procd_instance_pid('clashoo'); if (pid > 0) { let p = popen('readlink -f /proc/' + pid + '/exe 2>/dev/null'); if (p) { let running = trim(p.read('all')); p.close(); if (running && access(running, 'x')) return running; } } let channel = get_core_channel('mihomo'); if (channel == 'stable' && access('/usr/bin/mihomo-stable', 'x')) return '/usr/bin/mihomo-stable'; if (channel == 'alpha' && access('/usr/bin/mihomo', 'x')) return '/usr/bin/mihomo'; if (channel == 'smart') { for (let path in SMART_BINARY_PATHS) if (access(path, 'x')) return path; } } let paths = family === 'singbox' ? SINGBOX_BINARY_PATHS : BINARY_PATHS; for (let path in paths) if (access(path, 'x')) return path; return ''; } function find_smart_binary() { for (let path in SMART_BINARY_PATHS) if (access(path, 'x')) return path; return ''; } function find_binary() { let family = running_core_family() || get_core_type(); let binary = find_binary_for_family(family); if (binary) return binary; if (family != get_core_type()) return find_binary_for_family(get_core_type()); return ''; } function read_first_line(path) { if (!access(path, 'r')) return ''; let content = readfile(path); return content ? trim(split(content, '\n')[0] || '') : ''; } function runtime_state_get(key) { if (!key || !access('/tmp/clashoo/runtime_state', 'r')) return ''; let content = readfile('/tmp/clashoo/runtime_state') || ''; for (let line in split(content, '\n')) { let pos = index(line, '='); if (pos <= 0) continue; if (substr(line, 0, pos) == key) return trim(substr(line, pos + 1)); } return ''; } function is_running() { return running_core_family() != ''; } function get_local_ip() { let p = popen("ip -4 addr show br-lan 2>/dev/null | awk '/inet /{sub(/\\/.*/,\"\",$2); print $2; exit}'"); if (!p) return ''; let ip = trim(p.read('all')); p.close(); if (ip) return ip; p = popen("dev=$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}'); [ -n \"$dev\" ] && ip -4 addr show dev \"$dev\" 2>/dev/null | awk '/inet /{sub(/\\/.*/,\"\",$2); print $2; exit}'"); if (!p) return ''; ip = trim(p.read('all')); p.close(); return ip; } function get_core_version(binary) { if (!binary) return ''; let cmd = match(binary, /sing-box(-[a-z]+)?$/) ? (shell_quote(binary) + ' version 2>&1 | head -1') : (shell_quote(binary) + ' -v 2>&1 | head -1'); let p = popen(cmd); if (!p) return ''; let v = trim(p.read('all')); p.close(); return v; } function sync_legacy_core_fields(c) { let family = c.get('clashoo', 'config', 'core_type') || 'mihomo'; let dcore = c.get('clashoo', 'config', 'dcore') || '2'; let legacy = '2'; if (family == 'singbox') legacy = dcore == '5' ? '5' : '4'; else legacy = dcore == '1' ? '1' : dcore == '3' ? '3' : '2'; c.set('clashoo', 'config', 'core', legacy); } function has_mihomo_stable() { if (access('/usr/bin/mihomo-stable', 'x')) return true; if (system("[ -x /usr/bin/clash-meta ] && [ ! -L /usr/bin/clash-meta ]") != 0) return false; let version = get_core_version('/usr/bin/clash-meta'); return !!version && !match(version, /(alpha|beta|rc|pre|smart)/i); } function core_target_error(core, dcore) { if (core == 'mihomo' && dcore == '1') { if (find_smart_binary()) return null; return { error: 'smart_core_missing', message: '未检测到 Smart 内核,请先在“组件更新”中安装 Smart' }; } if (core == 'mihomo' && dcore == '2') { if (has_mihomo_stable()) return null; return { error: 'mihomo_stable_missing', message: '未检测到 mihomo 稳定版,请先在“组件更新”中更新稳定版' }; } if (core == 'mihomo' && dcore == '3') { if (access('/usr/bin/mihomo', 'x')) return null; return { error: 'mihomo_alpha_missing', message: '未检测到 mihomo Alpha,请先在“组件更新”中更新 Alpha' }; } if (core == 'singbox' && dcore == '4') { if (access('/usr/bin/sing-box-stable', 'x')) return null; return { error: 'singbox_stable_missing', message: '未检测到 sing-box 稳定版,请先在“组件更新”中更新稳定版' }; } if (core == 'singbox' && dcore == '5') { if (access('/usr/bin/sing-box-alpha', 'x')) return null; return { error: 'singbox_alpha_missing', message: '未检测到 sing-box Alpha,请先在“组件更新”中更新 Alpha' }; } return { error: 'invalid_core_target', message: '无效的内核类型与版本组合' }; } function prepare_singbox_runtime() { let active = uci_get('clashoo', 'config', 'singbox_active') || ''; let src = active ? ('/usr/share/clashoo/config/singbox/' + active) : '/etc/sing-box/config.json'; if (active && access(src, 'r')) { system('mkdir -p /etc/sing-box >/dev/null 2>&1'); if (system('cp -f ' + shell_quote(src) + ' /etc/sing-box/config.json >/dev/null 2>&1') != 0) return { success: false, message: '无法同步 sing-box 配置文件' }; } else if (!access('/etc/sing-box/config.json', 'r')) { return { success: false, message: '请先生成或选择 sing-box 配置文件' }; } if (system('command -v ucode >/dev/null 2>&1') == 0) { let redir_port = uci_get('clashoo', 'config', 'redir_port') || '7891'; let tproxy_port = uci_get('clashoo', 'config', 'tproxy_port') || '7982'; let mixed_port = uci_get('clashoo', 'config', 'mixed_port') || '7890'; let dns_port = uci_get('clashoo', 'config', 'listen_port') || '1053'; let dash_port = uci_get('clashoo', 'config', 'dash_port') || '9090'; let dash_pass = uci_get('clashoo', 'config', 'dash_pass') || ''; let has_tun = system('(ip tuntap add mode tun name cotuntest >/dev/null 2>&1 && ip link del cotuntest >/dev/null 2>&1)') == 0 ? '1' : '0'; let normalize_cmd = 'ucode /usr/share/clashoo/lib/normalize_singbox_config.uc ' + shell_quote('/etc/sing-box/config.json') + ' ' + shell_quote(redir_port) + ' ' + shell_quote(tproxy_port) + ' ' + shell_quote(mixed_port) + ' ' + shell_quote(has_tun) + ' ' + shell_quote('6666') + ' ' + shell_quote(dns_port) + ' ' + shell_quote(dash_port) + ' ' + shell_quote(dash_pass) + ' >/dev/null 2>&1'; if (system(normalize_cmd) != 0) return { success: false, message: 'sing-box 运行配置规范化失败' }; } let rc = system("uci -q set sing-box.main.enabled='1'; uci -q set sing-box.main.user='root'; uci -q set sing-box.main.conffile='/etc/sing-box/config.json'; uci -q set sing-box.main.workdir='/usr/share/sing-box'; uci commit sing-box >/dev/null 2>&1"); return { success: rc == 0 }; } function detect_firewall_backend() { if (system('command -v fw4 >/dev/null 2>&1') == 0) return 'fw4'; if (system('command -v firewall >/dev/null 2>&1') == 0) return 'fw3'; return ''; } function detect_missing_fw4_tools() { let missing = []; if (system('command -v fw4 >/dev/null 2>&1') != 0) push(missing, 'fw4'); if (system('command -v nft >/dev/null 2>&1') != 0) push(missing, 'nft'); if (system('command -v ip >/dev/null 2>&1') != 0) push(missing, 'ip'); return missing; } function get_profiles() { let profiles = []; let dirs = ['/etc/clashoo/profiles']; function has_profile_name(arr, name) { for (let i = 0; i < length(arr); i++) if (arr[i] == name) return true; return false; } for (let dir in dirs) { if (!access(dir, 'r')) continue; for (let f in (glob(dir + '/*.yaml') || [])) { let name = replace(f, dir + '/', ''); if (name && name != 'config.yaml' && !has_profile_name(profiles, name)) push(profiles, name); } if (length(profiles) > 0) break; } return profiles; } function format_log_dates(text) { return replace(text || '', /\d{4}-(\d{2}-\d{2}) /g, '$1 '); } function format_update_log(text) { let out = []; for (let line in split(format_log_dates(text || ''), '\n')) { line = trim(line || ''); if (!line) continue; if (index(line, '启动任务已触发') >= 0 || index(line, '停止任务已触发') >= 0 || index(line, '重启任务已触发') >= 0) continue; push(out, line); } return join('\n', out); } function reformat_core_line(line) { if (match(line, /^(===|---)/)) return line; // Extract local time+date "May 26 23:16:18 2026" → "05-26 23:16:18" let local_t = ''; let syslog_tm = match(line, /^\w{3} (\d{1,2}) (\d{2}:\d{2}:\d{2}) \d{4}/); if (syslog_tm) { let months = { Jan:'01',Feb:'02',Mar:'03',Apr:'04',May:'05',Jun:'06',Jul:'07',Aug:'08',Sep:'09',Oct:'10',Nov:'11',Dec:'12' }; let mon_match = match(line, /^\w{3}/); let mon = mon_match ? months[mon_match[0]] || mon_match[0] : '??'; local_t = mon + '-' + syslog_tm[1].padStart(2,'0') + ' ' + syslog_tm[2]; } // Strip syslog prefix "...process[pid]: " let msg = replace(line, /^.*?\[\d+\]:\s*/, ''); if (msg === line) msg = line; // mihomo: time="YYYY-MM-DDTHH:MM:SS..." level=LEVEL msg="TEXT" let mm = match(msg, /^time="\d{4}-(\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})[^"]*"\s+level=(\w+)\s+msg="(.*)"\s*$/); if (mm) { let t = local_t ? local_t : mm[1] + ' '; let h = +substr(mm[2], 0, 2); t += sprintf('%02d', (h + 8) % 24) + substr(mm[2], 2); if (mm[3] === 'info') return t + ' ' + mm[4]; return t + ' [' + mm[3] + '] ' + mm[4]; } // sing-box ISO "T" format: "YYYY-MM-DDTHH:MM:SS... LEVEL message" let sm = match(msg, /^\d{4}-(\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})[^ ]* +(\w+) +(.*)/); if (sm) { let t = local_t ? local_t : sm[1] + ' '; let h = +substr(sm[2], 0, 2); t += sprintf('%02d', (h + 8) % 24) + substr(sm[2], 2); let lvl = lc(sm[3]); if (lvl === 'info') return t + ' ' + sm[4]; return t + ' [' + lvl + '] ' + sm[4]; } // sing-box space format: "[+0000] YYYY-MM-DD HH:MM:SS LEVEL message" let ss = match(msg, /^[+-]\d{4} \d{4}-\d{2}-\d{2} (\d{2}:\d{2}:\d{2}) +(\w+) +(.*)/); if (ss) { let t = local_t; if (!t) { let h = +substr(ss[1], 0, 2); t = sprintf('%02d', (h + 8) % 24) + substr(ss[1], 2); } let lvl = lc(ss[2]); if (lvl === 'info') return t + ' ' + ss[3]; return t + ' [' + lvl + '] ' + ss[3]; } // fallback: strip ISO timestamp, keep syslog time msg = replace(msg, /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^ ]*\s*/, ''); return (local_t ? local_t + ' ' : '') + trim(msg); } function get_log_content() { // 统一用 awk 脚本简化 mihomo 原生日志 + log_msg 行格式 let formatter = "awk -f /usr/share/clashoo/net/log_format.awk 2>/dev/null"; for (let path in LOG_PATHS) { if (!access(path, 'r')) continue; let p = popen("tail -300 '" + path + "' | " + formatter); if (p) { let c = p.read('all'); p.close(); return c; } } if (get_core_type() == 'singbox') { let p0 = popen("logread -e 'sing-box' 2>/dev/null | tail -300"); if (p0) { let c0 = trim(p0.read('all')); p0.close(); if (c0) return c0; } } return ''; } function format_core_log(content) { content = content || ''; let out = ['=== 核心日志 ===']; // No server-side noise filtering: the UI level dropdown (全部/INFO/WARN/ERROR/FATAL) // is the single filter, so "全部" really means all. Keep a generous cap so errors // aren't pushed out by connection chatter. let max_lines = 400; for (let line in split(content, '\n')) { line = replace(line || '', /\x1b\[[0-9;]*m/g, ''); line = trim(line || ''); if (!line) continue; push(out, reformat_core_line(line)); if (length(out) > max_lines + 1) shift(out); } if (length(out) == 1) push(out, '暂无核心日志'); return join('\n', out); } function list_config_files() { let configs = []; let subs = read_subscriptions(); function has_name(arr, n) { for (let i = 0; i < length(arr); i++) if (arr[i] == n) return true; return false; } function sub_has_name(n) { for (let i = 0; i < length(subs); i++) { if (subs[i].name == n) return true; } return false; } for (let dir in CONFIG_DIRS) { for (let f in (glob(dir.path + '/*.yaml') || [])) { let name = replace(f, dir.path + '/', ''); if (dir.type == '1') { if (!sub_has_name(name)) continue; } if (!has_name(configs, name)) push(configs, name); } for (let f in (glob(dir.path + '/*.yml') || [])) { let name = replace(f, dir.path + '/', ''); if (dir.type == '1') { if (!sub_has_name(name)) continue; } if (!has_name(configs, name)) push(configs, name); } } let cur = get_current_config(); if (cur && !has_name(configs, cur)) push(configs, cur); return configs; } function first_available_config(exclude_path) { for (let dir in CONFIG_DIRS) { for (let f in (glob(dir.path + '/*.yaml') || [])) { if (f != exclude_path) return { path: f, type: dir.type }; } for (let f in (glob(dir.path + '/*.yml') || [])) { if (f != exclude_path) return { path: f, type: dir.type }; } } return null; } function get_current_config(core_family) { core_family = core_family || get_core_type(); if (core_family == 'singbox') { let active = uci_get('clashoo', 'config', 'singbox_active') || ''; let active_name = replace(active, /^.*\//, ''); /* 文件已被删除时不返回 stale 名 */ if (active_name && access(SINGBOX_DIR + '/' + active_name, 'r')) return active_name; if (access('/etc/sing-box/config.json', 'r')) return 'config.json'; return ''; } let use_config = uci_get('clashoo', 'config', 'use_config'); let name = replace(use_config, /^.*\//, ''); if (name && use_config && !access(use_config, 'r')) return ''; return name; } function get_display_config_name(core_family) { core_family = core_family || get_core_type(); if (core_family == 'singbox') { let active = uci_get('clashoo', 'config', 'singbox_active') || ''; let active_name = replace(active, /^.*\//, ''); if (active_name) { if (match(active_name, /\.singbox\.json$/)) return replace(active_name, /\.singbox\.json$/, '.json'); return active_name; } if (access('/etc/sing-box/config.json', 'r')) return 'config.json'; return 'config.json'; } return get_current_config('mihomo'); } function now_epoch_sec() { let p = popen('date +%s 2>/dev/null'); if (!p) return 0; let raw = trim(p.read('all')); p.close(); return raw ? int(raw) : 0; } function default_probe_result() { return { ok: false, state: 'pending', code: '000', ok_count: 0, attempts: 0, loss: 0, avg_ms: 0 }; } function default_access_payload(proxy_port, tcp_mode, udp_mode) { return { proxy_port: proxy_port || '7890', tcp_mode: tcp_mode || 'redirect', udp_mode: udp_mode || (tcp_mode || 'redirect'), updated_at: 0, stale: true, updating: true, last_checked_ago: -1, direct: { bytedance: default_probe_result(), youtube: default_probe_result() }, proxy: { bytedance: default_probe_result(), youtube: default_probe_result() } }; } function read_access_check_cache() { if (!access(ACCESS_CACHE_FILE, 'r')) return null; let raw = trim(readfile(ACCESS_CACHE_FILE) || ''); if (!raw) return null; let parsed = json(raw); return parsed ? parsed : null; } function access_cache_is_stale(cache, now) { if (!cache || !cache.updated_at) return true; let updated = int(cache.updated_at); return updated <= 0 || (updated + ACCESS_CACHE_TTL) < now; } function trigger_access_cache_refresh() { system('mkdir -p /tmp/clashoo 2>/dev/null; : > ' + ACCESS_UPDATING_FLAG + ' 2>/dev/null; nice -n 19 sh /usr/share/clashoo/net/access_check_cache.sh >/dev/null 2>&1 &'); } function access_check_is_updating(now) { if (!access(ACCESS_UPDATING_FLAG, 'r')) return false; let st = stat(ACCESS_UPDATING_FLAG); let mtime = st ? int(st.mtime || 0) : 0; if (mtime > 0 && now > 0 && (mtime + ACCESS_UPDATING_TTL) < now) { unlink(ACCESS_UPDATING_FLAG); return false; } return true; } function access_check_daemon_running() { if (!access(ACCESS_DAEMON_PID_FILE, 'r')) return false; let pid = trim(readfile(ACCESS_DAEMON_PID_FILE) || ''); if (!pid) return false; if (!access('/proc/' + pid, 'r')) return false; let cmd = readfile('/proc/' + pid + '/cmdline') || ''; return index(cmd, 'access_check_daemon.sh') >= 0; } function ensure_access_check_daemon() { if (access_check_daemon_running()) return true; system('nohup nice -n 19 sh /usr/share/clashoo/net/access_check_daemon.sh >/dev/null 2>&1 0 ? (now_epoch_sec() - payload.updated_at) : -1; return payload; } function get_cpu_arch() { let p = popen("opkg status libc 2>/dev/null | grep Architecture | awk -F': ' '{print $2}'"); if (!p) return ''; let arch = trim(p.read('all')); p.close(); if (arch) return arch; p = popen("apk --print-arch 2>/dev/null"); if (!p) return ''; arch = trim(p.read('all')); p.close(); return arch; } function shell_read(cmd) { let p = popen(cmd); if (!p) return ''; let out = trim(p.read('all') || ''); p.close(); return out; } function pkg_version(pkg) { let q = shell_quote(pkg); return shell_read("if command -v opkg >/dev/null 2>&1; then opkg status " + q + " 2>/dev/null | awk -F': ' '/^Version:/{print $2; exit}'; elif command -v apk >/dev/null 2>&1; then apk list -I " + q + " 2>/dev/null | awk '{print $1; exit}' | sed -n 's/^" + pkg + "-//p'; fi"); } // lgbm has no version; use a short sha256 of the model as its identifier (like Smart's hash) function lgbm_local_ver() { if (!stat('/etc/clashoo/Model.bin')) return ''; return trim(shell_read("sha256sum /etc/clashoo/Model.bin 2>/dev/null | cut -c1-12") || ''); } function file_mtime_label(path) { let s = stat(path); if (!s) return ''; let ts = shell_read('date -d @' + s.mtime + ' "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -r ' + shell_quote(path) + ' "+%Y-%m-%d %H:%M:%S" 2>/dev/null'); return ts || ('' + s.mtime); } function read_component_state() { let state = {}; if (!access(COMPONENT_UPDATE_STATE_FILE, 'r')) return state; let content = readfile(COMPONENT_UPDATE_STATE_FILE) || ''; for (let line in split(content, '\n')) { let pos = index(line, '='); if (pos <= 0) continue; state[substr(line, 0, pos)] = trim(substr(line, pos + 1)); } return state; } function component_log_tail(lines) { lines = lines || 80; return shell_read('tail -' + int(lines) + ' ' + shell_quote(COMPONENT_UPDATE_LOG_FILE) + ' 2>/dev/null'); } function component_last_log() { return shell_read('tail -1 ' + shell_quote(COMPONENT_UPDATE_LOG_FILE) + ' 2>/dev/null'); } // 从 `-v` / `version` 完整输出里抽短版本号: // mihomo/smart "Mihomo Meta linux ..." → ;sing-box "sing-box version " → function core_short_version(binary) { let full = get_core_version(binary); if (!full) return ''; let m = match(full, /Meta[ \t]+([^ \t]+)/); if (m) return m[1]; m = match(full, /version[ \t]+([^ \t]+)/); if (m) return m[1]; return full; } function component_installed_label(id) { if (id == 'clashoo') return pkg_version('clashoo') || '未安装'; if (id == 'luci') return pkg_version('luci-app-clashoo') || '未安装'; if (id == 'mihomo') { let bin = find_binary_for_family('mihomo'); return bin ? (core_short_version(bin) || '已安装') : '未安装'; } if (id == 'smart') { let bin = find_smart_binary(); return bin ? (core_short_version(bin) || '已安装') : '未安装'; } if (id == 'singbox') { let bin = find_binary_for_family('singbox'); return bin ? (core_short_version(bin) || '已安装') : '未安装'; } if (id == 'lgbm') return lgbm_local_ver() || '未安装'; if (id == 'china') return file_mtime_label('/usr/share/clashoo/nftables/geoip_cn.nft') || '未更新'; if (id == 'geoip') return file_mtime_label('/etc/clashoo/Country.mmdb') || '未安装'; return ''; } function mihomo_alpha_label(ver) { ver = ver || ''; if (!ver) return ''; if (match(ver, /^alpha-/)) return ver; if (match(ver, /^[0-9a-fA-F]{7,}$/)) return 'alpha-' + ver; return ver; } function is_prerelease_label(ver) { return !!match(ver || '', /(alpha|beta|rc|pre|smart)/i); } function component_installed_versions(id) { let out = {}; if (id == 'mihomo') { let stable = access('/usr/bin/mihomo-stable', 'x') ? read_first_line('/usr/share/clashoo/clash_meta_version') : ''; if (!stable && access('/usr/bin/mihomo-stable', 'x')) stable = core_short_version('/usr/bin/mihomo-stable'); if (is_prerelease_label(stable)) stable = ''; let alpha = read_first_line('/usr/share/clashoo/mihomo_version'); if (match(alpha, /^smart-/)) alpha = ''; if (!alpha && access('/usr/bin/mihomo', 'x')) alpha = core_short_version('/usr/bin/mihomo'); out.stable = stable || ''; out.alpha = mihomo_alpha_label(alpha); return out; } if (id == 'singbox') { let stable = read_first_line('/usr/share/clashoo/singbox_stable_version'); if (!stable && access('/usr/bin/sing-box-stable', 'x')) stable = core_short_version('/usr/bin/sing-box-stable'); if (is_prerelease_label(stable)) stable = ''; let alpha = read_first_line('/usr/share/clashoo/singbox_alpha_version'); if (!alpha && access('/usr/bin/sing-box-alpha', 'x')) alpha = core_short_version('/usr/bin/sing-box-alpha'); out.stable = stable || ''; out.alpha = alpha || ''; return out; } return out; } const FEED_BASE = 'https://down.dllkids.xyz/openwrt-feed/clashoo'; // bucket 多项目共用,按项目区分文件名免互相 sync 覆盖 const FEED_MANIFEST_NAME = 'manifest-clashoo.txt'; const GH_CLASHOO_REPO = 'kenzok8/openwrt-clashoo'; // 上游 amd64 默认是 AMD64-v3(要 AVX2),LXC/KVM 跑不起来;老值统一兜底 compatible function normalize_mihomo_arch(a) { if (!a) a = get_cpu_arch(); if (!a) return 'amd64-compatible'; if (a == 'amd64-compatible' || a == 'amd64-v1' || a == 'amd64-v2' || a == 'amd64-v3') return a; if (a == 'amd64' || a == 'x86_64') return 'amd64-compatible'; if (match(a, /^aarch64/)) return 'arm64'; if (match(a, /^armv7|^arm_cortex-a[7-9]|^arm_cortex-a1[0-9]/)) return 'armv7'; if (match(a, /^armv6|^arm_cortex-a[56]/)) return 'armv6'; if (match(a, /^arm/) && a != 'arm64' && a != 'armv7' && a != 'armv6' && a != 'armv5') return 'armv5'; if (match(a, /^i[3-6]86/)) return '386'; if (match(a, /^mips64el/)) return 'mips64le'; if (match(a, /^mips64/)) return 'mips64'; if (match(a, /^mipsel/)) return 'mipsle'; if (match(a, /^mips/) && a != 'mips64' && a != 'mipsle' && a != 'mips64le') return 'mips'; return a; } // alpha / smart 滚动版按 hash 比对;needle 用 -alpha- 边界,免 amd64 撞 amd64-compatible function alpha_asset_ver(html, arch, kind) { if (!html) return ''; let needle = (kind == 'smart') ? 'mihomo-linux-' + arch + '-alpha-smart-' : 'mihomo-linux-' + arch + '-alpha-'; for (let line in split(html, '\n')) { if (index(line, needle) < 0) continue; if (kind != 'smart' && index(line, '-alpha-smart-') >= 0) continue; let m = (kind == 'smart') ? match(line, /-(alpha-smart-[0-9A-Za-z]{6,})\.gz/) : match(line, /-(alpha-[0-9a-f]{7,})\.gz/); if (m) return m[1]; } return ''; } // 从 release expanded_assets HTML 抽 clashoo / luci 版本号 function gh_assets_versions(html) { let out = {}; if (!html) return out; for (let line in split(html, '\n')) { let m; if (!out.clashoo) { m = match(line, /(^|[\/" >])(clashoo_[^"<>\/ ]+_[^"<>\/ ]+\.ipk)/) || match(line, /(^|[\/" >])(clashoo-[^"<>\/ ]+\.apk)/); if (m) { let f = m[2]; let v = match(f, /^clashoo_([^_]+)_.+\.ipk$/) || match(f, /^clashoo-(.+-r[0-9]+)-.+\.apk$/); if (v) out.clashoo = v[1]; } } if (!out.luci) { m = match(line, /(^|[\/" >])(luci-app-clashoo_[^"<>\/ ]+_all\.ipk)/) || match(line, /(^|[\/" >])(luci-app-clashoo-[^"<>\/ ]+\.apk)/); if (m) { let f = m[2]; let v = match(f, /^luci-app-clashoo_(.+)_all\.ipk$/) || match(f, /^luci-app-clashoo-(.+-r[0-9]+)-.+\.apk$/); if (v) out.luci = v[1]; } } if (out.clashoo && out.luci) break; } return out; } // 并行抓取全部上游版本 —— 6+ 个 curl 串行会超 ubus 30s 上限,故全部 // 后台并发,wait 后统一解析(总耗时 ≈ 最慢的单个请求)。 // DISTRIB_RELEASE 可能是自定义串,不能当 SDK 版本;按包管理器试多个候选。 function component_latest_versions() { let latest = {}; let core_arch = normalize_mihomo_arch(uci_get('clashoo', 'config', 'download_core') || ''); let pkg_arch = trim(shell_read("opkg print-architecture 2>/dev/null | awk '/^arch /{print $2}' | tail -1") || ''); if (!pkg_arch) pkg_arch = trim(shell_read('apk --print-arch 2>/dev/null') || ''); let is_apk = !!trim(shell_read('command -v apk 2>/dev/null') || ''); let sdks = is_apk ? ['25.12', '24.10'] : ['24.10', '23.05', '22.03', '21.02']; let dir = trim(shell_read('mktemp -d 2>/dev/null') || ''); if (!dir) return latest; let GH = 'https://github.com/'; // geoip 远端版本跟随当前 geoip_source:从生效的 mmdb 源 URL 解析出 GitHub 仓库; // source=1 是 MaxMind(无 GitHub release),跳过 let geoip_src = uci_get('clashoo', 'config', 'geoip_source') || ''; let geoip_repo = ''; if (geoip_src != '1') { let mmdb_url = uci_get('clashoo', 'config', 'geoip_mmdb_url') || 'https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb'; let gm = match(mmdb_url, /github[a-z]*\.com\/([^\/]+)\/([^\/]+)/); if (gm) geoip_repo = gm[1] + '/' + gm[2]; } // In kernel-only mode there is no TPROXY, so these github/api/B2 probes would // leak out direct and stall at their -m timeout behind the GFW (the "检查更新 // 半天不出反馈"). Route them through the running core, same as the download // scripts (shared logic in proxy_lib.sh). Normal mode -> empty -> TPROXY. let cdp = trim(shell_read('. /usr/share/clashoo/update/proxy_lib.sh; clashoo_detect_proxy 2>/dev/null') || ''); let px = cdp ? ('--proxy ' + shell_quote(cdp) + ' ') : ''; let cmd = ''; cmd += "curl -fsS --connect-timeout 4 -o /dev/null -w '%{redirect_url}' -m 8 " + shell_quote(GH + 'MetaCubeX/mihomo/releases/latest') + ' >' + shell_quote(dir + '/ms') + ' 2>/dev/null &\n'; cmd += "curl -fsS --connect-timeout 4 -o /dev/null -w '%{redirect_url}' -m 8 " + shell_quote(GH + 'SagerNet/sing-box/releases/latest') + ' >' + shell_quote(dir + '/ss') + ' 2>/dev/null &\n'; cmd += 'curl -fsSL --connect-timeout 4 -m 8 ' + shell_quote(GH + 'MetaCubeX/mihomo/releases/expanded_assets/Prerelease-Alpha') + ' >' + shell_quote(dir + '/ma') + ' 2>/dev/null &\n'; cmd += 'curl -fsSL --connect-timeout 4 -m 8 ' + shell_quote(GH + 'vernesong/mihomo/releases/expanded_assets/Prerelease-Alpha') + ' >' + shell_quote(dir + '/sm') + ' 2>/dev/null &\n'; cmd += 'curl -fsSL --connect-timeout 4 -m 8 ' + shell_quote(GH + 'SagerNet/sing-box/releases.atom') + ' >' + shell_quote(dir + '/sa') + ' 2>/dev/null &\n'; if (pkg_arch) for (let sdk in sdks) cmd += 'curl -fsSL --connect-timeout 4 -m 8 ' + shell_quote(FEED_BASE + '/' + sdk + '/' + pkg_arch + '/' + FEED_MANIFEST_NAME) + ' >' + shell_quote(dir + '/b_' + sdk) + ' 2>/dev/null &\n'; // GitHub API 兜底,避免 expanded_assets 在部分网络下超时 cmd += 'curl -fsSL --connect-timeout 4 -m 8 ' + shell_quote('https://api.github.com/repos/' + GH_CLASHOO_REPO + '/releases/latest') + ' >' + shell_quote(dir + '/gc') + ' 2>/dev/null &\n'; // lgbm —— remote model sha256 from the release assets page (each asset lists its digest) cmd += 'curl -fsSL --connect-timeout 4 -m 8 ' + shell_quote(GH + 'vernesong/mihomo/releases/expanded_assets/LightGBM-Model') + ' >' + shell_quote(dir + '/lg') + ' 2>/dev/null &\n'; // geoip —— 源仓库 releases/latest 的 302 tag(多为日期串) if (geoip_repo) cmd += "curl -fsS --connect-timeout 4 -o /dev/null -w '%{redirect_url}' -m 8 " + shell_quote(GH + geoip_repo + '/releases/latest') + ' >' + shell_quote(dir + '/gi') + ' 2>/dev/null &\n'; // inject the local-core proxy into every probe above (no-op when px is empty) if (px) cmd = replace(cmd, 'curl ', 'curl ' + px); cmd += 'wait\n'; let pp = popen(cmd); if (pp) { pp.read('all'); pp.close(); } let m; // 稳定版 tag —— releases/latest 302 重定向 m = match(readfile(dir + '/ms') || '', /\/tag\/([^\/?#\s]+)/); if (m) latest.mihomo_stable = m[1]; m = match(readfile(dir + '/ss') || '', /\/tag\/([^\/?#\s]+)/); if (m) latest.singbox_stable = m[1]; // sing-box alpha —— atom feed 第一个 prerelease tag(带 -alpha/-beta/-rc); // SagerNet 稳定与 alpha 并行发布,atom 按时间排序,最新 entry 可能是稳定版(如 v1.13.13), // 直接取第一个 entry 会让 alpha 通道误显示稳定版本号 m = match(readfile(dir + '/sa') || '', /\/releases\/tag\/(v[^"?#\s]*-[^"?#\s]+)/); if (m) latest.singbox_alpha = m[1]; // lgbm —— locate the configured asset in the assets page, then its sha256 digest let lg_html = readfile(dir + '/lg') || ''; let lg_url = uci_get('clashoo', 'config', 'smart_lgbm_url') || 'Model.bin'; let lg_parts = split(lg_url, '/'); let lg_asset = lg_parts[length(lg_parts) - 1] || 'Model.bin'; let lg_pos = index(lg_html, '/' + lg_asset + '"'); if (lg_pos >= 0) { let lg_sha = match(substr(lg_html, lg_pos), /sha256:([0-9a-f]{12})/); if (lg_sha) latest.lgbm = lg_sha[1]; } // geoip —— 源仓库最新 release tag(多为日期串 YYYYMMDDhhmm)→ YYYY-MM-DD let gt = match(readfile(dir + '/gi') || '', /\/tag\/([^\/?#\s]+)/); if (gt) { let d8 = match(gt[1], /^(\d{4})(\d{2})(\d{2})/); latest.geoip = d8 ? (d8[1] + '-' + d8[2] + '-' + d8[3]) : gt[1]; } // mihomo alpha / smart —— Prerelease-Alpha 资源清单的 hash let v; v = alpha_asset_ver(readfile(dir + '/ma') || '', core_arch, 'alpha'); if (v) latest.mihomo_alpha = v; v = alpha_asset_ver(readfile(dir + '/sm') || '', core_arch, 'smart'); if (v) latest.smart = v; // clashoo / luci —— R2 feed manifest,按 SDK 候选取第一个命中 if (pkg_arch) for (let sdk in sdks) { let txt = readfile(dir + '/b_' + sdk); if (!txt) continue; let hit = false; for (let line in split(txt, '\n')) { let kv = match(trim(line), /^(core|luci)=(.+)$/); if (!kv) continue; let f = kv[2], mv; if (kv[1] == 'core') { mv = match(f, /^clashoo_([^_]+)_.+\.ipk$/) || match(f, /^clashoo-(.+-r[0-9]+)-.+\.apk$/); if (mv) { latest.clashoo = mv[1]; hit = true; } } else { mv = match(f, /^luci-app-clashoo_([^_]+)_.+\.ipk$/) || match(f, /^luci-app-clashoo-(.+-r[0-9]+)-.+\.apk$/); if (mv) latest.luci = mv[1]; } } if (hit) break; } // R2 feed 缺啥用 GitHub 兜底 if (!latest.clashoo || !latest.luci) { let gh = gh_assets_versions(readfile(dir + '/gc') || ''); if (!latest.clashoo && gh.clashoo) latest.clashoo = gh.clashoo; if (!latest.luci && gh.luci) latest.luci = gh.luci; } let rp = popen('rm -rf ' + shell_quote(dir)); if (rp) rp.close(); return latest; } function component_defs() { return [ { id: 'clashoo', name: 'Clashoo 核心', description: 'mihomo 运行时与脚本', kind: 'pkg' }, { id: 'luci', name: '客户端', description: 'LuCI 界面与中文语言包', kind: 'pkg' }, { id: 'mihomo', name: 'mihomo 内核', description: 'Clash Meta 核心', kind: 'core', variant: true }, { id: 'singbox', name: 'sing-box 内核', description: 'sing-box 核心', kind: 'core', variant: true }, { id: 'smart', name: 'mihomo Smart', description: 'vernesong Smart 核心', kind: 'core' }, { id: 'lgbm', name: 'LightGBM 模型', description: 'Smart 策略模型文件', kind: 'data' }, { id: 'china', name: '大陆白名单', description: '大陆 IP 直连白名单', kind: 'data' }, { id: 'geoip', name: 'GeoIP / GeoSite', description: 'GeoIP / GeoSite 数据', kind: 'data' } ]; } function valid_component_id(id) { for (let comp in component_defs()) if (comp.id == id) return true; return false; } function config_dir_by_type(type) { if (type == '1' || type == 'sub') return '/usr/share/clashoo/config/sub'; if (type == '2' || type == 'upload') return '/usr/share/clashoo/config/upload'; if (type == '3' || type == 'custom') return '/usr/share/clashoo/config/custom'; return ''; } function safe_name(name) { name = name || ''; if (!name) return ''; /* 拒绝前后导空白(不静默 trim,避免与可见名称偏差) */ if (trim(name) != name) return ''; /* 路径遍历防护 */ if (index(name, '/') >= 0 || index(name, '\\') >= 0) return ''; if (index(name, '..') >= 0) return ''; if (substr(name, 0, 1) == '.') return ''; /* 黑名单:# 注释、空格、? * : 等不安全/系统保留字符 */ if (index(name, '#') >= 0) return ''; if (index(name, ' ') >= 0) return ''; if (index(name, '?') >= 0) return ''; if (index(name, '*') >= 0) return ''; if (index(name, ':') >= 0) return ''; /* 拒绝 NUL 与换行/制表等控制字符(逐字节扫描,ucode 正则不支持 \x 转义) */ let n = length(name); for (let i = 0; i < n; i++) { let ch = ord(name, i); if (ch < 0x20) return ''; } /* 允许 Unicode(CJK 等)—— 不再用纯 ASCII 白名单 */ return name; } function ensure_yaml_name(name) { name = safe_name(name); if (!name) return ''; if (!match(name, /\.(yaml|yml)$/)) name = name + '.yaml'; return name; } function file_name_from_url(url) { url = trim(url || ''); if (!url) return ''; url = replace(url, /[?#].*$/, ''); let name = replace(url, /^.*\//, ''); return ensure_yaml_name(name); } function template_output_name(sub_name, tpl_name) { let s = replace((sub_name || ''), /\.(yaml|yml)$/, ''); let t = replace((tpl_name || ''), /\.(yaml|yml)$/, ''); s = replace(s, /[^A-Za-z0-9._-]/g, '-'); t = replace(t, /[^A-Za-z0-9._-]/g, '-'); if (!s) s = 'sub'; if (!t) t = 'template'; return '_merged_' + s + '__' + t + '.yaml'; } function template_search_dirs() { return [TEMPLATE_USER_DIR, TEMPLATE_DIR, '/usr/share/clashoo/config/template']; } function find_template_path(name) { name = ensure_yaml_name(name || ''); if (!name) return ''; for (let dir in template_search_dirs()) { let p = dir + '/' + name; if (access(p, 'r')) return p; } return ''; } function trim_quotes(v) { v = trim(v || ''); v = replace(v, /^["']/, ''); v = replace(v, /["']$/, ''); return v; } function parse_controller_port(value, fallback) { value = trim(value || ''); if (!value) return fallback || '9090'; value = replace(value, /^https?:\/\//, ''); value = replace(value, /\/.*$/, ''); let m = match(value, /:([0-9]+)$/); if (m && m[1]) return m[1]; if (match(value, /^[0-9]+$/)) return value; return fallback || '9090'; } function parse_mihomo_api_auth(path) { let info = { has_port: false, port: '', has_secret: false, secret: '' }; if (!access(path, 'r')) return info; let raw = readfile(path) || ''; if (!raw) return info; for (let line in split(raw, '\n')) { line = trim(line || ''); if (!line) continue; let m_controller = match(line, /^external-controller:\s*(.+)$/); if (m_controller && m_controller[1]) { info.has_port = true; info.port = parse_controller_port(trim_quotes(m_controller[1]), '9090'); } let m_secret = match(line, /^secret:\s*(.*)$/); if (m_secret) { info.has_secret = true; let secret = trim(m_secret[1] || ''); info.secret = trim_quotes(secret); } } return info; } function parse_singbox_api_auth(path) { let info = { has_port: false, port: '', has_secret: false, secret: '' }; if (!access(path, 'r')) return info; let raw = readfile(path) || ''; if (!raw) return info; let cfg = null; try { cfg = json(raw); } catch (e) { return info; } let api = cfg?.experimental?.clash_api; if (!api || type(api) != 'object') return info; let controller = api.external_controller || ''; if (controller) { info.has_port = true; info.port = parse_controller_port(controller, '9090'); } if (type(api.secret) != 'undefined' && api.secret !== null) { info.has_secret = true; info.secret = '' + api.secret; } return info; } function get_clash_api_auth() { let c = cursor(); c.load('clashoo'); let auth = { port: c.get('clashoo', 'config', 'dash_port') || '9090', pass: c.get('clashoo', 'config', 'dash_pass') || '' }; let family = running_core_family() || get_core_type(); if (family == 'singbox') { let sb = parse_singbox_api_auth('/etc/sing-box/config.json'); if (sb.has_port) auth.port = sb.port || auth.port; if (sb.has_secret) auth.pass = sb.secret || ''; } else { let mm = parse_mihomo_api_auth('/etc/clashoo/config.yaml'); if (mm.has_port) auth.port = mm.port || auth.port; if (mm.has_secret) auth.pass = mm.secret || ''; } return auth; } function api_unauthorized_payload(obj) { if (!obj || type(obj) != 'object') return false; let msg = '' + (obj.message || obj.error || ''); return !!match(msg, /[Uu]nauthorized|[Ff]orbidden|401/); } function mihomo_api_post(path) { let api = get_clash_api_auth(); let dash_port = api.port; let dash_pass = api.pass; let auth = dash_pass ? ('-H ' + shell_quote('Authorization: Bearer ' + dash_pass) + ' ') : ''; let url = 'http://127.0.0.1:' + dash_port + path; let cmd = 'curl -s -o /dev/null -w "%{http_code}" -m 10 -X POST ' + auth + shell_quote(url) + ' 2>/dev/null'; let p = popen(cmd); if (!p) return '000'; let code = trim(p.read('all')); p.close(); return code || '000'; } function clash_api_get(path, timeout) { let api = get_clash_api_auth(); let dash_port = api.port; let dash_pass = api.pass; let auth = dash_pass ? ('-H ' + shell_quote('Authorization: Bearer ' + dash_pass) + ' ') : ''; let t = timeout || 4; let url = 'http://127.0.0.1:' + dash_port + path; let cmd = 'curl -s -m ' + t + ' ' + auth + shell_quote(url) + ' 2>/dev/null'; let p = popen(cmd); if (!p) return ''; let raw = trim(p.read('all')); p.close(); return raw; } function clash_api_json(path, timeout) { let raw = clash_api_get(path, timeout); if (!raw) return null; try { let obj = json(raw); return obj ? obj : null; } catch (e) { /* Some endpoints can stream multiple JSON frames; parse first valid line */ for (let line in split(raw, '\n')) { line = trim(line || ''); if (!line) continue; try { let obj2 = json(line); if (obj2) return obj2; } catch (e2) {} } return null; } } /* 百分号编码:clash API 路径里的组名常含 emoji/中文/空格,curl 不会自动编码 */ function uri_encode(s) { s = '' + (s || ''); let out = ''; for (let i = 0; i < length(s); i++) { let ch = substr(s, i, 1); let o = ord(ch); if ((o >= 48 && o <= 57) || (o >= 65 && o <= 90) || (o >= 97 && o <= 122) || o == 45 || o == 46 || o == 95 || o == 126) out += ch; else out += sprintf('%%%02X', o); } return out; } /* PUT + JSON body,用于切换 Selector 组(成功返回 204);返回 HTTP 状态码字符串 */ function clash_api_put_json(path, body) { let api = get_clash_api_auth(); let auth = api.pass ? ('-H ' + shell_quote('Authorization: Bearer ' + api.pass) + ' ') : ''; let url = 'http://127.0.0.1:' + api.port + path; let payload = sprintf('%J', body); let cmd = 'curl -s -o /dev/null -w "%{http_code}" -m 8 -X PUT ' + auth + '-H ' + shell_quote('Content-Type: application/json') + ' ' + '-d ' + shell_quote(payload) + ' ' + shell_quote(url) + ' 2>/dev/null'; let p = popen(cmd); if (!p) return '000'; let code = trim(p.read('all')); p.close(); return code || '000'; } function read_overview_stats_cache() { let raw = trim(readfile(OVERVIEW_STATS_CACHE_FILE) || ''); if (!raw) return {}; try { let obj = json(raw); return obj ? obj : {}; } catch (e) { return {}; } } function write_overview_stats_cache(payload) { if (!payload) return; writefile(OVERVIEW_STATS_CACHE_FILE, sprintf('%J', payload)); } function parse_sub_userinfo(raw) { let info = { upload: 0, download: 0, total: 0, expire: 0 }; if (!raw) return info; for (let part in split(raw, ';')) { let kv = split(trim(part), '='); if (length(kv) == 2) { let k = trim(kv[0]); let v = int(trim(kv[1])) || 0; if (k == 'upload' || k == 'download' || k == 'total' || k == 'expire') info[k] = v; } } return info; } function read_subscriptions() { let rows = []; if (!access(LIST_FILE, 'r')) return rows; let content = readfile(LIST_FILE) || ''; for (let line in split(content, '\n')) { line = trim(line); if (!line) continue; let parts = split(line, '#'); if (length(parts) < 2) continue; let name = trim(parts[0]); let url = trim(parts[1]); let typ = length(parts) > 2 ? trim(parts[2]) : 'clashoo'; if (!name || !url) continue; let info_path = SUB_DIR + '/' + name + '.info'; let raw_info = access(info_path, 'r') ? trim(readfile(info_path) || '') : ''; let ui = parse_sub_userinfo(raw_info); push(rows, { name, url, type: typ, sub_upload: ui.upload, sub_download: ui.download, sub_total: ui.total, sub_expire: ui.expire }); } return rows; } function build_configs_payload() { let configs = []; let subs = read_subscriptions(); let core_type = get_core_type(); let current = get_current_config(core_type); let is_singbox = (core_type == 'singbox'); function has_cfg(n) { for (let i = 0; i < length(configs); i++) if (configs[i] == n) return true; return false; } if (is_singbox) { /* sing-box 内核:只列出 .json 配置 */ for (let f in (glob(SINGBOX_DIR + '/*.json') || [])) { let n = replace(f, SINGBOX_DIR + '/', ''); if (n && !has_cfg(n)) push(configs, n); } } else { /* mihomo / smart 内核:只列出 .yaml / .yml 配置 */ for (let row in subs) { let n = row.name; if (!n) continue; if (!access('/usr/share/clashoo/config/sub/' + n, 'r')) continue; if (!has_cfg(n)) push(configs, n); } for (let f in (glob('/usr/share/clashoo/config/upload/*.yaml') || [])) { let n = replace(f, '/usr/share/clashoo/config/upload/', ''); if (n && !has_cfg(n)) push(configs, n); } for (let f in (glob('/usr/share/clashoo/config/upload/*.yml') || [])) { let n = replace(f, '/usr/share/clashoo/config/upload/', ''); if (n && !has_cfg(n)) push(configs, n); } for (let f in (glob('/usr/share/clashoo/config/custom/*.yaml') || [])) { let n = replace(f, '/usr/share/clashoo/config/custom/', ''); if (n && !has_cfg(n)) push(configs, n); } for (let f in (glob('/usr/share/clashoo/config/custom/*.yml') || [])) { let n = replace(f, '/usr/share/clashoo/config/custom/', ''); if (n && !has_cfg(n)) push(configs, n); } } /* 仅当 current 真实存在于任一标准目录时才补到列表,避免删除文件后下拉框残留 */ if (current && !has_cfg(current)) { let exists = access('/usr/share/clashoo/config/sub/' + current, 'r') || access('/usr/share/clashoo/config/upload/' + current, 'r') || access('/usr/share/clashoo/config/custom/' + current, 'r') || access(SINGBOX_DIR + '/' + current, 'r'); if (exists) push(configs, current); else current = ''; } return { configs: configs, current: current, core_type: core_type }; } function get_access_payload() { let proxy_port = uci_get('clashoo', 'config', 'mixed_port') || uci_get('clashoo', 'config', 'http_port') || '7890'; let tcp_mode = uci_get('clashoo', 'config', 'tcp_mode') || 'redirect'; let udp_mode = uci_get('clashoo', 'config', 'udp_mode') || tcp_mode; ensure_access_check_daemon(); let now = now_epoch_sec(); let cache = read_access_check_cache(); let stale = access_cache_is_stale(cache, now); let updating = access_check_is_updating(now); if ((stale || !cache) && !updating) { trigger_access_cache_refresh(); updating = true; } if (!cache) return default_access_payload(proxy_port, tcp_mode, udp_mode); return ensure_access_payload_shape(cache, proxy_port, tcp_mode, udp_mode, stale, updating); } function read_template_bindings() { let rows = []; if (!access(TEMPLATE_BIND_FILE, 'r')) return rows; let content = readfile(TEMPLATE_BIND_FILE) || ''; for (let line in split(content, '\n')) { line = trim(line || ''); if (!line) continue; let parts = split(line, '#'); if (length(parts) < 2) continue; let sub_name = ensure_yaml_name(parts[0] || ''); let template_name = ensure_yaml_name(parts[1] || ''); let enabled = length(parts) > 2 ? trim(parts[2] || '') : '1'; if (!sub_name || !template_name) continue; push(rows, { sub_name, template_name, enabled: (enabled == '1' || enabled == 'true') ? '1' : '0' }); } return rows; } function write_template_bindings(rows) { let lines = []; for (let row in (rows || [])) { if (!row || !row.sub_name || !row.template_name) continue; push(lines, row.sub_name + '#' + row.template_name + '#' + (row.enabled == '1' ? '1' : '0')); } system('mkdir -p /usr/share/clashbackup'); return writefile(TEMPLATE_BIND_FILE, join('\n', lines) + '\n') !== null; } function find_template_binding(sub_name) { for (let row in read_template_bindings()) { if (row.sub_name == sub_name) return row; } return null; } // Migrate sing-box config from pre-1.12 deprecated formats to 1.12+ format function migrate_singbox(cfg) { // --- Fix DNS servers: address-based → type-based, remove detour --- if (cfg.dns && cfg.dns.servers) { cfg.dns.servers = map(cfg.dns.servers || [], function(srv) { let addr = srv.address || ''; let new_srv = {}; let skip = { address: 1, detour: 1, address_resolver: 1, address_strategy: 1 }; for (let k in srv) if (!skip[k]) new_srv[k] = srv[k]; if (addr === 'fakeip') { new_srv.type = 'fakeip'; } else if (addr === 'local' || addr === '') { new_srv.type = new_srv.type || 'local'; } else if (match(addr, /^rcode:\/\//) || addr === 'rcode') { new_srv._rcode = true; // mark for removal, handled via dns rule action } else if (match(addr, /^h3:\/\//)) { let host = replace(replace(addr, /^h3:\/\//, ''), /\/.*$/, ''); new_srv.type = 'h3'; new_srv.server = host; } else if (match(addr, /^https:\/\//)) { let host = replace(replace(addr, /^https:\/\//, ''), /\/.*$/, ''); new_srv.type = 'https'; new_srv.server = host; } else if (match(addr, /^tls:\/\//)) { new_srv.type = 'tls'; new_srv.server = replace(addr, /^tls:\/\//, ''); } else if (match(addr, /^tcp:\/\//)) { new_srv.type = 'tcp'; new_srv.server = replace(addr, /^tcp:\/\//, ''); } else if (addr && !new_srv.type) { new_srv.type = 'udp'; new_srv.server = addr; // plain IP → UDP } else if (addr) { new_srv.address = addr; // unknown format, keep as-is } return new_srv; }); } // --- Remove rcode servers (unsupported in 1.12+ new format), convert referencing rules to reject --- let rcode_tags = {}; if (cfg.dns && cfg.dns.servers) { let good = []; for (let srv in cfg.dns.servers) { if (srv._rcode) rcode_tags[srv.tag] = true; else push(good, srv); } cfg.dns.servers = good; } if (cfg.dns && cfg.dns.rules && length(keys(rcode_tags)) > 0) { cfg.dns.rules = map(cfg.dns.rules || [], function(rule) { if (rule.server && rcode_tags[rule.server]) { let r = {}; for (let k in rule) if (k !== 'server' && k !== 'action') r[k] = rule[k]; r.action = 'reject'; return r; } return rule; }); } // --- Remove legacy dns.fakeip.enabled (1.12+ fakeip is configured via type:"fakeip" server) --- if (cfg.dns && cfg.dns.fakeip) { let fp = cfg.dns.fakeip; let new_fp = {}; for (let k in fp) if (k !== 'enabled') new_fp[k] = fp[k]; if (length(keys(new_fp)) > 0) cfg.dns.fakeip = new_fp; else delete cfg.dns['fakeip']; } // --- Fix DNS rules: add action:"route" when server is set --- if (cfg.dns && cfg.dns.rules) { cfg.dns.rules = map(cfg.dns.rules || [], function(rule) { if (rule.server && !rule.action) { let r = {}; for (let k in rule) r[k] = rule[k]; r.action = 'route'; return r; } return rule; }); } // --- Fix legacy special outbounds (block / dns types) --- let special = {}; if (cfg.outbounds) { let kept = []; for (let ob in cfg.outbounds) { if (ob.type === 'block' || ob.type === 'dns') { special[ob.tag] = ob.type; } else { push(kept, ob); } } cfg.outbounds = kept; } // Patch route rules that pointed to removed special outbounds if (cfg.route && cfg.route.rules && length(keys(special)) > 0) { cfg.route.rules = map(cfg.route.rules || [], function(rule) { let sp = rule.outbound && special[rule.outbound]; if (!sp) return rule; let r = {}; for (let k in rule) if (k !== 'outbound') r[k] = rule[k]; r.action = (sp === 'dns') ? 'hijack-dns' : 'reject'; return r; }); } // --- Migrate geoip/geosite database refs to rule_set (removed in sing-box 1.12) --- let needed_rule_sets = {}; let migrate_geo_rule = function(rule) { let has_geo = rule.geoip || rule.geosite; if (!has_geo) return rule; let r = {}; let new_rs = []; for (let k in rule) { if (k === 'geoip') { for (let n in rule[k]) { let tag = 'geoip-' + n; needed_rule_sets[tag] = 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-' + n + '.srs'; push(new_rs, tag); } } else if (k === 'geosite') { for (let n in rule[k]) { let tag = 'geosite-' + n; needed_rule_sets[tag] = 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-' + n + '.srs'; push(new_rs, tag); } } else { r[k] = rule[k]; } } // Merge new rule_set tags with any existing ones let existing_rs = type(r.rule_set) === 'array' ? r.rule_set : (r.rule_set ? [r.rule_set] : []); for (let t in new_rs) push(existing_rs, t); r.rule_set = existing_rs; return r; }; if (cfg.route && cfg.route.rules) cfg.route.rules = map(cfg.route.rules, migrate_geo_rule); if (cfg.dns && cfg.dns.rules) cfg.dns.rules = map(cfg.dns.rules, migrate_geo_rule); // download_detour must be DIRECT: at first start rule-sets aren't loaded, so // routing it via a proxy deadlocks (DNS recursion -> sing-box FATAL). let pick_dl_detour = function() { if (cfg.outbounds) { for (let ob in cfg.outbounds) { if (ob && ob.type === 'direct' && ob.tag) return ob.tag; } } return 'DIRECT'; }; // Add rule_set download entries for each referenced geo tag if (length(keys(needed_rule_sets)) > 0) { if (!cfg.route) cfg.route = {}; if (!cfg.route.rule_set) cfg.route.rule_set = []; let existing_tags = {}; for (let rs in cfg.route.rule_set) existing_tags[rs.tag] = true; let dl_detour = pick_dl_detour(); for (let tag in keys(needed_rule_sets)) { if (!existing_tags[tag]) { push(cfg.route.rule_set, { tag: tag, type: 'remote', format: 'binary', url: needed_rule_sets[tag], download_detour: dl_detour }); } } } // --- Remove deprecated 'outbound' DNS rule items (fatal in sing-box 1.13) --- // Replacement: set route.default_domain_resolver to the plain IP-based resolver let dns_resolver_tag = ''; if (cfg.dns && cfg.dns.servers) { for (let srv in cfg.dns.servers) { if ((srv.type === 'udp' || srv.type === 'local') && srv.server && match(srv.server, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) { dns_resolver_tag = srv.tag; break; } } } if (cfg.dns && cfg.dns.rules) { let filtered = []; for (let rule in cfg.dns.rules) { if (!rule.outbound) push(filtered, rule); } cfg.dns.rules = filtered; } // Set route.default_domain_resolver (replaces outbound:any DNS routing) if (dns_resolver_tag) { if (!cfg.route) cfg.route = {}; if (!cfg.route.default_domain_resolver) cfg.route.default_domain_resolver = dns_resolver_tag; } // --- Fix DNS servers with domain-name server field: add domain_resolver --- // sing-box 1.12+ requires domain_resolver when server= is a hostname (not IP) if (cfg.dns && cfg.dns.servers && dns_resolver_tag) { let domain_types = { https: 1, h3: 1, tls: 1, tcp: 1 }; cfg.dns.servers = map(cfg.dns.servers, function(srv) { if (!domain_types[srv.type]) return srv; if (!srv.server || match(srv.server, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return srv; if (srv.domain_resolver) return srv; let r = {}; for (let k in srv) r[k] = srv[k]; r.domain_resolver = dns_resolver_tag; return r; }); } // --- Fix DNS rules referencing non-existent server tags → action: reject --- if (cfg.dns && cfg.dns.servers && cfg.dns.rules) { let valid_servers = {}; for (let srv in cfg.dns.servers) valid_servers[srv.tag] = true; cfg.dns.rules = map(cfg.dns.rules, function(rule) { if (!rule.server || valid_servers[rule.server]) return rule; let r = {}; for (let k in rule) if (k !== 'server' && k !== 'action') r[k] = rule[k]; r.action = 'reject'; return r; }); } // --- Fix reject method: sing-box 1.14 rejects "dropped", only "default"/"drop" --- if (cfg.dns && cfg.dns.rules) { cfg.dns.rules = map(cfg.dns.rules, function(rule) { if (rule.action !== 'reject' || !rule.method) return rule; let m = rule.method; if (m === 'dropped') m = 'drop'; if (m !== 'default' && m !== 'drop') { // unknown value: drop it, sing-box defaults to NXDOMAIN let r = {}; for (let k in rule) if (k !== 'method') r[k] = rule[k]; return r; } if (m === rule.method) return rule; let r = {}; for (let k in rule) r[k] = rule[k]; r.method = m; return r; }); } // --- Migrate TUN inet4_address/inet6_address -> address array (1.12+), and // strip legacy inbound fields removed in 1.13 (sniff / sniff_override_destination / // sniff_timeout / domain_strategy). If any inbound had sniff on, prepend a // { "action": "sniff" } route rule as the equivalent. --- let had_sniff = false; if (cfg.inbounds) { cfg.inbounds = map(cfg.inbounds, function(ib) { let r = {}; let addrs = []; for (let k in ib) { if (k === 'inet4_address') { if (type(ib[k]) === 'array') { for (let a in ib[k]) push(addrs, a); } else push(addrs, ib[k]); } else if (k === 'inet6_address') { if (type(ib[k]) === 'array') { for (let a in ib[k]) push(addrs, a); } else push(addrs, ib[k]); } else if (k === 'sniff') { if (ib[k]) had_sniff = true; } else if (k === 'sniff_override_destination' || k === 'sniff_timeout' || k === 'domain_strategy') { // drop — moved to route.rules actions in 1.13 } else { r[k] = ib[k]; } } if (length(addrs) > 0) r.address = addrs; return r; }); } if (had_sniff) { if (!cfg.route) cfg.route = {}; if (!cfg.route.rules) cfg.route.rules = []; let has_sniff_rule = false; for (let rl in cfg.route.rules) if (rl.action === 'sniff') { has_sniff_rule = true; break; } if (!has_sniff_rule) { let new_rules = [ { action: 'sniff' } ]; for (let rl in cfg.route.rules) push(new_rules, rl); cfg.route.rules = new_rules; } } // --- sing-box 1.14 requires route.default_domain_resolver; add one if missing, // preferring dns.final, else the first dns.servers entry with a tag. --- if (cfg.route && !cfg.route.default_domain_resolver) { let resolver_tag = ''; if (cfg.dns && cfg.dns.final) resolver_tag = cfg.dns.final; else if (cfg.dns && cfg.dns.servers) { for (let srv in cfg.dns.servers) if (srv.tag) { resolver_tag = srv.tag; break; } } if (resolver_tag) cfg.route.default_domain_resolver = { server: resolver_tag }; } // --- Remove dangling outbound refs from selectors/urltest, and drop airline // pseudo-nodes. Two cases: (1) subconverter may reference a missing tag (e.g. // REJECT); (2) "Traffic:.../Expire:.../quota" pseudo-nodes are real SS/Vmess // outbounds (kept so the UI can read traffic/expiry) but don't forward — in a // selector/urltest they win (0ms) and swallow all foreign traffic. --- let is_pseudo_tag = function(t) { if (!t) return false; return match(t, /^Traffic[::]/) || match(t, /^Expire[::]/) || match(t, /剩余流量|剩余[::]/) || match(t, /距离下次重置/) || match(t, /到期(时间|日期)?[::]/) || match(t, /官网[::]|网站[::]|套餐[::]?|客服[::]/) || match(t, /QQ[群]?[::]/) || match(t, /Telegram|TG群|官方群/) || match(t, /续费|订阅地址|流量重置/); }; if (cfg.outbounds) { let defined_tags = {}; for (let ob in cfg.outbounds) defined_tags[ob.tag] = true; cfg.outbounds = map(cfg.outbounds, function(ob) { if ((ob.type !== 'selector' && ob.type !== 'urltest' && ob.type !== 'loadbalance') || !ob.outbounds) return ob; let valid = []; for (let t in ob.outbounds) { if (!defined_tags[t]) continue; if (is_pseudo_tag(t)) continue; push(valid, t); } if (length(valid) === length(ob.outbounds)) return ob; let r = {}; for (let k in ob) r[k] = ob[k]; r.outbounds = valid; return r; }); } // --- Force download_detour=DIRECT on every remote rule_set --- // subconverter often emits download_detour="auto", but the proxy isn't ready at // first start -> urltest deadlock ("fetch rule-set: deadline exceeded") -> FATAL. // The srs URLs already use a CN-reachable mirror, so DIRECT is always correct. if (cfg.route && cfg.route.rule_set) { let dl_detour = pick_dl_detour(); cfg.route.rule_set = map(cfg.route.rule_set, function(rs) { if (!rs || rs.type !== 'remote') return rs; let r = {}; for (let k in rs) r[k] = rs[k]; r.download_detour = dl_detour; return r; }); } return cfg; } function restore_backup_json_locked(raw) { let rollback_ready = false; try { if (!raw) return { success: false, message: '备份内容为空' }; if (length(raw) > BACKUP_MAX_JSON_SIZE) return { success: false, message: '旧版 JSON 备份超过 8MB' }; let obj = null; try { obj = json(raw); } catch (e) { obj = null; } if (type(obj) != 'object') return { success: false, message: '备份文件格式错误,无法解析' }; let manifest = obj.manifest; if (type(manifest) != 'object' || manifest.marker != 'clashoo-backup-v1') return { success: false, message: '这不是有效的 clashoo 备份文件' }; let files = obj.files; if (type(files) != 'object') return { success: false, message: '备份文件缺少配置数据' }; let BACKUP_DIR_MAP = { 'config/sub': '/usr/share/clashoo/config/sub', 'config/upload': '/usr/share/clashoo/config/upload', 'config/custom': '/usr/share/clashoo/config/custom', 'config/singbox': SINGBOX_DIR, 'templates': TEMPLATE_USER_DIR }; let BACKUP_FIXED_MAP = { 'uci': '/etc/config/clashoo', 'meta/confit_list.conf': LIST_FILE, 'meta/template_bindings.conf': TEMPLATE_BIND_FILE }; let plan = []; let entry_count = 0; for (let rel in files) { entry_count++; if (entry_count > BACKUP_MAX_ENTRIES) return { success: false, message: '备份文件条目超过 2048 个' }; let content = files[rel]; if (type(content) != 'string') return { success: false, message: '备份文件含非法数据项: ' + rel }; if (BACKUP_FIXED_MAP[rel]) { push(plan, { path: BACKUP_FIXED_MAP[rel], content: content }); continue; } let slash = rindex(rel, '/'); if (slash < 0) return { success: false, message: '备份文件含未知数据项: ' + rel }; let prefix = substr(rel, 0, slash); let fname = substr(rel, slash + 1); let dir = BACKUP_DIR_MAP[prefix]; if (!dir) return { success: false, message: '备份文件含未知数据项: ' + rel }; if (!fname || safe_name(fname) != fname) return { success: false, message: '备份文件含非法文件名: ' + rel }; push(plan, { path: dir + '/' + fname, content: content }); } unlink(BACKUP_JSON_ROLLBACK_FILE); if (system('sh ' + shell_quote(BACKUP_ARCHIVE_SCRIPT) + ' export ' + shell_quote(BACKUP_JSON_ROLLBACK_FILE) + ' >/dev/null 2>&1') != 0) { unlink(BACKUP_JSON_ROLLBACK_FILE); return { success: false, message: '无法创建还原前安全快照,未修改当前配置' }; } rollback_ready = true; let apply_error = ''; for (let prefix in BACKUP_DIR_MAP) { let d = BACKUP_DIR_MAP[prefix]; if (system('mkdir -p ' + shell_quote(d)) != 0 || system('rm -f ' + shell_quote(d) + '/* >/dev/null 2>&1') != 0) { apply_error = '无法清空配置目录: ' + d; break; } } let written = 0; for (let item in plan) { if (apply_error) break; let p = item.path; let ps = rindex(p, '/'); if (ps > 0 && system('mkdir -p ' + shell_quote(substr(p, 0, ps))) != 0) { apply_error = '无法创建配置目录: ' + p; break; } if (writefile(p, item.content) === null) { apply_error = '写入失败: ' + p; break; } written++; } if (apply_error) { let rollback_rc = system('sh ' + shell_quote(BACKUP_ARCHIVE_SCRIPT) + ' restore-held ' + shell_quote(BACKUP_JSON_ROLLBACK_FILE) + ' >/dev/null 2>&1'); unlink(BACKUP_JSON_ROLLBACK_FILE); rollback_ready = false; return { success: false, message: apply_error + (rollback_rc == 0 ? ';已回滚原配置' : ';自动回滚失败,请检查存储空间') }; } unlink(BACKUP_JSON_ROLLBACK_FILE); rollback_ready = false; system('sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1'); return { success: true, restored: written, message: '已还原 ' + written + ' 个文件,正在重启服务' }; } catch (e) { if (rollback_ready) { system('sh ' + shell_quote(BACKUP_ARCHIVE_SCRIPT) + ' restore-held ' + shell_quote(BACKUP_JSON_ROLLBACK_FILE) + ' >/dev/null 2>&1'); unlink(BACKUP_JSON_ROLLBACK_FILE); } return { success: false, message: '导入失败: ' + e }; } } function restore_backup_json(raw) { if (!backup_tmp_ready()) return { success: false, message: '备份临时目录不安全或无法创建' }; if (system('sh ' + shell_quote(BACKUP_ARCHIVE_SCRIPT) + ' lock >/dev/null 2>&1') != 0) return { success: false, message: '已有备份正在还原,请稍后重试' }; let result = null; try { result = restore_backup_json_locked(raw); } catch (e) { result = { success: false, message: '导入失败: ' + e }; } system('sh ' + shell_quote(BACKUP_ARCHIVE_SCRIPT) + ' unlock >/dev/null 2>&1'); return result; } const methods = { version: { call: function() { try { let binary = find_binary(); return { app: read_first_line('/usr/share/clashoo/luci_version'), core: get_core_version(binary), binary: binary }; } catch (e) { return { success: false, error: 'version_failed', message: '' + e }; } } }, status: { call: function() { try { let c = cursor(); c.load('clashoo'); let configured_core = get_core_type(); let running = is_running(); let proxy_mode = c.get('clashoo', 'config', 'p_mode') || 'rule'; let conf_path = c.get('clashoo', 'config', 'use_config') || ''; let panel_type = c.get('clashoo', 'config', 'dashboard_panel')|| 'zashboard'; let tcp_mode = c.get('clashoo', 'config', 'tcp_mode') || 'tun'; let udp_mode = c.get('clashoo', 'config', 'udp_mode') || tcp_mode; let stack = c.get('clashoo', 'config', 'stack') || 'system'; let api = get_clash_api_auth(); let dash_port = api.port || (c.get('clashoo', 'config', 'dash_port') || '9090'); let dash_pass = api.pass || ''; let effective_tcp_mode = runtime_state_get('effective_tcp_mode') || tcp_mode; let effective_udp_mode = runtime_state_get('effective_udp_mode') || udp_mode; let runtime_degraded = runtime_state_get('degraded') == '1'; let runtime_degrade_reason = runtime_state_get('degrade_reason') || ''; let health_status = runtime_state_get('health_status') || (running ? 'unknown' : 'stopped'); let health_detail = runtime_state_get('health_detail') || ''; if (!running) { health_status = 'stopped'; if (health_detail != 'boot_disabled' && health_detail != 'service_disabled') health_detail = 'service_stopped'; } let has_tun_device = runtime_state_get('has_tun_device'); let local_ip = get_local_ip(); let config = get_display_config_name(configured_core); let slash = rindex(conf_path, '/'); let conf_name = slash >= 0 ? substr(conf_path, slash + 1) : conf_path; if (configured_core == 'singbox' && config) conf_name = config; let dcore = c.get('clashoo', 'config', 'dcore') || '2'; return { running, dash_port, dash_pass, proxy_mode, conf_path: conf_name, config, panel_type, tcp_mode, udp_mode, stack, effective_tcp_mode, effective_udp_mode, runtime_degraded, runtime_degrade_reason, health_status, health_detail, has_tun_device, core_type: configured_core, dcore, local_ip, has_smart: !!find_smart_binary() }; } catch (e) { return { success: false, error: 'status_failed', message: '' + e }; } } }, reload: { call: function() { let svc = get_service_name(); return { success: system('service ' + svc + ' reload >/dev/null 2>&1') == 0 }; } }, restart: { call: function() { return { success: system('sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1') == 0 }; } }, start: { call: function() { let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'enable', '1'); c.commit('clashoo'); system('/etc/init.d/clashoo enable >/dev/null 2>&1'); system('sh /usr/share/clashoo/rpc/rpc_async.sh start >/dev/null 2>&1'); return { success: true }; } }, stop: { call: function() { let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'enable', '0'); c.commit('clashoo'); system('sh /usr/share/clashoo/rpc/rpc_async.sh stop >/dev/null 2>&1'); return { success: true }; } }, commit_config: { call: function() { let c = cursor(); c.load('clashoo'); sync_legacy_core_fields(c); c.commit('clashoo'); let rc = system('uci commit clashoo >/dev/null 2>&1'); return { success: rc === 0 }; } }, dns_auto_setup: { call: function() { let running = is_running(); let p = popen('sh /usr/share/clashoo/net/dns_auto_setup.sh --apply 2>/dev/null'); if (!p) return { success: false, message: 'DNS 自动配置启动失败' }; let raw = trim(p.read('all') || ''); p.close(); let res = null; try { res = raw ? json(raw) : null; } catch (e) {} if (!res || !res.success) return res || { success: false, message: 'DNS 自动配置失败' }; res.restarted = false; if (running) { system('sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1'); res.restarted = true; } return res; } }, set_mode: { args: { mode: 'mode' }, call: function(req) { let mode = req.args?.mode || 'fake-ip'; if (mode != 'fake-ip' && mode != 'tun-system' && mode != 'tun-gvisor' && mode != 'tun-mixed') return { error: 'invalid mode' }; let c = cursor(); c.load('clashoo'); if (mode == 'tun-system' || mode == 'tun-gvisor' || mode == 'tun-mixed') { c.set('clashoo', 'config', 'tun_mode', '1'); c.set('clashoo', 'config', 'tcp_mode', 'tun'); c.set('clashoo', 'config', 'udp_mode', 'tun'); c.set('clashoo', 'config', 'stack', mode == 'tun-mixed' ? 'mixed' : (mode == 'tun-gvisor' ? 'gvisor' : 'system')); c.set('clashoo', 'config', 'enable_udp', '1'); c.set('clashoo', 'config', 'enhanced_mode', 'fake-ip'); } else { c.set('clashoo', 'config', 'tun_mode', '0'); c.set('clashoo', 'config', 'tcp_mode', 'redirect'); c.set('clashoo', 'config', 'udp_mode', 'tproxy'); c.set('clashoo', 'config', 'enhanced_mode', 'fake-ip'); } c.commit('clashoo'); system('uci commit clashoo >/dev/null 2>&1'); if (is_running()) system('sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1'); return { success: true }; } }, set_proxy_mode: { args: { mode: 'mode' }, call: function(req) { let mode = req.args?.mode || 'rule'; if (mode != 'rule' && mode != 'global' && mode != 'direct') return { error: 'invalid mode' }; let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'p_mode', mode); c.commit('clashoo'); if (is_running()) system('service ' + get_service_name() + ' restart >/dev/null 2>&1'); return { success: true }; } }, set_core: { args: { core: 'core', dcore: 'dcore', action: 'action' }, call: function(req) { let core = req.args?.core || 'mihomo'; if (core == 'sing-box') core = 'singbox'; let dcore = req.args?.dcore || ''; let action = req.args?.action || 'stop'; if (action != 'stop' && action != 'save' && action != 'apply') return { success: false, error: 'invalid_action', message: '无效的内核切换动作' }; let target_error = core_target_error(core, dcore); if (target_error) return { success: false, error: target_error.error, message: target_error.message }; let running_before = is_running(); let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'core_type', core); c.set('clashoo', 'config', 'dcore', dcore); /* Smart 策略仅 Smart 内核(dcore=1)有意义。切到 Smart 时自动开启 * smart_auto_switch;切回普通 mihomo / sing-box 时自动关闭,避免 * Smart 模型字段误注入到非 Smart 配置。 */ c.set('clashoo', 'config', 'smart_auto_switch', dcore === '1' ? '1' : '0'); sync_legacy_core_fields(c); c.commit('clashoo'); system('uci commit clashoo >/dev/null 2>&1'); // Overview 旧调用默认 stop;System 页可显式 save 或 apply。 // 所有耗时 init 操作仍由 rpc_async.sh + flock 串行化。 let restarting = false; if (action == 'save') { // Persist only. The selected core takes effect on the next start/restart. } else if (running_before && action == 'stop') system('sh /usr/share/clashoo/rpc/rpc_async.sh stop >/dev/null 2>&1'); else if (running_before && action == 'apply') { system('sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1'); restarting = true; } return { success: true, core_type: core, dcore, running_before, restarting, stopped: running_before && action == 'stop' }; } }, set_panel: { args: { name: 'name' }, call: function(req) { let name = req.args?.name || 'zashboard'; if (name != 'metacubexd' && name != 'yacd' && name != 'zashboard' && name != 'razord') name = 'zashboard'; let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'dashboard_panel', name); c.commit('clashoo'); system('uci commit clashoo >/dev/null 2>&1'); let activated = activate_dashboard_panel(name); return { success: true, activated }; } }, update_panel: { args: { name: 'name' }, call: function(req) { if (access('/var/run/panel_downloading', 'r')) return { success: false, busy: true, message: '面板正在下载' }; let name = req.args?.name || 'zashboard'; if (name != 'metacubexd' && name != 'yacd' && name != 'zashboard' && name != 'razord') name = 'zashboard'; let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'dashboard_panel', name); c.commit('clashoo'); system('uci commit clashoo >/dev/null 2>&1'); let pretty = name; if (name == 'metacubexd') pretty = 'MetaCubeXD'; else if (name == 'yacd') pretty = 'YACD'; else if (name == 'zashboard') pretty = 'Zashboard'; else if (name == 'razord') pretty = 'Razord'; system("printf '%s - 面板更新任务已提交: " + shell_quote(pretty) + "\\n' \"$(date '+%Y-%m-%d %H:%M:%S')\" >>/tmp/clash_update.txt"); // seed an immediate state so the UI poller never reads a stale prior result system("touch /var/run/panel_downloading; printf 'downloading:" + shell_quote(name) + ":开始下载' >/tmp/clash_panel_download_state"); system("nohup sh /usr/share/clashoo/update/panel_download.sh " + shell_quote(name) + " >>/tmp/clash_update.txt 2>&1 = 0) { state = substr(line, 0, i1); let rest = substr(line, i1 + 1); let i2 = index(rest, ':'); if (i2 >= 0) { panel = substr(rest, 0, i2); msg = substr(rest, i2 + 1); } else panel = rest; } return { downloading: downloading, state: state, panel: panel, msg: msg }; } }, list_configs: { call: function() { return build_configs_payload(); } }, set_config: { args: { name: 'name' }, call: function(req) { let name = safe_name(req.args?.name || ''); if (!name) return { error: 'missing name' }; if (match(name, /\.json$/)) { let profile = SINGBOX_DIR + '/' + name; if (!access(profile, 'r')) return { error: 'not found' }; let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'singbox_active', name); c.commit('clashoo'); if (is_running() && (running_core_family() == 'singbox' || get_core_type() == 'singbox')) system('sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1'); return { success: true, family: 'singbox', current: name }; } let found_path = ''; let found_type = ''; for (let dir in CONFIG_DIRS) { let candidate = dir.path + '/' + name; if (access(candidate, 'r')) { found_path = candidate; found_type = dir.type; break; } } if (!found_path) return { error: 'not found' }; let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'use_config', found_path); c.set('clashoo', 'config', 'config_type', found_type); c.commit('clashoo'); if (is_running()) system('service ' + get_service_name() + ' restart >/dev/null 2>&1'); return { success: true, family: 'mihomo', current: name }; } }, get_cpu_arch: { call: function() { return { arch: get_cpu_arch() }; } }, download_core: { call: function(req) { if (access('/var/run/core_update', 'r')) return { success: true, started: false, running: true }; let dc = req.args?.dcore || ''; let arch = req.args?.arch || ''; if (dc && dc != '0' && match(dc, /^[1-5]$/)) { let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'dcore', dc); if (arch) c.set('clashoo', 'config', 'download_core', arch); c.commit('clashoo'); system('uci commit clashoo >/dev/null 2>&1'); } system("printf '%s - 内核下载任务已触发\\n' \"\$(date '+%Y-%m-%d %H:%M:%S')\" >>/tmp/clash_update.txt"); system("nohup sh -c 'touch /var/run/core_update; sh /usr/share/clashoo/update/core_download.sh; rc=$?; rm -f /var/run/core_update; exit $rc' >/dev/null 2>&1 /dev/null 2>&1') == 0 }; } }, get_geoip_version: { call: function() { let f = '/etc/clashoo/Country.mmdb'; let s = stat(f); if (!s) return { version: '' }; let p = popen('date -d @' + s.mtime + ' "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -r ' + shell_quote(f) + ' "+%Y-%m-%d %H:%M:%S" 2>/dev/null'); let ts = ''; if (p) { ts = trim(p.read('all')); p.close(); } return { version: ts || ('' + s.mtime) }; } }, get_log_status: { call: function() { let core_updating = !!access('/var/run/core_update', 'r'); let geoip_updating = !!access('/var/run/geoip_update', 'r'); let p = popen("tail -8 /tmp/clash_update.txt 2>/dev/null"); let core_log = ''; if (p) { core_log = trim(p.read('all')); p.close(); } let p2 = popen("tail -5 /tmp/geoip_update.txt 2>/dev/null"); let geoip_log = ''; if (p2) { geoip_log = trim(p2.read('all')); p2.close(); } return { core_updating, geoip_updating, core_log, geoip_log }; } }, component_status: { call: function() { let running = !!access(COMPONENT_UPDATE_RUN_FILE, 'r'); let state = read_component_state(); let current = ''; if (access(COMPONENT_UPDATE_RUN_FILE, 'r')) current = trim(readfile(COMPONENT_UPDATE_RUN_FILE) || ''); if (!current) current = state.component || ''; let comps = []; for (let def in component_defs()) { let status = 'idle'; let message = ''; if (running && current == def.id) { status = 'running'; message = state.message || component_last_log(); } else if (!running && state.component == def.id && state.status) { status = state.status; message = state.message || ''; } push(comps, { id: def.id, name: def.name, description: def.description, kind: def.kind || '', variant: !!def.variant, installed_version: component_installed_label(def.id), installed_versions: def.variant ? component_installed_versions(def.id) : {}, status, message }); } return { running, current, components: comps, arch: { system: trim(shell_read('uname -m 2>/dev/null') || ''), // 老 arch 值归一化后再给前端 download_core: uci_get('clashoo', 'config', 'download_core') ? normalize_mihomo_arch(uci_get('clashoo', 'config', 'download_core')) : '' }, last_log: component_last_log(), log: component_log_tail(40) }; } }, component_update: { args: { component: 'component', variant: 'variant' }, call: function(req) { let component = req.args?.component || ''; let variant = req.args?.variant || ''; if (variant != 'stable' && variant != 'alpha') variant = ''; if (!valid_component_id(component)) return { success: false, error: 'invalid_component', message: '未知组件' }; if (!access(COMPONENT_UPDATE_SCRIPT, 'x')) return { success: false, error: 'missing_script', message: '组件更新脚本不存在' }; if (access(COMPONENT_UPDATE_RUN_FILE, 'r')) return { success: true, started: false, running: true, message: '已有组件更新任务运行中' }; if (writefile(COMPONENT_UPDATE_RUN_FILE, component + '\n') === null) return { success: false, error: 'lock_failed', message: '无法创建组件更新锁' }; system("printf '%s - 组件更新任务已提交: " + shell_quote(component) + "\\n' \"$(date '+%Y-%m-%d %H:%M:%S')\" >>" + shell_quote(COMPONENT_UPDATE_LOG_FILE)); let cmd = 'cp ' + shell_quote(COMPONENT_UPDATE_SCRIPT) + ' ' + shell_quote(COMPONENT_UPDATE_RUNNER) + ' && chmod +x ' + shell_quote(COMPONENT_UPDATE_RUNNER) + ' && nohup sh ' + shell_quote(COMPONENT_UPDATE_RUNNER) + ' ' + shell_quote(component) + (variant ? ' ' + shell_quote(variant) : '') + ' >/dev/null 2>&1 /dev/null"); let content = ''; if (p) { content = p.read('all'); p.close(); } return { content }; } }, clear_update_log: { call: function() { return { success: writefile('/tmp/clash_update.txt', '') !== null }; } }, read_geoip_log: { call: function() { let p = popen("tail -300 /tmp/geoip_update.txt 2>/dev/null"); let content = ''; if (p) { content = p.read('all'); p.close(); } return { content }; } }, clear_geoip_log: { call: function() { return { success: writefile('/tmp/geoip_update.txt', '') !== null }; } }, read_core_log: { call: function() { if (access(CORE_LOG_PATH, 'r')) { let p0 = popen("tail -400 '" + CORE_LOG_PATH + "' 2>/dev/null"); let c0 = ''; if (p0) { c0 = p0.read('all'); p0.close(); } return { content: format_core_log(c0) }; } let family = running_core_family() || get_core_type(); let tag = (family === 'singbox') ? 'sing-box' : 'mihomo'; let p = popen("logread -e '" + tag + "' 2>/dev/null | tail -400"); let content = ''; if (p) { content = p.read('all'); p.close(); } if (!content) { let p2 = popen("logread 2>/dev/null | grep -E '(sing-box|mihomo|clashoo)' | tail -400"); if (p2) { content = p2.read('all'); p2.close(); } } return { content: format_core_log(content) }; } }, clear_core_log: { call: function() { let dir = '/var/log/clashoo'; system('mkdir -p ' + shell_quote(dir) + ' >/dev/null 2>&1'); return { success: writefile(CORE_LOG_PATH, '') !== null }; } }, read_update_merged_log: { call: function() { let p1 = popen("tail -200 /tmp/clash_update.txt 2>/dev/null"); let c1 = ''; if (p1) { c1 = trim(p1.read('all')); p1.close(); } let p2 = popen("tail -200 /tmp/geoip_update.txt 2>/dev/null"); let c2 = ''; if (p2) { c2 = trim(p2.read('all')); p2.close(); } let parts = []; if (c1) push(parts, '=== 内核/面板/订阅更新 ===\n' + format_update_log(c1)); if (c2) push(parts, '=== GeoIP 更新 ===\n' + format_update_log(c2)); return { content: join('\n\n', parts) }; } }, clear_update_merged_log: { call: function() { writefile('/tmp/clash_update.txt', ''); writefile('/tmp/geoip_update.txt', ''); return { success: true }; } }, access_check: { call: function() { return get_access_payload(); } }, access_check_refresh: { call: function() { ensure_access_check_daemon(); let updating = access_check_is_updating(now_epoch_sec()); if (!updating) trigger_access_cache_refresh(); return { success: true, started: !updating }; } }, overview_stats: { call: function() { try { let running = is_running(); if (!running) return { ok: false, running: false }; /* Fast path: /connections already contains totals + memory on current kernels */ let conns0 = clash_api_json('/connections', 2); if (api_unauthorized_payload(conns0)) conns0 = null; let conns = conns0 || {}; let up_total = int(conns.uploadTotal || conns.upload_total || 0); let down_total = int(conns.downloadTotal || conns.download_total || 0); let up = int(conns.up || conns.upload || conns.upSpeed || conns.uploadSpeed || 0); let down = int(conns.down || conns.download || conns.downSpeed || conns.downloadSpeed || 0); let mem_inuse = int(conns.memory || conns.memory_inuse || conns.inuse || 0); let conn_count = 0; if (conns && conns.connections) { let n = int(conns.connections || 0); if (n > 0) conn_count = n; else for (let _ in conns.connections) conn_count++; } if (conn_count <= 0) conn_count = int(conns.connection || conns.connections_count || 0); /* Fallback: derive instantaneous rates from total deltas */ if ((up <= 0 && down <= 0) && (up_total > 0 || down_total > 0)) { let now = now_epoch_sec(); let prev = read_overview_stats_cache(); let prev_ts = int(prev.ts || 0); let dt = now - prev_ts; if (dt > 0 && dt < 120) { let prev_up_total = int(prev.up_total || 0); let prev_down_total = int(prev.down_total || 0); if (up_total >= prev_up_total) up = int((up_total - prev_up_total) / dt); if (down_total >= prev_down_total) down = int((down_total - prev_down_total) / dt); } write_overview_stats_cache({ ts: now, up_total: up_total, down_total: down_total }); } let ok = !!(conns0 || up_total > 0 || down_total > 0 || conn_count > 0 || mem_inuse > 0); return { ok, running: true, up, down, up_total, down_total, connections: conn_count, memory_inuse: mem_inuse }; } catch (e) { return { ok: false, running: is_running(), error: 'overview_stats_failed', message: '' + e }; } } }, /* Aggregate poll endpoint: returns status + realtime stats in ONE rpcd invocation. Critical: rpcd cold-starts ucode per call (~4s for this 2500-line script), so combining avoids a 3x penalty when the page polls. */ overview: { call: function() { try { let c = cursor(); c.load('clashoo'); let configured_core = get_core_type(); let running = is_running(); let proxy_mode = c.get('clashoo', 'config', 'p_mode') || 'rule'; let conf_path = c.get('clashoo', 'config', 'use_config') || ''; let panel_type = c.get('clashoo', 'config', 'dashboard_panel')|| 'zashboard'; let tcp_mode = c.get('clashoo', 'config', 'tcp_mode') || 'tun'; let udp_mode = c.get('clashoo', 'config', 'udp_mode') || tcp_mode; let stack = c.get('clashoo', 'config', 'stack') || 'system'; let api = get_clash_api_auth(); let dash_port = api.port || (c.get('clashoo', 'config', 'dash_port') || '9090'); let dash_pass = api.pass || ''; let effective_tcp_mode = runtime_state_get('effective_tcp_mode') || tcp_mode; let effective_udp_mode = runtime_state_get('effective_udp_mode') || udp_mode; let runtime_degraded = runtime_state_get('degraded') == '1'; let runtime_degrade_reason = runtime_state_get('degrade_reason') || ''; let health_status = runtime_state_get('health_status') || (running ? 'unknown' : 'stopped'); let health_detail = runtime_state_get('health_detail') || ''; if (!running) { health_status = 'stopped'; if (health_detail != 'boot_disabled' && health_detail != 'service_disabled') health_detail = 'service_stopped'; } let has_tun_device = runtime_state_get('has_tun_device'); let local_ip = get_local_ip(); let config = get_display_config_name(configured_core); let slash = rindex(conf_path, '/'); let conf_name = slash >= 0 ? substr(conf_path, slash + 1) : conf_path; if (configured_core == 'singbox' && config) conf_name = config; let dcore = c.get('clashoo', 'config', 'dcore') || '2'; let status_payload = { running, dash_port, dash_pass, proxy_mode, conf_path: conf_name, config, panel_type, tcp_mode, udp_mode, stack, effective_tcp_mode, effective_udp_mode, runtime_degraded, runtime_degrade_reason, health_status, health_detail, has_tun_device, core_type: configured_core, dcore, local_ip, has_mihomo_stable: has_mihomo_stable(), has_mihomo_alpha: !!access('/usr/bin/mihomo', 'x'), has_singbox_stable: !!access('/usr/bin/sing-box-stable', 'x'), has_singbox_alpha: !!access('/usr/bin/sing-box-alpha', 'x'), has_smart: !!find_smart_binary() }; let stats_payload = { ok: false, running: running }; if (running) { let conns0 = clash_api_json('/connections', 1); if (api_unauthorized_payload(conns0)) conns0 = null; let conns = conns0 || {}; let up_total = int(conns.uploadTotal || conns.upload_total || 0); let down_total = int(conns.downloadTotal || conns.download_total || 0); let up = int(conns.up || conns.upload || conns.upSpeed || conns.uploadSpeed || 0); let down = int(conns.down || conns.download || conns.downSpeed || conns.downloadSpeed || 0); let mem_inuse = int(conns.memory || conns.memory_inuse || conns.inuse || 0); let conn_count = 0; if (conns && conns.connections) { let n = int(conns.connections || 0); if (n > 0) conn_count = n; else for (let _ in conns.connections) conn_count++; } if (conn_count <= 0) conn_count = int(conns.connection || conns.connections_count || 0); if ((up <= 0 && down <= 0) && (up_total > 0 || down_total > 0)) { let now = now_epoch_sec(); let prev = read_overview_stats_cache(); let prev_ts = int(prev.ts || 0); let dt = now - prev_ts; if (dt > 0 && dt < 120) { let prev_up_total = int(prev.up_total || 0); let prev_down_total = int(prev.down_total || 0); if (up_total >= prev_up_total) up = int((up_total - prev_up_total) / dt); if (down_total >= prev_down_total) down = int((down_total - prev_down_total) / dt); } write_overview_stats_cache({ ts: now, up_total: up_total, down_total: down_total }); } stats_payload = { ok: !!(conns0 || up_total > 0 || down_total > 0 || conn_count > 0 || mem_inuse > 0), running: true, up, down, up_total, down_total, connections: conn_count, memory_inuse: mem_inuse }; } let configs_payload = build_configs_payload(); let access_payload = get_access_payload(); return { status: status_payload, stats: stats_payload, configs: configs_payload, access: access_payload }; } catch (e) { return { status: { running: false }, stats: { ok: false, running: false }, configs: { configs: [], current: '', core_type: get_core_type() }, access: default_access_payload('7890', 'redirect', 'redirect'), error: 'overview_failed', message: '' + e }; } } }, capabilities: { call: function() { return { backend: detect_firewall_backend(), missing_fw4_tools: detect_missing_fw4_tools(), has_singbox: !!find_binary_for_family('singbox'), has_mihomo: !!access('/usr/bin/mihomo', 'x'), has_mihomo_stable: has_mihomo_stable(), has_smart: !!find_smart_binary(), has_clash_meta: !!access('/usr/bin/clash-meta', 'x'), has_builtin_core: !!access('/etc/clashoo/clash', 'x') }; } }, list_subscriptions: { call: function() { let active = uci_get('clashoo', 'config', 'use_config'); let active_name = replace(active, /^.*\//, ''); let rows = read_subscriptions(); for (let row in rows) { let fi = stat(SUB_DIR + '/' + row.name); row.mtime = fi ? ('' + fi.mtime) : null; row.size = fi ? (int(fi.size / 1024) + ' KB') : null; row.has_file = !!fi; row.active = (row.name == active_name); } return { subs: rows, active: active_name }; } }, list_templates: { call: function() { let files = []; let seen = {}; for (let dir in template_search_dirs()) { for (let f in (glob(dir + '/*.yaml') || [])) { let name = replace(f, dir + '/', ''); if (index(name, '_merged_') == 0 || seen[name]) continue; seen[name] = 1; let fi = stat(f); push(files, { name, mtime: fi ? ('' + fi.mtime) : null, size: fi ? (int(fi.size / 1024) + ' KB') : null }); } for (let f in (glob(dir + '/*.yml') || [])) { let name = replace(f, dir + '/', ''); if (index(name, '_merged_') == 0 || seen[name]) continue; seen[name] = 1; let fi = stat(f); push(files, { name, mtime: fi ? ('' + fi.mtime) : null, size: fi ? (int(fi.size / 1024) + ' KB') : null }); } } return { files }; } }, list_template_bindings: { call: function() { return { bindings: read_template_bindings() }; } }, set_template_binding: { args: { sub_name: 'sub_name', template_name: 'template_name', enabled: 'enabled' }, call: function(req) { let sub_name = ensure_yaml_name(req.args?.sub_name || ''); let template_name = ensure_yaml_name(req.args?.template_name || ''); let enabled = (req.args?.enabled || '') == '0' ? '0' : '1'; if (!sub_name) return { success: false, error: 'invalid_sub_name', message: '订阅名称无效' }; if (!template_name) { let keep = []; for (let row in read_template_bindings()) { if (row.sub_name != sub_name) push(keep, row); } if (!write_template_bindings(keep)) return { success: false, error: 'write_failed', message: '保存绑定失败' }; return { success: true, message: '已移除模板绑定' }; } if (!access(SUB_DIR + '/' + sub_name, 'r')) return { success: false, error: 'sub_not_found', message: '订阅文件不存在' }; if (!find_template_path(template_name)) return { success: false, error: 'template_not_found', message: '模板文件不存在' }; let rows = read_template_bindings(); let updated = false; for (let i = 0; i < length(rows); i++) { if (rows[i].sub_name != sub_name) continue; rows[i].template_name = template_name; rows[i].enabled = enabled; updated = true; break; } if (!updated) push(rows, { sub_name, template_name, enabled }); if (!write_template_bindings(rows)) return { success: false, error: 'write_failed', message: '保存绑定失败' }; return { success: true, message: '模板绑定已保存' }; } }, apply_template_for_sub: { args: { sub_name: 'sub_name', template_name: 'template_name', set_active: 'set_active' }, call: function(req) { let sub_name = ensure_yaml_name(req.args?.sub_name || ''); let template_name = ensure_yaml_name(req.args?.template_name || ''); let set_active = (req.args?.set_active || '') == '1'; if (!sub_name) return { success: false, error: 'invalid_sub_name', message: '订阅名称无效' }; let sub_path = SUB_DIR + '/' + sub_name; if (!access(sub_path, 'r')) return { success: false, error: 'sub_not_found', message: '订阅文件不存在' }; if (!template_name) { let b = find_template_binding(sub_name); if (!b || b.enabled != '1') return { success: false, error: 'binding_not_found', message: '未找到启用的模板绑定' }; template_name = b.template_name; } let template_path = find_template_path(template_name); if (!template_path) return { success: false, error: 'template_not_found', message: '模板文件不存在' }; let output_name = template_output_name(sub_name, template_name); let out_path = TEMPLATE_DIR + '/' + output_name; let cmd = 'sh /usr/share/clashoo/update/template_merge.sh ' + shell_quote(sub_path) + ' ' + shell_quote(template_path) + ' ' + shell_quote(out_path); if (system(cmd + ' >/dev/null 2>&1') != 0) return { success: false, error: 'merge_failed', message: '模板复写生成失败,请检查模板内容' }; if (set_active) { let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'use_config', out_path); c.set('clashoo', 'config', 'config_type', '3'); c.commit('clashoo'); if (is_running()) system('service ' + get_service_name() + ' restart >/dev/null 2>&1'); } return { success: true, output_name, output_path: out_path, template_name, message: '模板复写生成成功: ' + output_name }; } }, apply_enabled_template_bindings: { args: { set_active: 'set_active' }, call: function(req) { let set_active = (req.args?.set_active || '') == '1'; let rows = read_template_bindings(); let done = []; let failed = []; for (let row in rows) { if (row.enabled != '1') continue; let sub_path = SUB_DIR + '/' + row.sub_name; let template_path = find_template_path(row.template_name); if (!access(sub_path, 'r') || !template_path) { push(failed, row.sub_name); continue; } let output_name = template_output_name(row.sub_name, row.template_name); let out_path = TEMPLATE_DIR + '/' + output_name; let cmd = 'sh /usr/share/clashoo/update/template_merge.sh ' + shell_quote(sub_path) + ' ' + shell_quote(template_path) + ' ' + shell_quote(out_path) + ' >/dev/null 2>&1'; if (system(cmd) == 0) { push(done, row.sub_name); if (set_active) { let current = uci_get('clashoo', 'config', 'use_config'); if (replace(current || '', /^.*\//, '') == row.sub_name) { let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'use_config', out_path); c.set('clashoo', 'config', 'config_type', '3'); c.commit('clashoo'); } } } else { push(failed, row.sub_name); } } if (set_active && is_running()) system('service ' + get_service_name() + ' restart >/dev/null 2>&1'); return { success: length(failed) == 0, done, failed, message: '模板生成完成: 成功 ' + length(done) + ',失败 ' + length(failed) }; } }, list_dir_files: { args: { type: 'type' }, call: function(req) { let type_map = { '1': '/usr/share/clashoo/config/sub', '2': '/usr/share/clashoo/config/upload', '3': '/usr/share/clashoo/config/custom' }; let dir = type_map[req.args?.type || '2']; if (!dir) return { files: [] }; let active = uci_get('clashoo', 'config', 'use_config'); let active_name = replace(active, /^.*\//, ''); let files = []; for (let f in (glob(dir + '/*.yaml') || [])) { let name = replace(f, dir + '/', ''); let fi = stat(f); push(files, { name, mtime: fi ? ('' + fi.mtime) : null, size: fi ? (int(fi.size / 1024) + ' KB') : null, active: name == active_name }); } for (let f in (glob(dir + '/*.yml') || [])) { let name = replace(f, dir + '/', ''); let fi = stat(f); push(files, { name, mtime: fi ? ('' + fi.mtime) : null, size: fi ? (int(fi.size / 1024) + ' KB') : null, active: name == active_name }); } return { files, active: active_name }; } }, delete_config: { args: { name: 'name', type: 'type' }, call: function(req) { let name = safe_name(req.args?.name || ''); let type = req.args?.type || '1'; if (!name) return { error: 'missing name' }; let type_map = { '1': SUB_DIR, '2': '/usr/share/clashoo/config/upload', '3': '/usr/share/clashoo/config/custom' }; let dir = type_map[type] || SUB_DIR; let target = dir + '/' + name; let file_exists = !!access(target, 'r'); let alt_name = name; let alt_target = target; let mirrored_template = null; if (type == '1' && match(name, /_new\.(yaml|yml)$/)) { alt_name = replace(name, /_new\.(yaml|yml)$/, '.yaml'); alt_target = dir + '/' + alt_name; } if (type == '3' && file_exists) { let user_template = TEMPLATE_USER_DIR + '/' + name; if (access(user_template, 'r') && readfile(user_template) == readfile(target)) mirrored_template = user_template; } if (file_exists) { if (unlink(target) === null) return { success: false, error: 'delete failed' }; if (type == '1') { unlink(target + '.info'); unlink(target + '.url'); unlink(target + '.ua'); } } else if (type != '1') { return { error: 'not found' }; } if (mirrored_template) unlink(mirrored_template); if (type == '1' && alt_target != target && access(alt_target, 'r')) { unlink(alt_target); unlink(alt_target + '.info'); unlink(alt_target + '.url'); unlink(alt_target + '.ua'); } let c = cursor(); c.load('clashoo'); let active = c.get('clashoo', 'config', 'use_config') || ''; if (active == target || (type == '1' && active == alt_target)) { let fallback = first_available_config(target); if (fallback) { c.set('clashoo', 'config', 'use_config', fallback.path); c.set('clashoo', 'config', 'config_type', fallback.type); } else { c.delete('clashoo', 'config', 'use_config'); c.delete('clashoo', 'config', 'config_type'); } c.commit('clashoo'); if (is_running()) system('service ' + get_service_name() + ' restart >/dev/null 2>&1'); } if (type == '1' && access(LIST_FILE, 'r')) { let content = readfile(LIST_FILE) || ''; let lines = []; for (let l in split(content, '\n')) { if (index(l, name + '#') == 0) continue; if (alt_name != name && index(l, alt_name + '#') == 0) continue; push(lines, l); } writefile(LIST_FILE, join('\n', lines)); } return { success: true }; } }, update_china_ip: { call: function() { /* Surface CN whitelist update progress in update log panel. */ return { success: system('sh /usr/share/clashoo/rpc/rpc_async.sh update_china_ip >/dev/null 2>&1') == 0 }; } }, download_subs: { call: function() { // 已有下载任务在跑则不重复触发 let pb = popen('ps w 2>/dev/null | grep "[c]lashoo/runtime/clashoo.sh" | head -1'); let busy = ''; if (pb) { busy = trim(pb.read('all')); pb.close(); } if (busy != '') return { success: true, started: false, running: true }; // 清空进度日志后后台执行,前端轮询 download_subs_status 取实时进度 system("printf '' >/tmp/clash_update.txt 2>/dev/null"); system('sh /usr/share/clashoo/runtime/clashoo.sh >/dev/null 2>&1 &'); return { success: true, started: true }; } }, download_subs_status: { call: function() { let p = popen('ps w 2>/dev/null | grep "[c]lashoo/runtime/clashoo.sh" | head -1'); let line = ''; if (p) { line = trim(p.read('all')); p.close(); } let running = (line && line != ''); let last_line = ''; let p2 = popen('tail -1 /tmp/clash_update.txt 2>/dev/null'); if (p2) { last_line = trim(p2.read('all')); p2.close(); } // 收尾行「订阅下载失败:全部链接失败」太笼统,额外捞最近一条带真因的 // 失败行(下载失败 rc=/HTTP、校验失败 格式不符),让前端能显示具体原因 let fail_detail = ''; let p3 = popen("grep -aE '下载失败:.*(rc=|HTTP )|校验失败:' /tmp/clash_update.txt 2>/dev/null | tail -1"); if (p3) { fail_detail = trim(p3.read('all')); p3.close(); } return { running, last_line, fail_detail }; } }, /* ── 简易代理面板:代理 clash API(前端不直接碰 9090 / secret)── */ proxies_list: { call: function() { let data = clash_api_json('/proxies', 6); if (!data || !data.proxies) return { ok: false, reason: 'unreachable' }; let proxies = data.proxies; let switchable = { 'Selector': 1 }; let readonly = { 'URLTest': 1, 'Fallback': 1, 'LoadBalance': 1, 'Relay': 1 }; let groups = []; for (let gname in proxies) { let g = proxies[gname]; if (!g || type(g) != 'object') continue; let gt = g.type || ''; if (!switchable[gt] && !readonly[gt]) continue; if (gname == 'GLOBAL') continue; let members = []; for (let mn in (g.all || [])) { let mp = proxies[mn]; let delay = 0; if (mp && mp.history && length(mp.history) > 0) delay = mp.history[length(mp.history) - 1].delay || 0; push(members, { name: mn, delay: delay }); } push(groups, { name: gname, type: gt, now: g.now || '', selectable: !!switchable[gt], members: members }); } return { ok: true, groups: groups }; } }, /* ── 探测主代理组:供自定义分流规则的「代理」(__PROXY__) 解析用 ── * 取一个可切换的 Selector 组(优先名字像主代理组的,排除 GLOBAL),写入 * clashoo.config.primary_proxy_group,iprules.sh 注入 mihomo 规则时引用。 */ detect_primary_group: { call: function() { let data = clash_api_json('/proxies', 6); if (!data || !data.proxies) return { ok: false, reason: 'unreachable' }; let proxies = data.proxies; let first = ''; let preferred = ''; for (let gname in proxies) { let g = proxies[gname]; if (!g || type(g) != 'object') continue; if ((g.type || '') != 'Selector') continue; if (gname == 'GLOBAL') continue; if (first == '') first = gname; if (preferred == '' && match(gname, /节点选择|代理|Proxy|PROXY|🚀/)) preferred = gname; } let grp = preferred != '' ? preferred : first; if (grp == '') return { ok: false, reason: 'no_selector' }; let c = cursor(); c.set('clashoo', 'config', 'primary_proxy_group', grp); c.commit('clashoo'); return { ok: true, group: grp }; } }, proxy_select: { args: { group: 'group', name: 'name' }, call: function(req) { let group = trim(req.args?.group || ''); let name = trim(req.args?.name || ''); if (!group || !name) return { ok: false, message: '参数缺失' }; let code = clash_api_put_json('/proxies/' + uri_encode(group), { name: name }); if (code == '204' || code == '200') return { ok: true }; if (code == '400') return { ok: false, code: code, message: '该节点不可选(组类型不支持或节点无效)' }; return { ok: false, code: code, message: '切换失败 (HTTP ' + code + ')' }; } }, proxy_delay: { args: { group: 'group' }, call: function(req) { let group = trim(req.args?.group || ''); if (!group) return { ok: false, message: '参数缺失' }; let path = '/group/' + uri_encode(group) + '/delay' + '?url=' + uri_encode('https://www.gstatic.com/generate_204') + '&timeout=3000'; let data = clash_api_json(path, 14); if (!data || type(data) != 'object') return { ok: false, message: '测速失败或超时' }; if (data.message) return { ok: false, message: '' + data.message }; return { ok: true, delays: data }; } }, update_sub: { args: { name: 'name' }, call: function(req) { let name = req.args?.name || ''; if (!name) return { error: 'missing name' }; let rc = system('sh ' + shell_quote(SUBSCRIPTION_UPDATE_SCRIPT) + ' --mihomo ' + shell_quote(name) + ' >/dev/null 2>&1'); if (rc != 0) { return { success: false, code: rc, message: '更新失败: ' + name }; } return { success: true, code: rc, message: '更新完成: ' + name }; } }, update_current_subscription: { call: function() { let c = cursor(); c.load('clashoo'); if (get_core_type() == 'singbox') { let name = c.get('clashoo', 'config', 'singbox_active') || ''; let path = SINGBOX_DIR + '/' + name; if (!name || !access(path + '.url', 'r') || !trim(readfile(path + '.url') || '')) return { success: false, reason: 'not_subscription', message: '当前配置未记录订阅链接' }; let rc = system('sh ' + shell_quote(SUBSCRIPTION_UPDATE_SCRIPT) + ' --singbox ' + shell_quote(name) + ' >/dev/null 2>&1'); return { success: rc == 0, code: rc, message: rc == 0 ? '当前订阅更新完成' : '当前订阅更新失败' }; } let path = c.get('clashoo', 'config', 'use_config') || ''; let slash = rindex(path, '/'); let name = slash >= 0 ? substr(path, slash + 1) : path; let found = false; for (let sub in read_subscriptions()) { if (sub.name == name && sub.url) { found = true; break; } } if (!found) return { success: false, reason: 'not_subscription', message: '当前配置未记录订阅链接' }; let rc = system('sh ' + shell_quote(SUBSCRIPTION_UPDATE_SCRIPT) + ' --mihomo ' + shell_quote(name) + ' >/dev/null 2>&1'); return { success: rc == 0, code: rc, message: rc == 0 ? '当前订阅更新完成' : '当前订阅更新失败' }; } }, subscription_update_all: { call: function() { if (access(SUBSCRIPTION_UPDATE_LOCK_DIR, 'r')) return { success: false, running: true, message: '订阅更新正在运行' }; let rc = system('sh ' + shell_quote(SUBSCRIPTION_UPDATE_SCRIPT) + ' --all >/dev/null 2>&1 &'); return { success: rc == 0, running: rc == 0, message: rc == 0 ? '已开始更新全部订阅' : '启动失败' }; } }, subscription_update_status: { call: function() { return subscription_update_status_data(); } }, set_subscription_update_schedule: { args: { enabled: 'enabled', interval: 'interval' }, call: function(req) { let enabled = req.args?.enabled == '1' ? '1' : '0'; let interval_raw = '' + (req.args?.interval || ''); if (!match(interval_raw, /^[0-9]+$/)) return { success: false, message: '更新间隔必须为数字' }; let interval = int(interval_raw); if (interval < 1 || interval > 8760) return { success: false, message: '更新间隔必须为 1 到 8760 小时' }; let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'auto_subscription_update', enabled); c.set('clashoo', 'config', 'subscription_update_interval', '' + interval); c.commit('clashoo'); system('/etc/init.d/clashoo refresh_cron >/dev/null 2>&1'); return { success: true, enabled: enabled, interval: interval }; } }, apply_template_with_url: { args: { template_source: 'template_source', sub_url: 'sub_url', output_name: 'output_name', set_active: 'set_active' }, call: function(req) { let tpl_src = trim(req.args?.template_source || ''); let sub_url = trim(req.args?.sub_url || ''); let output_name = ensure_yaml_name(req.args?.output_name || ''); let set_active = (req.args?.set_active || '') == '1'; if (!tpl_src) return { success: false, error: 'invalid_template', message: '模板来源不能为空' }; if (!sub_url || !match(sub_url, /^https?:\/\//)) return { success: false, error: 'invalid_sub_url', message: '订阅 URL 无效' }; let tmp_tpl = '/tmp/clashoo_tpl_in.yaml'; let tmp_out = '/tmp/clashoo_tpl_out.yaml'; let out_dir = '/usr/share/clashoo/config/custom'; system('mkdir -p ' + shell_quote(out_dir)); /* 获取模板:远程 URL 或本地文件 */ if (match(tpl_src, /^https?:\/\//)) { let dl_cmd = '(curl -fL --connect-timeout 15 --max-time 60 ' + shell_quote(tpl_src) + ' -o ' + shell_quote(tmp_tpl) + ' || wget -O ' + shell_quote(tmp_tpl) + ' ' + shell_quote(tpl_src) + ') >/dev/null 2>&1'; if (system(dl_cmd) != 0 || !access(tmp_tpl, 'r')) return { success: false, error: 'download_failed', message: '远程模板下载失败' }; } else { let safe = safe_name(tpl_src); let local_path = null; let search_dirs = template_search_dirs(); for (let i = 0; i < length(search_dirs); i++) { if (access(search_dirs[i] + '/' + safe, 'r')) { local_path = search_dirs[i] + '/' + safe; break; } } if (!local_path) return { success: false, error: 'template_not_found', message: '本地模板文件不存在' }; if (system('cp -f ' + shell_quote(local_path) + ' ' + shell_quote(tmp_tpl)) != 0) return { success: false, error: 'copy_failed', message: '读取本地模板失败' }; } /* 用 yq 把所有 proxy-providers 条目的 url 替换为订阅链接 */ if (system('command -v yq >/dev/null 2>&1') != 0) return { success: false, error: 'missing_yq', message: '缺少 yq,无法注入订阅 URL' }; /* 先展开 YAML anchor(<<: *xxx),再注入订阅 URL */ let exploded = tmp_tpl + ".exploded"; system("yq ea \"explode(.)\" " + shell_quote(tmp_tpl) + " > " + shell_quote(exploded) + " 2>/dev/null"); if (!access(exploded, "r")) { system("cp -f " + shell_quote(tmp_tpl) + " " + shell_quote(exploded)); } /* 只替换 proxy-providers.url,rule-providers 指向规则集保持原样 */ let inject_cmd = "URL=" + shell_quote(sub_url) + " yq e '.[\"proxy-providers\"][].url = env(URL)' " + shell_quote(exploded) + " > " + shell_quote(tmp_out); if (system(inject_cmd) != 0 || !access(tmp_out, "r")) { system("rm -f " + shell_quote(exploded)); return { success: false, error: "inject_failed", message: "订阅 URL 注入失败" }; } system("rm -f " + shell_quote(exploded)); if (!output_name) { output_name = 'tpl-rewrite.yaml'; } let out_path = out_dir + '/' + output_name; if (system('mv -f ' + shell_quote(tmp_out) + ' ' + shell_quote(out_path)) != 0) return { success: false, error: 'write_failed', message: '写入结果文件失败' }; /* 删除 respect-rules(模板可能设了但没提供 proxy-server-nameserver) */ system("sed -i '/respect-rules/d' " + shell_quote(out_path) + " 2>/dev/null"); system('rm -f ' + shell_quote(tmp_tpl)); /* 替换 直连→DIRECT(兼容不同模板的命名差异) */ system("sed -i 's/直连/DIRECT/g' " + shell_quote(out_path) + " 2>/dev/null"); system("yq e '(.proxy-groups[].proxies) |= sub(\"直连\", \"DIRECT\")' " + shell_quote(out_path) + " -i 2>/dev/null"); /* 用 mixin.uc + yq merge 叠加 UCI 覆盖(端口、routing-mark、dns 等) */ let mixin_script = "/usr/share/clashoo/runtime/mixin.uc"; if (access(mixin_script, "r") && system("command -v yq >/dev/null 2>&1") == 0) { let mixin_yaml = "/tmp/_clashoo_mixin.yaml"; let merged_out = out_path + ".merged"; system("ucode " + shell_quote(mixin_script) + " 2>/dev/null | yq -M -p json -o yaml > " + shell_quote(mixin_yaml) + " 2>/dev/null"); system("yq -M eval-all '. as $item ireduce ({}; . * $item)' " + shell_quote(out_path) + " " + shell_quote(mixin_yaml) + " > " + shell_quote(merged_out) + " 2>/dev/null"); if (access(merged_out, "r")) { system("mv -f " + shell_quote(merged_out) + " " + shell_quote(out_path)); } system("rm -f " + shell_quote(mixin_yaml)); } /* ── mihomo -t 预校验(搜所有 mihomo 变体)── */ let _core_bin = ''; for (let _cm in ['mihomo', 'clash-meta', 'clash']) { if (system('command -v ' + _cm + ' >/dev/null 2>&1') == 0) { _core_bin = _cm; break; } } if (_core_bin) { let _ret = system(_core_bin + " -t -d /etc/clashoo -f " + shell_quote(out_path) + " 2>/tmp/clashoo_tpl_verify.txt"); if (_ret != 0) { let _err = trim(readfile('/tmp/clashoo_tpl_verify.txt') || '配置校验失败'); system('rm -f ' + shell_quote(out_path) + ' /tmp/clashoo_tpl_verify.txt 2>/dev/null'); return { success: false, error: 'validation_failed', message: '模板校验失败: ' + _err }; } system('rm -f /tmp/clashoo_tpl_verify.txt 2>/dev/null'); } if (set_active) { let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'use_config', out_path); c.set('clashoo', 'config', 'config_type', '3'); c.commit('clashoo'); if (is_running()) system('service ' + get_service_name() + ' restart >/dev/null 2>&1'); } return { success: true, output_name, output_path: out_path, message: '模板已应用: ' + output_name }; } }, apply_rewrite: { args: { base_type: 'base_type', base_name: 'base_name', rewrite_type: 'rewrite_type', rewrite_name: 'rewrite_name', output_name: 'output_name', set_active: 'set_active' }, call: function(req) { let base_type = req.args?.base_type || ''; let base_name = safe_name(req.args?.base_name || ''); let rewrite_type = req.args?.rewrite_type || ''; let rewrite_name = safe_name(req.args?.rewrite_name || ''); let output_name = ensure_yaml_name(req.args?.output_name || ''); let set_active = (req.args?.set_active || '') == '1'; let base_dir = config_dir_by_type(base_type); let rewrite_dir = config_dir_by_type(rewrite_type); if (!base_dir || !base_name) return { success: false, error: 'invalid_base', message: '主配置无效' }; if (!rewrite_dir || !rewrite_name) return { success: false, error: 'invalid_rewrite', message: '复写配置无效' }; let base_path = base_dir + '/' + base_name; let rewrite_path = rewrite_dir + '/' + rewrite_name; if (!access(base_path, 'r')) return { success: false, error: 'base_not_found', message: '主配置文件不存在' }; if (!access(rewrite_path, 'r')) return { success: false, error: 'rewrite_not_found', message: '复写配置文件不存在' }; if (!output_name) { let stem = replace(base_name, /\.(yaml|yml)$/, ''); output_name = stem + '_rewrite.yaml'; } let out_dir = '/usr/share/clashoo/config/custom'; let out_path = out_dir + '/' + output_name; let tmp_path = '/tmp/clash_rewrite_merged.yaml'; system('mkdir -p ' + shell_quote(out_dir)); /* Template mode: when rewrite file comes from custom dir, always prefer template merge workflow. */ if (rewrite_type == '3' || rewrite_type == 'custom' || rewrite_dir == '/usr/share/clashoo/config/custom') { let tcmd = 'sh /usr/share/clashoo/update/template_merge.sh ' + shell_quote(base_path) + ' ' + shell_quote(rewrite_path) + ' ' + shell_quote(out_path) + ' >/dev/null 2>&1'; if (system(tcmd) != 0) return { success: false, error: 'merge_failed', message: '模板复写失败,请检查模板内容与订阅格式' }; } else { if (system('command -v yq >/dev/null 2>&1') != 0) return { success: false, error: 'missing_yq', message: '缺少 yq,无法执行复写' }; let cmd = "yq eval-all '(select(fileIndex == 0) | explode(.)) * (select(fileIndex == 1) | explode(.))' " + shell_quote(base_path) + ' ' + shell_quote(rewrite_path) + ' > ' + shell_quote(tmp_path); if (system(cmd) != 0 || !access(tmp_path, 'r')) return { success: false, error: 'merge_failed', message: '复写合并失败,请检查 YAML 格式' }; if (system('yq e \'.\' ' + shell_quote(tmp_path) + ' >/dev/null 2>&1') != 0) { system('rm -f ' + shell_quote(tmp_path)); return { success: false, error: 'invalid_yaml', message: '复写结果 YAML 无效,请检查锚点/别名配置' }; } let binary = find_binary(); if (binary && system(shell_quote(binary) + ' -t -f ' + shell_quote(tmp_path) + ' >/dev/null 2>&1') != 0) { system('rm -f ' + shell_quote(tmp_path)); return { success: false, error: 'invalid_runtime_yaml', message: '复写结果与内核不兼容,请检查复写内容' }; } if (system('mv -f ' + shell_quote(tmp_path) + ' ' + shell_quote(out_path)) != 0) return { success: false, error: 'write_failed', message: '写入复写文件失败' }; } if (set_active) { let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'use_config', out_path); c.set('clashoo', 'config', 'config_type', '3'); c.commit('clashoo'); if (is_running()) system('service ' + get_service_name() + ' restart >/dev/null 2>&1'); } return { success: true, output_name, output_path: out_path, message: '复写成功: ' + output_name }; } }, fetch_rewrite_url: { args: { url: 'url', name: 'name' }, call: function(req) { let url = trim(req.args?.url || ''); let name = ensure_yaml_name(req.args?.name || ''); if (!url || !match(url, /^https?:\/\//)) return { success: false, error: 'invalid_url', message: '无效的 URL' }; if (!name) name = file_name_from_url(url); if (!name) name = 'rewrite-remote.yaml'; let out_dir = '/usr/share/clashoo/config/custom'; let out_path = out_dir + '/' + name; let tmp_path = '/tmp/rewrite_remote.yaml'; system('mkdir -p ' + shell_quote(out_dir)); let dl_cmd = '(curl -fL --connect-timeout 15 --max-time 90 ' + shell_quote(url) + ' -o ' + shell_quote(tmp_path) + ' || wget -O ' + shell_quote(tmp_path) + ' ' + shell_quote(url) + ') >/dev/null 2>&1'; if (system(dl_cmd) != 0 || !access(tmp_path, 'r')) return { success: false, error: 'download_failed', message: '远程复写文件下载失败' }; if (system('yq e \'.\' ' + shell_quote(tmp_path) + ' >/dev/null 2>&1') != 0) { system('rm -f ' + shell_quote(tmp_path)); return { success: false, error: 'invalid_yaml', message: '远程复写文件 YAML 格式无效' }; } if (system('mv -f ' + shell_quote(tmp_path) + ' ' + shell_quote(out_path)) != 0) return { success: false, error: 'write_failed', message: '保存远程复写文件失败' }; return { success: true, name, path: out_path, message: '远程复写文件已保存: ' + name }; } }, read_other_config: { args: { name: 'name', type: 'type' }, call: function(req) { let name = safe_name(req.args?.name || ''); let type = req.args?.type || '2'; if (!name) return { error: 'invalid name' }; let dir = config_dir_by_type(type); if (!dir) return { error: 'invalid type' }; let path = dir + '/' + name; if (!access(path, 'r')) return { error: 'not found' }; let s = stat(path); if (s && s.size > 524288) return { error: 'too large' }; return { content: readfile(path) || '', name }; } }, upload_config: { args: { name: 'name', content: 'content', type: 'type' }, call: function(req) { let name = safe_name(ensure_yaml_name(req.args?.name || '')); let content = req.args?.content || ''; let type = req.args?.type || '2'; if (!name) return { success: false, error: 'invalid_name', message: '文件名无效' }; if (!content) return { success: false, error: 'empty_content', message: '文件内容为空' }; let dir = config_dir_by_type(type); if (!dir) dir = '/usr/share/clashoo/config/upload'; system('mkdir -p ' + shell_quote(dir)); let path = dir + '/' + name; if (writefile(path, content) === null) return { success: false, error: 'write_failed', message: '写入文件失败' }; return { success: true, name, path }; } }, upload_config_chunk: { args: { name: 'name', content: 'content', type: 'type', index: 'index', total: 'total' }, call: function(req) { let name = safe_name(ensure_yaml_name(req.args?.name || '')); let content = req.args?.content; let cfg_type = req.args?.type || '2'; let index = int(req.args?.index || 0); let total = int(req.args?.total || 0); if (!name) return { success: false, error: 'invalid_name', message: '文件名无效' }; if (type(content) != 'string') return { success: false, error: 'invalid_content', message: '文件内容无效' }; if (total < 1 || index < 0 || index >= total) return { success: false, error: 'invalid_chunk', message: '上传分片无效' }; if (total == 1 && !content) return { success: false, error: 'empty_content', message: '文件内容为空' }; let dir = config_dir_by_type(cfg_type); if (!dir) dir = '/usr/share/clashoo/config/upload'; system('mkdir -p ' + shell_quote(dir)); let path = dir + '/' + name; if (index == 0) { if (writefile(path, content) === null) return { success: false, error: 'write_failed', message: '写入文件失败' }; } else { let fp = open(path, 'a'); if (!fp) return { success: false, error: 'write_failed', message: '打开文件失败' }; let ok = fp.write(content); fp.close(); if (ok === null) return { success: false, error: 'write_failed', message: '写入文件失败' }; } return { success: true, name, path, index, total, complete: index + 1 >= total }; } }, upload_template: { args: { name: 'name', content: 'content' }, call: function(req) { let name = safe_name(ensure_yaml_name(req.args?.name || '')); let content = req.args?.content || ''; if (!name) return { success: false, error: 'invalid_name', message: '模板文件名无效' }; if (!content) return { success: false, error: 'empty_content', message: '模板内容为空' }; system('mkdir -p ' + shell_quote(TEMPLATE_USER_DIR) + ' ' + shell_quote(TEMPLATE_DIR)); let user_path = TEMPLATE_USER_DIR + '/' + name; if (writefile(user_path, content) === null) return { success: false, error: 'write_failed', message: '写入模板失败' }; let custom_path = TEMPLATE_DIR + '/' + name; if (writefile(custom_path, content) === null) return { success: false, error: 'mirror_failed', message: '写入模板镜像失败' }; return { success: true, name, path: user_path, mirror: custom_path }; } }, smart_flush_cache: { call: function() { let code = mihomo_api_post('/cache/smart/flush'); return { success: (code == '204' || code == '200'), code }; } }, /* ── sing-box profile management ── */ list_singbox_profiles: { call: function() { let dir = '/usr/share/clashoo/config/singbox'; system('mkdir -p ' + shell_quote(dir) + ' >/dev/null 2>&1'); let active = uci_get('clashoo', 'config', 'singbox_active') || ''; // 把 "211.98 GB" / "1000 MB" 之类解析成字节数;返回 0 表示无法识别 let parse_size_bytes = function(num_str, unit_str) { let n = +num_str; if (n != n) return 0; // NaN let u = uc(unit_str || ''); let mul = 1; if (u == 'KB' || u == 'K') mul = 1024; else if (u == 'MB' || u == 'M') mul = 1024 * 1024; else if (u == 'GB' || u == 'G') mul = 1024 * 1024 * 1024; else if (u == 'TB' || u == 'T') mul = 1024 * 1024 * 1024 * 1024; return int(n * mul); }; // busybox date 把 yyyy-mm-dd 转 unix ts;失败返回 0 let ymd_to_ts = function(s) { let p = popen('date -D %Y-%m-%d -d ' + shell_quote(s) + ' +%s 2>/dev/null'); if (!p) return 0; let v = trim(p.read('all')); p.close(); let n = +v; return (n == n && n > 0) ? int(n) : 0; }; // 优先读 .info 文件(Subscription-Userinfo 响应头),其次从 outbound tag 提取 // 常见 tag:"Traffic: 211.98 GB | 1000 GB"、"Expire: 2026-08-15" let extract_meta = function(path) { // Primary: .info file saved during download (same format as mihomo) let info_path = path + '.info'; if (access(info_path, 'r')) { let raw_info = trim(readfile(info_path) || ''); if (raw_info) { let ui = parse_sub_userinfo(raw_info); let r = {}; if (ui.total) { r.sub_total = ui.total; r.sub_used = ui.upload + ui.download; } if (ui.expire) r.sub_expire = ui.expire; if (r.sub_total || r.sub_expire) return r; } } // Fallback: parse from outbound tags in JSON let st = stat(path); if (!st || st.size > 524288) return {}; let raw = readfile(path); if (!raw) return {}; let obj = json(raw); if (!obj || type(obj.outbounds) != 'array') return {}; let used = 0, total = 0, expire = 0; for (let ob in obj.outbounds) { let tag = (ob && ob.tag) ? ob.tag : ''; if (!tag) continue; if (!total) { let m = match(tag, /Traffic[::]\s*([0-9.]+)\s*([KMGT]?B)\s*[|\/]\s*([0-9.]+)\s*([KMGT]?B)/); if (m) { used = parse_size_bytes(m[1], m[2]); total = parse_size_bytes(m[3], m[4]); } } if (!expire) { let m2 = match(tag, /Expire[::]\s*([0-9]{4}-[0-9]{1,2}-[0-9]{1,2})/); if (m2) expire = ymd_to_ts(m2[1]); } if (total && expire) break; } let r = {}; if (total) { r.sub_total = total; r.sub_used = used; } if (expire) r.sub_expire = expire; return r; }; let files = []; for (let f in (glob(dir + '/*.json') || [])) { let name = replace(f, dir + '/', ''); let fi = stat(f); let entry = { name, mtime: fi ? ('' + fi.mtime) : null, size: fi ? (int(fi.size / 1024) + ' KB') : null, active: name == active }; let meta = extract_meta(f); for (let k in meta) entry[k] = meta[k]; let url_path = f + '.url'; if (access(url_path, 'r')) { entry.sub_url = trim(readfile(url_path) || ''); entry.source = 'native'; } else { entry.source = 'local'; } push(files, entry); } return { profiles: files, active }; } }, get_singbox_profile: { args: { name: 'name' }, call: function(req) { let name = req.args?.name || ''; if (!name || index(name, '/') >= 0 || index(name, '..') >= 0) return { error: 'invalid name' }; let path = '/usr/share/clashoo/config/singbox/' + name; if (!access(path, 'r')) return { error: 'not found' }; let s = stat(path); if (s && s.size > 524288) return { error: 'too large' }; return { content: readfile(path) || '', name }; } }, save_singbox_profile: { args: { name: 'name', content: 'content' }, call: function(req) { let name = req.args?.name || ''; let content = req.args?.content || ''; if (!name || index(name, '/') >= 0 || index(name, '..') >= 0) return { success: false, error: 'invalid name' }; if (!content) return { success: false, error: 'empty content' }; if (!match(name, /\.json$/)) name = name + '.json'; let dir = '/usr/share/clashoo/config/singbox'; system('mkdir -p ' + shell_quote(dir) + ' >/dev/null 2>&1'); let tmp = '/tmp/clashoo_sb_validate.json'; writefile(tmp, content); let binary = find_binary(); if (binary && match(binary, /sing-box/)) { if (system(shell_quote(binary) + ' check -c ' + shell_quote(tmp) + ' >/dev/null 2>&1') != 0) { system('rm -f ' + shell_quote(tmp)); return { success: false, error: 'invalid_config', message: 'sing-box 配置验证失败,请检查 JSON 格式' }; } } system('rm -f ' + shell_quote(tmp)); if (writefile(dir + '/' + name, content) === null) return { success: false, error: 'write_failed', message: '写入文件失败' }; return { success: true, name }; } }, save_singbox_profile_chunk: { args: { name: 'name', content: 'content', index: 'index', total: 'total' }, call: function(req) { let name = req.args?.name || ''; let content = req.args?.content; let idx = int(req.args?.index || 0); let total = int(req.args?.total || 0); if (!name || index(name, '/') >= 0 || index(name, '..') >= 0) return { success: false, error: 'invalid name' }; if (!match(name, /\.json$/)) name = name + '.json'; if (type(content) != 'string') return { success: false, error: 'invalid content' }; if (total < 1 || idx < 0 || idx >= total) return { success: false, error: 'invalid chunk' }; if (total == 1 && !content) return { success: false, error: 'empty content' }; let dir = '/usr/share/clashoo/config/singbox'; system('mkdir -p ' + shell_quote(dir) + ' >/dev/null 2>&1'); let tmp = '/tmp/clashoo_sb_upload_' + name; if (idx == 0) { if (writefile(tmp, content) === null) return { success: false, error: 'write_failed', message: '写入文件失败' }; } else { let fp = open(tmp, 'a'); if (!fp) return { success: false, error: 'write_failed', message: '打开文件失败' }; let ok = fp.write(content); fp.close(); if (ok === null) return { success: false, error: 'write_failed', message: '写入文件失败' }; } if (idx + 1 < total) return { success: true, name, index: idx, total, complete: false }; let binary = find_binary(); if (binary && match(binary, /sing-box/)) { if (system(shell_quote(binary) + ' check -c ' + shell_quote(tmp) + ' >/dev/null 2>&1') != 0) { system('rm -f ' + shell_quote(tmp)); return { success: false, error: 'invalid_config', message: 'sing-box 配置验证失败,请检查 JSON 格式' }; } } if (system('cp -f ' + shell_quote(tmp) + ' ' + shell_quote(dir + '/' + name) + ' >/dev/null 2>&1') != 0) { system('rm -f ' + shell_quote(tmp)); return { success: false, error: 'write_failed', message: '写入文件失败' }; } system('rm -f ' + shell_quote(tmp)); return { success: true, name, index: idx, total, complete: true }; } }, set_singbox_profile: { args: { name: 'name' }, call: function(req) { let name = req.args?.name || ''; if (!name || index(name, '/') >= 0 || index(name, '..') >= 0) return { success: false, error: 'invalid name' }; let src = '/usr/share/clashoo/config/singbox/' + name; if (!access(src, 'r')) return { success: false, error: 'not found' }; system('mkdir -p /etc/sing-box >/dev/null 2>&1'); if (system('cp -f ' + shell_quote(src) + ' /etc/sing-box/config.json >/dev/null 2>&1') != 0) return { success: false, error: 'copy_failed', message: '无法写入 /etc/sing-box/config.json' }; let c = cursor(); c.load('clashoo'); c.set('clashoo', 'config', 'core_type', 'singbox'); c.set('clashoo', 'config', 'singbox_active', name); c.set('clashoo', 'config', 'enable', '1'); sync_legacy_core_fields(c); c.commit('clashoo'); let prepared = prepare_singbox_runtime(); if (!prepared.success) return { success: false, error: 'prepare_failed', message: prepared.message || '无法准备 sing-box 运行环境' }; if (is_running()) system('sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1'); return { success: true }; } }, delete_singbox_profile: { args: { name: 'name' }, call: function(req) { let name = req.args?.name || ''; if (!name || index(name, '/') >= 0 || index(name, '..') >= 0) return { success: false, error: 'invalid name' }; let path = '/usr/share/clashoo/config/singbox/' + name; if (!access(path, 'r')) return { success: false, error: 'not found' }; if (unlink(path) === null) return { success: false, error: 'delete_failed' }; unlink(path + '.info'); unlink(path + '.url'); unlink(path + '.ua'); let c = cursor(); c.load('clashoo'); if (c.get('clashoo', 'config', 'singbox_active') == name) { c.delete('clashoo', 'config', 'singbox_active'); c.commit('clashoo'); } return { success: true }; } }, fetch_singbox_native: { args: { url: 'url', name: 'name' }, call: function(req) { let sub_url = trim(req.args?.url || ''); let name = trim(req.args?.name || ''); if (!sub_url || !match(sub_url, /^https?:\/\//)) return { success: false, message: '请输入有效的订阅链接(须以 http:// 或 https:// 开头)' }; if (!name) name = 'singbox-native'; name = replace(name, /\.json$/, ''); name = replace(name, /[^A-Za-z0-9_.一-龥-]/g, '-'); name = replace(name, /-+/g, '-'); name = replace(name, /^[-.]|[-.]$/g, '') || 'singbox-native'; let dir = '/usr/share/clashoo/config/singbox'; system('mkdir -p ' + shell_quote(dir) + ' >/dev/null 2>&1'); let final_name = name + '.json'; let path = dir + '/' + final_name; let tmp_json = '/tmp/clashoo_sb_native.json'; let tmp_hdr = '/tmp/clashoo_sb_native.hdr'; system('rm -f ' + shell_quote(tmp_json) + ' ' + shell_quote(tmp_hdr) + ' >/dev/null 2>&1'); let sub_ua = uci_get('clashoo', 'config', 'sub_ua') || 'clash.meta'; let dl_cmd = 'curl -fsSL --connect-timeout 8 --max-time 20' + ' -D ' + shell_quote(tmp_hdr) + ' -A ' + shell_quote(sub_ua) + ' ' + shell_quote(sub_url) + ' -o ' + shell_quote(tmp_json) + ' >/dev/null 2>&1'; if (system(dl_cmd) != 0 || !access(tmp_json, 'r')) { system('rm -f ' + shell_quote(tmp_json) + ' ' + shell_quote(tmp_hdr) + ' >/dev/null 2>&1'); return { success: false, message: '下载失败,请检查链接是否可访问' }; } let raw = readfile(tmp_json) || ''; if (length(raw) < 10 || substr(trim(raw), 0, 1) !== '{') { system('rm -f ' + shell_quote(tmp_json) + ' ' + shell_quote(tmp_hdr) + ' >/dev/null 2>&1'); return { success: false, message: '下载内容不是 JSON,该链接可能不返回原生 sing-box 配置' }; } let cfg = json(raw); if (!cfg || type(cfg) !== 'object') { system('rm -f ' + shell_quote(tmp_json) + ' ' + shell_quote(tmp_hdr) + ' >/dev/null 2>&1'); return { success: false, message: 'JSON 解析失败' }; } if (type(cfg.outbounds) !== 'array' || length(cfg.outbounds) == 0) { system('rm -f ' + shell_quote(tmp_json) + ' ' + shell_quote(tmp_hdr) + ' >/dev/null 2>&1'); return { success: false, message: '配置缺少 outbounds 字段,可能不是原生 sing-box 格式' }; } if (writefile(path, raw) === null) { system('rm -f ' + shell_quote(tmp_json) + ' ' + shell_quote(tmp_hdr) + ' >/dev/null 2>&1'); return { success: false, message: '保存失败' }; } writefile(path + '.url', sub_url); writefile(path + '.ua', sub_ua); if (access(tmp_hdr, 'r')) { let hdr_raw = readfile(tmp_hdr) || ''; for (let line in split(hdr_raw, '\n')) { let ll = lc(trim(line)); if (substr(ll, 0, 22) == 'subscription-userinfo:') { let info_val = trim(substr(line, 22)); if (info_val) writefile(path + '.info', info_val); break; } } } system('rm -f ' + shell_quote(tmp_json) + ' ' + shell_quote(tmp_hdr) + ' >/dev/null 2>&1'); return { success: true, name: final_name, message: '已拉取 ' + final_name }; } }, update_singbox_native: { args: { name: 'name' }, call: function(req) { let name = req.args?.name || ''; if (!name || index(name, '/') >= 0 || index(name, '..') >= 0) return { success: false, message: 'invalid name' }; let rc = system('sh ' + shell_quote(SUBSCRIPTION_UPDATE_SCRIPT) + ' --singbox ' + shell_quote(name) + ' >/dev/null 2>&1'); return { success: rc == 0, code: rc, message: rc == 0 ? name + ' 已更新' : name + ' 更新失败' }; } }, migrate_singbox_profile: { args: { name: 'name' }, call: function(req) { let name = req.args?.name || ''; if (!name || index(name, '/') >= 0 || index(name, '..') >= 0) return { success: false, error: 'invalid name' }; let path = '/usr/share/clashoo/config/singbox/' + name; if (!access(path, 'r')) return { success: false, error: 'not found' }; let raw = readfile(path); if (!raw) return { success: false, error: 'read_failed' }; let cfg = json(raw); if (!cfg) return { success: false, message: 'JSON 解析失败' }; let before = sprintf('%J', cfg); cfg = migrate_singbox(cfg); let after = sprintf('%J', cfg); /* 无变化 */ if (before === after) return { success: true, changes: [] }; /* 写回 */ if (writefile(path, after) === null) return { success: false, message: '写入文件失败' }; /* 如果是激活配置,同步到运行时 */ let active = uci_get('clashoo', 'config', 'singbox_active') || ''; if (active === name) system('cp -f ' + shell_quote(path) + ' /etc/sing-box/config.json >/dev/null 2>&1'); return { success: true, changes: ['deprecated fields removed/updated'] }; } }, create_singbox_config: { args: { sub_url: 'sub_url', name: 'name' }, call: function(req) { let sub_url = trim(req.args?.sub_url || ''); let name = trim(req.args?.name || ''); // dashboard 字段(port/secret)由 normalize_singbox_config.uc 在启动时强制覆盖为 UCI 值, // 这里写入仅作占位,无实际生效途径,因此直接读 UCI 即可。 let secret = uci_get('clashoo', 'config', 'dash_pass') || ''; let ext_port = uci_get('clashoo', 'config', 'dash_port') || '9090'; if (!sub_url || !match(sub_url, /^https?:\/\//)) return { success: false, error: 'invalid_url', message: '请输入有效的订阅链接' }; if (!name) name = 'singbox'; name = replace(name, /\.json$/, ''); name = replace(name, /[^A-Za-z0-9_.\u4e00-\u9fa5-]/g, '-'); name = replace(name, /-+/g, '-'); name = replace(name, /^[-.]|[-.]$/g, '') || 'singbox'; let dir = '/usr/share/clashoo/config/singbox'; system('mkdir -p ' + shell_quote(dir) + ' >/dev/null 2>&1'); let final_name = name + '.json'; // Write sub_url to temp file to avoid shell injection in curl call let tmp_url = '/tmp/.clashoo-sb-url'; writefile(tmp_url, sub_url); let config_obj = null; let downloaded_yaml = ''; let preferred_yaml = uci_get('clashoo', 'config', 'use_config') || ''; let prefer_template = (preferred_yaml && access(preferred_yaml, 'r') && match(preferred_yaml, /\.(yaml|yml)$/)) || match(sub_url, /flag=Clash/i) || match(sub_url, /format=clash/i); function has_proxy_outbounds(obj) { if (!obj || type(obj) !== 'object' || type(obj.outbounds) !== 'array') return false; for (let ob in obj.outbounds) { let t = ob?.type || ''; if (t && t != 'selector' && t != 'urltest' && t != 'direct' && t != 'block' && t != 'dns') return true; } return false; } // Step 1: Try URL variants that might return native sing-box JSON // Many providers support flag=SingBox / format=singbox even if the URL has flag=Clash let try_urls = [sub_url]; // If URL has a Clash flag, also try the sing-box variant if (match(sub_url, /flag=Clash/i)) push(try_urls, replace(sub_url, /flag=Clash/i, 'flag=SingBox')); else if (match(sub_url, /format=clash/i)) push(try_urls, replace(sub_url, /format=clash/i, 'format=singbox')); if (!prefer_template) { let probe_ua = uci_get('clashoo', 'config', 'sub_ua') || 'clash.meta'; for (let try_url in try_urls) { let p0 = popen('curl -sL --max-time 12 -A ' + shell_quote(probe_ua) + ' ' + shell_quote(try_url) + ' 2>/dev/null'); let raw0 = p0 ? trim(p0.read('all')) : ''; if (p0) p0.close(); if (length(raw0) > 50 && substr(raw0, 0, 1) === '{') { let obj = json(raw0); if (has_proxy_outbounds(obj)) { config_obj = obj; break; } } } } // Step 2 (本地最快路径): 把 yaml 喂给 yaml2singbox,避免外部 subconverter 30s × 3 // 把它前置后,常规 yaml 订阅在 5s 内就生成完,不会触发 LuCI RPC ~30s 超时。 if (!config_obj && system('command -v ucode >/dev/null 2>&1') == 0 && access('/usr/share/clashoo/lib/yaml2singbox.uc', 'r') && access('/usr/share/clashoo/lib/templates/default.json', 'r')) { let yaml_src = ''; if (!(yaml_src && access(yaml_src, 'r') && match(yaml_src, /\.(yaml|yml)$/))) { let tmp_yaml = '/tmp/clashoo_sb_sub.yaml'; let tmp_hdr = '/tmp/clashoo_sb_sub.hdr'; system('rm -f ' + shell_quote(tmp_hdr) + ' >/dev/null 2>&1'); let conv_ua = uci_get('clashoo', 'config', 'sub_ua') || 'clash.meta'; let dl_cmd = 'curl -fsSL --connect-timeout 8 --max-time 20 -D ' + shell_quote(tmp_hdr) + ' -A ' + shell_quote(conv_ua) + ' ' + shell_quote(sub_url) + ' -o ' + shell_quote(tmp_yaml) + ' >/dev/null 2>&1 || wget -q --user-agent=' + shell_quote(conv_ua) + ' -O ' + shell_quote(tmp_yaml) + ' ' + shell_quote(sub_url) + ' >/dev/null 2>&1'; if (system(dl_cmd) == 0 && access(tmp_yaml, 'r')) { yaml_src = tmp_yaml; downloaded_yaml = tmp_yaml; } } if (yaml_src && access(yaml_src, 'r')) { let tmp_json = '/tmp/clashoo_sb_from_yaml.json'; let cv_cmd = 'ucode /usr/share/clashoo/lib/yaml2singbox.uc ' + shell_quote(yaml_src) + ' ' + shell_quote('/usr/share/clashoo/lib/templates/default.json') + ' ' + shell_quote(tmp_json) + ' >/tmp/clashoo_yaml2sb.log 2>&1'; if (system(cv_cmd) == 0 && access(tmp_json, 'r')) { let raw3 = readfile(tmp_json); let obj3 = raw3 ? json(raw3) : null; if (has_proxy_outbounds(obj3)) config_obj = obj3; } system('rm -f ' + shell_quote(tmp_json)); } } // Step 3: 本地 yaml2singbox 失败再走外部 subconverter(base64 ss/vmess 链接列表 // yaml2singbox 不识别,要靠外部转换器)。每个 max-time 12s,最坏 36s 兜底。 if (!config_obj) { let converters = [ ['https://sub.kidsqq.cn/singbox?selectedRules=%5B%22Location%3ACN%22%2C%22Private%22%2C%22Non-China%22%2C%22Github%22%2C%22Google%22%2C%22Youtube%22%2C%22AI%2BServices%22%2C%22Telegram%22%2C%22Ad%2BBlock%22%2C%22Streaming%22%2C%22Apple%22%2C%22Social%2BMedia%22%5D&customRules=%5B%5D&group_by_country=true', 'config'], ['https://sub.xeton.dev/sub?target=singbox', 'url'], ['https://api.v1.mk/sub?target=singbox', 'url'], ]; for (let cv in converters) { let base = cv[0], param = cv[1]; let p = popen('curl -sL --max-time 12 -G --data-urlencode ' + shell_quote(param + '@' + tmp_url) + ' ' + shell_quote(base) + ' 2>/dev/null'); let raw = p ? trim(p.read('all')) : ''; if (p) p.close(); if (length(raw) < 50 || substr(raw, 0, 1) !== '{') continue; let obj = json(raw); if (has_proxy_outbounds(obj)) { config_obj = obj; break; } } } system('rm -f ' + shell_quote(tmp_url)); if (downloaded_yaml) system('rm -f ' + shell_quote(downloaded_yaml)); let content; let from_converter = false; if (config_obj) { from_converter = true; // Migrate deprecated fields to sing-box 1.12+ format config_obj = migrate_singbox(config_obj); // Patch experimental.clash_api into the converted config if (!config_obj.experimental) config_obj.experimental = {}; config_obj.experimental.clash_api = { external_controller: '0.0.0.0:' + ext_port, external_ui: '/etc/clashoo/dashboard', secret: secret }; // cache_file disabled - can cause "timeout" on first run without pre-existing cache // config_obj.experimental.cache_file = { enabled: true }; if (!config_obj.log) config_obj.log = { level: 'info', timestamp: true }; // OpenWrt owns time sync; avoid sing-box NTP IPv6 noise on IPv4-only routers. config_obj.ntp = config_obj.ntp || {}; config_obj.ntp.enabled = false; // Fix rule_set URLs: rewrite to jsDelivr CDN (accessible from China) // Pattern: https://[gh-proxy.com/]https://github.com/{owner}/{repo}/raw/refs/heads/{branch}/{path} // → https://cdn.jsdelivr.net/gh/{owner}/{repo}@{branch}/{path} if (config_obj.route && config_obj.route.rule_set) { config_obj.route.rule_set = map(config_obj.route.rule_set, function(rs) { if (rs.type !== 'remote' || !rs.url) return rs; let url = rs.url; // Strip gh-proxy.com wrapper if present url = replace(url, /^https:\/\/gh-proxy\.com\//, ''); // Convert raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} → jsDelivr let m = match(url, /^https:\/\/raw\.githubusercontent\.com\/([^\/]+)\/([^\/]+)\/([^\/]+)\/(.+)$/); if (m) { url = 'https://cdn.jsdelivr.net/gh/' + m[1] + '/' + m[2] + '@' + m[3] + '/' + m[4]; } // Convert github.com/{owner}/{repo}/raw/refs/heads/{branch}/{path} → jsDelivr if (!m) { m = match(url, /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/raw\/refs\/heads\/([^\/]+)\/(.+)$/); if (m) { url = 'https://cdn.jsdelivr.net/gh/' + m[1] + '/' + m[2] + '@' + m[3] + '/' + m[4]; } } if (url === rs.url) return rs; let r = {}; for (let k in rs) r[k] = rs[k]; r.url = url; return r; }); } content = sprintf('%J\n', config_obj); } else { // Fallback: generate a skeleton sing-box config (no proxies) // User should upload a proper sing-box JSON config instead function je(s) { s = replace(s || '', /\\/g, '\\\\'); s = replace(s, /"/g, '\\"'); return s; } // 没拿到任何代理节点时给一个最小可启动骨架: // 不依赖任何远程 rule_set(路由器纯净环境 DIRECT 拉 jsdelivr 会卡 → FATAL), // 等用户在 LuCI 里替换/上传带节点的配置后再启用规则。 content = '{\n' + ' "log": { "level": "info", "timestamp": true },\n' + ' "_note": "subconverter unavailable - replace outbounds with your proxies",\n' + ' "_sub_url": "' + je(sub_url) + '",\n' + ' "dns": {\n' + ' "servers": [\n' + ' { "tag": "remote", "type": "https", "server": "1.1.1.1" },\n' + ' { "tag": "local", "type": "udp", "server": "223.5.5.5" }\n' + ' ],\n' + ' "rules": [\n' + ' { "clash_mode": "Global", "action": "route", "server": "remote" },\n' + ' { "clash_mode": "Direct", "action": "route", "server": "local" }\n' + ' ],\n' + ' "final": "local"\n' + ' },\n' + ' "inbounds": [\n' + ' { "type": "tun", "tag": "tun-in",\n' + ' "address": ["172.19.0.1/30", "fdfe:dcba:9876::1/126"],\n' + ' "auto_route": true, "strict_route": true }\n' + ' ],\n' + ' "outbounds": [\n' + ' { "type": "direct", "tag": "DIRECT" }\n' + ' ],\n' + ' "route": {\n' + ' "default_domain_resolver": { "server": "local" },\n' + ' "rules": [\n' + ' { "action": "sniff" },\n' + ' { "protocol": "dns", "action": "hijack-dns" },\n' + ' { "clash_mode": "Direct", "outbound": "DIRECT" }\n' + ' ],\n' + ' "final": "DIRECT",\n' + ' "auto_detect_interface": true\n' + ' },\n' + ' "experimental": {\n' + ' "clash_api": {\n' + ' "external_controller": "0.0.0.0:' + je(ext_port) + '",\n' + ' "external_ui": "/etc/clashoo/dashboard",\n' + ' "secret": "' + je(secret) + '"\n' + ' },\n' + ' "cache_file": { "enabled": true, "store_fakeip": true }\n' + ' }\n' + '}\n'; } let path = dir + '/' + final_name; if (writefile(path, content) === null) return { success: false, error: 'write_failed', message: '写入配置文件失败' }; // Save Subscription-Userinfo from captured HTTP headers as .json.info let sb_hdr_file = '/tmp/clashoo_sb_sub.hdr'; if (access(sb_hdr_file, 'r')) { let hdr_raw = readfile(sb_hdr_file) || ''; for (let line in split(hdr_raw, '\n')) { let ll = lc(trim(line)); if (substr(ll, 0, 22) == 'subscription-userinfo:') { let info_val = trim(substr(line, 22)); if (info_val) writefile(path + '.info', info_val); break; } } system('rm -f ' + shell_quote(sb_hdr_file) + ' >/dev/null 2>&1'); } let msg = from_converter ? '配置已从订阅生成: ' + final_name : '订阅转换失败,已生成基础模板(请手动添加代理): ' + final_name; return { success: true, name: final_name, path, message: msg }; } }, /* 备份导出:把 UCI 配置 + 各类配置文件打包成一个 JSON 对象返回。 仅含纯文本,前端存成 clashoo-backup-*.json。内核/GeoIP 体积大且可重下,不备。 */ backup_export: { call: function() { try { let BACKUP_DIR_MAP = { 'config/sub': '/usr/share/clashoo/config/sub', 'config/upload': '/usr/share/clashoo/config/upload', 'config/custom': '/usr/share/clashoo/config/custom', 'config/singbox': SINGBOX_DIR, 'templates': TEMPLATE_USER_DIR }; let BACKUP_FIXED_MAP = { 'uci': '/etc/config/clashoo', 'meta/confit_list.conf': LIST_FILE, 'meta/template_bindings.conf': TEMPLATE_BIND_FILE }; let MAX_TOTAL = 480 * 1024; let files = {}; let total = 0; let add_file = function(rel, path) { if (!access(path, 'r')) return true; let s = stat(path); if (s && s.type != 'file') return true; let content = readfile(path); if (content === null) return true; total += length(content); if (total > MAX_TOTAL) return false; files[rel] = content; return true; }; for (let rel in BACKUP_FIXED_MAP) if (!add_file(rel, BACKUP_FIXED_MAP[rel])) return { success: false, message: '备份内容过大(超过 480KB)' }; for (let prefix in BACKUP_DIR_MAP) { let d = BACKUP_DIR_MAP[prefix]; for (let f in (glob(d + '/*') || [])) { let base = substr(f, length(d) + 1); if (!add_file(prefix + '/' + base, f)) return { success: false, message: '备份内容过大(超过 480KB)' }; } } let host = ''; let hp = popen('uname -n 2>/dev/null'); if (hp) { host = trim(hp.read('all')); hp.close(); } return { success: true, manifest: { marker: 'clashoo-backup-v1', created_at: now_epoch_sec(), host: host, app_version: read_first_line('/usr/share/clashoo/luci_version') }, files: files }; } catch (e) { return { success: false, message: '导出失败: ' + e }; } } }, /* 大文件备份走 cgi-download:每次导出使用独立的随机路径。 */ backup_export_prepare: { call: function() { let export_path = ''; let result_path = ''; try { if (!backup_tmp_ready()) return { success: false, message: '备份临时目录不安全或无法创建' }; export_path = new_backup_temp_path('export'); if (!export_path) return { success: false, message: '无法创建导出临时文件' }; result_path = export_path + '.result'; let cmd = 'sh ' + shell_quote(BACKUP_ARCHIVE_SCRIPT) + ' export ' + shell_quote(export_path) + ' >' + shell_quote(result_path) + ' 2>&1'; if (system(cmd) != 0) { let detail = trim(readfile(result_path) || ''); unlink(export_path); unlink(result_path); return { success: false, message: detail || '生成备份文件失败' }; } let info = stat(export_path); if (!info || info.type != 'file' || info.size <= 0) { unlink(export_path); unlink(result_path); return { success: false, message: '生成的备份文件无效' }; } let stamp = ''; let dp = popen('date +%Y%m%d-%H%M 2>/dev/null'); if (dp) { stamp = trim(dp.read('all')); dp.close(); } unlink(result_path); return { success: true, path: export_path, filename: 'clashoo-backup-' + (stamp || now_epoch_sec()) + '.tar.gz', size: info.size }; } catch (e) { if (export_path) unlink(export_path); if (result_path) unlink(result_path); return { success: false, message: '导出失败: ' + e }; } } }, backup_export_cleanup: { args: { path: 'path' }, call: function(req) { if (!backup_tmp_ready()) return { success: false }; let export_path = safe_backup_export_path(req.args?.path || ''); if (!export_path) return { success: false }; unlink(export_path); unlink(export_path + '.result'); return { success: true }; } }, backup_import_prepare: { call: function() { if (!backup_tmp_ready()) return { success: false, message: '备份临时目录不安全或无法创建' }; let path = new_backup_temp_path('import'); return path ? { success: true, path: path } : { success: false, message: '无法创建上传临时文件' }; } }, /* 分块写入在 RPC 后端逐次校验偏移和总大小,超限时立即删除。 */ backup_import_chunk: { args: { path: 'path', offset: 0, data: 'data' }, call: function(req) { try { let upload_path = safe_backup_upload_path(req.args?.path || ''); let offset = int(req.args?.offset || 0); let encoded = req.args?.data || ''; if (!upload_path || offset < 0) return { success: false, message: '上传参数无效' }; if (!backup_tmp_ready()) return { success: false, message: '备份临时目录不安全或无法访问' }; if (!active_backup_upload(upload_path)) { release_backup_upload(upload_path); return { success: false, message: '上传会话已失效,请重新选择备份文件' }; } let info = stat(upload_path); if (!info || info.type != 'file' || info.size != offset) return { success: false, message: '上传偏移不匹配,请重新选择备份文件' }; if (!encoded || length(encoded) > BACKUP_MAX_CHUNK_BASE64_SIZE) { release_backup_upload(upload_path); return { success: false, message: '上传分块过大' }; } let chunk = null; try { chunk = b64dec(encoded); } catch (e) { chunk = null; } if (chunk === null || length(chunk) <= 0 || length(chunk) > BACKUP_MAX_CHUNK_SIZE) { release_backup_upload(upload_path); return { success: false, message: '上传分块无效' }; } let next_offset = offset + length(chunk); if (next_offset > BACKUP_MAX_ARCHIVE_SIZE) { release_backup_upload(upload_path); return { success: false, message: '备份文件超过 32MB' }; } let fp = open(upload_path, 'a'); if (!fp) { release_backup_upload(upload_path); return { success: false, message: '无法写入上传临时文件' }; } let written = fp.write(chunk); fp.close(); if (written != length(chunk)) { release_backup_upload(upload_path); return { success: false, message: '上传分块写入不完整' }; } if (!refresh_backup_upload(upload_path)) { release_backup_upload(upload_path); return { success: false, message: '无法刷新上传会话' }; } return { success: true, offset: next_offset }; } catch (e) { let upload_path = safe_backup_upload_path(req.args?.path || ''); if (upload_path && backup_tmp_ready()) release_backup_upload(upload_path); return { success: false, message: '上传失败: ' + e }; } } }, backup_import_cleanup: { args: { path: 'path' }, call: function(req) { if (!backup_tmp_ready()) return { success: false }; let upload_path = safe_backup_upload_path(req.args?.path || ''); if (!upload_path) return { success: false }; release_backup_upload(upload_path); return { success: true }; } }, /* 备份导入:用备份快照覆盖当前配置。先全量校验路径白名单(拒绝目录遍历), 通过后才清空目标目录并重写,最后异步重启服务。 */ backup_import: { args: { data: 'data' }, call: function(req) { return restore_backup_json(req.args?.data || ''); } }, /* 上传内容落到后端生成的随机临时路径,前端不能指定其他位置。 */ backup_import_file: { args: { name: 'name', path: 'path' }, call: function(req) { try { let name = req.args?.name || ''; let upload_path = safe_backup_upload_path(req.args?.path || ''); if (!upload_path) return { success: false, message: '上传临时路径无效' }; if (!backup_tmp_ready()) return { success: false, message: '备份临时目录不安全或无法访问' }; if (!active_backup_upload(upload_path)) { release_backup_upload(upload_path); return { success: false, message: '上传会话已失效,请重新选择备份文件' }; } let info = stat(upload_path); if (!info || info.type != 'file') { release_backup_upload(upload_path); return { success: false, message: '没有找到已上传的备份文件' }; } if (info.size <= 0) { release_backup_upload(upload_path); return { success: false, message: '备份文件为空' }; } if (match(lc(name), /\.json$/)) { if (info.size > BACKUP_MAX_JSON_SIZE) { release_backup_upload(upload_path); return { success: false, message: '旧版 JSON 备份超过 8MB' }; } let raw = readfile(upload_path); release_backup_upload(upload_path); if (raw === null) return { success: false, message: '读取备份文件失败' }; return restore_backup_json(raw); } if (!match(lc(name), /\.tar\.gz$/)) { release_backup_upload(upload_path); return { success: false, message: '仅支持 .tar.gz 和旧版 .json 备份文件' }; } let result_path = upload_path + '.result'; unlink(result_path); let cmd = 'sh ' + shell_quote(BACKUP_ARCHIVE_SCRIPT) + ' restore ' + shell_quote(upload_path) + ' >' + shell_quote(result_path) + ' 2>&1'; let rc = system(cmd); let detail = trim(readfile(result_path) || ''); release_backup_upload(upload_path); if (rc != 0) return { success: false, message: detail || '还原备份失败' }; system('sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1'); return { success: true, message: '备份已还原,正在重启服务' }; } catch (e) { let upload_path = safe_backup_upload_path(req.args?.path || ''); if (upload_path && backup_tmp_ready()) release_backup_upload(upload_path); return { success: false, message: '导入失败: ' + e }; } } }, /* 还原默认:把出厂默认 UCI 覆盖回 /etc/config/clashoo,只重置设置, 不动订阅/配置文件。出厂副本由 clashoo 包装在非 conffile 路径,永远最新。 */ backup_reset: { call: function() { try { let def = '/usr/share/clashoo/clashoo.default'; if (!access(def, 'r')) return { success: false, message: '出厂默认配置文件缺失,无法还原' }; if (system('cp ' + shell_quote(def) + ' /etc/config/clashoo') != 0) return { success: false, message: '写入默认配置失败' }; system('sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1'); return { success: true, message: '已还原出厂默认设置,正在重启服务' }; } catch (e) { return { success: false, message: '还原失败: ' + e }; } } } }; return { 'luci.clashoo': methods };