Files
2026-09-13 16:42:22 +08:00

4578 lines
158 KiB
Plaintext
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 </dev/null &');
return true;
}
function ensure_access_payload_shape(data, proxy_port, tcp_mode, udp_mode, stale, updating) {
let payload = data || {};
payload.proxy_port = payload.proxy_port || proxy_port || '7890';
payload.tcp_mode = payload.tcp_mode || tcp_mode || 'redirect';
payload.udp_mode = payload.udp_mode || udp_mode || payload.tcp_mode;
payload.updated_at = int(payload.updated_at || 0);
if (!payload.direct || type(payload.direct) != 'object')
payload.direct = {};
if (!payload.proxy || type(payload.proxy) != 'object')
payload.proxy = {};
if (!payload.direct.bytedance)
payload.direct.bytedance = default_probe_result();
if (!payload.direct.youtube)
payload.direct.youtube = default_probe_result();
if (!payload.proxy.bytedance)
payload.proxy.bytedance = default_probe_result();
if (!payload.proxy.youtube)
payload.proxy.youtube = default_probe_result();
payload.stale = stale;
payload.updating = updating;
payload.last_checked_ago = payload.updated_at > 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 <ver> linux ..." → <ver>sing-box "sing-box version <ver>" → <ver>
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 '';
}
/* 允许 UnicodeCJK 等)—— 不再用纯 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 旧调用默认 stopSystem 页可显式 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 </dev/null &");
return { success: true };
}
},
// panel download progress for inline UI feedback (state file: "state:panel:msg")
panel_status: {
call: function() {
let downloading = access('/var/run/panel_downloading', 'r') ? true : false;
let line = trim(readfile('/tmp/clash_panel_download_state') || '');
let state = '', panel = '', msg = '';
let i1 = index(line, ':');
if (i1 >= 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 &");
return { success: true, started: true };
}
},
update_geoip: {
call: function() {
return { success: system('sh /usr/share/clashoo/rpc/rpc_async.sh update_geoip >/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 rc = system(cmd);
if (rc != 0) unlink(COMPONENT_UPDATE_RUN_FILE);
return { success: rc == 0, started: rc == 0, running: rc == 0 };
}
},
// 点「检查更新」时调用:稳定版走 releases/latest 重定向,alpha/smart 走
// Prerelease-Alpha 资源清单,clashoo/客户端走 R2 feed manifest —— 均不碰
// GitHub API。返回按变体分键,前端按当前选的 stable/alpha 比对。
component_check_updates: {
call: function() {
return { ok: true, latest: component_latest_versions() };
}
},
component_update_log: {
call: function() {
return {
running: !!access(COMPONENT_UPDATE_RUN_FILE, 'r'),
content: component_log_tail(120),
last_log: component_last_log()
};
}
},
list_profiles: {
call: function() {
return { profiles: get_profiles() };
}
},
read_log: {
call: function() {
return { content: get_log_content() };
}
},
read_real_log: {
call: function() {
return { content: trim(readfile('/usr/share/clashoo/clashoo_real.txt') || '') };
}
},
clear_log: {
call: function() {
let cleared = false;
for (let path in LOG_PATHS) {
if (!access(path, 'r')) continue;
if (writefile(path, '') !== null) cleared = true;
}
return { success: cleared };
}
},
read_update_log: {
call: function() {
let p = popen("tail -300 /tmp/clash_update.txt 2>/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_groupiprules.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.urlrule-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 失败再走外部 subconverterbase64 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 <name>.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 };