diff --git a/fe-app-tachyon/.env.example b/fe-app-tachyon/.env.example new file mode 100644 index 00000000..86f2af31 --- /dev/null +++ b/fe-app-tachyon/.env.example @@ -0,0 +1,16 @@ +SFTP_HOST=192.168.160.129 +SFTP_PORT=22 +SFTP_USER=root +SFTP_PASS= + +# you can use key if needed +# SFTP_PRIVATE_KEY=~/.ssh/id_rsa + +LOCAL_DIR_FE=../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon +REMOTE_DIR_FE=/www/luci-static/resources/view/tachyon + +LOCAL_DIR_BIN=../tachyon/files/usr/bin/ +REMOTE_DIR_BIN=/usr/bin/ + +LOCAL_DIR_LIB=../tachyon/files/usr/lib/ +REMOTE_DIR_LIB=/usr/lib/tachyon/ diff --git a/fe-app-tachyon/.prettierrc b/fe-app-tachyon/.prettierrc new file mode 100644 index 00000000..7d30bd5e --- /dev/null +++ b/fe-app-tachyon/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "bracketSpacing": true +} diff --git a/fe-app-tachyon/distribute-locales.js b/fe-app-tachyon/distribute-locales.js new file mode 100644 index 00000000..590fe4c0 --- /dev/null +++ b/fe-app-tachyon/distribute-locales.js @@ -0,0 +1,38 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const sourceDir = path.resolve(__dirname, 'locales'); +const targetRoot = path.resolve(__dirname, '../luci-app-tachyon/po'); + +async function main() { + const files = await fs.readdir(sourceDir); + + for (const file of files) { + const filePath = path.join(sourceDir, file); + + if (file === 'tachyon.pot') { + const potTarget = path.join(targetRoot, 'templates', 'tachyon.pot'); + await fs.mkdir(path.dirname(potTarget), { recursive: true }); + await fs.copyFile(filePath, potTarget); + console.log(`✅ Copied POT: ${filePath} → ${potTarget}`); + } + + const match = file.match(/^tachyon\.([a-zA-Z_]+)\.po$/); + if (match) { + const lang = match[1]; + const poTarget = path.join(targetRoot, lang, 'tachyon.po'); + await fs.mkdir(path.dirname(poTarget), { recursive: true }); + await fs.copyFile(filePath, poTarget); + console.log(`✅ Copied ${lang.toUpperCase()}: ${filePath} → ${poTarget}`); + } + } +} + +main().catch((err) => { + console.error('❌ Ошибка при распространении переводов:', err); + process.exit(1); +}); diff --git a/fe-app-tachyon/eslint.config.js b/fe-app-tachyon/eslint.config.js new file mode 100644 index 00000000..a1b39a27 --- /dev/null +++ b/fe-app-tachyon/eslint.config.js @@ -0,0 +1,36 @@ +// eslint.config.js +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettier from 'eslint-config-prettier'; + +export default [ + js.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ['node_modules', 'watch-upload.js'], + }, + { + files: ['src/**/*.test.js'], + languageOptions: { + globals: { + Buffer: 'readonly', + URL: 'readonly', + }, + }, + }, + { + rules: { + 'no-console': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, + prettier, +]; diff --git a/fe-app-tachyon/extract-calls.js b/fe-app-tachyon/extract-calls.js new file mode 100644 index 00000000..20bfc81e --- /dev/null +++ b/fe-app-tachyon/extract-calls.js @@ -0,0 +1,75 @@ +import fs from 'fs/promises'; +import path from 'path'; +import glob from 'fast-glob'; +import { parse } from '@babel/parser'; +import traverse from '@babel/traverse'; +import * as t from '@babel/types'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function stripIllegalReturn(code) { + return code.replace(/^\s*return\s+[^;]+;\s*$/gm, (match, offset, input) => { + const after = input.slice(offset + match.length).trim(); + return after === '' ? '' : match; + }); +} + +const files = await glob([ + 'src/**/*.ts', + '../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/**/*.js', +], { + ignore: [ + '**/*.test.ts', + '**/main.js', + '../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js', + ], + absolute: true, +}); + +const results = {}; + +for (const file of files) { + const contentRaw = await fs.readFile(file, 'utf8'); + const content = stripIllegalReturn(contentRaw); + const relativePath = path.relative(process.cwd(), file).replaceAll('\\', '/'); + + let ast; + try { + ast = parse(content, { + sourceType: 'module', + plugins: file.endsWith('.ts') ? ['typescript'] : [], + }); + } catch (e) { + console.warn(`⚠️ Parse error in ${relativePath}, skipping`); + continue; + } + + traverse.default(ast, { + CallExpression(path) { + if (t.isIdentifier(path.node.callee, { name: '_' })) { + const arg = path.node.arguments[0]; + if (t.isStringLiteral(arg)) { + const key = arg.value.trim(); + if (!key) return; // ❌ пропустить пустые ключи + const location = `${relativePath}:${path.node.loc?.start.line ?? '?'}`; + + if (!results[key]) { + results[key] = { call: key, key, places: [] }; + } + + results[key].places.push(location); + } + } + }, + }); +} + +const outFile = 'locales/calls.json'; +const sorted = Object.values(results).sort((a, b) => a.key.localeCompare(b.key)); // 🔤 сортировка по ключу + +await fs.mkdir(path.dirname(outFile), { recursive: true }); +await fs.writeFile(outFile, JSON.stringify(sorted, null, 2), 'utf8'); +console.log(`✅ Extracted ${sorted.length} translations to ${outFile}`); diff --git a/fe-app-tachyon/generate-po.js b/fe-app-tachyon/generate-po.js new file mode 100644 index 00000000..548632eb --- /dev/null +++ b/fe-app-tachyon/generate-po.js @@ -0,0 +1,117 @@ +import fs from 'fs/promises'; +import { execSync } from 'child_process'; + +const lang = process.argv[2]; +if (!lang) { + console.error('❌ Укажи язык, например: node generate-po.js ru'); + process.exit(1); +} + +const callsPath = 'locales/calls.json'; +const poPath = `locales/tachyon.${lang}.po`; + +function getGitUser() { + try { + return execSync('git config user.name').toString().trim(); + } catch { + return 'Automatically generated'; + } +} + +function getHeader(lang) { + const now = new Date(); + const date = now.toISOString().split('T')[0]; + const time = now.toTimeString().split(' ')[0].slice(0, 5); + const tzOffset = (() => { + const offset = -now.getTimezoneOffset(); + const sign = offset >= 0 ? '+' : '-'; + const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0'); + const minutes = String(Math.abs(offset) % 60).padStart(2, '0'); + return `${sign}${hours}${minutes}`; + })(); + + const translator = getGitUser(); + const pluralForms = + lang === 'ru' + ? 'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);' + : 'nplurals=2; plural=(n != 1);'; + + return [ + `# ${lang.toUpperCase()} translations for TACHYON package.`, + `# Copyright (C) ${now.getFullYear()} THE TACHYON COPYRIGHT HOLDER`, + `# This file is distributed under the same license as the TACHYON package.`, + `# ${translator}, ${now.getFullYear()}.`, + '#', + 'msgid ""', + 'msgstr ""', + `"Project-Id-Version: TACHYON\\n"`, + `"Report-Msgid-Bugs-To: \\n"`, + `"POT-Creation-Date: ${date} ${time}${tzOffset}\\n"`, + `"PO-Revision-Date: ${date} ${time}${tzOffset}\\n"`, + `"Last-Translator: ${translator}\\n"`, + `"Language-Team: none\\n"`, + `"Language: ${lang}\\n"`, + `"MIME-Version: 1.0\\n"`, + `"Content-Type: text/plain; charset=UTF-8\\n"`, + `"Content-Transfer-Encoding: 8bit\\n"`, + `"Plural-Forms: ${pluralForms}\\n"`, + '', + ]; +} + +function parsePo(content) { + const lines = content.split('\n'); + const translations = new Map(); + let msgid = null; + let msgstr = null; + for (const line of lines) { + if (line.startsWith('msgid ')) { + msgid = JSON.parse(line.slice(6)); + } else if (line.startsWith('msgstr ') && msgid !== null) { + msgstr = JSON.parse(line.slice(7)); + translations.set(msgid, msgstr); + msgid = null; + msgstr = null; + } + } + return translations; +} + +function escapePoString(str) { + return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +async function generatePo() { + const [callsRaw, oldPoRaw] = await Promise.all([ + fs.readFile(callsPath, 'utf8'), + fs.readFile(poPath, 'utf8').catch(() => ''), + ]); + + const calls = JSON.parse(callsRaw); + const oldTranslations = parsePo(oldPoRaw); + const header = getHeader(lang); + + const body = calls + .map(({ key }) => { + const msgid = key; + const msgstr = oldTranslations.get(msgid) || ''; + return [ + `msgid "${escapePoString(msgid)}"`, + `msgstr "${escapePoString(msgstr)}"`, + '', + ].join('\n'); + }) + .join('\n'); + + const finalPo = header.join('\n') + '\n' + body; + + await fs.writeFile(poPath, finalPo, 'utf8'); + console.log( + `✅ Файл ${poPath} успешно сгенерирован. Переведено ${[...oldTranslations.keys()].length}/${calls.length}`, + ); +} + +generatePo().catch((err) => { + process.exitCode = 1; + console.error('Ошибка генерации PO файла:', err); +}); diff --git a/fe-app-tachyon/generate-pot.js b/fe-app-tachyon/generate-pot.js new file mode 100644 index 00000000..479b7b43 --- /dev/null +++ b/fe-app-tachyon/generate-pot.js @@ -0,0 +1,91 @@ +import fs from 'fs/promises'; +import { execSync } from 'child_process'; + +const inputFile = 'locales/calls.json'; +const outputFile = 'locales/tachyon.pot'; +const projectId = 'TACHYON'; + +function getGitUser() { + try { + const name = execSync('git config user.name', { + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim(); + const email = execSync('git config user.email', { + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim(); + + if (name && email) { + return { name, email }; + } + } catch (error) { + // Fall through to deterministic defaults when git identity is not configured. + } + + return { + name: 'Tachyon', + email: 'ushan0v@users.noreply.github.com', + }; +} + +function getPotHeader({ name, email }) { + const now = new Date(); + const date = now.toISOString().replace('T', ' ').slice(0, 16); + const offset = -now.getTimezoneOffset(); + const sign = offset >= 0 ? '+' : '-'; + const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0'); + const minutes = String(Math.abs(offset) % 60).padStart(2, '0'); + const timezone = `${sign}${hours}${minutes}`; + + return [ + '# SOME DESCRIPTIVE TITLE.', + `# Copyright (C) ${now.getFullYear()} THE PACKAGE'S COPYRIGHT HOLDER`, + `# This file is distributed under the same license as the ${projectId} package.`, + `# ${name} <${email}>, ${now.getFullYear()}.`, + '#, fuzzy', + 'msgid ""', + 'msgstr ""', + `"Project-Id-Version: ${projectId}\\n"`, + `"Report-Msgid-Bugs-To: \\n"`, + `"POT-Creation-Date: ${date}${timezone}\\n"`, + `"PO-Revision-Date: ${date}${timezone}\\n"`, + `"Last-Translator: ${name} <${email}>\\n"`, + `"Language-Team: LANGUAGE \\n"`, + `"Language: \\n"`, + `"MIME-Version: 1.0\\n"`, + `"Content-Type: text/plain; charset=UTF-8\\n"`, + `"Content-Transfer-Encoding: 8bit\\n"`, + '', + ].join('\n'); +} + +function escapePoString(str) { + return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function generateEntry(item) { + const locations = item.places.map((loc) => `#: ${loc}`).join('\n'); + const msgid = escapePoString(item.key); + return [locations, `msgid "${msgid}"`, `msgstr ""`, ''].join('\n'); +} + +async function generatePot() { + const gitUser = getGitUser(); + const raw = await fs.readFile(inputFile, 'utf8'); + const entries = JSON.parse(raw); + + const header = getPotHeader(gitUser); + const body = entries.map(generateEntry).join('\n'); + + await fs.writeFile(outputFile, `${header}\n${body}`, 'utf8'); + + console.log(`✅ POT-файл успешно создан: ${outputFile}`); +} + +generatePot().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/fe-app-tachyon/locales/calls.json b/fe-app-tachyon/locales/calls.json new file mode 100644 index 00000000..0fe14ada --- /dev/null +++ b/fe-app-tachyon/locales/calls.json @@ -0,0 +1,5631 @@ +[ + { + "call": "-- Select --", + "key": "-- Select --", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1961", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2308", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2648", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2663", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2861", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2876", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3112", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3264", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3274" + ] + }, + { + "call": "+ Add JSON outbound", + "key": "+ Add JSON outbound", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7372" + ] + }, + { + "call": "+ Add level", + "key": "+ Add level", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3028" + ] + }, + { + "call": "+ Add priority", + "key": "+ Add priority", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7458" + ] + }, + { + "call": "+ Add URLTest", + "key": "+ Add URLTest", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7399" + ] + }, + { + "call": "✔ Running", + "key": "✔ Running", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1675", + "src/tachyon/tabs/dashboard/initController.ts:1686" + ] + }, + { + "call": "✘ Stopped", + "key": "✘ Stopped", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1676", + "src/tachyon/tabs/dashboard/initController.ts:1687" + ] + }, + { + "call": "Accept external proxy connections and route them with sing-box.", + "key": "Accept external proxy connections and route them with sing-box.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:408" + ] + }, + { + "call": "Accept routes", + "key": "Accept routes", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3133" + ] + }, + { + "call": "Action", + "key": "Action", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7013", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7042" + ] + }, + { + "call": "Active", + "key": "Active", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:667", + "src/tachyon/tabs/monitoring/render.ts:20" + ] + }, + { + "call": "Active Bootstrap DNS", + "key": "Active Bootstrap DNS", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:56" + ] + }, + { + "call": "Active Connections", + "key": "Active Connections", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1633" + ] + }, + { + "call": "Active Main DNS", + "key": "Active Main DNS", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:67" + ] + }, + { + "call": "Add a section", + "key": "Add a section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:397" + ] + }, + { + "call": "Add a server inbound", + "key": "Add a server inbound", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:414" + ] + }, + { + "call": "Add at least one DNS server", + "key": "Add at least one DNS server", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:119" + ] + }, + { + "call": "Add prefix to nodes", + "key": "Add prefix to nodes", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2357" + ] + }, + { + "call": "Add URLs or local paths to .lst lists. Only domain rules are supported.", + "key": "Add URLs or local paths to .lst lists. Only domain rules are supported.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7915" + ] + }, + { + "call": "Add URLs or local paths to .srs / .json lists. Only domain rules are supported.", + "key": "Add URLs or local paths to .srs / .json lists. Only domain rules are supported.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7861", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7890" + ] + }, + { + "call": "Add URLTest / Priority group", + "key": "Add URLTest / Priority group", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3286" + ] + }, + { + "call": "Additional marking rules found", + "key": "Additional marking rules found", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:106" + ] + }, + { + "call": "Additionally exclude servers by protocol, transport, and security. Exclude only servers matching the specified parameters.", + "key": "Additionally exclude servers by protocol, transport, and security. Exclude only servers matching the specified parameters.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2578", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2788" + ] + }, + { + "call": "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters.", + "key": "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2561", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2770" + ] + }, + { + "call": "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters.", + "key": "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3186" + ] + }, + { + "call": "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters.", + "key": "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3166" + ] + }, + { + "call": "Address", + "key": "Address", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1653" + ] + }, + { + "call": "Advertise exit node", + "key": "Advertise exit node", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3116" + ] + }, + { + "call": "Advertise routes", + "key": "Advertise routes", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3126" + ] + }, + { + "call": "All", + "key": "All", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:626", + "src/tachyon/tabs/monitoring/render.ts:39" + ] + }, + { + "call": "All except selected", + "key": "All except selected", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1858" + ] + }, + { + "call": "All remaining except selected", + "key": "All remaining except selected", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1868" + ] + }, + { + "call": "All remaining servers", + "key": "All remaining servers", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1866" + ] + }, + { + "call": "All remaining servers means every server not already assigned to a higher-priority level.", + "key": "All remaining servers means every server not already assigned to a higher-priority level.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2744" + ] + }, + { + "call": "All servers", + "key": "All servers", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1857" + ] + }, + { + "call": "All traffic from these IP addresses will be routed through the section unconditionally, ignoring all other conditions.", + "key": "All traffic from these IP addresses will be routed through the section unconditionally, ignoring all other conditions.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7939" + ] + }, + { + "call": "Allowed domain prefixes are full:, keyword:, and regex:", + "key": "Allowed domain prefixes are full:, keyword:, and regex:", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5131" + ] + }, + { + "call": "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall.", + "key": "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:423" + ] + }, + { + "call": "Allows limiting the list of servers for URLTest", + "key": "Allows limiting the list of servers for URLTest", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2538" + ] + }, + { + "call": "ALPN", + "key": "ALPN", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2645" + ] + }, + { + "call": "Alter ID", + "key": "Alter ID", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2879" + ] + }, + { + "call": "Apply section rules only to the specified local IP addresses", + "key": "Apply section rules only to the specified local IP addresses", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7930" + ] + }, + { + "call": "At least one valid domain must be specified.", + "key": "At least one valid domain must be specified.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5162" + ] + }, + { + "call": "At least one valid IP or subnet must be specified.", + "key": "At least one valid IP or subnet must be specified.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5244" + ] + }, + { + "call": "Auto refresh", + "key": "Auto refresh", + "places": [ + "src/partials/modal/renderModal.ts:286" + ] + }, + { + "call": "Auto update", + "key": "Auto update", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3036" + ] + }, + { + "call": "Auto-generate HWID", + "key": "Auto-generate HWID", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2324" + ] + }, + { + "call": "Automatic component update checks", + "key": "Automatic component update checks", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:501" + ] + }, + { + "call": "Automatic User-Agent selection", + "key": "Automatic User-Agent selection", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2293" + ] + }, + { + "call": "Automatically add text to the name of each server from this subscription for convenient filtering.", + "key": "Automatically add text to the name of each server from this subscription for convenient filtering.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2358" + ] + }, + { + "call": "Automatically check installed components for new versions", + "key": "Automatically check installed components for new versions", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:502" + ] + }, + { + "call": "Automatically select the fastest node in the current level", + "key": "Automatically select the fastest node in the current level", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2978", + "src/tachyon/tabs/dashboard/initController.ts:1155" + ] + }, + { + "call": "Available actions", + "key": "Available actions", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:43" + ] + }, + { + "call": "Backend validation failed: %s", + "key": "Backend validation failed: %s", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5377", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5822", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6234" + ] + }, + { + "call": "Bootstrap DNS", + "key": "Bootstrap DNS", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:57" + ] + }, + { + "call": "Bootstrap DNS Servers", + "key": "Bootstrap DNS Servers", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:182" + ] + }, + { + "call": "Browser FakeIP check could not be completed", + "key": "Browser FakeIP check could not be completed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:51", + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:79" + ] + }, + { + "call": "Browser is not using FakeIP", + "key": "Browser is not using FakeIP", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:82" + ] + }, + { + "call": "Browser is using FakeIP correctly", + "key": "Browser is using FakeIP correctly", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:81" + ] + }, + { + "call": "Built-in rule sets", + "key": "Built-in rule sets", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7760" + ] + }, + { + "call": "By flag emoji from name", + "key": "By flag emoji from name", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1875" + ] + }, + { + "call": "ByeDPI listen address and port are assigned by Tachyon and must not be set in the strategy.", + "key": "ByeDPI listen address and port are assigned by Tachyon and must not be set in the strategy.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6303" + ] + }, + { + "call": "ByeDPI option does not accept a compact value: %s", + "key": "ByeDPI option does not accept a compact value: %s", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6480" + ] + }, + { + "call": "ByeDPI option does not accept a value: %s", + "key": "ByeDPI option does not accept a value: %s", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6409" + ] + }, + { + "call": "ByeDPI option requires a value: %s", + "key": "ByeDPI option requires a value: %s", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6401", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6427", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6461", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6470" + ] + }, + { + "call": "ByeDPI package is installed", + "key": "ByeDPI package is installed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:71" + ] + }, + { + "call": "ByeDPI package is not installed", + "key": "ByeDPI package is not installed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:72" + ] + }, + { + "call": "ByeDPI provider binary is available", + "key": "ByeDPI provider binary is available", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:64" + ] + }, + { + "call": "ByeDPI provider binary is not available", + "key": "ByeDPI provider binary is not available", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:65" + ] + }, + { + "call": "ByeDPI sing-box outbound is configured", + "key": "ByeDPI sing-box outbound is configured", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:107" + ] + }, + { + "call": "ByeDPI sing-box outbound is not configured", + "key": "ByeDPI sing-box outbound is not configured", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:108" + ] + }, + { + "call": "ByeDPI Strategy", + "key": "ByeDPI Strategy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7214" + ] + }, + { + "call": "ByeDPI strategy cannot be empty", + "key": "ByeDPI strategy cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6506" + ] + }, + { + "call": "Cache File Path", + "key": "Cache File Path", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:623" + ] + }, + { + "call": "Cache file path cannot be empty", + "key": "Cache file path cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:637" + ] + }, + { + "call": "Cannot receive checks result", + "key": "Cannot receive checks result", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:26", + "src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:27", + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:172", + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:28", + "src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:48", + "src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:25", + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:26", + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:26" + ] + }, + { + "call": "Cannot save server", + "key": "Cannot save server", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2241" + ] + }, + { + "call": "Cannot save settings", + "key": "Cannot save settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3414" + ] + }, + { + "call": "Cascade connection", + "key": "Cascade connection", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7491" + ] + }, + { + "call": "Certificate path", + "key": "Certificate path", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2655" + ] + }, + { + "call": "Certificate pin is unavailable. Apply or restart the server to generate the default certificate, then open this dialog again.", + "key": "Certificate pin is unavailable. Apply or restart the server to generate the default certificate, then open this dialog again.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1598" + ] + }, + { + "call": "Check interval", + "key": "Check interval", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2464", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2929", + "src/tachyon/tabs/dashboard/initController.ts:1142" + ] + }, + { + "call": "Check timeout after which the server is considered dead", + "key": "Check timeout after which the server is considered dead", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2942" + ] + }, + { + "call": "Check update", + "key": "Check update", + "places": [ + "src/tachyon/tabs/updates/initController.ts:740" + ] + }, + { + "call": "Check URL", + "key": "Check URL", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2490", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2916", + "src/tachyon/tabs/dashboard/initController.ts:1140" + ] + }, + { + "call": "Checking, please wait", + "key": "Checking, please wait", + "places": [ + "src/tachyon/tabs/diagnostic/initController.ts:1022", + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:14", + "src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:15", + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:15", + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:160", + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:13", + "src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:34", + "src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:13", + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:14", + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:14" + ] + }, + { + "call": "checks", + "key": "checks", + "places": [ + "src/tachyon/tabs/diagnostic/helpers/getCheckTitle.ts:2" + ] + }, + { + "call": "Checks failed", + "key": "Checks failed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts:10", + "src/tachyon/tabs/diagnostic/helpers/getMeta.ts:26" + ] + }, + { + "call": "Checks passed", + "key": "Checks passed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts:23", + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:43", + "src/tachyon/tabs/diagnostic/helpers/getMeta.ts:13" + ] + }, + { + "call": "Checks passed with manual DHCP", + "key": "Checks passed with manual DHCP", + "places": [ + "src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts:27" + ] + }, + { + "call": "CIDR must be between 0 and 32 for IPv4 or 0 and 128 for IPv6", + "key": "CIDR must be between 0 and 32 for IPv4 or 0 and 128 for IPv6", + "places": [ + "src/validators/validateSubnet.ts:35" + ] + }, + { + "call": "Client fingerprint", + "key": "Client fingerprint", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2753" + ] + }, + { + "call": "Client link", + "key": "Client link", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1670" + ] + }, + { + "call": "Client link is not available yet", + "key": "Client link is not available yet", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1578" + ] + }, + { + "call": "Client link QR code", + "key": "Client link QR code", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1611" + ] + }, + { + "call": "Close", + "key": "Close", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3481", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1718", + "src/partials/modal/renderModal.ts:263", + "src/tachyon/tabs/dashboard/initController.ts:1027", + "src/tachyon/tabs/dashboard/initController.ts:1283", + "src/tachyon/tabs/monitoring/initController.ts:832", + "src/tachyon/tabs/monitoring/initController.ts:882" + ] + }, + { + "call": "Close all connections", + "key": "Close all connections", + "places": [ + "src/tachyon/tabs/monitoring/render.ts:58", + "src/tachyon/tabs/monitoring/render.ts:59" + ] + }, + { + "call": "Close connection", + "key": "Close connection", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:799", + "src/tachyon/tabs/monitoring/initController.ts:800" + ] + }, + { + "call": "Closed", + "key": "Closed", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:674", + "src/tachyon/tabs/monitoring/render.ts:29" + ] + }, + { + "call": "Component update available", + "key": "Component update available", + "places": [ + "src/tachyon/services/core.service.ts:43" + ] + }, + { + "call": "Component update check interval", + "key": "Component update check interval", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:510" + ] + }, + { + "call": "Components", + "key": "Components", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:473" + ] + }, + { + "call": "compressed", + "key": "compressed", + "places": [ + "src/tachyon/helpers/singBoxVariant.ts:51" + ] + }, + { + "call": "Concurrency", + "key": "Concurrency", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2988" + ] + }, + { + "call": "Conditions", + "key": "Conditions", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7001" + ] + }, + { + "call": "Config File Path", + "key": "Config File Path", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:610" + ] + }, + { + "call": "Configuration for Tachyon service", + "key": "Configuration for Tachyon service", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:343" + ] + }, + { + "call": "Configure User-Agent in the item settings", + "key": "Configure User-Agent in the item settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4473" + ] + }, + { + "call": "Connect through", + "key": "Connect through", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7529" + ] + }, + { + "call": "Connection URL", + "key": "Connection URL", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7252" + ] + }, + { + "call": "Connections are unavailable", + "key": "Connections are unavailable", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:946" + ] + }, + { + "call": "Connectivity probe failed", + "key": "Connectivity probe failed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:145" + ] + }, + { + "call": "Copied", + "key": "Copied", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1565", + "src/helpers/copyToClipboard.ts:10" + ] + }, + { + "call": "Copy", + "key": "Copy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1694", + "src/partials/modal/renderModal.ts:258" + ] + }, + { + "call": "Copy proxy link", + "key": "Copy proxy link", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:997", + "src/tachyon/tabs/dashboard/initController.ts:1253", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:366", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:367" + ] + }, + { + "call": "Could not compare FakeIP and control public IPs", + "key": "Could not compare FakeIP and control public IPs", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:89" + ] + }, + { + "call": "Current section cannot be used as its own transit section", + "key": "Current section cannot be used as its own transit section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7546" + ] + }, + { + "call": "Current section cannot download its own subscription", + "key": "Current section cannot download its own subscription", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2285" + ] + }, + { + "call": "Currently unavailable", + "key": "Currently unavailable", + "places": [ + "src/tachyon/tabs/dashboard/partials/renderWidget.ts:22" + ] + }, + { + "call": "Custom outbound configurations in JSON format", + "key": "Custom outbound configurations in JSON format", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7367" + ] + }, + { + "call": "Dashboard", + "key": "Dashboard", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:449" + ] + }, + { + "call": "Dashboard currently unavailable", + "key": "Dashboard currently unavailable", + "places": [ + "src/tachyon/tabs/dashboard/partials/renderSections.ts:47" + ] + }, + { + "call": "Default address for checking server availability and latency. URLTest uses its own address.", + "key": "Default address for checking server availability and latency. URLTest uses its own address.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:546" + ] + }, + { + "call": "Delay in milliseconds before reloading Tachyon after interface UP", + "key": "Delay in milliseconds before reloading Tachyon after interface UP", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:398" + ] + }, + { + "call": "Delay value cannot be empty", + "key": "Delay value cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:405" + ] + }, + { + "call": "Delete", + "key": "Delete", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1745" + ] + }, + { + "call": "Detect server country", + "key": "Detect server country", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2548", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2757", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3153" + ] + }, + { + "call": "detour must be a non-empty string", + "key": "detour must be a non-empty string", + "places": [ + "src/validators/validateOutboundJson.ts:115" + ] + }, + { + "call": "Device filter", + "key": "Device filter", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7929" + ] + }, + { + "call": "Device or IP", + "key": "Device or IP", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6769" + ] + }, + { + "call": "DHCP has DNS server", + "key": "DHCP has DNS server", + "places": [ + "src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts:37" + ] + }, + { + "call": "DHCP is managed manually", + "key": "DHCP is managed manually", + "places": [ + "src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts:36" + ] + }, + { + "call": "Diagnostics", + "key": "Diagnostics", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:437" + ] + }, + { + "call": "Direct", + "key": "Direct", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2563" + ] + }, + { + "call": "Direct connection", + "key": "Direct connection", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2734" + ] + }, + { + "call": "Disable autostart", + "key": "Disable autostart", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:79" + ] + }, + { + "call": "Disable QUIC", + "key": "Disable QUIC", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:445" + ] + }, + { + "call": "Disable the QUIC protocol to improve compatibility or fix issues with video streaming", + "key": "Disable the QUIC protocol to improve compatibility or fix issues with video streaming", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:446" + ] + }, + { + "call": "Disabling interception is incompatible with action=zapret2 because Tachyon sends matched traffic through NFQUEUE.", + "key": "Disabling interception is incompatible with action=zapret2 because Tachyon sends matched traffic through NFQUEUE.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5904" + ] + }, + { + "call": "Display name", + "key": "Display name", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2446", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2905" + ] + }, + { + "call": "DNS Check Interval", + "key": "DNS Check Interval", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:196" + ] + }, + { + "call": "DNS on router", + "key": "DNS on router", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:75" + ] + }, + { + "call": "DNS over HTTPS (DoH)", + "key": "DNS over HTTPS (DoH)", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1743", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:163" + ] + }, + { + "call": "DNS over TLS (DoT)", + "key": "DNS over TLS (DoT)", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1744", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:164" + ] + }, + { + "call": "DNS protocol", + "key": "DNS protocol", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2413", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7063" + ] + }, + { + "call": "DNS Protocol Type", + "key": "DNS Protocol Type", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:160" + ] + }, + { + "call": "DNS protocol used by the resolver", + "key": "DNS protocol used by the resolver", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2414", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7064" + ] + }, + { + "call": "DNS requests through section", + "key": "DNS requests through section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7107", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:259" + ] + }, + { + "call": "DNS Rewrite TTL", + "key": "DNS Rewrite TTL", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:222" + ] + }, + { + "call": "DNS server", + "key": "DNS server", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2423", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7076" + ] + }, + { + "call": "DNS server address cannot be empty", + "key": "DNS server address cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7085", + "src/validators/validateDns.ts:8" + ] + }, + { + "call": "DNS server used by the resolver", + "key": "DNS server used by the resolver", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2424", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7077" + ] + }, + { + "call": "DNS server used to obtain IP addresses for upstream DNS and proxies. If multiple servers are selected, a timeout switches to a backup.", + "key": "DNS server used to obtain IP addresses for upstream DNS and proxies. If multiple servers are selected, a timeout switches to a backup.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:183" + ] + }, + { + "call": "DNS Servers", + "key": "DNS Servers", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:172" + ] + }, + { + "call": "DNS Strategy", + "key": "DNS Strategy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:240" + ] + }, + { + "call": "DNS through proxy", + "key": "DNS through proxy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:251" + ] + }, + { + "call": "DNS through section", + "key": "DNS through section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7095" + ] + }, + { + "call": "DNS Unavailability Timeout", + "key": "DNS Unavailability Timeout", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:212" + ] + }, + { + "call": "Do not panic, everything can be fixed, just...", + "key": "Do not panic, everything can be fixed, just...", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts:26" + ] + }, + { + "call": "Do not test servers from these countries.", + "key": "Do not test servers from these countries.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2621" + ] + }, + { + "call": "Do not test servers whose names match the expression.", + "key": "Do not test servers whose names match the expression.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2637" + ] + }, + { + "call": "Do not test servers with one of the selected protocols.", + "key": "Do not test servers with one of the selected protocols.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2582" + ] + }, + { + "call": "Do not test servers with one of the selected security types.", + "key": "Do not test servers with one of the selected security types.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2588" + ] + }, + { + "call": "Do not test servers with one of the selected transports.", + "key": "Do not test servers with one of the selected transports.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2585" + ] + }, + { + "call": "Do not test specified servers.", + "key": "Do not test specified servers.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2629" + ] + }, + { + "call": "Does not resolve", + "key": "Does not resolve", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:54" + ] + }, + { + "call": "Domain lists", + "key": "Domain lists", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7914" + ] + }, + { + "call": "Domain Resolver", + "key": "Domain Resolver", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2404" + ] + }, + { + "call": "Domains", + "key": "Domains", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7721" + ] + }, + { + "call": "Dont Touch My DHCP!", + "key": "Dont Touch My DHCP!", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:601" + ] + }, + { + "call": "Downlink", + "key": "Downlink", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1559", + "src/tachyon/tabs/dashboard/initController.ts:1597" + ] + }, + { + "call": "Download", + "key": "Download", + "places": [ + "src/partials/modal/renderModal.ts:253" + ] + }, + { + "call": "Download component packages via the selected section", + "key": "Download component packages via the selected section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:582" + ] + }, + { + "call": "Download components through", + "key": "Download components through", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:589" + ] + }, + { + "call": "Download components through a section", + "key": "Download components through a section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:581" + ] + }, + { + "call": "Download lists through", + "key": "Download lists through", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:569" + ] + }, + { + "call": "Download lists through a section", + "key": "Download lists through a section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:561" + ] + }, + { + "call": "Download remote lists and rule sets via the selected section", + "key": "Download remote lists and rule sets via the selected section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:562" + ] + }, + { + "call": "Download subscription through a section", + "key": "Download subscription through a section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2259" + ] + }, + { + "call": "Download subscriptions via the selected section", + "key": "Download subscriptions via the selected section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2260" + ] + }, + { + "call": "Download through", + "key": "Download through", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2268" + ] + }, + { + "call": "Downloaded", + "key": "Downloaded", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:825", + "src/tachyon/tabs/monitoring/initController.ts:879" + ] + }, + { + "call": "Drag rows to change priority. The rule at the top is checked first.", + "key": "Drag rows to change priority. The rule at the top is checked first.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:391" + ] + }, + { + "call": "Duplicate JSON outbound tag", + "key": "Duplicate JSON outbound tag", + "places": [ + "src/validators/validateOutboundJson.ts:75" + ] + }, + { + "call": "Duplicate value", + "key": "Duplicate value", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4568", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4583", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4603" + ] + }, + { + "call": "Edit rule action", + "key": "Edit rule action", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:68", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1738" + ] + }, + { + "call": "Enable", + "key": "Enable", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7003", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2480" + ] + }, + { + "call": "Enable authentication", + "key": "Enable authentication", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2840" + ] + }, + { + "call": "Enable automatic updates for remote lists and rule sets", + "key": "Enable automatic updates for remote lists and rule sets", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:457" + ] + }, + { + "call": "Enable autostart", + "key": "Enable autostart", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:89" + ] + }, + { + "call": "Enable built-in DNS resolver for domains handled by this section", + "key": "Enable built-in DNS resolver for domains handled by this section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2405" + ] + }, + { + "call": "Enable list updates", + "key": "Enable list updates", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:456" + ] + }, + { + "call": "Enable Mixed Proxy", + "key": "Enable Mixed Proxy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7567" + ] + }, + { + "call": "Enable Mixed Proxy Authentication", + "key": "Enable Mixed Proxy Authentication", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7608" + ] + }, + { + "call": "Enable Output Network Interface", + "key": "Enable Output Network Interface", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:304" + ] + }, + { + "call": "Enable YACD", + "key": "Enable YACD", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:413" + ] + }, + { + "call": "Enable YACD WAN Access", + "key": "Enable YACD WAN Access", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:422" + ] + }, + { + "call": "Enter a complete sing-box inbound object", + "key": "Enter a complete sing-box inbound object", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2586" + ] + }, + { + "call": "Enter a complete sing-box outbound object", + "key": "Enter a complete sing-box outbound object", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3815" + ] + }, + { + "call": "Enter a display name", + "key": "Enter a display name", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2458", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2910", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3770", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3786" + ] + }, + { + "call": "Enter a level name", + "key": "Enter a level name", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2707", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2728" + ] + }, + { + "call": "Enter a valid DNS server address", + "key": "Enter a valid DNS server address", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7088" + ] + }, + { + "call": "Enter HWID", + "key": "Enter HWID", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2342" + ] + }, + { + "call": "Enter the HWID sent with subscription requests", + "key": "Enter the HWID sent with subscription requests", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2334" + ] + }, + { + "call": "Enter the subscription URL", + "key": "Enter the subscription URL", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7278" + ] + }, + { + "call": "Exclude by proxy parameters", + "key": "Exclude by proxy parameters", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2577", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2787", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3185" + ] + }, + { + "call": "Exclude by regular expression", + "key": "Exclude by regular expression", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2636", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2849", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3245" + ] + }, + { + "call": "Exclude countries", + "key": "Exclude countries", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2620", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2833", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3229" + ] + }, + { + "call": "Exclude NTP", + "key": "Exclude NTP", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:675" + ] + }, + { + "call": "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN", + "key": "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:676" + ] + }, + { + "call": "Exclude servers", + "key": "Exclude servers", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2628", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2841", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3237" + ] + }, + { + "call": "Exclude servers with one of the selected protocols from this level.", + "key": "Exclude servers with one of the selected protocols from this level.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2795" + ] + }, + { + "call": "Exclude servers with one of the selected security types from this level.", + "key": "Exclude servers with one of the selected security types from this level.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2801" + ] + }, + { + "call": "Exclude servers with one of the selected transports from this level.", + "key": "Exclude servers with one of the selected transports from this level.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2798" + ] + }, + { + "call": "Exclude URLTest / Priority group", + "key": "Exclude URLTest / Priority group", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3300" + ] + }, + { + "call": "Expecting: %s", + "key": "Expecting: %s", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:54" + ] + }, + { + "call": "Expires", + "key": "Expires", + "places": [ + "src/tachyon/tabs/dashboard/partials/renderSections.ts:134" + ] + }, + { + "call": "Expose this section as a local HTTP+SOCKS proxy", + "key": "Expose this section as a local HTTP+SOCKS proxy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7568" + ] + }, + { + "call": "External nfqws config files bypass Tachyon queue management and explicit validation.", + "key": "External nfqws config files bypass Tachyon queue management and explicit validation.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5438" + ] + }, + { + "call": "External nfqws2 config files bypass Tachyon queue management and explicit validation.", + "key": "External nfqws2 config files bypass Tachyon queue management and explicit validation.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5883" + ] + }, + { + "call": "Failed to close connection", + "key": "Failed to close connection", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:1213", + "src/tachyon/tabs/monitoring/initController.ts:1228" + ] + }, + { + "call": "Failed to close connections", + "key": "Failed to close connections", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:1247", + "src/tachyon/tabs/monitoring/initController.ts:1260" + ] + }, + { + "call": "Failed to copy!", + "key": "Failed to copy!", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1567", + "src/helpers/copyToClipboard.ts:12" + ] + }, + { + "call": "Failed to execute", + "key": "Failed to execute", + "places": [ + "src/tachyon/methods/shell/index.ts:170", + "src/tachyon/methods/shell/index.ts:177", + "src/tachyon/tabs/updates/initController.ts:508", + "src/tachyon/tabs/updates/initController.ts:509", + "src/tachyon/tabs/updates/initController.ts:569", + "src/tachyon/tabs/updates/initController.ts:719" + ] + }, + { + "call": "Failed to update subscriptions", + "key": "Failed to update subscriptions", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:275", + "src/tachyon/tabs/dashboard/initController.ts:276", + "src/tachyon/tabs/dashboard/initController.ts:295", + "src/tachyon/tabs/dashboard/initController.ts:341", + "src/tachyon/tabs/dashboard/initController.ts:345", + "src/tachyon/tabs/dashboard/initController.ts:1341", + "src/tachyon/tabs/dashboard/initController.ts:1345" + ] + }, + { + "call": "FakeIP and control checks use different public IPs", + "key": "FakeIP and control checks use different public IPs", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:91" + ] + }, + { + "call": "FakeIP and control checks use the same public IP", + "key": "FakeIP and control checks use the same public IP", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:92" + ] + }, + { + "call": "FakeIP works; public IP comparison is inconclusive", + "key": "FakeIP works; public IP comparison is inconclusive", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:46" + ] + }, + { + "call": "FakeTLS host", + "key": "FakeTLS host", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2963" + ] + }, + { + "call": "Fallback on unknown DC", + "key": "Fallback on unknown DC", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3045" + ] + }, + { + "call": "Faster server search interval", + "key": "Faster server search interval", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2989", + "src/tachyon/tabs/dashboard/initController.ts:1161" + ] + }, + { + "call": "Fastest", + "key": "Fastest", + "places": [ + "src/tachyon/methods/custom/getDashboardSections.ts:641" + ] + }, + { + "call": "Filter the servers that will be displayed on the dashboard.", + "key": "Filter the servers that will be displayed on the dashboard.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3142" + ] + }, + { + "call": "Firewall WAN port", + "key": "Firewall WAN port", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:136" + ] + }, + { + "call": "Fix the highlighted fields and save again.", + "key": "Fix the highlighted fields and save again.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3415", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2242" + ] + }, + { + "call": "Forced device routing", + "key": "Forced device routing", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7938" + ] + }, + { + "call": "Tachyon Error", + "key": "Tachyon Error", + "places": [ + "src/tachyon/services/core.service.ts:52" + ] + }, + { + "call": "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here.", + "key": "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6312" + ] + }, + { + "call": "Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here.", + "key": "Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6309" + ] + }, + { + "call": "Tachyon manages the nfqws process lifecycle itself, so daemon mode is not allowed here.", + "key": "Tachyon manages the nfqws process lifecycle itself, so daemon mode is not allowed here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5456" + ] + }, + { + "call": "Tachyon manages the nfqws2 process lifecycle itself, so daemon mode is not allowed here.", + "key": "Tachyon manages the nfqws2 process lifecycle itself, so daemon mode is not allowed here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5907" + ] + }, + { + "call": "Tachyon service is stopped. Start the service to display connections.", + "key": "Tachyon service is stopped. Start the service to display connections.", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:925" + ] + }, + { + "call": "Tachyon service is stopped. Start the service to display the dashboard.", + "key": "Tachyon service is stopped. Start the service to display the dashboard.", + "places": [ + "src/tachyon/tabs/dashboard/render.ts:17" + ] + }, + { + "call": "Tachyon Settings", + "key": "Tachyon Settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:342" + ] + }, + { + "call": "Tachyon will not modify your DHCP configuration", + "key": "Tachyon will not modify your DHCP configuration", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:602" + ] + }, + { + "call": "Tachyon-managed ciadpi runtime has restarted", + "key": "Tachyon-managed ciadpi runtime has restarted", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:91" + ] + }, + { + "call": "Tachyon-managed ciadpi runtime is not ready", + "key": "Tachyon-managed ciadpi runtime is not ready", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:94" + ] + }, + { + "call": "Tachyon-managed ciadpi runtime is not running", + "key": "Tachyon-managed ciadpi runtime is not running", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:97" + ] + }, + { + "call": "Tachyon-managed ciadpi runtime is ready", + "key": "Tachyon-managed ciadpi runtime is ready", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:93" + ] + }, + { + "call": "Tachyon-managed nfqws runtime is not ready", + "key": "Tachyon-managed nfqws runtime is not ready", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:87" + ] + }, + { + "call": "Tachyon-managed nfqws runtime is not running", + "key": "Tachyon-managed nfqws runtime is not running", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:90" + ] + }, + { + "call": "Tachyon-managed nfqws runtime is ready", + "key": "Tachyon-managed nfqws runtime is ready", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:86" + ] + }, + { + "call": "Tachyon-managed nfqws2 runtime is not ready", + "key": "Tachyon-managed nfqws2 runtime is not ready", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:90" + ] + }, + { + "call": "Tachyon-managed nfqws2 runtime is not running", + "key": "Tachyon-managed nfqws2 runtime is not running", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:93" + ] + }, + { + "call": "Tachyon-managed nfqws2 runtime is ready", + "key": "Tachyon-managed nfqws2 runtime is ready", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:89" + ] + }, + { + "call": "Fronting IP", + "key": "Fronting IP", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3008" + ] + }, + { + "call": "Fronting port", + "key": "Fronting port", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2997" + ] + }, + { + "call": "Fronting proxy protocol", + "key": "Fronting proxy protocol", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3018" + ] + }, + { + "call": "Fuzzing is not supported here because Tachyon needs deterministic runtime validation.", + "key": "Fuzzing is not supported here because Tachyon needs deterministic runtime validation.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5901" + ] + }, + { + "call": "Generate HWID from router hardware information for this subscription", + "key": "Generate HWID from router hardware information for this subscription", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2325" + ] + }, + { + "call": "Generated inbound", + "key": "Generated inbound", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:89" + ] + }, + { + "call": "Get global check", + "key": "Get global check", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:98" + ] + }, + { + "call": "Global check", + "key": "Global check", + "places": [ + "src/tachyon/tabs/diagnostic/initController.ts:682" + ] + }, + { + "call": "gRPC service", + "key": "gRPC service", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2820" + ] + }, + { + "call": "Handshake timeout", + "key": "Handshake timeout", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3073" + ] + }, + { + "call": "Hide cascade connection nodes", + "key": "Hide cascade connection nodes", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2393" + ] + }, + { + "call": "Hide individual nodes that are already included in imported subscription URLTest groups", + "key": "Hide individual nodes that are already included in imported subscription URLTest groups", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2382" + ] + }, + { + "call": "Hide intermediate nodes used as detours by other subscription nodes", + "key": "Hide intermediate nodes used as detours by other subscription nodes", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2394" + ] + }, + { + "call": "Hide selected servers.", + "key": "Hide selected servers.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3238" + ] + }, + { + "call": "Hide servers from selected groups without repeating their filtering criteria.", + "key": "Hide servers from selected groups without repeating their filtering criteria.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3301" + ] + }, + { + "call": "Hide servers from the specified countries.", + "key": "Hide servers from the specified countries.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3230" + ] + }, + { + "call": "Hide servers whose names match the expression.", + "key": "Hide servers whose names match the expression.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3246" + ] + }, + { + "call": "Hide servers with one of the selected protocols.", + "key": "Hide servers with one of the selected protocols.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3193" + ] + }, + { + "call": "Hide servers with one of the selected security types.", + "key": "Hide servers with one of the selected security types.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3197" + ] + }, + { + "call": "Hide servers with one of the selected transports.", + "key": "Hide servers with one of the selected transports.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3194" + ] + }, + { + "call": "Hide URLTest group nodes", + "key": "Hide URLTest group nodes", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2381" + ] + }, + { + "call": "Hide values", + "key": "Hide values", + "places": [ + "src/partials/modal/renderModal.ts:310" + ] + }, + { + "call": "Higher-level check interval", + "key": "Higher-level check interval", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2953", + "src/tachyon/tabs/dashboard/initController.ts:1147" + ] + }, + { + "call": "Higher-priority DNS Check", + "key": "Higher-priority DNS Check", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:204" + ] + }, + { + "call": "Host", + "key": "Host", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:815", + "src/tachyon/tabs/monitoring/initController.ts:875" + ] + }, + { + "call": "How often higher priority levels are checked while a lower level is active", + "key": "How often higher priority levels are checked while a lower level is active", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2954" + ] + }, + { + "call": "How often the currently selected server is checked", + "key": "How often the currently selected server is checked", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2930" + ] + }, + { + "call": "How often to check the active DNS servers.", + "key": "How often to check the active DNS servers.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:197" + ] + }, + { + "call": "How often to check whether a higher-priority DNS server has recovered.", + "key": "How often to check whether a higher-priority DNS server has recovered.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:205" + ] + }, + { + "call": "HTTP error", + "key": "HTTP error", + "places": [ + "src/tachyon/api.ts:27" + ] + }, + { + "call": "HTTP hosts", + "key": "HTTP hosts", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2813" + ] + }, + { + "call": "HWID", + "key": "HWID", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2333" + ] + }, + { + "call": "Idle timeout", + "key": "Idle timeout", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2503", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3063", + "src/tachyon/tabs/dashboard/initController.ts:916" + ] + }, + { + "call": "Import subscription URLTest groups", + "key": "Import subscription URLTest groups", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2372" + ] + }, + { + "call": "Import URLTest groups returned by this subscription provider", + "key": "Import URLTest groups returned by this subscription provider", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2373" + ] + }, + { + "call": "Inbound JSON", + "key": "Inbound JSON", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2585" + ] + }, + { + "call": "Inbound JSON cannot be empty", + "key": "Inbound JSON cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1971" + ] + }, + { + "call": "Inbound JSON must be an object with a type field", + "key": "Inbound JSON must be an object with a type field", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1982" + ] + }, + { + "call": "Include by proxy parameters", + "key": "Include by proxy parameters", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2560", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2769", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3165" + ] + }, + { + "call": "Include by regular expression", + "key": "Include by regular expression", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2612", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2825", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3221" + ] + }, + { + "call": "Include countries", + "key": "Include countries", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2596", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2809", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3205" + ] + }, + { + "call": "Include IP addresses and subnets", + "key": "Include IP addresses and subnets", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3890" + ] + }, + { + "call": "Include servers", + "key": "Include servers", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2604", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2817", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3213" + ] + }, + { + "call": "Information", + "key": "Information", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1746" + ] + }, + { + "call": "Install", + "key": "Install", + "places": [ + "src/tachyon/tabs/updates/initController.ts:754" + ] + }, + { + "call": "Install another build:", + "key": "Install another build:", + "places": [ + "src/tachyon/tabs/updates/initController.ts:1159" + ] + }, + { + "call": "Installed version is newer than release", + "key": "Installed version is newer than release", + "places": [ + "src/tachyon/tabs/updates/initController.ts:291", + "src/tachyon/tabs/updates/initController.ts:1017" + ] + }, + { + "call": "Interface Monitoring", + "key": "Interface Monitoring", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:365" + ] + }, + { + "call": "Interface Monitoring Delay", + "key": "Interface Monitoring Delay", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:397" + ] + }, + { + "call": "Interface monitoring for Bad WAN", + "key": "Interface monitoring for Bad WAN", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:366" + ] + }, + { + "call": "Interrupt connections", + "key": "Interrupt connections", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2517", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3005", + "src/tachyon/tabs/dashboard/initController.ts:918", + "src/tachyon/tabs/dashboard/initController.ts:1167" + ] + }, + { + "call": "Interrupt connections when priority failover switches server", + "key": "Interrupt connections when priority failover switches server", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3006" + ] + }, + { + "call": "Interrupt connections when URLTest switches the selected server", + "key": "Interrupt connections when URLTest switches the selected server", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2518" + ] + }, + { + "call": "Interval", + "key": "Interval", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:914" + ] + }, + { + "call": "Invalid ByeDPI strategy", + "key": "Invalid ByeDPI strategy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7235" + ] + }, + { + "call": "Invalid CIDR prefix", + "key": "Invalid CIDR prefix", + "places": [ + "src/validators/validateSubnet.ts:25" + ] + }, + { + "call": "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH", + "key": "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH", + "places": [ + "src/validators/validateDns.ts:30" + ] + }, + { + "call": "Invalid domain address", + "key": "Invalid domain address", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5111", + "src/validators/validateDomain.ts:67", + "src/validators/validateDomain.ts:73" + ] + }, + { + "call": "Invalid format. Use an IP address or CIDR subnet", + "key": "Invalid format. Use an IP address or CIDR subnet", + "places": [ + "src/validators/validateSubnet.ts:10" + ] + }, + { + "call": "Invalid HTTP proxy URL: invalid host and port", + "key": "Invalid HTTP proxy URL: invalid host and port", + "places": [ + "src/validators/validateHttpProxyUrl.ts:57" + ] + }, + { + "call": "Invalid HTTP proxy URL: invalid host format", + "key": "Invalid HTTP proxy URL: invalid host format", + "places": [ + "src/validators/validateHttpProxyUrl.ts:87" + ] + }, + { + "call": "Invalid HTTP proxy URL: invalid port number", + "key": "Invalid HTTP proxy URL: invalid port number", + "places": [ + "src/validators/validateHttpProxyUrl.ts:80" + ] + }, + { + "call": "Invalid HTTP proxy URL: missing host and port", + "key": "Invalid HTTP proxy URL: missing host and port", + "places": [ + "src/validators/validateHttpProxyUrl.ts:49" + ] + }, + { + "call": "Invalid HTTP proxy URL: missing hostname or IP", + "key": "Invalid HTTP proxy URL: missing hostname or IP", + "places": [ + "src/validators/validateHttpProxyUrl.ts:65" + ] + }, + { + "call": "Invalid HTTP proxy URL: missing port", + "key": "Invalid HTTP proxy URL: missing port", + "places": [ + "src/validators/validateHttpProxyUrl.ts:72" + ] + }, + { + "call": "Invalid HTTP proxy URL: missing username", + "key": "Invalid HTTP proxy URL: missing username", + "places": [ + "src/validators/validateHttpProxyUrl.ts:41" + ] + }, + { + "call": "Invalid HTTP proxy URL: must not contain spaces", + "key": "Invalid HTTP proxy URL: must not contain spaces", + "places": [ + "src/validators/validateHttpProxyUrl.ts:18" + ] + }, + { + "call": "Invalid HTTP proxy URL: must start with http:// or https://", + "key": "Invalid HTTP proxy URL: must start with http:// or https://", + "places": [ + "src/validators/validateHttpProxyUrl.ts:9" + ] + }, + { + "call": "Invalid HTTP proxy URL: parsing failed", + "key": "Invalid HTTP proxy URL: parsing failed", + "places": [ + "src/validators/validateHttpProxyUrl.ts:93" + ] + }, + { + "call": "Invalid HTTP proxy URL: path, query, and fragment are not supported", + "key": "Invalid HTTP proxy URL: path, query, and fragment are not supported", + "places": [ + "src/validators/validateHttpProxyUrl.ts:26" + ] + }, + { + "call": "Invalid HY2 URL: insecure must be 0 or 1", + "key": "Invalid HY2 URL: insecure must be 0 or 1", + "places": [ + "src/validators/validateHysteriaUrl.ts:115" + ] + }, + { + "call": "Invalid HY2 URL: invalid host & port", + "key": "Invalid HY2 URL: invalid host & port", + "places": [ + "src/validators/validateHysteriaUrl.ts:51" + ] + }, + { + "call": "Invalid HY2 URL: invalid port number", + "key": "Invalid HY2 URL: invalid port number", + "places": [ + "src/validators/validateHysteriaUrl.ts:101", + "src/validators/validateHysteriaUrl.ts:157" + ] + }, + { + "call": "Invalid HY2 URL: missing credentials/server", + "key": "Invalid HY2 URL: missing credentials/server", + "places": [ + "src/validators/validateHysteriaUrl.ts:33" + ] + }, + { + "call": "Invalid HY2 URL: missing host", + "key": "Invalid HY2 URL: missing host", + "places": [ + "src/validators/validateHysteriaUrl.ts:57" + ] + }, + { + "call": "Invalid HY2 URL: missing host & port", + "key": "Invalid HY2 URL: missing host & port", + "places": [ + "src/validators/validateHysteriaUrl.ts:44" + ] + }, + { + "call": "Invalid HY2 URL: missing password", + "key": "Invalid HY2 URL: missing password", + "places": [ + "src/validators/validateHysteriaUrl.ts:39" + ] + }, + { + "call": "Invalid HY2 URL: missing port", + "key": "Invalid HY2 URL: missing port", + "places": [ + "src/validators/validateHysteriaUrl.ts:61" + ] + }, + { + "call": "Invalid HY2 URL: must not contain spaces", + "key": "Invalid HY2 URL: must not contain spaces", + "places": [ + "src/validators/validateHysteriaUrl.ts:20" + ] + }, + { + "call": "Invalid HY2 URL: must start with hysteria2:// or hy2://", + "key": "Invalid HY2 URL: must start with hysteria2:// or hy2://", + "places": [ + "src/validators/validateHysteriaUrl.ts:13" + ] + }, + { + "call": "Invalid HY2 URL: obfs-password required when obfs is set", + "key": "Invalid HY2 URL: obfs-password required when obfs is set", + "places": [ + "src/validators/validateHysteriaUrl.ts:138" + ] + }, + { + "call": "Invalid HY2 URL: parsing failed", + "key": "Invalid HY2 URL: parsing failed", + "places": [ + "src/validators/validateHysteriaUrl.ts:164" + ] + }, + { + "call": "Invalid HY2 URL: sni cannot be empty", + "key": "Invalid HY2 URL: sni cannot be empty", + "places": [ + "src/validators/validateHysteriaUrl.ts:147" + ] + }, + { + "call": "Invalid HY2 URL: unsupported obfs type", + "key": "Invalid HY2 URL: unsupported obfs type", + "places": [ + "src/validators/validateHysteriaUrl.ts:127" + ] + }, + { + "call": "Invalid IP address", + "key": "Invalid IP address", + "places": [ + "src/validators/validateIp.ts:28", + "src/validators/validateIp.ts:36", + "src/validators/validateIp.ts:44" + ] + }, + { + "call": "Invalid JSON format", + "key": "Invalid JSON format", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1984", + "src/validators/validateOutboundJson.ts:58" + ] + }, + { + "call": "Invalid NFQWS2 strategy", + "key": "Invalid NFQWS2 strategy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7192" + ] + }, + { + "call": "Invalid path format. Path must start with \"/\" and contain valid characters", + "key": "Invalid path format. Path must start with \"/\" and contain valid characters", + "places": [ + "src/validators/validatePath.ts:22" + ] + }, + { + "call": "Invalid port number. Must be between 1 and 65535", + "key": "Invalid port number. Must be between 1 and 65535", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7601", + "src/validators/validateShadowsocksUrl.ts:93" + ] + }, + { + "call": "Invalid port or range. Use 80 or 1000-2000", + "key": "Invalid port or range. Use 80 or 1000-2000", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5260" + ] + }, + { + "call": "Invalid regular expression", + "key": "Invalid regular expression", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4384" + ] + }, + { + "call": "Invalid Shadowsocks URL: decoded credentials must contain method:password", + "key": "Invalid Shadowsocks URL: decoded credentials must contain method:password", + "places": [ + "src/validators/validateShadowsocksUrl.ts:38" + ] + }, + { + "call": "Invalid Shadowsocks URL: invalid server and port", + "key": "Invalid Shadowsocks URL: invalid server and port", + "places": [ + "src/validators/validateShadowsocksUrl.ts:67" + ] + }, + { + "call": "Invalid Shadowsocks URL: missing credentials", + "key": "Invalid Shadowsocks URL: missing credentials", + "places": [ + "src/validators/validateShadowsocksUrl.ts:28" + ] + }, + { + "call": "Invalid Shadowsocks URL: missing method and password separator \":\"", + "key": "Invalid Shadowsocks URL: missing method and password separator \":\"", + "places": [ + "src/validators/validateShadowsocksUrl.ts:47" + ] + }, + { + "call": "Invalid Shadowsocks URL: missing port", + "key": "Invalid Shadowsocks URL: missing port", + "places": [ + "src/validators/validateShadowsocksUrl.ts:84" + ] + }, + { + "call": "Invalid Shadowsocks URL: missing server", + "key": "Invalid Shadowsocks URL: missing server", + "places": [ + "src/validators/validateShadowsocksUrl.ts:75" + ] + }, + { + "call": "Invalid Shadowsocks URL: missing server address", + "key": "Invalid Shadowsocks URL: missing server address", + "places": [ + "src/validators/validateShadowsocksUrl.ts:59" + ] + }, + { + "call": "Invalid Shadowsocks URL: must not contain spaces", + "key": "Invalid Shadowsocks URL: must not contain spaces", + "places": [ + "src/validators/validateShadowsocksUrl.ts:17" + ] + }, + { + "call": "Invalid Shadowsocks URL: must start with ss://", + "key": "Invalid Shadowsocks URL: must start with ss://", + "places": [ + "src/validators/validateShadowsocksUrl.ts:9" + ] + }, + { + "call": "Invalid Shadowsocks URL: parsing failed", + "key": "Invalid Shadowsocks URL: parsing failed", + "places": [ + "src/validators/validateShadowsocksUrl.ts:99" + ] + }, + { + "call": "Invalid SOCKS URL: invalid host and port", + "key": "Invalid SOCKS URL: invalid host and port", + "places": [ + "src/validators/validateSocksUrl.ts:49" + ] + }, + { + "call": "Invalid SOCKS URL: invalid host format", + "key": "Invalid SOCKS URL: invalid host format", + "places": [ + "src/validators/validateSocksUrl.ts:76" + ] + }, + { + "call": "Invalid SOCKS URL: invalid port number", + "key": "Invalid SOCKS URL: invalid port number", + "places": [ + "src/validators/validateSocksUrl.ts:69" + ] + }, + { + "call": "Invalid SOCKS URL: missing host and port", + "key": "Invalid SOCKS URL: missing host and port", + "places": [ + "src/validators/validateSocksUrl.ts:41" + ] + }, + { + "call": "Invalid SOCKS URL: missing hostname or IP", + "key": "Invalid SOCKS URL: missing hostname or IP", + "places": [ + "src/validators/validateSocksUrl.ts:57" + ] + }, + { + "call": "Invalid SOCKS URL: missing port", + "key": "Invalid SOCKS URL: missing port", + "places": [ + "src/validators/validateSocksUrl.ts:62" + ] + }, + { + "call": "Invalid SOCKS URL: missing username", + "key": "Invalid SOCKS URL: missing username", + "places": [ + "src/validators/validateSocksUrl.ts:33" + ] + }, + { + "call": "Invalid SOCKS URL: must not contain spaces", + "key": "Invalid SOCKS URL: must not contain spaces", + "places": [ + "src/validators/validateSocksUrl.ts:18" + ] + }, + { + "call": "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://", + "key": "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://", + "places": [ + "src/validators/validateSocksUrl.ts:9" + ] + }, + { + "call": "Invalid SOCKS URL: parsing failed", + "key": "Invalid SOCKS URL: parsing failed", + "places": [ + "src/validators/validateSocksUrl.ts:80" + ] + }, + { + "call": "Invalid Trojan URL: must not contain spaces", + "key": "Invalid Trojan URL: must not contain spaces", + "places": [ + "src/validators/validateTrojanUrl.ts:16" + ] + }, + { + "call": "Invalid Trojan URL: must start with trojan://", + "key": "Invalid Trojan URL: must start with trojan://", + "places": [ + "src/validators/validateTrojanUrl.ts:9" + ] + }, + { + "call": "Invalid Trojan URL: parsing failed", + "key": "Invalid Trojan URL: parsing failed", + "places": [ + "src/validators/validateTrojanUrl.ts:63" + ] + }, + { + "call": "Invalid URL format", + "key": "Invalid URL format", + "places": [ + "src/validators/validateUrl.ts:9", + "src/validators/validateUrl.ts:37", + "src/validators/validateUrl.ts:40" + ] + }, + { + "call": "Invalid VLESS URL: parsing failed", + "key": "Invalid VLESS URL: parsing failed", + "places": [ + "src/validators/validateVlessUrl.ts:118" + ] + }, + { + "call": "Invalid VMess URL: parsing failed", + "key": "Invalid VMess URL: parsing failed", + "places": [ + "src/validators/validateVmessUrl.ts:68" + ] + }, + { + "call": "IPs", + "key": "IPs", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7750" + ] + }, + { + "call": "IPv4 only", + "key": "IPv4 only", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:242" + ] + }, + { + "call": "IPv6 only", + "key": "IPv6 only", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:244" + ] + }, + { + "call": "Issues detected", + "key": "Issues detected", + "places": [ + "src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts:17", + "src/tachyon/tabs/diagnostic/helpers/getMeta.ts:20" + ] + }, + { + "call": "JSON outbound", + "key": "JSON outbound", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1036", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3814", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4050", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7366" + ] + }, + { + "call": "JSON outbound cannot be empty", + "key": "JSON outbound cannot be empty", + "places": [ + "src/validators/validateOutboundJson.ts:50" + ] + }, + { + "call": "JSON outbound must be a JSON object", + "key": "JSON outbound must be a JSON object", + "places": [ + "src/validators/validateOutboundJson.ts:62" + ] + }, + { + "call": "JSON outbound must contain a non-empty tag field", + "key": "JSON outbound must contain a non-empty tag field", + "places": [ + "src/validators/validateOutboundJson.ts:70" + ] + }, + { + "call": "JSON outbound must contain a non-empty type field", + "key": "JSON outbound must contain a non-empty type field", + "places": [ + "src/validators/validateOutboundJson.ts:66" + ] + }, + { + "call": "JSON outbound settings", + "key": "JSON outbound settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3838" + ] + }, + { + "call": "Keep Priority before latency-sorted servers", + "key": "Keep Priority before latency-sorted servers", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3015" + ] + }, + { + "call": "Keep URLTest before latency-sorted servers", + "key": "Keep URLTest before latency-sorted servers", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2529" + ] + }, + { + "call": "Key path", + "key": "Key path", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2670" + ] + }, + { + "call": "Keyword must not contain spaces or commas", + "key": "Keyword must not contain spaces or commas", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4394" + ] + }, + { + "call": "Latency test failed", + "key": "Latency test failed", + "places": [ + "src/tachyon/methods/shell/index.ts:462", + "src/tachyon/methods/shell/index.ts:483" + ] + }, + { + "call": "Latency test URL", + "key": "Latency test URL", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:545" + ] + }, + { + "call": "Latest version is installed", + "key": "Latest version is installed", + "places": [ + "src/tachyon/tabs/updates/initController.ts:294", + "src/tachyon/tabs/updates/initController.ts:1015" + ] + }, + { + "call": "Latest version:", + "key": "Latest version:", + "places": [ + "src/tachyon/tabs/updates/initController.ts:1017" + ] + }, + { + "call": "Level", + "key": "Level", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1100" + ] + }, + { + "call": "Level name", + "key": "Level name", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2723" + ] + }, + { + "call": "List must be an HTTP(S) URL or a local .lst path", + "key": "List must be an HTTP(S) URL or a local .lst path", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6934" + ] + }, + { + "call": "List Update Frequency", + "key": "List Update Frequency", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:465" + ] + }, + { + "call": "Listen address", + "key": "Listen address", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2535" + ] + }, + { + "call": "Listen port", + "key": "Listen port", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2542" + ] + }, + { + "call": "Listening port", + "key": "Listening port", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:125" + ] + }, + { + "call": "Loading client link...", + "key": "Loading client link...", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1703" + ] + }, + { + "call": "Loading connections", + "key": "Loading connections", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:936", + "src/tachyon/tabs/monitoring/render.ts:88" + ] + }, + { + "call": "Loading...", + "key": "Loading...", + "places": [ + "src/tachyon/tabs/updates/initController.ts:905", + "src/tachyon/tabs/updates/initController.ts:916", + "src/tachyon/tabs/updates/initController.ts:927", + "src/tachyon/tabs/updates/initController.ts:940", + "src/tachyon/tabs/updates/initController.ts:953" + ] + }, + { + "call": "Log Level", + "key": "Log Level", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:659" + ] + }, + { + "call": "Main DNS", + "key": "Main DNS", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:67" + ] + }, + { + "call": "Main DNS server. If multiple servers are selected, a timeout switches to a backup.", + "key": "Main DNS server. If multiple servers are selected, a timeout switches to a backup.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:173" + ] + }, + { + "call": "Match destination IPs or subnets", + "key": "Match destination IPs or subnets", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7751" + ] + }, + { + "call": "Match destination ports. Use a single port or a range", + "key": "Match destination ports. Use a single port or a range", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7948" + ] + }, + { + "call": "Maximum time to wait for example.com to resolve during a DNS health check.", + "key": "Maximum time to wait for example.com to resolve during a DNS health check.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:213" + ] + }, + { + "call": "Memory Usage", + "key": "Memory Usage", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1637" + ] + }, + { + "call": "Method", + "key": "Method", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2827" + ] + }, + { + "call": "Minimum latency difference in milliseconds that triggers switching to a faster server.", + "key": "Minimum latency difference in milliseconds that triggers switching to a faster server.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2477" + ] + }, + { + "call": "Mixed Proxy Password", + "key": "Mixed Proxy Password", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7659" + ] + }, + { + "call": "Mixed Proxy Port", + "key": "Mixed Proxy Port", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7582" + ] + }, + { + "call": "Mixed Proxy Username", + "key": "Mixed Proxy Username", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7623" + ] + }, + { + "call": "Monitored Interfaces", + "key": "Monitored Interfaces", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:374" + ] + }, + { + "call": "Monitoring", + "key": "Monitoring", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:461" + ] + }, + { + "call": "Must be a number in the range of 0 - 10000", + "key": "Must be a number in the range of 0 - 10000", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1973", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1988" + ] + }, + { + "call": "Name displayed on the dashboard", + "key": "Name displayed on the dashboard", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2447", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2906" + ] + }, + { + "call": "Name shown in the priority level list", + "key": "Name shown in the priority level list", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2724" + ] + }, + { + "call": "Network Interface", + "key": "Network Interface", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1788", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7325" + ] + }, + { + "call": "Network interface settings", + "key": "Network interface settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3605" + ] + }, + { + "call": "New version %s is available for %s", + "key": "New version %s is available for %s", + "places": [ + "src/tachyon/services/core.service.ts:38" + ] + }, + { + "call": "NFQUEUE range is available", + "key": "NFQUEUE range is available", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:100", + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:97" + ] + }, + { + "call": "NFQUEUE range overlaps with another rule", + "key": "NFQUEUE range overlaps with another rule", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:99", + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:96" + ] + }, + { + "call": "NFQWS Strategy", + "key": "NFQWS Strategy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7124" + ] + }, + { + "call": "NFQWS strategy cannot be empty", + "key": "NFQWS strategy cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5568" + ] + }, + { + "call": "NFQWS2 Strategy", + "key": "NFQWS2 Strategy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7171" + ] + }, + { + "call": "NFQWS2 strategy cannot be empty", + "key": "NFQWS2 strategy cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6042" + ] + }, + { + "call": "No", + "key": "No", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:762", + "src/tachyon/tabs/dashboard/initController.ts:766", + "src/tachyon/tabs/dashboard/initController.ts:842", + "src/tachyon/tabs/dashboard/initController.ts:1055" + ] + }, + { + "call": "No active connections", + "key": "No active connections", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:960" + ] + }, + { + "call": "No closed connections", + "key": "No closed connections", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:961" + ] + }, + { + "call": "No conflict detected", + "key": "No conflict detected", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:32" + ] + }, + { + "call": "No enabled server inbounds configured", + "key": "No enabled server inbounds configured", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:187" + ] + }, + { + "call": "No other marking rules found", + "key": "No other marking rules found", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:105" + ] + }, + { + "call": "No public firewall port required", + "key": "No public firewall port required", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:99" + ] + }, + { + "call": "No rules use ByeDPI", + "key": "No rules use ByeDPI", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:79" + ] + }, + { + "call": "No rules use Zapret", + "key": "No rules use Zapret", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:79" + ] + }, + { + "call": "No rules use Zapret2", + "key": "No rules use Zapret2", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:82" + ] + }, + { + "call": "Node list is empty", + "key": "Node list is empty", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1012", + "src/tachyon/tabs/dashboard/initController.ts:1268" + ] + }, + { + "call": "Nodes", + "key": "Nodes", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:942", + "src/tachyon/tabs/dashboard/initController.ts:1191" + ] + }, + { + "call": "None", + "key": "None", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:446", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2600", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2892", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2901" + ] + }, + { + "call": "Not configured", + "key": "Not configured", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:46" + ] + }, + { + "call": "Not detected", + "key": "Not detected", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:70", + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:199" + ] + }, + { + "call": "Not implement yet", + "key": "Not implement yet", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderCheckSection.ts:189" + ] + }, + { + "call": "Not installed", + "key": "Not installed", + "places": [ + "src/tachyon/helpers/singBoxVariant.ts:29", + "src/tachyon/helpers/singBoxVariant.ts:37", + "src/tachyon/tabs/updates/initController.ts:930", + "src/tachyon/tabs/updates/initController.ts:943", + "src/tachyon/tabs/updates/initController.ts:956" + ] + }, + { + "call": "Not public", + "key": "Not public", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:62" + ] + }, + { + "call": "Not required for this listen address", + "key": "Not required for this listen address", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:139" + ] + }, + { + "call": "Not responding", + "key": "Not responding", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:90", + "src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:117", + "src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:123", + "src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:151" + ] + }, + { + "call": "Not running", + "key": "Not running", + "places": [ + "src/tachyon/tabs/diagnostic/diagnostic.store.ts:125", + "src/tachyon/tabs/diagnostic/initController.ts:116", + "src/tachyon/tabs/diagnostic/initController.ts:346", + "src/tachyon/tabs/diagnostic/initController.ts:403", + "src/tachyon/tabs/diagnostic/initController.ts:489" + ] + }, + { + "call": "Obfuscation", + "key": "Obfuscation", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2900" + ] + }, + { + "call": "Obfuscation password", + "key": "Obfuscation password", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2923" + ] + }, + { + "call": "Only from the specified countries.", + "key": "Only from the specified countries.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2810" + ] + }, + { + "call": "Only selected", + "key": "Only selected", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1859", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1867" + ] + }, + { + "call": "Only selected except exclusions", + "key": "Only selected except exclusions", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1860", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1869" + ] + }, + { + "call": "Only servers whose names match the expression.", + "key": "Only servers whose names match the expression.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2826" + ] + }, + { + "call": "Only servers with one of the selected protocols.", + "key": "Only servers with one of the selected protocols.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2777" + ] + }, + { + "call": "Only servers with one of the selected security types.", + "key": "Only servers with one of the selected security types.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2781" + ] + }, + { + "call": "Only servers with one of the selected transports.", + "key": "Only servers with one of the selected transports.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2778" + ] + }, + { + "call": "Only the specified servers.", + "key": "Only the specified servers.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2818" + ] + }, + { + "call": "Open", + "key": "Open", + "places": [ + "src/tachyon/tabs/updates/initController.ts:1008", + "src/tachyon/tabs/updates/initController.ts:1030" + ] + }, + { + "call": "Open priority settings and enter a display name", + "key": "Open priority settings and enter a display name", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3782" + ] + }, + { + "call": "Open Project Page", + "key": "Open Project Page", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts:31" + ] + }, + { + "call": "Operation timed out", + "key": "Operation timed out", + "places": [ + "src/helpers/withTimeout.ts:7", + "src/tachyon/methods/shell/index.ts:432", + "src/tachyon/methods/shell/index.ts:520" + ] + }, + { + "call": "Option \"%s\" contains an invalid input value.", + "key": "Option \"%s\" contains an invalid input value.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1353", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4502", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6644" + ] + }, + { + "call": "Optional values must be attached with '=' here; a separate token would be ignored by nfqws.", + "key": "Optional values must be attached with '=' here; a separate token would be ignored by nfqws.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5646" + ] + }, + { + "call": "Optional values must be attached with '=' here; a separate token would be ignored by nfqws2.", + "key": "Optional values must be attached with '=' here; a separate token would be ignored by nfqws2.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6120" + ] + }, + { + "call": "outbounds must be a non-empty array of strings", + "key": "outbounds must be a non-empty array of strings", + "places": [ + "src/validators/validateOutboundJson.ts:111" + ] + }, + { + "call": "Output Network Interface", + "key": "Output Network Interface", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:313" + ] + }, + { + "call": "Padding", + "key": "Padding", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2982" + ] + }, + { + "call": "Password", + "key": "Password", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2863" + ] + }, + { + "call": "Password cannot be empty", + "key": "Password cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7685" + ] + }, + { + "call": "Path cannot be empty", + "key": "Path cannot be empty", + "places": [ + "src/validators/validatePath.ts:7" + ] + }, + { + "call": "Path must be absolute (start with /)", + "key": "Path must be absolute (start with /)", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:641" + ] + }, + { + "call": "Path must contain at least one directory (like /tmp/cache.db)", + "key": "Path must contain at least one directory (like /tmp/cache.db)", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:650" + ] + }, + { + "call": "Path must end with cache.db", + "key": "Path must end with cache.db", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:645" + ] + }, + { + "call": "Pause updates", + "key": "Pause updates", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:691", + "src/tachyon/tabs/monitoring/render.ts:70", + "src/tachyon/tabs/monitoring/render.ts:71" + ] + }, + { + "call": "Pending", + "key": "Pending", + "places": [ + "src/tachyon/tabs/diagnostic/diagnostic.store.ts:63" + ] + }, + { + "call": "Periodically check the current level and switch to a faster server even when the current one works.", + "key": "Periodically check the current level and switch to a faster server even when the current one works.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2979" + ] + }, + { + "call": "Pin on dashboard", + "key": "Pin on dashboard", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2528", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3014" + ] + }, + { + "call": "Port cannot be empty", + "key": "Port cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7593" + ] + }, + { + "call": "Port conflict", + "key": "Port conflict", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:19" + ] + }, + { + "call": "Port for the local mixed proxy of this section", + "key": "Port for the local mixed proxy of this section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7583" + ] + }, + { + "call": "Port must be between 1 and 65535", + "key": "Port must be between 1 and 65535", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5267", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1814" + ] + }, + { + "call": "Port range start must be less than or equal to end", + "key": "Port range start must be less than or equal to end", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5271" + ] + }, + { + "call": "Ports", + "key": "Ports", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7947" + ] + }, + { + "call": "Prefer IPv4", + "key": "Prefer IPv4", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:241" + ] + }, + { + "call": "Prefer IPv6", + "key": "Prefer IPv6", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:243" + ] + }, + { + "call": "Preferred IP", + "key": "Preferred IP", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3025" + ] + }, + { + "call": "Prefix text", + "key": "Prefix text", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2365" + ] + }, + { + "call": "Priority", + "key": "Priority", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7452" + ] + }, + { + "call": "Priority details", + "key": "Priority details", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1298", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:404", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:405" + ] + }, + { + "call": "Priority details are unavailable", + "key": "Priority details are unavailable", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1128" + ] + }, + { + "call": "Priority level settings", + "key": "Priority level settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3669", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3670" + ] + }, + { + "call": "Priority levels", + "key": "Priority levels", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3023" + ] + }, + { + "call": "Priority settings", + "key": "Priority settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3727", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3732", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3733" + ] + }, + { + "call": "Protocol", + "key": "Protocol", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1929", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2486", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2520" + ] + }, + { + "call": "Proxy link is unavailable", + "key": "Proxy link is unavailable", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:757" + ] + }, + { + "call": "Public host", + "key": "Public host", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2549", + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:40" + ] + }, + { + "call": "QR code is too large", + "key": "QR code is too large", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1613" + ] + }, + { + "call": "Reality fake site", + "key": "Reality fake site", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2688" + ] + }, + { + "call": "Reality fake site port", + "key": "Reality fake site port", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2699" + ] + }, + { + "call": "Reality max time difference", + "key": "Reality max time difference", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2742" + ] + }, + { + "call": "Reality private key", + "key": "Reality private key", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2710" + ] + }, + { + "call": "Reality public key", + "key": "Reality public key", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2717" + ] + }, + { + "call": "Reality short ID", + "key": "Reality short ID", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2723" + ] + }, + { + "call": "Reality short ID must contain 1-8 hex digits", + "key": "Reality short ID must contain 1-8 hex digits", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2095" + ] + }, + { + "call": "Refill", + "key": "Refill", + "places": [ + "src/tachyon/tabs/dashboard/partials/renderSections.ts:135" + ] + }, + { + "call": "Regional options cannot be used together", + "key": "Regional options cannot be used together", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7805" + ] + }, + { + "call": "Regular expression must not contain spaces or commas", + "key": "Regular expression must not contain spaces or commas", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5153" + ] + }, + { + "call": "Remove", + "key": "Remove", + "places": [ + "src/tachyon/tabs/updates/initController.ts:799" + ] + }, + { + "call": "Remove servers from the specified countries from this level.", + "key": "Remove servers from the specified countries from this level.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2834" + ] + }, + { + "call": "Remove servers whose names match the expression from this level.", + "key": "Remove servers whose names match the expression from this level.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2850" + ] + }, + { + "call": "Remove the specified servers from this level.", + "key": "Remove the specified servers from this level.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2842" + ] + }, + { + "call": "Require a username and password for the local mixed proxy", + "key": "Require a username and password for the local mixed proxy", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7609" + ] + }, + { + "call": "Resolve domain names before routing so sing-box can use real destination IPs.", + "key": "Resolve domain names before routing so sing-box can use real destination IPs.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7696" + ] + }, + { + "call": "Resolve real IP for routing", + "key": "Resolve real IP for routing", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7695" + ] + }, + { + "call": "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE.", + "key": "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5441" + ] + }, + { + "call": "Resource selection by hostname inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE.", + "key": "Resource selection by hostname inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5886" + ] + }, + { + "call": "Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE.", + "key": "Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5444" + ] + }, + { + "call": "Resource selection by IP or CIDR inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE.", + "key": "Resource selection by IP or CIDR inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5889" + ] + }, + { + "call": "Restart Tachyon", + "key": "Restart Tachyon", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:49" + ] + }, + { + "call": "Resume updates", + "key": "Resume updates", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:691" + ] + }, + { + "call": "Route", + "key": "Route", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:819", + "src/tachyon/tabs/monitoring/initController.ts:877" + ] + }, + { + "call": "Route main DNS requests through the selected section.", + "key": "Route main DNS requests through the selected section.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:252" + ] + }, + { + "call": "Route requests to this DNS server through another section.", + "key": "Route requests to this DNS server through another section.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7096" + ] + }, + { + "call": "Routing mode", + "key": "Routing mode", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2561" + ] + }, + { + "call": "Routing rules", + "key": "Routing rules", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:103", + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:114", + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:144" + ] + }, + { + "call": "Routing section", + "key": "Routing section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2570" + ] + }, + { + "call": "Rule set must be an HTTP(S) URL or a local .srs / .json path", + "key": "Rule set must be an HTTP(S) URL or a local .srs / .json path", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6925" + ] + }, + { + "call": "Rule set settings", + "key": "Rule set settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3897" + ] + }, + { + "call": "Rule sets", + "key": "Rule sets", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7860", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7889" + ] + }, + { + "call": "Rules", + "key": "Rules", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2562" + ] + }, + { + "call": "Rules mangle counters", + "key": "Rules mangle counters", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:79" + ] + }, + { + "call": "Rules mangle exist", + "key": "Rules mangle exist", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:74" + ] + }, + { + "call": "Rules mangle output counters", + "key": "Rules mangle output counters", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:89" + ] + }, + { + "call": "Rules mangle output exist", + "key": "Rules mangle output exist", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:84" + ] + }, + { + "call": "Rules proxy counters", + "key": "Rules proxy counters", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:99" + ] + }, + { + "call": "Rules proxy exist", + "key": "Rules proxy exist", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:94" + ] + }, + { + "call": "Run Diagnostic", + "key": "Run Diagnostic", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderRunAction.ts:17" + ] + }, + { + "call": "Russia inside restrictions", + "key": "Russia inside restrictions", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7824" + ] + }, + { + "call": "Save", + "key": "Save", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3490" + ] + }, + { + "call": "Search", + "key": "Search", + "places": [ + "src/tachyon/tabs/monitoring/render.ts:47" + ] + }, + { + "call": "Secret", + "key": "Secret", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2939" + ] + }, + { + "call": "Secret key for authenticating remote access to YACD when WAN access is enabled.", + "key": "Secret key for authenticating remote access to YACD when WAN access is enabled.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:435" + ] + }, + { + "call": "Section", + "key": "Section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:396" + ] + }, + { + "call": "Section name", + "key": "Section name", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7029" + ] + }, + { + "call": "Sections", + "key": "Sections", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:390" + ] + }, + { + "call": "Security", + "key": "Security", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1941", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2597" + ] + }, + { + "call": "Select a common client profile or enter a custom User-Agent", + "key": "Select a common client profile or enter a custom User-Agent", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2305" + ] + }, + { + "call": "Select a network interface", + "key": "Select a network interface", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7331" + ] + }, + { + "call": "Select a predefined domain list", + "key": "Select a predefined domain list", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7761" + ] + }, + { + "call": "Select a section", + "key": "Select a section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7117", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:84" + ] + }, + { + "call": "Select a section for downloading this subscription", + "key": "Select a section for downloading this subscription", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2282" + ] + }, + { + "call": "Select a transit section", + "key": "Select a transit section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7530" + ] + }, + { + "call": "Select an existing network interface", + "key": "Select an existing network interface", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1839" + ] + }, + { + "call": "Select an existing URLTest or Priority group", + "key": "Select an existing URLTest or Priority group", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3125" + ] + }, + { + "call": "Select an intermediate section", + "key": "Select an intermediate section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7543" + ] + }, + { + "call": "Select DNS protocol to use", + "key": "Select DNS protocol to use", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:161" + ] + }, + { + "call": "Select network interface for VPN connection", + "key": "Select network interface for VPN connection", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7326" + ] + }, + { + "call": "Select or enter a User-Agent", + "key": "Select or enter a User-Agent", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2318" + ] + }, + { + "call": "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing", + "key": "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:624" + ] + }, + { + "call": "Select path for sing-box config file. Change this ONLY if you know what you are doing", + "key": "Select path for sing-box config file. Change this ONLY if you know what you are doing", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:611" + ] + }, + { + "call": "Select the fastest node", + "key": "Select the fastest node", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2967", + "src/tachyon/tabs/dashboard/initController.ts:1151" + ] + }, + { + "call": "Select the log level for sing-box", + "key": "Select the log level for sing-box", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:660" + ] + }, + { + "call": "Select the network interface from which the traffic will originate", + "key": "Select the network interface from which the traffic will originate", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:268" + ] + }, + { + "call": "Select the network interface to which the traffic will originate", + "key": "Select the network interface to which the traffic will originate", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:314" + ] + }, + { + "call": "Select the WAN interfaces to be monitored", + "key": "Select the WAN interfaces to be monitored", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:375" + ] + }, + { + "call": "Selected", + "key": "Selected", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:910", + "src/tachyon/tabs/dashboard/initController.ts:1137" + ] + }, + { + "call": "Selected section", + "key": "Selected section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2564" + ] + }, + { + "call": "Selector and URLTest outbounds must contain a non-empty outbounds array", + "key": "Selector and URLTest outbounds must contain a non-empty outbounds array", + "places": [ + "src/validators/validateOutboundJson.ts:85" + ] + }, + { + "call": "Server", + "key": "Server", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:413" + ] + }, + { + "call": "Server filtering", + "key": "Server filtering", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2537", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2743" + ] + }, + { + "call": "Server group for automatic lowest-latency selection", + "key": "Server group for automatic lowest-latency selection", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7394" + ] + }, + { + "call": "Server group for priority failover", + "key": "Server group for priority failover", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7453" + ] + }, + { + "call": "Server information", + "key": "Server information", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1707" + ] + }, + { + "call": "Server name", + "key": "Server name", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1623", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2497" + ] + }, + { + "call": "Server name / SNI", + "key": "Server name / SNI", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2626" + ] + }, + { + "call": "Server outbound must contain a non-empty server field", + "key": "Server outbound must contain a non-empty server field", + "places": [ + "src/validators/validateOutboundJson.ts:94" + ] + }, + { + "call": "Server outbound must contain a numeric server_port from 1 to 65535", + "key": "Server outbound must contain a numeric server_port from 1 to 65535", + "places": [ + "src/validators/validateOutboundJson.ts:100" + ] + }, + { + "call": "server_port must be a number from 1 to 65535", + "key": "server_port must be a number from 1 to 65535", + "places": [ + "src/validators/validateOutboundJson.ts:107" + ] + }, + { + "call": "Servers", + "key": "Servers", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:407" + ] + }, + { + "call": "Servers on dashboard", + "key": "Servers on dashboard", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3141" + ] + }, + { + "call": "Service action failed", + "key": "Service action failed", + "places": [ + "src/tachyon/methods/shell/index.ts:383", + "src/tachyon/methods/shell/index.ts:404", + "src/tachyon/tabs/diagnostic/initController.ts:594" + ] + }, + { + "call": "Service list", + "key": "Service list", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7764" + ] + }, + { + "call": "Services info", + "key": "Services info", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1670" + ] + }, + { + "call": "Settings", + "key": "Settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:425", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1097", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7000" + ] + }, + { + "call": "Show metadata on dashboard", + "key": "Show metadata on dashboard", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2348" + ] + }, + { + "call": "Show only selected servers.", + "key": "Show only selected servers.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3214" + ] + }, + { + "call": "Show only servers with one of the selected protocols.", + "key": "Show only servers with one of the selected protocols.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3173" + ] + }, + { + "call": "Show only servers with one of the selected security types.", + "key": "Show only servers with one of the selected security types.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3179" + ] + }, + { + "call": "Show only servers with one of the selected transports.", + "key": "Show only servers with one of the selected transports.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3176" + ] + }, + { + "call": "Show servers from selected groups without repeating their filtering criteria.", + "key": "Show servers from selected groups without repeating their filtering criteria.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3287" + ] + }, + { + "call": "Show servers only from the specified countries.", + "key": "Show servers only from the specified countries.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3206" + ] + }, + { + "call": "Show servers whose names match the expression.", + "key": "Show servers whose names match the expression.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3222" + ] + }, + { + "call": "Show sing-box config", + "key": "Show sing-box config", + "places": [ + "src/tachyon/tabs/diagnostic/initController.ts:751", + "src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:116" + ] + }, + { + "call": "Show subscription metadata for this source on the dashboard", + "key": "Show subscription metadata for this source on the dashboard", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2349" + ] + }, + { + "call": "Sing-box autostart disabled", + "key": "Sing-box autostart disabled", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:77" + ] + }, + { + "call": "Sing-box FakeIP DNS does not work", + "key": "Sing-box FakeIP DNS does not work", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:69" + ] + }, + { + "call": "Sing-box FakeIP DNS works", + "key": "Sing-box FakeIP DNS works", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:68" + ] + }, + { + "call": "Sing-box installed", + "key": "Sing-box installed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:62" + ] + }, + { + "call": "Sing-box listening ports", + "key": "Sing-box listening ports", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:87" + ] + }, + { + "call": "Sing-box process running", + "key": "Sing-box process running", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:82" + ] + }, + { + "call": "Sing-box service exist", + "key": "Sing-box service exist", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:72" + ] + }, + { + "call": "Sing-box version is compatible (newer than 1.12.4)", + "key": "Sing-box version is compatible (newer than 1.12.4)", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:67" + ] + }, + { + "call": "Sort by latency", + "key": "Sort by latency", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7555" + ] + }, + { + "call": "Sorts servers in this section by lowest latency in the dashboard.", + "key": "Sorts servers in this section by lowest latency in the dashboard.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7556" + ] + }, + { + "call": "Source", + "key": "Source", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:831", + "src/tachyon/tabs/monitoring/initController.ts:881" + ] + }, + { + "call": "Source Network Interface", + "key": "Source Network Interface", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:267" + ] + }, + { + "call": "Specify a file path", + "key": "Specify a file path", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1930" + ] + }, + { + "call": "Specify different files", + "key": "Specify different files", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1941", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1953" + ] + }, + { + "call": "Standalone ByeDPI autostart is enabled", + "key": "Standalone ByeDPI autostart is enabled", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:122" + ] + }, + { + "call": "Standalone ByeDPI is active together with Tachyon ByeDPI rules", + "key": "Standalone ByeDPI is active together with Tachyon ByeDPI rules", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:119" + ] + }, + { + "call": "Standalone ByeDPI service is active", + "key": "Standalone ByeDPI service is active", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:120" + ] + }, + { + "call": "Standalone ByeDPI service is inactive", + "key": "Standalone ByeDPI service is inactive", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:123" + ] + }, + { + "call": "Standalone Zapret is active together with Tachyon Zapret rules", + "key": "Standalone Zapret is active together with Tachyon Zapret rules", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:111" + ] + }, + { + "call": "Standalone Zapret service is active", + "key": "Standalone Zapret service is active", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:112" + ] + }, + { + "call": "Standalone Zapret service is inactive", + "key": "Standalone Zapret service is inactive", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:113" + ] + }, + { + "call": "Standalone Zapret2 autostart is enabled", + "key": "Standalone Zapret2 autostart is enabled", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:121" + ] + }, + { + "call": "Standalone Zapret2 is active together with Tachyon Zapret2 rules", + "key": "Standalone Zapret2 is active together with Tachyon Zapret2 rules", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:118" + ] + }, + { + "call": "Standalone Zapret2 service is active", + "key": "Standalone Zapret2 service is active", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:119" + ] + }, + { + "call": "Standalone Zapret2 service is inactive", + "key": "Standalone Zapret2 service is inactive", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:122" + ] + }, + { + "call": "Start Tachyon", + "key": "Start Tachyon", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:69" + ] + }, + { + "call": "Stop checking when URLTest group is not used. Use sing-box duration format like 1d, 12h or 30m.", + "key": "Stop checking when URLTest group is not used. Use sing-box duration format like 1d, 12h or 30m.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2504" + ] + }, + { + "call": "Stop Tachyon", + "key": "Stop Tachyon", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:59" + ] + }, + { + "call": "Subnets from the list will be extracted and added to nftables", + "key": "Subnets from the list will be extracted and added to nftables", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3891" + ] + }, + { + "call": "Subscription auto update", + "key": "Subscription auto update", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2236" + ] + }, + { + "call": "Subscription info:", + "key": "Subscription info:", + "places": [ + "src/tachyon/tabs/dashboard/partials/renderSections.ts:149" + ] + }, + { + "call": "Subscription update failed", + "key": "Subscription update failed", + "places": [ + "src/tachyon/methods/shell/index.ts:720", + "src/tachyon/methods/shell/index.ts:740" + ] + }, + { + "call": "Subscription update interval", + "key": "Subscription update interval", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2245" + ] + }, + { + "call": "Subscription URL", + "key": "Subscription URL", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7277" + ] + }, + { + "call": "Subscription URL cannot be empty", + "key": "Subscription URL cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4513" + ] + }, + { + "call": "Subscription URL settings", + "key": "Subscription URL settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3594" + ] + }, + { + "call": "Subscriptions updated", + "key": "Subscriptions updated", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:301" + ] + }, + { + "call": "System info", + "key": "System info", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1630" + ] + }, + { + "call": "System information", + "key": "System information", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderSystemInfo.ts:21" + ] + }, + { + "call": "Table exist", + "key": "Table exist", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:69" + ] + }, + { + "call": "Tailscale auth key", + "key": "Tailscale auth key", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3081" + ] + }, + { + "call": "Tailscale control URL", + "key": "Tailscale control URL", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3090" + ] + }, + { + "call": "Tailscale endpoint", + "key": "Tailscale endpoint", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:98" + ] + }, + { + "call": "Tailscale hostname", + "key": "Tailscale hostname", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3098" + ] + }, + { + "call": "Test latency", + "key": "Test latency", + "places": [ + "src/tachyon/tabs/dashboard/partials/renderSections.ts:242", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:250", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:491" + ] + }, + { + "call": "Test only selected servers.", + "key": "Test only selected servers.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2605" + ] + }, + { + "call": "Test only servers with one of the selected protocols.", + "key": "Test only servers with one of the selected protocols.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2565" + ] + }, + { + "call": "Test only servers with one of the selected security types.", + "key": "Test only servers with one of the selected security types.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2571" + ] + }, + { + "call": "Test only servers with one of the selected transports.", + "key": "Test only servers with one of the selected transports.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2568" + ] + }, + { + "call": "Test servers only from the specified countries.", + "key": "Test servers only from the specified countries.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2597" + ] + }, + { + "call": "Test servers whose names match the expression.", + "key": "Test servers whose names match the expression.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2613" + ] + }, + { + "call": "Testing URL", + "key": "Testing URL", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:913" + ] + }, + { + "call": "The desync fwmark is managed by Tachyon for loop prevention and must not be overridden here.", + "key": "The desync fwmark is managed by Tachyon for loop prevention and must not be overridden here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5453", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5898" + ] + }, + { + "call": "The NFQUEUE number is assigned by Tachyon for each rule and must not be overridden here.", + "key": "The NFQUEUE number is assigned by Tachyon for each rule and must not be overridden here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5450", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5895" + ] + }, + { + "call": "The rule applies to the domain and all its subdomains. Use full:, keyword:, or regex: prefixes for exact match, keyword match, or regular expression.", + "key": "The rule applies to the domain and all its subdomains. Use full:, keyword:, or regex: prefixes for exact match, keyword match, or regular expression.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7722" + ] + }, + { + "call": "There are rules using ByeDPI", + "key": "There are rules using ByeDPI", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:78" + ] + }, + { + "call": "There are rules using Zapret", + "key": "There are rules using Zapret", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:78" + ] + }, + { + "call": "There are rules using Zapret2", + "key": "There are rules using Zapret2", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:81" + ] + }, + { + "call": "This field is required", + "key": "This field is required", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1829", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1834", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1924", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2086", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2886" + ] + }, + { + "call": "This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here.", + "key": "This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6315" + ] + }, + { + "call": "This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here.", + "key": "This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5459" + ] + }, + { + "call": "This field must start a working nfqws strategy; --version exits immediately and is not allowed here.", + "key": "This field must start a working nfqws strategy; --version exits immediately and is not allowed here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5462" + ] + }, + { + "call": "This field must start a working nfqws2 strategy; --dry-run exits immediately and is not allowed here.", + "key": "This field must start a working nfqws2 strategy; --dry-run exits immediately and is not allowed here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5910" + ] + }, + { + "call": "This field must start a working nfqws2 strategy; --version exits immediately and is not allowed here.", + "key": "This field must start a working nfqws2 strategy; --version exits immediately and is not allowed here.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5913" + ] + }, + { + "call": "This flag does not accept a value.", + "key": "This flag does not accept a value.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5631", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6105" + ] + }, + { + "call": "This option requires a value.", + "key": "This option requires a value.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5662", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6136" + ] + }, + { + "call": "Time", + "key": "Time", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:822", + "src/tachyon/tabs/monitoring/initController.ts:878" + ] + }, + { + "call": "Time in seconds for DNS record caching (default: 60)", + "key": "Time in seconds for DNS record caching (default: 60)", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:223" + ] + }, + { + "call": "Time skew tolerance", + "key": "Time skew tolerance", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3055" + ] + }, + { + "call": "tiny", + "key": "tiny", + "places": [ + "src/tachyon/helpers/singBoxVariant.ts:53" + ] + }, + { + "call": "Tolerance", + "key": "Tolerance", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2476", + "src/tachyon/tabs/dashboard/initController.ts:915" + ] + }, + { + "call": "Top level has the highest priority; lower levels are used as fallback", + "key": "Top level has the highest priority; lower levels are used as fallback", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3024" + ] + }, + { + "call": "Traffic", + "key": "Traffic", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1556", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:130" + ] + }, + { + "call": "Traffic for this level goes directly.", + "key": "Traffic for this level goes directly.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2735" + ] + }, + { + "call": "Traffic Total", + "key": "Traffic Total", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1590" + ] + }, + { + "call": "Transparent proxy mode is incompatible with action=byedpi because Tachyon connects to ciadpi through SOCKS.", + "key": "Transparent proxy mode is incompatible with action=byedpi because Tachyon connects to ciadpi through SOCKS.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6306" + ] + }, + { + "call": "Transport", + "key": "Transport", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1935", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2764" + ] + }, + { + "call": "Transport host", + "key": "Transport host", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2788" + ] + }, + { + "call": "Transport path", + "key": "Transport path", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2772" + ] + }, + { + "call": "Transport path must start with /", + "key": "Transport path must start with /", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2007" + ] + }, + { + "call": "Troubleshooting", + "key": "Troubleshooting", + "places": [ + "src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts:25" + ] + }, + { + "call": "Try compatible User-Agent profiles automatically when downloading this subscription", + "key": "Try compatible User-Agent profiles automatically when downloading this subscription", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2294" + ] + }, + { + "call": "TTL must be a positive number", + "key": "TTL must be a positive number", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:234" + ] + }, + { + "call": "TTL value cannot be empty", + "key": "TTL value cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:229" + ] + }, + { + "call": "Type", + "key": "Type", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1638", + "src/tachyon/tabs/monitoring/initController.ts:816", + "src/tachyon/tabs/monitoring/initController.ts:876" + ] + }, + { + "call": "UDP (Unprotected DNS)", + "key": "UDP (Unprotected DNS)", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:165" + ] + }, + { + "call": "Unable to validate the ByeDPI strategy through the backend parser.", + "key": "Unable to validate the ByeDPI strategy through the backend parser.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6230" + ] + }, + { + "call": "Unable to validate the NFQWS strategy through the backend parser.", + "key": "Unable to validate the NFQWS strategy through the backend parser.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5373", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6708", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7151" + ] + }, + { + "call": "Unable to validate the NFQWS2 strategy through the backend parser.", + "key": "Unable to validate the NFQWS2 strategy through the backend parser.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5818", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6717" + ] + }, + { + "call": "Unavailability timeout", + "key": "Unavailability timeout", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2941", + "src/tachyon/tabs/dashboard/initController.ts:1145" + ] + }, + { + "call": "Unexpected ByeDPI strategy argument: %s", + "key": "Unexpected ByeDPI strategy argument: %s", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6447", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6496" + ] + }, + { + "call": "Unexpected Tachyon-managed ciadpi runtime is running", + "key": "Unexpected Tachyon-managed ciadpi runtime is running", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:96" + ] + }, + { + "call": "Unexpected Tachyon-managed nfqws runtime is running", + "key": "Unexpected Tachyon-managed nfqws runtime is running", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:89" + ] + }, + { + "call": "Unexpected Tachyon-managed nfqws2 runtime is running", + "key": "Unexpected Tachyon-managed nfqws2 runtime is running", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:92" + ] + }, + { + "call": "Unexpected standalone token. Use explicit flags such as --name or --name=value.", + "key": "Unexpected standalone token. Use explicit flags such as --name or --name=value.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5611", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6085" + ] + }, + { + "call": "unique UCI identifier", + "key": "unique UCI identifier", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:54" + ] + }, + { + "call": "unknown", + "key": "unknown", + "places": [ + "src/tachyon/helpers/singBoxVariant.ts:28", + "src/tachyon/services/systemInfo.service.ts:10", + "src/tachyon/services/systemInfo.service.ts:11", + "src/tachyon/services/systemInfo.service.ts:12", + "src/tachyon/services/systemInfo.service.ts:13", + "src/tachyon/services/systemInfo.service.ts:18", + "src/tachyon/services/systemInfo.service.ts:20", + "src/tachyon/services/systemInfo.service.ts:22", + "src/tachyon/services/systemInfo.service.ts:25", + "src/tachyon/services/systemInfo.service.ts:26", + "src/tachyon/tabs/updates/initController.ts:329", + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:25" + ] + }, + { + "call": "Unknown ByeDPI option: %s", + "key": "Unknown ByeDPI option: %s", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6416", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6438", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6489" + ] + }, + { + "call": "Unknown country", + "key": "Unknown country", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:578" + ] + }, + { + "call": "Unknown error", + "key": "Unknown error", + "places": [ + "src/tachyon/api.ts:40" + ] + }, + { + "call": "Unknown NFQWS flag.", + "key": "Unknown NFQWS flag.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5622" + ] + }, + { + "call": "Unknown NFQWS2 flag.", + "key": "Unknown NFQWS2 flag.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6096" + ] + }, + { + "call": "Unspecified IP address is not allowed", + "key": "Unspecified IP address is not allowed", + "places": [ + "src/validators/validateSubnet.ts:17" + ] + }, + { + "call": "Unsupported security mode for this protocol", + "key": "Unsupported security mode for this protocol", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2120" + ] + }, + { + "call": "Update", + "key": "Update", + "places": [ + "src/tachyon/tabs/updates/initController.ts:754" + ] + }, + { + "call": "Update is available", + "key": "Update is available", + "places": [ + "src/tachyon/tabs/updates/initController.ts:287" + ] + }, + { + "call": "Update is available:", + "key": "Update is available:", + "places": [ + "src/tachyon/tabs/updates/initController.ts:994" + ] + }, + { + "call": "Update subscriptions", + "key": "Update subscriptions", + "places": [ + "src/tachyon/tabs/dashboard/partials/renderSections.ts:219", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:232", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:233" + ] + }, + { + "call": "Update this subscription automatically", + "key": "Update this subscription automatically", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2237" + ] + }, + { + "call": "Uplink", + "key": "Uplink", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1558", + "src/tachyon/tabs/dashboard/initController.ts:1593" + ] + }, + { + "call": "Uploaded", + "key": "Uploaded", + "places": [ + "src/tachyon/tabs/monitoring/initController.ts:828", + "src/tachyon/tabs/monitoring/initController.ts:880" + ] + }, + { + "call": "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://", + "key": "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://", + "places": [ + "src/validators/validateProxyUrl.ts:47" + ] + }, + { + "call": "URL must use one of the following protocols:", + "key": "URL must use one of the following protocols:", + "places": [ + "src/validators/validateUrl.ts:18" + ] + }, + { + "call": "URL used to check whether a server is alive", + "key": "URL used to check whether a server is alive", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2917" + ] + }, + { + "call": "URL used to test server latency", + "key": "URL used to test server latency", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2491" + ] + }, + { + "call": "URLTest", + "key": "URLTest", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7393" + ] + }, + { + "call": "URLTest details", + "key": "URLTest details", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:1042", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:385", + "src/tachyon/tabs/dashboard/partials/renderSections.ts:386" + ] + }, + { + "call": "URLTest details are unavailable", + "key": "URLTest details are unavailable", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:901" + ] + }, + { + "call": "URLTest settings", + "key": "URLTest settings", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3626", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3627" + ] + }, + { + "call": "Use 32 hex characters", + "key": "Use 32 hex characters", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2104" + ] + }, + { + "call": "Use a domain name or IP address", + "key": "Use a domain name or IP address", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1894", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1900", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2016" + ] + }, + { + "call": "Use a duration like 30s, 5m, or 1h30m", + "key": "Use a duration like 30s, 5m, or 1h30m", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1995" + ] + }, + { + "call": "Use a non-negative integer", + "key": "Use a non-negative integer", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2001", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2888" + ] + }, + { + "call": "Use a non-zero secret", + "key": "Use a non-zero secret", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2107" + ] + }, + { + "call": "Use an HTTP or HTTPS URL", + "key": "Use an HTTP or HTTPS URL", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1963", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1965" + ] + }, + { + "call": "Use an IP address", + "key": "Use an IP address", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1917" + ] + }, + { + "call": "Use an IP listen address", + "key": "Use an IP listen address", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1910" + ] + }, + { + "call": "Use another section as an intermediate hop to connect to servers in this section. Does not apply to network interfaces or JSON outbounds.", + "key": "Use another section as an intermediate hop to connect to servers in this section. Does not apply to network interfaces or JSON outbounds.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7492" + ] + }, + { + "call": "Use CIDR prefixes like 192.168.1.0/24 or 2001:db8::/32", + "key": "Use CIDR prefixes like 192.168.1.0/24 or 2001:db8::/32", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2064" + ] + }, + { + "call": "Use CIDR prefixes or IP addresses like 192.168.1.0/24, 192.168.1.10, or 2001:db8::1", + "key": "Use CIDR prefixes or IP addresses like 192.168.1.0/24, 192.168.1.10, or 2001:db8::1", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2078" + ] + }, + { + "call": "Use letters, digits, dots, underscores, or hyphens", + "key": "Use letters, digits, dots, underscores, or hyphens", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2022" + ] + }, + { + "call": "Use sing-box duration format like 10s, 1m or 2m30s", + "key": "Use sing-box duration format like 10s, 1m or 2m30s", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:149" + ] + }, + { + "call": "Use sing-box duration format like 1d, 12h or 30m", + "key": "Use sing-box duration format like 1d, 12h or 30m", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2246", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2465", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2990", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4446", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4453", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4460", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:466", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:488", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:495", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:511", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:539" + ] + }, + { + "call": "Used by", + "key": "Used by", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:25" + ] + }, + { + "call": "User-Agent", + "key": "User-Agent", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2304" + ] + }, + { + "call": "Username", + "key": "Username", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2847" + ] + }, + { + "call": "Username cannot be empty", + "key": "Username cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7649" + ] + }, + { + "call": "Valid", + "key": "Valid", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5146", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5159", + "src/validators/validateDns.ts:21", + "src/validators/validateDns.ts:25", + "src/validators/validateDomain.ts:57", + "src/validators/validateDomain.ts:76", + "src/validators/validateHttpProxyUrl.ts:97", + "src/validators/validateHysteriaUrl.ts:162", + "src/validators/validateIp.ts:25", + "src/validators/validateIp.ts:33", + "src/validators/validateIp.ts:41", + "src/validators/validateOutboundJson.ts:118", + "src/validators/validatePath.ts:16", + "src/validators/validateShadowsocksUrl.ts:103", + "src/validators/validateSocksUrl.ts:83", + "src/validators/validateSubnet.ts:42", + "src/validators/validateTrojanUrl.ts:66", + "src/validators/validateUrl.ts:34", + "src/validators/validateVlessUrl.ts:116", + "src/validators/validateVmessUrl.ts:66" + ] + }, + { + "call": "Validation errors:", + "key": "Validation errors:", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4575", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4595", + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4599" + ] + }, + { + "call": "Value cannot be empty", + "key": "Value cannot be empty", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5136" + ] + }, + { + "call": "Value must not contain control characters", + "key": "Value must not contain control characters", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1838" + ] + }, + { + "call": "Via country.is", + "key": "Via country.is", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1876" + ] + }, + { + "call": "View logs", + "key": "View logs", + "places": [ + "src/tachyon/tabs/diagnostic/initController.ts:717", + "src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:107" + ] + }, + { + "call": "Visible name of this section", + "key": "Visible name of this section", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7030" + ] + }, + { + "call": "VLESS flow", + "key": "VLESS flow", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2891" + ] + }, + { + "call": "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links", + "key": "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7253" + ] + }, + { + "call": "WAN", + "key": "WAN", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:70" + ] + }, + { + "call": "WAN public IP", + "key": "WAN public IP", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:198" + ] + }, + { + "call": "Warning: %s cannot be used together with %s. Previous selections have been removed.", + "key": "Warning: %s cannot be used together with %s. Previous selections have been removed.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7807" + ] + }, + { + "call": "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.", + "key": "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7826" + ] + }, + { + "call": "What Tachyon should do when this section matches", + "key": "What Tachyon should do when this section matches", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7043" + ] + }, + { + "call": "When switching to another level, test every server and select the fastest instead of the first working one.", + "key": "When switching to another level, test every server and select the fastest instead of the first working one.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2968" + ] + }, + { + "call": "XHTTP mode", + "key": "XHTTP mode", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2801" + ] + }, + { + "call": "YACD Secret Key", + "key": "YACD Secret Key", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:434" + ] + }, + { + "call": "Yes", + "key": "Yes", + "places": [ + "src/tachyon/tabs/dashboard/initController.ts:762" + ] + }, + { + "call": "You can select Output Network Interface, by default autodetect", + "key": "You can select Output Network Interface, by default autodetect", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:305" + ] + }, + { + "call": "Zapret hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS strategies.", + "key": "Zapret hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS strategies.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5447" + ] + }, + { + "call": "Zapret package is installed", + "key": "Zapret package is installed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:71" + ] + }, + { + "call": "Zapret package is not installed", + "key": "Zapret package is not installed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:72" + ] + }, + { + "call": "Zapret provider binary is available", + "key": "Zapret provider binary is available", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:60" + ] + }, + { + "call": "Zapret provider binary is not available", + "key": "Zapret provider binary is not available", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:61" + ] + }, + { + "call": "Zapret sing-box outbound is configured", + "key": "Zapret sing-box outbound is configured", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:103" + ] + }, + { + "call": "Zapret sing-box outbound is not configured", + "key": "Zapret sing-box outbound is not configured", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:104" + ] + }, + { + "call": "Zapret2 hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS2 strategies.", + "key": "Zapret2 hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS2 strategies.", + "places": [ + "../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5892" + ] + }, + { + "call": "Zapret2 package is installed", + "key": "Zapret2 package is installed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:74" + ] + }, + { + "call": "Zapret2 package is not installed", + "key": "Zapret2 package is not installed", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:75" + ] + }, + { + "call": "Zapret2 provider binary is available", + "key": "Zapret2 provider binary is available", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:63" + ] + }, + { + "call": "Zapret2 provider binary is not available", + "key": "Zapret2 provider binary is not available", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:64" + ] + }, + { + "call": "Zapret2 sing-box outbound is configured", + "key": "Zapret2 sing-box outbound is configured", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:106" + ] + }, + { + "call": "Zapret2 sing-box outbound is not configured", + "key": "Zapret2 sing-box outbound is not configured", + "places": [ + "src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:107" + ] + } +] diff --git a/fe-app-tachyon/locales/tachyon.pot b/fe-app-tachyon/locales/tachyon.pot new file mode 100644 index 00000000..14e8bfec --- /dev/null +++ b/fe-app-tachyon/locales/tachyon.pot @@ -0,0 +1,3354 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the TACHYON package. +# ushan0v , 2026. +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TACHYON\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-13 22:05+0300\n" +"PO-Revision-Date: 2026-07-13 22:05+0300\n" +"Last-Translator: ushan0v \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1961 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2308 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2648 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2663 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2861 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2876 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3112 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3264 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3274 +msgid "-- Select --" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7372 +msgid "+ Add JSON outbound" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3028 +msgid "+ Add level" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7458 +msgid "+ Add priority" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7399 +msgid "+ Add URLTest" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1675 +#: src/tachyon/tabs/dashboard/initController.ts:1686 +msgid "✔ Running" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1676 +#: src/tachyon/tabs/dashboard/initController.ts:1687 +msgid "✘ Stopped" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:408 +msgid "Accept external proxy connections and route them with sing-box." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3133 +msgid "Accept routes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7013 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7042 +msgid "Action" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:667 +#: src/tachyon/tabs/monitoring/render.ts:20 +msgid "Active" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:56 +msgid "Active Bootstrap DNS" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1633 +msgid "Active Connections" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:67 +msgid "Active Main DNS" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:397 +msgid "Add a section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:414 +msgid "Add a server inbound" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:119 +msgid "Add at least one DNS server" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2357 +msgid "Add prefix to nodes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7915 +msgid "Add URLs or local paths to .lst lists. Only domain rules are supported." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7861 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7890 +msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3286 +msgid "Add URLTest / Priority group" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:106 +msgid "Additional marking rules found" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2578 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2788 +msgid "Additionally exclude servers by protocol, transport, and security. Exclude only servers matching the specified parameters." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2561 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2770 +msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3186 +msgid "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3166 +msgid "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1653 +msgid "Address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3116 +msgid "Advertise exit node" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3126 +msgid "Advertise routes" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:626 +#: src/tachyon/tabs/monitoring/render.ts:39 +msgid "All" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1858 +msgid "All except selected" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1868 +msgid "All remaining except selected" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1866 +msgid "All remaining servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2744 +msgid "All remaining servers means every server not already assigned to a higher-priority level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1857 +msgid "All servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7939 +msgid "All traffic from these IP addresses will be routed through the section unconditionally, ignoring all other conditions." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5131 +msgid "Allowed domain prefixes are full:, keyword:, and regex:" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:423 +msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2538 +msgid "Allows limiting the list of servers for URLTest" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2645 +msgid "ALPN" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2879 +msgid "Alter ID" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7930 +msgid "Apply section rules only to the specified local IP addresses" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5162 +msgid "At least one valid domain must be specified." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5244 +msgid "At least one valid IP or subnet must be specified." +msgstr "" + +#: src/partials/modal/renderModal.ts:286 +msgid "Auto refresh" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3036 +msgid "Auto update" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2324 +msgid "Auto-generate HWID" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:501 +msgid "Automatic component update checks" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2293 +msgid "Automatic User-Agent selection" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2358 +msgid "Automatically add text to the name of each server from this subscription for convenient filtering." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:502 +msgid "Automatically check installed components for new versions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2978 +#: src/tachyon/tabs/dashboard/initController.ts:1155 +msgid "Automatically select the fastest node in the current level" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:43 +msgid "Available actions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5377 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5822 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6234 +msgid "Backend validation failed: %s" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:57 +msgid "Bootstrap DNS" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:182 +msgid "Bootstrap DNS Servers" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:51 +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:79 +msgid "Browser FakeIP check could not be completed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:82 +msgid "Browser is not using FakeIP" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:81 +msgid "Browser is using FakeIP correctly" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7760 +msgid "Built-in rule sets" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1875 +msgid "By flag emoji from name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6303 +msgid "ByeDPI listen address and port are assigned by Tachyon and must not be set in the strategy." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6480 +msgid "ByeDPI option does not accept a compact value: %s" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6409 +msgid "ByeDPI option does not accept a value: %s" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6401 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6427 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6461 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6470 +msgid "ByeDPI option requires a value: %s" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:71 +msgid "ByeDPI package is installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:72 +msgid "ByeDPI package is not installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:64 +msgid "ByeDPI provider binary is available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:65 +msgid "ByeDPI provider binary is not available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:107 +msgid "ByeDPI sing-box outbound is configured" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:108 +msgid "ByeDPI sing-box outbound is not configured" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7214 +msgid "ByeDPI Strategy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6506 +msgid "ByeDPI strategy cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:623 +msgid "Cache File Path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:637 +msgid "Cache file path cannot be empty" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:26 +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:27 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:172 +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:28 +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:48 +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:25 +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:26 +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:26 +msgid "Cannot receive checks result" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2241 +msgid "Cannot save server" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3414 +msgid "Cannot save settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7491 +msgid "Cascade connection" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2655 +msgid "Certificate path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1598 +msgid "Certificate pin is unavailable. Apply or restart the server to generate the default certificate, then open this dialog again." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2464 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2929 +#: src/tachyon/tabs/dashboard/initController.ts:1142 +msgid "Check interval" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2942 +msgid "Check timeout after which the server is considered dead" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:740 +msgid "Check update" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2490 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2916 +#: src/tachyon/tabs/dashboard/initController.ts:1140 +msgid "Check URL" +msgstr "" + +#: src/tachyon/tabs/diagnostic/initController.ts:1022 +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:14 +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:15 +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:15 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:160 +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:13 +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:34 +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:13 +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:14 +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:14 +msgid "Checking, please wait" +msgstr "" + +#: src/tachyon/tabs/diagnostic/helpers/getCheckTitle.ts:2 +msgid "checks" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts:10 +#: src/tachyon/tabs/diagnostic/helpers/getMeta.ts:26 +msgid "Checks failed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts:23 +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:43 +#: src/tachyon/tabs/diagnostic/helpers/getMeta.ts:13 +msgid "Checks passed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts:27 +msgid "Checks passed with manual DHCP" +msgstr "" + +#: src/validators/validateSubnet.ts:35 +msgid "CIDR must be between 0 and 32 for IPv4 or 0 and 128 for IPv6" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2753 +msgid "Client fingerprint" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1670 +msgid "Client link" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1578 +msgid "Client link is not available yet" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1611 +msgid "Client link QR code" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3481 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1718 +#: src/partials/modal/renderModal.ts:263 +#: src/tachyon/tabs/dashboard/initController.ts:1027 +#: src/tachyon/tabs/dashboard/initController.ts:1283 +#: src/tachyon/tabs/monitoring/initController.ts:832 +#: src/tachyon/tabs/monitoring/initController.ts:882 +msgid "Close" +msgstr "" + +#: src/tachyon/tabs/monitoring/render.ts:58 +#: src/tachyon/tabs/monitoring/render.ts:59 +msgid "Close all connections" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:799 +#: src/tachyon/tabs/monitoring/initController.ts:800 +msgid "Close connection" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:674 +#: src/tachyon/tabs/monitoring/render.ts:29 +msgid "Closed" +msgstr "" + +#: src/tachyon/services/core.service.ts:43 +msgid "Component update available" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:510 +msgid "Component update check interval" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:473 +msgid "Components" +msgstr "" + +#: src/tachyon/helpers/singBoxVariant.ts:51 +msgid "compressed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2988 +msgid "Concurrency" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7001 +msgid "Conditions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:610 +msgid "Config File Path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:343 +msgid "Configuration for Tachyon service" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4473 +msgid "Configure User-Agent in the item settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7529 +msgid "Connect through" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7252 +msgid "Connection URL" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:946 +msgid "Connections are unavailable" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:145 +msgid "Connectivity probe failed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1565 +#: src/helpers/copyToClipboard.ts:10 +msgid "Copied" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1694 +#: src/partials/modal/renderModal.ts:258 +msgid "Copy" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:997 +#: src/tachyon/tabs/dashboard/initController.ts:1253 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:366 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:367 +msgid "Copy proxy link" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:89 +msgid "Could not compare FakeIP and control public IPs" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7546 +msgid "Current section cannot be used as its own transit section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2285 +msgid "Current section cannot download its own subscription" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderWidget.ts:22 +msgid "Currently unavailable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7367 +msgid "Custom outbound configurations in JSON format" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:449 +msgid "Dashboard" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:47 +msgid "Dashboard currently unavailable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:546 +msgid "Default address for checking server availability and latency. URLTest uses its own address." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:398 +msgid "Delay in milliseconds before reloading Tachyon after interface UP" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:405 +msgid "Delay value cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1745 +msgid "Delete" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2548 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2757 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3153 +msgid "Detect server country" +msgstr "" + +#: src/validators/validateOutboundJson.ts:115 +msgid "detour must be a non-empty string" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7929 +msgid "Device filter" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6769 +msgid "Device or IP" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts:37 +msgid "DHCP has DNS server" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts:36 +msgid "DHCP is managed manually" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:437 +msgid "Diagnostics" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2563 +msgid "Direct" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2734 +msgid "Direct connection" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:79 +msgid "Disable autostart" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:445 +msgid "Disable QUIC" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:446 +msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5904 +msgid "Disabling interception is incompatible with action=zapret2 because Tachyon sends matched traffic through NFQUEUE." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2446 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2905 +msgid "Display name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:196 +msgid "DNS Check Interval" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:75 +msgid "DNS on router" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1743 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:163 +msgid "DNS over HTTPS (DoH)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1744 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:164 +msgid "DNS over TLS (DoT)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2413 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7063 +msgid "DNS protocol" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:160 +msgid "DNS Protocol Type" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2414 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7064 +msgid "DNS protocol used by the resolver" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7107 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:259 +msgid "DNS requests through section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:222 +msgid "DNS Rewrite TTL" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2423 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7076 +msgid "DNS server" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7085 +#: src/validators/validateDns.ts:8 +msgid "DNS server address cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2424 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7077 +msgid "DNS server used by the resolver" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:183 +msgid "DNS server used to obtain IP addresses for upstream DNS and proxies. If multiple servers are selected, a timeout switches to a backup." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:172 +msgid "DNS Servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:240 +msgid "DNS Strategy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:251 +msgid "DNS through proxy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7095 +msgid "DNS through section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:212 +msgid "DNS Unavailability Timeout" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts:26 +msgid "Do not panic, everything can be fixed, just..." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2621 +msgid "Do not test servers from these countries." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2637 +msgid "Do not test servers whose names match the expression." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2582 +msgid "Do not test servers with one of the selected protocols." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2588 +msgid "Do not test servers with one of the selected security types." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2585 +msgid "Do not test servers with one of the selected transports." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2629 +msgid "Do not test specified servers." +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:54 +msgid "Does not resolve" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7914 +msgid "Domain lists" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2404 +msgid "Domain Resolver" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7721 +msgid "Domains" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:601 +msgid "Dont Touch My DHCP!" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1559 +#: src/tachyon/tabs/dashboard/initController.ts:1597 +msgid "Downlink" +msgstr "" + +#: src/partials/modal/renderModal.ts:253 +msgid "Download" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:582 +msgid "Download component packages via the selected section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:589 +msgid "Download components through" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:581 +msgid "Download components through a section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:569 +msgid "Download lists through" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:561 +msgid "Download lists through a section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:562 +msgid "Download remote lists and rule sets via the selected section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2259 +msgid "Download subscription through a section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2260 +msgid "Download subscriptions via the selected section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2268 +msgid "Download through" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:825 +#: src/tachyon/tabs/monitoring/initController.ts:879 +msgid "Downloaded" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:391 +msgid "Drag rows to change priority. The rule at the top is checked first." +msgstr "" + +#: src/validators/validateOutboundJson.ts:75 +msgid "Duplicate JSON outbound tag" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4568 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4583 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4603 +msgid "Duplicate value" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:68 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1738 +msgid "Edit rule action" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7003 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2480 +msgid "Enable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2840 +msgid "Enable authentication" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:457 +msgid "Enable automatic updates for remote lists and rule sets" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:89 +msgid "Enable autostart" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2405 +msgid "Enable built-in DNS resolver for domains handled by this section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:456 +msgid "Enable list updates" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7567 +msgid "Enable Mixed Proxy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7608 +msgid "Enable Mixed Proxy Authentication" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:304 +msgid "Enable Output Network Interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:413 +msgid "Enable YACD" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:422 +msgid "Enable YACD WAN Access" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2586 +msgid "Enter a complete sing-box inbound object" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3815 +msgid "Enter a complete sing-box outbound object" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2458 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2910 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3770 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3786 +msgid "Enter a display name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2707 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2728 +msgid "Enter a level name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7088 +msgid "Enter a valid DNS server address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2342 +msgid "Enter HWID" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2334 +msgid "Enter the HWID sent with subscription requests" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7278 +msgid "Enter the subscription URL" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2577 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2787 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3185 +msgid "Exclude by proxy parameters" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2636 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2849 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3245 +msgid "Exclude by regular expression" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2620 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2833 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3229 +msgid "Exclude countries" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:675 +msgid "Exclude NTP" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:676 +msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2628 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2841 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3237 +msgid "Exclude servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2795 +msgid "Exclude servers with one of the selected protocols from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2801 +msgid "Exclude servers with one of the selected security types from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2798 +msgid "Exclude servers with one of the selected transports from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3300 +msgid "Exclude URLTest / Priority group" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:54 +msgid "Expecting: %s" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:134 +msgid "Expires" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7568 +msgid "Expose this section as a local HTTP+SOCKS proxy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5438 +msgid "External nfqws config files bypass Tachyon queue management and explicit validation." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5883 +msgid "External nfqws2 config files bypass Tachyon queue management and explicit validation." +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:1213 +#: src/tachyon/tabs/monitoring/initController.ts:1228 +msgid "Failed to close connection" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:1247 +#: src/tachyon/tabs/monitoring/initController.ts:1260 +msgid "Failed to close connections" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1567 +#: src/helpers/copyToClipboard.ts:12 +msgid "Failed to copy!" +msgstr "" + +#: src/tachyon/methods/shell/index.ts:170 +#: src/tachyon/methods/shell/index.ts:177 +#: src/tachyon/tabs/updates/initController.ts:508 +#: src/tachyon/tabs/updates/initController.ts:509 +#: src/tachyon/tabs/updates/initController.ts:569 +#: src/tachyon/tabs/updates/initController.ts:719 +msgid "Failed to execute" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:275 +#: src/tachyon/tabs/dashboard/initController.ts:276 +#: src/tachyon/tabs/dashboard/initController.ts:295 +#: src/tachyon/tabs/dashboard/initController.ts:341 +#: src/tachyon/tabs/dashboard/initController.ts:345 +#: src/tachyon/tabs/dashboard/initController.ts:1341 +#: src/tachyon/tabs/dashboard/initController.ts:1345 +msgid "Failed to update subscriptions" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:91 +msgid "FakeIP and control checks use different public IPs" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:92 +msgid "FakeIP and control checks use the same public IP" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:46 +msgid "FakeIP works; public IP comparison is inconclusive" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2963 +msgid "FakeTLS host" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3045 +msgid "Fallback on unknown DC" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2989 +#: src/tachyon/tabs/dashboard/initController.ts:1161 +msgid "Faster server search interval" +msgstr "" + +#: src/tachyon/methods/custom/getDashboardSections.ts:641 +msgid "Fastest" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3142 +msgid "Filter the servers that will be displayed on the dashboard." +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:136 +msgid "Firewall WAN port" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3415 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2242 +msgid "Fix the highlighted fields and save again." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7938 +msgid "Forced device routing" +msgstr "" + +#: src/tachyon/services/core.service.ts:52 +msgid "Tachyon Error" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6312 +msgid "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6309 +msgid "Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5456 +msgid "Tachyon manages the nfqws process lifecycle itself, so daemon mode is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5907 +msgid "Tachyon manages the nfqws2 process lifecycle itself, so daemon mode is not allowed here." +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:925 +msgid "Tachyon service is stopped. Start the service to display connections." +msgstr "" + +#: src/tachyon/tabs/dashboard/render.ts:17 +msgid "Tachyon service is stopped. Start the service to display the dashboard." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:342 +msgid "Tachyon Settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:602 +msgid "Tachyon will not modify your DHCP configuration" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:91 +msgid "Tachyon-managed ciadpi runtime has restarted" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:94 +msgid "Tachyon-managed ciadpi runtime is not ready" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:97 +msgid "Tachyon-managed ciadpi runtime is not running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:93 +msgid "Tachyon-managed ciadpi runtime is ready" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:87 +msgid "Tachyon-managed nfqws runtime is not ready" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:90 +msgid "Tachyon-managed nfqws runtime is not running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:86 +msgid "Tachyon-managed nfqws runtime is ready" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:90 +msgid "Tachyon-managed nfqws2 runtime is not ready" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:93 +msgid "Tachyon-managed nfqws2 runtime is not running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:89 +msgid "Tachyon-managed nfqws2 runtime is ready" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3008 +msgid "Fronting IP" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2997 +msgid "Fronting port" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3018 +msgid "Fronting proxy protocol" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5901 +msgid "Fuzzing is not supported here because Tachyon needs deterministic runtime validation." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2325 +msgid "Generate HWID from router hardware information for this subscription" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:89 +msgid "Generated inbound" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:98 +msgid "Get global check" +msgstr "" + +#: src/tachyon/tabs/diagnostic/initController.ts:682 +msgid "Global check" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2820 +msgid "gRPC service" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3073 +msgid "Handshake timeout" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2393 +msgid "Hide cascade connection nodes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2382 +msgid "Hide individual nodes that are already included in imported subscription URLTest groups" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2394 +msgid "Hide intermediate nodes used as detours by other subscription nodes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3238 +msgid "Hide selected servers." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3301 +msgid "Hide servers from selected groups without repeating their filtering criteria." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3230 +msgid "Hide servers from the specified countries." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3246 +msgid "Hide servers whose names match the expression." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3193 +msgid "Hide servers with one of the selected protocols." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3197 +msgid "Hide servers with one of the selected security types." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3194 +msgid "Hide servers with one of the selected transports." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2381 +msgid "Hide URLTest group nodes" +msgstr "" + +#: src/partials/modal/renderModal.ts:310 +msgid "Hide values" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2953 +#: src/tachyon/tabs/dashboard/initController.ts:1147 +msgid "Higher-level check interval" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:204 +msgid "Higher-priority DNS Check" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:815 +#: src/tachyon/tabs/monitoring/initController.ts:875 +msgid "Host" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2954 +msgid "How often higher priority levels are checked while a lower level is active" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2930 +msgid "How often the currently selected server is checked" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:197 +msgid "How often to check the active DNS servers." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:205 +msgid "How often to check whether a higher-priority DNS server has recovered." +msgstr "" + +#: src/tachyon/api.ts:27 +msgid "HTTP error" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2813 +msgid "HTTP hosts" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2333 +msgid "HWID" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2503 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3063 +#: src/tachyon/tabs/dashboard/initController.ts:916 +msgid "Idle timeout" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2372 +msgid "Import subscription URLTest groups" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2373 +msgid "Import URLTest groups returned by this subscription provider" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2585 +msgid "Inbound JSON" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1971 +msgid "Inbound JSON cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1982 +msgid "Inbound JSON must be an object with a type field" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2560 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2769 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3165 +msgid "Include by proxy parameters" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2612 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2825 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3221 +msgid "Include by regular expression" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2596 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2809 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3205 +msgid "Include countries" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3890 +msgid "Include IP addresses and subnets" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2604 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2817 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3213 +msgid "Include servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1746 +msgid "Information" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:754 +msgid "Install" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:1159 +msgid "Install another build:" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:291 +#: src/tachyon/tabs/updates/initController.ts:1017 +msgid "Installed version is newer than release" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:365 +msgid "Interface Monitoring" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:397 +msgid "Interface Monitoring Delay" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:366 +msgid "Interface monitoring for Bad WAN" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2517 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3005 +#: src/tachyon/tabs/dashboard/initController.ts:918 +#: src/tachyon/tabs/dashboard/initController.ts:1167 +msgid "Interrupt connections" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3006 +msgid "Interrupt connections when priority failover switches server" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2518 +msgid "Interrupt connections when URLTest switches the selected server" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:914 +msgid "Interval" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7235 +msgid "Invalid ByeDPI strategy" +msgstr "" + +#: src/validators/validateSubnet.ts:25 +msgid "Invalid CIDR prefix" +msgstr "" + +#: src/validators/validateDns.ts:30 +msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5111 +#: src/validators/validateDomain.ts:67 +#: src/validators/validateDomain.ts:73 +msgid "Invalid domain address" +msgstr "" + +#: src/validators/validateSubnet.ts:10 +msgid "Invalid format. Use an IP address or CIDR subnet" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:57 +msgid "Invalid HTTP proxy URL: invalid host and port" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:87 +msgid "Invalid HTTP proxy URL: invalid host format" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:80 +msgid "Invalid HTTP proxy URL: invalid port number" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:49 +msgid "Invalid HTTP proxy URL: missing host and port" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:65 +msgid "Invalid HTTP proxy URL: missing hostname or IP" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:72 +msgid "Invalid HTTP proxy URL: missing port" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:41 +msgid "Invalid HTTP proxy URL: missing username" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:18 +msgid "Invalid HTTP proxy URL: must not contain spaces" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:9 +msgid "Invalid HTTP proxy URL: must start with http:// or https://" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:93 +msgid "Invalid HTTP proxy URL: parsing failed" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:26 +msgid "Invalid HTTP proxy URL: path, query, and fragment are not supported" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:115 +msgid "Invalid HY2 URL: insecure must be 0 or 1" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:51 +msgid "Invalid HY2 URL: invalid host & port" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:101 +#: src/validators/validateHysteriaUrl.ts:157 +msgid "Invalid HY2 URL: invalid port number" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:33 +msgid "Invalid HY2 URL: missing credentials/server" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:57 +msgid "Invalid HY2 URL: missing host" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:44 +msgid "Invalid HY2 URL: missing host & port" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:39 +msgid "Invalid HY2 URL: missing password" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:61 +msgid "Invalid HY2 URL: missing port" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:20 +msgid "Invalid HY2 URL: must not contain spaces" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:13 +msgid "Invalid HY2 URL: must start with hysteria2:// or hy2://" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:138 +msgid "Invalid HY2 URL: obfs-password required when obfs is set" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:164 +msgid "Invalid HY2 URL: parsing failed" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:147 +msgid "Invalid HY2 URL: sni cannot be empty" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:127 +msgid "Invalid HY2 URL: unsupported obfs type" +msgstr "" + +#: src/validators/validateIp.ts:28 +#: src/validators/validateIp.ts:36 +#: src/validators/validateIp.ts:44 +msgid "Invalid IP address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1984 +#: src/validators/validateOutboundJson.ts:58 +msgid "Invalid JSON format" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7192 +msgid "Invalid NFQWS2 strategy" +msgstr "" + +#: src/validators/validatePath.ts:22 +msgid "Invalid path format. Path must start with \"/\" and contain valid characters" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7601 +#: src/validators/validateShadowsocksUrl.ts:93 +msgid "Invalid port number. Must be between 1 and 65535" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5260 +msgid "Invalid port or range. Use 80 or 1000-2000" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4384 +msgid "Invalid regular expression" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:38 +msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:67 +msgid "Invalid Shadowsocks URL: invalid server and port" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:28 +msgid "Invalid Shadowsocks URL: missing credentials" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:47 +msgid "Invalid Shadowsocks URL: missing method and password separator \":\"" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:84 +msgid "Invalid Shadowsocks URL: missing port" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:75 +msgid "Invalid Shadowsocks URL: missing server" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:59 +msgid "Invalid Shadowsocks URL: missing server address" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:17 +msgid "Invalid Shadowsocks URL: must not contain spaces" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:9 +msgid "Invalid Shadowsocks URL: must start with ss://" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:99 +msgid "Invalid Shadowsocks URL: parsing failed" +msgstr "" + +#: src/validators/validateSocksUrl.ts:49 +msgid "Invalid SOCKS URL: invalid host and port" +msgstr "" + +#: src/validators/validateSocksUrl.ts:76 +msgid "Invalid SOCKS URL: invalid host format" +msgstr "" + +#: src/validators/validateSocksUrl.ts:69 +msgid "Invalid SOCKS URL: invalid port number" +msgstr "" + +#: src/validators/validateSocksUrl.ts:41 +msgid "Invalid SOCKS URL: missing host and port" +msgstr "" + +#: src/validators/validateSocksUrl.ts:57 +msgid "Invalid SOCKS URL: missing hostname or IP" +msgstr "" + +#: src/validators/validateSocksUrl.ts:62 +msgid "Invalid SOCKS URL: missing port" +msgstr "" + +#: src/validators/validateSocksUrl.ts:33 +msgid "Invalid SOCKS URL: missing username" +msgstr "" + +#: src/validators/validateSocksUrl.ts:18 +msgid "Invalid SOCKS URL: must not contain spaces" +msgstr "" + +#: src/validators/validateSocksUrl.ts:9 +msgid "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://" +msgstr "" + +#: src/validators/validateSocksUrl.ts:80 +msgid "Invalid SOCKS URL: parsing failed" +msgstr "" + +#: src/validators/validateTrojanUrl.ts:16 +msgid "Invalid Trojan URL: must not contain spaces" +msgstr "" + +#: src/validators/validateTrojanUrl.ts:9 +msgid "Invalid Trojan URL: must start with trojan://" +msgstr "" + +#: src/validators/validateTrojanUrl.ts:63 +msgid "Invalid Trojan URL: parsing failed" +msgstr "" + +#: src/validators/validateUrl.ts:9 +#: src/validators/validateUrl.ts:37 +#: src/validators/validateUrl.ts:40 +msgid "Invalid URL format" +msgstr "" + +#: src/validators/validateVlessUrl.ts:118 +msgid "Invalid VLESS URL: parsing failed" +msgstr "" + +#: src/validators/validateVmessUrl.ts:68 +msgid "Invalid VMess URL: parsing failed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7750 +msgid "IPs" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:242 +msgid "IPv4 only" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:244 +msgid "IPv6 only" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts:17 +#: src/tachyon/tabs/diagnostic/helpers/getMeta.ts:20 +msgid "Issues detected" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1036 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3814 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4050 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7366 +msgid "JSON outbound" +msgstr "" + +#: src/validators/validateOutboundJson.ts:50 +msgid "JSON outbound cannot be empty" +msgstr "" + +#: src/validators/validateOutboundJson.ts:62 +msgid "JSON outbound must be a JSON object" +msgstr "" + +#: src/validators/validateOutboundJson.ts:70 +msgid "JSON outbound must contain a non-empty tag field" +msgstr "" + +#: src/validators/validateOutboundJson.ts:66 +msgid "JSON outbound must contain a non-empty type field" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3838 +msgid "JSON outbound settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3015 +msgid "Keep Priority before latency-sorted servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2529 +msgid "Keep URLTest before latency-sorted servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2670 +msgid "Key path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4394 +msgid "Keyword must not contain spaces or commas" +msgstr "" + +#: src/tachyon/methods/shell/index.ts:462 +#: src/tachyon/methods/shell/index.ts:483 +msgid "Latency test failed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:545 +msgid "Latency test URL" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:294 +#: src/tachyon/tabs/updates/initController.ts:1015 +msgid "Latest version is installed" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:1017 +msgid "Latest version:" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1100 +msgid "Level" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2723 +msgid "Level name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6934 +msgid "List must be an HTTP(S) URL or a local .lst path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:465 +msgid "List Update Frequency" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2535 +msgid "Listen address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2542 +msgid "Listen port" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:125 +msgid "Listening port" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1703 +msgid "Loading client link..." +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:936 +#: src/tachyon/tabs/monitoring/render.ts:88 +msgid "Loading connections" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:905 +#: src/tachyon/tabs/updates/initController.ts:916 +#: src/tachyon/tabs/updates/initController.ts:927 +#: src/tachyon/tabs/updates/initController.ts:940 +#: src/tachyon/tabs/updates/initController.ts:953 +msgid "Loading..." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:659 +msgid "Log Level" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:67 +msgid "Main DNS" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:173 +msgid "Main DNS server. If multiple servers are selected, a timeout switches to a backup." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7751 +msgid "Match destination IPs or subnets" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7948 +msgid "Match destination ports. Use a single port or a range" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:213 +msgid "Maximum time to wait for example.com to resolve during a DNS health check." +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1637 +msgid "Memory Usage" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2827 +msgid "Method" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2477 +msgid "Minimum latency difference in milliseconds that triggers switching to a faster server." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7659 +msgid "Mixed Proxy Password" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7582 +msgid "Mixed Proxy Port" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7623 +msgid "Mixed Proxy Username" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:374 +msgid "Monitored Interfaces" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:461 +msgid "Monitoring" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1973 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1988 +msgid "Must be a number in the range of 0 - 10000" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2447 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2906 +msgid "Name displayed on the dashboard" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2724 +msgid "Name shown in the priority level list" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1788 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7325 +msgid "Network Interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3605 +msgid "Network interface settings" +msgstr "" + +#: src/tachyon/services/core.service.ts:38 +msgid "New version %s is available for %s" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:100 +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:97 +msgid "NFQUEUE range is available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:99 +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:96 +msgid "NFQUEUE range overlaps with another rule" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7124 +msgid "NFQWS Strategy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5568 +msgid "NFQWS strategy cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7171 +msgid "NFQWS2 Strategy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6042 +msgid "NFQWS2 strategy cannot be empty" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:762 +#: src/tachyon/tabs/dashboard/initController.ts:766 +#: src/tachyon/tabs/dashboard/initController.ts:842 +#: src/tachyon/tabs/dashboard/initController.ts:1055 +msgid "No" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:960 +msgid "No active connections" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:961 +msgid "No closed connections" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:32 +msgid "No conflict detected" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:187 +msgid "No enabled server inbounds configured" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:105 +msgid "No other marking rules found" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:99 +msgid "No public firewall port required" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:79 +msgid "No rules use ByeDPI" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:79 +msgid "No rules use Zapret" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:82 +msgid "No rules use Zapret2" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1012 +#: src/tachyon/tabs/dashboard/initController.ts:1268 +msgid "Node list is empty" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:942 +#: src/tachyon/tabs/dashboard/initController.ts:1191 +msgid "Nodes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:446 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2600 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2892 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2901 +msgid "None" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:46 +msgid "Not configured" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:70 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:199 +msgid "Not detected" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderCheckSection.ts:189 +msgid "Not implement yet" +msgstr "" + +#: src/tachyon/helpers/singBoxVariant.ts:29 +#: src/tachyon/helpers/singBoxVariant.ts:37 +#: src/tachyon/tabs/updates/initController.ts:930 +#: src/tachyon/tabs/updates/initController.ts:943 +#: src/tachyon/tabs/updates/initController.ts:956 +msgid "Not installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:62 +msgid "Not public" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:139 +msgid "Not required for this listen address" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:90 +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:117 +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:123 +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:151 +msgid "Not responding" +msgstr "" + +#: src/tachyon/tabs/diagnostic/diagnostic.store.ts:125 +#: src/tachyon/tabs/diagnostic/initController.ts:116 +#: src/tachyon/tabs/diagnostic/initController.ts:346 +#: src/tachyon/tabs/diagnostic/initController.ts:403 +#: src/tachyon/tabs/diagnostic/initController.ts:489 +msgid "Not running" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2900 +msgid "Obfuscation" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2923 +msgid "Obfuscation password" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2810 +msgid "Only from the specified countries." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1859 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1867 +msgid "Only selected" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1860 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1869 +msgid "Only selected except exclusions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2826 +msgid "Only servers whose names match the expression." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2777 +msgid "Only servers with one of the selected protocols." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2781 +msgid "Only servers with one of the selected security types." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2778 +msgid "Only servers with one of the selected transports." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2818 +msgid "Only the specified servers." +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:1008 +#: src/tachyon/tabs/updates/initController.ts:1030 +msgid "Open" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3782 +msgid "Open priority settings and enter a display name" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts:31 +msgid "Open Project Page" +msgstr "" + +#: src/helpers/withTimeout.ts:7 +#: src/tachyon/methods/shell/index.ts:432 +#: src/tachyon/methods/shell/index.ts:520 +msgid "Operation timed out" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1353 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4502 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6644 +msgid "Option \"%s\" contains an invalid input value." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5646 +msgid "Optional values must be attached with '=' here; a separate token would be ignored by nfqws." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6120 +msgid "Optional values must be attached with '=' here; a separate token would be ignored by nfqws2." +msgstr "" + +#: src/validators/validateOutboundJson.ts:111 +msgid "outbounds must be a non-empty array of strings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:313 +msgid "Output Network Interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2982 +msgid "Padding" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2863 +msgid "Password" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7685 +msgid "Password cannot be empty" +msgstr "" + +#: src/validators/validatePath.ts:7 +msgid "Path cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:641 +msgid "Path must be absolute (start with /)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:650 +msgid "Path must contain at least one directory (like /tmp/cache.db)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:645 +msgid "Path must end with cache.db" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:691 +#: src/tachyon/tabs/monitoring/render.ts:70 +#: src/tachyon/tabs/monitoring/render.ts:71 +msgid "Pause updates" +msgstr "" + +#: src/tachyon/tabs/diagnostic/diagnostic.store.ts:63 +msgid "Pending" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2979 +msgid "Periodically check the current level and switch to a faster server even when the current one works." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2528 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3014 +msgid "Pin on dashboard" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7593 +msgid "Port cannot be empty" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:19 +msgid "Port conflict" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7583 +msgid "Port for the local mixed proxy of this section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5267 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1814 +msgid "Port must be between 1 and 65535" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5271 +msgid "Port range start must be less than or equal to end" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7947 +msgid "Ports" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:241 +msgid "Prefer IPv4" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:243 +msgid "Prefer IPv6" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3025 +msgid "Preferred IP" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2365 +msgid "Prefix text" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7452 +msgid "Priority" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1298 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:404 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:405 +msgid "Priority details" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1128 +msgid "Priority details are unavailable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3669 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3670 +msgid "Priority level settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3023 +msgid "Priority levels" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3727 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3732 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3733 +msgid "Priority settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1929 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2486 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2520 +msgid "Protocol" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:757 +msgid "Proxy link is unavailable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2549 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:40 +msgid "Public host" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1613 +msgid "QR code is too large" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2688 +msgid "Reality fake site" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2699 +msgid "Reality fake site port" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2742 +msgid "Reality max time difference" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2710 +msgid "Reality private key" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2717 +msgid "Reality public key" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2723 +msgid "Reality short ID" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2095 +msgid "Reality short ID must contain 1-8 hex digits" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:135 +msgid "Refill" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7805 +msgid "Regional options cannot be used together" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5153 +msgid "Regular expression must not contain spaces or commas" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:799 +msgid "Remove" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2834 +msgid "Remove servers from the specified countries from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2850 +msgid "Remove servers whose names match the expression from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2842 +msgid "Remove the specified servers from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7609 +msgid "Require a username and password for the local mixed proxy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7696 +msgid "Resolve domain names before routing so sing-box can use real destination IPs." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7695 +msgid "Resolve real IP for routing" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5441 +msgid "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5886 +msgid "Resource selection by hostname inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5444 +msgid "Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5889 +msgid "Resource selection by IP or CIDR inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE." +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:49 +msgid "Restart Tachyon" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:691 +msgid "Resume updates" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:819 +#: src/tachyon/tabs/monitoring/initController.ts:877 +msgid "Route" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:252 +msgid "Route main DNS requests through the selected section." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7096 +msgid "Route requests to this DNS server through another section." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2561 +msgid "Routing mode" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:103 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:114 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:144 +msgid "Routing rules" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2570 +msgid "Routing section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6925 +msgid "Rule set must be an HTTP(S) URL or a local .srs / .json path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3897 +msgid "Rule set settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7860 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7889 +msgid "Rule sets" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2562 +msgid "Rules" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:79 +msgid "Rules mangle counters" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:74 +msgid "Rules mangle exist" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:89 +msgid "Rules mangle output counters" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:84 +msgid "Rules mangle output exist" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:99 +msgid "Rules proxy counters" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:94 +msgid "Rules proxy exist" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderRunAction.ts:17 +msgid "Run Diagnostic" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7824 +msgid "Russia inside restrictions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3490 +msgid "Save" +msgstr "" + +#: src/tachyon/tabs/monitoring/render.ts:47 +msgid "Search" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2939 +msgid "Secret" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:435 +msgid "Secret key for authenticating remote access to YACD when WAN access is enabled." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:396 +msgid "Section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7029 +msgid "Section name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:390 +msgid "Sections" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1941 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2597 +msgid "Security" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2305 +msgid "Select a common client profile or enter a custom User-Agent" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7331 +msgid "Select a network interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7761 +msgid "Select a predefined domain list" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7117 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:84 +msgid "Select a section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2282 +msgid "Select a section for downloading this subscription" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7530 +msgid "Select a transit section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1839 +msgid "Select an existing network interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3125 +msgid "Select an existing URLTest or Priority group" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7543 +msgid "Select an intermediate section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:161 +msgid "Select DNS protocol to use" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7326 +msgid "Select network interface for VPN connection" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2318 +msgid "Select or enter a User-Agent" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:624 +msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:611 +msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2967 +#: src/tachyon/tabs/dashboard/initController.ts:1151 +msgid "Select the fastest node" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:660 +msgid "Select the log level for sing-box" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:268 +msgid "Select the network interface from which the traffic will originate" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:314 +msgid "Select the network interface to which the traffic will originate" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:375 +msgid "Select the WAN interfaces to be monitored" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:910 +#: src/tachyon/tabs/dashboard/initController.ts:1137 +msgid "Selected" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2564 +msgid "Selected section" +msgstr "" + +#: src/validators/validateOutboundJson.ts:85 +msgid "Selector and URLTest outbounds must contain a non-empty outbounds array" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:413 +msgid "Server" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2537 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2743 +msgid "Server filtering" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7394 +msgid "Server group for automatic lowest-latency selection" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7453 +msgid "Server group for priority failover" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1707 +msgid "Server information" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1623 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2497 +msgid "Server name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2626 +msgid "Server name / SNI" +msgstr "" + +#: src/validators/validateOutboundJson.ts:94 +msgid "Server outbound must contain a non-empty server field" +msgstr "" + +#: src/validators/validateOutboundJson.ts:100 +msgid "Server outbound must contain a numeric server_port from 1 to 65535" +msgstr "" + +#: src/validators/validateOutboundJson.ts:107 +msgid "server_port must be a number from 1 to 65535" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:407 +msgid "Servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3141 +msgid "Servers on dashboard" +msgstr "" + +#: src/tachyon/methods/shell/index.ts:383 +#: src/tachyon/methods/shell/index.ts:404 +#: src/tachyon/tabs/diagnostic/initController.ts:594 +msgid "Service action failed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7764 +msgid "Service list" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1670 +msgid "Services info" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:425 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1097 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7000 +msgid "Settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2348 +msgid "Show metadata on dashboard" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3214 +msgid "Show only selected servers." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3173 +msgid "Show only servers with one of the selected protocols." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3179 +msgid "Show only servers with one of the selected security types." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3176 +msgid "Show only servers with one of the selected transports." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3287 +msgid "Show servers from selected groups without repeating their filtering criteria." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3206 +msgid "Show servers only from the specified countries." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3222 +msgid "Show servers whose names match the expression." +msgstr "" + +#: src/tachyon/tabs/diagnostic/initController.ts:751 +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:116 +msgid "Show sing-box config" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2349 +msgid "Show subscription metadata for this source on the dashboard" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:77 +msgid "Sing-box autostart disabled" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:69 +msgid "Sing-box FakeIP DNS does not work" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:68 +msgid "Sing-box FakeIP DNS works" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:62 +msgid "Sing-box installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:87 +msgid "Sing-box listening ports" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:82 +msgid "Sing-box process running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:72 +msgid "Sing-box service exist" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:67 +msgid "Sing-box version is compatible (newer than 1.12.4)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7555 +msgid "Sort by latency" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7556 +msgid "Sorts servers in this section by lowest latency in the dashboard." +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:831 +#: src/tachyon/tabs/monitoring/initController.ts:881 +msgid "Source" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:267 +msgid "Source Network Interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1930 +msgid "Specify a file path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1941 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1953 +msgid "Specify different files" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:122 +msgid "Standalone ByeDPI autostart is enabled" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:119 +msgid "Standalone ByeDPI is active together with Tachyon ByeDPI rules" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:120 +msgid "Standalone ByeDPI service is active" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:123 +msgid "Standalone ByeDPI service is inactive" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:111 +msgid "Standalone Zapret is active together with Tachyon Zapret rules" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:112 +msgid "Standalone Zapret service is active" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:113 +msgid "Standalone Zapret service is inactive" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:121 +msgid "Standalone Zapret2 autostart is enabled" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:118 +msgid "Standalone Zapret2 is active together with Tachyon Zapret2 rules" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:119 +msgid "Standalone Zapret2 service is active" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:122 +msgid "Standalone Zapret2 service is inactive" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:69 +msgid "Start Tachyon" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2504 +msgid "Stop checking when URLTest group is not used. Use sing-box duration format like 1d, 12h or 30m." +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:59 +msgid "Stop Tachyon" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3891 +msgid "Subnets from the list will be extracted and added to nftables" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2236 +msgid "Subscription auto update" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:149 +msgid "Subscription info:" +msgstr "" + +#: src/tachyon/methods/shell/index.ts:720 +#: src/tachyon/methods/shell/index.ts:740 +msgid "Subscription update failed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2245 +msgid "Subscription update interval" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7277 +msgid "Subscription URL" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4513 +msgid "Subscription URL cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3594 +msgid "Subscription URL settings" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:301 +msgid "Subscriptions updated" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1630 +msgid "System info" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderSystemInfo.ts:21 +msgid "System information" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:69 +msgid "Table exist" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3081 +msgid "Tailscale auth key" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3090 +msgid "Tailscale control URL" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:98 +msgid "Tailscale endpoint" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3098 +msgid "Tailscale hostname" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:242 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:250 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:491 +msgid "Test latency" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2605 +msgid "Test only selected servers." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2565 +msgid "Test only servers with one of the selected protocols." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2571 +msgid "Test only servers with one of the selected security types." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2568 +msgid "Test only servers with one of the selected transports." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2597 +msgid "Test servers only from the specified countries." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2613 +msgid "Test servers whose names match the expression." +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:913 +msgid "Testing URL" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5453 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5898 +msgid "The desync fwmark is managed by Tachyon for loop prevention and must not be overridden here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5450 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5895 +msgid "The NFQUEUE number is assigned by Tachyon for each rule and must not be overridden here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7722 +msgid "The rule applies to the domain and all its subdomains. Use full:, keyword:, or regex: prefixes for exact match, keyword match, or regular expression." +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:78 +msgid "There are rules using ByeDPI" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:78 +msgid "There are rules using Zapret" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:81 +msgid "There are rules using Zapret2" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1829 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1834 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1924 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2086 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2886 +msgid "This field is required" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6315 +msgid "This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5459 +msgid "This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5462 +msgid "This field must start a working nfqws strategy; --version exits immediately and is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5910 +msgid "This field must start a working nfqws2 strategy; --dry-run exits immediately and is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5913 +msgid "This field must start a working nfqws2 strategy; --version exits immediately and is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5631 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6105 +msgid "This flag does not accept a value." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5662 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6136 +msgid "This option requires a value." +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:822 +#: src/tachyon/tabs/monitoring/initController.ts:878 +msgid "Time" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:223 +msgid "Time in seconds for DNS record caching (default: 60)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3055 +msgid "Time skew tolerance" +msgstr "" + +#: src/tachyon/helpers/singBoxVariant.ts:53 +msgid "tiny" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2476 +#: src/tachyon/tabs/dashboard/initController.ts:915 +msgid "Tolerance" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3024 +msgid "Top level has the highest priority; lower levels are used as fallback" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1556 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:130 +msgid "Traffic" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2735 +msgid "Traffic for this level goes directly." +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1590 +msgid "Traffic Total" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6306 +msgid "Transparent proxy mode is incompatible with action=byedpi because Tachyon connects to ciadpi through SOCKS." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1935 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2764 +msgid "Transport" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2788 +msgid "Transport host" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2772 +msgid "Transport path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2007 +msgid "Transport path must start with /" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts:25 +msgid "Troubleshooting" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2294 +msgid "Try compatible User-Agent profiles automatically when downloading this subscription" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:234 +msgid "TTL must be a positive number" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:229 +msgid "TTL value cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1638 +#: src/tachyon/tabs/monitoring/initController.ts:816 +#: src/tachyon/tabs/monitoring/initController.ts:876 +msgid "Type" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:165 +msgid "UDP (Unprotected DNS)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6230 +msgid "Unable to validate the ByeDPI strategy through the backend parser." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5373 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6708 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7151 +msgid "Unable to validate the NFQWS strategy through the backend parser." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5818 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6717 +msgid "Unable to validate the NFQWS2 strategy through the backend parser." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2941 +#: src/tachyon/tabs/dashboard/initController.ts:1145 +msgid "Unavailability timeout" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6447 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6496 +msgid "Unexpected ByeDPI strategy argument: %s" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:96 +msgid "Unexpected Tachyon-managed ciadpi runtime is running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:89 +msgid "Unexpected Tachyon-managed nfqws runtime is running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:92 +msgid "Unexpected Tachyon-managed nfqws2 runtime is running" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5611 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6085 +msgid "Unexpected standalone token. Use explicit flags such as --name or --name=value." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:54 +msgid "unique UCI identifier" +msgstr "" + +#: src/tachyon/helpers/singBoxVariant.ts:28 +#: src/tachyon/services/systemInfo.service.ts:10 +#: src/tachyon/services/systemInfo.service.ts:11 +#: src/tachyon/services/systemInfo.service.ts:12 +#: src/tachyon/services/systemInfo.service.ts:13 +#: src/tachyon/services/systemInfo.service.ts:18 +#: src/tachyon/services/systemInfo.service.ts:20 +#: src/tachyon/services/systemInfo.service.ts:22 +#: src/tachyon/services/systemInfo.service.ts:25 +#: src/tachyon/services/systemInfo.service.ts:26 +#: src/tachyon/tabs/updates/initController.ts:329 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:25 +msgid "unknown" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6416 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6438 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6489 +msgid "Unknown ByeDPI option: %s" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:578 +msgid "Unknown country" +msgstr "" + +#: src/tachyon/api.ts:40 +msgid "Unknown error" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5622 +msgid "Unknown NFQWS flag." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6096 +msgid "Unknown NFQWS2 flag." +msgstr "" + +#: src/validators/validateSubnet.ts:17 +msgid "Unspecified IP address is not allowed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2120 +msgid "Unsupported security mode for this protocol" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:754 +msgid "Update" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:287 +msgid "Update is available" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:994 +msgid "Update is available:" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:219 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:232 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:233 +msgid "Update subscriptions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2237 +msgid "Update this subscription automatically" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1558 +#: src/tachyon/tabs/dashboard/initController.ts:1593 +msgid "Uplink" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:828 +#: src/tachyon/tabs/monitoring/initController.ts:880 +msgid "Uploaded" +msgstr "" + +#: src/validators/validateProxyUrl.ts:47 +msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://" +msgstr "" + +#: src/validators/validateUrl.ts:18 +msgid "URL must use one of the following protocols:" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2917 +msgid "URL used to check whether a server is alive" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2491 +msgid "URL used to test server latency" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7393 +msgid "URLTest" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1042 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:385 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:386 +msgid "URLTest details" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:901 +msgid "URLTest details are unavailable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3626 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3627 +msgid "URLTest settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2104 +msgid "Use 32 hex characters" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1894 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1900 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2016 +msgid "Use a domain name or IP address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1995 +msgid "Use a duration like 30s, 5m, or 1h30m" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2001 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2888 +msgid "Use a non-negative integer" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2107 +msgid "Use a non-zero secret" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1963 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1965 +msgid "Use an HTTP or HTTPS URL" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1917 +msgid "Use an IP address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1910 +msgid "Use an IP listen address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7492 +msgid "Use another section as an intermediate hop to connect to servers in this section. Does not apply to network interfaces or JSON outbounds." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2064 +msgid "Use CIDR prefixes like 192.168.1.0/24 or 2001:db8::/32" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2078 +msgid "Use CIDR prefixes or IP addresses like 192.168.1.0/24, 192.168.1.10, or 2001:db8::1" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2022 +msgid "Use letters, digits, dots, underscores, or hyphens" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:149 +msgid "Use sing-box duration format like 10s, 1m or 2m30s" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2246 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2465 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2990 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4446 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4453 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4460 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:466 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:488 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:495 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:511 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:539 +msgid "Use sing-box duration format like 1d, 12h or 30m" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:25 +msgid "Used by" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2304 +msgid "User-Agent" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2847 +msgid "Username" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7649 +msgid "Username cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5146 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5159 +#: src/validators/validateDns.ts:21 +#: src/validators/validateDns.ts:25 +#: src/validators/validateDomain.ts:57 +#: src/validators/validateDomain.ts:76 +#: src/validators/validateHttpProxyUrl.ts:97 +#: src/validators/validateHysteriaUrl.ts:162 +#: src/validators/validateIp.ts:25 +#: src/validators/validateIp.ts:33 +#: src/validators/validateIp.ts:41 +#: src/validators/validateOutboundJson.ts:118 +#: src/validators/validatePath.ts:16 +#: src/validators/validateShadowsocksUrl.ts:103 +#: src/validators/validateSocksUrl.ts:83 +#: src/validators/validateSubnet.ts:42 +#: src/validators/validateTrojanUrl.ts:66 +#: src/validators/validateUrl.ts:34 +#: src/validators/validateVlessUrl.ts:116 +#: src/validators/validateVmessUrl.ts:66 +msgid "Valid" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4575 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4595 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4599 +msgid "Validation errors:" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5136 +msgid "Value cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1838 +msgid "Value must not contain control characters" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1876 +msgid "Via country.is" +msgstr "" + +#: src/tachyon/tabs/diagnostic/initController.ts:717 +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:107 +msgid "View logs" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7030 +msgid "Visible name of this section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2891 +msgid "VLESS flow" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7253 +msgid "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:70 +msgid "WAN" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:198 +msgid "WAN public IP" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7807 +msgid "Warning: %s cannot be used together with %s. Previous selections have been removed." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7826 +msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7043 +msgid "What Tachyon should do when this section matches" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2968 +msgid "When switching to another level, test every server and select the fastest instead of the first working one." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2801 +msgid "XHTTP mode" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:434 +msgid "YACD Secret Key" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:762 +msgid "Yes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:305 +msgid "You can select Output Network Interface, by default autodetect" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5447 +msgid "Zapret hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS strategies." +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:71 +msgid "Zapret package is installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:72 +msgid "Zapret package is not installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:60 +msgid "Zapret provider binary is available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:61 +msgid "Zapret provider binary is not available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:103 +msgid "Zapret sing-box outbound is configured" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:104 +msgid "Zapret sing-box outbound is not configured" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5892 +msgid "Zapret2 hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS2 strategies." +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:74 +msgid "Zapret2 package is installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:75 +msgid "Zapret2 package is not installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:63 +msgid "Zapret2 provider binary is available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:64 +msgid "Zapret2 provider binary is not available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:106 +msgid "Zapret2 sing-box outbound is configured" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:107 +msgid "Zapret2 sing-box outbound is not configured" +msgstr "" diff --git a/fe-app-tachyon/locales/tachyon.ru.po b/fe-app-tachyon/locales/tachyon.ru.po new file mode 100644 index 00000000..f583f559 --- /dev/null +++ b/fe-app-tachyon/locales/tachyon.ru.po @@ -0,0 +1,2310 @@ +# RU translations for TACHYON package. +# Copyright (C) 2026 THE TACHYON COPYRIGHT HOLDER +# This file is distributed under the same license as the TACHYON package. +# ushan0v, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: TACHYON\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-13 01:05+0300\n" +"PO-Revision-Date: 2026-07-13 01:05+0300\n" +"Last-Translator: ushan0v\n" +"Language-Team: none\n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +msgid "-- Select --" +msgstr "-- Выберите --" + +msgid "+ Add JSON outbound" +msgstr "+ Добавить JSON outbound" + +msgid "+ Add level" +msgstr "+ Добавить уровень" + +msgid "+ Add priority" +msgstr "+ Добавить приоритет" + +msgid "+ Add URLTest" +msgstr "+ Добавить URLTest" + +msgid "✔ Running" +msgstr "✔ Работает" + +msgid "✘ Stopped" +msgstr "✘ Остановлен" + +msgid "Accept external proxy connections and route them with sing-box." +msgstr "Принимает внешние прокси-подключения и маршрутизирует их через sing-box." + +msgid "Accept routes" +msgstr "Принимать маршруты" + +msgid "Action" +msgstr "Действие" + +msgid "Active" +msgstr "Активные" + +msgid "Active Bootstrap DNS" +msgstr "Активный Bootstrap DNS" + +msgid "Active Connections" +msgstr "Активные соединения" + +msgid "Active Main DNS" +msgstr "Активный основной DNS" + +msgid "Add a section" +msgstr "Добавить секцию" + +msgid "Add a server inbound" +msgstr "Добавить серверный inbound" + +msgid "Add at least one DNS server" +msgstr "Добавьте хотя бы один DNS-сервер" + +msgid "Add prefix to nodes" +msgstr "Добавлять префикс к узлам" + +msgid "Add URLs or local paths to .lst lists. Only domain rules are supported." +msgstr "Добавьте URL или локальные пути к спискам .lst. Поддерживаются только доменные правила." + +msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported." +msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только доменные правила." + +msgid "Add URLTest / Priority group" +msgstr "Добавить URLTest / Приоритет группу" + +msgid "Additional marking rules found" +msgstr "Найдены дополнительные правила маркировки" + +msgid "Additionally exclude servers by protocol, transport, and security. Exclude only servers matching the specified parameters." +msgstr "Дополнительно исключать серверы по протоколу, транспорту и защите. Исключать только серверы, совпавшие по указанным параметрам." + +msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters." +msgstr "Дополнительно отбирать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам." + +msgid "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters." +msgstr "Дополнительно скрывать серверы по протоколу, транспорту и защите. Скрывать серверы, совпавшие по указанным параметрам." + +msgid "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters." +msgstr "Дополнительно показывать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам." + +msgid "Address" +msgstr "Адрес" + +msgid "Advertise exit node" +msgstr "Анонсировать exit node" + +msgid "Advertise routes" +msgstr "Анонсировать маршруты" + +msgid "All" +msgstr "Все" + +msgid "All except selected" +msgstr "Все, кроме выбранных" + +msgid "All remaining except selected" +msgstr "Все оставшиеся, кроме выбранных" + +msgid "All remaining servers" +msgstr "Все оставшиеся серверы" + +msgid "All remaining servers means every server not already assigned to a higher-priority level." +msgstr "Добавляет все серверы, которые не попали в уровни с более высоким приоритетом." + +msgid "All servers" +msgstr "Все серверы" + +msgid "All traffic from these IP addresses will be routed through the section unconditionally, ignoring all other conditions." +msgstr "Весь трафик с этих IP-адресов будет направлен через секцию безусловно, игнорируя остальные условия." + +msgid "Allowed domain prefixes are full:, keyword:, and regex:" +msgstr "Разрешённые префиксы доменов: full:, keyword: и regex:" + +msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall." +msgstr "Обеспечивает доступ к YACD из WAN. Убедитесь, что в брандмауэре открыт соответствующий порт." + +msgid "Allows limiting the list of servers for URLTest" +msgstr "Позволяет ограничить список серверов для URLTest" + +msgid "ALPN" +msgstr "ALPN" + +msgid "Alter ID" +msgstr "Alter ID" + +msgid "Apply section rules only to the specified local IP addresses" +msgstr "Применять правила секции только к указанным локальным IP-адресам" + +msgid "At least one valid domain must be specified." +msgstr "Нужно указать хотя бы один корректный домен." + +msgid "At least one valid IP or subnet must be specified." +msgstr "Нужно указать хотя бы один корректный IP-адрес или подсеть." + +msgid "Auto refresh" +msgstr "Автообновление" + +msgid "Auto update" +msgstr "Автообновление" + +msgid "Auto-generate HWID" +msgstr "Авто-генерация HWID" + +msgid "Automatic component update checks" +msgstr "Автоматическая проверка обновлений компонентов" + +msgid "Automatic User-Agent selection" +msgstr "Авто-подбор User-Agent" + +msgid "Automatically add text to the name of each server from this subscription for convenient filtering." +msgstr "Автоматически добавлять текст к имени каждого сервера из этой подписки для удобной фильтрации." + +msgid "Automatically check installed components for new versions" +msgstr "Автоматически проверять наличие новых версий установленных компонентов" + +msgid "Automatically select the fastest node in the current level" +msgstr "Автовыбор самого быстрого узла уровня" + +msgid "Available actions" +msgstr "Доступные действия" + +msgid "Backend validation failed: %s" +msgstr "Backend-проверка завершилась ошибкой: %s" + +msgid "Bootstrap DNS" +msgstr "Bootstrap DNS" + +msgid "Bootstrap DNS Servers" +msgstr "Bootstrap DNS-серверы" + +msgid "Browser FakeIP check could not be completed" +msgstr "Не удалось выполнить проверку FakeIP в браузере" + +msgid "Browser is not using FakeIP" +msgstr "Браузер не использует FakeIP" + +msgid "Browser is using FakeIP correctly" +msgstr "Браузер использует FakeIP" + +msgid "Built-in rule sets" +msgstr "Встроенные наборы правил" + +msgid "By flag emoji from name" +msgstr "По эмодзи флага из названия" + +msgid "ByeDPI listen address and port are assigned by Tachyon and must not be set in the strategy." +msgstr "Адрес и порт прослушивания ByeDPI назначаются Tachyon и не должны задаваться в стратегии." + +msgid "ByeDPI option does not accept a compact value: %s" +msgstr "Опция ByeDPI не принимает компактное значение: %s" + +msgid "ByeDPI option does not accept a value: %s" +msgstr "Опция ByeDPI не принимает значение: %s" + +msgid "ByeDPI option requires a value: %s" +msgstr "Для опции ByeDPI требуется значение: %s" + +msgid "ByeDPI package is installed" +msgstr "Пакет ByeDPI установлен" + +msgid "ByeDPI package is not installed" +msgstr "Пакет ByeDPI не установлен" + +msgid "ByeDPI provider binary is available" +msgstr "Бинарный файл провайдера ByeDPI доступен" + +msgid "ByeDPI provider binary is not available" +msgstr "Бинарный файл провайдера ByeDPI недоступен" + +msgid "ByeDPI sing-box outbound is configured" +msgstr "Outbound ByeDPI в sing-box настроен" + +msgid "ByeDPI sing-box outbound is not configured" +msgstr "Outbound ByeDPI в sing-box не настроен" + +msgid "ByeDPI Strategy" +msgstr "Стратегия ByeDPI" + +msgid "ByeDPI strategy cannot be empty" +msgstr "Стратегия ByeDPI не может быть пустой" + +msgid "Cache File Path" +msgstr "Путь к файлу кэша" + +msgid "Cache file path cannot be empty" +msgstr "Путь к файлу кэша не может быть пустым" + +msgid "Cannot receive checks result" +msgstr "Не удалось получить результаты проверки" + +msgid "Cannot save server" +msgstr "Не удалось сохранить сервер" + +msgid "Cannot save settings" +msgstr "Не удалось сохранить настройки" + +msgid "Cascade connection" +msgstr "Каскадное подключение" + +msgid "Certificate path" +msgstr "Путь к сертификату" + +msgid "Certificate pin is unavailable. Apply or restart the server to generate the default certificate, then open this dialog again." +msgstr "Пин сертификата недоступен. Примените или перезапустите сервер, чтобы сгенерировать дефолтный сертификат, затем откройте это окно снова." + +msgid "Check interval" +msgstr "Интервал проверки" + +msgid "Check timeout after which the server is considered dead" +msgstr "Таймаут проверки, после которого сервер считается мёртвым" + +msgid "Check update" +msgstr "Проверить обновление" + +msgid "Check URL" +msgstr "URL проверки" + +msgid "Checking, please wait" +msgstr "Проверяем, пожалуйста подождите" + +msgid "checks" +msgstr "проверки" + +msgid "Checks failed" +msgstr "Проверки не выполнены" + +msgid "Checks passed" +msgstr "Проверки пройдены" + +msgid "Checks passed with manual DHCP" +msgstr "Проверки пройдены с ручным DHCP" + +msgid "CIDR must be between 0 and 32 for IPv4 or 0 and 128 for IPv6" +msgstr "CIDR должен быть от 0 до 32 для IPv4 или от 0 до 128 для IPv6" + +msgid "Client fingerprint" +msgstr "Отпечаток клиента" + +msgid "Client link" +msgstr "Ссылка клиента" + +msgid "Client link is not available yet" +msgstr "Ссылка клиента пока недоступна" + +msgid "Client link QR code" +msgstr "QR-код клиентской ссылки" + +msgid "Close" +msgstr "Закрыть" + +msgid "Close all connections" +msgstr "Закрыть все подключения" + +msgid "Close connection" +msgstr "Закрыть подключение" + +msgid "Closed" +msgstr "Закрытые" + +msgid "Component update available" +msgstr "Доступно обновление компонента" + +msgid "Component update check interval" +msgstr "Период проверки обновлений" + +msgid "Components" +msgstr "Компоненты" + +msgid "compressed" +msgstr "compressed" + +msgid "Concurrency" +msgstr "Параллелизм" + +msgid "Conditions" +msgstr "Условия" + +msgid "Config File Path" +msgstr "Путь к файлу конфигурации" + +msgid "Configuration for Tachyon service" +msgstr "Настройки сервиса Tachyon" + +msgid "Configure User-Agent in the item settings" +msgstr "Настройте User-Agent в настройках элемента" + +msgid "Connect through" +msgstr "Подключаться через" + +msgid "Connection URL" +msgstr "URL подключения" + +msgid "Connections are unavailable" +msgstr "Подключения недоступны" + +msgid "Connectivity probe failed" +msgstr "Проверка соединения не прошла" + +msgid "Copied" +msgstr "Скопировано" + +msgid "Copy" +msgstr "Копировать" + +msgid "Copy proxy link" +msgstr "Скопировать ссылку прокси" + +msgid "Could not compare FakeIP and control public IPs" +msgstr "Не удалось сравнить публичные IP FakeIP и контрольной проверки" + +msgid "Current section cannot be used as its own transit section" +msgstr "Текущую секцию нельзя использовать как транзит для самой себя" + +msgid "Current section cannot download its own subscription" +msgstr "Текущую секцию нельзя выбрать для скачивания её же подписки" + +msgid "Currently unavailable" +msgstr "Временно недоступно" + +msgid "Custom outbound configurations in JSON format" +msgstr "Пользовательские конфигурации outbound в формате JSON" + +msgid "Dashboard" +msgstr "Дашборд" + +msgid "Dashboard currently unavailable" +msgstr "Дашборд сейчас недоступен" + +msgid "Default address for checking server availability and latency. URLTest uses its own address." +msgstr "Адрес по умолчанию для проверки доступности и задержки серверов. Для URLTest используется собственный адрес." + +msgid "Delay in milliseconds before reloading Tachyon after interface UP" +msgstr "Задержка в миллисекундах перед перезапуском Tachyon после поднятия интерфейса" + +msgid "Delay value cannot be empty" +msgstr "Значение задержки не может быть пустым" + +msgid "Delete" +msgstr "Удалить" + +msgid "Detect server country" +msgstr "Определять страну сервера" + +msgid "detour must be a non-empty string" +msgstr "detour должен быть непустой строкой" + +msgid "Device filter" +msgstr "Фильтр по устройствам" + +msgid "Device or IP" +msgstr "Устройство или IP" + +msgid "DHCP has DNS server" +msgstr "DHCP содержит DNS сервер" + +msgid "DHCP is managed manually" +msgstr "DHCP управляется вручную" + +msgid "Diagnostics" +msgstr "Диагностика" + +msgid "Direct" +msgstr "Напрямую" + +msgid "Direct connection" +msgstr "Прямое подключение" + +msgid "Disable autostart" +msgstr "Отключить автостарт" + +msgid "Disable QUIC" +msgstr "Отключить QUIC" + +msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming" +msgstr "Отключить QUIC протокол для улучшения совместимости или исправления видео стриминга" + +msgid "Disabling interception is incompatible with action=zapret2 because Tachyon sends matched traffic through NFQUEUE." +msgstr "Отключение перехвата несовместимо с action=zapret2, потому что Tachyon отправляет совпавший трафик через NFQUEUE." + +msgid "Display name" +msgstr "Отображаемое имя" + +msgid "DNS Check Interval" +msgstr "Период проверки DNS" + +msgid "DNS on router" +msgstr "DNS на роутере" + +msgid "DNS over HTTPS (DoH)" +msgstr "DNS через HTTPS (DoH)" + +msgid "DNS over TLS (DoT)" +msgstr "DNS через TLS (DoT)" + +msgid "DNS protocol" +msgstr "Протокол DNS" + +msgid "DNS Protocol Type" +msgstr "Тип протокола DNS" + +msgid "DNS protocol used by the resolver" +msgstr "DNS-протокол, который будет использовать резолвер" + +msgid "DNS requests through section" +msgstr "DNS-запросы через секцию" + +msgid "DNS Rewrite TTL" +msgstr "Перезапись TTL для DNS" + +msgid "DNS server" +msgstr "DNS-сервер" + +msgid "DNS server address cannot be empty" +msgstr "Адрес DNS-сервера не может быть пустым" + +msgid "DNS server used by the resolver" +msgstr "DNS-сервер, который будет использовать резолвер" + +msgid "DNS server used to obtain IP addresses for upstream DNS and proxies. If multiple servers are selected, a timeout switches to a backup." +msgstr "DNS-сервер для получения IP вышестоящих DNS и прокси. Если выбрать несколько, то при таймауте произойдёт переключение на резервный." + +msgid "DNS Servers" +msgstr "DNS-серверы" + +msgid "DNS Strategy" +msgstr "Стратегия DNS" + +msgid "DNS through proxy" +msgstr "DNS через прокси" + +msgid "DNS through section" +msgstr "DNS через секцию" + +msgid "DNS Unavailability Timeout" +msgstr "Таймаут недоступности DNS" + +msgid "Do not panic, everything can be fixed, just..." +msgstr "Не паникуйте, всё можно исправить, просто..." + +msgid "Do not test servers from these countries." +msgstr "Не тестировать серверы из этих стран." + +msgid "Do not test servers whose names match the expression." +msgstr "Не тестировать серверы, имена которых совпадают с выражением." + +msgid "Do not test servers with one of the selected protocols." +msgstr "Не тестировать серверы с одним из выбранных протоколов." + +msgid "Do not test servers with one of the selected security types." +msgstr "Не тестировать серверы с одним из выбранных типов защиты." + +msgid "Do not test servers with one of the selected transports." +msgstr "Не тестировать серверы с одним из выбранных транспортов." + +msgid "Do not test specified servers." +msgstr "Не тестировать указанные серверы." + +msgid "Does not resolve" +msgstr "Не резолвится" + +msgid "Domain lists" +msgstr "Списки доменов" + +msgid "Domain Resolver" +msgstr "Резолвер доменов" + +msgid "Domains" +msgstr "Домены" + +msgid "Dont Touch My DHCP!" +msgstr "Отключить управление DHCP" + +msgid "Downlink" +msgstr "Входящий" + +msgid "Download" +msgstr "Скачать" + +msgid "Download component packages via the selected section" +msgstr "Скачивать пакеты компонентов через выбранную секцию" + +msgid "Download components through" +msgstr "Скачивать компоненты через" + +msgid "Download components through a section" +msgstr "Скачивать компоненты через секцию" + +msgid "Download lists through" +msgstr "Скачивать списки через" + +msgid "Download lists through a section" +msgstr "Скачивать списки через секцию" + +msgid "Download remote lists and rule sets via the selected section" +msgstr "Скачивать удаленные списки и наборы правил через выбранную секцию" + +msgid "Download subscription through a section" +msgstr "Скачивать подписку через секцию" + +msgid "Download subscriptions via the selected section" +msgstr "Скачивать подписки через выбранную секцию" + +msgid "Download through" +msgstr "Скачивать через" + +msgid "Downloaded" +msgstr "Загружено" + +msgid "Drag rows to change priority. The rule at the top is checked first." +msgstr "Перетаскивайте строки, чтобы изменить приоритет. Правило сверху проверяется первым." + +msgid "Duplicate JSON outbound tag" +msgstr "Такой тег JSON outbound уже используется" + +msgid "Duplicate value" +msgstr "Дублирующееся значение" + +msgid "Edit rule action" +msgstr "Изменить" + +msgid "Enable" +msgstr "Включить" + +msgid "Enable authentication" +msgstr "Включить авторизацию" + +msgid "Enable automatic updates for remote lists and rule sets" +msgstr "Включить автоматическое обновление удалённых списков и наборов правил" + +msgid "Enable autostart" +msgstr "Включить автостарт" + +msgid "Enable built-in DNS resolver for domains handled by this section" +msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых этой секцией" + +msgid "Enable list updates" +msgstr "Включить обновление списков" + +msgid "Enable Mixed Proxy" +msgstr "Включить смешанный прокси" + +msgid "Enable Mixed Proxy Authentication" +msgstr "Включить авторизацию смешанного прокси" + +msgid "Enable Output Network Interface" +msgstr "Включить выходной сетевой интерфейс" + +msgid "Enable YACD" +msgstr "Включить YACD" + +msgid "Enable YACD WAN Access" +msgstr "Включить доступ YACD WAN" + +msgid "Enter a complete sing-box inbound object" +msgstr "Введите полный объект inbound sing-box" + +msgid "Enter a complete sing-box outbound object" +msgstr "Введите полный объект outbound sing-box" + +msgid "Enter a display name" +msgstr "Введите отображаемое имя" + +msgid "Enter a level name" +msgstr "Введите название уровня" + +msgid "Enter a valid DNS server address" +msgstr "Укажите корректный адрес DNS-сервера" + +msgid "Enter HWID" +msgstr "Укажите HWID" + +msgid "Enter the HWID sent with subscription requests" +msgstr "HWID, который будет отправляться при запросах подписки" + +msgid "Enter the subscription URL" +msgstr "Введите URL подписки" + +msgid "Exclude by proxy parameters" +msgstr "Исключить по параметрам прокси" + +msgid "Exclude by regular expression" +msgstr "Исключить по регулярному выражению" + +msgid "Exclude countries" +msgstr "Исключить страны" + +msgid "Exclude NTP" +msgstr "Исключить NTP" + +msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN" +msgstr "Исключите трафик протокола NTP из туннеля, чтобы предотвратить его маршрутизацию через прокси-сервер или VPN." + +msgid "Exclude servers" +msgstr "Исключить серверы" + +msgid "Exclude servers with one of the selected protocols from this level." +msgstr "Исключить из этого уровня серверы с одним из выбранных протоколов." + +msgid "Exclude servers with one of the selected security types from this level." +msgstr "Исключить из этого уровня серверы с одним из выбранных типов защиты." + +msgid "Exclude servers with one of the selected transports from this level." +msgstr "Исключить из этого уровня серверы с одним из выбранных транспортов." + +msgid "Exclude URLTest / Priority group" +msgstr "Исключить URLTest / Приоритет группу" + +msgid "Expecting: %s" +msgstr "Ожидается: %s" + +msgid "Expires" +msgstr "Истекает" + +msgid "Expose this section as a local HTTP+SOCKS proxy" +msgstr "Открыть эту секцию как локальный HTTP+SOCKS прокси" + +msgid "External nfqws config files bypass Tachyon queue management and explicit validation." +msgstr "Внешние конфиги nfqws обходят управление очередями и явную валидацию Tachyon." + +msgid "External nfqws2 config files bypass Tachyon queue management and explicit validation." +msgstr "Внешние конфиги nfqws2 обходят управление очередями и явную валидацию Tachyon." + +msgid "Failed to close connection" +msgstr "Не удалось закрыть подключение" + +msgid "Failed to close connections" +msgstr "Не удалось закрыть подключения" + +msgid "Failed to copy!" +msgstr "Не удалось скопировать!" + +msgid "Failed to execute" +msgstr "Не удалось выполнить" + +msgid "Failed to update subscriptions" +msgstr "Не удалось обновить подписки" + +msgid "FakeIP and control checks use different public IPs" +msgstr "FakeIP и контрольная проверка используют разные публичные IP" + +msgid "FakeIP and control checks use the same public IP" +msgstr "FakeIP и контрольная проверка используют один публичный IP" + +msgid "FakeIP works; public IP comparison is inconclusive" +msgstr "FakeIP работает; сравнение публичных IP не дает однозначного результата" + +msgid "FakeTLS host" +msgstr "FakeTLS-хост" + +msgid "Fallback on unknown DC" +msgstr "Fallback для неизвестного DC" + +msgid "Faster server search interval" +msgstr "Интервал поиска более быстрого сервера" + +msgid "Fastest" +msgstr "Самый быстрый" + +msgid "Filter the servers that will be displayed on the dashboard." +msgstr "Отфильтровать серверы, которые будут отображаться в дашборде." + +msgid "Firewall WAN port" +msgstr "Порт в firewall для WAN" + +msgid "Fix the highlighted fields and save again." +msgstr "Исправьте подсвеченные поля и сохраните снова." + +msgid "Forced device routing" +msgstr "Принудительная маршрутизация устройств" + +msgid "Tachyon Error" +msgstr "Ошибка Tachyon" + +msgid "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here." +msgstr "Tachyon сам управляет pid-файлами ciadpi, поэтому параметры pidfile здесь запрещены." + +msgid "Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here." +msgstr "Tachyon сам управляет жизненным циклом ciadpi, поэтому режим daemon здесь запрещён." + +msgid "Tachyon manages the nfqws process lifecycle itself, so daemon mode is not allowed here." +msgstr "Tachyon сам управляет жизненным циклом nfqws, поэтому режим daemon здесь запрещён." + +msgid "Tachyon manages the nfqws2 process lifecycle itself, so daemon mode is not allowed here." +msgstr "Tachyon сам управляет жизненным циклом nfqws2, поэтому режим daemon здесь запрещён." + +msgid "Tachyon service is stopped. Start the service to display connections." +msgstr "Служба Tachyon остановлена. Запустите сервис для отображения подключений." + +msgid "Tachyon service is stopped. Start the service to display the dashboard." +msgstr "Служба Tachyon остановлена. Запустите сервис для отображения дашборда." + +msgid "Tachyon Settings" +msgstr "Настройки Tachyon" + +msgid "Tachyon will not modify your DHCP configuration" +msgstr "Tachyon не будет изменять вашу конфигурацию DHCP" + +msgid "Tachyon-managed ciadpi runtime has restarted" +msgstr "ciadpi Tachyon перезапускался" + +msgid "Tachyon-managed ciadpi runtime is not ready" +msgstr "ciadpi Tachyon не готов" + +msgid "Tachyon-managed ciadpi runtime is not running" +msgstr "ciadpi Tachyon не запущен" + +msgid "Tachyon-managed ciadpi runtime is ready" +msgstr "ciadpi Tachyon готов" + +msgid "Tachyon-managed nfqws runtime is not ready" +msgstr "NFQWS Tachyon не готов" + +msgid "Tachyon-managed nfqws runtime is not running" +msgstr "NFQWS Tachyon не запущен" + +msgid "Tachyon-managed nfqws runtime is ready" +msgstr "NFQWS Tachyon готов" + +msgid "Tachyon-managed nfqws2 runtime is not ready" +msgstr "NFQWS2 Tachyon не готов" + +msgid "Tachyon-managed nfqws2 runtime is not running" +msgstr "NFQWS2 Tachyon не запущен" + +msgid "Tachyon-managed nfqws2 runtime is ready" +msgstr "NFQWS2 Tachyon готов" + +msgid "Fronting IP" +msgstr "IP фронтинга" + +msgid "Fronting port" +msgstr "Порт фронтинга" + +msgid "Fronting proxy protocol" +msgstr "Proxy protocol для фронтинга" + +msgid "Fuzzing is not supported here because Tachyon needs deterministic runtime validation." +msgstr "Fuzzing здесь не поддерживается, потому что Tachyon нужна предсказуемая runtime-валидация." + +msgid "Generate HWID from router hardware information for this subscription" +msgstr "Генерировать HWID из аппаратной информации роутера для этой подписки" + +msgid "Generated inbound" +msgstr "Созданный inbound" + +msgid "Get global check" +msgstr "Получить глобальную проверку" + +msgid "Global check" +msgstr "Глобальная проверка" + +msgid "gRPC service" +msgstr "gRPC-сервис" + +msgid "Handshake timeout" +msgstr "Таймаут рукопожатия" + +msgid "Hide cascade connection nodes" +msgstr "Скрывать узлы каскадных подключений" + +msgid "Hide individual nodes that are already included in imported subscription URLTest groups" +msgstr "Скрывать отдельные узлы, которые уже входят в импортированные URLTest группы подписки" + +msgid "Hide intermediate nodes used as detours by other subscription nodes" +msgstr "Скрывать промежуточные узлы, которые используются как detour для других узлов подписки" + +msgid "Hide selected servers." +msgstr "Скрывать выбранные серверы." + +msgid "Hide servers from selected groups without repeating their filtering criteria." +msgstr "Скрывать серверы из выбранных групп без повторения их параметров фильтрации." + +msgid "Hide servers from the specified countries." +msgstr "Скрывать серверы из указанных стран." + +msgid "Hide servers whose names match the expression." +msgstr "Скрывать серверы, имена которых совпадают с выражением." + +msgid "Hide servers with one of the selected protocols." +msgstr "Скрывать серверы с одним из выбранных протоколов." + +msgid "Hide servers with one of the selected security types." +msgstr "Скрывать серверы с одним из выбранных типов защиты." + +msgid "Hide servers with one of the selected transports." +msgstr "Скрывать серверы с одним из выбранных транспортов." + +msgid "Hide URLTest group nodes" +msgstr "Скрывать узлы URLTest групп" + +msgid "Hide values" +msgstr "Скрывать значения" + +msgid "Higher-level check interval" +msgstr "Интервал проверки высших уровней" + +msgid "Higher-priority DNS Check" +msgstr "Проверка верхних DNS" + +msgid "Host" +msgstr "Хост" + +msgid "How often higher priority levels are checked while a lower level is active" +msgstr "Как часто проверять верхние уровни приоритета, пока активен нижний уровень" + +msgid "How often the currently selected server is checked" +msgstr "Как часто проверяется текущий выбранный сервер" + +msgid "How often to check the active DNS servers." +msgstr "Как часто проверять активные DNS-серверы." + +msgid "How often to check whether a higher-priority DNS server has recovered." +msgstr "Как часто проверять, восстановился ли DNS-сервер с более высоким приоритетом." + +msgid "HTTP error" +msgstr "Ошибка HTTP" + +msgid "HTTP hosts" +msgstr "HTTP-хосты" + +msgid "HWID" +msgstr "HWID" + +msgid "Idle timeout" +msgstr "Таймаут простоя" + +msgid "Import subscription URLTest groups" +msgstr "Добавлять URLTest группы из подписки" + +msgid "Import URLTest groups returned by this subscription provider" +msgstr "Импортировать URLTest группы, если провайдер подписки возвращает их" + +msgid "Inbound JSON" +msgstr "Inbound JSON" + +msgid "Inbound JSON cannot be empty" +msgstr "Inbound JSON не может быть пустым" + +msgid "Inbound JSON must be an object with a type field" +msgstr "Inbound JSON должен быть объектом с полем type" + +msgid "Include by proxy parameters" +msgstr "Включить по параметрам прокси" + +msgid "Include by regular expression" +msgstr "Включить по регулярному выражению" + +msgid "Include countries" +msgstr "Включить страны" + +msgid "Include IP addresses and subnets" +msgstr "Учитывать IP-адреса и подсети" + +msgid "Include servers" +msgstr "Включить серверы" + +msgid "Information" +msgstr "Информация" + +msgid "Install" +msgstr "Установить" + +msgid "Install another build:" +msgstr "Установить другую сборку:" + +msgid "Installed version is newer than release" +msgstr "Установлена версия новее релиза" + +msgid "Interface Monitoring" +msgstr "Мониторинг интерфейса" + +msgid "Interface Monitoring Delay" +msgstr "Задержка при мониторинге интерфейсов" + +msgid "Interface monitoring for Bad WAN" +msgstr "Мониторинг интерфейса для Bad WAN" + +msgid "Interrupt connections" +msgstr "Прерывать соединения" + +msgid "Interrupt connections when priority failover switches server" +msgstr "Разрывать соединения при переключении сервера приоритетом" + +msgid "Interrupt connections when URLTest switches the selected server" +msgstr "Прерывать соединения, когда URLTest переключает выбранный сервер" + +msgid "Interval" +msgstr "Интервал" + +msgid "Invalid ByeDPI strategy" +msgstr "Некорректная стратегия ByeDPI" + +msgid "Invalid CIDR prefix" +msgstr "Неверный префикс CIDR" + +msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" +msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, dns.example.com или dns.example.com/nicedns для DoH" + +msgid "Invalid domain address" +msgstr "Неверный домен" + +msgid "Invalid format. Use an IP address or CIDR subnet" +msgstr "Неверный формат. Используйте IP-адрес или подсеть CIDR" + +msgid "Invalid HTTP proxy URL: invalid host and port" +msgstr "Неверный URL HTTP proxy: неверный хост и порт" + +msgid "Invalid HTTP proxy URL: invalid host format" +msgstr "Неверный URL HTTP proxy: неверный формат хоста" + +msgid "Invalid HTTP proxy URL: invalid port number" +msgstr "Неверный URL HTTP proxy: неверный номер порта" + +msgid "Invalid HTTP proxy URL: missing host and port" +msgstr "Неверный URL HTTP proxy: отсутствуют хост и порт" + +msgid "Invalid HTTP proxy URL: missing hostname or IP" +msgstr "Неверный URL HTTP proxy: отсутствует имя хоста или IP-адрес" + +msgid "Invalid HTTP proxy URL: missing port" +msgstr "Неверный URL HTTP proxy: отсутствует порт" + +msgid "Invalid HTTP proxy URL: missing username" +msgstr "Неверный URL HTTP proxy: отсутствует имя пользователя" + +msgid "Invalid HTTP proxy URL: must not contain spaces" +msgstr "Неверный URL HTTP proxy: не должен содержать пробелов" + +msgid "Invalid HTTP proxy URL: must start with http:// or https://" +msgstr "Неверный URL HTTP proxy: должен начинаться с http:// или https://" + +msgid "Invalid HTTP proxy URL: parsing failed" +msgstr "Неверный URL HTTP proxy: ошибка разбора" + +msgid "Invalid HTTP proxy URL: path, query, and fragment are not supported" +msgstr "Неверный URL HTTP proxy: путь, query и fragment не поддерживаются" + +msgid "Invalid HY2 URL: insecure must be 0 or 1" +msgstr "Неверный URL Hysteria2: параметр insecure должен быть 0 или 1" + +msgid "Invalid HY2 URL: invalid host & port" +msgstr "Некорректный HY2 URL: некорректные хост и порт" + +msgid "Invalid HY2 URL: invalid port number" +msgstr "Неверный URL Hysteria2: неверный номер порта" + +msgid "Invalid HY2 URL: missing credentials/server" +msgstr "Неверный URL Hysteria2: отсутствуют учетные данные/сервер" + +msgid "Invalid HY2 URL: missing host" +msgstr "Неверный URL Hysteria2: отсутствует хост" + +msgid "Invalid HY2 URL: missing host & port" +msgstr "Неверный URL Hysteria2: отсутствуют хост и порт" + +msgid "Invalid HY2 URL: missing password" +msgstr "Неверный URL Hysteria2: отсутствует пароль" + +msgid "Invalid HY2 URL: missing port" +msgstr "Неверный URL Hysteria2: отсутствует порт" + +msgid "Invalid HY2 URL: must not contain spaces" +msgstr "Неверный URL Hysteria2: не должен содержать пробелов" + +msgid "Invalid HY2 URL: must start with hysteria2:// or hy2://" +msgstr "Неверный URL Hysteria2: должен начинаться с hysteria2:// или hy2://" + +msgid "Invalid HY2 URL: obfs-password required when obfs is set" +msgstr "Неверный URL Hysteria2: требуется obfs-password, когда установлен obfs" + +msgid "Invalid HY2 URL: parsing failed" +msgstr "Неверный URL Hysteria2: ошибка разбора" + +msgid "Invalid HY2 URL: sni cannot be empty" +msgstr "Неверный URL Hysteria2: sni не может быть пустым" + +msgid "Invalid HY2 URL: unsupported obfs type" +msgstr "Неверный URL Hysteria2: неподдерживаемый тип obfs" + +msgid "Invalid IP address" +msgstr "Неверный IP-адрес" + +msgid "Invalid JSON format" +msgstr "Неверный формат JSON" + +msgid "Invalid NFQWS2 strategy" +msgstr "Некорректная стратегия NFQWS2" + +msgid "Invalid path format. Path must start with \"/\" and contain valid characters" +msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы" + +msgid "Invalid port number. Must be between 1 and 65535" +msgstr "Неверный номер порта. Допустимо от 1 до 65535" + +msgid "Invalid port or range. Use 80 or 1000-2000" +msgstr "Неверный порт или диапазон. Используйте 80 или 1000-2000" + +msgid "Invalid regular expression" +msgstr "Неверное регулярное выражение" + +msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password" +msgstr "Неверный URL Shadowsocks: декодированные данные должны содержать method:password" + +msgid "Invalid Shadowsocks URL: invalid server and port" +msgstr "Некорректный Shadowsocks URL: некорректные сервер и порт" + +msgid "Invalid Shadowsocks URL: missing credentials" +msgstr "Неверный URL Shadowsocks: отсутствуют учетные данные" + +msgid "Invalid Shadowsocks URL: missing method and password separator \":\"" +msgstr "Неверный URL Shadowsocks: отсутствует разделитель метода и пароля \":\"" + +msgid "Invalid Shadowsocks URL: missing port" +msgstr "Неверный URL Shadowsocks: отсутствует порт" + +msgid "Invalid Shadowsocks URL: missing server" +msgstr "Неверный URL Shadowsocks: отсутствует сервер" + +msgid "Invalid Shadowsocks URL: missing server address" +msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера" + +msgid "Invalid Shadowsocks URL: must not contain spaces" +msgstr "Неверный URL Shadowsocks: не должен содержать пробелов" + +msgid "Invalid Shadowsocks URL: must start with ss://" +msgstr "Неверный URL Shadowsocks: должен начинаться с ss://" + +msgid "Invalid Shadowsocks URL: parsing failed" +msgstr "Неверный URL Shadowsocks: ошибка разбора" + +msgid "Invalid SOCKS URL: invalid host and port" +msgstr "Некорректный SOCKS URL: некорректные хост и порт" + +msgid "Invalid SOCKS URL: invalid host format" +msgstr "Неверный URL SOCKS: неверный формат хоста" + +msgid "Invalid SOCKS URL: invalid port number" +msgstr "Неверный URL SOCKS: неверный номер порта" + +msgid "Invalid SOCKS URL: missing host and port" +msgstr "Неверный URL SOCKS: отсутствует хост и порт" + +msgid "Invalid SOCKS URL: missing hostname or IP" +msgstr "Неверный URL SOCKS: отсутствует имя хоста или IP-адрес" + +msgid "Invalid SOCKS URL: missing port" +msgstr "Неверный URL SOCKS: отсутствует порт" + +msgid "Invalid SOCKS URL: missing username" +msgstr "Неверный URL SOCKS: отсутствует имя пользователя" + +msgid "Invalid SOCKS URL: must not contain spaces" +msgstr "Неверный URL SOCKS: не должен содержать пробелов" + +msgid "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://" +msgstr "Неверный URL-адрес SOCKS: должен начинаться с socks4://, socks4a:// или socks5://" + +msgid "Invalid SOCKS URL: parsing failed" +msgstr "Неверный URL SOCKS: парсинг не удался" + +msgid "Invalid Trojan URL: must not contain spaces" +msgstr "Неверный URL Trojan: не должен содержать пробелов" + +msgid "Invalid Trojan URL: must start with trojan://" +msgstr "Неверный URL Trojan: должен начинаться с trojan://" + +msgid "Invalid Trojan URL: parsing failed" +msgstr "Неверный URL Trojan: ошибка разбора" + +msgid "Invalid URL format" +msgstr "Неверный формат URL" + +msgid "Invalid VLESS URL: parsing failed" +msgstr "Неверный URL VLESS: ошибка разбора" + +msgid "Invalid VMess URL: parsing failed" +msgstr "Некорректный VMess URL: ошибка разбора" + +msgid "IPs" +msgstr "IP-адреса" + +msgid "IPv4 only" +msgstr "Только IPv4" + +msgid "IPv6 only" +msgstr "Только IPv6" + +msgid "Issues detected" +msgstr "Обнаружены проблемы" + +msgid "JSON outbound" +msgstr "JSON outbound" + +msgid "JSON outbound cannot be empty" +msgstr "JSON outbound не может быть пустым" + +msgid "JSON outbound must be a JSON object" +msgstr "JSON outbound должен быть JSON-объектом" + +msgid "JSON outbound must contain a non-empty tag field" +msgstr "JSON outbound должен содержать непустое поле tag" + +msgid "JSON outbound must contain a non-empty type field" +msgstr "JSON outbound должен содержать непустое поле type" + +msgid "JSON outbound settings" +msgstr "Настройки JSON outbound" + +msgid "Keep Priority before latency-sorted servers" +msgstr "Показывать Priority перед серверами, отсортированными по задержке" + +msgid "Keep URLTest before latency-sorted servers" +msgstr "Держать URLTest перед серверами, отсортированными по задержке" + +msgid "Key path" +msgstr "Путь к ключу" + +msgid "Keyword must not contain spaces or commas" +msgstr "Ключевое слово не должно содержать пробелы или запятые" + +msgid "Latency test failed" +msgstr "Проверка задержки не удалась" + +msgid "Latency test URL" +msgstr "URL для проверки задержки" + +msgid "Latest version is installed" +msgstr "Установлена последняя версия" + +msgid "Latest version:" +msgstr "Последняя версия:" + +msgid "Level" +msgstr "Уровень" + +msgid "Level name" +msgstr "Название уровня" + +msgid "List must be an HTTP(S) URL or a local .lst path" +msgstr "Список должен быть HTTP(S) URL или локальным путём .lst" + +msgid "List Update Frequency" +msgstr "Частота обновления списков" + +msgid "Listen address" +msgstr "Адрес прослушивания" + +msgid "Listen port" +msgstr "Порт прослушивания" + +msgid "Listening port" +msgstr "Слушающий порт" + +msgid "Loading client link..." +msgstr "Загрузка клиентской ссылки..." + +msgid "Loading connections" +msgstr "Загрузка подключений" + +msgid "Loading..." +msgstr "Загрузка..." + +msgid "Log Level" +msgstr "Уровень логов" + +msgid "Main DNS" +msgstr "Основной DNS" + +msgid "Main DNS server. If multiple servers are selected, a timeout switches to a backup." +msgstr "Основной DNS-сервер. Если выбрать несколько, то при таймауте произойдёт переключение на резервный." + +msgid "Match destination IPs or subnets" +msgstr "Совпадение по IP-адресам или подсетям назначения" + +msgid "Match destination ports. Use a single port or a range" +msgstr "Совпадение по портам назначения. Используйте одиночный порт или диапазон" + +msgid "Maximum time to wait for example.com to resolve during a DNS health check." +msgstr "Максимальное время ожидания разрешения example.com при проверке доступности DNS." + +msgid "Memory Usage" +msgstr "Использование памяти" + +msgid "Method" +msgstr "Метод" + +msgid "Minimum latency difference in milliseconds that triggers switching to a faster server." +msgstr "Минимальная разница задержки в миллисекундах, при которой произойдет переключение на более быстрый сервер." + +msgid "Mixed Proxy Password" +msgstr "Пароль смешанного прокси" + +msgid "Mixed Proxy Port" +msgstr "Порт смешанного прокси" + +msgid "Mixed Proxy Username" +msgstr "Логин смешанного прокси" + +msgid "Monitored Interfaces" +msgstr "Наблюдаемые интерфейсы" + +msgid "Monitoring" +msgstr "Мониторинг" + +msgid "Must be a number in the range of 0 - 10000" +msgstr "Должно быть числом в диапазоне 0 - 10000" + +msgid "Name displayed on the dashboard" +msgstr "Имя, которое отображается в дашборде" + +msgid "Name shown in the priority level list" +msgstr "Имя, которое отображается в списке уровней приоритета" + +msgid "Network Interface" +msgstr "Сетевой интерфейс" + +msgid "Network interface settings" +msgstr "Настройки сетевого интерфейса" + +msgid "New version %s is available for %s" +msgstr "Доступна новая версия %s для %s" + +msgid "NFQUEUE range is available" +msgstr "Диапазон NFQUEUE свободен" + +msgid "NFQUEUE range overlaps with another rule" +msgstr "Диапазон NFQUEUE пересекается с другим правилом" + +msgid "NFQWS Strategy" +msgstr "Стратегия NFQWS" + +msgid "NFQWS strategy cannot be empty" +msgstr "Стратегия NFQWS не может быть пустой" + +msgid "NFQWS2 Strategy" +msgstr "Стратегия NFQWS2" + +msgid "NFQWS2 strategy cannot be empty" +msgstr "Стратегия NFQWS2 не может быть пустой" + +msgid "No" +msgstr "Нет" + +msgid "No active connections" +msgstr "Нет активных подключений" + +msgid "No closed connections" +msgstr "Нет закрытых подключений" + +msgid "No conflict detected" +msgstr "Конфликт не обнаружен" + +msgid "No enabled server inbounds configured" +msgstr "Включённые серверные inbounds не настроены" + +msgid "No other marking rules found" +msgstr "Другие правила маркировки не найдены" + +msgid "No public firewall port required" +msgstr "Публичный порт в firewall не требуется" + +msgid "No rules use ByeDPI" +msgstr "Нет правил, использующих ByeDPI" + +msgid "No rules use Zapret" +msgstr "Нет правил, использующих Zapret" + +msgid "No rules use Zapret2" +msgstr "Нет правил, использующих Zapret2" + +msgid "Node list is empty" +msgstr "Список узлов пуст" + +msgid "Nodes" +msgstr "Узлы" + +msgid "None" +msgstr "Нет" + +msgid "Not configured" +msgstr "Не настроено" + +msgid "Not detected" +msgstr "Не обнаружено" + +msgid "Not implement yet" +msgstr "Ещё не реализовано" + +msgid "Not installed" +msgstr "Не установлено" + +msgid "Not public" +msgstr "Не публичный" + +msgid "Not required for this listen address" +msgstr "Не требуется для этого адреса прослушивания" + +msgid "Not responding" +msgstr "Не отвечает" + +msgid "Not running" +msgstr "Не запущено" + +msgid "Obfuscation" +msgstr "Обфускация" + +msgid "Obfuscation password" +msgstr "Пароль обфускации" + +msgid "Only from the specified countries." +msgstr "Только из указанных стран." + +msgid "Only selected" +msgstr "Только выбранные" + +msgid "Only selected except exclusions" +msgstr "Только выбранные, кроме исключений" + +msgid "Only servers whose names match the expression." +msgstr "Только серверы, имена которых соответствуют выражению." + +msgid "Only servers with one of the selected protocols." +msgstr "Только серверы с одним из выбранных протоколов." + +msgid "Only servers with one of the selected security types." +msgstr "Только серверы с одним из выбранных типов защиты." + +msgid "Only servers with one of the selected transports." +msgstr "Только серверы с одним из выбранных транспортов." + +msgid "Only the specified servers." +msgstr "Только указанные серверы." + +msgid "Open" +msgstr "" + +msgid "Open priority settings and enter a display name" +msgstr "Откройте настройки приоритета и укажите отображаемое имя" + +msgid "Open Project Page" +msgstr "Открыть страницу проекта" + +msgid "Operation timed out" +msgstr "Время ожидания истекло" + +msgid "Option \"%s\" contains an invalid input value." +msgstr "Параметр \"%s\" содержит некорректное значение." + +msgid "Optional values must be attached with '=' here; a separate token would be ignored by nfqws." +msgstr "Необязательное значение здесь нужно передавать только через '='; отдельный токен nfqws проигнорирует." + +msgid "Optional values must be attached with '=' here; a separate token would be ignored by nfqws2." +msgstr "Необязательные значения здесь должны задаваться через '='; отдельный токен будет проигнорирован nfqws2." + +msgid "outbounds must be a non-empty array of strings" +msgstr "outbounds должен быть непустым массивом строк" + +msgid "Output Network Interface" +msgstr "Выходной сетевой интерфейс" + +msgid "Padding" +msgstr "Padding" + +msgid "Password" +msgstr "Пароль" + +msgid "Password cannot be empty" +msgstr "Пароль не может быть пустым" + +msgid "Path cannot be empty" +msgstr "Путь не может быть пустым" + +msgid "Path must be absolute (start with /)" +msgstr "Путь должен быть абсолютным (начинаться с /)" + +msgid "Path must contain at least one directory (like /tmp/cache.db)" +msgstr "Путь должен содержать хотя бы одну директорию (например /tmp/cache.db)" + +msgid "Path must end with cache.db" +msgstr "Путь должен заканчиваться на cache.db" + +msgid "Pause updates" +msgstr "Приостановить обновление" + +msgid "Pending" +msgstr "Ожидает запуска" + +msgid "Periodically check the current level and switch to a faster server even when the current one works." +msgstr "Периодически проверять текущий уровень и переключаться на более быстрый сервер, даже если текущий работает." + +msgid "Pin on dashboard" +msgstr "Закрепить в дашборде" + +msgid "Port cannot be empty" +msgstr "Порт не может быть пустым" + +msgid "Port conflict" +msgstr "Конфликт порта" + +msgid "Port for the local mixed proxy of this section" +msgstr "Порт локального смешанного прокси для этой секции" + +msgid "Port must be between 1 and 65535" +msgstr "Порт должен быть от 1 до 65535" + +msgid "Port range start must be less than or equal to end" +msgstr "Начало диапазона портов должно быть меньше или равно концу" + +msgid "Ports" +msgstr "Порты" + +msgid "Prefer IPv4" +msgstr "Предпочитать IPv4" + +msgid "Prefer IPv6" +msgstr "Предпочитать IPv6" + +msgid "Preferred IP" +msgstr "Предпочитаемый IP" + +msgid "Prefix text" +msgstr "Текст префикса" + +msgid "Priority" +msgstr "Приоритет" + +msgid "Priority details" +msgstr "Детали Priority" + +msgid "Priority details are unavailable" +msgstr "Детали Priority недоступны" + +msgid "Priority level settings" +msgstr "Настройки уровня приоритета" + +msgid "Priority levels" +msgstr "Уровни приоритета" + +msgid "Priority settings" +msgstr "Настройки приоритета" + +msgid "Protocol" +msgstr "Протокол" + +msgid "Proxy link is unavailable" +msgstr "Ссылка прокси недоступна" + +msgid "Public host" +msgstr "Публичный хост" + +msgid "QR code is too large" +msgstr "QR-код слишком большой" + +msgid "Reality fake site" +msgstr "Fake-сайт Reality" + +msgid "Reality fake site port" +msgstr "Порт fake-сайта Reality" + +msgid "Reality max time difference" +msgstr "Максимальная разница времени Reality" + +msgid "Reality private key" +msgstr "Приватный ключ Reality" + +msgid "Reality public key" +msgstr "Публичный ключ Reality" + +msgid "Reality short ID" +msgstr "Reality short ID" + +msgid "Reality short ID must contain 1-8 hex digits" +msgstr "Reality short ID должен содержать 1-8 hex-символов" + +msgid "Refill" +msgstr "Сброс" + +msgid "Regional options cannot be used together" +msgstr "Нельзя использовать несколько региональных опций одновременно" + +msgid "Regular expression must not contain spaces or commas" +msgstr "Регулярное выражение не должно содержать пробелы или запятые" + +msgid "Remove" +msgstr "Удалить" + +msgid "Remove servers from the specified countries from this level." +msgstr "Исключить из уровня серверы из указанных стран." + +msgid "Remove servers whose names match the expression from this level." +msgstr "Исключить из уровня серверы, имена которых соответствуют выражению." + +msgid "Remove the specified servers from this level." +msgstr "Исключить из уровня указанные серверы." + +msgid "Require a username and password for the local mixed proxy" +msgstr "Требовать логин и пароль для локального смешанного прокси" + +msgid "Resolve domain names before routing so sing-box can use real destination IPs." +msgstr "Разрешать доменные имена перед маршрутизацией, чтобы sing-box использовал реальные IP-адреса назначения." + +msgid "Resolve real IP for routing" +msgstr "Разрешать реальный IP для маршрутизации" + +msgid "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE." +msgstr "Выбор ресурсов по hostname внутри nfqws здесь не поддерживается; ресурсы выбирает sing-box до NFQUEUE." + +msgid "Resource selection by hostname inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE." +msgstr "Выбор ресурсов по hostname внутри nfqws2 здесь не поддерживается; sing-box выбирает ресурсы до NFQUEUE." + +msgid "Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE." +msgstr "Выбор ресурсов по IP или CIDR внутри nfqws здесь не поддерживается; ресурсы выбирает sing-box до NFQUEUE." + +msgid "Resource selection by IP or CIDR inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE." +msgstr "Выбор ресурсов по IP или CIDR внутри nfqws2 здесь не поддерживается; sing-box выбирает ресурсы до NFQUEUE." + +msgid "Restart Tachyon" +msgstr "Перезапустить Tachyon" + +msgid "Resume updates" +msgstr "Продолжить обновление" + +msgid "Route" +msgstr "Маршрут" + +msgid "Route main DNS requests through the selected section." +msgstr "Маршрутизировать основные DNS-запросы через выбранную секцию." + +msgid "Route requests to this DNS server through another section." +msgstr "Направлять запросы к этому DNS-серверу через другую секцию." + +msgid "Routing mode" +msgstr "Режим маршрутизации" + +msgid "Routing rules" +msgstr "Правила маршрутизации" + +msgid "Routing section" +msgstr "Секция маршрутизации" + +msgid "Rule set must be an HTTP(S) URL or a local .srs / .json path" +msgstr "Набор правил должен быть HTTP(S) URL или локальным путём .srs / .json" + +msgid "Rule set settings" +msgstr "Настройки набора правил" + +msgid "Rule sets" +msgstr "Наборы правил" + +msgid "Rules" +msgstr "Правила" + +msgid "Rules mangle counters" +msgstr "Счётчики правил mangle" + +msgid "Rules mangle exist" +msgstr "Правила mangle существуют" + +msgid "Rules mangle output counters" +msgstr "Счётчики правил mangle output" + +msgid "Rules mangle output exist" +msgstr "Правила mangle output существуют" + +msgid "Rules proxy counters" +msgstr "Счётчики правил proxy" + +msgid "Rules proxy exist" +msgstr "Правила прокси существуют" + +msgid "Run Diagnostic" +msgstr "Запустить диагностику" + +msgid "Russia inside restrictions" +msgstr "Ограничения Russia inside" + +msgid "Save" +msgstr "Сохранить" + +msgid "Search" +msgstr "Поиск" + +msgid "Secret" +msgstr "Секрет" + +msgid "Secret key for authenticating remote access to YACD when WAN access is enabled." +msgstr "Секретный ключ для аутентификации удаленного доступа к YACD при включенном доступе через WAN." + +msgid "Section" +msgstr "Секция" + +msgid "Section name" +msgstr "Имя секции" + +msgid "Sections" +msgstr "Секции" + +msgid "Security" +msgstr "Защита" + +msgid "Select a common client profile or enter a custom User-Agent" +msgstr "Выберите распространённый профиль клиента или введите свой User-Agent" + +msgid "Select a network interface" +msgstr "Выберите сетевой интерфейс" + +msgid "Select a predefined domain list" +msgstr "Выберите готовый список доменов" + +msgid "Select a section" +msgstr "Выберите секцию" + +msgid "Select a section for downloading this subscription" +msgstr "Выберите секцию для скачивания этой подписки" + +msgid "Select a transit section" +msgstr "Выберите транзитную секцию" + +msgid "Select an existing network interface" +msgstr "Выберите существующий сетевой интерфейс" + +msgid "Select an existing URLTest or Priority group" +msgstr "Выберите существующую URLTest или Приоритет группу" + +msgid "Select an intermediate section" +msgstr "Выберите промежуточную секцию" + +msgid "Select DNS protocol to use" +msgstr "Выберите протокол DNS" + +msgid "Select network interface for VPN connection" +msgstr "Выберите сетевой интерфейс для VPN-подключения" + +msgid "Select or enter a User-Agent" +msgstr "Выберите или введите User-Agent" + +msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing" +msgstr "Выберите или введите путь к файлу кеша sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете" + +msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing" +msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете" + +msgid "Select the fastest node" +msgstr "Выбирать самый быстрый узел" + +msgid "Select the log level for sing-box" +msgstr "Выберите уровень логов для sing-box" + +msgid "Select the network interface from which the traffic will originate" +msgstr "Выберите сетевой интерфейс, с которого будет исходить трафик" + +msgid "Select the network interface to which the traffic will originate" +msgstr "Выберите сетевой интерфейс, на который будет поступать трафик." + +msgid "Select the WAN interfaces to be monitored" +msgstr "Выберите WAN интерфейсы для мониторинга" + +msgid "Selected" +msgstr "Выбран" + +msgid "Selected section" +msgstr "Выбранная секция" + +msgid "Selector and URLTest outbounds must contain a non-empty outbounds array" +msgstr "Selector и URLTest outbound должны содержать непустой массив outbounds" + +msgid "Server" +msgstr "Сервер" + +msgid "Server filtering" +msgstr "Фильтрация серверов" + +msgid "Server group for automatic lowest-latency selection" +msgstr "Группа серверов для автоматического выбора по наименьшей задержке" + +msgid "Server group for priority failover" +msgstr "Группа автоматически выбирает рабочий узел на основе установленного приоритета." + +msgid "Server information" +msgstr "Информация о сервере" + +msgid "Server name" +msgstr "Имя сервера" + +msgid "Server name / SNI" +msgstr "Имя сервера / SNI" + +msgid "Server outbound must contain a non-empty server field" +msgstr "Серверный outbound должен содержать непустое поле server" + +msgid "Server outbound must contain a numeric server_port from 1 to 65535" +msgstr "Серверный outbound должен содержать числовой server_port от 1 до 65535" + +msgid "server_port must be a number from 1 to 65535" +msgstr "server_port должен быть числом от 1 до 65535" + +msgid "Servers" +msgstr "Серверы" + +msgid "Servers on dashboard" +msgstr "Серверы в дашборде" + +msgid "Service action failed" +msgstr "Действие сервиса не выполнено" + +msgid "Service list" +msgstr "Список сервисов" + +msgid "Services info" +msgstr "Информация о сервисах" + +msgid "Settings" +msgstr "Настройки" + +msgid "Show metadata on dashboard" +msgstr "Отображать метаданные в дашборде" + +msgid "Show only selected servers." +msgstr "Показывать только выбранные серверы." + +msgid "Show only servers with one of the selected protocols." +msgstr "Показывать только серверы с одним из выбранных протоколов." + +msgid "Show only servers with one of the selected security types." +msgstr "Показывать только серверы с одним из выбранных типов защиты." + +msgid "Show only servers with one of the selected transports." +msgstr "Показывать только серверы с одним из выбранных транспортов." + +msgid "Show servers from selected groups without repeating their filtering criteria." +msgstr "Показывать серверы из выбранных групп без повторения их параметров фильтрации." + +msgid "Show servers only from the specified countries." +msgstr "Показывать серверы только из указанных стран." + +msgid "Show servers whose names match the expression." +msgstr "Показывать серверы, имена которых совпадают с выражением." + +msgid "Show sing-box config" +msgstr "Показать sing-box конфигурацию" + +msgid "Show subscription metadata for this source on the dashboard" +msgstr "Отображать метаданные этой подписки в дашборде" + +msgid "Sing-box autostart disabled" +msgstr "Автостарт sing-box отключен" + +msgid "Sing-box FakeIP DNS does not work" +msgstr "FakeIP DNS sing-box не работает" + +msgid "Sing-box FakeIP DNS works" +msgstr "FakeIP DNS sing-box работает" + +msgid "Sing-box installed" +msgstr "Sing-box установлен" + +msgid "Sing-box listening ports" +msgstr "Sing-box слушает порты" + +msgid "Sing-box process running" +msgstr "Процесс sing-box запущен" + +msgid "Sing-box service exist" +msgstr "Сервис sing-box существует" + +msgid "Sing-box version is compatible (newer than 1.12.4)" +msgstr "Версия Sing-box совместима (новее 1.12.4)" + +msgid "Sort by latency" +msgstr "Сортировать по задержке" + +msgid "Sorts servers in this section by lowest latency in the dashboard." +msgstr "Сортирует серверы этой секции по наименьшей задержке в дашборде" + +msgid "Source" +msgstr "Источник" + +msgid "Source Network Interface" +msgstr "Сетевой интерфейс источника" + +msgid "Specify a file path" +msgstr "Укажите путь к файлу" + +msgid "Specify different files" +msgstr "Укажите разные файлы" + +msgid "Standalone ByeDPI autostart is enabled" +msgstr "Автозапуск отдельной службы ByeDPI включен" + +msgid "Standalone ByeDPI is active together with Tachyon ByeDPI rules" +msgstr "Отдельная служба ByeDPI активна вместе с правилами ByeDPI в Tachyon" + +msgid "Standalone ByeDPI service is active" +msgstr "Отдельная служба ByeDPI активна" + +msgid "Standalone ByeDPI service is inactive" +msgstr "Отдельная служба ByeDPI неактивна" + +msgid "Standalone Zapret is active together with Tachyon Zapret rules" +msgstr "Отдельная служба Zapret активна вместе с правилами Zapret в Tachyon" + +msgid "Standalone Zapret service is active" +msgstr "Отдельная служба Zapret активна" + +msgid "Standalone Zapret service is inactive" +msgstr "Отдельная служба Zapret неактивна" + +msgid "Standalone Zapret2 autostart is enabled" +msgstr "Автозапуск отдельной службы Zapret2 включён" + +msgid "Standalone Zapret2 is active together with Tachyon Zapret2 rules" +msgstr "Отдельная служба Zapret2 активна вместе с правилами Zapret2 в Tachyon" + +msgid "Standalone Zapret2 service is active" +msgstr "Отдельная служба Zapret2 активна" + +msgid "Standalone Zapret2 service is inactive" +msgstr "Отдельная служба Zapret2 неактивна" + +msgid "Start Tachyon" +msgstr "Запустить Tachyon" + +msgid "Stop checking when URLTest group is not used. Use sing-box duration format like 1d, 12h or 30m." +msgstr "Останавливать проверку если URLTest группа не используется. Используйте формат длительности sing-box, например 1d, 12h или 30m." + +msgid "Stop Tachyon" +msgstr "Остановить Tachyon" + +msgid "Subnets from the list will be extracted and added to nftables" +msgstr "IP-адреса и подсети из списка будут извлечены и добавлены в nftables" + +msgid "Subscription auto update" +msgstr "Автообновление подписи" + +msgid "Subscription info:" +msgstr "Информация о подписке:" + +msgid "Subscription update failed" +msgstr "Обновление подписки завершилось ошибкой" + +msgid "Subscription update interval" +msgstr "Интервал обновления подписки" + +msgid "Subscription URL" +msgstr "URL подписки" + +msgid "Subscription URL cannot be empty" +msgstr "URL подписки не может быть пустым" + +msgid "Subscription URL settings" +msgstr "Настройки URL подписки" + +msgid "Subscriptions updated" +msgstr "Подписки обновлены" + +msgid "System info" +msgstr "Системная информация" + +msgid "System information" +msgstr "Системная информация" + +msgid "Table exist" +msgstr "Таблица существует" + +msgid "Tailscale auth key" +msgstr "Ключ авторизации Tailscale" + +msgid "Tailscale control URL" +msgstr "URL control plane Tailscale" + +msgid "Tailscale endpoint" +msgstr "Tailscale endpoint" + +msgid "Tailscale hostname" +msgstr "Hostname Tailscale" + +msgid "Test latency" +msgstr "Тестирование задержки" + +msgid "Test only selected servers." +msgstr "Тестировать только выбранные серверы." + +msgid "Test only servers with one of the selected protocols." +msgstr "Тестировать только серверы с одним из выбранных протоколов." + +msgid "Test only servers with one of the selected security types." +msgstr "Тестировать только серверы с одним из выбранных типов защиты." + +msgid "Test only servers with one of the selected transports." +msgstr "Тестировать только серверы с одним из выбранных транспортов." + +msgid "Test servers only from the specified countries." +msgstr "Тестировать серверы только из указанных стран." + +msgid "Test servers whose names match the expression." +msgstr "Тестировать серверы, имена которых совпадают с выражением." + +msgid "Testing URL" +msgstr "URL проверки" + +msgid "The desync fwmark is managed by Tachyon for loop prevention and must not be overridden here." +msgstr "Desync fwmark управляется Tachyon для защиты от циклов и не должен переопределяться здесь." + +msgid "The NFQUEUE number is assigned by Tachyon for each rule and must not be overridden here." +msgstr "Номер NFQUEUE назначается Tachyon для каждого правила и не должен переопределяться здесь." + +msgid "The rule applies to the domain and all its subdomains. Use full:, keyword:, or regex: prefixes for exact match, keyword match, or regular expression." +msgstr "Правило применяется к домену и всем его поддоменам. Для полного совпадения, совпадению по слову или регулярному выражению используйте префиксы full:, keyword: или regex:." + +msgid "There are rules using ByeDPI" +msgstr "Есть правила, использующие ByeDPI" + +msgid "There are rules using Zapret" +msgstr "Есть правила использующие Zapret" + +msgid "There are rules using Zapret2" +msgstr "Есть правила, использующие Zapret2" + +msgid "This field is required" +msgstr "Заполните это поле" + +msgid "This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here." +msgstr "Это поле должно запускать рабочую стратегию ciadpi; параметры help/version сразу завершают процесс и здесь запрещены." + +msgid "This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here." +msgstr "Это поле должно запускать рабочую стратегию nfqws; --dry-run сразу завершает процесс и здесь запрещён." + +msgid "This field must start a working nfqws strategy; --version exits immediately and is not allowed here." +msgstr "Это поле должно запускать рабочую стратегию nfqws; --version сразу завершает процесс и здесь запрещён." + +msgid "This field must start a working nfqws2 strategy; --dry-run exits immediately and is not allowed here." +msgstr "Это поле должно запускать рабочую стратегию nfqws2; --dry-run сразу завершает работу и здесь запрещён." + +msgid "This field must start a working nfqws2 strategy; --version exits immediately and is not allowed here." +msgstr "Это поле должно запускать рабочую стратегию nfqws2; --version сразу завершает работу и здесь запрещён." + +msgid "This flag does not accept a value." +msgstr "Этот флаг не принимает значение." + +msgid "This option requires a value." +msgstr "Этой опции требуется значение." + +msgid "Time" +msgstr "Время" + +msgid "Time in seconds for DNS record caching (default: 60)" +msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)" + +msgid "Time skew tolerance" +msgstr "Допуск расхождения времени" + +msgid "tiny" +msgstr "tiny" + +msgid "Tolerance" +msgstr "Допуск" + +msgid "Top level has the highest priority; lower levels are used as fallback" +msgstr "Верхний уровень имеет самый высокий приоритет; нижние уровни используются как резерв" + +msgid "Traffic" +msgstr "Трафик" + +msgid "Traffic for this level goes directly." +msgstr "Трафик этого уровня пойдёт напрямую." + +msgid "Traffic Total" +msgstr "Всего трафика" + +msgid "Transparent proxy mode is incompatible with action=byedpi because Tachyon connects to ciadpi through SOCKS." +msgstr "Режим прозрачного прокси несовместим с action=byedpi, потому что Tachyon подключается к ciadpi через SOCKS." + +msgid "Transport" +msgstr "Транспорт" + +msgid "Transport host" +msgstr "Хост транспорта" + +msgid "Transport path" +msgstr "Путь транспорта" + +msgid "Transport path must start with /" +msgstr "Путь транспорта должен начинаться с /" + +msgid "Troubleshooting" +msgstr "Устранение неполадок" + +msgid "Try compatible User-Agent profiles automatically when downloading this subscription" +msgstr "Автоматически пробовать совместимые профили User-Agent при загрузке этой подписки" + +msgid "TTL must be a positive number" +msgstr "TTL должно быть положительным числом" + +msgid "TTL value cannot be empty" +msgstr "Значение TTL не может быть пустым" + +msgid "Type" +msgstr "Тип" + +msgid "UDP (Unprotected DNS)" +msgstr "UDP (Незащищённый DNS)" + +msgid "Unable to validate the ByeDPI strategy through the backend parser." +msgstr "Не удалось проверить стратегию ByeDPI через backend-парсер." + +msgid "Unable to validate the NFQWS strategy through the backend parser." +msgstr "Не удалось проверить стратегию NFQWS через backend-парсер." + +msgid "Unable to validate the NFQWS2 strategy through the backend parser." +msgstr "Не удалось проверить стратегию NFQWS2 через backend-парсер." + +msgid "Unavailability timeout" +msgstr "Таймаут недоступности" + +msgid "Unexpected ByeDPI strategy argument: %s" +msgstr "Неожиданный аргумент стратегии ByeDPI: %s" + +msgid "Unexpected Tachyon-managed ciadpi runtime is running" +msgstr "Обнаружен лишний ciadpi Tachyon" + +msgid "Unexpected Tachyon-managed nfqws runtime is running" +msgstr "Обнаружен лишний NFQWS Tachyon" + +msgid "Unexpected Tachyon-managed nfqws2 runtime is running" +msgstr "Обнаружен лишний NFQWS2 Tachyon" + +msgid "Unexpected standalone token. Use explicit flags such as --name or --name=value." +msgstr "Неожиданный отдельный токен. Используйте явные флаги вида --имя или --имя=значение." + +msgid "unique UCI identifier" +msgstr "уникальный идентификатор UCI" + +msgid "unknown" +msgstr "неизвестно" + +msgid "Unknown ByeDPI option: %s" +msgstr "Неизвестная опция ByeDPI: %s" + +msgid "Unknown country" +msgstr "Неизвестная страна" + +msgid "Unknown error" +msgstr "Неизвестная ошибка" + +msgid "Unknown NFQWS flag." +msgstr "Неизвестный флаг NFQWS." + +msgid "Unknown NFQWS2 flag." +msgstr "Неизвестный флаг NFQWS2." + +msgid "Unspecified IP address is not allowed" +msgstr "Неуказанный IP-адрес не разрешён" + +msgid "Unsupported security mode for this protocol" +msgstr "Этот протокол не поддерживает выбранный режим защиты" + +msgid "Update" +msgstr "Обновить" + +msgid "Update is available" +msgstr "Доступно обновление" + +msgid "Update is available:" +msgstr "Доступна новая версия:" + +msgid "Update subscriptions" +msgstr "Обновить подписки" + +msgid "Update this subscription automatically" +msgstr "Автоматически обновлять эту подписку" + +msgid "Uplink" +msgstr "Исходящий" + +msgid "Uploaded" +msgstr "Отдано" + +msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://" +msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2:// или hy2://" + +msgid "URL must use one of the following protocols:" +msgstr "URL должен использовать один из следующих протоколов:" + +msgid "URL used to check whether a server is alive" +msgstr "URL, который используется для проверки, что сервер жив" + +msgid "URL used to test server latency" +msgstr "URL который будет использоваться для тестирования задержки серверов" + +msgid "URLTest" +msgstr "URLTest" + +msgid "URLTest details" +msgstr "Детали URLTest" + +msgid "URLTest details are unavailable" +msgstr "Детали URLTest недоступны" + +msgid "URLTest settings" +msgstr "Настройки URLTest" + +msgid "Use 32 hex characters" +msgstr "Укажите 32 hex-символа" + +msgid "Use a domain name or IP address" +msgstr "Используйте доменное имя или IP-адрес" + +msgid "Use a duration like 30s, 5m, or 1h30m" +msgstr "Укажите длительность в формате 30s, 5m или 1h30m" + +msgid "Use a non-negative integer" +msgstr "Используйте неотрицательное целое число" + +msgid "Use a non-zero secret" +msgstr "Укажите ненулевой секрет" + +msgid "Use an HTTP or HTTPS URL" +msgstr "Укажите HTTP или HTTPS URL" + +msgid "Use an IP address" +msgstr "Используйте IP-адрес" + +msgid "Use an IP listen address" +msgstr "Используйте IP-адрес для прослушивания" + +msgid "Use another section as an intermediate hop to connect to servers in this section. Does not apply to network interfaces or JSON outbounds." +msgstr "Использовать другую секцию как промежуточный узел подключения к серверам этой секции. Не применяется к сетевым интерфейсам и JSON outbound." + +msgid "Use CIDR prefixes like 192.168.1.0/24 or 2001:db8::/32" +msgstr "Используйте CIDR-префиксы, например 192.168.1.0/24 или 2001:db8::/32" + +msgid "Use CIDR prefixes or IP addresses like 192.168.1.0/24, 192.168.1.10, or 2001:db8::1" +msgstr "Используйте CIDR-префиксы или IP-адреса, например 192.168.1.0/24, 192.168.1.10 или 2001:db8::1" + +msgid "Use letters, digits, dots, underscores, or hyphens" +msgstr "Используйте буквы, цифры, точки, подчёркивания или дефисы" + +msgid "Use sing-box duration format like 10s, 1m or 2m30s" +msgstr "Используйте формат длительности sing-box, например 10s, 1m или 2m30s" + +msgid "Use sing-box duration format like 1d, 12h or 30m" +msgstr "Используйте формат длительности sing-box, например 1d, 12h или 30m" + +msgid "Used by" +msgstr "Используется" + +msgid "User-Agent" +msgstr "User-Agent" + +msgid "Username" +msgstr "Имя пользователя" + +msgid "Username cannot be empty" +msgstr "Логин не может быть пустым" + +msgid "Valid" +msgstr "Валидно" + +msgid "Validation errors:" +msgstr "Ошибки валидации:" + +msgid "Value cannot be empty" +msgstr "Значение не может быть пустым" + +msgid "Value must not contain control characters" +msgstr "Значение не должно содержать управляющие символы" + +msgid "Via country.is" +msgstr "Через сервис country.is" + +msgid "View logs" +msgstr "Посмотреть логи" + +msgid "Visible name of this section" +msgstr "Видимое имя этой секции" + +msgid "VLESS flow" +msgstr "VLESS flow" + +msgid "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links" +msgstr "ссылки vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2://" + +msgid "WAN" +msgstr "WAN" + +msgid "WAN public IP" +msgstr "Публичный WAN IP" + +msgid "Warning: %s cannot be used together with %s. Previous selections have been removed." +msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены." + +msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection." +msgstr "Предупреждение: Russia inside может быть использован только с %s. %s уже есть в Russia inside и будет удален из выбранных." + +msgid "What Tachyon should do when this section matches" +msgstr "Что Tachyon должен делать при совпадении этой секции" + +msgid "When switching to another level, test every server and select the fastest instead of the first working one." +msgstr "При переходе на другой уровень тестировать все сервера и выбирать самый быстрый, а не первый работающий." + +msgid "XHTTP mode" +msgstr "Режим XHTTP" + +msgid "YACD Secret Key" +msgstr "Секретный ключ YACD" + +msgid "Yes" +msgstr "Да" + +msgid "You can select Output Network Interface, by default autodetect" +msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически." + +msgid "Zapret hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS strategies." +msgstr "Шаблоны hostlist zapret здесь не поддерживаются, потому что Tachyon не разворачивает их для per-rule стратегий NFQWS." + +msgid "Zapret package is installed" +msgstr "Пакет Zapret установлен" + +msgid "Zapret package is not installed" +msgstr "Пакет Zapret не установлен" + +msgid "Zapret provider binary is available" +msgstr "Бинарник провайдера Zapret доступен" + +msgid "Zapret provider binary is not available" +msgstr "Бинарник провайдера Zapret недоступен" + +msgid "Zapret sing-box outbound is configured" +msgstr "sing-box outbound для Zapret настроен" + +msgid "Zapret sing-box outbound is not configured" +msgstr "sing-box outbound для Zapret не настроен" + +msgid "Zapret2 hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS2 strategies." +msgstr "Шаблоны hostlist Zapret2 здесь не поддерживаются, потому что Tachyon не разворачивает их для per-rule стратегий NFQWS2." + +msgid "Zapret2 package is installed" +msgstr "Пакет Zapret2 установлен" + +msgid "Zapret2 package is not installed" +msgstr "Пакет Zapret2 не установлен" + +msgid "Zapret2 provider binary is available" +msgstr "Бинарник провайдера Zapret2 доступен" + +msgid "Zapret2 provider binary is not available" +msgstr "Бинарник провайдера Zapret2 недоступен" + +msgid "Zapret2 sing-box outbound is configured" +msgstr "sing-box outbound для Zapret2 настроен" + +msgid "Zapret2 sing-box outbound is not configured" +msgstr "sing-box outbound для Zapret2 не настроен" diff --git a/fe-app-tachyon/package.json b/fe-app-tachyon/package.json new file mode 100644 index 00000000..a6235601 --- /dev/null +++ b/fe-app-tachyon/package.json @@ -0,0 +1,40 @@ +{ + "name": "fe-app-tachyon", + "version": "1.0.0", + "license": "MIT", + "type": "module", + "scripts": { + "format": "prettier --write src", + "format:js": "prettier --write ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "build": "tsc --noEmit && tsup src/main.ts", + "dev": "tsup src/main.ts --watch", + "test": "vitest", + "ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build", + "watch:sftp": "node watch-upload.js", + "locales:extract-calls": "node extract-calls.js", + "locales:generate-pot": "node generate-pot.js", + "locales:generate-po:ru": "node generate-po.js ru", + "locales:distribute": "node distribute-locales.js", + "locales:actualize": "yarn locales:extract-calls && yarn locales:generate-pot && yarn locales:generate-po:ru && yarn locales:distribute" + }, + "devDependencies": { + "@babel/parser": "7.28.4", + "@babel/traverse": "7.28.4", + "@babel/types": "7.28.4", + "@eslint/js": "9.36.0", + "chokidar": "4.0.3", + "dotenv": "17.2.3", + "eslint": "9.36.0", + "eslint-config-prettier": "10.1.8", + "fast-glob": "3.3.3", + "glob": "11.0.3", + "prettier": "3.6.2", + "ssh2-sftp-client": "12.0.1", + "tsup": "8.5.0", + "typescript": "5.9.3", + "typescript-eslint": "8.45.0", + "vitest": "3.2.4" + } +} diff --git a/fe-app-tachyon/src/constants.ts b/fe-app-tachyon/src/constants.ts new file mode 100644 index 00000000..a12ac598 --- /dev/null +++ b/fe-app-tachyon/src/constants.ts @@ -0,0 +1,88 @@ +export const TACHYON_UCI_PACKAGE = 'tachyon'; +export const TACHYON_LUCI_APP_VERSION = '__COMPILED_VERSION_VARIABLE__'; +export const TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT = + 'tachyon:action-providers-availability'; +export const FAKEIP_CHECK_DOMAIN = 'fakeip.podkop.fyi'; +export const IP_CHECK_DOMAIN = 'ip.podkop.fyi'; +export const DEFAULT_LATENCY_TEST_URL = 'https://www.gstatic.com/generate_204'; +export const LATENCY_TEST_URL_OPTIONS = [ + DEFAULT_LATENCY_TEST_URL, + 'https://cp.cloudflare.com/generate_204', + 'https://captive.apple.com', + 'https://connectivity-check.ubuntu.com', +]; + +export const REGIONAL_OPTIONS = [ + 'russia_inside', + 'russia_outside', + 'ukraine_inside', +]; + +export const ALLOWED_WITH_RUSSIA_INSIDE = [ + 'russia_inside', + 'meta', + 'twitter', + 'discord', + 'telegram', + 'cloudflare', + 'google_ai', + 'google_play', + 'hetzner', + 'ovh', + 'hodca', + 'roblox', + 'digitalocean', + 'cloudfront', +]; + +export const DOMAIN_LIST_OPTIONS = { + russia_inside: 'Russia inside', + russia_outside: 'Russia outside', + ukraine_inside: 'Ukraine', + geoblock: 'Geo Block', + block: 'Block', + porn: 'Porn', + news: 'News', + anime: 'Anime', + youtube: 'Youtube', + discord: 'Discord', + meta: 'Meta', + twitter: 'Twitter (X)', + hdrezka: 'HDRezka', + tiktok: 'Tik-Tok', + telegram: 'Telegram', + cloudflare: 'Cloudflare', + google_ai: 'Google AI', + google_play: 'Google Play', + hodca: 'H.O.D.C.A', + roblox: 'Roblox', + ads_hagezi_pro: 'Ads (Hagezi Pro)', + supercell: 'Supercell', + github: 'GitHub', + hetzner: 'Hetzner ASN', + ovh: 'OVH ASN', + digitalocean: 'Digital Ocean ASN', + cloudfront: 'CloudFront ASN', +}; + +export const DNS_SERVER_OPTIONS = { + '1.1.1.1': '1.1.1.1 (Cloudflare)', + '8.8.8.8': '8.8.8.8 (Google)', + '9.9.9.9': '9.9.9.9 (Quad9)', + 'dns.adguard-dns.com': 'dns.adguard-dns.com (AdGuard Default)', + 'unfiltered.adguard-dns.com': + 'unfiltered.adguard-dns.com (AdGuard Unfiltered)', + 'family.adguard-dns.com': 'family.adguard-dns.com (AdGuard Family)', +}; +export const BOOTSTRAP_DNS_SERVER_OPTIONS = { + '77.88.8.8': '77.88.8.8 (Yandex DNS)', + '77.88.8.1': '77.88.8.1 (Yandex DNS)', + '1.1.1.1': '1.1.1.1 (Cloudflare DNS)', + '1.0.0.1': '1.0.0.1 (Cloudflare DNS)', + '8.8.8.8': '8.8.8.8 (Google DNS)', + '8.8.4.4': '8.8.4.4 (Google DNS)', + '9.9.9.9': '9.9.9.9 (Quad9 DNS)', + '9.9.9.11': '9.9.9.11 (Quad9 DNS)', +}; + +export const COMMAND_TIMEOUT = 10000; // 10 seconds diff --git a/fe-app-tachyon/src/helpers/copyToClipboard.ts b/fe-app-tachyon/src/helpers/copyToClipboard.ts new file mode 100644 index 00000000..54ccfb6d --- /dev/null +++ b/fe-app-tachyon/src/helpers/copyToClipboard.ts @@ -0,0 +1,16 @@ +import { showToast } from './showToast'; + +export function copyToClipboard(text: string) { + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + showToast(_('Copied'), 'success'); + } catch (_err) { + showToast(_('Failed to copy!'), 'error'); + console.error('copyToClipboard - e', _err); + } + document.body.removeChild(textarea); +} diff --git a/fe-app-tachyon/src/helpers/downloadAsTxt.ts b/fe-app-tachyon/src/helpers/downloadAsTxt.ts new file mode 100644 index 00000000..a0d9222f --- /dev/null +++ b/fe-app-tachyon/src/helpers/downloadAsTxt.ts @@ -0,0 +1,15 @@ +export function downloadAsTxt(text: string, filename: string) { + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + + const safeName = filename.endsWith('.txt') ? filename : `${filename}.txt`; + link.download = safeName; + + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + URL.revokeObjectURL(link.href); +} diff --git a/fe-app-tachyon/src/helpers/executeShellCommand.ts b/fe-app-tachyon/src/helpers/executeShellCommand.ts new file mode 100644 index 00000000..531fc3a8 --- /dev/null +++ b/fe-app-tachyon/src/helpers/executeShellCommand.ts @@ -0,0 +1,33 @@ +import { COMMAND_TIMEOUT } from '../constants'; +import { withTimeout } from './withTimeout'; + +interface ExecuteShellCommandParams { + command: string; + args: string[]; + timeout?: number; +} + +interface ExecuteShellCommandResponse { + stdout: string; + stderr: string; + code?: number; +} + +export async function executeShellCommand({ + command, + args, + timeout = COMMAND_TIMEOUT, +}: ExecuteShellCommandParams): Promise { + try { + return await withTimeout( + fs.exec(command, args), + timeout, + [command, ...args].join(' '), + ); + } catch (err) { + const error = err as Error & { code?: unknown }; + const code = typeof error?.code === 'number' ? error.code : 1; + + return { stdout: '', stderr: error?.message, code }; + } +} diff --git a/fe-app-tachyon/src/helpers/getClashApiUrl.ts b/fe-app-tachyon/src/helpers/getClashApiUrl.ts new file mode 100644 index 00000000..ef421984 --- /dev/null +++ b/fe-app-tachyon/src/helpers/getClashApiUrl.ts @@ -0,0 +1,31 @@ +function getWindowLocation(): Location | undefined { + return typeof window !== 'undefined' ? window.location : undefined; +} + +export function canUseDirectClashApi(): boolean { + const location = getWindowLocation(); + + return ( + typeof location?.hostname === 'string' && + location.hostname !== '' && + location.protocol !== 'https:' + ); +} + +export function getClashWsUrl(): string { + const { hostname } = window.location; + + return `ws://${hostname}:9090`; +} + +export function getClashHttpUrl(): string { + const { hostname } = window.location; + + return `http://${hostname}:9090`; +} + +export function getClashUIUrl(): string { + const { hostname } = window.location; + + return `http://${hostname}:9090/ui`; +} diff --git a/fe-app-tachyon/src/helpers/getProxyUrlName.ts b/fe-app-tachyon/src/helpers/getProxyUrlName.ts new file mode 100644 index 00000000..f903429b --- /dev/null +++ b/fe-app-tachyon/src/helpers/getProxyUrlName.ts @@ -0,0 +1,13 @@ +export function getProxyUrlName(url: string) { + try { + const [_link, hash] = url.split('#'); + + if (!hash) { + return ''; + } + + return decodeURIComponent(hash); + } catch { + return ''; + } +} diff --git a/fe-app-tachyon/src/helpers/index.ts b/fe-app-tachyon/src/helpers/index.ts new file mode 100644 index 00000000..a62dd72b --- /dev/null +++ b/fe-app-tachyon/src/helpers/index.ts @@ -0,0 +1,12 @@ +export * from './parseValueList'; +export * from './injectGlobalStyles'; +export * from './withTimeout'; +export * from './executeShellCommand'; +export * from './getProxyUrlName'; +export * from './onMount'; +export * from './getClashApiUrl'; +export * from './preserveScrollForPage'; +export * from './parseQueryString'; +export * from './svgEl'; +export * from './insertIf'; +export * from './isCopyableProxyLink'; diff --git a/fe-app-tachyon/src/helpers/injectGlobalStyles.ts b/fe-app-tachyon/src/helpers/injectGlobalStyles.ts new file mode 100644 index 00000000..5ce5cb98 --- /dev/null +++ b/fe-app-tachyon/src/helpers/injectGlobalStyles.ts @@ -0,0 +1,18 @@ +import { GlobalStyles } from '../styles'; + +const TACHYON_GLOBAL_STYLES_ID = 'tachyon-global-styles'; + +export function injectGlobalStyles() { + if (document.getElementById(TACHYON_GLOBAL_STYLES_ID)) { + return; + } + + document.head.insertAdjacentHTML( + 'beforeend', + ` + + `, + ); +} diff --git a/fe-app-tachyon/src/helpers/insertIf.ts b/fe-app-tachyon/src/helpers/insertIf.ts new file mode 100644 index 00000000..d814d32d --- /dev/null +++ b/fe-app-tachyon/src/helpers/insertIf.ts @@ -0,0 +1,3 @@ +export function insertIf(condition: boolean, elements: Array) { + return condition ? elements : ([] as Array); +} diff --git a/fe-app-tachyon/src/helpers/isCopyableProxyLink.ts b/fe-app-tachyon/src/helpers/isCopyableProxyLink.ts new file mode 100644 index 00000000..4c08284a --- /dev/null +++ b/fe-app-tachyon/src/helpers/isCopyableProxyLink.ts @@ -0,0 +1,19 @@ +const COPYABLE_PROXY_URI_RE = + /^(vless|vmess|trojan|ss|ssr|hysteria2|hy2|tuic|socks4|socks4a|socks5):\/\//i; +const HTTP_PROXY_URI_RE = + /^https?:\/\/(?:[^/@\s]+(?::[^/@\s]*)?@)?[^:/?#@\s]+:(\d{1,5})$/i; + +export function isCopyableProxyLink(link?: string) { + const value = (link || '').trim(); + if (COPYABLE_PROXY_URI_RE.test(value)) { + return true; + } + + const httpProxy = value.match(HTTP_PROXY_URI_RE); + if (!httpProxy) { + return false; + } + + const port = Number(httpProxy[1]); + return Number.isInteger(port) && port >= 1 && port <= 65535; +} diff --git a/fe-app-tachyon/src/helpers/normalizeCompiledVersion.ts b/fe-app-tachyon/src/helpers/normalizeCompiledVersion.ts new file mode 100644 index 00000000..60274b05 --- /dev/null +++ b/fe-app-tachyon/src/helpers/normalizeCompiledVersion.ts @@ -0,0 +1,7 @@ +export function normalizeCompiledVersion(version: string) { + if (version.includes('COMPILED')) { + return 'dev'; + } + + return version; +} diff --git a/fe-app-tachyon/src/helpers/onMount.ts b/fe-app-tachyon/src/helpers/onMount.ts new file mode 100644 index 00000000..d83b9936 --- /dev/null +++ b/fe-app-tachyon/src/helpers/onMount.ts @@ -0,0 +1,46 @@ +function getTarget(target: string | HTMLElement): HTMLElement | null { + if (typeof target === 'string') { + return document.getElementById(target); + } + + return target; +} + +export async function onMount( + target: string | HTMLElement, +): Promise { + return new Promise((resolve) => { + let observer: MutationObserver | null = null; + + const resolveIfMountedAndVisible = () => { + const mountedTarget = getTarget(target); + + if ( + mountedTarget && + mountedTarget.isConnected && + mountedTarget.offsetParent !== null + ) { + observer?.disconnect(); + resolve(mountedTarget); + return true; + } + + return false; + }; + + if (resolveIfMountedAndVisible()) { + return; + } + + observer = new MutationObserver(() => { + resolveIfMountedAndVisible(); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style', 'hidden'], + }); + }); +} diff --git a/fe-app-tachyon/src/helpers/parseQueryString.ts b/fe-app-tachyon/src/helpers/parseQueryString.ts new file mode 100644 index 00000000..5c4c0a0f --- /dev/null +++ b/fe-app-tachyon/src/helpers/parseQueryString.ts @@ -0,0 +1,22 @@ +export function parseQueryString(query: string): Record { + const clean = query.startsWith('?') ? query.slice(1) : query; + + return clean + .split('&') + .filter(Boolean) + .reduce( + (acc, pair) => { + const [rawKey, rawValue = ''] = pair.split('='); + + if (!rawKey) { + return acc; + } + + const key = decodeURIComponent(rawKey); + const value = decodeURIComponent(rawValue); + + return { ...acc, [key]: value }; + }, + {} as Record, + ); +} diff --git a/fe-app-tachyon/src/helpers/parseValueList.ts b/fe-app-tachyon/src/helpers/parseValueList.ts new file mode 100644 index 00000000..8835f23e --- /dev/null +++ b/fe-app-tachyon/src/helpers/parseValueList.ts @@ -0,0 +1,9 @@ +export function parseValueList(value: string): string[] { + return value + .split(/\n/) // Split to array by newline separator + .map((line) => line.split('//')[0].split('#')[0]) // Remove comments + .join(' ') // Build clean string + .split(/[,\s]+/) // Split to array by comma and space + .map((s) => s.trim()) // Remove extra spaces + .filter(Boolean); // Leave nonempty items +} diff --git a/fe-app-tachyon/src/helpers/preserveScrollForPage.ts b/fe-app-tachyon/src/helpers/preserveScrollForPage.ts new file mode 100644 index 00000000..c56eb8d1 --- /dev/null +++ b/fe-app-tachyon/src/helpers/preserveScrollForPage.ts @@ -0,0 +1,9 @@ +export function preserveScrollForPage(renderFn: () => void) { + const scrollY = window.scrollY; + + renderFn(); + + requestAnimationFrame(() => { + window.scrollTo({ top: scrollY }); + }); +} diff --git a/fe-app-tachyon/src/helpers/prettyBytes.ts b/fe-app-tachyon/src/helpers/prettyBytes.ts new file mode 100644 index 00000000..5572ccb6 --- /dev/null +++ b/fe-app-tachyon/src/helpers/prettyBytes.ts @@ -0,0 +1,12 @@ +// steal from https://github.com/sindresorhus/pretty-bytes/blob/master/index.js +export function prettyBytes(n: number) { + const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + if (n < 1000) { + return n + ' B'; + } + const exponent = Math.min(Math.floor(Math.log10(n) / 3), UNITS.length - 1); + n = Number((n / Math.pow(1000, exponent)).toPrecision(3)); + const unit = UNITS[exponent]; + return n + ' ' + unit; +} diff --git a/fe-app-tachyon/src/helpers/showToast.ts b/fe-app-tachyon/src/helpers/showToast.ts new file mode 100644 index 00000000..92dcdd98 --- /dev/null +++ b/fe-app-tachyon/src/helpers/showToast.ts @@ -0,0 +1,24 @@ +export function showToast( + message: string, + type: 'success' | 'error', + duration: number = 3000, +) { + let container = document.querySelector('.toast-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'toast-container'; + document.body.appendChild(container); + } + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + + container.appendChild(toast); + setTimeout(() => toast.classList.add('visible'), 100); + + setTimeout(() => { + toast.classList.remove('visible'); + setTimeout(() => toast.remove(), 300); + }, duration); +} diff --git a/fe-app-tachyon/src/helpers/svgEl.ts b/fe-app-tachyon/src/helpers/svgEl.ts new file mode 100644 index 00000000..f7ebfd4a --- /dev/null +++ b/fe-app-tachyon/src/helpers/svgEl.ts @@ -0,0 +1,18 @@ +export function svgEl( + tag: K, + attrs: Partial> = {}, + children: (SVGElement | null | undefined)[] = [], +): SVGElementTagNameMap[K] { + const NS = 'http://www.w3.org/2000/svg'; + const el = document.createElementNS(NS, tag); + + for (const [k, v] of Object.entries(attrs)) { + if (v != null) el.setAttribute(k, String(v)); + } + + (Array.isArray(children) ? children : [children]) + .filter(Boolean) + .forEach((ch) => el.appendChild(ch as SVGElement)); + + return el; +} diff --git a/fe-app-tachyon/src/helpers/tests/getClashApiUrl.test.js b/fe-app-tachyon/src/helpers/tests/getClashApiUrl.test.js new file mode 100644 index 00000000..6b2798b6 --- /dev/null +++ b/fe-app-tachyon/src/helpers/tests/getClashApiUrl.test.js @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { canUseDirectClashApi } from '../getClashApiUrl'; + +describe('canUseDirectClashApi', () => { + it('allows direct Clash API access from HTTP LuCI', () => { + vi.stubGlobal('window', { + location: { hostname: 'router.example', protocol: 'http:' }, + }); + + expect(canUseDirectClashApi()).toBe(true); + }); + + it('blocks direct Clash API access from HTTPS LuCI', () => { + vi.stubGlobal('window', { + location: { hostname: 'router.example', protocol: 'https:' }, + }); + + expect(canUseDirectClashApi()).toBe(false); + }); + + it('blocks direct Clash API access outside a browser location', () => { + vi.stubGlobal('window', undefined); + + expect(canUseDirectClashApi()).toBe(false); + }); +}); diff --git a/fe-app-tachyon/src/helpers/tests/isCopyableProxyLink.test.js b/fe-app-tachyon/src/helpers/tests/isCopyableProxyLink.test.js new file mode 100644 index 00000000..02a31c70 --- /dev/null +++ b/fe-app-tachyon/src/helpers/tests/isCopyableProxyLink.test.js @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { isCopyableProxyLink } from '../isCopyableProxyLink'; + +const copyableLinks = [ + 'vless://uuid@example.com:443', + 'vmess://encoded', + 'trojan://password@example.com:443', + 'ss://encoded@example.com:443', + 'ssr://encoded', + 'hysteria2://password@example.com:443', + 'hy2://password@example.com:443', + 'tuic://uuid:password@example.com:443', + 'socks4://example.com:1080', + 'socks4a://example.com:1080', + 'socks5://user:pass@example.com:1080', + 'http://example.com:80', + 'https://user:pass@example.com:443', +]; + +const nonCopyableLinks = [ + '', + 'direct', + 'block', + 'urltest', + 'https://example.com/subscription', + 'https://example.com', + 'http://example.com:99999', + 'wireguard://example.com', +]; + +describe('isCopyableProxyLink', () => { + describe.each(copyableLinks)('copyable proxy URI %s', (link) => { + it('returns true', () => { + expect(isCopyableProxyLink(link)).toBe(true); + }); + }); + + describe.each(nonCopyableLinks)('non-copyable value %s', (link) => { + it('returns false', () => { + expect(isCopyableProxyLink(link)).toBe(false); + }); + }); +}); diff --git a/fe-app-tachyon/src/helpers/tests/serverHysteria2Link.test.js b/fe-app-tachyon/src/helpers/tests/serverHysteria2Link.test.js new file mode 100644 index 00000000..e0c48c4b --- /dev/null +++ b/fe-app-tachyon/src/helpers/tests/serverHysteria2Link.test.js @@ -0,0 +1,119 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from '@babel/parser'; +import { describe, expect, it } from 'vitest'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const serverViewPath = path.resolve( + testDir, + '../../../../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js', +); + +function loadBuildHysteria2Link(values) { + const source = fs.readFileSync(serverViewPath, 'utf8'); + const requiredFunctions = [ + 'encodeQuery', + 'normalizeHost', + 'isIpv4', + 'isIpv6', + 'formatHostForUri', + 'getPublicHost', + 'normalizeSha256', + 'buildHysteria2Link', + ]; + const ast = parse(source, { + allowReturnOutsideFunction: true, + sourceType: 'script', + }); + const functionSources = new Map( + ast.program.body + .filter((node) => node.type === 'FunctionDeclaration') + .map((node) => [node.id.name, source.slice(node.start, node.end)]), + ); + const functions = requiredFunctions + .map((name) => { + const functionSource = functionSources.get(name); + if (!functionSource) { + throw new Error(`Function ${name} not found`); + } + return functionSource; + }) + .join('\n'); + const factory = new Function( + 'uci', + 'UCI_PACKAGE', + 'window', + `${functions}; return buildHysteria2Link;`, + ); + const uci = { + get(_packageName, sectionId, optionName) { + return values[`${sectionId}.${optionName}`] || ''; + }, + }; + + return factory(uci, 'tachyon', { location: { hostname: 'router.lan' } }); +} + +describe('buildHysteria2Link', () => { + const certificatePin = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + it('exports a single certificate pin alias and omits stale obfs password when obfs is disabled', () => { + const buildHysteria2Link = loadBuildHysteria2Link({ + 'server1.public_host': 'vpn.example.com', + 'server1.listen_port': '8443', + 'server1.tls_server_name': 'sni.example.com', + 'server1.hysteria2_obfs_type': '', + 'server1.hysteria2_obfs_password': 'stale-secret', + }); + + const link = buildHysteria2Link( + 'server1', + { password: 'p@ss word', name: 'Server 1' }, + { tlsCertificateSha256: certificatePin }, + ); + const params = new URL(link).searchParams; + + expect(params.get('pcs')).toBe(certificatePin); + expect(params.has('pinSHA256')).toBe(false); + expect(params.has('obfs')).toBe(false); + expect(params.has('obfs-password')).toBe(false); + }); + + it('exports salamander obfuscation together with its password', () => { + const buildHysteria2Link = loadBuildHysteria2Link({ + 'server1.public_host': 'vpn.example.com', + 'server1.listen_port': '8443', + 'server1.hysteria2_obfs_type': 'salamander', + 'server1.hysteria2_obfs_password': 'secret', + }); + + const link = buildHysteria2Link( + 'server1', + { password: 'password', name: 'Server 1' }, + { tlsCertificateSha256: certificatePin }, + ); + const params = new URL(link).searchParams; + + expect(params.get('pcs')).toBe(certificatePin); + expect(params.has('pinSHA256')).toBe(false); + expect(params.get('obfs')).toBe('salamander'); + expect(params.get('obfs-password')).toBe('secret'); + }); + + it('brackets IPv6 public host in exported URI', () => { + const buildHysteria2Link = loadBuildHysteria2Link({ + 'server1.public_host': '2001:db8::1', + 'server1.listen_port': '8443', + }); + + const link = buildHysteria2Link( + 'server1', + { password: 'password', name: 'Server 1' }, + { tlsCertificateSha256: certificatePin }, + ); + + expect(link).toContain('@[2001:db8::1]:8443'); + }); +}); diff --git a/fe-app-tachyon/src/helpers/withTimeout.ts b/fe-app-tachyon/src/helpers/withTimeout.ts new file mode 100644 index 00000000..b9dbb600 --- /dev/null +++ b/fe-app-tachyon/src/helpers/withTimeout.ts @@ -0,0 +1,23 @@ +import { logger } from '../tachyon/services/logger.service'; + +export async function withTimeout( + promise: Promise, + timeoutMs: number, + operationName: string, + timeoutMessage = _('Operation timed out'), +): Promise { + let timeoutId; + const start = performance.now(); + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutId); + const elapsed = performance.now() - start; + logger.info('[SHELL]', `[${operationName}] took ${elapsed.toFixed(2)} ms`); + } +} diff --git a/fe-app-tachyon/src/icons/index.ts b/fe-app-tachyon/src/icons/index.ts new file mode 100644 index 00000000..2f4f2bf9 --- /dev/null +++ b/fe-app-tachyon/src/icons/index.ts @@ -0,0 +1,22 @@ +export * from './renderLoaderCircleIcon24'; +export * from './renderCircleAlertIcon24'; +export * from './renderCircleCheckIcon24'; +export * from './renderCircleSlashIcon24'; +export * from './renderCircleXIcon24'; +export * from './renderCheckIcon24'; +export * from './renderXIcon24'; +export * from './renderTriangleAlertIcon24'; +export * from './renderPauseIcon24'; +export * from './renderPlayIcon24'; +export * from './renderRotateCcwIcon24'; +export * from './renderCircleStopIcon24'; +export * from './renderCirclePlayIcon24'; +export * from './renderCircleCheckBigIcon24'; +export * from './renderSquareChartGanttIcon24'; +export * from './renderCogIcon24'; +export * from './renderSearchIcon24'; +export * from './renderBookOpenTextIcon24'; +export * from './renderCopyIcon24'; +export * from './renderLinkIcon24'; +export * from './renderDownloadIcon24'; +export * from './renderInfoIcon24'; diff --git a/fe-app-tachyon/src/icons/renderBookOpenTextIcon24.ts b/fe-app-tachyon/src/icons/renderBookOpenTextIcon24.ts new file mode 100644 index 00000000..552f8ffe --- /dev/null +++ b/fe-app-tachyon/src/icons/renderBookOpenTextIcon24.ts @@ -0,0 +1,28 @@ +import { svgEl } from '../helpers'; + +export function renderBookOpenTextIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-book-open-text-icon lucide-book-open-text', + }, + [ + svgEl('path', { d: 'M12 7v14' }), + svgEl('path', { d: 'M16 12h2' }), + svgEl('path', { d: 'M16 8h2' }), + svgEl('path', { + d: 'M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z', + }), + svgEl('path', { d: 'M6 12h2' }), + svgEl('path', { d: 'M6 8h2' }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderCheckIcon24.ts b/fe-app-tachyon/src/icons/renderCheckIcon24.ts new file mode 100644 index 00000000..b1c9ab2d --- /dev/null +++ b/fe-app-tachyon/src/icons/renderCheckIcon24.ts @@ -0,0 +1,23 @@ +import { svgEl } from '../helpers'; + +export function renderCheckIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-check-icon lucide-check', + }, + [ + svgEl('path', { + d: 'M20 6 9 17l-5-5', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderCircleAlertIcon24.ts b/fe-app-tachyon/src/icons/renderCircleAlertIcon24.ts new file mode 100644 index 00000000..8e6820b2 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderCircleAlertIcon24.ts @@ -0,0 +1,39 @@ +import { svgEl } from '../helpers'; + +export function renderCircleAlertIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-circle-alert-icon lucide-circle-alert', + }, + [ + svgEl('circle', { + cx: '12', + cy: '12', + r: '10', + }), + svgEl('line', { + x1: '12', + y1: '8', + x2: '12', + y2: '12', + }), + svgEl('line', { + x1: '12', + y1: '16', + x2: '12.01', + y2: '16', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderCircleCheckBigIcon24.ts b/fe-app-tachyon/src/icons/renderCircleCheckBigIcon24.ts new file mode 100644 index 00000000..e353091c --- /dev/null +++ b/fe-app-tachyon/src/icons/renderCircleCheckBigIcon24.ts @@ -0,0 +1,26 @@ +import { svgEl } from '../helpers'; + +export function renderCircleCheckBigIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-circle-check-big-icon lucide-circle-check-big', + }, + [ + svgEl('path', { + d: 'M21.801 10A10 10 0 1 1 17 3.335', + }), + svgEl('path', { + d: 'm9 11 3 3L22 4', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderCircleCheckIcon24.ts b/fe-app-tachyon/src/icons/renderCircleCheckIcon24.ts new file mode 100644 index 00000000..4ccf018c --- /dev/null +++ b/fe-app-tachyon/src/icons/renderCircleCheckIcon24.ts @@ -0,0 +1,30 @@ +import { svgEl } from '../helpers'; + +export function renderCircleCheckIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-circle-check-icon lucide-circle-check', + }, + [ + svgEl('circle', { + cx: '12', + cy: '12', + r: '10', + }), + svgEl('path', { + d: 'M9 12l2 2 4-4', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderCirclePlayIcon24.ts b/fe-app-tachyon/src/icons/renderCirclePlayIcon24.ts new file mode 100644 index 00000000..a4102f10 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderCirclePlayIcon24.ts @@ -0,0 +1,28 @@ +import { svgEl } from '../helpers'; + +export function renderCirclePlayIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-circle-play-icon lucide-circle-play', + }, + [ + svgEl('path', { + d: 'M9 9.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997A1 1 0 0 1 9 14.996z', + }), + svgEl('circle', { + cx: '12', + cy: '12', + r: '10', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderCircleSlashIcon24.ts b/fe-app-tachyon/src/icons/renderCircleSlashIcon24.ts new file mode 100644 index 00000000..e082c114 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderCircleSlashIcon24.ts @@ -0,0 +1,33 @@ +import { svgEl } from '../helpers'; + +export function renderCircleSlashIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-circle-slash-icon lucide-circle-slash', + }, + [ + svgEl('circle', { + cx: '12', + cy: '12', + r: '10', + }), + svgEl('line', { + x1: '9', + y1: '15', + x2: '15', + y2: '9', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderCircleStopIcon24.ts b/fe-app-tachyon/src/icons/renderCircleStopIcon24.ts new file mode 100644 index 00000000..bb9e6142 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderCircleStopIcon24.ts @@ -0,0 +1,32 @@ +import { svgEl } from '../helpers'; + +export function renderCircleStopIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-circle-stop-icon lucide-circle-stop', + }, + [ + svgEl('circle', { + cx: '12', + cy: '12', + r: '10', + }), + svgEl('rect', { + x: '9', + y: '9', + width: '6', + height: '6', + rx: '1', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderCircleXIcon24.ts b/fe-app-tachyon/src/icons/renderCircleXIcon24.ts new file mode 100644 index 00000000..56956178 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderCircleXIcon24.ts @@ -0,0 +1,33 @@ +import { svgEl } from '../helpers'; + +export function renderCircleXIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-circle-x-icon lucide-circle-x', + }, + [ + svgEl('circle', { + cx: '12', + cy: '12', + r: '10', + }), + svgEl('path', { + d: 'M15 9L9 15', + }), + svgEl('path', { + d: 'M9 9L15 15', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderCogIcon24.ts b/fe-app-tachyon/src/icons/renderCogIcon24.ts new file mode 100644 index 00000000..0093bbbd --- /dev/null +++ b/fe-app-tachyon/src/icons/renderCogIcon24.ts @@ -0,0 +1,34 @@ +import { svgEl } from '../helpers'; + +export function renderCogIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-cog-icon lucide-cog', + }, + [ + svgEl('path', { d: 'M11 10.27 7 3.34' }), + svgEl('path', { d: 'm11 13.73-4 6.93' }), + svgEl('path', { d: 'M12 22v-2' }), + svgEl('path', { d: 'M12 2v2' }), + svgEl('path', { d: 'M14 12h8' }), + svgEl('path', { d: 'm17 20.66-1-1.73' }), + svgEl('path', { d: 'm17 3.34-1 1.73' }), + svgEl('path', { d: 'M2 12h2' }), + svgEl('path', { d: 'm20.66 17-1.73-1' }), + svgEl('path', { d: 'm20.66 7-1.73 1' }), + svgEl('path', { d: 'm3.34 17 1.73-1' }), + svgEl('path', { d: 'm3.34 7 1.73 1' }), + svgEl('circle', { cx: '12', cy: '12', r: '2' }), + svgEl('circle', { cx: '12', cy: '12', r: '8' }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderCopyIcon24.ts b/fe-app-tachyon/src/icons/renderCopyIcon24.ts new file mode 100644 index 00000000..c0a24997 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderCopyIcon24.ts @@ -0,0 +1,32 @@ +import { svgEl } from '../helpers/svgEl'; + +export function renderCopyIcon24() { + return svgEl( + 'svg', + { + xmlns: 'http://www.w3.org/2000/svg', + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-copy-icon lucide-copy', + }, + [ + svgEl('rect', { + width: '14', + height: '14', + x: '8', + y: '8', + rx: '2', + ry: '2', + }), + svgEl('path', { + d: 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderDownloadIcon24.ts b/fe-app-tachyon/src/icons/renderDownloadIcon24.ts new file mode 100644 index 00000000..12ec462a --- /dev/null +++ b/fe-app-tachyon/src/icons/renderDownloadIcon24.ts @@ -0,0 +1,29 @@ +import { svgEl } from '../helpers'; + +export function renderDownloadIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-download-icon lucide-download', + }, + [ + svgEl('path', { + d: 'M12 15V3', + }), + svgEl('path', { + d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4', + }), + svgEl('path', { + d: 'm7 10 5 5 5-5', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderInfoIcon24.ts b/fe-app-tachyon/src/icons/renderInfoIcon24.ts new file mode 100644 index 00000000..1cea9048 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderInfoIcon24.ts @@ -0,0 +1,32 @@ +import { svgEl } from '../helpers/svgEl'; + +export function renderInfoIcon24() { + return svgEl( + 'svg', + { + xmlns: 'http://www.w3.org/2000/svg', + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-info-icon lucide-info', + }, + [ + svgEl('circle', { + cx: '12', + cy: '12', + r: '10', + }), + svgEl('path', { + d: 'M12 16v-4', + }), + svgEl('path', { + d: 'M12 8h.01', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderLinkIcon24.ts b/fe-app-tachyon/src/icons/renderLinkIcon24.ts new file mode 100644 index 00000000..29a2f814 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderLinkIcon24.ts @@ -0,0 +1,27 @@ +import { svgEl } from '../helpers/svgEl'; + +export function renderLinkIcon24() { + return svgEl( + 'svg', + { + xmlns: 'http://www.w3.org/2000/svg', + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-link-icon lucide-link', + }, + [ + svgEl('path', { + d: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71', + }), + svgEl('path', { + d: 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderLoaderCircleIcon24.ts b/fe-app-tachyon/src/icons/renderLoaderCircleIcon24.ts new file mode 100644 index 00000000..e6ecd3dc --- /dev/null +++ b/fe-app-tachyon/src/icons/renderLoaderCircleIcon24.ts @@ -0,0 +1,32 @@ +import { svgEl } from '../helpers'; + +export function renderLoaderCircleIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-loader-circle rotate', + }, + [ + svgEl('path', { + d: 'M21 12a9 9 0 1 1-6.219-8.56', + }), + svgEl('animateTransform', { + attributeName: 'transform', + attributeType: 'XML', + type: 'rotate', + from: '0 12 12', + to: '360 12 12', + dur: '1s', + repeatCount: 'indefinite', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderPauseIcon24.ts b/fe-app-tachyon/src/icons/renderPauseIcon24.ts new file mode 100644 index 00000000..8b150c79 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderPauseIcon24.ts @@ -0,0 +1,34 @@ +import { svgEl } from '../helpers'; + +export function renderPauseIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-pause-icon lucide-pause', + }, + [ + svgEl('rect', { + x: '14', + y: '3', + width: '5', + height: '18', + rx: '1', + }), + svgEl('rect', { + x: '5', + y: '3', + width: '5', + height: '18', + rx: '1', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderPlayIcon24.ts b/fe-app-tachyon/src/icons/renderPlayIcon24.ts new file mode 100644 index 00000000..46c161d7 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderPlayIcon24.ts @@ -0,0 +1,23 @@ +import { svgEl } from '../helpers'; + +export function renderPlayIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-play-icon lucide-play', + }, + [ + svgEl('path', { + d: 'M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderRotateCcwIcon24.ts b/fe-app-tachyon/src/icons/renderRotateCcwIcon24.ts new file mode 100644 index 00000000..82a5d162 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderRotateCcwIcon24.ts @@ -0,0 +1,26 @@ +import { svgEl } from '../helpers'; + +export function renderRotateCcwIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-rotate-ccw-icon lucide-rotate-ccw', + }, + [ + svgEl('path', { + d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', + }), + svgEl('path', { + d: 'M3 3v5h5', + }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderSearchIcon24.ts b/fe-app-tachyon/src/icons/renderSearchIcon24.ts new file mode 100644 index 00000000..30253845 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderSearchIcon24.ts @@ -0,0 +1,22 @@ +import { svgEl } from '../helpers'; + +export function renderSearchIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-search-icon lucide-search', + }, + [ + svgEl('path', { d: 'm21 21-4.34-4.34' }), + svgEl('circle', { cx: '11', cy: '11', r: '8' }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderSquareChartGanttIcon24.ts b/fe-app-tachyon/src/icons/renderSquareChartGanttIcon24.ts new file mode 100644 index 00000000..16c500ed --- /dev/null +++ b/fe-app-tachyon/src/icons/renderSquareChartGanttIcon24.ts @@ -0,0 +1,30 @@ +import { svgEl } from '../helpers'; + +export function renderSquareChartGanttIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-square-chart-gantt-icon lucide-square-chart-gantt', + }, + [ + svgEl('rect', { + width: '18', + height: '18', + x: '3', + y: '3', + rx: '2', + }), + svgEl('path', { d: 'M9 8h7' }), + svgEl('path', { d: 'M8 12h6' }), + svgEl('path', { d: 'M11 16h5' }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderTriangleAlertIcon24.ts b/fe-app-tachyon/src/icons/renderTriangleAlertIcon24.ts new file mode 100644 index 00000000..993b3175 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderTriangleAlertIcon24.ts @@ -0,0 +1,25 @@ +import { svgEl } from '../helpers'; + +export function renderTriangleAlertIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-triangle-alert-icon lucide-triangle-alert', + }, + [ + svgEl('path', { + d: 'm21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3', + }), + svgEl('path', { d: 'M12 9v4' }), + svgEl('path', { d: 'M12 17h.01' }), + ], + ); +} diff --git a/fe-app-tachyon/src/icons/renderXIcon24.ts b/fe-app-tachyon/src/icons/renderXIcon24.ts new file mode 100644 index 00000000..d8b6ce69 --- /dev/null +++ b/fe-app-tachyon/src/icons/renderXIcon24.ts @@ -0,0 +1,19 @@ +import { svgEl } from '../helpers'; + +export function renderXIcon24() { + const NS = 'http://www.w3.org/2000/svg'; + return svgEl( + 'svg', + { + xmlns: NS, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'lucide lucide-x-icon lucide-x', + }, + [svgEl('path', { d: 'M18 6 6 18' }), svgEl('path', { d: 'm6 6 12 12' })], + ); +} diff --git a/fe-app-tachyon/src/luci.d.ts b/fe-app-tachyon/src/luci.d.ts new file mode 100644 index 00000000..fd4c428b --- /dev/null +++ b/fe-app-tachyon/src/luci.d.ts @@ -0,0 +1,59 @@ +type HtmlTag = keyof HTMLElementTagNameMap; + +type HtmlElement = HTMLElementTagNameMap[T]; + +type HtmlAttributes = Partial< + Omit, 'style' | 'children' | 'click'> & { + style?: string | Partial; + class?: string; + 'aria-busy'?: string; + 'aria-disabled'?: string; + 'aria-label'?: string; + 'data-latency-section'?: string; + click?: (event: MouseEvent) => void; + onclick?: (event: MouseEvent) => void; + } +>; + +declare global { + const fs: { + read(path: string): Promise; + write(path: string, data: string): Promise; + remove(path: string): Promise; + exec( + command: string, + args?: string[], + env?: Record, + ): Promise<{ + stdout: string; + stderr: string; + code?: number; + }>; + }; + + const E: ( + type: T, + attr?: HtmlAttributes | null, + children?: (Node | string)[] | Node | string, + ) => HTMLElementTagNameMap[T]; + + const uci: { + load: (packages: string | string[]) => Promise; + unload?: (packages: string | string[]) => void; + sections: (conf: string, type?: string, cb?: () => void) => Promise; + }; + + const _ = (_key: string) => string; + + const ui = { + showModal: (_title: string, _content: HTMLElement) => undefined, + hideModal: () => undefined, + addNotification: ( + _title: string, + _children: HtmlElement | HtmlElement[], + ..._classNames: string[] + ) => HTMLElement, + }; +} + +export {}; diff --git a/fe-app-tachyon/src/main.ts b/fe-app-tachyon/src/main.ts new file mode 100644 index 00000000..2fca1885 --- /dev/null +++ b/fe-app-tachyon/src/main.ts @@ -0,0 +1,42 @@ +'use strict'; +'require baseclass'; +'require fs'; +'require uci'; +'require ui'; + +if (typeof structuredClone !== 'function') + globalThis.structuredClone = (obj) => JSON.parse(JSON.stringify(obj)); + +export { validateIP } from './validators/validateIp'; +export { validateDomain } from './validators/validateDomain'; +export { validateDNS } from './validators/validateDns'; +export { validateUrl } from './validators/validateUrl'; +export { validatePath } from './validators/validatePath'; +export { validateSubnet } from './validators/validateSubnet'; +export { bulkValidate } from './validators/bulkValidate'; +export { validateOutboundJson } from './validators/validateOutboundJson'; +export { validateProxyUrl } from './validators/validateProxyUrl'; +export { parseValueList } from './helpers/parseValueList'; +export { getProxyUrlName } from './helpers/getProxyUrlName'; +export { injectGlobalStyles } from './helpers/injectGlobalStyles'; +export { showToast } from './helpers/showToast'; +export { getClashUIUrl } from './helpers/getClashApiUrl'; +export { TachyonShellMethods } from './tachyon/methods/shell'; +export { coreService } from './tachyon/services/core.service'; +export { store } from './tachyon/services/store.service'; +export { applyUiStateToStore } from './tachyon/services/uiState.service'; +export { DashboardTab } from './tachyon/tabs/dashboard'; +export { DiagnosticTab } from './tachyon/tabs/diagnostic'; +export { MonitoringTab } from './tachyon/tabs/monitoring'; +export { UpdatesTab } from './tachyon/tabs/updates'; +export { + ALLOWED_WITH_RUSSIA_INSIDE, + BOOTSTRAP_DNS_SERVER_OPTIONS, + DEFAULT_LATENCY_TEST_URL, + DNS_SERVER_OPTIONS, + DOMAIN_LIST_OPTIONS, + LATENCY_TEST_URL_OPTIONS, + TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT, + TACHYON_UCI_PACKAGE, + REGIONAL_OPTIONS, +} from './constants'; diff --git a/fe-app-tachyon/src/partials/button/renderButton.ts b/fe-app-tachyon/src/partials/button/renderButton.ts new file mode 100644 index 00000000..54646f63 --- /dev/null +++ b/fe-app-tachyon/src/partials/button/renderButton.ts @@ -0,0 +1,74 @@ +import { insertIf } from '../../helpers'; +import { renderLoaderCircleIcon24 } from '../../icons'; + +interface IRenderButtonProps { + classNames?: string[]; + disabled?: boolean; + loading?: boolean; + icon?: () => SVGSVGElement; + onClick: () => void; + text: string; +} + +export function renderButton({ + classNames = [], + disabled, + loading, + onClick, + text, + icon, +}: IRenderButtonProps) { + const hasIcon = !!loading || !!icon; + + function getWrappedIcon() { + const iconWrap = E('span', { + class: 'fkp-partial-button__icon', + }); + + if (loading) { + iconWrap.appendChild(renderLoaderCircleIcon24()); + + return iconWrap; + } + + if (icon) { + iconWrap.appendChild(icon()); + + return iconWrap; + } + + return iconWrap; + } + + function getClass() { + return [ + 'btn', + 'fkp-partial-button', + ...insertIf(Boolean(disabled), ['fkp-partial-button--disabled']), + ...insertIf(Boolean(loading), ['fkp-partial-button--loading']), + ...insertIf(Boolean(hasIcon), ['fkp-partial-button--with-icon']), + ...classNames, + ] + .filter(Boolean) + .join(' '); + } + + function getDisabled() { + if (loading || disabled) { + return true; + } + + return undefined; + } + + return E( + 'button', + { + type: 'button', + class: getClass(), + disabled: getDisabled(), + click: onClick, + }, + [...insertIf(hasIcon, [getWrappedIcon()]), E('span', {}, text)], + ); +} diff --git a/fe-app-tachyon/src/partials/button/styles.ts b/fe-app-tachyon/src/partials/button/styles.ts new file mode 100644 index 00000000..afe9319d --- /dev/null +++ b/fe-app-tachyon/src/partials/button/styles.ts @@ -0,0 +1,36 @@ +// language=CSS +export const styles = ` +.fkp-partial-button { + text-align: center; +} + +.fkp-partial-button--with-icon { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; +} + +.fkp-partial-button--loading { +} + +.fkp-partial-button--disabled { +} + +.fkp-partial-button__icon { + flex: 0 0 auto; +} + +.fkp-partial-button__icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.fkp-partial-button__icon svg { + width: 16px; + height: 16px; + display: block; + flex: 0 0 auto; +} +`; diff --git a/fe-app-tachyon/src/partials/index.ts b/fe-app-tachyon/src/partials/index.ts new file mode 100644 index 00000000..fc5f5d87 --- /dev/null +++ b/fe-app-tachyon/src/partials/index.ts @@ -0,0 +1,10 @@ +import { styles as ButtonStyles } from './button/styles'; +import { styles as ModalStyles } from './modal/styles'; + +export * from './button/renderButton'; +export * from './modal/renderModal'; + +export const PartialStyles = ` +${ButtonStyles} +${ModalStyles} +`; diff --git a/fe-app-tachyon/src/partials/modal/renderModal.ts b/fe-app-tachyon/src/partials/modal/renderModal.ts new file mode 100644 index 00000000..de4e61ac --- /dev/null +++ b/fe-app-tachyon/src/partials/modal/renderModal.ts @@ -0,0 +1,353 @@ +import { renderButton } from '../button/renderButton'; +import { copyToClipboard } from '../../helpers/copyToClipboard'; +import { downloadAsTxt } from '../../helpers/downloadAsTxt'; + +interface ModalTextContext { + maskValues: boolean; +} + +interface RenderModalOptions { + getText?: (context: ModalTextContext) => string | Promise; + maskText?: (text: string) => string; + refreshMs?: number; + initialAutoRefresh?: boolean; + showAutoRefreshToggle?: boolean; + showMaskValuesToggle?: boolean; + initialMaskValues?: boolean; + startAtEnd?: boolean; + autoRefreshLabel?: string; + maskValuesLabel?: string; +} + +export function renderModal( + text: string, + name: string, + options?: RenderModalOptions, +) { + let rawText = text ?? ''; + let currentText = ''; + let refreshInFlight = false; + let pendingRefresh = false; + let pendingForcedRefresh = false; + let refreshSessionId = 0; + let timer: ReturnType | undefined; + let observer: MutationObserver | undefined; + let autoRefreshEnabled = + options?.initialAutoRefresh ?? Boolean(options?.getText); + let maskValuesEnabled = options?.initialMaskValues ?? true; + let shouldScrollToBottomOnMount = Boolean(options?.startAtEnd); + let autoRefreshInput: HTMLInputElement | undefined; + let maskValuesInput: HTMLInputElement | undefined; + + const getDisplayText = (value: string) => { + if (maskValuesEnabled && options?.maskText) { + return options.maskText(value); + } + + return value; + }; + + const codeEl = E('code', {}, '') as HTMLElement; + const contentEl = E( + 'pre', + { class: 'fkp-partial-modal__content' }, + codeEl, + ) as HTMLElement; + + const stopRefreshTimer = () => { + if (timer) { + clearInterval(timer); + timer = undefined; + } + }; + + const destroyLiveRefresh = () => { + refreshSessionId += 1; + pendingRefresh = false; + pendingForcedRefresh = false; + stopRefreshTimer(); + + observer?.disconnect(); + observer = undefined; + }; + + const scrollToBottom = () => { + contentEl.scrollTop = contentEl.scrollHeight; + }; + + const scheduleInitialScrollToBottom = () => { + if (!shouldScrollToBottomOnMount || !body.isConnected) { + return; + } + + shouldScrollToBottomOnMount = false; + + requestAnimationFrame(() => { + scrollToBottom(); + }); + }; + + const updateText = (nextText: string) => { + const normalizedText = nextText ?? ''; + + const shouldStickToBottom = + shouldScrollToBottomOnMount || + contentEl.scrollTop + contentEl.clientHeight >= + contentEl.scrollHeight - 16; + + if (normalizedText === currentText) { + if (shouldStickToBottom) { + requestAnimationFrame(() => { + scrollToBottom(); + }); + } + + return; + } + + currentText = normalizedText; + codeEl.textContent = currentText; + + if (shouldStickToBottom) { + requestAnimationFrame(() => { + scrollToBottom(); + }); + } + }; + + const updateDisplayedTextFromRaw = () => { + updateText(getDisplayText(rawText)); + }; + + const refreshText = async (force = false) => { + if ( + !options?.getText || + (!force && !autoRefreshEnabled) || + refreshInFlight + ) { + return; + } + + if (!body.isConnected) { + return; + } + + refreshInFlight = true; + const sessionId = refreshSessionId; + + try { + const nextText = await options.getText({ + maskValues: options.maskText ? false : maskValuesEnabled, + }); + + if (!body.isConnected || (!force && !autoRefreshEnabled)) { + return; + } + + if (sessionId !== refreshSessionId) { + return; + } + + const normalizedText = nextText ?? ''; + rawText = normalizedText; + updateText(getDisplayText(normalizedText)); + } catch (error) { + console.warn('[renderModal] failed to refresh modal content', error); + } finally { + refreshInFlight = false; + + if (pendingRefresh) { + const shouldForceRefresh = pendingForcedRefresh; + pendingRefresh = false; + pendingForcedRefresh = false; + + if ((shouldForceRefresh || autoRefreshEnabled) && body.isConnected) { + void refreshText(shouldForceRefresh); + } + } + } + }; + + const requestRefresh = () => { + if (!options?.getText || !autoRefreshEnabled) { + return; + } + + if (refreshInFlight) { + pendingRefresh = true; + return; + } + + void refreshText(); + }; + + const requestForcedRefresh = () => { + if (!options?.getText) { + return; + } + + if (refreshInFlight) { + pendingRefresh = true; + pendingForcedRefresh = true; + return; + } + + void refreshText(true); + }; + + const startRefreshTimer = () => { + if ( + !options?.getText || + !autoRefreshEnabled || + timer || + typeof document === 'undefined' + ) { + return; + } + + timer = setInterval(() => { + requestRefresh(); + }, options.refreshMs ?? 3000); + }; + + const setAutoRefreshEnabled = (nextValue: boolean) => { + autoRefreshEnabled = nextValue; + refreshSessionId += 1; + pendingRefresh = false; + pendingForcedRefresh = false; + + if (autoRefreshInput) { + autoRefreshInput.checked = nextValue; + } + + if (nextValue) { + startRefreshTimer(); + requestRefresh(); + return; + } + + stopRefreshTimer(); + }; + + const setMaskValuesEnabled = (nextValue: boolean) => { + maskValuesEnabled = nextValue; + refreshSessionId += 1; + pendingRefresh = false; + pendingForcedRefresh = false; + + if (maskValuesInput) { + maskValuesInput.checked = nextValue; + } + + if (options?.maskText) { + updateDisplayedTextFromRaw(); + return; + } + + requestForcedRefresh(); + }; + + const footerChildren: HTMLElement[] = [ + renderButton({ + classNames: ['cbi-button-apply'], + text: _('Download'), + onClick: () => downloadAsTxt(currentText, name), + }), + renderButton({ + classNames: ['cbi-button-apply'], + text: _('Copy'), + onClick: () => copyToClipboard(`\`\`\`${name}\n${currentText}\n\`\`\``), + }), + renderButton({ + classNames: ['cbi-button-remove'], + text: _('Close'), + onClick: () => { + destroyLiveRefresh(); + ui.hideModal(); + }, + }), + ]; + + if (options?.getText && options?.showAutoRefreshToggle) { + autoRefreshInput = document.createElement('input'); + autoRefreshInput.type = 'checkbox'; + autoRefreshInput.className = 'cbi-input-checkbox'; + autoRefreshInput.checked = autoRefreshEnabled; + autoRefreshInput.addEventListener('change', () => { + setAutoRefreshEnabled(autoRefreshInput!.checked); + }); + + footerChildren.unshift( + E('label', { class: 'fkp-partial-modal__checkbox' }, [ + autoRefreshInput, + E( + 'span', + { class: 'fkp-partial-modal__checkbox-text' }, + options.autoRefreshLabel ?? _('Auto refresh'), + ), + ]) as HTMLElement, + ); + } + + if ( + (options?.getText || options?.maskText) && + options?.showMaskValuesToggle + ) { + maskValuesInput = document.createElement('input'); + maskValuesInput.type = 'checkbox'; + maskValuesInput.className = 'cbi-input-checkbox'; + maskValuesInput.checked = maskValuesEnabled; + maskValuesInput.addEventListener('change', () => { + setMaskValuesEnabled(maskValuesInput!.checked); + }); + + footerChildren.unshift( + E('label', { class: 'fkp-partial-modal__checkbox' }, [ + maskValuesInput, + E( + 'span', + { class: 'fkp-partial-modal__checkbox-text' }, + options.maskValuesLabel ?? _('Hide values'), + ), + ]) as HTMLElement, + ); + } + + const body = E('div', { class: 'fkp-partial-modal__body' }, [ + E('div', {}, [ + contentEl, + + E('div', { class: 'fkp-partial-modal__footer' }, footerChildren), + ]), + ]) as HTMLElement; + + if ( + (options?.getText || options?.startAtEnd) && + typeof document !== 'undefined' + ) { + observer = new MutationObserver(() => { + if (!body.isConnected) { + destroyLiveRefresh(); + return; + } + + scheduleInitialScrollToBottom(); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + scheduleInitialScrollToBottom(); + } + + if (options?.getText && typeof document !== 'undefined') { + startRefreshTimer(); + requestRefresh(); + } + + updateDisplayedTextFromRaw(); + + return body; +} diff --git a/fe-app-tachyon/src/partials/modal/styles.ts b/fe-app-tachyon/src/partials/modal/styles.ts new file mode 100644 index 00000000..797055cc --- /dev/null +++ b/fe-app-tachyon/src/partials/modal/styles.ts @@ -0,0 +1,36 @@ +// language=CSS +export const styles = ` + +.fkp-partial-modal__body {} + +.fkp-partial-modal__content { + max-height: 70vh; + overflow: scroll; + border-radius: 4px; +} + +.fkp-partial-modal__footer { + display: flex; + justify-content: flex-end; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.fkp-partial-modal__footer button { + margin-left: 0; +} + +.fkp-partial-modal__checkbox { + display: inline-flex; + align-items: center; + gap: 8px; + margin-right: auto; + cursor: pointer; + user-select: none; +} + +.fkp-partial-modal__checkbox-text { + line-height: 1.2; +} +`; diff --git a/fe-app-tachyon/src/styles.ts b/fe-app-tachyon/src/styles.ts new file mode 100644 index 00000000..53f49c2d --- /dev/null +++ b/fe-app-tachyon/src/styles.ts @@ -0,0 +1,166 @@ +// language=CSS +import { DashboardTab } from './tachyon/tabs/dashboard'; +import { DiagnosticTab } from './tachyon/tabs/diagnostic'; +import { MonitoringTab } from './tachyon/tabs/monitoring'; +import { UpdatesTab } from './tachyon/tabs/updates'; +import { PartialStyles } from './partials'; +import { TACHYON_UCI_PACKAGE as TACHYON_CBI_PREFIX } from './constants'; + +export const GlobalStyles = ` +${DashboardTab.styles} +${DiagnosticTab.styles} +${MonitoringTab.styles} +${UpdatesTab.styles} +${PartialStyles} + + +/* Hide extra H3 for settings tab */ +#cbi-${TACHYON_CBI_PREFIX}-settings > h3 { + display: none; +} + +/* Hide extra H3 for rules tab */ +#cbi-${TACHYON_CBI_PREFIX}-section > h3:nth-child(1) { + display: none; +} + +/* Vertical align for remove rule action button */ +#cbi-${TACHYON_CBI_PREFIX}-section > .cbi-section-remove { + margin-bottom: -32px; +} + +#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-actions > div { + display: inline-flex; + align-items: center; + gap: 4px; +} + +#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-actions { + text-align: right; +} + +/* Rule reorder visuals */ +#cbi-${TACHYON_CBI_PREFIX}-section { + position: relative; +} + +#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row { + position: relative; +} + +#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.placeholder { + opacity: 1; +} + +#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.placeholder em { + font-style: italic; +} + +#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.drag-over-above::after, +#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.drag-over-below::after { + content: ''; + position: absolute; + left: 10px; + right: 10px; + height: 2px; + border-radius: 2px; + background: var(--primary-color-high, #1976d2); + pointer-events: none; + z-index: 2; +} + +#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.drag-over-above::after { + top: -1px; +} + +#cbi-${TACHYON_CBI_PREFIX}-section .cbi-section-table-row.drag-over-below::after { + bottom: -1px; +} + +/* Centered class helper */ +.centered { + display: flex; + align-items: center; + justify-content: center; +} + +/* Rotate class helper */ +.rotate { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Skeleton styles*/ +.skeleton { + background-color: var(--background-color-low, #e0e0e0); + border-radius: 4px; + position: relative; + overflow: hidden; +} + +.skeleton::after { + content: ''; + position: absolute; + top: 0; + left: -150%; + width: 150%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent + ); + animation: skeleton-shimmer 1.6s infinite; +} + +@keyframes skeleton-shimmer { + 100% { + left: 150%; + } +} +/* Toast */ +.toast-container { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + z-index: 9999; + font-family: system-ui, sans-serif; +} + +.toast { + opacity: 0; + transform: translateY(10px); + transition: opacity 0.3s ease, transform 0.3s ease; + padding: 10px 16px; + border-radius: 6px; + color: #fff; + font-size: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + min-width: 220px; + max-width: 340px; + text-align: center; +} + +.toast-success { + background-color: #28a745; +} + +.toast-error { + background-color: #dc3545; +} + +.toast.visible { + opacity: 1; + transform: translateY(0); +} +`; diff --git a/fe-app-tachyon/src/tachyon/api.ts b/fe-app-tachyon/src/tachyon/api.ts new file mode 100644 index 00000000..5214c541 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/api.ts @@ -0,0 +1,53 @@ +import { withTimeout } from '../helpers'; + +export async function createBaseApiRequest( + fetchFn: () => Promise, + options?: { + timeoutMs?: number; + operationName?: string; + timeoutMessage?: string; + }, +): Promise> { + const wrappedFn = () => + options?.timeoutMs && options?.operationName + ? withTimeout( + fetchFn(), + options.timeoutMs, + options.operationName, + options.timeoutMessage, + ) + : fetchFn(); + + try { + const response = await wrappedFn(); + + if (!response.ok) { + return { + success: false as const, + message: `${_('HTTP error')} ${response.status}: ${response.statusText}`, + }; + } + + const data: T = await response.json(); + + return { + success: true as const, + data, + }; + } catch (e) { + return { + success: false as const, + message: e instanceof Error ? e.message : _('Unknown error'), + }; + } +} + +export type IBaseApiResponse = + | { + success: true; + data: T; + } + | { + success: false; + message: string; + }; diff --git a/fe-app-tachyon/src/tachyon/fetchers/fetchServicesInfo.ts b/fe-app-tachyon/src/tachyon/fetchers/fetchServicesInfo.ts new file mode 100644 index 00000000..7b68abbb --- /dev/null +++ b/fe-app-tachyon/src/tachyon/fetchers/fetchServicesInfo.ts @@ -0,0 +1,70 @@ +import { TachyonShellMethods } from '../methods'; +import { logger } from '../services/logger.service'; +import { store } from '../services/store.service'; +import { refreshRuntimeUiState } from '../services/runtimeUiState.service'; +import { Tachyon } from '../types'; + +let latestServicesInfoRequestId = 0; + +function getSettledMethodResponse( + scope: string, + result: PromiseSettledResult>, +): Tachyon.MethodResponse { + if (result.status === 'fulfilled') { + return result.value; + } + + logger.error('[SERVICES_INFO]', `${scope} failed`, result.reason); + + return { + success: false, + error: result.reason instanceof Error ? result.reason.message : '', + }; +} + +export async function fetchServicesInfo() { + const requestId = ++latestServicesInfoRequestId; + const uiState = await refreshRuntimeUiState({ force: true }); + + if (requestId !== latestServicesInfoRequestId) { + return; + } + + if (uiState) { + return uiState; + } + + const [tachyonResult, singboxResult] = await Promise.allSettled([ + TachyonShellMethods.getStatus(), + TachyonShellMethods.getSingBoxStatus(), + ]); + + if (requestId !== latestServicesInfoRequestId) { + return; + } + + const tachyon = getSettledMethodResponse('getStatus', tachyonResult); + const singbox = getSettledMethodResponse('getSingBoxStatus', singboxResult); + const previousData = store.get().servicesInfoWidget.data; + + store.set({ + servicesInfoWidget: { + loading: false, + failed: !tachyon.success || !singbox.success, + data: { + singbox: singbox.success ? singbox.data.running : previousData.singbox, + tachyonRunning: tachyon.success + ? tachyon.data.running + : previousData.tachyonRunning, + tachyonEnabled: tachyon.success + ? tachyon.data.enabled + : previousData.tachyonEnabled, + tachyonStatus: tachyon.success + ? tachyon.data.status + : previousData.tachyonStatus, + }, + }, + }); + + return undefined; +} diff --git a/fe-app-tachyon/src/tachyon/fetchers/tests/fetchServicesInfo.test.ts b/fe-app-tachyon/src/tachyon/fetchers/tests/fetchServicesInfo.test.ts new file mode 100644 index 00000000..fadf8b4e --- /dev/null +++ b/fe-app-tachyon/src/tachyon/fetchers/tests/fetchServicesInfo.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + executeShellCommand: vi.fn(), +})); + +vi.mock('../../../helpers', () => ({ + executeShellCommand: mocks.executeShellCommand, +})); + +import { store } from '../../services/store.service'; +import { fetchServicesInfo } from '../fetchServicesInfo'; + +describe('fetchServicesInfo', () => { + beforeEach(() => { + store.reset(); + mocks.executeShellCommand.mockReset(); + }); + + it('returns the fast UI state after applying it to the shared store', async () => { + const uiState = { + service: { + tachyon: { + running: 1, + enabled: 1, + status: 'restarting', + dns_configured: 1, + }, + sing_box: { + running: 1, + enabled: 0, + status: 'running but disabled', + }, + }, + capabilities: { + sing_box_extended: 1, + sing_box_tiny: 0, + sing_box_tailscale: 1, + zapret_installed: 1, + zapret2_installed: 0, + byedpi_installed: 0, + server_inbounds_enabled_count: 0, + }, + actions: { + service: [ + { + success: true, + running: true, + kind: 'service', + action: 'restart', + job_id: 'service-1', + }, + ], + latency: [], + component: [], + subscription: [], + }, + }; + + mocks.executeShellCommand.mockResolvedValue({ + stdout: JSON.stringify(uiState), + stderr: '', + code: 0, + }); + + await expect(fetchServicesInfo()).resolves.toEqual(uiState); + + const state = store.get(); + + expect(state.servicesInfoWidget.data.tachyonStatus).toBe('restarting'); + expect(state.diagnosticsActions.restart.loading).toBe(true); + }); + + it('keeps the previous service state when the fallback status fetch fails', async () => { + store.set({ + servicesInfoWidget: { + loading: false, + failed: false, + data: { + singbox: 1, + tachyonRunning: 1, + tachyonEnabled: 1, + tachyonStatus: 'running & enabled', + }, + }, + }); + + mocks.executeShellCommand + .mockResolvedValueOnce({ + stdout: '', + stderr: 'get_ui_state failed', + code: 1, + }) + .mockResolvedValueOnce({ + stdout: '', + stderr: 'get_status failed', + code: 1, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + running: 0, + enabled: 0, + status: 'stopped but disabled', + }), + stderr: '', + code: 0, + }); + + await fetchServicesInfo(); + + const state = store.get(); + + expect(state.servicesInfoWidget.failed).toBe(true); + expect(state.servicesInfoWidget.data).toEqual({ + singbox: 0, + tachyonRunning: 1, + tachyonEnabled: 1, + tachyonStatus: 'running & enabled', + }); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts b/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts new file mode 100644 index 00000000..d6cc3235 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts @@ -0,0 +1,31 @@ +import type { StoreType } from '../services/store.service'; +import type { Tachyon } from '../types'; + +export type UpdatesActionKey = keyof StoreType['updatesActions']; + +const componentActionKeyMap: Record = { + 'tachyon:check_update': 'tachyonCheck', + 'tachyon:install': 'tachyonInstall', + 'sing_box:check_update': 'singBoxCheck', + 'sing_box:install': 'singBoxInstall', + 'sing_box:install_extended': 'singBoxInstallExtended', + 'sing_box:install_extended_compressed': 'singBoxInstallExtendedCompressed', + 'sing_box:install_tiny': 'singBoxInstallTiny', + 'sing_box:install_stable': 'singBoxInstallStable', + 'zapret:check_update': 'zapretCheck', + 'zapret:install': 'zapretInstall', + 'zapret:remove': 'zapretRemove', + 'zapret2:check_update': 'zapret2Check', + 'zapret2:install': 'zapret2Install', + 'zapret2:remove': 'zapret2Remove', + 'byedpi:check_update': 'byedpiCheck', + 'byedpi:install': 'byedpiInstall', + 'byedpi:remove': 'byedpiRemove', +}; + +export function getComponentActionKey( + component: Tachyon.ComponentName, + action: Tachyon.ComponentAction, +): UpdatesActionKey | undefined { + return componentActionKeyMap[`${component}:${action}`]; +} diff --git a/fe-app-tachyon/src/tachyon/helpers/isActiveLuciTab.ts b/fe-app-tachyon/src/tachyon/helpers/isActiveLuciTab.ts new file mode 100644 index 00000000..653de84c --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/isActiveLuciTab.ts @@ -0,0 +1,11 @@ +export function isActiveLuciTab(tabId: string) { + if (typeof document === 'undefined') { + return false; + } + + return Boolean( + document.querySelector( + `.cbi-tab[data-tab="${tabId}"]:not(.cbi-tab-disabled)`, + ), + ); +} diff --git a/fe-app-tachyon/src/tachyon/helpers/isTransientRpcError.ts b/fe-app-tachyon/src/tachyon/helpers/isTransientRpcError.ts new file mode 100644 index 00000000..1885d69e --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/isTransientRpcError.ts @@ -0,0 +1,17 @@ +const TRANSIENT_RPC_ERROR_PATTERNS = [ + 'no related rpc reply', + 'request aborted', + 'operation was aborted', +]; + +export function isTransientRpcError(message?: string | null) { + if (!message) { + return false; + } + + const normalized = message.toLowerCase(); + + return TRANSIENT_RPC_ERROR_PATTERNS.some((pattern) => + normalized.includes(pattern), + ); +} diff --git a/fe-app-tachyon/src/tachyon/helpers/restoredActionLoading.ts b/fe-app-tachyon/src/tachyon/helpers/restoredActionLoading.ts new file mode 100644 index 00000000..3be0e2b7 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/restoredActionLoading.ts @@ -0,0 +1,5 @@ +export function shouldShowLoadingForRestoredAction(state: { + running?: boolean; +}) { + return state.running === true; +} diff --git a/fe-app-tachyon/src/tachyon/helpers/serviceAvailability.ts b/fe-app-tachyon/src/tachyon/helpers/serviceAvailability.ts new file mode 100644 index 00000000..d086d0f4 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/serviceAvailability.ts @@ -0,0 +1,25 @@ +export type ServiceAvailability = + | 'loading' + | 'running' + | 'stopped' + | 'unavailable'; + +export function getServiceAvailability({ + loading, + failed, + running, +}: { + loading: boolean; + failed: boolean; + running: boolean | number; +}): ServiceAvailability { + if (loading) { + return 'loading'; + } + + if (failed) { + return 'unavailable'; + } + + return running ? 'running' : 'stopped'; +} diff --git a/fe-app-tachyon/src/tachyon/helpers/singBoxVariant.ts b/fe-app-tachyon/src/tachyon/helpers/singBoxVariant.ts new file mode 100644 index 00000000..77663ee3 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/singBoxVariant.ts @@ -0,0 +1,72 @@ +type SingBoxVariantFields = { + sing_box_version?: string; + sing_box_extended?: number; + sing_box_tiny?: number; + sing_box_compressed?: number; + sing_box_tailscale?: number; +}; + +export function isExtendedSingBoxVersion(version?: string) { + return String(version || '').includes('extended'); +} + +function isVersionPlaceholder(version?: string) { + const normalized = String(version || '') + .trim() + .toLowerCase(); + + if ( + !normalized || + normalized === 'loading' || + normalized === 'unknown' || + normalized === 'not installed' + ) { + return true; + } + + return ( + (typeof _ === 'function' && normalized === _('unknown').toLowerCase()) || + (typeof _ === 'function' && normalized === _('Not installed').toLowerCase()) + ); +} + +export function formatSingBoxVersion(value: SingBoxVariantFields) { + const version = String(value.sing_box_version || ''); + + if (!version || version === 'not installed') { + return _('Not installed'); + } + + if (isVersionPlaceholder(version)) { + return version; + } + + const normalizedValue = normalizeSingBoxVariantFields(value); + let variant = ''; + + if ( + normalizedValue.sing_box_extended && + normalizedValue.sing_box_compressed + ) { + variant = _('compressed'); + } else if (normalizedValue.sing_box_tiny) { + variant = _('tiny'); + } + + return variant ? `${version} (${variant})` : version; +} + +export function normalizeSingBoxVariantFields( + value: T, +): T { + const versionExtended = isExtendedSingBoxVersion(value.sing_box_version); + const singBoxExtended = Boolean(value.sing_box_extended) || versionExtended; + + return { + ...value, + sing_box_extended: singBoxExtended ? 1 : 0, + sing_box_tiny: singBoxExtended ? 0 : value.sing_box_tiny ? 1 : 0, + sing_box_compressed: singBoxExtended && value.sing_box_compressed ? 1 : 0, + sing_box_tailscale: singBoxExtended || value.sing_box_tailscale ? 1 : 0, + } as T; +} diff --git a/fe-app-tachyon/src/tachyon/helpers/tests/isTransientRpcError.test.ts b/fe-app-tachyon/src/tachyon/helpers/tests/isTransientRpcError.test.ts new file mode 100644 index 00000000..4059dd9d --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/tests/isTransientRpcError.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { isTransientRpcError } from '../isTransientRpcError'; + +describe('isTransientRpcError', () => { + it('detects LuCI RPC reply races caused by interrupted page requests', () => { + expect(isTransientRpcError('No related RPC reply')).toBe(true); + expect(isTransientRpcError('Request aborted while waiting for RPC')).toBe( + true, + ); + }); + + it('does not treat backend action failures as transient transport errors', () => { + expect( + isTransientRpcError('Another component action is already running'), + ).toBe(false); + expect(isTransientRpcError('Component action job was not found')).toBe( + false, + ); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/helpers/tests/restoredActionLoading.test.ts b/fe-app-tachyon/src/tachyon/helpers/tests/restoredActionLoading.test.ts new file mode 100644 index 00000000..f1cdb97a --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/tests/restoredActionLoading.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldShowLoadingForRestoredAction } from '../restoredActionLoading'; + +describe('restored action loading', () => { + it('shows loading only for actions that are still running', () => { + expect(shouldShowLoadingForRestoredAction({ running: true })).toBe(true); + expect(shouldShowLoadingForRestoredAction({ running: false })).toBe(false); + expect(shouldShowLoadingForRestoredAction({})).toBe(false); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/helpers/tests/serviceAvailability.test.ts b/fe-app-tachyon/src/tachyon/helpers/tests/serviceAvailability.test.ts new file mode 100644 index 00000000..25d70fd3 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/tests/serviceAvailability.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { getServiceAvailability } from '../serviceAvailability'; + +describe('getServiceAvailability', () => { + it('does not treat an unresolved service status as stopped', () => { + expect( + getServiceAvailability({ loading: true, failed: false, running: false }), + ).toBe('loading'); + }); + + it('distinguishes a stopped service from an unavailable status request', () => { + expect( + getServiceAvailability({ loading: false, failed: false, running: 0 }), + ).toBe('stopped'); + expect( + getServiceAvailability({ loading: false, failed: true, running: 0 }), + ).toBe('unavailable'); + }); + + it('accepts the numeric running flag returned by the backend', () => { + expect( + getServiceAvailability({ loading: false, failed: false, running: 1 }), + ).toBe('running'); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/methods/custom/getClashApiSecret.ts b/fe-app-tachyon/src/tachyon/methods/custom/getClashApiSecret.ts new file mode 100644 index 00000000..4bc654a5 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/custom/getClashApiSecret.ts @@ -0,0 +1,9 @@ +import { getConfigSections } from './getConfigSections'; + +export async function getClashApiSecret() { + const sections = await getConfigSections(); + + const settings = sections.find((section) => section['.type'] === 'settings'); + + return settings?.yacd_secret_key || ''; +} diff --git a/fe-app-tachyon/src/tachyon/methods/custom/getConfigSections.ts b/fe-app-tachyon/src/tachyon/methods/custom/getConfigSections.ts new file mode 100644 index 00000000..19d5c4de --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/custom/getConfigSections.ts @@ -0,0 +1,8 @@ +import { Tachyon } from '../../types'; +import { TACHYON_UCI_PACKAGE } from '../../../constants'; + +export async function getConfigSections(): Promise { + return uci + .load(TACHYON_UCI_PACKAGE) + .then(() => uci.sections(TACHYON_UCI_PACKAGE)); +} diff --git a/fe-app-tachyon/src/tachyon/methods/custom/getDashboardSections.ts b/fe-app-tachyon/src/tachyon/methods/custom/getDashboardSections.ts new file mode 100644 index 00000000..b301e006 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/custom/getDashboardSections.ts @@ -0,0 +1,1491 @@ +import { getConfigSections } from './getConfigSections'; +import { ClashAPI, Tachyon } from '../../types'; +import { + canUseDirectClashApi, + getClashHttpUrl, + getProxyUrlName, + isCopyableProxyLink, +} from '../../../helpers'; +import { getOutboundTagBySection } from '../../runtimeTags'; +import { TachyonShellMethods } from '../shell'; + +interface IGetDashboardSectionsResponse { + success: boolean; + data: Tachyon.OutboundGroup[]; +} + +interface IGetDashboardSectionsOptions { + includeSubscriptionCopyState?: boolean; +} + +type ClashProxyEntry = { + code: string; + value: ClashAPI.ProxyBase; +}; + +type DashboardSectionCache = { + version?: number; + section?: string; + links?: Record; + outboundMetadata?: Tachyon.GetOutboundMetadata; + urltestGroups?: Record; + priorityGroups?: Record; + subscriptionMetadata?: + | Tachyon.SubscriptionMetadata + | Tachyon.SubscriptionMetadata[]; +}; + +type UrlTestCacheGroup = { + displayName?: string; + outbounds?: string[]; + url?: string; + interval?: string; + tolerance?: string | number; + idle_timeout?: string; + interrupt_exist_connections?: boolean; +}; + +type PriorityCacheLevel = { + id?: string; + displayName?: string; + order?: number; + direct?: boolean; + filter_mode?: string; + detect_server_country?: string; + outbounds?: string[]; +}; + +type PriorityCacheGroup = { + id?: string; + tag?: string; + section?: string; + displayName?: string; + health_url?: string; + active_check_interval?: string; + check_timeout?: string; + recovery_check_interval?: string; + pick_fastest?: boolean; + switch_to_faster_same_priority?: boolean; + fastest_check_interval?: string; + interrupt_exist_connections?: boolean; + pin_dashboard?: boolean; + outbounds?: string[]; + levels?: PriorityCacheLevel[]; +}; + +type PriorityLevelConfig = { + id: string; + displayName: string; + order: number; + direct: boolean; + filterMode: string; + detectServerCountry: string; + country: string[]; + serverName: string[]; + regex: string[]; + excludeCountries: string[]; + excludeOutbounds: string[]; + excludeRegex: string[]; + outbounds?: string[]; +}; + +type ItemSettingsValue = string | string[] | PriorityLevelConfig[] | undefined; +type ItemSettings = Record; + +type UrlTestConfig = { + id: string; + code: string; + displayName: string; + settings: ItemSettings; + pinDashboard: boolean; + showDetectedCountries: boolean; +}; + +type PriorityConfig = { + id: string; + code: string; + displayName: string; + settings: ItemSettings; + pinDashboard: boolean; + healthUrl: string; + activeCheckInterval: string; + checkTimeout: string; + recoveryCheckInterval: string; + pickFastest: boolean; + switchToFasterSamePriority: boolean; + fastestCheckInterval: string; + interruptExistConnections: boolean; + showDetectedCountries: boolean; + levels: PriorityLevelConfig[]; +}; + +type ChildType = + | 'subscription_url' + | 'section_interface' + | 'urltest' + | 'priority_group' + | 'priority_level'; + +const DASHBOARD_SECTION_CACHE_DIR = '/var/run/tachyon/section-cache'; + +function getDisplayName(section: Tachyon.ConfigSection) { + return section.label || section['.name']; +} + +function getSettingsSection(configSections: Tachyon.ConfigSection[]) { + return configSections.find((section) => section['.type'] === 'settings'); +} + +function getClashApiSecret(configSections: Tachyon.ConfigSection[]) { + return getSettingsSection(configSections)?.yacd_secret_key || ''; +} + +function canFetchClashApiDirectly() { + return canUseDirectClashApi() && typeof fetch === 'function'; +} + +async function getClashApiProxies( + configSections: Tachyon.ConfigSection[], +): Promise> { + if (canFetchClashApiDirectly()) { + const secret = getClashApiSecret(configSections); + + try { + const response = await fetch(`${getClashHttpUrl()}/proxies`, { + headers: secret ? { Authorization: `Bearer ${secret}` } : undefined, + }); + + if (response.ok) { + return { + success: true, + data: (await response.json()) as ClashAPI.Proxies, + }; + } + } catch (_error) { + // Fall back to rpcd below for controllers unavailable from the browser. + } + } + + return TachyonShellMethods.getClashApiProxies(); +} + +function getListValues(value?: string[] | string) { + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return value.map((item) => `${item}`.trim()).filter(Boolean); + } + + return `${value}` + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function childSections( + configSections: Tachyon.ConfigSection[], + type: ChildType, +) { + return configSections.filter((section) => section['.type'] === type); +} + +function ownedChildSections( + parent: Tachyon.ConfigSection, + children: Tachyon.ConfigSection[], +) { + return children.filter( + (section): section is Tachyon.ConfigSection => + section.section === parent['.name'], + ); +} + +function childSectionsByOwner( + children: Tachyon.ConfigSection[], + ownerKey: keyof Tachyon.ConfigSection, + ownerValue: string, +) { + return children.filter((section) => section[ownerKey] === ownerValue); +} + +function compactSettingsMap(settings: Record) { + return Object.keys(settings).length ? JSON.stringify(settings) : undefined; +} + +function hydrateConfigSections(configSections: Tachyon.ConfigSection[]) { + const subscriptionUrls = childSections(configSections, 'subscription_url'); + const interfaces = childSections(configSections, 'section_interface'); + const urltests = childSections(configSections, 'urltest'); + const priorityGroups = childSections(configSections, 'priority_group'); + const priorityLevels = childSections(configSections, 'priority_level'); + + return configSections.map((section) => { + if (section['.type'] !== 'section') { + return section; + } + + const next: Tachyon.ConfigSection = { ...section }; + const subscriptionUrlItems = ownedChildSections(next, subscriptionUrls); + const interfaceItems = ownedChildSections(next, interfaces); + const urltestItems = ownedChildSections(next, urltests); + const priorityGroupItems = ownedChildSections(next, priorityGroups); + + if (subscriptionUrlItems.length) { + const settings: Record = {}; + next.subscription_urls = subscriptionUrlItems + .map((item) => item.url || '') + .filter(Boolean); + subscriptionUrlItems.forEach((item) => { + if (!item.url) { + return; + } + settings[item.url] = { + subscription_update_enabled: item.subscription_update_enabled, + subscription_update_interval: item.subscription_update_interval, + download_via_proxy_enabled: item.download_via_proxy_enabled, + download_via_proxy_section: item.download_via_proxy_section, + auto_user_agent: item.auto_user_agent, + user_agent: item.user_agent, + auto_hwid: item.auto_hwid, + hwid: item.hwid, + show_dashboard_metadata: item.show_dashboard_metadata, + prefix_nodes: item.prefix_nodes, + node_prefix: item.node_prefix, + include_urltest_groups: item.include_urltest_groups, + hide_urltest_group_outbounds: item.hide_urltest_group_outbounds, + hide_detour_outbounds: item.hide_detour_outbounds, + }; + }); + next.subscription_url_settings = compactSettingsMap(settings); + } + + if (interfaceItems.length) { + next.interfaces = interfaceItems + .map((item) => item.name || '') + .filter(Boolean); + } + + if (urltestItems.length) { + const settings: Record = {}; + next.urltests = urltestItems.map((item) => item['.name']); + urltestItems.forEach((item) => { + settings[item['.name']] = { + display_name: item.name || item.display_name, + urltest_check_interval: item.check_interval, + urltest_tolerance: item.tolerance, + urltest_testing_url: item.testing_url, + idle_timeout: item.idle_timeout, + interrupt_exist_connections: item.interrupt_exist_connections, + pin_dashboard: item.pin_dashboard, + urltest_filter_mode: item.filter_mode, + detect_server_country: item.detect_server_country, + urltest_include_countries: item.include_countries, + urltest_include_outbounds: item.include_outbounds, + urltest_include_regex: item.include_regex, + urltest_exclude_countries: item.exclude_countries, + urltest_exclude_outbounds: item.exclude_outbounds, + urltest_exclude_regex: item.exclude_regex, + }; + }); + next.urltest_settings = compactSettingsMap(settings); + } + + if (priorityGroupItems.length) { + const settings: Record = {}; + next.priority_groups = priorityGroupItems.map((item) => item['.name']); + priorityGroupItems.forEach((item) => { + const groupId = item['.name']; + const levels = childSectionsByOwner(priorityLevels, 'group', groupId) + .map( + (level, index): PriorityLevelConfig => ({ + id: level['.name'], + displayName: level.name || level['.name'], + order: Number.parseInt(level.order || `${index}`, 10) || 0, + direct: level.direct === '1', + filterMode: level.filter_mode || 'include', + detectServerCountry: level.detect_server_country || 'flag_emoji', + country: getListValues(level.country), + serverName: getListValues(level.server_name), + regex: getListValues(level.regex), + excludeCountries: getListValues(level.exclude_countries), + excludeOutbounds: getListValues(level.exclude_outbounds), + excludeRegex: getListValues(level.exclude_regex), + }), + ) + .sort((left, right) => + left.order === right.order + ? left.id.localeCompare(right.id) + : left.order - right.order, + ); + + settings[groupId] = { + name: item.name, + health_url: item.health_url, + active_check_interval: item.active_check_interval, + check_timeout: item.check_timeout, + recovery_check_interval: item.recovery_check_interval, + pick_fastest: item.pick_fastest, + switch_to_faster_same_priority: item.switch_to_faster_same_priority, + fastest_check_interval: item.fastest_check_interval, + interrupt_exist_connections: item.interrupt_exist_connections, + pin_dashboard: item.pin_dashboard, + levels, + }; + }); + next.priority_group_settings = compactSettingsMap(settings); + } + + return next; + }); +} + +function getManualProxyLinks(section: Tachyon.ConfigSection) { + return getListValues(section.selector_proxy_links); +} + +function getConnectionInterfaces(section: Tachyon.ConfigSection) { + const values = getListValues(section.interfaces); + return values.length ? values : getListValues(section.interface); +} + +function getJsonOutbounds(section: Tachyon.ConfigSection) { + const values = getListValues(section.outbound_jsons); + return values.length ? values : getListValues(section.outbound_json); +} + +function isConnectionAction(action?: string) { + return Boolean( + action && ['connection', 'proxy', 'outbound', 'vpn'].includes(action), + ); +} + +function hasSubscriptionSources(section: Tachyon.ConfigSection) { + return getSubscriptionSourceCount(section) > 0; +} + +function getSubscriptionSourceCount(section: Tachyon.ConfigSection) { + return getListValues(section.subscription_urls).length; +} + +function shouldSortByLatency(section: Tachyon.ConfigSection) { + return section.sort_by_latency === '1'; +} + +function hasConfiguredUrlTestList(section: Tachyon.ConfigSection) { + return getListValues(section.urltests).length > 0; +} + +function hasConfiguredPriorityList(section: Tachyon.ConfigSection) { + return getListValues(section.priority_groups).length > 0; +} + +function getUrlTestIds(section: Tachyon.ConfigSection) { + const values = getListValues(section.urltests); + return values.length + ? values + : section.urltest_enabled === '1' + ? ['urltest'] + : []; +} + +function isUrlTestEnabled(section: Tachyon.ConfigSection) { + return getUrlTestIds(section).length > 0; +} + +function shouldUseProxyGroup(section: Tachyon.ConfigSection) { + return ( + getManualProxyLinks(section).length > 0 || + hasSubscriptionSources(section) || + getConnectionInterfaces(section).length > 0 || + getJsonOutbounds(section).length > 0 || + isUrlTestEnabled(section) || + hasConfiguredPriorityList(section) + ); +} + +function getSectionProxyConfigType(section: Tachyon.ConfigSection) { + if (hasSubscriptionSources(section)) { + return 'subscription' as const; + } + + if (isUrlTestEnabled(section) && shouldUseProxyGroup(section)) { + return 'urltest' as const; + } + + if (getManualProxyLinks(section).length > 0) { + return 'selector' as const; + } + + if (getJsonOutbounds(section).length > 0) { + return 'outbound' as const; + } + + if (getConnectionInterfaces(section).length > 0) { + return 'interface' as const; + } + + if (hasConfiguredPriorityList(section)) { + return 'selector' as const; + } + + return undefined; +} + +function getJsonOutboundDisplayName(section: Tachyon.ConfigSection) { + try { + const parsedOutbound = JSON.parse(section.outbound_json || '{}'); + return parsedOutbound?.tag ? decodeURIComponent(parsedOutbound.tag) : ''; + } catch (_error) { + return ''; + } +} + +function buildManualLinkByCode(section: Tachyon.ConfigSection) { + const sectionName = section['.name']; + + return new Map( + getManualProxyLinks(section).map((link, index) => [ + getOutboundTagBySection(`${sectionName}-${index + 1}`), + link, + ]), + ); +} + +function getProxyEntryByCode(proxies: ClashProxyEntry[]) { + return new Map(proxies.map((proxy) => [proxy.code, proxy])); +} + +function uniqueCodes(codes: string[]) { + return Array.from(new Set(codes.filter(Boolean))); +} + +function isSelectorOutbound(outbound: Tachyon.Outbound) { + return outbound.type?.toLowerCase() === 'selector'; +} + +function isUrlTestProxyEntry(entry?: ClashProxyEntry) { + return entry?.value?.type?.toLowerCase() === 'urltest'; +} + +function getLatencySortValue(outbound: { latency: number }) { + const latency = Number(outbound.latency); + + return Number.isFinite(latency) && latency > 0 + ? latency + : Number.POSITIVE_INFINITY; +} + +function sortOutboundsForDashboard( + outbounds: Tachyon.Outbound[], + options: { + pinnedCode?: string; + pinnedCodes?: string[]; + sortByLatency?: boolean; + } = {}, +) { + const pinnedCodes = [ + ...(options.pinnedCode ? [options.pinnedCode] : []), + ...(options.pinnedCodes || []), + ].filter(Boolean); + const pinnedRank = new Map( + pinnedCodes.map((code, index) => [code, index] as const), + ); + const sortByLatency = options.sortByLatency === true; + + return outbounds + .map((outbound, index) => ({ outbound, index })) + .sort((left, right) => { + const leftPinned = pinnedRank.has(left.outbound.code); + const rightPinned = pinnedRank.has(right.outbound.code); + + if (leftPinned !== rightPinned) { + return leftPinned ? -1 : 1; + } + + if (leftPinned && rightPinned) { + const rankDiff = + (pinnedRank.get(left.outbound.code) ?? 0) - + (pinnedRank.get(right.outbound.code) ?? 0); + + if (rankDiff !== 0) { + return rankDiff; + } + } + + if (sortByLatency) { + const latencyDiff = + getLatencySortValue(left.outbound) - + getLatencySortValue(right.outbound); + + if (latencyDiff !== 0) { + return latencyDiff; + } + } + + return left.index - right.index; + }) + .map((item) => item.outbound); +} + +function sortUrlTestMembers(outbounds: Tachyon.UrlTestMember[]) { + return outbounds + .map((outbound, index) => ({ outbound, index })) + .sort((left, right) => { + if (left.outbound.selected !== right.outbound.selected) { + return left.outbound.selected ? -1 : 1; + } + + const latencyDiff = + getLatencySortValue(left.outbound) - + getLatencySortValue(right.outbound); + + if (latencyDiff !== 0) { + return latencyDiff; + } + + return left.index - right.index; + }) + .map((item) => item.outbound); +} + +function isSafeSectionName(sectionName: string) { + return /^[A-Za-z0-9_-]+$/.test(sectionName); +} + +function objectMap(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, item]) => typeof item === 'string') + .map(([key, item]) => [key, item as string]), + ); +} + +function itemSettingsMap(value?: string): Record { + if (!value) { + return {}; + } + + try { + const parsed = JSON.parse(value) as unknown; + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + return Object.fromEntries( + Object.entries(parsed as Record).filter( + ([, item]) => item && typeof item === 'object' && !Array.isArray(item), + ), + ) as Record; + } catch (_error) { + return {}; + } +} + +function itemSettingString( + settings: ItemSettings | undefined, + key: string, + fallback = '', +) { + const value = settings?.[key]; + return typeof value === 'string' ? value : fallback; +} + +function itemSettingBoolean( + settings: ItemSettings | undefined, + key: string, + fallback: boolean, +) { + const value = settings?.[key]; + if (value === undefined || value === null || value === '') { + return fallback; + } + + return value === '1' || value === 'true'; +} + +function isUrlTestFilteringEnabled(settings: ItemSettings | undefined) { + return ['exclude', 'include', 'mixed'].includes( + itemSettingString(settings, 'urltest_filter_mode', 'disabled'), + ); +} + +function getUrlTestTag(sectionName: string, id: string) { + return getOutboundTagBySection( + id === 'urltest' + ? `${sectionName}-urltest` + : `${sectionName}-urltest-${id}`, + ); +} + +function getPriorityTag(sectionName: string, id: string) { + return getOutboundTagBySection(`${sectionName}-priority-${id}`); +} + +function getUrlTestDisplayName( + section: Tachyon.ConfigSection, + id: string, + settings: ItemSettings | undefined, +) { + return itemSettingString( + settings, + 'display_name', + id === 'urltest' && !hasConfiguredUrlTestList(section) ? _('Fastest') : id, + ); +} + +function getUrlTestConfigs(section: Tachyon.ConfigSection): UrlTestConfig[] { + const settingsMap = itemSettingsMap(section.urltest_settings); + const sectionName = section['.name']; + + return getUrlTestIds(section).map((id) => { + const settings = settingsMap[id] || {}; + const filteringEnabled = isUrlTestFilteringEnabled(settings); + + return { + id, + code: getUrlTestTag(sectionName, id), + displayName: getUrlTestDisplayName(section, id, settings), + settings, + pinDashboard: itemSettingBoolean(settings, 'pin_dashboard', true), + showDetectedCountries: + filteringEnabled && + itemSettingString(settings, 'detect_server_country', 'flag_emoji') === + 'country_is', + }; + }); +} + +function priorityLevelConfigsFromSettings( + settings: ItemSettings | undefined, +): PriorityLevelConfig[] { + const value = settings?.levels; + + if (!Array.isArray(value)) { + return []; + } + + return value + .flatMap((item, index) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return []; + } + + const level = item as Partial & + Record; + const id = `${level.id || ''}`.trim(); + + if (!id) { + return []; + } + + return [ + { + id, + displayName: `${level.displayName || id}`, + order: + typeof level.order === 'number' && Number.isFinite(level.order) + ? level.order + : index, + direct: Boolean(level.direct), + filterMode: `${level.filterMode || 'include'}` || 'include', + detectServerCountry: + `${level.detectServerCountry || 'flag_emoji'}` || 'flag_emoji', + country: getListValues(level.country as string[] | string), + serverName: getListValues(level.serverName as string[] | string), + regex: getListValues(level.regex as string[] | string), + excludeCountries: getListValues( + level.excludeCountries as string[] | string, + ), + excludeOutbounds: getListValues( + level.excludeOutbounds as string[] | string, + ), + excludeRegex: getListValues(level.excludeRegex as string[] | string), + outbounds: getListValues(level.outbounds as string[] | string), + }, + ]; + }) + .sort((left, right) => + left.order === right.order + ? left.id.localeCompare(right.id) + : left.order - right.order, + ); +} + +function getPriorityGroupIds(section: Tachyon.ConfigSection) { + return getListValues(section.priority_groups); +} + +function getPriorityConfigs(section: Tachyon.ConfigSection): PriorityConfig[] { + const settingsMap = itemSettingsMap(section.priority_group_settings); + const sectionName = section['.name']; + + return getPriorityGroupIds(section).map((id) => { + const settings = settingsMap[id] || {}; + const levels = priorityLevelConfigsFromSettings(settings); + + return { + id, + code: getPriorityTag(sectionName, id), + displayName: itemSettingString(settings, 'name', id), + settings, + pinDashboard: itemSettingBoolean(settings, 'pin_dashboard', true), + healthUrl: itemSettingString( + settings, + 'health_url', + 'https://www.gstatic.com/generate_204', + ), + activeCheckInterval: itemSettingString( + settings, + 'active_check_interval', + '5s', + ), + checkTimeout: itemSettingString(settings, 'check_timeout', '2s'), + recoveryCheckInterval: itemSettingString( + settings, + 'recovery_check_interval', + '15s', + ), + pickFastest: itemSettingBoolean(settings, 'pick_fastest', false), + switchToFasterSamePriority: itemSettingBoolean( + settings, + 'switch_to_faster_same_priority', + false, + ), + fastestCheckInterval: itemSettingString( + settings, + 'fastest_check_interval', + '3m', + ), + interruptExistConnections: itemSettingBoolean( + settings, + 'interrupt_exist_connections', + true, + ), + showDetectedCountries: levels.some( + (level) => level.detectServerCountry === 'country_is', + ), + levels, + }; + }); +} + +async function readDashboardSectionCache( + sectionName: string, +): Promise { + if (!isSafeSectionName(sectionName)) { + return undefined; + } + + try { + const raw = await fs.read( + `${DASHBOARD_SECTION_CACHE_DIR}/${sectionName}.json`, + ); + const parsed = JSON.parse(raw) as DashboardSectionCache; + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + + return parsed; + } catch (_error) { + return undefined; + } +} + +function getUrlTestGroups(dashboardCache?: DashboardSectionCache) { + const groups = dashboardCache?.urltestGroups; + + if (!groups || typeof groups !== 'object' || Array.isArray(groups)) { + return {}; + } + + return groups; +} + +function getPriorityGroups(dashboardCache?: DashboardSectionCache) { + const groups = dashboardCache?.priorityGroups; + + if (!groups || typeof groups !== 'object' || Array.isArray(groups)) { + return {}; + } + + return groups; +} + +function getOutboundDisplayName( + code: string, + entry: ClashProxyEntry | undefined, + link: string, + outboundMetadata?: Tachyon.GetOutboundMetadata, + preferMetadata = false, +) { + const metadataName = outboundMetadata?.names?.[code]; + + return ( + (preferMetadata ? metadataName : getProxyUrlName(link)) || + (preferMetadata ? getProxyUrlName(link) : metadataName) || + entry?.value?.name || + code + ); +} + +function buildUrlTestInfo({ + code, + displayName, + entry, + groupCache, + proxyByCode, + manualLinkByCode, + cachedProxyLinks, + outboundMetadata, + showDetectedCountries, +}: { + code: string; + displayName: string; + entry?: ClashProxyEntry; + groupCache?: UrlTestCacheGroup; + proxyByCode: Map; + manualLinkByCode: Map; + cachedProxyLinks: Map; + outboundMetadata?: Tachyon.GetOutboundMetadata; + showDetectedCountries: boolean; +}): Tachyon.UrlTestInfo { + const childCodes = uniqueCodes( + groupCache?.outbounds?.length + ? groupCache.outbounds + : entry?.value.all || [], + ); + const selectedCode = entry?.value.now || ''; + const outbounds = sortUrlTestMembers( + childCodes.flatMap((childCode) => { + const childEntry = proxyByCode.get(childCode); + const link = + manualLinkByCode.get(childCode) || + cachedProxyLinks.get(childCode) || + ''; + const canCopyLink = isCopyableProxyLink(link); + + return [ + { + code: childCode, + displayName: getOutboundDisplayName( + childCode, + childEntry, + link, + outboundMetadata, + cachedProxyLinks.has(childCode), + ), + latency: childEntry?.value?.history?.[0]?.delay || 0, + type: childEntry?.value?.type || '', + selected: selectedCode === childCode, + link, + canCopyLink, + country: showDetectedCountries + ? outboundMetadata?.countries?.[childCode] + : undefined, + }, + ]; + }), + ); + const selectedName = + outbounds.find((outbound) => outbound.code === selectedCode)?.displayName || + selectedCode; + + return { + code, + displayName: groupCache?.displayName || displayName, + selectedCode: selectedCode || undefined, + selectedName: selectedName || undefined, + url: groupCache?.url, + interval: groupCache?.interval, + tolerance: groupCache?.tolerance, + idleTimeout: groupCache?.idle_timeout || '30m', + interruptExistConnections: groupCache?.interrupt_exist_connections, + outbounds, + }; +} + +function buildPriorityInfo({ + config, + entry, + groupCache, + proxyByCode, + manualLinkByCode, + cachedProxyLinks, + outboundMetadata, + showDetectedCountries, +}: { + config: PriorityConfig; + entry?: ClashProxyEntry; + groupCache?: PriorityCacheGroup; + proxyByCode: Map; + manualLinkByCode: Map; + cachedProxyLinks: Map; + outboundMetadata?: Tachyon.GetOutboundMetadata; + showDetectedCountries: boolean; +}): Tachyon.PriorityInfo { + const selectedCode = entry?.value.now || ''; + const cacheLevels = Array.isArray(groupCache?.levels) + ? groupCache.levels + : []; + const configLevelById = new Map( + config.levels.map((level) => [level.id, level]), + ); + const levels = ( + cacheLevels.length + ? cacheLevels.map((level, index): PriorityLevelConfig => { + const id = `${level.id || ''}`; + const configLevel = id ? configLevelById.get(id) : undefined; + + return { + id: id || configLevel?.id || `level-${index + 1}`, + displayName: + level.displayName || configLevel?.displayName || `${index + 1}`, + order: + typeof level.order === 'number' && Number.isFinite(level.order) + ? level.order + : (configLevel?.order ?? index), + direct: level.direct ?? configLevel?.direct ?? false, + filterMode: + level.filter_mode || configLevel?.filterMode || 'include', + detectServerCountry: + level.detect_server_country || + configLevel?.detectServerCountry || + 'flag_emoji', + country: configLevel?.country || [], + serverName: configLevel?.serverName || [], + regex: configLevel?.regex || [], + excludeCountries: configLevel?.excludeCountries || [], + excludeOutbounds: configLevel?.excludeOutbounds || [], + excludeRegex: configLevel?.excludeRegex || [], + outbounds: uniqueCodes(level.outbounds || []), + }; + }) + : config.levels.map((level) => ({ + ...level, + outbounds: uniqueCodes(level.outbounds || []), + })) + ).sort((left, right) => + left.order === right.order + ? left.id.localeCompare(right.id) + : left.order - right.order, + ); + const outbounds = levels.flatMap((level, levelIndex) => { + const members = uniqueCodes(level.outbounds || []).map((childCode) => { + const childEntry = proxyByCode.get(childCode); + const link = + manualLinkByCode.get(childCode) || + cachedProxyLinks.get(childCode) || + ''; + const canCopyLink = isCopyableProxyLink(link); + + return { + code: childCode, + displayName: getOutboundDisplayName( + childCode, + childEntry, + link, + outboundMetadata, + cachedProxyLinks.has(childCode), + ), + latency: childEntry?.value?.history?.[0]?.delay || 0, + type: childEntry?.value?.type || '', + selected: selectedCode === childCode, + link, + canCopyLink, + country: showDetectedCountries + ? outboundMetadata?.countries?.[childCode] + : undefined, + levelIndex, + levelName: level.displayName, + levelId: level.id, + }; + }); + + if (!config.pickFastest) { + return members; + } + + return members + .map((outbound, index) => ({ outbound, index })) + .sort((left, right) => { + const latencyDiff = + getLatencySortValue(left.outbound) - + getLatencySortValue(right.outbound); + + return latencyDiff !== 0 ? latencyDiff : left.index - right.index; + }) + .map((item) => item.outbound); + }); + const selectedName = + outbounds.find((outbound) => outbound.code === selectedCode)?.displayName || + selectedCode; + + return { + code: config.code, + displayName: groupCache?.displayName || config.displayName, + selectedCode: selectedCode || undefined, + selectedName: selectedName || undefined, + healthUrl: groupCache?.health_url || config.healthUrl, + activeCheckInterval: + groupCache?.active_check_interval || config.activeCheckInterval, + checkTimeout: groupCache?.check_timeout || config.checkTimeout, + recoveryCheckInterval: + groupCache?.recovery_check_interval || config.recoveryCheckInterval, + pickFastest: groupCache?.pick_fastest ?? config.pickFastest, + switchToFasterSamePriority: + groupCache?.switch_to_faster_same_priority ?? + config.switchToFasterSamePriority, + fastestCheckInterval: + groupCache?.fastest_check_interval || config.fastestCheckInterval, + interruptExistConnections: + groupCache?.interrupt_exist_connections ?? + config.interruptExistConnections, + outbounds, + }; +} + +function buildProxyGroupOutbounds( + section: Tachyon.ConfigSection, + proxies: ClashProxyEntry[], + outboundMetadata?: Tachyon.GetOutboundMetadata, + urltestGroups: Record = {}, + priorityGroups: Record = {}, + cachedProxyLinks: Map = new Map(), +) { + const sectionName = section['.name']; + const proxyByCode = getProxyEntryByCode(proxies); + const selectorTag = getOutboundTagBySection(sectionName); + const selector = proxyByCode.get(selectorTag); + const urlTestConfigs = getUrlTestConfigs(section); + const urlTestConfigByCode = new Map( + urlTestConfigs.map((config) => [config.code, config]), + ); + const priorityConfigs = getPriorityConfigs(section); + const priorityConfigByCode = new Map( + priorityConfigs.map((config) => [config.code, config]), + ); + const urlTestEntries = urlTestConfigs.map((config) => ({ + config, + entry: proxyByCode.get(config.code), + })); + const priorityEntries = priorityConfigs.map((config) => ({ + config, + entry: proxyByCode.get(config.code), + })); + const manualLinkByCode = buildManualLinkByCode(section); + const selectorCodes = selector?.value?.all ?? []; + const urlTestCodes = urlTestConfigs.map((config) => config.code); + const priorityCodes = priorityConfigs.map((config) => config.code); + const showDetectedCountries = + urlTestConfigs.some((config) => config.showDetectedCountries) || + priorityConfigs.some((config) => config.showDetectedCountries); + const builtInUrltestCode = urlTestCodes[0] || ''; + const fallbackCodes = uniqueCodes([ + ...urlTestCodes, + ...priorityCodes, + ...urlTestEntries.flatMap(({ entry }) => entry?.value.all || []), + ...priorityEntries.flatMap(({ entry }) => entry?.value.all || []), + ]); + const groupCodes = uniqueCodes([ + ...(selectorCodes.length ? selectorCodes : fallbackCodes), + ...urlTestCodes, + ...priorityCodes, + ]); + + const outbounds = uniqueCodes(groupCodes).flatMap((code) => { + const item = proxyByCode.get(code); + const urlTestConfig = urlTestConfigByCode.get(code); + const priorityConfig = priorityConfigByCode.get(code); + + if (!item && !urlTestConfig && !priorityConfig) { + return []; + } + + const link = manualLinkByCode.get(code) || cachedProxyLinks.get(code) || ''; + const canCopyLink = isCopyableProxyLink(link); + const displayName = + priorityConfig?.displayName || + urlTestConfig?.displayName || + getOutboundDisplayName( + code, + item, + link, + outboundMetadata, + cachedProxyLinks.has(code), + ); + const isRuntimeUrlTest = isUrlTestProxyEntry(item); + + return [ + { + code, + displayName, + latency: item?.value.history?.[0]?.delay || 0, + type: priorityConfig ? 'Priority' : item?.value.type || 'URLTest', + selected: selector?.value?.now === code, + link, + canCopyLink, + country: showDetectedCountries + ? outboundMetadata?.countries?.[code] + : undefined, + runtimeAvailable: item ? undefined : false, + urlTestInfo: + urlTestConfig || isRuntimeUrlTest + ? buildUrlTestInfo({ + code, + displayName, + entry: item, + groupCache: urltestGroups[code], + proxyByCode, + manualLinkByCode, + cachedProxyLinks, + outboundMetadata, + showDetectedCountries: + urlTestConfig?.showDetectedCountries || showDetectedCountries, + }) + : undefined, + priorityInfo: priorityConfig + ? buildPriorityInfo({ + config: priorityConfig, + entry: item, + groupCache: priorityGroups[code], + proxyByCode, + manualLinkByCode, + cachedProxyLinks, + outboundMetadata, + showDetectedCountries: priorityConfig.showDetectedCountries, + }) + : undefined, + }, + ]; + }); + + const sortedOutbounds = sortOutboundsForDashboard(outbounds, { + pinnedCodes: [ + ...urlTestEntries + .filter(({ config }) => config.pinDashboard) + .map(({ config }) => config.code), + ...priorityEntries + .filter(({ config }) => config.pinDashboard) + .map(({ config }) => config.code), + ], + sortByLatency: shouldSortByLatency(section), + }); + const latencyTestCodes = sortedOutbounds + .filter( + (outbound) => + outbound.runtimeAvailable !== false && + !isSelectorOutbound(outbound) && + !outbound.priorityInfo, + ) + .map((outbound) => outbound.code); + + return { + selector, + latencyTestCode: selector?.code || builtInUrltestCode, + latencyTestCodes: + latencyTestCodes.length > 0 ? latencyTestCodes : undefined, + outbounds: sortedOutbounds, + }; +} + +function metadataMatchesCurrentSource( + sectionName: string, + sourceCount: number, + metadata: Tachyon.SubscriptionMetadata, +) { + const legacyMetadata = metadata as Tachyon.SubscriptionMetadata & { + source_index?: number; + source_section?: string; + }; + const sourceIndex = metadata.sourceIndex ?? legacyMetadata.source_index; + const sourceSection = + metadata.sourceSection || legacyMetadata.source_section || ''; + const hasSourceIndex = typeof sourceIndex === 'number'; + const hasSourceSection = sourceSection !== ''; + + if (!hasSourceIndex && !hasSourceSection) { + return sourceCount <= 1; + } + + if (sourceCount > 1 && !hasSourceSection) { + return false; + } + + if (hasSourceIndex && (sourceIndex < 1 || sourceIndex > sourceCount)) { + return false; + } + + if (hasSourceSection) { + const expectedSourcePrefix = `${sectionName}-subscription-`; + + if (!sourceSection.startsWith(expectedSourcePrefix)) { + return false; + } + + const sourceSectionIndex = Number( + sourceSection.slice(expectedSourcePrefix.length), + ); + + if ( + !Number.isInteger(sourceSectionIndex) || + sourceSectionIndex < 1 || + sourceSectionIndex > sourceCount + ) { + return false; + } + + if (hasSourceIndex && sourceIndex !== sourceSectionIndex) { + return false; + } + } + + return true; +} + +function getSubscriptionMetadataSourceIndex( + sectionName: string, + sourceCount: number, + metadata: Tachyon.SubscriptionMetadata, +) { + const legacyMetadata = metadata as Tachyon.SubscriptionMetadata & { + source_index?: number; + source_section?: string; + }; + const sourceIndex = metadata.sourceIndex ?? legacyMetadata.source_index; + + if (typeof sourceIndex === 'number') { + return sourceIndex; + } + + const sourceSection = + metadata.sourceSection || legacyMetadata.source_section || ''; + const expectedSourcePrefix = `${sectionName}-subscription-`; + + if (sourceSection.startsWith(expectedSourcePrefix)) { + const parsed = Number(sourceSection.slice(expectedSourcePrefix.length)); + return Number.isInteger(parsed) ? parsed : undefined; + } + + return sourceCount <= 1 ? 1 : undefined; +} + +function isSubscriptionMetadataVisible( + section: Tachyon.ConfigSection, + sourceCount: number, + metadata: Tachyon.SubscriptionMetadata, +) { + const sourceIndex = getSubscriptionMetadataSourceIndex( + section['.name'], + sourceCount, + metadata, + ); + + if (!sourceIndex || sourceIndex < 1 || sourceIndex > sourceCount) { + return true; + } + + const sourceEntry = getListValues(section.subscription_urls)[sourceIndex - 1]; + const settings = itemSettingsMap(section.subscription_url_settings)[ + sourceEntry + ]; + + return settings?.show_dashboard_metadata !== '0'; +} + +function getSubscriptionMetadata( + section: Tachyon.ConfigSection, + sourceCount: number, + dashboardCache?: DashboardSectionCache, +) { + if (!dashboardCache?.subscriptionMetadata) { + return undefined; + } + + const metadataItems = Array.isArray(dashboardCache.subscriptionMetadata) + ? dashboardCache.subscriptionMetadata + : [dashboardCache.subscriptionMetadata]; + const visibleMetadataItems = metadataItems.filter( + (metadata) => + metadata && + Object.keys(metadata).length > 1 && + metadataMatchesCurrentSource(section['.name'], sourceCount, metadata) && + isSubscriptionMetadataVisible(section, sourceCount, metadata), + ); + + if (visibleMetadataItems.length > 0) { + return visibleMetadataItems; + } + + return undefined; +} + +function getOutboundMetadata(dashboardCache?: DashboardSectionCache) { + const metadata = dashboardCache?.outboundMetadata; + + if (!metadata || typeof metadata !== 'object') { + return undefined; + } + + return { + names: objectMap(metadata.names), + countries: objectMap(metadata.countries), + }; +} + +function getCachedProxyLinks(dashboardCache?: DashboardSectionCache) { + return new Map( + Object.entries(objectMap(dashboardCache?.links)).filter(([, link]) => + isCopyableProxyLink(link), + ), + ); +} + +export async function getDashboardSections( + options: IGetDashboardSectionsOptions = {}, +): Promise { + const includeSubscriptionCopyState = + options.includeSubscriptionCopyState ?? true; + const configSections = hydrateConfigSections(await getConfigSections()); + const clashProxies = await getClashApiProxies(configSections); + + if (!clashProxies.success || !clashProxies.data?.proxies) { + return { + success: false, + data: [], + }; + } + + const proxies = Object.entries(clashProxies.data.proxies).map( + ([key, value]) => ({ + code: key, + value, + }), + ); + const data = await Promise.all( + configSections + .filter( + (section) => + section.enabled !== '0' && isConnectionAction(section.action), + ) + .map(async (section) => { + const displayName = getDisplayName(section); + const sectionName = section['.name']; + const sectionAction = section.action; + const proxyConfigType = getSectionProxyConfigType(section); + + if (isConnectionAction(sectionAction) && shouldUseProxyGroup(section)) { + const subscriptionSourceCount = getSubscriptionSourceCount(section); + const subscriptionEnabled = subscriptionSourceCount > 0; + const dashboardCache = await readDashboardSectionCache(sectionName); + const outboundMetadata = getOutboundMetadata(dashboardCache); + const subscriptionMetadata = subscriptionEnabled + ? getSubscriptionMetadata( + section, + subscriptionSourceCount, + dashboardCache, + ) + : undefined; + const cachedProxyLinks = includeSubscriptionCopyState + ? getCachedProxyLinks(dashboardCache) + : new Map(); + const urltestGroups = getUrlTestGroups(dashboardCache); + const priorityGroups = getPriorityGroups(dashboardCache); + const { selector, latencyTestCode, latencyTestCodes, outbounds } = + buildProxyGroupOutbounds( + section, + proxies, + outboundMetadata, + urltestGroups, + priorityGroups, + cachedProxyLinks, + ); + + return { + withTagSelect: true, + code: selector?.code || sectionName, + sectionName, + displayName, + action: sectionAction, + latencyTestCode, + latencyTestCodes, + proxyConfigType, + subscriptionSourceCount, + subscriptionMetadata, + outbounds, + }; + } + + if (sectionAction === 'vpn') { + const outboundTag = getOutboundTagBySection(sectionName); + const outbound = proxies.find((proxy) => proxy.code === outboundTag); + + return { + withTagSelect: false, + code: outbound?.code || sectionName, + sectionName, + displayName, + action: sectionAction, + latencyTestTimeout: '10000', + outbounds: [ + { + code: outbound?.code || sectionName, + displayName: section.interface || outbound?.value?.name || '', + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || '', + selected: true, + canCopyLink: false, + runtimeAvailable: Boolean(outbound), + }, + ], + }; + } + + if (sectionAction === 'outbound') { + const outboundTag = getOutboundTagBySection(sectionName); + const outbound = proxies.find((proxy) => proxy.code === outboundTag); + + return { + withTagSelect: false, + code: outbound?.code || sectionName, + sectionName, + displayName, + action: sectionAction, + outbounds: [ + { + code: outbound?.code || sectionName, + displayName: + getJsonOutboundDisplayName(section) || + outbound?.value?.name || + '', + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || '', + selected: true, + canCopyLink: false, + }, + ], + }; + } + + return { + withTagSelect: false, + code: sectionName, + sectionName, + displayName, + action: sectionAction, + outbounds: [], + }; + }), + ); + + return { + success: true, + data, + }; +} diff --git a/fe-app-tachyon/src/tachyon/methods/custom/index.ts b/fe-app-tachyon/src/tachyon/methods/custom/index.ts new file mode 100644 index 00000000..1c2d4584 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/custom/index.ts @@ -0,0 +1,9 @@ +import { getConfigSections } from './getConfigSections'; +import { getDashboardSections } from './getDashboardSections'; +import { getClashApiSecret } from './getClashApiSecret'; + +export const CustomTachyonMethods = { + getConfigSections, + getDashboardSections, + getClashApiSecret, +}; diff --git a/fe-app-tachyon/src/tachyon/methods/custom/tests/getDashboardSections.test.ts b/fe-app-tachyon/src/tachyon/methods/custom/tests/getDashboardSections.test.ts new file mode 100644 index 00000000..b908818e --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/custom/tests/getDashboardSections.test.ts @@ -0,0 +1,1071 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getConfigSections: vi.fn(), + getClashApiProxies: vi.fn(), + canUseDirectClashApi: vi.fn(), + fsRead: vi.fn(), +})); + +vi.mock('../getConfigSections', () => ({ + getConfigSections: mocks.getConfigSections, +})); + +vi.mock('../../shell', () => ({ + TachyonShellMethods: { + getClashApiProxies: mocks.getClashApiProxies, + }, +})); + +vi.mock('../../../../helpers', () => ({ + canUseDirectClashApi: mocks.canUseDirectClashApi, + getClashHttpUrl: () => 'http://router.example:9090', + getProxyUrlName: (link?: string) => + link?.includes('#') ? decodeURIComponent(link.split('#').pop() || '') : '', + isCopyableProxyLink: (link?: string) => Boolean(link), +})); + +import { getDashboardSections } from '../getDashboardSections'; +import { ClashAPI, Tachyon } from '../../../types'; + +function proxy( + type: string, + options: Partial = {}, +): ClashAPI.ProxyBase { + return { + type, + name: options.name || '', + udp: true, + history: options.history || [], + now: options.now, + all: options.all, + }; +} + +function proxySection( + options: Partial = {}, +): Tachyon.ConfigSection { + return { + '.name': 'main', + '.type': 'section', + enabled: '1', + action: 'proxy', + selector_proxy_links: ['vless://example#one'], + urltests: ['urltest'], + urltest_settings: urlTestSettings('urltest', { + display_name: 'Fastest', + urltest_filter_mode: 'exclude', + }), + ...options, + }; +} + +function urlTestSettings( + id: string, + settings: Record = {}, +): string { + return JSON.stringify({ + [id]: settings, + }); +} + +function priorityGroup( + id: string, + options: Partial = {}, +): Tachyon.ConfigSection { + return { + '.name': id, + '.type': 'priority_group', + section: 'main', + name: 'Codex Priority', + health_url: 'https://priority.example/204', + active_check_interval: '5s', + check_timeout: '2s', + recovery_check_interval: '15s', + pick_fastest: '0', + switch_to_faster_same_priority: '0', + fastest_check_interval: '3m', + interrupt_exist_connections: '1', + pin_dashboard: '1', + ...options, + }; +} + +function priorityLevel( + id: string, + options: Partial = {}, +): Tachyon.ConfigSection { + return { + '.name': id, + '.type': 'priority_level', + group: 'pg_main', + name: 'Germany', + order: '0', + detect_server_country: 'flag_emoji', + regex: ['Included'], + ...options, + }; +} + +const clashProxies: Record = { + 'main-out': proxy('Selector', { + name: 'main-out', + now: 'main-1-out', + all: ['main-1-out', 'main-2-out', 'main-3-out', 'main-urltest-out'], + }), + 'main-urltest-out': proxy('URLTest', { + name: 'main-urltest-out', + history: [{ time: '2026-05-27T00:00:00Z', delay: 10 }], + all: ['main-1-out', 'main-3-out'], + }), + 'main-1-out': proxy('VLESS', { + name: 'Included 1', + history: [{ time: '2026-05-27T00:00:00Z', delay: 100 }], + }), + 'main-2-out': proxy('VLESS', { + name: 'Filtered 2', + history: [{ time: '2026-05-27T00:00:00Z', delay: 200 }], + }), + 'main-3-out': proxy('VLESS', { + name: 'Included 3', + history: [{ time: '2026-05-27T00:00:00Z', delay: 300 }], + }), +}; + +describe('getDashboardSections', () => { + beforeEach(() => { + mocks.getConfigSections.mockReset(); + mocks.getClashApiProxies.mockReset(); + mocks.canUseDirectClashApi.mockReset(); + mocks.fsRead.mockReset(); + mocks.fsRead.mockRejectedValue(new Error('cache miss')); + vi.stubGlobal('fs', { read: mocks.fsRead }); + vi.stubGlobal('window', undefined); + vi.stubGlobal('fetch', undefined); + + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { proxies: clashProxies }, + }); + mocks.canUseDirectClashApi.mockReturnValue(false); + }); + + it('shows the full selector group by default', async () => { + mocks.getConfigSections.mockResolvedValue([proxySection()]); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect(section.latencyTestCode).toBe('main-out'); + expect(section.latencyTestCodes).toEqual([ + 'main-urltest-out', + 'main-1-out', + 'main-2-out', + 'main-3-out', + ]); + expect(section.outbounds.map((item) => item.code)).toEqual([ + 'main-urltest-out', + 'main-1-out', + 'main-2-out', + 'main-3-out', + ]); + }); + + it('hydrates URLTest details from the section cache and Clash API', async () => { + mocks.getConfigSections.mockResolvedValue([proxySection()]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + ...clashProxies, + 'main-urltest-out': proxy('URLTest', { + name: 'main-urltest-out', + now: 'main-3-out', + history: [{ time: '2026-05-27T00:00:00Z', delay: 10 }], + all: ['main-1-out', 'main-3-out'], + }), + }, + }, + }); + mocks.fsRead.mockResolvedValue( + JSON.stringify({ + outboundMetadata: { + names: { + 'main-1-out': 'First cached', + 'main-3-out': 'Third cached', + }, + countries: {}, + }, + urltestGroups: { + 'main-urltest-out': { + displayName: 'Fastest', + outbounds: ['main-1-out', 'main-3-out', 'main-2-out'], + url: 'https://probe.example/204', + interval: '3m', + tolerance: 50, + interrupt_exist_connections: true, + }, + }, + }), + ); + + const result = await getDashboardSections(); + const [section] = result.data; + const urltest = section.outbounds.find( + (item) => item.code === 'main-urltest-out', + ); + + expect(result.success).toBe(true); + expect(urltest?.urlTestInfo).toMatchObject({ + code: 'main-urltest-out', + displayName: 'Fastest', + url: 'https://probe.example/204', + interval: '3m', + tolerance: 50, + idleTimeout: '30m', + interruptExistConnections: true, + selectedCode: 'main-3-out', + selectedName: 'Third cached', + }); + expect(urltest?.urlTestInfo?.outbounds.map((item) => item.code)).toEqual([ + 'main-3-out', + 'main-1-out', + 'main-2-out', + ]); + expect(urltest?.urlTestInfo?.outbounds[0]).toMatchObject({ + displayName: 'Third cached', + latency: 300, + type: 'VLESS', + selected: true, + }); + expect(urltest?.urlTestInfo?.outbounds[1]).toMatchObject({ + displayName: 'one', + latency: 100, + selected: false, + }); + }); + + it('keeps an empty configured URLTest visible when sing-box skipped the group', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + urltest_settings: urlTestSettings('urltest', { + display_name: 'Empty URLTest', + urltest_filter_mode: 'include', + }), + }), + ]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + 'main-out': proxy('Selector', { + name: 'main-out', + now: 'main-1-out', + all: ['main-1-out'], + }), + 'main-1-out': clashProxies['main-1-out'], + }, + }, + }); + mocks.fsRead.mockResolvedValue( + JSON.stringify({ + urltestGroups: { + 'main-urltest-out': { + displayName: 'Empty URLTest', + outbounds: [], + url: 'https://probe.example/204', + interval: '3m', + tolerance: 50, + }, + }, + }), + ); + + const result = await getDashboardSections(); + const [section] = result.data; + const urltest = section.outbounds.find( + (item) => item.code === 'main-urltest-out', + ); + + expect(result.success).toBe(true); + expect(section.outbounds.map((item) => item.code)).toEqual([ + 'main-urltest-out', + 'main-1-out', + ]); + expect(urltest).toMatchObject({ + displayName: 'Empty URLTest', + type: 'URLTest', + runtimeAvailable: false, + }); + expect(urltest?.urlTestInfo?.selectedName).toBeUndefined(); + expect(urltest?.urlTestInfo?.outbounds).toEqual([]); + }); + + it('hydrates Priority dashboard details from config, cache and Clash API', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ urltests: [], urltest_settings: undefined }), + priorityGroup('pg_main'), + priorityLevel('pl_de', { + name: 'Germany', + order: '0', + detect_server_country: 'country_is', + }), + priorityLevel('pl_nl', { + name: 'Netherlands', + order: '1', + regex: ['Filtered'], + }), + ]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + ...clashProxies, + 'main-out': proxy('Selector', { + name: 'main-out', + now: 'main-priority-pg_main-out', + all: ['main-1-out', 'main-2-out', 'main-priority-pg_main-out'], + }), + 'main-priority-pg_main-out': proxy('Selector', { + name: 'main-priority-pg_main-out', + now: 'main-2-out', + all: ['main-2-out', 'main-1-out'], + }), + }, + }, + }); + mocks.fsRead.mockResolvedValue( + JSON.stringify({ + outboundMetadata: { + names: { + 'main-1-out': 'First cached', + 'main-2-out': 'Second cached', + }, + countries: { 'main-2-out': 'DE' }, + }, + priorityGroups: { + 'main-priority-pg_main-out': { + displayName: 'Codex Priority', + health_url: 'https://priority.example/204', + active_check_interval: '5s', + check_timeout: '2s', + recovery_check_interval: '15s', + pick_fastest: false, + switch_to_faster_same_priority: false, + interrupt_exist_connections: true, + outbounds: ['main-2-out', 'main-1-out'], + levels: [ + { + id: 'pl_de', + displayName: 'Germany', + order: 0, + outbounds: ['main-2-out'], + }, + { + id: 'pl_nl', + displayName: 'Netherlands', + order: 1, + outbounds: ['main-1-out'], + }, + ], + }, + }, + }), + ); + + const result = await getDashboardSections(); + const [section] = result.data; + const priority = section.outbounds.find( + (item) => item.code === 'main-priority-pg_main-out', + ); + + expect(result.success).toBe(true); + expect(priority).toMatchObject({ + displayName: 'Codex Priority', + type: 'Priority', + selected: true, + }); + expect(priority?.priorityInfo).toMatchObject({ + selectedCode: 'main-2-out', + selectedName: 'Second cached', + healthUrl: 'https://priority.example/204', + activeCheckInterval: '5s', + checkTimeout: '2s', + recoveryCheckInterval: '15s', + pickFastest: false, + interruptExistConnections: true, + }); + expect( + priority?.priorityInfo?.outbounds.map((item) => ({ + code: item.code, + levelIndex: item.levelIndex, + levelName: item.levelName, + selected: item.selected, + country: item.country, + })), + ).toEqual([ + { + code: 'main-2-out', + levelIndex: 0, + levelName: 'Germany', + selected: true, + country: 'DE', + }, + { + code: 'main-1-out', + levelIndex: 1, + levelName: 'Netherlands', + selected: false, + country: undefined, + }, + ]); + }); + + it('keeps an empty Priority group visible when sing-box skipped the selector', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + selector_proxy_links: [], + urltests: [], + urltest_settings: undefined, + }), + priorityGroup('pg_empty', { name: 'Empty Priority' }), + ]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { proxies: {} }, + }); + mocks.fsRead.mockResolvedValue( + JSON.stringify({ + priorityGroups: { + 'main-priority-pg_empty-out': { + displayName: 'Empty Priority', + outbounds: [], + levels: [], + interrupt_exist_connections: true, + }, + }, + }), + ); + + const result = await getDashboardSections(); + const [section] = result.data; + const priority = section.outbounds.find( + (item) => item.code === 'main-priority-pg_empty-out', + ); + + expect(result.success).toBe(true); + expect(section.outbounds.map((item) => item.code)).toEqual([ + 'main-priority-pg_empty-out', + ]); + expect(priority).toMatchObject({ + displayName: 'Empty Priority', + type: 'Priority', + runtimeAvailable: false, + latency: 0, + }); + expect(priority?.priorityInfo?.selectedName).toBeUndefined(); + expect(priority?.priorityInfo?.outbounds).toEqual([]); + }); + + it('keeps legacy URLTest sections compatible before migration', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + urltests: undefined, + urltest_settings: undefined, + urltest_enabled: '1', + urltest_filter_mode: 'exclude', + }), + ]); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect(section.outbounds.map((item) => item.code)).toEqual([ + 'main-urltest-out', + 'main-1-out', + 'main-2-out', + 'main-3-out', + ]); + expect(section.outbounds[0].displayName).toBe('Fastest'); + }); + + it('supports multiple configured URLTest groups', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + urltests: undefined, + urltest_settings: undefined, + }), + { + '.name': 'cfg010001', + '.type': 'urltest', + section: 'main', + name: 'Fast group', + }, + { + '.name': 'cfg010002', + '.type': 'urltest', + section: 'main', + name: 'Stable group', + }, + ]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + ...clashProxies, + 'main-out': proxy('Selector', { + name: 'main-out', + now: 'main-urltest-cfg010001-out', + all: [ + 'main-1-out', + 'main-urltest-cfg010002-out', + 'main-2-out', + 'main-urltest-cfg010001-out', + ], + }), + 'main-urltest-cfg010001-out': proxy('URLTest', { + name: 'main-urltest-cfg010001-out', + history: [{ time: '2026-05-27T00:00:00Z', delay: 10 }], + all: ['main-1-out'], + }), + 'main-urltest-cfg010002-out': proxy('URLTest', { + name: 'main-urltest-cfg010002-out', + history: [{ time: '2026-05-27T00:00:00Z', delay: 50 }], + all: ['main-2-out'], + }), + }, + }, + }); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect(section.outbounds.map((item) => item.code)).toEqual([ + 'main-urltest-cfg010001-out', + 'main-urltest-cfg010002-out', + 'main-1-out', + 'main-2-out', + ]); + expect(section.outbounds.map((item) => item.displayName)).toEqual([ + 'Fast group', + 'Stable group', + 'one', + 'Filtered 2', + ]); + }); + + it('keeps non-built-in URLTest groups in selector order when latency sorting is disabled', async () => { + mocks.getConfigSections.mockResolvedValue([proxySection()]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + 'main-out': proxy('Selector', { + name: 'main-out', + now: 'main-2-out', + all: [ + 'main-2-out', + 'main-provider-urltest-out', + 'main-1-out', + 'main-urltest-out', + ], + }), + 'main-urltest-out': proxy('URLTest', { + name: 'main-urltest-out', + history: [{ time: '2026-06-11T00:00:00Z', delay: 10 }], + all: ['main-2-out', 'main-1-out'], + }), + 'main-provider-urltest-out': proxy('URLTest', { + name: 'Provider URLTest', + history: [{ time: '2026-06-11T00:00:00Z', delay: 150 }], + all: ['provider-hidden-1-out'], + }), + 'main-1-out': proxy('VLESS', { + name: 'Fast leaf', + history: [{ time: '2026-06-11T00:00:00Z', delay: 100 }], + }), + 'main-2-out': proxy('VLESS', { + name: 'Slow leaf', + history: [{ time: '2026-06-11T00:00:00Z', delay: 300 }], + }), + }, + }, + }); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect(section.latencyTestCodes).toEqual([ + 'main-urltest-out', + 'main-2-out', + 'main-provider-urltest-out', + 'main-1-out', + ]); + expect(section.outbounds.map((item) => item.code)).toEqual([ + 'main-urltest-out', + 'main-2-out', + 'main-provider-urltest-out', + 'main-1-out', + ]); + }); + + it('sorts outbounds by latency only when enabled and does not prioritize URLTest groups except Fastest', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ sort_by_latency: '1' }), + ]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + 'main-out': proxy('Selector', { + name: 'main-out', + now: 'main-2-out', + all: [ + 'main-2-out', + 'main-provider-urltest-out', + 'main-1-out', + 'main-urltest-out', + ], + }), + 'main-urltest-out': proxy('URLTest', { + name: 'main-urltest-out', + history: [{ time: '2026-06-11T00:00:00Z', delay: 10 }], + all: ['main-2-out', 'main-1-out'], + }), + 'main-provider-urltest-out': proxy('URLTest', { + name: 'Provider URLTest', + history: [{ time: '2026-06-11T00:00:00Z', delay: 150 }], + all: ['provider-hidden-1-out'], + }), + 'main-1-out': proxy('VLESS', { + name: 'Fast leaf', + history: [{ time: '2026-06-11T00:00:00Z', delay: 100 }], + }), + 'main-2-out': proxy('VLESS', { + name: 'Slow leaf', + history: [{ time: '2026-06-11T00:00:00Z', delay: 300 }], + }), + }, + }, + }); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect(section.latencyTestCodes).toEqual([ + 'main-urltest-out', + 'main-1-out', + 'main-provider-urltest-out', + 'main-2-out', + ]); + expect(section.outbounds.map((item) => item.code)).toEqual([ + 'main-urltest-out', + 'main-1-out', + 'main-provider-urltest-out', + 'main-2-out', + ]); + }); + + it('sorts an unpinned configured URLTest group by latency', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + sort_by_latency: '1', + urltest_settings: urlTestSettings('urltest', { + display_name: 'Fastest', + pin_dashboard: '0', + }), + }), + ]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + 'main-out': proxy('Selector', { + name: 'main-out', + now: 'main-2-out', + all: ['main-2-out', 'main-1-out', 'main-urltest-out'], + }), + 'main-urltest-out': proxy('URLTest', { + name: 'main-urltest-out', + history: [{ time: '2026-06-11T00:00:00Z', delay: 250 }], + all: ['main-2-out', 'main-1-out'], + }), + 'main-1-out': proxy('VLESS', { + name: 'Fast leaf', + history: [{ time: '2026-06-11T00:00:00Z', delay: 100 }], + }), + 'main-2-out': proxy('VLESS', { + name: 'Slow leaf', + history: [{ time: '2026-06-11T00:00:00Z', delay: 300 }], + }), + }, + }, + }); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect(section.outbounds.map((item) => item.code)).toEqual([ + 'main-1-out', + 'main-urltest-out', + 'main-2-out', + ]); + }); + + it('does not expose flag-emoji detected countries on dashboard outbounds', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + urltest_settings: urlTestSettings('urltest', { + display_name: 'Fastest', + urltest_filter_mode: 'exclude', + detect_server_country: 'flag_emoji', + }), + }), + ]); + mocks.fsRead.mockResolvedValue( + JSON.stringify({ + outboundMetadata: { + names: {}, + countries: { 'main-1-out': 'US' }, + }, + }), + ); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect( + section.outbounds.find((item) => item.code === 'main-1-out')?.country, + ).toBeUndefined(); + }); + + it('exposes country.is detected countries on dashboard outbounds', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + urltest_settings: urlTestSettings('urltest', { + display_name: 'Fastest', + urltest_filter_mode: 'exclude', + detect_server_country: 'country_is', + }), + }), + ]); + mocks.fsRead.mockResolvedValue( + JSON.stringify({ + outboundMetadata: { + names: {}, + countries: { 'main-1-out': 'US' }, + }, + }), + ); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect( + section.outbounds.find((item) => item.code === 'main-1-out')?.country, + ).toBe('US'); + }); + + it('ignores legacy link refs without a resolved proxy link', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + subscription_urls: ['https://subscription.example/list'], + }), + ]); + mocks.fsRead.mockResolvedValue( + JSON.stringify({ + links: {}, + linkRefs: { + 'main-2-out': { + sourceSection: 'main-subscription-1', + sourceIndex: 1, + }, + }, + }), + ); + + const result = await getDashboardSections(); + const [section] = result.data; + const subscriptionOutbound = section.outbounds.find( + (item) => item.code === 'main-2-out', + ); + + expect(result.success).toBe(true); + expect(subscriptionOutbound?.link).toBe(''); + expect(subscriptionOutbound?.canCopyLink).toBe(false); + }); + + it('uses cached subscription links without a click-time backend lookup', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + subscription_urls: ['https://subscription.example/list'], + }), + ]); + mocks.fsRead.mockResolvedValue( + JSON.stringify({ + outboundMetadata: { + names: { 'main-2-out': 'Provider Subscription' }, + countries: {}, + }, + links: { + 'main-2-out': + 'vless://00000000-0000-4000-8000-000000000002@example.com:443#Subscription', + }, + }), + ); + + const result = await getDashboardSections(); + const [section] = result.data; + const subscriptionOutbound = section.outbounds.find( + (item) => item.code === 'main-2-out', + ); + + expect(result.success).toBe(true); + expect(subscriptionOutbound?.link).toBe( + 'vless://00000000-0000-4000-8000-000000000002@example.com:443#Subscription', + ); + expect(subscriptionOutbound?.displayName).toBe('Provider Subscription'); + expect(subscriptionOutbound?.canCopyLink).toBe(true); + }); + + it('does not expose countries when URLTest filtering is set to all servers', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + urltest_settings: urlTestSettings('urltest', { + display_name: 'Fastest', + detect_server_country: 'country_is', + urltest_filter_mode: 'disabled', + }), + }), + ]); + mocks.fsRead.mockResolvedValue( + JSON.stringify({ + outboundMetadata: { + names: {}, + countries: { 'main-1-out': 'US' }, + }, + }), + ); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect(section.latencyTestCode).toBe('main-out'); + expect(section.latencyTestCodes).toEqual([ + 'main-urltest-out', + 'main-1-out', + 'main-2-out', + 'main-3-out', + ]); + expect(section.outbounds.map((item) => item.code)).toContain('main-2-out'); + expect( + section.outbounds.find((item) => item.code === 'main-1-out')?.country, + ).toBeUndefined(); + }); + + it('uses allocated tags for section names that collide with system tags', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection({ + '.name': 'direct', + urltests: [], + urltest_settings: undefined, + urltest_enabled: '0', + }), + ]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + 'direct-out-1': proxy('Selector', { + name: 'direct-out-1', + now: 'direct-1-out', + all: ['direct-1-out'], + }), + 'direct-1-out': proxy('VLESS', { + name: 'Direct manual', + history: [{ time: '2026-05-27T00:00:00Z', delay: 100 }], + }), + }, + }, + }); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect(section.code).toBe('direct-out-1'); + expect(section.outbounds.map((item) => item.code)).toEqual([ + 'direct-1-out', + ]); + }); + + it('shows legacy VPN interface sections as a Connection selector group', async () => { + mocks.getConfigSections.mockResolvedValue([ + { + '.name': 'AWG', + '.type': 'section', + enabled: '1', + action: 'vpn', + interface: 'awg1', + }, + ]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + 'AWG-out': proxy('Selector', { + name: 'AWG-out', + now: 'AWG-interface-1-out', + all: ['AWG-interface-1-out'], + }), + 'AWG-interface-1-out': proxy('Direct', { + name: 'awg1', + history: [{ time: '2026-06-07T00:00:00Z', delay: 445 }], + }), + }, + }, + }); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect(section.action).toBe('vpn'); + expect(section.withTagSelect).toBe(true); + expect(section.proxyConfigType).toBe('interface'); + expect(section.outbounds[0]).toMatchObject({ + code: 'AWG-interface-1-out', + displayName: 'awg1', + selected: true, + }); + }); + + it('hydrates migrated interface children into the Connection selector group', async () => { + mocks.getConfigSections.mockResolvedValue([ + { + '.name': 'AWG', + '.type': 'section', + enabled: '1', + action: 'connection', + }, + { + '.name': 'cfg01', + '.type': 'section_interface', + section: 'AWG', + name: 'awg1', + }, + ]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + 'AWG-out': proxy('Selector', { + name: 'AWG-out', + now: 'AWG-interface-1-out', + all: ['AWG-interface-1-out'], + }), + 'AWG-interface-1-out': proxy('Direct', { + name: 'awg1', + history: [{ time: '2026-07-13T00:00:00Z', delay: 445 }], + }), + }, + }, + }); + + const result = await getDashboardSections(); + const [section] = result.data; + + expect(result.success).toBe(true); + expect(section.code).toBe('AWG-out'); + expect(section.withTagSelect).toBe(true); + expect(section.proxyConfigType).toBe('interface'); + expect(section.outbounds[0]).toMatchObject({ + code: 'AWG-interface-1-out', + displayName: 'awg1', + selected: true, + }); + }); + + it('does not expose ByeDPI sections on the dashboard', async () => { + mocks.getConfigSections.mockResolvedValue([ + proxySection(), + { + '.name': 'dpi', + '.type': 'section', + enabled: '1', + action: 'byedpi', + }, + ]); + mocks.getClashApiProxies.mockResolvedValue({ + success: true, + data: { + proxies: { + ...clashProxies, + 'dpi-out': proxy('Socks', { + name: 'dpi-out', + history: [{ time: '2026-06-10T00:00:00Z', delay: 20 }], + }), + }, + }, + }); + + const result = await getDashboardSections(); + + expect(result.success).toBe(true); + expect(result.data.map((section) => section.sectionName)).toEqual(['main']); + }); + + it('fetches Clash API proxies directly in the browser to avoid rpcd output limits', async () => { + mocks.getConfigSections.mockResolvedValue([ + { '.name': 'settings', '.type': 'settings', yacd_secret_key: 'secret' }, + proxySection(), + ]); + mocks.canUseDirectClashApi.mockReturnValue(true); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ proxies: clashProxies }), + }); + + vi.stubGlobal('window', { location: { hostname: 'router.example' } }); + vi.stubGlobal('fetch', fetchMock); + + const result = await getDashboardSections(); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + 'http://router.example:9090/proxies', + { + headers: { Authorization: 'Bearer secret' }, + }, + ); + expect(mocks.getClashApiProxies).not.toHaveBeenCalled(); + }); + + it('uses rpcd fallback when direct Clash API access is unsafe', async () => { + mocks.getConfigSections.mockResolvedValue([proxySection()]); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ proxies: clashProxies }), + }); + + vi.stubGlobal('window', { + location: { hostname: 'router.example', protocol: 'https:' }, + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await getDashboardSections(); + + expect(result.success).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + expect(mocks.getClashApiProxies).toHaveBeenCalledTimes(1); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/methods/fakeip/getFakeIpCheck.ts b/fe-app-tachyon/src/tachyon/methods/fakeip/getFakeIpCheck.ts new file mode 100644 index 00000000..4044ca06 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/fakeip/getFakeIpCheck.ts @@ -0,0 +1,23 @@ +import { FAKEIP_CHECK_DOMAIN } from '../../../constants'; +import { createBaseApiRequest, IBaseApiResponse } from '../../api'; + +interface IGetFakeIpCheckResponse { + fakeip: boolean; + IP: string; +} + +export async function getFakeIpCheck(): Promise< + IBaseApiResponse +> { + return createBaseApiRequest( + () => + fetch(`https://${FAKEIP_CHECK_DOMAIN}/check`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }), + { + operationName: 'getFakeIpCheck', + timeoutMs: 5000, + }, + ); +} diff --git a/fe-app-tachyon/src/tachyon/methods/fakeip/getIpCheck.ts b/fe-app-tachyon/src/tachyon/methods/fakeip/getIpCheck.ts new file mode 100644 index 00000000..c25a60e8 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/fakeip/getIpCheck.ts @@ -0,0 +1,23 @@ +import { IP_CHECK_DOMAIN } from '../../../constants'; +import { createBaseApiRequest, IBaseApiResponse } from '../../api'; + +interface IGetIpCheckResponse { + fakeip: boolean; + IP: string; +} + +export async function getIpCheck(): Promise< + IBaseApiResponse +> { + return createBaseApiRequest( + () => + fetch(`https://${IP_CHECK_DOMAIN}/check`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }), + { + operationName: 'getIpCheck', + timeoutMs: 5000, + }, + ); +} diff --git a/fe-app-tachyon/src/tachyon/methods/fakeip/index.ts b/fe-app-tachyon/src/tachyon/methods/fakeip/index.ts new file mode 100644 index 00000000..4a95f16c --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/fakeip/index.ts @@ -0,0 +1,7 @@ +import { getFakeIpCheck } from './getFakeIpCheck'; +import { getIpCheck } from './getIpCheck'; + +export const RemoteFakeIPMethods = { + getFakeIpCheck, + getIpCheck, +}; diff --git a/fe-app-tachyon/src/tachyon/methods/index.ts b/fe-app-tachyon/src/tachyon/methods/index.ts new file mode 100644 index 00000000..fca93a43 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/index.ts @@ -0,0 +1,3 @@ +export * from './custom'; +export * from './fakeip'; +export * from './shell'; diff --git a/fe-app-tachyon/src/tachyon/methods/shell/callBaseMethod.ts b/fe-app-tachyon/src/tachyon/methods/shell/callBaseMethod.ts new file mode 100644 index 00000000..c8f800b7 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/shell/callBaseMethod.ts @@ -0,0 +1,57 @@ +import { executeShellCommand } from '../../../helpers'; +import { Tachyon } from '../../types'; + +interface CallBaseMethodOptions { + allowNonZeroWithStdout?: boolean; + timeout?: number; +} + +export async function callBaseMethod( + method: Tachyon.AvailableMethods, + args: string[] = [], + command: string = '/usr/bin/tachyon', + options: CallBaseMethodOptions = {}, +): Promise> { + try { + const response = await executeShellCommand({ + command, + args: [method as string, ...args], + timeout: options.timeout ?? 15000, + }); + const exitCode = response.code ?? 0; + + if ( + exitCode !== 0 && + !(options.allowNonZeroWithStdout && response.stdout) + ) { + return { + success: false, + error: response.stderr || response.stdout || '', + }; + } + + if (response.stdout) { + try { + return { + success: true, + data: JSON.parse(response.stdout) as T, + }; + } catch (_e) { + return { + success: true, + data: response.stdout as T, + }; + } + } + + return { + success: false, + error: response.stderr || '', + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '', + }; + } +} diff --git a/fe-app-tachyon/src/tachyon/methods/shell/index.ts b/fe-app-tachyon/src/tachyon/methods/shell/index.ts new file mode 100644 index 00000000..32d819f2 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/shell/index.ts @@ -0,0 +1,765 @@ +import { callBaseMethod } from './callBaseMethod'; +import { ClashAPI, Tachyon } from '../../types'; +import { executeShellCommand } from '../../../helpers'; +import { isTransientRpcError } from '../../helpers/isTransientRpcError'; + +const SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS = 15000; +const SUBSCRIPTION_UPDATE_POLL_INTERVAL_MS = 1500; +const UI_ACTION_RPC_TIMEOUT_MS = 15000; +const UI_ACTION_TRANSIENT_RPC_GRACE_MS = 30000; +const SERVICE_ACTION_TIMEOUT_MS = 2 * 60 * 1000; +const SERVICE_ACTION_POLL_INTERVAL_MS = 1000; +const LATENCY_TEST_TIMEOUT_MS = 30 * 1000; +const LATENCY_TEST_POLL_INTERVAL_MS = 1000; +const COMPONENT_ACTION_RPC_TIMEOUT_MS = 15000; +const COMPONENT_ACTION_POLL_INTERVAL_MS = 1500; +const COMPONENT_ACTION_STATUS_REFRESH_INTERVAL_MS = 15000; +const COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS = 30000; +const COMPONENT_ACTION_TRANSIENT_RPC_GRACE_MS = 30000; +const COMPONENT_ACTION_STATE_DIR = '/var/run/tachyon/component-actions'; +const GET_UI_STATE_RPC_TIMEOUT_MS = 3000; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function translate(message: string) { + return typeof _ === 'function' ? _(message) : message; +} + +function parseJsonObjectOutput(output: string): T | null { + if (!output) { + return null; + } + + try { + return JSON.parse(output) as T; + } catch (_error) { + const jsonMatch = output.match(/(\{[\s\S]*\})\s*$/); + + if (!jsonMatch) { + return null; + } + + try { + return JSON.parse(jsonMatch[1]) as T; + } catch (_jsonError) { + return null; + } + } +} + +function parseComponentActionOutput(output: string) { + return parseJsonObjectOutput(output); +} + +function parseComponentActionResult( + response: Awaited>, +) { + return parseComponentActionOutput(response.stdout); +} + +function parseComponentActionStartResult( + response: Awaited>, +) { + const parsedResponse = parseComponentActionResult(response); + + if (!parsedResponse) { + return null; + } + + return parsedResponse as unknown as Tachyon.ComponentActionStartResult; +} + +function parseSubscriptionUpdateStartResult( + response: Awaited>, +) { + return parseJsonObjectOutput( + response.stdout, + ); +} + +function parseSubscriptionUpdateJobState( + response: Awaited>, +) { + return parseJsonObjectOutput( + response.stdout, + ); +} + +function parseUiActionStartResult( + response: Awaited>, +) { + return parseJsonObjectOutput(response.stdout); +} + +function parseServiceActionState( + response: Awaited>, +) { + return parseJsonObjectOutput(response.stdout); +} + +function parseLatencyActionState( + response: Awaited>, +) { + return parseJsonObjectOutput(response.stdout); +} + +function isComponentActionJobId(jobId: string) { + return /^[A-Za-z0-9._-]+$/.test(jobId) && jobId !== '.' && jobId !== '..'; +} + +async function readComponentActionState(jobId: string) { + if (!isComponentActionJobId(jobId)) { + return null; + } + + try { + return parseComponentActionOutput( + await fs.read(`${COMPONENT_ACTION_STATE_DIR}/${jobId}.json`), + ); + } catch (_error) { + return null; + } +} + +async function readTachyonVersion() { + const response = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: ['show_version'], + timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS, + }); + + if ((response.code ?? 0) !== 0 || !response.stdout) { + return ''; + } + + return response.stdout.trim(); +} + +async function isComponentActionStillRunning( + jobId: string, + component: Tachyon.ComponentName, + action: Tachyon.ComponentAction, +) { + const response = await callBaseMethod( + Tachyon.AvailableMethods.GET_UI_STATE, + [], + '/usr/bin/tachyon', + { timeout: GET_UI_STATE_RPC_TIMEOUT_MS }, + ); + + return ( + response.success && + response.data.actions.component.some( + (state) => + state.job_id === jobId && + state.component === component && + state.action === action && + state.running === true, + ) + ); +} + +function componentActionFailure( + response: Awaited>, + parsedResponse?: Pick | null, +) { + return { + success: false, + error: parsedResponse?.message || response.stderr || _('Failed to execute'), + } as Tachyon.MethodFailureResponse; +} + +function uiActionFailure( + response: Awaited>, + parsedResponse?: { message?: string } | null, + fallback: string = _('Failed to execute'), +) { + return { + success: false, + error: parsedResponse?.message || response.stderr || fallback, + } as Tachyon.MethodFailureResponse; +} + +function createTransientRpcGraceTracker(graceMs: number) { + let failureStartedAt = 0; + + return { + reset() { + failureStartedAt = 0; + }, + shouldContinue(error?: string) { + if (!isTransientRpcError(error)) { + failureStartedAt = 0; + return false; + } + + if (!failureStartedAt) { + failureStartedAt = Date.now(); + } + + return Date.now() - failureStartedAt < graceMs; + }, + }; +} + +export const TachyonShellMethods = { + checkDNSAvailable: async () => + callBaseMethod( + Tachyon.AvailableMethods.CHECK_DNS_AVAILABLE, + ), + checkFakeIP: async () => + callBaseMethod( + Tachyon.AvailableMethods.CHECK_FAKEIP, + ), + checkNftRules: async () => + callBaseMethod( + Tachyon.AvailableMethods.CHECK_NFT_RULES, + ), + checkZapretRuntime: async () => + callBaseMethod( + Tachyon.AvailableMethods.CHECK_ZAPRET_RUNTIME, + ), + checkZapret2Runtime: async () => + callBaseMethod( + Tachyon.AvailableMethods.CHECK_ZAPRET2_RUNTIME, + ), + checkByedpiRuntime: async () => + callBaseMethod( + Tachyon.AvailableMethods.CHECK_BYEDPI_RUNTIME, + ), + checkInboundsConfig: async () => + callBaseMethod( + Tachyon.AvailableMethods.CHECK_INBOUNDS_CONFIG, + ), + getStatus: async () => + callBaseMethod(Tachyon.AvailableMethods.GET_STATUS), + getOutboundMetadata: async (section: string) => + callBaseMethod( + Tachyon.AvailableMethods.GET_OUTBOUND_METADATA, + [section], + ), + getSubscriptionMetadata: async (section: string) => + callBaseMethod( + Tachyon.AvailableMethods.GET_SUBSCRIPTION_METADATA, + [section], + ), + checkSingBox: async () => + callBaseMethod( + Tachyon.AvailableMethods.CHECK_SING_BOX, + ), + checkInbounds: async () => + callBaseMethod( + Tachyon.AvailableMethods.CHECK_INBOUNDS, + ), + getSingBoxStatus: async () => + callBaseMethod( + Tachyon.AvailableMethods.GET_SING_BOX_STATUS, + ), + getZapretStatus: async () => + callBaseMethod( + Tachyon.AvailableMethods.GET_ZAPRET_STATUS, + ), + getZapret2Status: async () => + callBaseMethod( + Tachyon.AvailableMethods.GET_ZAPRET2_STATUS, + ), + getByedpiStatus: async () => + callBaseMethod( + Tachyon.AvailableMethods.GET_BYEDPI_STATUS, + ), + getClashApiProxies: async () => + callBaseMethod(Tachyon.AvailableMethods.CLASH_API, [ + Tachyon.AvailableClashAPIMethods.GET_PROXIES, + ]), + getClashApiConnections: async () => + callBaseMethod(Tachyon.AvailableMethods.CLASH_API, [ + Tachyon.AvailableClashAPIMethods.GET_CONNECTIONS, + ]), + getClashApiProxyLatency: async (tag: string, timeout = '5000') => + callBaseMethod( + Tachyon.AvailableMethods.CLASH_API, + [Tachyon.AvailableClashAPIMethods.GET_PROXY_LATENCY, tag, timeout], + ), + getClashApiProxyLatencies: async (tags: string[]) => + callBaseMethod( + Tachyon.AvailableMethods.CLASH_API, + [ + Tachyon.AvailableClashAPIMethods.GET_PROXY_LATENCIES, + JSON.stringify(tags), + '5000', + ], + ), + getClashApiGroupLatency: async (tag: string) => + callBaseMethod( + Tachyon.AvailableMethods.CLASH_API, + [Tachyon.AvailableClashAPIMethods.GET_GROUP_LATENCY, tag, '10000'], + ), + setClashApiGroupProxy: async (group: string, proxy: string) => + callBaseMethod(Tachyon.AvailableMethods.CLASH_API, [ + Tachyon.AvailableClashAPIMethods.SET_GROUP_PROXY, + group, + proxy, + ]), + closeClashApiConnection: async (connectionId: string) => + callBaseMethod(Tachyon.AvailableMethods.CLASH_API, [ + Tachyon.AvailableClashAPIMethods.CLOSE_CONNECTION, + connectionId, + ]), + closeAllClashApiConnections: async () => + callBaseMethod(Tachyon.AvailableMethods.CLASH_API, [ + Tachyon.AvailableClashAPIMethods.CLOSE_ALL_CONNECTIONS, + ]), + enable: async () => + callBaseMethod( + Tachyon.AvailableMethods.ENABLE, + [], + '/etc/init.d/tachyon', + ), + disable: async () => + callBaseMethod( + Tachyon.AvailableMethods.DISABLE, + [], + '/etc/init.d/tachyon', + ), + globalCheck: async (masked = true) => + callBaseMethod(Tachyon.AvailableMethods.GLOBAL_CHECK, [ + masked ? 'masked' : 'raw', + ]), + showSingBoxConfig: async (masked = true) => + callBaseMethod(Tachyon.AvailableMethods.SHOW_SING_BOX_CONFIG, [ + masked ? 'masked' : 'raw', + ]), + checkLogs: async () => + callBaseMethod(Tachyon.AvailableMethods.CHECK_LOGS), + checkSingBoxLogs: async () => + callBaseMethod(Tachyon.AvailableMethods.CHECK_SING_BOX_LOGS), + getSystemInfo: async () => + callBaseMethod( + Tachyon.AvailableMethods.GET_SYSTEM_INFO, + ), + getServerCapabilities: async () => + callBaseMethod( + Tachyon.AvailableMethods.GET_SERVER_CAPABILITIES, + ), + getUiCapabilities: async () => + callBaseMethod( + Tachyon.AvailableMethods.GET_UI_CAPABILITIES, + ), + getUiState: async () => + callBaseMethod( + Tachyon.AvailableMethods.GET_UI_STATE, + [], + '/usr/bin/tachyon', + { timeout: GET_UI_STATE_RPC_TIMEOUT_MS }, + ), + serviceActionStart: async (action: Tachyon.ServiceAction) => { + const response = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: [Tachyon.AvailableMethods.SERVICE_ACTION_ASYNC, action], + timeout: UI_ACTION_RPC_TIMEOUT_MS, + }); + const parsedResponse = parseUiActionStartResult(response); + + if ( + (response.code ?? 0) !== 0 || + !parsedResponse?.success || + !parsedResponse.job_id + ) { + return uiActionFailure( + response, + parsedResponse, + _('Service action failed'), + ); + } + + return { + success: true, + data: parsedResponse, + } as Tachyon.MethodSuccessResponse; + }, + serviceActionStatus: async (jobId: string) => { + const response = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: [Tachyon.AvailableMethods.SERVICE_ACTION_STATUS, jobId], + timeout: UI_ACTION_RPC_TIMEOUT_MS, + }); + const parsedResponse = parseServiceActionState(response); + + if ((response.code ?? 0) !== 0 || !parsedResponse) { + return uiActionFailure( + response, + parsedResponse, + _('Service action failed'), + ); + } + + return { + success: true, + data: parsedResponse, + } as Tachyon.MethodSuccessResponse; + }, + waitServiceActionJob: async (jobId: string, startedAt = Date.now()) => { + while (Date.now() - startedAt < SERVICE_ACTION_TIMEOUT_MS) { + await sleep(SERVICE_ACTION_POLL_INTERVAL_MS); + + const response = await TachyonShellMethods.serviceActionStatus(jobId); + + if (!response.success) { + return response; + } + + if (response.data.running) { + continue; + } + + return response; + } + + return { + success: false, + error: _('Operation timed out'), + } as Tachyon.MethodFailureResponse; + }, + latencyTestStart: async ( + latencyType: Tachyon.LatencyActionState['latency_type'], + section: string, + tag: string, + timeout?: string, + ) => { + const response = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: [ + Tachyon.AvailableMethods.LATENCY_TEST_ASYNC, + latencyType, + section, + tag, + ...(timeout ? [timeout] : []), + ], + timeout: UI_ACTION_RPC_TIMEOUT_MS, + }); + const parsedResponse = parseUiActionStartResult(response); + + if ( + (response.code ?? 0) !== 0 || + !parsedResponse?.success || + !parsedResponse.job_id + ) { + return uiActionFailure( + response, + parsedResponse, + _('Latency test failed'), + ); + } + + return { + success: true, + data: parsedResponse, + } as Tachyon.MethodSuccessResponse; + }, + latencyTestStatus: async (jobId: string) => { + const response = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: [Tachyon.AvailableMethods.LATENCY_TEST_STATUS, jobId], + timeout: UI_ACTION_RPC_TIMEOUT_MS, + }); + const parsedResponse = parseLatencyActionState(response); + + if ((response.code ?? 0) !== 0 || !parsedResponse) { + return uiActionFailure( + response, + parsedResponse, + _('Latency test failed'), + ); + } + + return { + success: true, + data: parsedResponse, + } as Tachyon.MethodSuccessResponse; + }, + waitLatencyTestJob: async (jobId: string, startedAt = Date.now()) => { + const transientRpc = createTransientRpcGraceTracker( + UI_ACTION_TRANSIENT_RPC_GRACE_MS, + ); + + while (Date.now() - startedAt < LATENCY_TEST_TIMEOUT_MS) { + await sleep(LATENCY_TEST_POLL_INTERVAL_MS); + + const response = await TachyonShellMethods.latencyTestStatus(jobId); + + if (!response.success) { + if (transientRpc.shouldContinue(response.error)) { + continue; + } + + return response; + } + + transientRpc.reset(); + if (response.data.running) { + continue; + } + + return response; + } + + return { + success: false, + error: _('Operation timed out'), + } as Tachyon.MethodFailureResponse; + }, + uiActionAck: async ( + kind: 'service' | 'latency' | 'component' | 'subscription', + jobId: string, + ) => { + const response = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: [Tachyon.AvailableMethods.UI_ACTION_ACK, kind, jobId], + timeout: UI_ACTION_RPC_TIMEOUT_MS, + }); + const parsedResponse = parseUiActionStartResult(response); + + if ((response.code ?? 0) !== 0 || !parsedResponse?.success) { + return uiActionFailure(response, parsedResponse); + } + + return { + success: true, + data: parsedResponse, + } as Tachyon.MethodSuccessResponse; + }, + componentActionStart: async ( + component: Tachyon.ComponentName, + action: Tachyon.ComponentAction, + ) => { + const response = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: [Tachyon.AvailableMethods.COMPONENT_ACTION_ASYNC, component, action], + timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS, + }); + const parsedResponse = parseComponentActionStartResult(response); + + if ( + (response.code ?? 0) !== 0 || + !parsedResponse?.success || + !parsedResponse.job_id + ) { + return componentActionFailure(response, parsedResponse); + } + + return { + success: true, + data: parsedResponse, + } as Tachyon.MethodSuccessResponse; + }, + componentActionStatus: async (jobId: string) => { + const response = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: [Tachyon.AvailableMethods.COMPONENT_ACTION_STATUS, jobId], + timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS, + }); + const parsedResponse = parseComponentActionResult(response); + + if ((response.code ?? 0) !== 0 || !parsedResponse) { + return componentActionFailure(response, parsedResponse); + } + + return { + success: true, + data: parsedResponse, + } as Tachyon.MethodSuccessResponse; + }, + componentUpdateCheckCache: async () => + callBaseMethod( + Tachyon.AvailableMethods.COMPONENT_UPDATE_CHECK_CACHE, + ), + waitComponentActionJob: async ( + jobId: string, + component: Tachyon.ComponentName, + action: Tachyon.ComponentAction, + expectedLatestVersion?: string, + ) => { + let selfUpdateVersionMatchedAt = 0; + let lastStatusRefreshAt = 0; + const transientRpc = createTransientRpcGraceTracker( + COMPONENT_ACTION_TRANSIENT_RPC_GRACE_MS, + ); + + while (true) { + await sleep(COMPONENT_ACTION_POLL_INTERVAL_MS); + + const stateResponse = await readComponentActionState(jobId); + + if (stateResponse) { + if (!stateResponse.running) { + transientRpc.reset(); + return { + success: true, + data: stateResponse, + } as Tachyon.MethodSuccessResponse; + } + + if ( + Date.now() - lastStatusRefreshAt < + COMPONENT_ACTION_STATUS_REFRESH_INTERVAL_MS + ) { + continue; + } + } + + lastStatusRefreshAt = Date.now(); + const statusResponse = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: [Tachyon.AvailableMethods.COMPONENT_ACTION_STATUS, jobId], + timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS, + }); + const parsedResponse = parseComponentActionResult(statusResponse); + + if ((statusResponse.code ?? 0) !== 0 || !parsedResponse) { + if (stateResponse?.running) { + transientRpc.reset(); + continue; + } + + if (await isComponentActionStillRunning(jobId, component, action)) { + transientRpc.reset(); + continue; + } + + const failure = componentActionFailure(statusResponse, parsedResponse); + + if (transientRpc.shouldContinue(failure.error)) { + continue; + } + + if (component === 'tachyon' && action === 'install') { + const installedVersion = expectedLatestVersion + ? await readTachyonVersion() + : ''; + + if ( + expectedLatestVersion && + installedVersion === expectedLatestVersion + ) { + if (!selfUpdateVersionMatchedAt) { + selfUpdateVersionMatchedAt = Date.now(); + } + + if ( + Date.now() - selfUpdateVersionMatchedAt >= + COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS + ) { + return { + success: true, + data: { + success: true, + component, + action, + message: translate('Tachyon has been installed'), + current_version: installedVersion, + latest_version: expectedLatestVersion, + changed: true, + status: 'latest', + }, + } as Tachyon.MethodSuccessResponse; + } + } + + continue; + } + + return failure; + } + + transientRpc.reset(); + if (parsedResponse.running) { + continue; + } + + return { + success: true, + data: parsedResponse, + } as Tachyon.MethodSuccessResponse; + } + }, + subscriptionUpdateStart: async (section?: string, sourceIndex?: number) => { + const startArgs = [ + Tachyon.AvailableMethods.SUBSCRIPTION_UPDATE_ASYNC, + ...(section ? [section] : []), + ...(section && sourceIndex !== undefined ? [String(sourceIndex)] : []), + ]; + const response = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: startArgs, + timeout: SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS, + }); + const parsedResponse = parseSubscriptionUpdateStartResult(response); + + if ( + (response.code ?? 0) !== 0 || + !parsedResponse?.success || + !parsedResponse.job_id + ) { + return { + success: false, + error: + parsedResponse?.message || + response.stderr || + _('Subscription update failed'), + } as Tachyon.MethodFailureResponse; + } + + return { + success: true, + data: parsedResponse, + } as Tachyon.MethodSuccessResponse; + }, + subscriptionUpdateStatus: async (jobId: string) => { + const response = await executeShellCommand({ + command: '/usr/bin/tachyon', + args: [Tachyon.AvailableMethods.SUBSCRIPTION_UPDATE_STATUS, jobId], + timeout: SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS, + }); + const parsedResponse = parseSubscriptionUpdateJobState(response); + + if ((response.code ?? 0) !== 0 || !parsedResponse) { + return { + success: false, + error: response.stderr || _('Subscription update failed'), + } as Tachyon.MethodFailureResponse; + } + + return { + success: true, + data: parsedResponse, + } as Tachyon.MethodSuccessResponse; + }, + waitSubscriptionUpdateJob: async (jobId: string) => { + const transientRpc = createTransientRpcGraceTracker( + UI_ACTION_TRANSIENT_RPC_GRACE_MS, + ); + + while (true) { + await sleep(SUBSCRIPTION_UPDATE_POLL_INTERVAL_MS); + + const response = await TachyonShellMethods.subscriptionUpdateStatus(jobId); + + if (!response.success) { + if (transientRpc.shouldContinue(response.error)) { + continue; + } + + return response; + } + + transientRpc.reset(); + if (response.data.running) { + continue; + } + + return response; + } + }, +}; diff --git a/fe-app-tachyon/src/tachyon/methods/shell/tests/callBaseMethod.test.ts b/fe-app-tachyon/src/tachyon/methods/shell/tests/callBaseMethod.test.ts new file mode 100644 index 00000000..1b54acad --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/shell/tests/callBaseMethod.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + executeShellCommand: vi.fn(), +})); + +vi.mock('../../../../helpers', () => ({ + executeShellCommand: mocks.executeShellCommand, +})); + +import { Tachyon } from '../../../types'; +import { callBaseMethod } from '../callBaseMethod'; + +describe('callBaseMethod', () => { + beforeEach(() => { + mocks.executeShellCommand.mockReset(); + }); + + it('fails on non-zero exit code even when stdout is present by default', async () => { + mocks.executeShellCommand.mockResolvedValue({ + stdout: '{"ok":false}', + stderr: 'command failed', + code: 1, + }); + + const response = await callBaseMethod( + Tachyon.AvailableMethods.CHECK_DNS_AVAILABLE, + ); + + expect(response).toEqual({ + success: false, + error: 'command failed', + }); + }); + + it('can return stdout for diagnostic commands with non-zero exit code', async () => { + mocks.executeShellCommand.mockResolvedValue({ + stdout: 'Global check run\n[FAIL] Sing-box process running', + stderr: '', + code: 1, + }); + + const response = await callBaseMethod( + Tachyon.AvailableMethods.GLOBAL_CHECK, + [], + '/usr/bin/tachyon', + { allowNonZeroWithStdout: true }, + ); + + expect(response).toEqual({ + success: true, + data: 'Global check run\n[FAIL] Sing-box process running', + }); + }); + + it('passes a custom timeout to long-running commands', async () => { + mocks.executeShellCommand.mockResolvedValue({ + stdout: '{"ok":true}', + stderr: '', + code: 0, + }); + + await callBaseMethod( + Tachyon.AvailableMethods.GLOBAL_CHECK, + [], + '/usr/bin/tachyon', + { timeout: 60000 }, + ); + + expect(mocks.executeShellCommand).toHaveBeenCalledWith({ + command: '/usr/bin/tachyon', + args: ['global_check'], + timeout: 60000, + }); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/methods/shell/tests/componentAction.test.ts b/fe-app-tachyon/src/tachyon/methods/shell/tests/componentAction.test.ts new file mode 100644 index 00000000..caf8baa0 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/shell/tests/componentAction.test.ts @@ -0,0 +1,347 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + executeShellCommand: vi.fn(), + fsRead: vi.fn(), +})); + +vi.mock('../../../../helpers', () => ({ + executeShellCommand: mocks.executeShellCommand, +})); + +import { TachyonShellMethods } from '../index'; + +describe('TachyonShellMethods.componentAction', () => { + beforeEach(() => { + vi.useFakeTimers(); + mocks.executeShellCommand.mockReset(); + mocks.fsRead.mockReset(); + vi.stubGlobal('fs', { + read: mocks.fsRead, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('does not fail Tachyon self-update when status polling disappears after package replacement', async () => { + mocks.fsRead.mockRejectedValue(new Error('Access denied')); + mocks.executeShellCommand.mockImplementation(({ args }) => { + if (args[0] === 'component_action_status') { + return Promise.resolve({ + stdout: '', + stderr: 'Unknown command', + code: 1, + }); + } + + if (args[0] === 'show_version') { + return Promise.resolve({ + stdout: '0.7.17.11\n', + stderr: '', + code: 0, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: 'Unexpected command', + code: 1, + }); + }); + + const responsePromise = TachyonShellMethods.waitComponentActionJob( + 'job-1', + 'tachyon', + 'install', + '0.7.17.11', + ); + + await vi.advanceTimersByTimeAsync(33000); + + await expect(responsePromise).resolves.toEqual({ + success: true, + data: { + success: true, + component: 'tachyon', + action: 'install', + message: 'Tachyon has been installed', + current_version: '0.7.17.11', + latest_version: '0.7.17.11', + changed: true, + status: 'latest', + }, + }); + }); + + it('returns the backend component action start error message', async () => { + mocks.executeShellCommand.mockResolvedValue({ + stdout: JSON.stringify({ + success: false, + message: 'Another component action is already running', + }), + stderr: '', + code: 1, + }); + + await expect( + TachyonShellMethods.componentActionStart('zapret', 'install'), + ).resolves.toEqual({ + success: false, + error: 'Another component action is already running', + }); + }); + + it('keeps following a component job after the former browser-side wait timeout while the backend reports it running', async () => { + let stateReads = 0; + + mocks.fsRead.mockImplementation(() => + Promise.resolve( + JSON.stringify( + stateReads++ < 401 + ? { + success: true, + running: true, + component: 'sing_box', + action: 'check_update', + message: 'Component action is running', + job_id: 'job-1', + } + : { + success: true, + running: false, + component: 'sing_box', + action: 'check_update', + message: 'sing-box is up to date', + current_version: '1.13.12-extended-2.3.2', + latest_version: '1.13.12-extended-2.3.2', + changed: false, + status: 'latest', + }, + ), + ), + ); + + mocks.executeShellCommand.mockImplementation(({ args }) => { + if (args[0] === 'component_action_status') { + return Promise.resolve({ + stdout: JSON.stringify({ + success: true, + running: true, + component: 'sing_box', + action: 'check_update', + message: 'Component action is running', + job_id: 'job-1', + }), + stderr: '', + code: 0, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: 'Unexpected command', + code: 1, + }); + }); + + const responsePromise = TachyonShellMethods.waitComponentActionJob( + 'job-1', + 'sing_box', + 'check_update', + ); + + await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 3000); + + await expect(responsePromise).resolves.toEqual({ + success: true, + data: { + success: true, + running: false, + component: 'sing_box', + action: 'check_update', + message: 'sing-box is up to date', + current_version: '1.13.12-extended-2.3.2', + latest_version: '1.13.12-extended-2.3.2', + changed: false, + status: 'latest', + }, + }); + }); + + it('keeps waiting when status polling briefly fails but UI state still reports the job running', async () => { + mocks.fsRead + .mockRejectedValueOnce(new Error('State is temporarily unavailable')) + .mockResolvedValueOnce( + JSON.stringify({ + success: true, + running: false, + component: 'sing_box', + action: 'install_extended', + message: 'sing-box-extended has been installed', + current_version: '1.13.12-extended-2.3.2', + latest_version: '1.13.12-extended-2.3.2', + changed: true, + status: 'latest', + }), + ); + + mocks.executeShellCommand.mockImplementation(({ args }) => { + if (args[0] === 'component_action_status') { + return Promise.resolve({ + stdout: '', + stderr: 'Component action job was not found', + code: 1, + }); + } + + if (args[0] === 'get_ui_state') { + return Promise.resolve({ + stdout: JSON.stringify({ + service: { + tachyon: { + running: 0, + enabled: 1, + status: 'stopped but enabled', + dns_configured: 0, + }, + sing_box: { + running: 0, + enabled: 1, + status: 'stopped but enabled', + }, + }, + capabilities: { + sing_box_extended: 0, + sing_box_tiny: 1, + sing_box_compressed: 0, + sing_box_tailscale: 0, + zapret_installed: 1, + zapret2_installed: 1, + byedpi_installed: 0, + server_inbounds_enabled_count: 0, + }, + actions: { + service: [], + latency: [], + component: [ + { + success: true, + running: true, + component: 'sing_box', + action: 'install_extended', + message: 'Component action is running', + job_id: 'job-1', + }, + ], + subscription: [], + }, + }), + stderr: '', + code: 0, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: 'Unexpected command', + code: 1, + }); + }); + + const responsePromise = TachyonShellMethods.waitComponentActionJob( + 'job-1', + 'sing_box', + 'install_extended', + ); + + await vi.advanceTimersByTimeAsync(3000); + + await expect(responsePromise).resolves.toEqual({ + success: true, + data: { + success: true, + running: false, + component: 'sing_box', + action: 'install_extended', + message: 'sing-box-extended has been installed', + current_version: '1.13.12-extended-2.3.2', + latest_version: '1.13.12-extended-2.3.2', + changed: true, + status: 'latest', + }, + }); + expect(mocks.executeShellCommand).toHaveBeenCalledWith({ + command: '/usr/bin/tachyon', + args: ['get_ui_state'], + timeout: 3000, + }); + }); + + it('keeps waiting through a transient RPC reply loss until the action state settles', async () => { + mocks.fsRead + .mockRejectedValueOnce(new Error('State is temporarily unavailable')) + .mockResolvedValueOnce( + JSON.stringify({ + success: true, + running: false, + component: 'zapret', + action: 'check_update', + message: 'zapret is up to date', + current_version: '70.2', + latest_version: '70.2', + changed: false, + status: 'latest', + }), + ); + + mocks.executeShellCommand.mockImplementation(({ args }) => { + if (args[0] === 'component_action_status') { + return Promise.resolve({ + stdout: '', + stderr: 'No related RPC reply', + code: 1, + }); + } + + if (args[0] === 'get_ui_state') { + return Promise.resolve({ + stdout: '', + stderr: 'No related RPC reply', + code: 1, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: 'Unexpected command', + code: 1, + }); + }); + + const responsePromise = TachyonShellMethods.waitComponentActionJob( + 'job-1', + 'zapret', + 'check_update', + ); + + await vi.advanceTimersByTimeAsync(3000); + + await expect(responsePromise).resolves.toEqual({ + success: true, + data: { + success: true, + running: false, + component: 'zapret', + action: 'check_update', + message: 'zapret is up to date', + current_version: '70.2', + latest_version: '70.2', + changed: false, + status: 'latest', + }, + }); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/methods/shell/tests/latencyAction.test.ts b/fe-app-tachyon/src/tachyon/methods/shell/tests/latencyAction.test.ts new file mode 100644 index 00000000..feecff09 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/shell/tests/latencyAction.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + executeShellCommand: vi.fn(), +})); + +vi.mock('../../../../helpers', () => ({ + executeShellCommand: mocks.executeShellCommand, +})); + +import { TachyonShellMethods } from '../index'; + +describe('TachyonShellMethods.latencyAction', () => { + beforeEach(() => { + vi.useFakeTimers(); + mocks.executeShellCommand.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps waiting through a transient RPC reply loss', async () => { + mocks.executeShellCommand.mockImplementation(({ args }) => { + if (args[0] === 'latency_test_status') { + if (mocks.executeShellCommand.mock.calls.length === 1) { + return Promise.resolve({ + stdout: '', + stderr: 'No related RPC reply', + code: 1, + }); + } + + return Promise.resolve({ + stdout: JSON.stringify({ + success: true, + running: false, + message: 'Latency test completed', + section: 'main', + tag: 'proxy-1', + exit_code: 0, + }), + stderr: '', + code: 0, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: 'Unexpected command', + code: 1, + }); + }); + + const responsePromise = TachyonShellMethods.waitLatencyTestJob('job-1'); + + await vi.advanceTimersByTimeAsync(2000); + + await expect(responsePromise).resolves.toEqual({ + success: true, + data: { + success: true, + running: false, + message: 'Latency test completed', + section: 'main', + tag: 'proxy-1', + exit_code: 0, + }, + }); + }); + + it('passes an optional timeout to latency jobs', async () => { + mocks.executeShellCommand.mockResolvedValue({ + stdout: JSON.stringify({ success: true, job_id: 'latency-1' }), + stderr: '', + code: 0, + }); + + await TachyonShellMethods.latencyTestStart( + 'proxy', + 'AWG', + 'AWG-out', + '10000', + ); + + expect(mocks.executeShellCommand).toHaveBeenCalledWith({ + command: '/usr/bin/tachyon', + args: ['latency_test_async', 'proxy', 'AWG', 'AWG-out', '10000'], + timeout: 15000, + }); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/methods/shell/tests/serviceAction.test.ts b/fe-app-tachyon/src/tachyon/methods/shell/tests/serviceAction.test.ts new file mode 100644 index 00000000..32d353c0 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/shell/tests/serviceAction.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + executeShellCommand: vi.fn(), +})); + +vi.mock('../../../../helpers', () => ({ + executeShellCommand: mocks.executeShellCommand, +})); + +import { TachyonShellMethods } from '../index'; + +describe('TachyonShellMethods.serviceAction', () => { + beforeEach(() => { + vi.useFakeTimers(); + mocks.executeShellCommand.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps failed finished service state available to low-level waiters', async () => { + mocks.executeShellCommand.mockImplementation(({ args }) => { + if (args[0] === 'service_action_status') { + return Promise.resolve({ + stdout: JSON.stringify({ + success: false, + running: false, + kind: 'service', + action: 'restart', + message: 'Service restart failed', + exit_code: 1, + }), + stderr: '', + code: 0, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: 'Unexpected command', + code: 1, + }); + }); + + const responsePromise = TachyonShellMethods.waitServiceActionJob('job-1'); + + await vi.advanceTimersByTimeAsync(1000); + + await expect(responsePromise).resolves.toEqual({ + success: true, + data: { + success: false, + running: false, + kind: 'service', + action: 'restart', + message: 'Service restart failed', + exit_code: 1, + }, + }); + }); + + it('returns the backend service action start error message', async () => { + mocks.executeShellCommand.mockResolvedValue({ + stdout: JSON.stringify({ + success: false, + message: 'Another service action is already running', + }), + stderr: '', + code: 1, + }); + + await expect( + TachyonShellMethods.serviceActionStart('restart'), + ).resolves.toEqual({ + success: false, + error: 'Another service action is already running', + }); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/methods/shell/tests/subscriptionUpdate.test.ts b/fe-app-tachyon/src/tachyon/methods/shell/tests/subscriptionUpdate.test.ts new file mode 100644 index 00000000..cae54f1a --- /dev/null +++ b/fe-app-tachyon/src/tachyon/methods/shell/tests/subscriptionUpdate.test.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + executeShellCommand: vi.fn(), +})); + +vi.mock('../../../../helpers', () => ({ + executeShellCommand: mocks.executeShellCommand, +})); + +import { TachyonShellMethods } from '../index'; + +describe('TachyonShellMethods.subscriptionUpdate', () => { + beforeEach(() => { + vi.useFakeTimers(); + mocks.executeShellCommand.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts a targeted background subscription update and waits for its state', async () => { + mocks.executeShellCommand.mockImplementation(({ args }) => { + if (args[0] === 'subscription_update_async') { + return Promise.resolve({ + stdout: JSON.stringify({ + success: true, + job_id: 'job-1', + message: 'Subscription update started', + }), + stderr: '', + code: 0, + }); + } + + if (args[0] === 'subscription_update_status') { + return Promise.resolve({ + stdout: JSON.stringify({ + success: true, + running: false, + message: 'Subscription update completed', + exit_code: 0, + }), + stderr: '', + code: 0, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: 'Unexpected command', + code: 1, + }); + }); + + await expect( + TachyonShellMethods.subscriptionUpdateStart('main'), + ).resolves.toEqual({ + success: true, + data: { + success: true, + job_id: 'job-1', + message: 'Subscription update started', + }, + }); + + const responsePromise = + TachyonShellMethods.waitSubscriptionUpdateJob('job-1'); + + await vi.advanceTimersByTimeAsync(1500); + + await expect(responsePromise).resolves.toEqual({ + success: true, + data: { + success: true, + running: false, + message: 'Subscription update completed', + exit_code: 0, + }, + }); + expect(mocks.executeShellCommand).toHaveBeenNthCalledWith(1, { + command: '/usr/bin/tachyon', + args: ['subscription_update_async', 'main'], + timeout: 15000, + }); + expect(mocks.executeShellCommand).toHaveBeenNthCalledWith(2, { + command: '/usr/bin/tachyon', + args: ['subscription_update_status', 'job-1'], + timeout: 15000, + }); + }); + + it('returns the backend subscription start error message', async () => { + mocks.executeShellCommand.mockResolvedValue({ + stdout: JSON.stringify({ + success: false, + message: 'Subscription update is already running', + }), + stderr: '', + code: 1, + }); + + await expect( + TachyonShellMethods.subscriptionUpdateStart('main'), + ).resolves.toEqual({ + success: false, + error: 'Subscription update is already running', + }); + }); + + it('returns failed finished job state from the low-level waiter for UI restoration', async () => { + mocks.executeShellCommand.mockImplementation(({ args }) => { + if (args[0] === 'subscription_update_status') { + return Promise.resolve({ + stdout: JSON.stringify({ + success: false, + running: false, + message: 'Failed to download subscriptions', + section: 'main', + source_index: '', + exit_code: 1, + }), + stderr: '', + code: 0, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: 'Unexpected command', + code: 1, + }); + }); + + const responsePromise = + TachyonShellMethods.waitSubscriptionUpdateJob('job-1'); + + await vi.advanceTimersByTimeAsync(1500); + + await expect(responsePromise).resolves.toEqual({ + success: true, + data: { + success: false, + running: false, + message: 'Failed to download subscriptions', + section: 'main', + source_index: '', + exit_code: 1, + }, + }); + }); + + it('keeps following a subscription job after the former browser-side wait timeout while the backend reports it running', async () => { + let statusCalls = 0; + + mocks.executeShellCommand.mockImplementation(({ args }) => { + if (args[0] === 'subscription_update_status') { + return Promise.resolve({ + stdout: JSON.stringify( + statusCalls++ < 401 + ? { + success: true, + running: true, + message: 'Subscription update is running', + section: 'main', + source_index: '', + exit_code: null, + } + : { + success: true, + running: false, + message: 'Subscription update completed', + section: 'main', + source_index: '', + exit_code: 0, + }, + ), + stderr: '', + code: 0, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: 'Unexpected command', + code: 1, + }); + }); + + const responsePromise = + TachyonShellMethods.waitSubscriptionUpdateJob('job-1'); + + await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 3000); + + await expect(responsePromise).resolves.toEqual({ + success: true, + data: { + success: true, + running: false, + message: 'Subscription update completed', + section: 'main', + source_index: '', + exit_code: 0, + }, + }); + }); + + it('keeps waiting through a transient RPC reply loss', async () => { + mocks.executeShellCommand.mockImplementation(({ args }) => { + if (args[0] === 'subscription_update_status') { + if (mocks.executeShellCommand.mock.calls.length === 1) { + return Promise.resolve({ + stdout: '', + stderr: 'No related RPC reply', + code: 1, + }); + } + + return Promise.resolve({ + stdout: JSON.stringify({ + success: true, + running: false, + message: 'Subscription update completed', + section: 'main', + source_index: '', + exit_code: 0, + }), + stderr: '', + code: 0, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: 'Unexpected command', + code: 1, + }); + }); + + const responsePromise = + TachyonShellMethods.waitSubscriptionUpdateJob('job-1'); + + await vi.advanceTimersByTimeAsync(3000); + + await expect(responsePromise).resolves.toEqual({ + success: true, + data: { + success: true, + running: false, + message: 'Subscription update completed', + section: 'main', + source_index: '', + exit_code: 0, + }, + }); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/runtimeTags.ts b/fe-app-tachyon/src/tachyon/runtimeTags.ts new file mode 100644 index 00000000..2ec10617 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/runtimeTags.ts @@ -0,0 +1,30 @@ +const RESERVED_RUNTIME_TAGS = new Set([ + 'dns-server', + 'fakeip-server', + 'bootstrap-dns-server', + 'fakeip-dns-rule-tag', + 'fakeip-ruleset-dns-rule-tag', + 'service-fakeip-dns-rule-tag', + 'tproxy-in', + 'tproxy6-in', + 'dns-in', + 'service-mixed-in', + 'direct-out', + 'bypass-out', +]); + +export function allocateRuntimeTag(base: string, postfix: string): string { + let suffix = 1; + let candidate = `${base}-${postfix}`; + + while (RESERVED_RUNTIME_TAGS.has(candidate)) { + candidate = `${base}-${postfix}-${suffix}`; + suffix += 1; + } + + return candidate; +} + +export function getOutboundTagBySection(sectionName: string): string { + return allocateRuntimeTag(sectionName, 'out'); +} diff --git a/fe-app-tachyon/src/tachyon/services/core.service.ts b/fe-app-tachyon/src/tachyon/services/core.service.ts new file mode 100644 index 00000000..dd99cf7c --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/core.service.ts @@ -0,0 +1,118 @@ +import { TabServiceInstance } from './tab.service'; +import { store } from './store.service'; +import { logger } from './logger.service'; +import { TachyonLogWatcher } from './tachyonLogWatcher.service'; +import { + getTachyonLogNotification, + LogNotificationDeduper, + TachyonLogNotification, +} from './logNotificationDeduper.service'; +import { TachyonShellMethods } from '../methods'; +import { + registerRuntimeStateResumeRefresh, + startRuntimeUiStatePolling, +} from './runtimeUiState.service'; + +type CoreServiceOptions = { + waitForLogWatcherStart?: () => Promise; + logWatcherStartDelayMs?: number; +}; + +const LOG_WATCHER_INTERVAL_MS = 10000; +const LOG_WATCHER_START_DELAY_MS = 5000; + +function componentDisplayName(component: string) { + const names: Record = { + tachyon: 'Tachyon', + sing_box: 'sing-box', + zapret: 'Zapret', + zapret2: 'Zapret2', + byedpi: 'ByeDPI', + }; + + return names[component] || component; +} + +function showLogNotification(notification: TachyonLogNotification) { + if (notification.kind === 'component-update') { + const message = _('New version %s is available for %s') + .replace('%s', notification.version) + .replace('%s', componentDisplayName(notification.component)); + + ui.addNotification( + _('Component update available'), + E('div', {}, message), + 'warning', + 'fkp-component-update-notification', + ); + return; + } + + ui.addNotification( + _('Tachyon Error'), + E('div', {}, notification.line), + 'error', + 'fkp-log-error-notification', + ); +} + +export function coreService(options: CoreServiceOptions = {}) { + TabServiceInstance.onChange((activeId, tabs) => { + logger.info('[TAB]', activeId); + store.set({ + tabService: { + current: activeId || '', + all: tabs.map((tab) => tab.id), + }, + }); + }); + + const watcher = TachyonLogWatcher.getInstance(); + const logNotificationDeduper = new LogNotificationDeduper(); + + watcher.init( + async () => { + const logs = await TachyonShellMethods.checkLogs(); + + if (logs.success) { + return logs.data as string; + } + + return ''; + }, + { + intervalMs: LOG_WATCHER_INTERVAL_MS, + onNewLog: (line) => { + if (logNotificationDeduper.shouldNotify(line)) { + const notification = getTachyonLogNotification(line); + if (notification) { + showLogNotification(notification); + } + } + }, + }, + ); + + const startWatcher = async () => { + if (options.waitForLogWatcherStart) { + await Promise.resolve() + .then(() => options.waitForLogWatcherStart?.()) + .catch(() => null); + } + + watcher.start(); + }; + const scheduleStartWatcher = () => + window.setTimeout(() => { + void startWatcher(); + }, options.logWatcherStartDelayMs ?? LOG_WATCHER_START_DELAY_MS); + + if (typeof window !== 'undefined') { + scheduleStartWatcher(); + } else { + void startWatcher(); + } + + registerRuntimeStateResumeRefresh(); + startRuntimeUiStatePolling(); +} diff --git a/fe-app-tachyon/src/tachyon/services/index.ts b/fe-app-tachyon/src/tachyon/services/index.ts new file mode 100644 index 00000000..df0e8ce6 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/index.ts @@ -0,0 +1,8 @@ +export * from './tab.service'; +export * from './core.service'; +export * from './socket.service'; +export * from './store.service'; +export * from './logger.service'; +export * from './runtimeUiState.service'; +export * from './uiActionNotification.service'; +export * from './localActionOverlay.service'; diff --git a/fe-app-tachyon/src/tachyon/services/localActionOverlay.service.ts b/fe-app-tachyon/src/tachyon/services/localActionOverlay.service.ts new file mode 100644 index 00000000..a35da16f --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/localActionOverlay.service.ts @@ -0,0 +1,61 @@ +import type { StoreType } from './store.service'; + +type UpdatesActionKey = keyof StoreType['updatesActions']; +type ServiceActionKey = 'restart' | 'start' | 'stop'; + +const componentActions = new Set(); +const subscriptionSections = new Set(); +const latencySections = new Set(); +const serviceActions = new Set(); + +function setMembership(set: Set, value: T, active: boolean) { + if (active) { + set.add(value); + } else { + set.delete(value); + } +} + +export function setLocalComponentAction( + action: UpdatesActionKey, + active: boolean, +) { + setMembership(componentActions, action, active); +} + +export function setLocalSubscriptionAction(section: string, active: boolean) { + if (section) { + setMembership(subscriptionSections, section, active); + } +} + +export function setLocalLatencyAction(section: string, active: boolean) { + if (section) { + setMembership(latencySections, section, active); + } +} + +export function setLocalServiceAction( + action: keyof StoreType['diagnosticsActions'], + active: boolean, +) { + if (action === 'restart' || action === 'start' || action === 'stop') { + setMembership(serviceActions, action, active); + } +} + +export function getLocalActionOverlay() { + return { + componentActions: new Set(componentActions), + subscriptionSections: new Set(subscriptionSections), + latencySections: new Set(latencySections), + serviceActions: new Set(serviceActions), + }; +} + +export function clearLocalActionOverlay() { + componentActions.clear(); + subscriptionSections.clear(); + latencySections.clear(); + serviceActions.clear(); +} diff --git a/fe-app-tachyon/src/tachyon/services/logNotificationDeduper.service.ts b/fe-app-tachyon/src/tachyon/services/logNotificationDeduper.service.ts new file mode 100644 index 00000000..0e44cda9 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/logNotificationDeduper.service.ts @@ -0,0 +1,114 @@ +const LOG_NOTIFICATION_STORAGE_KEY = 'tachyon:shown-log-error-notifications:v1'; +const MAX_STORED_LOG_NOTIFICATIONS = 500; + +export type TachyonLogNotification = + | { kind: 'error'; line: string } + | { + kind: 'component-update'; + line: string; + component: string; + version: string; + }; + +function getSessionStorage(): Storage | null { + if (typeof window === 'undefined') { + return null; + } + + try { + return window.sessionStorage; + } catch { + return null; + } +} + +function readStoredKeys(storage: Storage | null): string[] { + if (!storage) { + return []; + } + + try { + const parsed = JSON.parse( + storage.getItem(LOG_NOTIFICATION_STORAGE_KEY) || '[]', + ); + + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === 'string') + : []; + } catch { + return []; + } +} + +function writeStoredKeys(storage: Storage | null, keys: string[]) { + if (!storage) { + return; + } + + try { + storage.setItem( + LOG_NOTIFICATION_STORAGE_KEY, + JSON.stringify(keys.slice(-MAX_STORED_LOG_NOTIFICATIONS)), + ); + } catch { + // Notifications are still deduped in-memory when sessionStorage is blocked. + } +} + +export function isErrorLogLine(line: string) { + const lower = line.toLowerCase(); + return lower.includes('[error]') || lower.includes('[fatal]'); +} + +export function getTachyonLogNotification( + line: string, +): TachyonLogNotification | null { + if (isErrorLogLine(line)) { + return { kind: 'error', line }; + } + + const update = line.match( + /\[component-update\]\s+(tachyon|sing_box|zapret|zapret2|byedpi)\s+(\S+)/i, + ); + + if (!update) { + return null; + } + + return { + kind: 'component-update', + line, + component: update[1].toLowerCase(), + version: update[2], + }; +} + +export function getLogNotificationKey(line: string) { + return line.trim(); +} + +export class LogNotificationDeduper { + private readonly storage: Storage | null; + private readonly seenKeys: Set; + + constructor(storage: Storage | null = getSessionStorage()) { + this.storage = storage; + this.seenKeys = new Set(readStoredKeys(storage)); + } + + shouldNotify(line: string) { + if (!getTachyonLogNotification(line)) { + return false; + } + + const key = getLogNotificationKey(line); + + if (!key || this.seenKeys.has(key)) { + return false; + } + + this.seenKeys.add(key); + writeStoredKeys(this.storage, Array.from(this.seenKeys)); + return true; + } +} diff --git a/fe-app-tachyon/src/tachyon/services/logger.service.ts b/fe-app-tachyon/src/tachyon/services/logger.service.ts new file mode 100644 index 00000000..1ed779dd --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/logger.service.ts @@ -0,0 +1,66 @@ +import { downloadAsTxt } from '../../helpers/downloadAsTxt'; + +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +class Logger { + private logs: string[] = []; + private readonly levels: LogLevel[] = ['debug', 'info', 'warn', 'error']; + + private format(level: LogLevel, ...args: unknown[]): string { + return `[${level.toUpperCase()}] ${args.join(' ')}`; + } + + private push(level: LogLevel, ...args: unknown[]): void { + if (!this.levels.includes(level)) level = 'info'; + const message = this.format(level, ...args); + this.logs.push(message); + + switch (level) { + case 'error': + console.error(message); + break; + case 'warn': + console.warn(message); + break; + case 'info': + console.info(message); + break; + default: + console.log(message); + } + } + + debug(...args: unknown[]): void { + this.push('debug', ...args); + } + + info(...args: unknown[]): void { + this.push('info', ...args); + } + + warn(...args: unknown[]): void { + this.push('warn', ...args); + } + + error(...args: unknown[]): void { + this.push('error', ...args); + } + + clear(): void { + this.logs = []; + } + + getLogs(): string { + return this.logs.join('\n'); + } + + download(filename = 'logs.txt'): void { + if (typeof document === 'undefined') { + console.warn('Logger.download() доступен только в браузере'); + return; + } + downloadAsTxt(this.getLogs(), filename); + } +} + +export const logger = new Logger(); diff --git a/fe-app-tachyon/src/tachyon/services/runtimeUiState.service.ts b/fe-app-tachyon/src/tachyon/services/runtimeUiState.service.ts new file mode 100644 index 00000000..3811fe49 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/runtimeUiState.service.ts @@ -0,0 +1,166 @@ +import { TachyonShellMethods } from '../methods'; +import { Tachyon } from '../types'; +import { logger } from './logger.service'; +import { applyUiStateToStore } from './uiState.service'; + +const RUNTIME_UI_STATE_REFRESH_MIN_INTERVAL_MS = 500; +const RUNTIME_UI_STATE_IDLE_POLL_INTERVAL_MS = 1000; +const RUNTIME_UI_STATE_ACTIVE_POLL_INTERVAL_MS = 500; +type RuntimeUiStateListener = (uiState: Tachyon.UiState) => void; + +let runtimeUiStateRefreshPromise: Promise | null = + null; +let lastRuntimeUiStateRefreshAt = 0; +let lastRuntimeUiState: Tachyon.UiState | undefined; +let runtimeStateResumeRefreshRegistered = false; +let runtimeStatePollTimer: ReturnType | null = null; +let runtimeStatePollingStarted = false; +let runtimeStateHasRunningAction = false; +const runtimeUiStateListeners = new Set(); + +function isDocumentVisible() { + return ( + typeof document === 'undefined' || + !document.visibilityState || + document.visibilityState === 'visible' + ); +} + +function hasRunningAction(uiState: Tachyon.UiState) { + return Object.values(uiState.actions).some((actions) => + actions.some((action) => action.running), + ); +} + +function getNextPollDelay() { + return runtimeStateHasRunningAction + ? RUNTIME_UI_STATE_ACTIVE_POLL_INTERVAL_MS + : RUNTIME_UI_STATE_IDLE_POLL_INTERVAL_MS; +} + +function scheduleRuntimeUiStatePoll(delay = getNextPollDelay()) { + if ( + !runtimeStatePollingStarted || + runtimeStatePollTimer || + typeof window === 'undefined' + ) { + return; + } + + runtimeStatePollTimer = window.setTimeout(() => { + runtimeStatePollTimer = null; + void refreshRuntimeUiState() + .catch(() => undefined) + .finally(() => { + scheduleRuntimeUiStatePoll(); + }); + }, delay); +} + +function notifyRuntimeUiStateListeners(uiState: Tachyon.UiState) { + for (const listener of runtimeUiStateListeners) { + try { + listener(uiState); + } catch (error) { + logger.error('[RUNTIME_UI_STATE]', 'listener failed', error); + } + } +} + +export async function refreshRuntimeUiState({ + force = false, +}: { force?: boolean } = {}): Promise { + if (!isDocumentVisible()) { + return undefined; + } + + if (runtimeUiStateRefreshPromise) { + return runtimeUiStateRefreshPromise; + } + + const now = Date.now(); + if ( + !force && + now - lastRuntimeUiStateRefreshAt < RUNTIME_UI_STATE_REFRESH_MIN_INTERVAL_MS + ) { + return undefined; + } + + lastRuntimeUiStateRefreshAt = now; + + const promise = TachyonShellMethods.getUiState() + .then((response) => { + if (!response.success) { + return undefined; + } + + applyUiStateToStore(response.data); + lastRuntimeUiState = response.data; + runtimeStateHasRunningAction = hasRunningAction(response.data); + notifyRuntimeUiStateListeners(response.data); + return response.data; + }) + .catch((error) => { + logger.error('[RUNTIME_UI_STATE]', 'refresh failed', error); + return undefined; + }) + .finally(() => { + if (runtimeUiStateRefreshPromise === promise) { + runtimeUiStateRefreshPromise = null; + } + }); + + runtimeUiStateRefreshPromise = promise; + return runtimeUiStateRefreshPromise; +} + +export function subscribeRuntimeUiState(listener: RuntimeUiStateListener) { + runtimeUiStateListeners.add(listener); + + if (lastRuntimeUiState) { + try { + listener(lastRuntimeUiState); + } catch (error) { + logger.error('[RUNTIME_UI_STATE]', 'listener failed', error); + } + } + + return () => { + runtimeUiStateListeners.delete(listener); + }; +} + +export function getCachedRuntimeUiState() { + return lastRuntimeUiState; +} + +export function registerRuntimeStateResumeRefresh() { + if (runtimeStateResumeRefreshRegistered || typeof window === 'undefined') { + return; + } + + runtimeStateResumeRefreshRegistered = true; + + const refreshOnResume = () => { + if (!isDocumentVisible()) { + return; + } + + void refreshRuntimeUiState({ force: true }); + }; + + document.addEventListener('visibilitychange', refreshOnResume); + window.addEventListener('pageshow', refreshOnResume); + window.addEventListener('focus', refreshOnResume); +} + +export function startRuntimeUiStatePolling() { + if (runtimeStatePollingStarted || typeof window === 'undefined') { + return; + } + + runtimeStatePollingStarted = true; + void refreshRuntimeUiState({ force: true }).finally(() => { + scheduleRuntimeUiStatePoll(); + }); +} diff --git a/fe-app-tachyon/src/tachyon/services/socket.service.ts b/fe-app-tachyon/src/tachyon/services/socket.service.ts new file mode 100644 index 00000000..e1766e2b --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/socket.service.ts @@ -0,0 +1,167 @@ +import { logger } from './logger.service'; + +// eslint-disable-next-line +type Listener = (data: any) => void; +type ErrorListener = (error: Event | string) => void; + +class SocketManager { + private static instance: SocketManager; + private sockets = new Map(); + private listeners = new Map>(); + private connected = new Map(); + private errorListeners = new Map>(); + + private constructor() {} + + static getInstance(): SocketManager { + if (!SocketManager.instance) { + SocketManager.instance = new SocketManager(); + } + return SocketManager.instance; + } + + resetAll(): void { + for (const [url, ws] of this.sockets.entries()) { + try { + if ( + ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING + ) { + ws.close(); + } + } catch (err) { + logger.error( + '[SOCKET]', + `resetAll: failed to close socket ${url}`, + err, + ); + } + } + + this.sockets.clear(); + this.listeners.clear(); + this.errorListeners.clear(); + this.connected.clear(); + logger.info('[SOCKET]', 'All connections and state have been reset.'); + } + + connect(url: string): void { + if (this.sockets.has(url)) return; + + let ws: WebSocket; + + try { + ws = new WebSocket(url); + } catch (err) { + logger.error( + '[SOCKET]', + `failed to construct WebSocket for ${url}:`, + err, + ); + this.triggerError(url, err instanceof Event ? err : String(err)); + return; + } + + this.sockets.set(url, ws); + this.connected.set(url, false); + if (!this.listeners.has(url)) this.listeners.set(url, new Set()); + if (!this.errorListeners.has(url)) this.errorListeners.set(url, new Set()); + + ws.addEventListener('open', () => { + this.connected.set(url, true); + logger.info('[SOCKET]', 'Connected to', url); + }); + + ws.addEventListener('message', (event) => { + const handlers = this.listeners.get(url); + if (handlers) { + for (const handler of handlers) { + try { + handler(event.data); + } catch (err) { + logger.error('[SOCKET]', `Handler error for ${url}:`, err); + } + } + } + }); + + ws.addEventListener('close', () => { + this.connected.set(url, false); + logger.warn('[SOCKET]', `Disconnected: ${url}`); + this.triggerError(url, 'Connection closed'); + }); + + ws.addEventListener('error', (err) => { + logger.error('[SOCKET]', `Socket error for ${url}:`, err); + this.triggerError(url, err); + }); + } + + subscribe(url: string, listener: Listener, onError?: ErrorListener): void { + if (!this.errorListeners.has(url)) { + this.errorListeners.set(url, new Set()); + } + if (onError) { + this.errorListeners.get(url)?.add(onError); + } + + if (!this.sockets.has(url)) { + this.connect(url); + } + + if (!this.listeners.has(url)) { + this.listeners.set(url, new Set()); + } + this.listeners.get(url)?.add(listener); + } + + unsubscribe(url: string, listener: Listener, onError?: ErrorListener): void { + this.listeners.get(url)?.delete(listener); + if (onError) { + this.errorListeners.get(url)?.delete(onError); + } + } + + // eslint-disable-next-line + send(url: string, data: any): void { + const ws = this.sockets.get(url); + if (ws && this.connected.get(url)) { + ws.send(typeof data === 'string' ? data : JSON.stringify(data)); + } else { + logger.warn('[SOCKET]', `Cannot send: not connected to ${url}`); + this.triggerError(url, 'Not connected'); + } + } + + disconnect(url: string): void { + const ws = this.sockets.get(url); + if (ws) { + ws.close(); + this.sockets.delete(url); + this.listeners.delete(url); + this.errorListeners.delete(url); + this.connected.delete(url); + } + } + + disconnectAll(): void { + for (const url of this.sockets.keys()) { + this.disconnect(url); + } + } + + private triggerError(url: string, err: Event | string): void { + const handlers = this.errorListeners.get(url); + if (handlers) { + for (const cb of handlers) { + try { + cb(err); + } catch (e) { + logger.error('[SOCKET]', `Error handler threw for ${url}:`, e); + } + } + } + } +} + +export const socket = SocketManager.getInstance(); diff --git a/fe-app-tachyon/src/tachyon/services/store.service.ts b/fe-app-tachyon/src/tachyon/services/store.service.ts new file mode 100644 index 00000000..1db71922 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/store.service.ts @@ -0,0 +1,282 @@ +import { Tachyon } from '../types'; +import { initialDiagnosticStore } from '../tabs/diagnostic/diagnostic.store'; + +function jsonStableStringify(obj: T): string { + return JSON.stringify(obj, (_, value) => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return Object.keys(value) + .sort() + .reduce( + (acc, key) => { + acc[key] = value[key]; + return acc; + }, + {} as Record, + ); + } + return value; + }); +} + +function jsonEqual(a: A, b: B): boolean { + try { + return jsonStableStringify(a) === jsonStableStringify(b); + } catch { + return false; + } +} + +type Listener = (next: T, prev: T, diff: Partial) => void; + +// eslint-disable-next-line +export class StoreService> { + private value: T; + private readonly initial: T; + private listeners = new Set>(); + + constructor(initial: T) { + this.value = initial; + this.initial = structuredClone(initial); + } + + get(): T { + return this.value; + } + + set(next: Partial): void { + const prev = this.value; + const diff: Partial = {}; + + for (const key in next) { + if (!jsonEqual(next[key], prev[key])) diff[key] = next[key]; + } + + if (Object.keys(diff).length === 0) { + return; + } + + const merged = { ...prev, ...next }; + this.value = merged; + this.listeners.forEach((cb) => cb(this.value, prev, diff)); + } + + reset(keys?: K[]): void { + const prev = this.value; + const next = structuredClone(this.value); + + if (keys && keys.length > 0) { + keys.forEach((key) => { + next[key] = structuredClone(this.initial[key]); + }); + } else { + Object.assign(next, structuredClone(this.initial)); + } + + if (jsonEqual(prev, next)) return; + + this.value = next; + + const diff: Partial = {}; + for (const key in next) { + if (!jsonEqual(next[key], prev[key])) diff[key] = next[key]; + } + + this.listeners.forEach((cb) => cb(this.value, prev, diff)); + } + + subscribe(cb: Listener): () => void { + this.listeners.add(cb); + cb(this.value, this.value, {}); + return () => this.listeners.delete(cb); + } + + unsubscribe(cb: Listener): void { + this.listeners.delete(cb); + } + + patch(key: K, value: T[K]): void { + this.set({ [key]: value } as unknown as Partial); + } + + getKey(key: K): T[K] { + return this.value[key]; + } + + subscribeKey( + key: K, + cb: (value: T[K]) => void, + ): () => void { + let prev = this.value[key]; + const wrapper: Listener = (val) => { + if (!jsonEqual(val[key], prev)) { + prev = val[key]; + cb(val[key]); + } + }; + this.listeners.add(wrapper); + return () => this.listeners.delete(wrapper); + } +} + +export interface IDiagnosticsChecksItem { + state: 'error' | 'warning' | 'success'; + key: string; + value: string; +} + +export interface IDiagnosticsChecksStoreItem { + order: number; + code: string; + title: string; + description: string; + state: 'loading' | 'warning' | 'success' | 'error' | 'skipped'; + items: Array; +} + +export interface StoreType { + tabService: { + current: string; + all: string[]; + }; + bandwidthWidget: { + loading: boolean; + failed: boolean; + data: { up: number; down: number }; + }; + trafficTotalWidget: { + loading: boolean; + failed: boolean; + data: { downloadTotal: number; uploadTotal: number }; + }; + systemInfoWidget: { + loading: boolean; + failed: boolean; + data: { connections: number; memory: number }; + }; + servicesInfoWidget: { + loading: boolean; + failed: boolean; + data: { + singbox: number; + tachyonRunning: number; + tachyonEnabled: number; + tachyonStatus: string; + }; + }; + sectionsWidget: { + loading: boolean; + failed: boolean; + data: Tachyon.OutboundGroup[]; + latencyFetchingSections: Record; + latencyProgressSections: Record; + selectorSwitchingSections: Record; + subscriptionUpdatingSections: Record; + }; + diagnosticsRunAction: { + loading: boolean; + }; + diagnosticsChecks: Array; + diagnosticsActions: { + restart: { loading: boolean }; + start: { loading: boolean }; + stop: { loading: boolean }; + enable: { loading: boolean }; + disable: { loading: boolean }; + globalCheck: { loading: boolean }; + viewLogs: { loading: boolean }; + showSingBoxConfig: { loading: boolean }; + }; + diagnosticsSystemInfo: { + loading: boolean; + loaded: boolean; + providerInfoLoaded: boolean; + tachyon_version: string; + tachyon_latest_version: string; + luci_app_version: string; + sing_box_version: string; + sing_box_extended: number; + sing_box_tiny: number; + sing_box_compressed: number; + sing_box_tailscale: number; + zapret_version: string; + zapret_installed: number; + zapret2_version: string; + zapret2_installed: number; + byedpi_version: string; + byedpi_installed: number; + server_inbounds_enabled_count: number; + openwrt_version: string; + device_model: string; + }; + updatesActions: { + tachyonCheck: { loading: boolean }; + tachyonInstall: { loading: boolean }; + singBoxCheck: { loading: boolean }; + singBoxInstall: { loading: boolean }; + singBoxInstallExtended: { loading: boolean }; + singBoxInstallExtendedCompressed: { loading: boolean }; + singBoxInstallTiny: { loading: boolean }; + singBoxInstallStable: { loading: boolean }; + zapretCheck: { loading: boolean }; + zapretInstall: { loading: boolean }; + zapretRemove: { loading: boolean }; + zapret2Check: { loading: boolean }; + zapret2Install: { loading: boolean }; + zapret2Remove: { loading: boolean }; + byedpiCheck: { loading: boolean }; + byedpiInstall: { loading: boolean }; + byedpiRemove: { loading: boolean }; + }; + updatesChecks: Record< + Tachyon.ComponentName, + { + status: 'latest' | 'outdated' | 'dev' | null; + latest_version: string; + release_url: string; + } + >; +} + +const initialStore: StoreType = { + tabService: { + current: '', + all: [], + }, + bandwidthWidget: { + loading: true, + failed: false, + data: { up: 0, down: 0 }, + }, + trafficTotalWidget: { + loading: true, + failed: false, + data: { downloadTotal: 0, uploadTotal: 0 }, + }, + systemInfoWidget: { + loading: true, + failed: false, + data: { connections: 0, memory: 0 }, + }, + servicesInfoWidget: { + loading: true, + failed: false, + data: { + singbox: 0, + tachyonRunning: 0, + tachyonEnabled: 0, + tachyonStatus: '', + }, + }, + sectionsWidget: { + loading: true, + failed: false, + latencyFetchingSections: {}, + latencyProgressSections: {}, + selectorSwitchingSections: {}, + subscriptionUpdatingSections: {}, + data: [], + }, + ...initialDiagnosticStore, +}; + +export const store = new StoreService(initialStore); diff --git a/fe-app-tachyon/src/tachyon/services/systemInfo.service.ts b/fe-app-tachyon/src/tachyon/services/systemInfo.service.ts new file mode 100644 index 00000000..46f227f8 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/systemInfo.service.ts @@ -0,0 +1,129 @@ +import { TachyonShellMethods } from '../methods'; +import { normalizeSingBoxVariantFields } from '../helpers/singBoxVariant'; +import { logger } from './logger.service'; +import { store, StoreType } from './store.service'; + +const UNKNOWN_SYSTEM_INFO: StoreType['diagnosticsSystemInfo'] = { + loading: false, + loaded: false, + providerInfoLoaded: false, + tachyon_version: _('unknown'), + tachyon_latest_version: _('unknown'), + luci_app_version: _('unknown'), + sing_box_version: _('unknown'), + sing_box_extended: 0, + sing_box_tiny: 0, + sing_box_compressed: 0, + sing_box_tailscale: 1, + zapret_version: _('unknown'), + zapret_installed: 0, + zapret2_version: _('unknown'), + zapret2_installed: 0, + byedpi_version: _('unknown'), + byedpi_installed: 0, + server_inbounds_enabled_count: -1, + openwrt_version: _('unknown'), + device_model: _('unknown'), +}; + +let systemInfoPromise: Promise | null = + null; +let latestSystemInfoRequestId = 0; + +function hasLoadedSystemInfo() { + const systemInfo = store.get().diagnosticsSystemInfo; + + return Boolean(systemInfo.loaded) && !systemInfo.loading; +} + +export async function ensureSystemInfo({ + force = false, + silent = false, +}: { + force?: boolean; + silent?: boolean; +} = {}) { + if (!force && hasLoadedSystemInfo()) { + return store.get().diagnosticsSystemInfo; + } + + if (systemInfoPromise && !force) { + return systemInfoPromise; + } + + const requestId = ++latestSystemInfoRequestId; + const currentSystemInfo = store.get().diagnosticsSystemInfo; + + if (!silent) { + store.set({ + diagnosticsSystemInfo: { + ...currentSystemInfo, + loading: true, + }, + }); + } + + const promise = (async () => { + try { + const systemInfo = await TachyonShellMethods.getSystemInfo(); + + if (requestId !== latestSystemInfoRequestId) { + return store.get().diagnosticsSystemInfo; + } + + if (systemInfo.success) { + const nextSystemInfo: StoreType['diagnosticsSystemInfo'] = + normalizeSingBoxVariantFields({ + ...UNKNOWN_SYSTEM_INFO, + loading: false, + loaded: true, + providerInfoLoaded: true, + server_inbounds_enabled_count: + currentSystemInfo.server_inbounds_enabled_count, + ...systemInfo.data, + }); + + store.set({ + diagnosticsSystemInfo: nextSystemInfo, + }); + + return nextSystemInfo; + } + } catch (error) { + logger.error('[SYSTEM_INFO]', 'ensureSystemInfo failed', error); + } + + if (requestId === latestSystemInfoRequestId && !silent) { + const latestSystemInfo = store.get().diagnosticsSystemInfo; + const nextSystemInfo = { + ...UNKNOWN_SYSTEM_INFO, + loading: false, + loaded: false, + providerInfoLoaded: latestSystemInfo.providerInfoLoaded, + zapret_installed: latestSystemInfo.zapret_installed, + zapret2_installed: latestSystemInfo.zapret2_installed, + byedpi_installed: latestSystemInfo.byedpi_installed, + server_inbounds_enabled_count: + latestSystemInfo.server_inbounds_enabled_count, + }; + + store.set({ + diagnosticsSystemInfo: nextSystemInfo, + }); + + return nextSystemInfo; + } + + return store.get().diagnosticsSystemInfo; + })(); + + systemInfoPromise = promise; + + try { + return await promise; + } finally { + if (systemInfoPromise === promise) { + systemInfoPromise = null; + } + } +} diff --git a/fe-app-tachyon/src/tachyon/services/tab.service.ts b/fe-app-tachyon/src/tachyon/services/tab.service.ts new file mode 100644 index 00000000..a3def0ad --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/tab.service.ts @@ -0,0 +1,79 @@ +type TabInfo = { + el: HTMLElement; + id: string; + active: boolean; +}; + +type TabChangeCallback = (activeId: string | null, allTabs: TabInfo[]) => void; + +class TabService { + private static instance: TabService; + private observer: MutationObserver | null = null; + private callback?: TabChangeCallback; + private lastActiveId: string | null = null; + + private constructor() { + this.init(); + } + + public static getInstance(): TabService { + if (!TabService.instance) { + TabService.instance = new TabService(); + } + return TabService.instance; + } + + private init() { + this.observer = new MutationObserver(() => this.handleMutations()); + this.observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['class'], + }); + + // initial check + this.notify(); + } + + private handleMutations() { + this.notify(); + } + + private getTabsInfo(): TabInfo[] { + const tabs = Array.from( + document.querySelectorAll('.cbi-tab, .cbi-tab-disabled'), + ); + return tabs.map((el) => ({ + el, + id: el.dataset.tab || '', + active: + el.classList.contains('cbi-tab') && + !el.classList.contains('cbi-tab-disabled'), + })); + } + + private getActiveTabId(): string | null { + const active = document.querySelector( + '.cbi-tab:not(.cbi-tab-disabled)', + ); + return active?.dataset.tab || null; + } + + private notify() { + const tabs = this.getTabsInfo(); + const activeId = this.getActiveTabId(); + + if (activeId !== this.lastActiveId) { + this.lastActiveId = activeId; + this.callback?.(activeId, tabs); + } + } + + public onChange(callback: TabChangeCallback) { + this.callback = callback; + this.notify(); + } +} + +export const TabServiceInstance = TabService.getInstance(); diff --git a/fe-app-tachyon/src/tachyon/services/tachyonLogWatcher.service.ts b/fe-app-tachyon/src/tachyon/services/tachyonLogWatcher.service.ts new file mode 100644 index 00000000..73a8e406 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/tachyonLogWatcher.service.ts @@ -0,0 +1,142 @@ +import { logger } from './logger.service'; + +type LogFetcher = () => Promise | string; + +interface TachyonLogWatcherOptions { + intervalMs?: number; + onNewLog?: (line: string) => void; + maxTrackedLines?: number; +} + +export class TachyonLogWatcher { + private static instance: TachyonLogWatcher; + private fetcher?: LogFetcher; + private onNewLog?: (line: string) => void; + private intervalMs = 5000; + private lastLines = new Set(); + private maxTrackedLines = 500; + private timer?: ReturnType; + private running = false; + private paused = false; + private checking = false; + + private constructor() { + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.hidden) this.pause(); + else this.resume(); + }); + } + } + + static getInstance(): TachyonLogWatcher { + if (!TachyonLogWatcher.instance) { + TachyonLogWatcher.instance = new TachyonLogWatcher(); + } + return TachyonLogWatcher.instance; + } + + init(fetcher: LogFetcher, options?: TachyonLogWatcherOptions): void { + this.fetcher = fetcher; + this.onNewLog = options?.onNewLog; + this.intervalMs = options?.intervalMs ?? 5000; + this.maxTrackedLines = options?.maxTrackedLines ?? 500; + this.lastLines = new Set(); + logger.info( + '[TachyonLogWatcher]', + `initialized (interval: ${this.intervalMs}ms)`, + ); + } + + private normalizeLines(raw: string): string[] { + return raw.split('\n').filter(Boolean).slice(-this.maxTrackedLines); + } + + async checkOnce(): Promise { + if (!this.fetcher) { + logger.warn('[TachyonLogWatcher]', 'fetcher not found'); + return; + } + + if (this.paused) { + logger.debug('[TachyonLogWatcher]', 'skipped check — tab not visible'); + return; + } + + if (this.checking) { + logger.debug( + '[TachyonLogWatcher]', + 'skipped check — previous check is running', + ); + return; + } + + this.checking = true; + + try { + const raw = await this.fetcher(); + const lines = this.normalizeLines(raw); + + for (const line of lines) { + if (this.lastLines.has(line)) { + continue; + } + + this.lastLines.add(line); + this.onNewLog?.(line); + } + + if (this.lastLines.size > this.maxTrackedLines) { + this.lastLines = new Set( + Array.from(this.lastLines).slice(-this.maxTrackedLines), + ); + } + } catch (err) { + logger.error('[TachyonLogWatcher]', 'failed to read logs:', err); + } finally { + this.checking = false; + } + } + + start(): void { + if (this.running) return; + if (!this.fetcher) { + logger.warn('[TachyonLogWatcher]', 'attempted to start without fetcher'); + return; + } + + this.running = true; + void this.checkOnce(); + this.timer = setInterval(() => this.checkOnce(), this.intervalMs); + logger.info( + '[TachyonLogWatcher]', + `started (interval: ${this.intervalMs}ms)`, + ); + } + + stop(): void { + if (!this.running) return; + this.running = false; + if (this.timer) clearInterval(this.timer); + logger.info('[TachyonLogWatcher]', 'stopped'); + } + + pause(): void { + if (!this.running || this.paused) return; + this.paused = true; + logger.info('[TachyonLogWatcher]', 'paused (tab not visible)'); + } + + resume(): void { + if (!this.running || !this.paused) return; + this.paused = false; + logger.info('[TachyonLogWatcher]', 'resumed (tab active)'); + void this.checkOnce(); + } + + reset(): void { + this.lastLines = new Set(); + this.checking = false; + logger.info('[TachyonLogWatcher]', 'log history reset'); + } +} diff --git a/fe-app-tachyon/src/tachyon/services/tests/logNotificationDeduper.service.test.ts b/fe-app-tachyon/src/tachyon/services/tests/logNotificationDeduper.service.test.ts new file mode 100644 index 00000000..d6a58882 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/tests/logNotificationDeduper.service.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { + getLogNotificationKey, + getTachyonLogNotification, + isErrorLogLine, + LogNotificationDeduper, +} from '../logNotificationDeduper.service'; + +class MemoryStorage implements Storage { + private values = new Map(); + + get length() { + return this.values.size; + } + + clear() { + this.values.clear(); + } + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + key(index: number) { + return Array.from(this.values.keys())[index] ?? null; + } + + removeItem(key: string) { + this.values.delete(key); + } + + setItem(key: string, value: string) { + this.values.set(key, value); + } +} + +describe('LogNotificationDeduper', () => { + it('accepts error, fatal, and component update log lines', () => { + expect(isErrorLogLine('tachyon: [info] ok')).toBe(false); + expect(isErrorLogLine('tachyon: [error] failed')).toBe(true); + expect(isErrorLogLine('tachyon: [fatal] failed')).toBe(true); + expect( + getTachyonLogNotification( + 'tachyon: [info] [component-update] zapret2 v1.2.3', + ), + ).toEqual({ + kind: 'component-update', + line: 'tachyon: [info] [component-update] zapret2 v1.2.3', + component: 'zapret2', + version: 'v1.2.3', + }); + expect(getTachyonLogNotification('tachyon: [info] ok')).toBeNull(); + }); + + it('dedupes already shown log lines through session storage', () => { + const storage = new MemoryStorage(); + const first = new LogNotificationDeduper(storage); + + expect(first.shouldNotify('tachyon: [error] failed')).toBe(true); + expect(first.shouldNotify('tachyon: [error] failed')).toBe(false); + expect(first.shouldNotify('tachyon: [error] another failure')).toBe(true); + expect( + first.shouldNotify('tachyon: [info] [component-update] tachyon 1.2.3'), + ).toBe(true); + + const afterReload = new LogNotificationDeduper(storage); + + expect(afterReload.shouldNotify('tachyon: [error] failed')).toBe(false); + expect(afterReload.shouldNotify('tachyon: [fatal] fatal failure')).toBe( + true, + ); + }); + + it('keeps the full log line as the replay key', () => { + expect(getLogNotificationKey(' Jun 06 tachyon: [error] failed ')).toBe( + 'Jun 06 tachyon: [error] failed', + ); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/services/tests/runtimeUiState.service.test.ts b/fe-app-tachyon/src/tachyon/services/tests/runtimeUiState.service.test.ts new file mode 100644 index 00000000..67f32efe --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/tests/runtimeUiState.service.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + executeShellCommand: vi.fn(), +})); + +vi.mock('../../../helpers', () => ({ + executeShellCommand: mocks.executeShellCommand, +})); + +import { Tachyon } from '../../types'; +import { store } from '../store.service'; +import { + getCachedRuntimeUiState, + refreshRuntimeUiState, + subscribeRuntimeUiState, +} from '../runtimeUiState.service'; + +function createUiState( + status = 'running & enabled', + running = 1, +): Tachyon.UiState { + return { + service: { + tachyon: { + running, + enabled: 1, + status, + dns_configured: running, + }, + sing_box: { + running, + enabled: 0, + status: running ? 'running but disabled' : 'stopped & disabled', + }, + }, + capabilities: { + sing_box_extended: 1, + sing_box_tiny: 0, + sing_box_compressed: 0, + sing_box_tailscale: 1, + zapret_installed: 1, + zapret2_installed: 1, + byedpi_installed: 0, + server_inbounds_enabled_count: 0, + }, + actions: { + service: [], + latency: [], + component: [], + subscription: [], + }, + }; +} + +describe('refreshRuntimeUiState', () => { + beforeEach(() => { + store.reset(); + mocks.executeShellCommand.mockReset(); + }); + + it('applies current UI state to the shared store', async () => { + const uiState = createUiState('stopped but enabled', 0); + + mocks.executeShellCommand.mockResolvedValue({ + stdout: JSON.stringify(uiState), + stderr: '', + code: 0, + }); + + await expect(refreshRuntimeUiState({ force: true })).resolves.toEqual( + uiState, + ); + + expect(mocks.executeShellCommand).toHaveBeenCalledWith({ + command: '/usr/bin/tachyon', + args: ['get_ui_state'], + timeout: 3000, + }); + expect(store.get().servicesInfoWidget.data).toMatchObject({ + tachyonRunning: 0, + tachyonEnabled: 1, + tachyonStatus: 'stopped but enabled', + }); + }); + + it('coalesces concurrent refreshes into one RPC call', async () => { + const uiState = createUiState(); + let resolveRpc: (value: { + stdout: string; + stderr: string; + code: number; + }) => void = () => undefined; + + mocks.executeShellCommand.mockReturnValue( + new Promise((resolve) => { + resolveRpc = resolve; + }), + ); + + const firstRefresh = refreshRuntimeUiState({ force: true }); + const secondRefresh = refreshRuntimeUiState({ force: true }); + + expect(mocks.executeShellCommand).toHaveBeenCalledTimes(1); + + resolveRpc({ + stdout: JSON.stringify(uiState), + stderr: '', + code: 0, + }); + + await expect(Promise.all([firstRefresh, secondRefresh])).resolves.toEqual([ + uiState, + uiState, + ]); + }); + + it('notifies subscribers after applying fresh state', async () => { + const uiState = createUiState('stopped but enabled', 0); + const listener = vi.fn(); + const unsubscribe = subscribeRuntimeUiState(listener); + listener.mockClear(); + + mocks.executeShellCommand.mockResolvedValue({ + stdout: JSON.stringify(uiState), + stderr: '', + code: 0, + }); + + await refreshRuntimeUiState({ force: true }); + + expect(listener).toHaveBeenCalledWith(uiState); + + unsubscribe(); + listener.mockClear(); + + await refreshRuntimeUiState({ force: true }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('replays cached state to a new subscriber without another RPC', async () => { + const uiState = createUiState('starting', 0); + + mocks.executeShellCommand.mockResolvedValue({ + stdout: JSON.stringify(uiState), + stderr: '', + code: 0, + }); + + await refreshRuntimeUiState({ force: true }); + + const listener = vi.fn(); + const unsubscribe = subscribeRuntimeUiState(listener); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(uiState); + expect(mocks.executeShellCommand).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + it('exposes the cached state without another RPC', async () => { + const uiState = createUiState('running & enabled', 1); + + mocks.executeShellCommand.mockResolvedValue({ + stdout: JSON.stringify(uiState), + stderr: '', + code: 0, + }); + + await refreshRuntimeUiState({ force: true }); + mocks.executeShellCommand.mockClear(); + + expect(getCachedRuntimeUiState()).toEqual(uiState); + expect(mocks.executeShellCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/services/tests/socket.service.test.ts b/fe-app-tachyon/src/tachyon/services/tests/socket.service.test.ts new file mode 100644 index 00000000..3fa21198 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/tests/socket.service.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { socket } from '../socket.service'; + +class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static instances: FakeWebSocket[] = []; + + readyState = FakeWebSocket.CONNECTING; + private listeners = new Map void>>(); + + constructor(_url: string) { + FakeWebSocket.instances.push(this); + } + + addEventListener(type: string, listener: (event: Event) => void) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + emit(type: string) { + for (const listener of this.listeners.get(type) || []) { + listener(new Event(type)); + } + } + + close() {} + send() {} +} + +describe('socket service', () => { + beforeEach(() => { + socket.resetAll(); + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); + }); + + afterEach(() => { + socket.resetAll(); + vi.unstubAllGlobals(); + }); + + it('keeps the initial subscriber when the first connection fails', () => { + const onError = vi.fn(); + + socket.subscribe('ws://router.test', vi.fn(), onError); + FakeWebSocket.instances[0].emit('error'); + + expect(onError).toHaveBeenCalledOnce(); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/services/tests/store.service.test.ts b/fe-app-tachyon/src/tachyon/services/tests/store.service.test.ts new file mode 100644 index 00000000..9a7628d8 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/tests/store.service.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { StoreService } from '../store.service'; + +describe('StoreService', () => { + it('diffs only keys provided to set', () => { + const store = new StoreService({ + light: { value: 1 }, + heavy: { items: [{ id: 1 }] }, + }); + const listener = vi.fn(); + + store.subscribe(listener); + listener.mockClear(); + + store.set({ light: { value: 2 } }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][2]).toEqual({ light: { value: 2 } }); + }); + + it('does not notify when provided values are unchanged', () => { + const store = new StoreService({ + light: { value: 1 }, + heavy: { items: [{ id: 1 }] }, + }); + const listener = vi.fn(); + + store.subscribe(listener); + listener.mockClear(); + + store.set({ light: { value: 1 } }); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/services/tests/tachyonLogWatcher.service.test.ts b/fe-app-tachyon/src/tachyon/services/tests/tachyonLogWatcher.service.test.ts new file mode 100644 index 00000000..33a743d2 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/tests/tachyonLogWatcher.service.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { TachyonLogWatcher } from '../tachyonLogWatcher.service'; + +describe('TachyonLogWatcher', () => { + const watcher = TachyonLogWatcher.getInstance(); + let rawLogs = ''; + let seenLines: string[] = []; + + beforeEach(() => { + watcher.stop(); + watcher.reset(); + rawLogs = ''; + seenLines = []; + }); + + it('emits the initial logread snapshot', async () => { + watcher.init(() => rawLogs, { + onNewLog: (line) => seenLines.push(line), + }); + + rawLogs = 'old info\nold error'; + await watcher.checkOnce(); + + expect(seenLines).toEqual(['old info', 'old error']); + }); + + it('does not emit the same log line twice', async () => { + watcher.init(() => rawLogs, { + onNewLog: (line) => seenLines.push(line), + }); + + rawLogs = 'line 1\nline 2'; + await watcher.checkOnce(); + await watcher.checkOnce(); + + rawLogs = 'line 1\nline 2\nline 3'; + await watcher.checkOnce(); + + expect(seenLines).toEqual(['line 1', 'line 2', 'line 3']); + }); + + it('forgets old tracked lines only after the tracked window is exceeded', async () => { + watcher.init(() => rawLogs, { + maxTrackedLines: 3, + onNewLog: (line) => seenLines.push(line), + }); + + rawLogs = 'line 1\nline 2\nline 3'; + await watcher.checkOnce(); + + rawLogs = 'line 1\nline 2\nline 3\nline 4'; + await watcher.checkOnce(); + + rawLogs = 'line 1\nline 2\nline 3\nline 4\nline 5'; + await watcher.checkOnce(); + + expect(seenLines).toEqual([ + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + ]); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/services/tests/uiActionNotification.service.test.ts b/fe-app-tachyon/src/tachyon/services/tests/uiActionNotification.service.test.ts new file mode 100644 index 00000000..5d4faf2b --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/tests/uiActionNotification.service.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { UiActionNotificationTracker } from '../uiActionNotification.service'; + +class MemoryStorage implements Storage { + private values = new Map(); + + get length() { + return this.values.size; + } + + clear() { + this.values.clear(); + } + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + key(index: number) { + return Array.from(this.values.keys())[index] ?? null; + } + + removeItem(key: string) { + this.values.delete(key); + } + + setItem(key: string, value: string) { + this.values.set(key, value); + } +} + +describe('UiActionNotificationTracker', () => { + it('does not notify jobs that were not started by this UI session', () => { + const tracker = new UiActionNotificationTracker(new MemoryStorage()); + + expect(tracker.shouldNotify('component', 'component-1')).toBe(false); + }); + + it('notifies an owned job exactly once', () => { + const tracker = new UiActionNotificationTracker(new MemoryStorage()); + + tracker.markOwned('subscription', 'subscription-1'); + + expect(tracker.shouldNotify('subscription', 'subscription-1')).toBe(true); + expect(tracker.shouldNotify('subscription', 'subscription-1')).toBe(false); + }); + + it('keeps ownership and notification state across reloads', () => { + const storage = new MemoryStorage(); + const beforeReload = new UiActionNotificationTracker(storage); + + beforeReload.markOwned('component', 'component-1'); + + const afterReload = new UiActionNotificationTracker(storage); + + expect(afterReload.shouldNotify('component', 'component-1')).toBe(true); + + const afterNotification = new UiActionNotificationTracker(storage); + + expect(afterNotification.shouldNotify('component', 'component-1')).toBe( + false, + ); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/services/tests/uiState.service.test.ts b/fe-app-tachyon/src/tachyon/services/tests/uiState.service.test.ts new file mode 100644 index 00000000..1edfac4c --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/tests/uiState.service.test.ts @@ -0,0 +1,429 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { applyUiStateToStore } from '../uiState.service'; +import { store } from '../store.service'; +import { Tachyon } from '../../types'; +import { + clearLocalActionOverlay, + setLocalComponentAction, + setLocalLatencyAction, + setLocalServiceAction, + setLocalSubscriptionAction, +} from '../localActionOverlay.service'; + +function createUiState( + actions: Partial = {}, + capabilities: Partial = {}, +): Tachyon.UiState { + return { + service: { + tachyon: { + running: 0, + enabled: 1, + status: 'starting', + dns_configured: 0, + }, + sing_box: { + running: 0, + enabled: 1, + status: 'stopped but enabled', + }, + }, + capabilities: { + sing_box_extended: 1, + sing_box_tiny: 0, + sing_box_compressed: 0, + sing_box_tailscale: 1, + zapret_installed: 1, + zapret2_installed: 0, + byedpi_installed: 1, + server_inbounds_enabled_count: 0, + ...capabilities, + }, + actions: { + service: [], + latency: [], + component: [], + subscription: [], + ...actions, + }, + }; +} + +describe('applyUiStateToStore', () => { + beforeEach(() => { + clearLocalActionOverlay(); + store.reset(); + }); + + it('applies service, capability, and running action state before first render', () => { + applyUiStateToStore( + createUiState({ + service: [ + { + success: true, + running: true, + kind: 'service', + action: 'start', + job_id: 'service-1', + }, + ], + subscription: [ + { + success: true, + running: true, + job_id: 'subscription-1', + section: 'main', + message: 'Subscription update is running', + }, + ], + latency: [ + { + success: true, + running: true, + kind: 'latency', + latency_type: 'group', + section: 'main', + tag: 'AUTO', + job_id: 'latency-1', + progress: { + completed: 2, + total: 5, + failed: 1, + }, + }, + ], + component: [ + { + success: true, + running: true, + job_id: 'component-1', + component: 'zapret', + action: 'install', + message: 'Install is running', + current_version: '', + latest_version: '', + changed: false, + }, + ], + }), + ); + + const state = store.get(); + + expect(state.servicesInfoWidget).toMatchObject({ + loading: false, + failed: false, + data: { + tachyonEnabled: 1, + tachyonRunning: 0, + tachyonStatus: 'starting', + }, + }); + expect(state.diagnosticsSystemInfo).toMatchObject({ + providerInfoLoaded: true, + sing_box_extended: 1, + zapret_installed: 1, + zapret2_installed: 0, + byedpi_installed: 1, + server_inbounds_enabled_count: 0, + }); + expect(state.diagnosticsActions.start.loading).toBe(true); + expect(state.sectionsWidget.subscriptionUpdatingSections).toEqual({ + main: true, + }); + expect(state.sectionsWidget.latencyFetchingSections).toEqual({ + main: true, + }); + expect(state.sectionsWidget.latencyProgressSections).toEqual({ + main: { + completed: 2, + total: 5, + failed: 1, + }, + }); + expect(state.updatesActions.zapretInstall.loading).toBe(true); + }); + + it('clears finished persisted action flags while preserving local enable actions', () => { + store.set({ + diagnosticsActions: { + ...store.get().diagnosticsActions, + start: { loading: true }, + enable: { loading: true }, + }, + sectionsWidget: { + ...store.get().sectionsWidget, + subscriptionUpdatingSections: { main: true }, + latencyFetchingSections: { main: true }, + latencyProgressSections: { + main: { + completed: 1, + total: 2, + failed: 0, + }, + }, + }, + updatesActions: { + ...store.get().updatesActions, + zapretInstall: { loading: true }, + }, + }); + + applyUiStateToStore(createUiState()); + + const state = store.get(); + + expect(state.diagnosticsActions.start.loading).toBe(false); + expect(state.diagnosticsActions.enable.loading).toBe(true); + expect(state.sectionsWidget.subscriptionUpdatingSections).toEqual({}); + expect(state.sectionsWidget.latencyFetchingSections).toEqual({}); + expect(state.sectionsWidget.latencyProgressSections).toEqual({}); + expect(state.updatesActions.zapretInstall.loading).toBe(false); + }); + + it('keeps locally started actions loading until their owner clears them', () => { + setLocalComponentAction('zapretInstall', true); + setLocalSubscriptionAction('proxy', true); + setLocalLatencyAction('gemini', true); + setLocalServiceAction('restart', true); + + applyUiStateToStore(createUiState()); + + expect(store.get().updatesActions.zapretInstall.loading).toBe(true); + expect(store.get().sectionsWidget.subscriptionUpdatingSections).toEqual({ + proxy: true, + }); + expect(store.get().sectionsWidget.latencyFetchingSections).toEqual({ + gemini: true, + }); + expect(store.get().diagnosticsActions.restart.loading).toBe(true); + + clearLocalActionOverlay(); + applyUiStateToStore(createUiState()); + + expect(store.get().updatesActions.zapretInstall.loading).toBe(false); + expect(store.get().sectionsWidget.subscriptionUpdatingSections).toEqual({}); + expect(store.get().sectionsWidget.latencyFetchingSections).toEqual({}); + expect(store.get().diagnosticsActions.restart.loading).toBe(false); + }); + + it('preserves local latency progress while waiting for runtime progress', () => { + setLocalLatencyAction('gemini', true); + store.set({ + sectionsWidget: { + ...store.get().sectionsWidget, + latencyFetchingSections: { gemini: true }, + latencyProgressSections: { + gemini: { + completed: 0, + total: 3, + failed: 0, + }, + }, + }, + }); + + applyUiStateToStore(createUiState()); + + expect(store.get().sectionsWidget.latencyFetchingSections).toEqual({ + gemini: true, + }); + expect(store.get().sectionsWidget.latencyProgressSections).toEqual({ + gemini: { + completed: 0, + total: 3, + failed: 0, + }, + }); + }); + + it('preserves latency progress for running runtime actions without progress', () => { + store.set({ + sectionsWidget: { + ...store.get().sectionsWidget, + latencyFetchingSections: { main: true }, + latencyProgressSections: { + main: { + completed: 2, + total: 5, + failed: 0, + }, + }, + }, + }); + + applyUiStateToStore( + createUiState({ + latency: [ + { + success: true, + running: true, + kind: 'latency', + latency_type: 'proxy_list', + section: 'main', + tag: '["one","two"]', + job_id: 'latency-1', + }, + ], + }), + ); + + expect(store.get().sectionsWidget.latencyFetchingSections).toEqual({ + main: true, + }); + expect(store.get().sectionsWidget.latencyProgressSections).toEqual({ + main: { + completed: 2, + total: 5, + failed: 0, + }, + }); + }); + + it('maps a running reload action to the restart control', () => { + applyUiStateToStore( + createUiState({ + service: [ + { + success: true, + running: true, + kind: 'service', + action: 'reload', + job_id: 'service-reload', + }, + ], + }), + ); + + expect(store.get().diagnosticsActions.restart.loading).toBe(true); + }); + + it('does not combine a stale extended version with tiny capabilities', () => { + store.set({ + diagnosticsSystemInfo: { + ...store.get().diagnosticsSystemInfo, + sing_box_version: '1.13.12-extended-2.3.2', + sing_box_extended: 1, + sing_box_tiny: 0, + }, + }); + + applyUiStateToStore( + createUiState(undefined, { + sing_box_extended: 0, + sing_box_tiny: 1, + sing_box_compressed: 0, + sing_box_tailscale: 0, + }), + ); + + expect(store.get().diagnosticsSystemInfo).toMatchObject({ + sing_box_version: '1.13.12-extended-2.3.2', + sing_box_extended: 1, + sing_box_tiny: 0, + sing_box_tailscale: 1, + }); + }); + + it('keeps current sing-box variant while a different sing-box install action is running', () => { + store.set({ + diagnosticsSystemInfo: { + ...store.get().diagnosticsSystemInfo, + providerInfoLoaded: true, + sing_box_version: '1.12.25', + sing_box_extended: 0, + sing_box_tiny: 1, + sing_box_compressed: 0, + sing_box_tailscale: 1, + zapret_installed: 0, + zapret2_installed: 0, + byedpi_installed: 1, + server_inbounds_enabled_count: 0, + }, + }); + + applyUiStateToStore( + createUiState( + { + component: [ + { + success: true, + running: true, + job_id: 'sing-box-install', + component: 'sing_box', + action: 'install_extended_compressed', + message: 'Install is running', + current_version: '', + latest_version: '', + changed: false, + }, + ], + }, + { + sing_box_extended: 0, + sing_box_tiny: 1, + sing_box_compressed: 0, + sing_box_tailscale: 0, + zapret_installed: 1, + zapret2_installed: 1, + byedpi_installed: 0, + server_inbounds_enabled_count: 2, + }, + ), + ); + + expect(store.get().diagnosticsSystemInfo).toMatchObject({ + providerInfoLoaded: true, + sing_box_version: '1.12.25', + sing_box_extended: 0, + sing_box_tiny: 1, + sing_box_compressed: 0, + sing_box_tailscale: 0, + zapret_installed: 1, + zapret2_installed: 1, + byedpi_installed: 0, + server_inbounds_enabled_count: 2, + }); + expect( + store.get().updatesActions.singBoxInstallExtendedCompressed.loading, + ).toBe(true); + }); + + it('marks the running sing-box install action without pretending it is installed', () => { + applyUiStateToStore( + createUiState( + { + component: [ + { + success: true, + running: true, + job_id: 'sing-box-install', + component: 'sing_box', + action: 'install_extended', + message: 'Install is running', + current_version: '', + latest_version: '', + changed: false, + }, + ], + }, + { + sing_box_extended: 0, + sing_box_tiny: 1, + sing_box_compressed: 0, + sing_box_tailscale: 0, + }, + ), + ); + + expect(store.get().diagnosticsSystemInfo).toMatchObject({ + sing_box_extended: 0, + sing_box_tiny: 1, + sing_box_compressed: 0, + sing_box_tailscale: 0, + }); + expect(store.get().updatesActions.singBoxInstallExtended.loading).toBe( + true, + ); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/services/uiActionNotification.service.ts b/fe-app-tachyon/src/tachyon/services/uiActionNotification.service.ts new file mode 100644 index 00000000..ba1e3785 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/uiActionNotification.service.ts @@ -0,0 +1,165 @@ +const UI_ACTION_NOTIFICATION_STORAGE_KEY = + 'tachyon:owned-ui-action-notifications:v1'; +const MAX_STORED_UI_ACTION_NOTIFICATIONS = 100; + +export type UiActionNotificationKind = 'component' | 'subscription'; + +interface StoredUiActionNotification { + kind: UiActionNotificationKind; + jobId: string; + notified: boolean; + updatedAt: number; +} + +function getSessionStorage(): Storage | null { + if (typeof window === 'undefined') { + return null; + } + + try { + return window.sessionStorage; + } catch { + return null; + } +} + +function getNotificationKey(kind: UiActionNotificationKind, jobId: string) { + return `${kind}:${jobId}`; +} + +function isStoredNotification( + value: unknown, +): value is StoredUiActionNotification { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + + return ( + (candidate.kind === 'component' || candidate.kind === 'subscription') && + typeof candidate.jobId === 'string' && + typeof candidate.notified === 'boolean' && + typeof candidate.updatedAt === 'number' + ); +} + +function readStoredNotifications( + storage: Storage | null, +): StoredUiActionNotification[] { + if (!storage) { + return []; + } + + try { + const parsed = JSON.parse( + storage.getItem(UI_ACTION_NOTIFICATION_STORAGE_KEY) || '[]', + ); + + return Array.isArray(parsed) ? parsed.filter(isStoredNotification) : []; + } catch { + return []; + } +} + +function writeStoredNotifications( + storage: Storage | null, + notifications: StoredUiActionNotification[], +) { + if (!storage) { + return; + } + + try { + storage.setItem( + UI_ACTION_NOTIFICATION_STORAGE_KEY, + JSON.stringify( + notifications + .sort((a, b) => a.updatedAt - b.updatedAt) + .slice(-MAX_STORED_UI_ACTION_NOTIFICATIONS), + ), + ); + } catch { + // In-memory ownership still prevents duplicate notifications this session. + } +} + +export class UiActionNotificationTracker { + private readonly storage: Storage | null; + private readonly notifications = new Map< + string, + StoredUiActionNotification + >(); + + constructor(storage: Storage | null = getSessionStorage()) { + this.storage = storage; + + for (const notification of readStoredNotifications(storage)) { + this.notifications.set( + getNotificationKey(notification.kind, notification.jobId), + notification, + ); + } + } + + markOwned(kind: UiActionNotificationKind, jobId: string) { + if (!jobId) { + return; + } + + const key = getNotificationKey(kind, jobId); + const current = this.notifications.get(key); + + this.notifications.set(key, { + kind, + jobId, + notified: current?.notified ?? false, + updatedAt: Date.now(), + }); + this.persist(); + } + + shouldNotify(kind: UiActionNotificationKind, jobId: string) { + if (!jobId) { + return false; + } + + const key = getNotificationKey(kind, jobId); + const current = this.notifications.get(key); + + if (!current || current.notified) { + return false; + } + + this.notifications.set(key, { + ...current, + notified: true, + updatedAt: Date.now(), + }); + this.persist(); + return true; + } + + private persist() { + writeStoredNotifications( + this.storage, + Array.from(this.notifications.values()), + ); + } +} + +const uiActionNotifications = new UiActionNotificationTracker(); + +export function markUiActionOwned( + kind: UiActionNotificationKind, + jobId: string, +) { + uiActionNotifications.markOwned(kind, jobId); +} + +export function shouldNotifyOwnedUiAction( + kind: UiActionNotificationKind, + jobId: string, +) { + return uiActionNotifications.shouldNotify(kind, jobId); +} diff --git a/fe-app-tachyon/src/tachyon/services/uiState.service.ts b/fe-app-tachyon/src/tachyon/services/uiState.service.ts new file mode 100644 index 00000000..3dc94ef1 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/services/uiState.service.ts @@ -0,0 +1,194 @@ +import { getComponentActionKey } from '../helpers/getComponentActionKey'; +import { normalizeSingBoxVariantFields } from '../helpers/singBoxVariant'; +import type { Tachyon } from '../types'; +import { getLocalActionOverlay } from './localActionOverlay.service'; +import { store } from './store.service'; +import type { StoreType } from './store.service'; + +type UiActionMap = Partial; + +function isRunningAction(state: { running?: boolean }) { + return state.running === true; +} + +function getEmptyUpdatesActions(): StoreType['updatesActions'] { + return { + tachyonCheck: { loading: false }, + tachyonInstall: { loading: false }, + singBoxCheck: { loading: false }, + singBoxInstall: { loading: false }, + singBoxInstallExtended: { loading: false }, + singBoxInstallExtendedCompressed: { loading: false }, + singBoxInstallTiny: { loading: false }, + singBoxInstallStable: { loading: false }, + zapretCheck: { loading: false }, + zapretInstall: { loading: false }, + zapretRemove: { loading: false }, + zapret2Check: { loading: false }, + zapret2Install: { loading: false }, + zapret2Remove: { loading: false }, + byedpiCheck: { loading: false }, + byedpiInstall: { loading: false }, + byedpiRemove: { loading: false }, + }; +} + +function getEmptyDiagnosticsActions(): StoreType['diagnosticsActions'] { + return { + ...store.get().diagnosticsActions, + restart: { loading: false }, + start: { loading: false }, + stop: { loading: false }, + }; +} + +function normalizeLatencyProgress( + progress?: Tachyon.LatencyActionProgress, +): Tachyon.LatencyActionProgress | undefined { + const total = Math.trunc(Number(progress?.total ?? 0)); + + if (!Number.isFinite(total) || total <= 0) { + return undefined; + } + + const completedValue = Number(progress?.completed ?? 0); + const failedValue = Number(progress?.failed ?? 0); + const completed = Number.isFinite(completedValue) + ? Math.trunc(completedValue) + : 0; + const failed = Number.isFinite(failedValue) ? Math.trunc(failedValue) : 0; + + return { + completed: Math.min(Math.max(0, completed), total), + total, + failed: Math.max(0, failed), + }; +} + +function applyServiceState(uiState: Tachyon.UiState) { + const currentSystemInfo = store.get().diagnosticsSystemInfo; + const nextSystemInfo = { + ...currentSystemInfo, + providerInfoLoaded: true, + zapret_installed: uiState.capabilities.zapret_installed, + zapret2_installed: uiState.capabilities.zapret2_installed, + byedpi_installed: uiState.capabilities.byedpi_installed, + server_inbounds_enabled_count: + uiState.capabilities.server_inbounds_enabled_count, + }; + + nextSystemInfo.sing_box_extended = uiState.capabilities.sing_box_extended; + nextSystemInfo.sing_box_tiny = uiState.capabilities.sing_box_tiny; + nextSystemInfo.sing_box_compressed = uiState.capabilities.sing_box_compressed; + nextSystemInfo.sing_box_tailscale = uiState.capabilities.sing_box_tailscale; + + store.set({ + servicesInfoWidget: { + loading: false, + failed: false, + data: { + singbox: uiState.service.sing_box.running, + tachyonRunning: uiState.service.tachyon.running, + tachyonEnabled: uiState.service.tachyon.enabled, + tachyonStatus: uiState.service.tachyon.status, + }, + }, + diagnosticsSystemInfo: normalizeSingBoxVariantFields(nextSystemInfo), + }); +} + +function applyActionState(actions: UiActionMap = {}) { + const current = store.get(); + const localOverlay = getLocalActionOverlay(); + const currentLatencyProgressSections = + current.sectionsWidget.latencyProgressSections; + const subscriptionUpdatingSections: Record = {}; + const latencyFetchingSections: Record = {}; + const latencyProgressSections: Record = + {}; + const updatesActions = getEmptyUpdatesActions(); + const diagnosticsActions = getEmptyDiagnosticsActions(); + + for (const state of actions.subscription || []) { + if (isRunningAction(state) && state.section) { + subscriptionUpdatingSections[state.section] = true; + } + } + + for (const state of actions.latency || []) { + if (isRunningAction(state) && state.section) { + latencyFetchingSections[state.section] = true; + const progress = normalizeLatencyProgress(state.progress); + if (progress) { + latencyProgressSections[state.section] = progress; + } else if (currentLatencyProgressSections[state.section]) { + latencyProgressSections[state.section] = + currentLatencyProgressSections[state.section]; + } + } + } + + for (const state of actions.component || []) { + if (!isRunningAction(state)) { + continue; + } + + const key = getComponentActionKey(state.component, state.action); + if (key) { + updatesActions[key] = { loading: true }; + } + } + + for (const state of actions.service || []) { + if (!isRunningAction(state)) { + continue; + } + + if (state.action === 'start') { + diagnosticsActions.start = { loading: true }; + } else if (state.action === 'stop') { + diagnosticsActions.stop = { loading: true }; + } else if (state.action === 'restart' || state.action === 'reload') { + diagnosticsActions.restart = { loading: true }; + } + } + + for (const section of localOverlay.subscriptionSections) { + subscriptionUpdatingSections[section] = true; + } + + for (const section of localOverlay.latencySections) { + latencyFetchingSections[section] = true; + if ( + !latencyProgressSections[section] && + currentLatencyProgressSections[section] + ) { + latencyProgressSections[section] = + currentLatencyProgressSections[section]; + } + } + + for (const key of localOverlay.componentActions) { + updatesActions[key] = { loading: true }; + } + + for (const action of localOverlay.serviceActions) { + diagnosticsActions[action] = { loading: true }; + } + + store.set({ + sectionsWidget: { + ...current.sectionsWidget, + subscriptionUpdatingSections, + latencyFetchingSections, + latencyProgressSections, + }, + updatesActions, + diagnosticsActions, + }); +} + +export function applyUiStateToStore(uiState: Tachyon.UiState) { + applyServiceState(uiState); + applyActionState(uiState.actions); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/dashboard/index.ts b/fe-app-tachyon/src/tachyon/tabs/dashboard/index.ts new file mode 100644 index 00000000..75407314 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/dashboard/index.ts @@ -0,0 +1,9 @@ +import { render } from './render'; +import { initController } from './initController'; +import { styles } from './styles'; + +export const DashboardTab = { + render, + initController, + styles, +}; diff --git a/fe-app-tachyon/src/tachyon/tabs/dashboard/initController.ts b/fe-app-tachyon/src/tachyon/tabs/dashboard/initController.ts new file mode 100644 index 00000000..ca8fa16c --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/dashboard/initController.ts @@ -0,0 +1,1820 @@ +import { + getClashWsUrl, + isCopyableProxyLink, + onMount, + preserveScrollForPage, +} from '../../../helpers'; +import { copyToClipboard } from '../../../helpers/copyToClipboard'; +import { showToast } from '../../../helpers/showToast'; +import { prettyBytes } from '../../../helpers/prettyBytes'; +import { renderCopyIcon24 } from '../../../icons'; +import { CustomTachyonMethods, TachyonShellMethods } from '../../methods'; +import { + logger, + markUiActionOwned, + setLocalLatencyAction, + setLocalSubscriptionAction, + shouldNotifyOwnedUiAction, + socket, + store, + StoreType, +} from '../../services'; +import { getLatencyTestLabel, renderSections, renderWidget } from './partials'; +import { fetchServicesInfo } from '../../fetchers/fetchServicesInfo'; +import { getClashApiSecret } from '../../methods/custom/getClashApiSecret'; +import { Tachyon } from '../../types'; +import { + getCachedRuntimeUiState, + refreshRuntimeUiState, + subscribeRuntimeUiState, +} from '../../services/runtimeUiState.service'; +import { isActiveLuciTab } from '../../helpers/isActiveLuciTab'; +import { isTransientRpcError } from '../../helpers/isTransientRpcError'; +import { shouldShowLoadingForRestoredAction } from '../../helpers/restoredActionLoading'; +import { getServiceAvailability } from '../../helpers/serviceAvailability'; + +const SECTIONS_REFRESH_INTERVAL_MS = 10000; +const LATENCY_TEST_BUTTON_CLASS = 'dashboard-sections-grid-item-test-latency'; +const LATENCY_TEST_BUTTON_LABEL_CLASS = + 'dashboard-sections-grid-item-test-latency__label'; +let sectionsRefreshTimer: ReturnType | null = null; +let sectionsRefreshPromise: Promise | null = null; +let sectionsRefreshQueued = false; +let actionStateUnsubscribe: (() => void) | null = null; +let dashboardMounted = false; +let dashboardMountId = 0; +let dashboardDataUpdatesStarted = false; +let dashboardDataUpdatesId = 0; +let pageUnloading = false; +const followedSubscriptionJobs = new Set(); +const followedLatencyJobs = new Set(); +const handledSubscriptionJobs = new Set(); +const handledLatencyJobs = new Set(); + +if (typeof window !== 'undefined') { + window.addEventListener('pagehide', () => { + pageUnloading = true; + }); + window.addEventListener('pageshow', () => { + pageUnloading = false; + }); +} + +// Fetchers + +async function fetchDashboardSectionsOnce(mountId: number) { + if (getDashboardServiceAvailability() === 'stopped') { + return false; + } + + const prev = store.get().sectionsWidget; + const hasRenderedData = prev.data.length > 0; + + store.set({ + sectionsWidget: { + ...prev, + failed: false, + loading: prev.loading && !hasRenderedData, + }, + }); + + try { + const { data, success } = await CustomTachyonMethods.getDashboardSections(); + + if ( + !dashboardMounted || + mountId !== dashboardMountId || + getDashboardServiceAvailability() === 'stopped' + ) { + return false; + } + + if (!success) { + throw new Error('failed to fetch dashboard sections'); + } + + const current = store.get().sectionsWidget; + + store.set({ + sectionsWidget: { + ...current, + loading: false, + failed: false, + data, + }, + }); + + return true; + } catch (error) { + logger.error('[DASHBOARD]', 'fetchDashboardSections: failed', error); + + if ( + !dashboardMounted || + mountId !== dashboardMountId || + getDashboardServiceAvailability() === 'stopped' + ) { + return false; + } + + const current = store.get().sectionsWidget; + + store.set({ + sectionsWidget: { + ...current, + loading: false, + failed: current.data.length === 0, + data: current.data, + }, + }); + + return false; + } +} + +async function fetchDashboardSections(options: { force?: boolean } = {}) { + if (sectionsRefreshPromise) { + if (options.force) { + sectionsRefreshQueued = true; + } + + return sectionsRefreshPromise; + } + + const mountId = dashboardMountId; + const promise = (async () => { + let success = false; + + do { + sectionsRefreshQueued = false; + success = await fetchDashboardSectionsOnce(mountId); + } while ( + sectionsRefreshQueued && + dashboardMounted && + mountId === dashboardMountId + ); + + return success; + })(); + + sectionsRefreshPromise = promise; + + try { + return await promise; + } finally { + if (sectionsRefreshPromise === promise) { + sectionsRefreshPromise = null; + } + } +} + +function setSubscriptionUpdating( + sectionName: string, + updating: boolean, + local = false, +) { + if (local || !updating) { + setLocalSubscriptionAction(sectionName, updating && local); + } + + const sectionsWidget = store.get().sectionsWidget; + const subscriptionUpdatingSections = { + ...sectionsWidget.subscriptionUpdatingSections, + }; + + if (updating) { + subscriptionUpdatingSections[sectionName] = true; + } else { + delete subscriptionUpdatingSections[sectionName]; + } + + store.set({ + sectionsWidget: { + ...sectionsWidget, + subscriptionUpdatingSections, + }, + }); +} + +function setSelectorSwitching(sectionName: string, tag?: string) { + const sectionsWidget = store.get().sectionsWidget; + const selectorSwitchingSections = { + ...sectionsWidget.selectorSwitchingSections, + }; + + if (tag) { + selectorSwitchingSections[sectionName] = tag; + } else { + delete selectorSwitchingSections[sectionName]; + } + + store.set({ + sectionsWidget: { + ...sectionsWidget, + selectorSwitchingSections, + }, + }); +} + +function setLatencyFetching( + sectionName: string, + fetching: boolean, + local = false, + progress?: Tachyon.LatencyActionProgress, +) { + if (local || !fetching) { + setLocalLatencyAction(sectionName, fetching && local); + } + + const sectionsWidget = store.get().sectionsWidget; + const latencyFetchingSections = { + ...sectionsWidget.latencyFetchingSections, + }; + const latencyProgressSections = { + ...sectionsWidget.latencyProgressSections, + }; + + if (fetching) { + latencyFetchingSections[sectionName] = true; + if (progress) { + latencyProgressSections[sectionName] = progress; + } + } else { + delete latencyFetchingSections[sectionName]; + delete latencyProgressSections[sectionName]; + } + + store.set({ + sectionsWidget: { + ...sectionsWidget, + latencyFetchingSections, + latencyProgressSections, + }, + }); +} + +async function completeSubscriptionUpdateJob( + jobId: string, + sectionName: string, + response: Tachyon.MethodResponse, +) { + if (pageUnloading) { + setSubscriptionUpdating(sectionName, false); + return; + } + + if (jobId && handledSubscriptionJobs.has(jobId)) { + setSubscriptionUpdating(sectionName, false); + return; + } + + const shouldNotify = jobId + ? shouldNotifyOwnedUiAction('subscription', jobId) + : false; + const failed = !response.success || response.data.success === false; + const message = response.success + ? response.data.message || _('Failed to update subscriptions') + : response.error || _('Failed to update subscriptions'); + + if (failed && isTransientRpcError(message)) { + void refreshRuntimeUiState({ force: true }); + return; + } + + if (jobId) { + handledSubscriptionJobs.add(jobId); + } + + setSubscriptionUpdating(sectionName, false); + + if (jobId && response.success) { + void TachyonShellMethods.uiActionAck('subscription', jobId); + } + + if (failed) { + if (shouldNotify) { + showToast(_('Failed to update subscriptions'), 'error'); + } + return; + } + + if (shouldNotify) { + showToast(_('Subscriptions updated'), 'success'); + } + void fetchDashboardSections({ force: true }); + void fetchServicesInfo(); +} + +async function followSubscriptionUpdateState( + state: Tachyon.SubscriptionUpdateJobState, +) { + const jobId = state.job_id; + const sectionName = state.section || ''; + + if (!jobId || !sectionName || followedSubscriptionJobs.has(jobId)) { + return; + } + + if (!state.running && handledSubscriptionJobs.has(jobId)) { + return; + } + + followedSubscriptionJobs.add(jobId); + if (shouldShowLoadingForRestoredAction(state)) { + setSubscriptionUpdating(sectionName, true); + } + + try { + const response = state.running + ? await TachyonShellMethods.waitSubscriptionUpdateJob(jobId) + : ({ + success: true, + data: state, + } as Tachyon.MethodSuccessResponse); + + await completeSubscriptionUpdateJob(jobId, sectionName, response); + } catch (error) { + logger.error('[DASHBOARD]', 'followSubscriptionUpdateState failed', error); + if (!pageUnloading) { + const message = + error instanceof Error + ? error.message + : _('Failed to update subscriptions'); + + setSubscriptionUpdating(sectionName, false); + if (!isTransientRpcError(message)) { + showToast(_('Failed to update subscriptions'), 'error'); + } + } + } finally { + followedSubscriptionJobs.delete(jobId); + } +} + +async function completeLatencyTestJob(jobId: string, sectionName: string) { + setLatencyFetching(sectionName, false); + + if (pageUnloading) { + return; + } + + if (jobId && handledLatencyJobs.has(jobId)) { + return; + } + + if (jobId) { + handledLatencyJobs.add(jobId); + } + + if (jobId) { + void TachyonShellMethods.uiActionAck('latency', jobId); + } + + void fetchDashboardSections({ force: true }); +} + +async function followLatencyTestState(state: Tachyon.LatencyActionState) { + const jobId = state.job_id; + const sectionName = state.section || ''; + + if (!jobId || !sectionName || followedLatencyJobs.has(jobId)) { + return; + } + + if (!state.running && handledLatencyJobs.has(jobId)) { + return; + } + + followedLatencyJobs.add(jobId); + if (shouldShowLoadingForRestoredAction(state)) { + setLatencyFetching(sectionName, true); + } + + try { + if (state.running) { + await TachyonShellMethods.waitLatencyTestJob(jobId); + } + + await completeLatencyTestJob(jobId, sectionName); + } catch (error) { + logger.error('[DASHBOARD]', 'followLatencyTestState failed', error); + if (!pageUnloading) { + setLatencyFetching(sectionName, false); + } + } finally { + followedLatencyJobs.delete(jobId); + } +} + +function followDashboardActionsFromUiState(uiState: Tachyon.UiState) { + for (const state of uiState.actions.subscription || []) { + if (state.running || (state.job_id && state.section)) { + void followSubscriptionUpdateState(state); + } else if (state.job_id && !handledSubscriptionJobs.has(state.job_id)) { + handledSubscriptionJobs.add(state.job_id); + void TachyonShellMethods.uiActionAck('subscription', state.job_id); + } + } + + for (const state of uiState.actions.latency || []) { + if (state.running || (state.job_id && state.section)) { + void followLatencyTestState(state); + } else if (state.job_id && !handledLatencyJobs.has(state.job_id)) { + handledLatencyJobs.add(state.job_id); + void TachyonShellMethods.uiActionAck('latency', state.job_id); + } + } +} + +function startActionStateWatcher() { + if (actionStateUnsubscribe) { + return; + } + + actionStateUnsubscribe = subscribeRuntimeUiState((uiState) => { + if (dashboardMounted) { + followDashboardActionsFromUiState(uiState); + } + }); +} + +function stopActionStateWatcher() { + if (!actionStateUnsubscribe) { + return; + } + + actionStateUnsubscribe(); + actionStateUnsubscribe = null; +} + +async function connectToClashSockets(dataUpdatesId: number) { + const mountId = dashboardMountId; + const clashApiSecret = await getClashApiSecret(); + + if ( + !dashboardMounted || + mountId !== dashboardMountId || + dataUpdatesId !== dashboardDataUpdatesId || + getDashboardServiceAvailability() === 'stopped' + ) { + return; + } + + socket.subscribe( + `${getClashWsUrl()}/traffic?token=${clashApiSecret}`, + (msg) => { + if ( + dataUpdatesId !== dashboardDataUpdatesId || + getDashboardServiceAvailability() === 'stopped' + ) { + return; + } + + const parsedMsg = JSON.parse(msg); + + store.set({ + bandwidthWidget: { + loading: false, + failed: false, + data: { up: parsedMsg.up, down: parsedMsg.down }, + }, + }); + }, + (_err) => { + if ( + dataUpdatesId !== dashboardDataUpdatesId || + getDashboardServiceAvailability() === 'stopped' + ) { + return; + } + + logger.error( + '[DASHBOARD]', + 'connectToClashSockets - traffic: failed to connect to', + getClashWsUrl(), + ); + store.set({ + bandwidthWidget: { + loading: false, + failed: true, + data: { up: 0, down: 0 }, + }, + }); + }, + ); + + socket.subscribe( + `${getClashWsUrl()}/connections?token=${clashApiSecret}`, + (msg) => { + if ( + dataUpdatesId !== dashboardDataUpdatesId || + getDashboardServiceAvailability() === 'stopped' + ) { + return; + } + + const parsedMsg = JSON.parse(msg); + + store.set({ + trafficTotalWidget: { + loading: false, + failed: false, + data: { + downloadTotal: parsedMsg.downloadTotal, + uploadTotal: parsedMsg.uploadTotal, + }, + }, + systemInfoWidget: { + loading: false, + failed: false, + data: { + connections: parsedMsg.connections?.length, + memory: parsedMsg.memory, + }, + }, + }); + }, + (_err) => { + if ( + dataUpdatesId !== dashboardDataUpdatesId || + getDashboardServiceAvailability() === 'stopped' + ) { + return; + } + + logger.error( + '[DASHBOARD]', + 'connectToClashSockets - connections: failed to connect to', + getClashWsUrl(), + ); + store.set({ + trafficTotalWidget: { + loading: false, + failed: true, + data: { downloadTotal: 0, uploadTotal: 0 }, + }, + systemInfoWidget: { + loading: false, + failed: true, + data: { + connections: 0, + memory: 0, + }, + }, + }); + }, + ); +} + +function getDashboardServiceAvailability() { + const service = store.get().servicesInfoWidget; + + return getServiceAvailability({ + loading: service.loading, + failed: service.failed, + running: service.data.tachyonRunning, + }); +} + +function stopDashboardDataUpdates() { + dashboardDataUpdatesStarted = false; + dashboardDataUpdatesId += 1; + + if (sectionsRefreshTimer) { + clearInterval(sectionsRefreshTimer); + sectionsRefreshTimer = null; + } + + sectionsRefreshQueued = false; + socket.resetAll(); +} + +function startDashboardDataUpdates() { + if ( + dashboardDataUpdatesStarted || + !dashboardMounted || + getDashboardServiceAvailability() === 'stopped' + ) { + return; + } + + dashboardDataUpdatesStarted = true; + const dataUpdatesId = ++dashboardDataUpdatesId; + void fetchDashboardSections({ force: true }); + void connectToClashSockets(dataUpdatesId); + sectionsRefreshTimer = setInterval(() => { + void fetchDashboardSections(); + }, SECTIONS_REFRESH_INTERVAL_MS); +} + +function syncDashboardServiceAvailability() { + const availability = getDashboardServiceAvailability(); + const stopped = availability === 'stopped'; + const container = document.getElementById('dashboard-status'); + + container?.classList.toggle('fkp_dashboard-page--service-stopped', stopped); + + if (stopped || availability === 'loading') { + stopDashboardDataUpdates(); + return; + } + + startDashboardDataUpdates(); +} + +// Handlers + +async function handleChooseOutbound( + sectionName: string, + selector: string, + tag: string, +) { + const sectionsWidget = store.get().sectionsWidget; + const section = sectionsWidget.data.find( + (item) => item.sectionName === sectionName, + ); + + if ( + !section?.withTagSelect || + sectionsWidget.selectorSwitchingSections[sectionName] || + section.outbounds.some( + (outbound) => outbound.code === tag && outbound.selected, + ) + ) { + return; + } + + setSelectorSwitching(sectionName, tag); + + try { + await TachyonShellMethods.setClashApiGroupProxy(selector, tag); + await fetchDashboardSections({ force: true }); + } finally { + setSelectorSwitching(sectionName); + } +} + +function getInitialLatencyProgress( + latencyType: Tachyon.LatencyActionState['latency_type'], + tag: string, +): Tachyon.LatencyActionProgress | undefined { + if (latencyType !== 'proxy_list') { + return undefined; + } + + try { + const tags = JSON.parse(tag); + if (!Array.isArray(tags)) { + return undefined; + } + + const total = tags.filter( + (item) => typeof item === 'string' && item.length > 0, + ).length; + + return total > 0 ? { completed: 0, total, failed: 0 } : undefined; + } catch { + return undefined; + } +} + +async function handleTestLatency( + latencyType: Tachyon.LatencyActionState['latency_type'], + sectionName: string, + tag: string, + timeout?: string, +) { + if (store.get().sectionsWidget.latencyFetchingSections[sectionName]) { + return; + } + + setLatencyFetching( + sectionName, + true, + true, + getInitialLatencyProgress(latencyType, tag), + ); + let jobId = ''; + let ownsJobFollow = false; + let completed = false; + + try { + const startResponse = await TachyonShellMethods.latencyTestStart( + latencyType, + sectionName, + tag, + timeout, + ); + + if (!startResponse.success) { + throw new Error(startResponse.error); + } + + jobId = startResponse.data.job_id; + if (followedLatencyJobs.has(jobId)) { + completed = true; + return; + } + + followedLatencyJobs.add(jobId); + ownsJobFollow = true; + await TachyonShellMethods.waitLatencyTestJob(jobId); + await completeLatencyTestJob(jobId, sectionName); + completed = true; + } catch (error) { + logger.error('[DASHBOARD]', 'handleTestLatency: failed', error); + } finally { + if (ownsJobFollow) { + followedLatencyJobs.delete(jobId); + } + + if (!completed) { + setLatencyFetching(sectionName, false); + } + } +} + +function handleCopyOutbound(outbound: Tachyon.Outbound) { + const link = outbound.link; + + if (link && isCopyableProxyLink(link)) { + copyToClipboard(link); + return; + } + + showToast(_('Proxy link is unavailable'), 'error'); +} + +function formatUrlTestModalValue(value: unknown) { + if (typeof value === 'boolean') { + return value ? _('Yes') : _('No'); + } + + const text = `${value ?? ''}`.trim(); + return text || _('No'); +} + +function getUrlTestLatencyClass(latency: number) { + if (!latency) { + return 'fkp_dashboard-page__outbound-grid__item__latency--empty'; + } + + if (latency < 800) { + return 'fkp_dashboard-page__outbound-grid__item__latency--green'; + } + + if (latency < 1500) { + return 'fkp_dashboard-page__outbound-grid__item__latency--yellow'; + } + + return 'fkp_dashboard-page__outbound-grid__item__latency--red'; +} + +function formatUrlTestLatency(latency: number) { + return latency ? `${latency}ms` : 'N/A'; +} + +function renderDetailsUrl(value: unknown) { + const url = `${value ?? ''}`.trim(); + + if (!/^https?:\/\//i.test(url)) { + return E('span', {}, formatUrlTestModalValue(value)); + } + + return E( + 'a', + { + class: 'fkp_dashboard-page__urltest-details__url', + href: url, + target: '_blank', + rel: 'noopener noreferrer', + }, + url, + ); +} + +function getDetectedCountryFlag(country?: string) { + const code = `${country || ''}`.trim().toUpperCase(); + + if (!/^[A-Z]{2}$/.test(code)) { + return ''; + } + + return String.fromCodePoint( + ...code.split('').map((char) => 0x1f1e6 + char.charCodeAt(0) - 65), + ); +} + +function renderDetailsMemberName(member: Tachyon.UrlTestMember) { + const countryFlag = getDetectedCountryFlag(member.country); + if (!countryFlag) { + return member.displayName; + } + + return [ + E( + 'span', + { class: 'fkp_dashboard-page__urltest-details__country-badge' }, + countryFlag, + ), + member.displayName, + ]; +} + +function renderUrlTestSelectedValue(info: Tachyon.UrlTestInfo) { + const selectedMember = info.outbounds.find((member) => member.selected); + const selectedName = + selectedMember?.displayName || info.selectedName || info.selectedCode || ''; + const name = formatUrlTestModalValue(selectedName); + + if (name === _('No')) { + return E('span', {}, name); + } + + return E( + 'span', + { class: 'fkp_dashboard-page__urltest-details__selected-value' }, + [ + E( + 'span', + { class: 'fkp_dashboard-page__urltest-details__selected-name' }, + selectedMember ? renderDetailsMemberName(selectedMember) : name, + ), + ...(selectedMember?.type + ? [ + E( + 'span', + { class: 'fkp_dashboard-page__urltest-details__selected-type' }, + selectedMember.type, + ), + ] + : []), + ...(selectedMember + ? [ + E( + 'span', + { class: getUrlTestLatencyClass(selectedMember.latency) }, + formatUrlTestLatency(selectedMember.latency), + ), + ] + : []), + ], + ); +} + +function renderUrlTestCopyButton( + title: string, + onClick: (event: MouseEvent) => void, +) { + return E( + 'button', + { + type: 'button', + class: 'btn fkp_dashboard-page__urltest-details__copy-button', + title, + 'aria-label': title, + click: onClick, + }, + renderCopyIcon24(), + ); +} + +function renderUrlTestInfoModal(outbound: Tachyon.Outbound) { + const info = outbound.urlTestInfo; + + if (!info) { + return E('div', {}, _('URLTest details are unavailable')); + } + + const fields: Array<{ + label: string; + value?: unknown; + children?: Array; + }> = [ + { + label: _('Selected'), + children: [renderUrlTestSelectedValue(info)], + }, + { label: _('Testing URL'), children: [renderDetailsUrl(info.url)] }, + { label: _('Interval'), value: info.interval }, + { label: _('Tolerance'), value: info.tolerance }, + { label: _('Idle timeout'), value: info.idleTimeout }, + { + label: _('Interrupt connections'), + value: info.interruptExistConnections, + }, + ]; + + return E('div', { class: 'fkp_dashboard-page__urltest-details' }, [ + E( + 'dl', + { class: 'fkp_dashboard-page__urltest-details__params' }, + fields.map(({ label, value, children }) => + E('div', { class: 'fkp_dashboard-page__urltest-details__param' }, [ + E('dt', {}, label), + E( + 'dd', + {}, + children || [E('span', {}, formatUrlTestModalValue(value))], + ), + ]), + ), + ), + E('div', { class: 'fkp_dashboard-page__urltest-details__outbounds' }, [ + E( + 'div', + { class: 'fkp_dashboard-page__urltest-details__outbounds-title' }, + _('Nodes'), + ), + E( + 'div', + { class: 'fkp_dashboard-page__urltest-details__table' }, + info.outbounds.length + ? info.outbounds.map((member) => + E( + 'div', + { + class: [ + 'fkp_dashboard-page__urltest-details__row', + member.selected + ? 'fkp_dashboard-page__urltest-details__row--active' + : '', + ] + .filter(Boolean) + .join(' '), + }, + [ + E( + 'div', + { + class: 'fkp_dashboard-page__urltest-details__row-name', + }, + [ + E('b', {}, renderDetailsMemberName(member)), + ...(member.type + ? [ + E( + 'span', + { + class: + 'fkp_dashboard-page__urltest-details__row-type', + }, + member.type, + ), + ] + : []), + ], + ), + E( + 'div', + { + class: 'fkp_dashboard-page__urltest-details__row-meta', + }, + [ + E( + 'span', + { class: getUrlTestLatencyClass(member.latency) }, + formatUrlTestLatency(member.latency), + ), + ], + ), + member.canCopyLink + ? renderUrlTestCopyButton(_('Copy proxy link'), (event) => { + event.preventDefault(); + handleCopyOutbound(member); + }) + : E('span', { + class: + 'fkp_dashboard-page__urltest-details__copy-placeholder', + }), + ], + ), + ) + : [ + E( + 'div', + { class: 'fkp_dashboard-page__urltest-details__empty' }, + _('Node list is empty'), + ), + ], + ), + ]), + E('div', { class: 'fkp_dashboard-page__urltest-details__footer' }, [ + E( + 'button', + { + type: 'button', + class: 'btn cbi-button cbi-button-neutral', + click: () => { + ui.hideModal(); + }, + }, + _('Close'), + ), + ]), + ]); +} + +function handleShowUrlTestInfo(outbound: Tachyon.Outbound) { + if (!outbound.urlTestInfo) { + return; + } + + ui.showModal( + `${_('URLTest details')}: ${ + outbound.urlTestInfo.displayName || outbound.displayName + }`, + renderUrlTestInfoModal(outbound), + ); +} + +function renderPrioritySelectedValue(info: Tachyon.PriorityInfo) { + const selectedMember = info.outbounds.find((member) => member.selected); + const selectedName = + selectedMember?.displayName || info.selectedName || info.selectedCode || ''; + const name = formatUrlTestModalValue(selectedName); + + if (name === _('No')) { + return E('span', {}, name); + } + + return E( + 'span', + { class: 'fkp_dashboard-page__urltest-details__selected-value' }, + [ + E( + 'span', + { + class: [ + 'fkp_dashboard-page__urltest-details__selected-name', + selectedMember + ? 'fkp_dashboard-page__urltest-details__priority-name' + : '', + ] + .filter(Boolean) + .join(' '), + }, + selectedMember ? renderPriorityMemberName(selectedMember) : name, + ), + ...(selectedMember?.type + ? [ + E( + 'span', + { class: 'fkp_dashboard-page__urltest-details__selected-type' }, + selectedMember.type, + ), + ] + : []), + ...(selectedMember + ? [ + E( + 'span', + { class: getUrlTestLatencyClass(selectedMember.latency) }, + formatUrlTestLatency(selectedMember.latency), + ), + ] + : []), + ], + ); +} + +function renderPriorityMemberName(member: Tachyon.PriorityMember) { + const levelName = member.levelName || _('Level'); + + return [ + E( + 'span', + { class: 'fkp_dashboard-page__urltest-details__priority-number' }, + `#${member.levelIndex + 1}`, + ), + E( + 'span', + { class: 'fkp_dashboard-page__urltest-details__priority-level' }, + levelName, + ), + E( + 'span', + { class: 'fkp_dashboard-page__urltest-details__priority-node' }, + renderDetailsMemberName(member), + ), + ]; +} + +function renderPriorityInfoModal(outbound: Tachyon.Outbound) { + const info = outbound.priorityInfo; + + if (!info) { + return E('div', {}, _('Priority details are unavailable')); + } + + const fields: Array<{ + label: string; + value?: unknown; + children?: Array; + }> = [ + { + label: _('Selected'), + children: [renderPrioritySelectedValue(info)], + }, + { label: _('Check URL'), children: [renderDetailsUrl(info.healthUrl)] }, + { + label: _('Check interval'), + value: info.activeCheckInterval, + }, + { label: _('Unavailability timeout'), value: info.checkTimeout }, + { + label: _('Higher-level check interval'), + value: info.recoveryCheckInterval, + }, + { + label: _('Select the fastest node'), + value: info.pickFastest, + }, + { + label: _('Automatically select the fastest node in the current level'), + value: info.switchToFasterSamePriority, + }, + ...(info.switchToFasterSamePriority + ? [ + { + label: _('Faster server search interval'), + value: info.fastestCheckInterval, + }, + ] + : []), + { + label: _('Interrupt connections'), + value: info.interruptExistConnections, + }, + ]; + + return E('div', { class: 'fkp_dashboard-page__urltest-details' }, [ + E( + 'dl', + { class: 'fkp_dashboard-page__urltest-details__params' }, + fields.map(({ label, value, children }) => + E('div', { class: 'fkp_dashboard-page__urltest-details__param' }, [ + E('dt', {}, label), + E( + 'dd', + {}, + children || [E('span', {}, formatUrlTestModalValue(value))], + ), + ]), + ), + ), + E('div', { class: 'fkp_dashboard-page__urltest-details__outbounds' }, [ + E( + 'div', + { class: 'fkp_dashboard-page__urltest-details__outbounds-title' }, + _('Nodes'), + ), + E( + 'div', + { class: 'fkp_dashboard-page__urltest-details__table' }, + info.outbounds.length + ? info.outbounds.map((member) => + E( + 'div', + { + class: [ + 'fkp_dashboard-page__urltest-details__row', + member.selected + ? 'fkp_dashboard-page__urltest-details__row--active' + : '', + ] + .filter(Boolean) + .join(' '), + }, + [ + E( + 'div', + { + class: 'fkp_dashboard-page__urltest-details__row-name', + }, + [ + E( + 'b', + { + class: + 'fkp_dashboard-page__urltest-details__priority-name', + }, + renderPriorityMemberName(member), + ), + ...(member.type + ? [ + E( + 'span', + { + class: + 'fkp_dashboard-page__urltest-details__row-type', + }, + member.type, + ), + ] + : []), + ], + ), + E( + 'div', + { + class: 'fkp_dashboard-page__urltest-details__row-meta', + }, + [ + E( + 'span', + { class: getUrlTestLatencyClass(member.latency) }, + formatUrlTestLatency(member.latency), + ), + ], + ), + member.canCopyLink + ? renderUrlTestCopyButton(_('Copy proxy link'), (event) => { + event.preventDefault(); + handleCopyOutbound(member); + }) + : E('span', { + class: + 'fkp_dashboard-page__urltest-details__copy-placeholder', + }), + ], + ), + ) + : [ + E( + 'div', + { class: 'fkp_dashboard-page__urltest-details__empty' }, + _('Node list is empty'), + ), + ], + ), + ]), + E('div', { class: 'fkp_dashboard-page__urltest-details__footer' }, [ + E( + 'button', + { + type: 'button', + class: 'btn cbi-button cbi-button-neutral', + click: () => { + ui.hideModal(); + }, + }, + _('Close'), + ), + ]), + ]); +} + +function handleShowPriorityInfo(outbound: Tachyon.Outbound) { + if (!outbound.priorityInfo) { + return; + } + + ui.showModal( + `${_('Priority details')}: ${ + outbound.priorityInfo.displayName || outbound.displayName + }`, + renderPriorityInfoModal(outbound), + ); +} + +async function handleUpdateSubscription(section: Tachyon.OutboundGroup) { + if ( + store.get().sectionsWidget.subscriptionUpdatingSections[section.sectionName] + ) { + return; + } + + setSubscriptionUpdating(section.sectionName, true, true); + let jobId = ''; + let ownsJobFollow = false; + + try { + const startResponse = await TachyonShellMethods.subscriptionUpdateStart( + section.sectionName, + ); + + if (!startResponse.success) { + throw new Error(startResponse.error); + } + + jobId = startResponse.data.job_id; + markUiActionOwned('subscription', jobId); + if (followedSubscriptionJobs.has(jobId)) { + return; + } + + followedSubscriptionJobs.add(jobId); + ownsJobFollow = true; + const response = await TachyonShellMethods.waitSubscriptionUpdateJob(jobId); + await completeSubscriptionUpdateJob(jobId, section.sectionName, response); + } catch (error) { + logger.error('[DASHBOARD]', 'handleUpdateSubscription: failed', error); + if (!pageUnloading) { + const message = + error instanceof Error + ? error.message + : _('Failed to update subscriptions'); + + setSubscriptionUpdating(section.sectionName, false); + if (!isTransientRpcError(message)) { + showToast(_('Failed to update subscriptions'), 'error'); + } + } + } finally { + if (ownsJobFollow) { + followedSubscriptionJobs.delete(jobId); + } + } +} + +function shallowRecordEqual( + left: Record, + right: Record, +) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every((key) => left[key] === right[key]); +} + +function canUpdateLatencyProgressInline( + prev: StoreType['sectionsWidget'], + next: StoreType['sectionsWidget'], +) { + return ( + prev.loading === next.loading && + prev.failed === next.failed && + prev.data === next.data && + shallowRecordEqual( + prev.latencyFetchingSections, + next.latencyFetchingSections, + ) && + shallowRecordEqual( + prev.subscriptionUpdatingSections, + next.subscriptionUpdatingSections, + ) && + shallowRecordEqual( + prev.selectorSwitchingSections, + next.selectorSwitchingSections, + ) + ); +} + +function findLatencyTestButton(container: HTMLElement, sectionName: string) { + return Array.from( + container.querySelectorAll( + `.${LATENCY_TEST_BUTTON_CLASS}`, + ), + ).find((button) => button.dataset.latencySection === sectionName); +} + +function updateLatencyProgressInline( + sectionsWidget: StoreType['sectionsWidget'], +) { + const container = document.getElementById('dashboard-sections-grid'); + + if (!container) { + return false; + } + + for (const section of sectionsWidget.data) { + if (!sectionsWidget.latencyFetchingSections[section.sectionName]) { + continue; + } + + const button = findLatencyTestButton(container, section.sectionName); + const label = button?.querySelector( + `.${LATENCY_TEST_BUTTON_LABEL_CLASS}`, + ); + + if (!label) { + return false; + } + + const text = getLatencyTestLabel( + sectionsWidget.latencyProgressSections[section.sectionName], + ); + + if (label.textContent !== text) { + label.textContent = text; + } + } + + return true; +} + +// Renderer + +async function renderSectionsWidget() { + logger.debug('[DASHBOARD]', 'renderSectionsWidget'); + const sectionsWidget = store.get().sectionsWidget; + const container = document.getElementById('dashboard-sections-grid'); + + if (!container) { + return; + } + + if (sectionsWidget.loading || sectionsWidget.failed) { + const renderedWidget = renderSections({ + loading: sectionsWidget.loading, + failed: sectionsWidget.failed, + section: { + code: '', + sectionName: '', + displayName: '', + outbounds: [], + withTagSelect: false, + }, + onTestLatency: () => {}, + onChooseOutbound: () => {}, + onCopyOutbound: () => {}, + onShowUrlTestInfo: () => {}, + onShowPriorityInfo: () => {}, + onUpdateSubscription: () => {}, + latencyFetching: false, + latencyProgress: undefined, + subscriptionUpdating: false, + selectorSwitchingTag: undefined, + }); + + return preserveScrollForPage(() => { + container.replaceChildren(renderedWidget); + }); + } + + const renderedWidgets = sectionsWidget.data.map((section) => + renderSections({ + loading: sectionsWidget.loading, + failed: sectionsWidget.failed, + section, + latencyFetching: Boolean( + sectionsWidget.latencyFetchingSections[section.sectionName], + ), + latencyProgress: + sectionsWidget.latencyProgressSections[section.sectionName], + subscriptionUpdating: Boolean( + sectionsWidget.subscriptionUpdatingSections[section.sectionName], + ), + selectorSwitchingTag: + sectionsWidget.selectorSwitchingSections[section.sectionName], + onTestLatency: (tag) => { + if (section.withTagSelect) { + if (Array.isArray(tag)) { + return handleTestLatency( + 'proxy_list', + section.sectionName, + JSON.stringify(tag), + ); + } + + return handleTestLatency('group', section.sectionName, tag); + } + + return handleTestLatency( + 'proxy', + section.sectionName, + Array.isArray(tag) ? JSON.stringify(tag) : tag, + section.latencyTestTimeout, + ); + }, + onChooseOutbound: (sectionName, selector, tag) => { + void handleChooseOutbound(sectionName, selector, tag); + }, + onCopyOutbound: (_section, outbound) => { + handleCopyOutbound(outbound); + }, + onShowUrlTestInfo: (outbound) => { + handleShowUrlTestInfo(outbound); + }, + onShowPriorityInfo: (outbound) => { + handleShowPriorityInfo(outbound); + }, + onUpdateSubscription: (section) => { + void handleUpdateSubscription(section); + }, + }), + ); + + return preserveScrollForPage(() => { + container.replaceChildren(...renderedWidgets); + }); +} + +async function renderBandwidthWidget() { + logger.debug('[DASHBOARD]', 'renderBandwidthWidget'); + const traffic = store.get().bandwidthWidget; + + const container = document.getElementById('dashboard-widget-traffic'); + + if (!container) { + return; + } + + if (traffic.loading || traffic.failed) { + const renderedWidget = renderWidget({ + loading: traffic.loading, + failed: traffic.failed, + title: '', + items: [], + }); + + return container.replaceChildren(renderedWidget); + } + + const renderedWidget = renderWidget({ + loading: traffic.loading, + failed: traffic.failed, + title: _('Traffic'), + items: [ + { key: _('Uplink'), value: `${prettyBytes(traffic.data.up)}/s` }, + { key: _('Downlink'), value: `${prettyBytes(traffic.data.down)}/s` }, + ], + }); + + container.replaceChildren(renderedWidget); +} + +async function renderTrafficTotalWidget() { + logger.debug('[DASHBOARD]', 'renderTrafficTotalWidget'); + const trafficTotalWidget = store.get().trafficTotalWidget; + + const container = document.getElementById('dashboard-widget-traffic-total'); + + if (!container) { + return; + } + + if (trafficTotalWidget.loading || trafficTotalWidget.failed) { + const renderedWidget = renderWidget({ + loading: trafficTotalWidget.loading, + failed: trafficTotalWidget.failed, + title: '', + items: [], + }); + + return container.replaceChildren(renderedWidget); + } + + const renderedWidget = renderWidget({ + loading: trafficTotalWidget.loading, + failed: trafficTotalWidget.failed, + title: _('Traffic Total'), + items: [ + { + key: _('Uplink'), + value: String(prettyBytes(trafficTotalWidget.data.uploadTotal)), + }, + { + key: _('Downlink'), + value: String(prettyBytes(trafficTotalWidget.data.downloadTotal)), + }, + ], + }); + + container.replaceChildren(renderedWidget); +} + +async function renderSystemInfoWidget() { + logger.debug('[DASHBOARD]', 'renderSystemInfoWidget'); + const systemInfoWidget = store.get().systemInfoWidget; + + const container = document.getElementById('dashboard-widget-system-info'); + + if (!container) { + return; + } + + if (systemInfoWidget.loading || systemInfoWidget.failed) { + const renderedWidget = renderWidget({ + loading: systemInfoWidget.loading, + failed: systemInfoWidget.failed, + title: '', + items: [], + }); + + return container.replaceChildren(renderedWidget); + } + + const renderedWidget = renderWidget({ + loading: systemInfoWidget.loading, + failed: systemInfoWidget.failed, + title: _('System info'), + items: [ + { + key: _('Active Connections'), + value: String(systemInfoWidget.data.connections), + }, + { + key: _('Memory Usage'), + value: String(prettyBytes(systemInfoWidget.data.memory)), + }, + ], + }); + + container.replaceChildren(renderedWidget); +} + +async function renderServicesInfoWidget() { + logger.debug('[DASHBOARD]', 'renderServicesInfoWidget'); + const servicesInfoWidget = store.get().servicesInfoWidget; + + const container = document.getElementById('dashboard-widget-service-info'); + + if (!container) { + return; + } + + if (servicesInfoWidget.loading || servicesInfoWidget.failed) { + const renderedWidget = renderWidget({ + loading: servicesInfoWidget.loading, + failed: servicesInfoWidget.failed, + title: '', + items: [], + }); + + return container.replaceChildren(renderedWidget); + } + + const renderedWidget = renderWidget({ + loading: servicesInfoWidget.loading, + failed: servicesInfoWidget.failed, + title: _('Services info'), + items: [ + { + key: 'Tachyon', + value: servicesInfoWidget.data.tachyonRunning + ? _('✔ Running') + : _('✘ Stopped'), + attributes: { + class: servicesInfoWidget.data.tachyonRunning + ? 'fkp_dashboard-page__widgets-section__item__row--success' + : 'fkp_dashboard-page__widgets-section__item__row--error', + }, + }, + { + key: 'Sing-box', + value: servicesInfoWidget.data.singbox + ? _('✔ Running') + : _('✘ Stopped'), + attributes: { + class: servicesInfoWidget.data.singbox + ? 'fkp_dashboard-page__widgets-section__item__row--success' + : 'fkp_dashboard-page__widgets-section__item__row--error', + }, + }, + ], + }); + + container.replaceChildren(renderedWidget); +} + +async function onStoreUpdate( + next: StoreType, + prev: StoreType, + diff: Partial, +) { + if (diff.sectionsWidget) { + const inlineUpdated = + canUpdateLatencyProgressInline( + prev.sectionsWidget, + next.sectionsWidget, + ) && updateLatencyProgressInline(next.sectionsWidget); + + if (!inlineUpdated) { + renderSectionsWidget(); + } + } + + if (diff.bandwidthWidget) { + renderBandwidthWidget(); + } + + if (diff.trafficTotalWidget) { + renderTrafficTotalWidget(); + } + + if (diff.systemInfoWidget) { + renderSystemInfoWidget(); + } + + if (diff.servicesInfoWidget) { + syncDashboardServiceAvailability(); + renderServicesInfoWidget(); + } +} + +async function onPageMount() { + // Cleanup before mount + onPageUnmount(); + + dashboardMounted = true; + dashboardMountId += 1; + const mountId = dashboardMountId; + const hasRuntimeSnapshot = Boolean(getCachedRuntimeUiState()); + + if (!hasRuntimeSnapshot) { + const uiState = await refreshRuntimeUiState({ force: true }); + + if (!dashboardMounted || mountId !== dashboardMountId) { + return; + } + + if (!uiState) { + void fetchServicesInfo(); + } + } + + // Add new listener + store.subscribe(onStoreUpdate); + startActionStateWatcher(); + void renderSectionsWidget(); + void renderBandwidthWidget(); + void renderTrafficTotalWidget(); + void renderSystemInfoWidget(); + void renderServicesInfoWidget(); + syncDashboardServiceAvailability(); + + if (hasRuntimeSnapshot) { + void refreshRuntimeUiState({ force: true }); + } +} + +function onPageUnmount() { + dashboardMounted = false; + dashboardMountId += 1; + + stopDashboardDataUpdates(); + stopActionStateWatcher(); + sectionsRefreshQueued = false; + sectionsRefreshPromise = null; + // Remove old listener + store.unsubscribe(onStoreUpdate); + // Clear store + store.reset(['bandwidthWidget', 'trafficTotalWidget', 'systemInfoWidget']); +} + +let dashboardLifecycleRegistered = false; +let dashboardControllerInitialized = false; + +function registerLifecycleListeners() { + if (dashboardLifecycleRegistered) { + return; + } + + dashboardLifecycleRegistered = true; + + store.subscribe((next, prev, diff) => { + if ( + diff.tabService && + next.tabService.current !== prev.tabService.current + ) { + logger.debug( + '[DASHBOARD]', + 'active tab diff event, active tab:', + diff.tabService.current, + ); + const isDashboardVisible = next.tabService.current === 'dashboard'; + + if (isDashboardVisible) { + logger.debug( + '[DASHBOARD]', + 'registerLifecycleListeners', + 'onPageMount', + ); + return onPageMount(); + } + + if (!isDashboardVisible) { + logger.debug( + '[DASHBOARD]', + 'registerLifecycleListeners', + 'onPageUnmount', + ); + return onPageUnmount(); + } + } + }); +} + +export async function initController(): Promise { + if (dashboardControllerInitialized) { + return; + } + + dashboardControllerInitialized = true; + + onMount('dashboard-status').then(() => { + logger.debug('[DASHBOARD]', 'initController', 'onMount'); + registerLifecycleListeners(); + if ( + store.get().tabService.current === 'dashboard' || + isActiveLuciTab('dashboard') + ) { + onPageMount(); + } + }); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/index.ts b/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/index.ts new file mode 100644 index 00000000..f797e317 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/index.ts @@ -0,0 +1,2 @@ +export * from './renderSections'; +export * from './renderWidget'; diff --git a/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/renderSections.ts b/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/renderSections.ts new file mode 100644 index 00000000..5533f398 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/renderSections.ts @@ -0,0 +1,508 @@ +import { + renderLoaderCircleIcon24, + renderCopyIcon24, + renderLinkIcon24, + renderInfoIcon24, +} from '../../../../icons'; +import { isCopyableProxyLink, svgEl } from '../../../../helpers'; +import { prettyBytes } from '../../../../helpers/prettyBytes'; +import { Tachyon } from '../../../types'; + +interface IRenderSectionsProps { + loading: boolean; + failed: boolean; + section: Tachyon.OutboundGroup; + onTestLatency: (tag: string | string[]) => void; + onChooseOutbound: ( + sectionName: string, + selector: string, + tag: string, + ) => void; + onCopyOutbound: ( + section: Tachyon.OutboundGroup, + outbound: Tachyon.Outbound, + ) => void; + onShowUrlTestInfo: (outbound: Tachyon.Outbound) => void; + onShowPriorityInfo: (outbound: Tachyon.Outbound) => void; + onUpdateSubscription: (section: Tachyon.OutboundGroup) => void; + latencyFetching: boolean; + latencyProgress?: Tachyon.LatencyActionProgress; + subscriptionUpdating: boolean; + selectorSwitchingTag?: string; +} + +function renderFailedState() { + return E( + 'div', + { + class: 'fkp_dashboard-page__outbound-section centered', + style: 'height: 127px', + }, + E('span', {}, [E('span', {}, _('Dashboard currently unavailable'))]), + ); +} + +function renderLoadingState() { + return E('div', { + id: 'dashboard-sections-grid-skeleton', + class: 'fkp_dashboard-page__outbound-section skeleton', + style: 'height: 127px', + }); +} + +function isValidHttpUrl(url?: string) { + return Boolean(url && /^https?:\/\/\S+$/i.test(url)); +} + +function formatBytes(value?: number) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return undefined; + } + + return prettyBytes(value); +} + +function formatDate(seconds?: number) { + if ( + typeof seconds !== 'number' || + !Number.isFinite(seconds) || + seconds <= 0 + ) { + return undefined; + } + + const date = new Date(seconds * 1000); + if (Number.isNaN(date.getTime())) { + return undefined; + } + + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); +} + +function renderMetadataAction(label: string, url?: string) { + if (!isValidHttpUrl(url)) { + return undefined; + } + + return E( + 'a', + { + class: 'btn fkp_dashboard-page__subscription-meta__action', + href: url, + target: '_blank', + rel: 'noopener noreferrer', + title: label, + 'aria-label': label, + }, + renderLinkIcon24(), + ); +} + +function renderSubscriptionMetadata( + metadata: Tachyon.SubscriptionMetadata | undefined, +) { + if (!metadata || Object.keys(metadata).length <= 1) { + return undefined; + } + + const title = metadata.title || metadata.fileName; + const traffic = metadata.traffic; + const used = formatBytes(traffic?.used) || '0 B'; + const total = traffic?.isUnlimited + ? '∞' + : formatBytes(traffic?.total) || '0 B'; + const expire = formatDate(metadata.expire); + const refillDate = formatDate(metadata.refillDate); + + const rows = [ + traffic + ? { + label: _('Traffic'), + value: `${used} / ${total}`, + } + : undefined, + expire ? { label: _('Expires'), value: expire } : undefined, + refillDate ? { label: _('Refill'), value: refillDate } : undefined, + ].filter(Boolean) as { label: string; value: string }[]; + + const actions = [ + renderMetadataAction('Profile', metadata.webPageUrl), + renderMetadataAction('Support', metadata.supportUrl), + renderMetadataAction('More details', metadata.announceUrl), + ].filter(Boolean) as HTMLElement[]; + + return E('div', { class: 'fkp_dashboard-page__subscription-meta' }, [ + E('div', { class: 'fkp_dashboard-page__subscription-meta__main' }, [ + E( + 'div', + { class: 'fkp_dashboard-page__subscription-meta__heading' }, + _('Subscription info:'), + ), + title + ? E( + 'div', + { class: 'fkp_dashboard-page__subscription-meta__title' }, + title, + ) + : '', + rows.length + ? E( + 'div', + { class: 'fkp_dashboard-page__subscription-meta__facts' }, + rows.map((row) => + E( + 'div', + { class: 'fkp_dashboard-page__subscription-meta__fact' }, + [ + E( + 'span', + { + class: 'fkp_dashboard-page__subscription-meta__fact-key', + }, + row.label, + ), + E( + 'span', + { + class: + 'fkp_dashboard-page__subscription-meta__fact-value', + }, + row.value, + ), + ], + ), + ), + ) + : '', + actions.length + ? E( + 'div', + { class: 'fkp_dashboard-page__subscription-meta__actions' }, + actions, + ) + : '', + ]), + metadata.announce + ? E( + 'blockquote', + { class: 'fkp_dashboard-page__subscription-meta__announce' }, + metadata.announce, + ) + : '', + ]); +} + +function renderSubscriptionUpdateAction( + section: Tachyon.OutboundGroup, + subscriptionUpdating: boolean, + onUpdateSubscription: (section: Tachyon.OutboundGroup) => void, +) { + if (!section.subscriptionSourceCount) { + return undefined; + } + + return E( + 'button', + { + type: 'button', + class: 'btn fkp_dashboard-page__outbound-section__subscription-update', + 'aria-label': _('Update subscriptions'), + disabled: subscriptionUpdating ? true : undefined, + click: (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (subscriptionUpdating) { + return; + } + + onUpdateSubscription(section); + }, + }, + subscriptionUpdating + ? [renderLoaderCircleIcon24(), _('Update subscriptions')] + : _('Update subscriptions'), + ); +} + +export function getLatencyTestLabel( + latencyProgress?: Tachyon.LatencyActionProgress, +) { + const total = Math.trunc(Number(latencyProgress?.total ?? 0)); + if (!Number.isFinite(total) || total <= 0) { + return _('Test latency'); + } + + const completedValue = Number(latencyProgress?.completed ?? 0); + const completed = Number.isFinite(completedValue) + ? Math.trunc(completedValue) + : 0; + + return `${_('Test latency')}: ${Math.min( + Math.max(0, completed), + total, + )}/${total}`; +} + +function renderDefaultState({ + section, + onChooseOutbound, + onCopyOutbound, + onShowUrlTestInfo, + onShowPriorityInfo, + onTestLatency, + onUpdateSubscription, + latencyFetching, + latencyProgress, + subscriptionUpdating, + selectorSwitchingTag, +}: IRenderSectionsProps) { + function testLatency() { + if (section.withTagSelect) { + return onTestLatency( + section.latencyTestCodes?.length + ? section.latencyTestCodes + : section.latencyTestCode || section.code, + ); + } + + if (section.outbounds.length) { + return onTestLatency(section.outbounds[0].code); + } + } + + function renderOutbound(outbound: Tachyon.Outbound) { + function getLatencyClass() { + if (!outbound.latency) { + return 'fkp_dashboard-page__outbound-grid__item__latency--empty'; + } + + if (outbound.latency < 800) { + return 'fkp_dashboard-page__outbound-grid__item__latency--green'; + } + + if (outbound.latency < 1500) { + return 'fkp_dashboard-page__outbound-grid__item__latency--yellow'; + } + + return 'fkp_dashboard-page__outbound-grid__item__latency--red'; + } + + const canCopyLink = + Boolean(outbound.canCopyLink) || isCopyableProxyLink(outbound.link); + const selectorSwitching = Boolean(selectorSwitchingTag); + const outboundSwitching = selectorSwitchingTag === outbound.code; + const canChooseOutbound = + section.withTagSelect && + outbound.runtimeAvailable !== false && + !selectorSwitching && + !outbound.selected; + const className = [ + 'fkp_dashboard-page__outbound-grid__item', + outbound.selected + ? 'fkp_dashboard-page__outbound-grid__item--active' + : '', + canChooseOutbound + ? 'fkp_dashboard-page__outbound-grid__item--selectable' + : '', + section.withTagSelect && !canChooseOutbound + ? 'fkp_dashboard-page__outbound-grid__item--disabled' + : '', + outboundSwitching + ? 'fkp_dashboard-page__outbound-grid__item--switching' + : '', + ] + .filter(Boolean) + .join(' '); + return E( + 'div', + { + class: className, + 'aria-busy': outboundSwitching ? 'true' : undefined, + 'aria-disabled': + section.withTagSelect && !canChooseOutbound ? 'true' : undefined, + click: () => + canChooseOutbound && + onChooseOutbound(section.sectionName, section.code, outbound.code), + }, + [ + ...(outboundSwitching + ? [ + svgEl( + 'svg', + { class: 'fkp_dashboard-page__outbound-grid__item__snake' }, + [ + svgEl('rect', { + width: '100%', + height: '100%', + fill: 'none', + rx: 4, + ry: 4, + pathLength: 100, + }), + ], + ), + ] + : []), + E('div', { class: 'fkp_dashboard-page__outbound-grid__item__header' }, [ + E('b', {}, outbound.displayName), + ...(canCopyLink + ? [ + E( + 'button', + { + type: 'button', + class: + 'btn fkp_dashboard-page__outbound-grid__item__copy-button', + title: _('Copy proxy link'), + 'aria-label': _('Copy proxy link'), + click: (event: MouseEvent) => { + event.stopPropagation(); + onCopyOutbound(section, outbound); + }, + }, + renderCopyIcon24(), + ), + ] + : []), + ...(outbound.urlTestInfo + ? [ + E( + 'button', + { + type: 'button', + class: + 'btn fkp_dashboard-page__outbound-grid__item__copy-button', + title: _('URLTest details'), + 'aria-label': _('URLTest details'), + click: (event: MouseEvent) => { + event.stopPropagation(); + onShowUrlTestInfo(outbound); + }, + }, + renderInfoIcon24(), + ), + ] + : []), + ...(outbound.priorityInfo + ? [ + E( + 'button', + { + type: 'button', + class: + 'btn fkp_dashboard-page__outbound-grid__item__copy-button', + title: _('Priority details'), + 'aria-label': _('Priority details'), + click: (event: MouseEvent) => { + event.stopPropagation(); + onShowPriorityInfo(outbound); + }, + }, + renderInfoIcon24(), + ), + ] + : []), + ]), + E('div', { class: 'fkp_dashboard-page__outbound-grid__item__footer' }, [ + E( + 'div', + { class: 'fkp_dashboard-page__outbound-grid__item__type' }, + [outbound.type].filter(Boolean), + ), + E( + 'div', + { class: getLatencyClass() }, + outbound.latency ? `${outbound.latency}ms` : 'N/A', + ), + ]), + ], + ); + } + + const metadataNodes = (section.subscriptionMetadata || []) + .map((metadata) => renderSubscriptionMetadata(metadata)) + .filter(Boolean) as HTMLElement[]; + const subscriptionUpdateAction = renderSubscriptionUpdateAction( + section, + subscriptionUpdating, + onUpdateSubscription, + ); + + return E('div', { class: 'fkp_dashboard-page__outbound-section' }, [ + // Title with test latency + E('div', { class: 'fkp_dashboard-page__outbound-section__title-section' }, [ + E( + 'div', + { + class: 'fkp_dashboard-page__outbound-section__title-section__title', + }, + section.displayName, + ), + E( + 'div', + { + class: 'fkp_dashboard-page__outbound-section__title-section__actions', + }, + [ + ...(subscriptionUpdateAction ? [subscriptionUpdateAction] : []), + E( + 'button', + { + type: 'button', + class: 'btn dashboard-sections-grid-item-test-latency', + 'data-latency-section': section.sectionName, + disabled: latencyFetching ? true : undefined, + click: (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (latencyFetching) { + return; + } + + testLatency(); + }, + }, + latencyFetching + ? [ + renderLoaderCircleIcon24(), + E( + 'span', + { + class: 'dashboard-sections-grid-item-test-latency__label', + }, + getLatencyTestLabel(latencyProgress), + ), + ] + : E( + 'span', + { + class: 'dashboard-sections-grid-item-test-latency__label', + }, + _('Test latency'), + ), + ), + ], + ), + ]), + E('div', { class: 'fkp_dashboard-page__outbound-grid' }, [ + ...metadataNodes, + ...section.outbounds.map((outbound) => renderOutbound(outbound)), + ]), + ]); +} + +export function renderSections(props: IRenderSectionsProps) { + if (props.failed) { + return renderFailedState(); + } + + if (props.loading) { + return renderLoadingState(); + } + + return renderDefaultState(props); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/renderWidget.ts b/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/renderWidget.ts new file mode 100644 index 00000000..6a8b79ed --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/renderWidget.ts @@ -0,0 +1,78 @@ +interface IRenderWidgetProps { + loading: boolean; + failed: boolean; + title: string; + items: Array<{ + key: string; + value: string; + attributes?: { + class?: string; + }; + }>; +} + +function renderFailedState() { + return E( + 'div', + { + id: '', + style: 'height: 78px', + class: 'fkp_dashboard-page__widgets-section__item centered', + }, + _('Currently unavailable'), + ); +} + +function renderLoadingState() { + return E( + 'div', + { + id: '', + style: 'height: 78px', + class: 'fkp_dashboard-page__widgets-section__item skeleton', + }, + '', + ); +} + +function renderDefaultState({ title, items }: IRenderWidgetProps) { + return E('div', { class: 'fkp_dashboard-page__widgets-section__item' }, [ + E( + 'b', + { class: 'fkp_dashboard-page__widgets-section__item__title' }, + title, + ), + ...items.map((item) => + E( + 'div', + { + class: `fkp_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ''}`, + }, + [ + E( + 'span', + { class: 'fkp_dashboard-page__widgets-section__item__row__key' }, + `${item.key}: `, + ), + E( + 'span', + { class: 'fkp_dashboard-page__widgets-section__item__row__value' }, + item.value, + ), + ], + ), + ), + ]); +} + +export function renderWidget(props: IRenderWidgetProps) { + if (props.loading) { + return renderLoadingState(); + } + + if (props.failed) { + return renderFailedState(); + } + + return renderDefaultState(props); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/dashboard/render.ts b/fe-app-tachyon/src/tachyon/tabs/dashboard/render.ts new file mode 100644 index 00000000..f32e3afe --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/dashboard/render.ts @@ -0,0 +1,94 @@ +import { renderSections, renderWidget } from './partials'; + +export function render() { + return E( + 'div', + { + id: 'dashboard-status', + class: 'fkp_dashboard-page', + }, + [ + E( + 'div', + { + class: 'fkp_dashboard-page__service-stopped', + role: 'status', + }, + _( + 'Tachyon service is stopped. Start the service to display the dashboard.', + ), + ), + E('div', { class: 'fkp_dashboard-page__content' }, [ + // Widgets section + E('div', { class: 'fkp_dashboard-page__widgets-section' }, [ + E( + 'div', + { id: 'dashboard-widget-traffic' }, + renderWidget({ + loading: true, + failed: false, + title: '', + items: [], + }), + ), + E( + 'div', + { id: 'dashboard-widget-traffic-total' }, + renderWidget({ + loading: true, + failed: false, + title: '', + items: [], + }), + ), + E( + 'div', + { id: 'dashboard-widget-system-info' }, + renderWidget({ + loading: true, + failed: false, + title: '', + items: [], + }), + ), + E( + 'div', + { id: 'dashboard-widget-service-info' }, + renderWidget({ + loading: true, + failed: false, + title: '', + items: [], + }), + ), + ]), + // All outbounds + E( + 'div', + { id: 'dashboard-sections-grid' }, + renderSections({ + loading: true, + failed: false, + section: { + code: '', + sectionName: '', + displayName: '', + outbounds: [], + withTagSelect: false, + }, + onTestLatency: () => {}, + onChooseOutbound: () => {}, + onCopyOutbound: () => {}, + onShowUrlTestInfo: () => {}, + onShowPriorityInfo: () => {}, + onUpdateSubscription: () => {}, + latencyFetching: false, + latencyProgress: undefined, + subscriptionUpdating: false, + selectorSwitchingTag: undefined, + }), + ), + ]), + ], + ); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/dashboard/styles.ts b/fe-app-tachyon/src/tachyon/tabs/dashboard/styles.ts new file mode 100644 index 00000000..cddf3bed --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/dashboard/styles.ts @@ -0,0 +1,678 @@ +// language=CSS +import { TACHYON_UCI_PACKAGE as TACHYON_CBI_PREFIX } from '../../../constants'; + +export const styles = ` +#cbi-${TACHYON_CBI_PREFIX}-dashboard-_mount_node > .cbi-value-title { + display: none; +} + +#cbi-${TACHYON_CBI_PREFIX}-dashboard-_mount_node > .cbi-value-field { + margin-left: 0; + width: 100%; +} + +#cbi-${TACHYON_CBI_PREFIX}-dashboard-_mount_node > div { + width: 100%; +} + +#cbi-${TACHYON_CBI_PREFIX}-dashboard > h3 { + display: none; +} + +.fkp_dashboard-page { + width: 100%; + --dashboard-grid-columns: 4; + --dashboard-grid-min-width: 180px; +} + +.fkp_dashboard-page__service-stopped { + display: none; + width: 100%; + min-height: 180px; + margin-top: 10px; + align-items: center; + justify-content: center; + padding: 20px; + box-sizing: border-box; + border: 1px dashed var(--border-color-high, #555); + border-radius: 6px; + color: var(--text-color-medium, #888); + background: transparent; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + font-style: italic; + text-align: center; +} + +.fkp_dashboard-page--service-stopped { + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), minmax(var(--dashboard-grid-min-width), 1fr)); + gap: 10px; +} + +.fkp_dashboard-page--service-stopped .fkp_dashboard-page__service-stopped { + display: flex; + grid-column: 1 / -1; +} + +.fkp_dashboard-page--service-stopped .fkp_dashboard-page__content { + display: none; +} + +@media (max-width: 900px) { + .fkp_dashboard-page { + --dashboard-grid-columns: 2; + } +} + +@media (max-width: 560px) { + .fkp_dashboard-page { + --dashboard-grid-columns: 1; + --dashboard-grid-min-width: 0; + } +} + +.fkp_dashboard-page__widgets-section { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), minmax(var(--dashboard-grid-min-width), 1fr)); + grid-gap: 10px; +} + +.fkp_dashboard-page__widgets-section__item { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + min-width: 0; +} + +.fkp_dashboard-page__widgets-section__item__title {} + +.fkp_dashboard-page__widgets-section__item__row {} + +.fkp_dashboard-page__widgets-section__item__row--success .fkp_dashboard-page__widgets-section__item__row__value { + color: var(--success-color-medium, green); +} + +.fkp_dashboard-page__widgets-section__item__row--error .fkp_dashboard-page__widgets-section__item__row__value { + color: var(--error-color-medium, red); +} + +.fkp_dashboard-page__widgets-section__item__row__key {} + +.fkp_dashboard-page__widgets-section__item__row__value {} + +.fkp_dashboard-page__outbound-section { + margin-top: 10px; + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; +} + +.fkp_dashboard-page__outbound-section__title-section { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px 10px; + min-width: 0; +} + +.fkp_dashboard-page__outbound-section__title-section__title { + color: var(--text-color-high); + font-weight: 700; + min-width: 0; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__outbound-section__title-section__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex: 0 0 auto; +} + +.fkp_dashboard-page .btn.fkp_dashboard-page__outbound-section__subscription-update { + min-width: 130px; + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.fkp_dashboard-page__outbound-section__subscription-update svg { + width: 15px; + height: 15px; + display: block; + flex: 0 0 auto; +} + +.fkp_dashboard-page__outbound-section__subscription-update[disabled] { + cursor: not-allowed; + opacity: 0.65; +} + +.fkp_dashboard-page .btn.dashboard-sections-grid-item-test-latency { + min-width: 99px; + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.fkp_dashboard-page .btn.dashboard-sections-grid-item-test-latency svg { + width: 15px; + height: 15px; + display: block; + flex: 0 0 auto; +} + +.fkp_dashboard-page .btn.dashboard-sections-grid-item-test-latency[disabled] { + cursor: not-allowed; + opacity: 0.65; +} + +.fkp_dashboard-page__outbound-grid { + margin-top: 5px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), minmax(var(--dashboard-grid-min-width), 1fr)); + grid-gap: 10px; +} + +.fkp_dashboard-page__subscription-meta { + --subscription-meta-action-size: 28px; + --subscription-meta-action-gap: 6px; + grid-column: 1 / -1; + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 8px 10px; + background: var(--background-color-high, transparent); +} + +.fkp_dashboard-page__subscription-meta__main { + display: flex; + align-items: center; + gap: 6px 10px; + min-width: 0; +} + +.fkp_dashboard-page__subscription-meta__heading { + flex: 0 0 auto; + color: var(--text-color-high); + font-weight: 700; + line-height: 1.25; + white-space: nowrap; +} + +.fkp_dashboard-page__subscription-meta__title { + flex: 0 1 auto; + width: max-content; + max-width: min(28ch, 30%); + min-width: min-content; + color: var(--text-color-high); + font-weight: 700; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__subscription-meta__facts { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px 12px; +} + +.fkp_dashboard-page__subscription-meta__fact { + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; + line-height: 1.25; +} + +.fkp_dashboard-page__subscription-meta__fact-key { + color: var(--text-color-medium); + font-size: 12px; + white-space: nowrap; +} + +.fkp_dashboard-page__subscription-meta__fact-value { + color: var(--text-color-high); + font-weight: 600; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__subscription-meta__actions { + flex: 0 0 auto; + margin-left: auto; + display: flex; + justify-content: flex-end; + gap: var(--subscription-meta-action-gap); +} + +.fkp_dashboard-page .btn.fkp_dashboard-page__subscription-meta__action { + width: var(--subscription-meta-action-size); + height: var(--subscription-meta-action-size); + min-width: var(--subscription-meta-action-size); + min-height: var(--subscription-meta-action-size); + padding: 2px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; + margin: 0; +} + +.fkp_dashboard-page__subscription-meta__action svg { + width: 15px; + height: 15px; + display: block; + flex: 0 0 auto; +} + +.fkp_dashboard-page__subscription-meta__announce { + margin: 6px 0 0; + border-left: 3px solid var(--primary-color-medium, dodgerblue); + padding: 4px 8px; + background: var(--background-color-low, rgba(0, 0, 0, 0.04)); + color: var(--text-color-medium); + font-style: italic; + line-height: 1.25; + overflow-wrap: anywhere; +} + +@media (max-width: 700px) { + .fkp_dashboard-page__subscription-meta__main { + align-items: flex-start; + flex-wrap: wrap; + } + + .fkp_dashboard-page__subscription-meta__heading, + .fkp_dashboard-page__subscription-meta__title { + order: 1; + } + + .fkp_dashboard-page__subscription-meta__actions { + order: 2; + } + + .fkp_dashboard-page__subscription-meta__facts { + order: 3; + flex-basis: 100%; + } + + .fkp_dashboard-page__subscription-meta__title { + max-width: calc(100% - 42px); + } +} + +.fkp_dashboard-page__outbound-grid__item { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + transition: border 0.2s ease; + min-width: 0; + position: relative; +} + +.fkp_dashboard-page__outbound-grid__item--selectable { + cursor: pointer; +} + +.fkp_dashboard-page__outbound-grid__item--selectable:hover { + border-color: var(--primary-color-high, dodgerblue); +} + +.fkp_dashboard-page__outbound-grid__item--active { + border-color: var(--success-color-medium, green); +} + +.fkp_dashboard-page__outbound-grid__item--disabled { + cursor: default; +} + +.fkp_dashboard-page__outbound-grid__item--switching { + border-color: transparent !important; + overflow: hidden; + cursor: wait; +} + +.fkp_dashboard-page__outbound-grid__item__snake { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; + box-sizing: border-box; +} + +.fkp_dashboard-page__outbound-grid__item__snake rect { + stroke: var(--primary-color-high, dodgerblue); + stroke-width: 4; + animation: fkp-dashboard-selector-snake-svg 1.2s linear infinite; +} + +@keyframes fkp-dashboard-selector-snake-svg { + 0% { + stroke-dasharray: 30 70; + stroke-dashoffset: 100; + } + 100% { + stroke-dasharray: 30 70; + stroke-dashoffset: 0; + } +} + +.fkp_dashboard-page__outbound-grid__item__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.fkp_dashboard-page__outbound-grid__item__header b { + min-width: 0; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page .btn.fkp_dashboard-page__outbound-grid__item__copy-button { + width: 22px; + height: 22px; + min-width: 22px; + min-height: 22px; + padding: 1px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; +} + +.fkp_dashboard-page__outbound-grid__item__copy-button svg { + width: 13px; + height: 13px; + display: block; + flex: 0 0 auto; +} + +.fkp_dashboard-page__outbound-grid__item__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 10px; +} + +.fkp_dashboard-page__outbound-grid__item__type { + min-width: 0; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__outbound-grid__item__latency--empty { + color: var(--primary-color-low, lightgray); +} + +.fkp_dashboard-page__outbound-grid__item__latency--green { + color: var(--success-color-medium, green); +} + +.fkp_dashboard-page__outbound-grid__item__latency--yellow { + color: var(--warn-color-medium, orange); +} + +.fkp_dashboard-page__outbound-grid__item__latency--red { + color: var(--error-color-medium, red); +} + +.fkp_dashboard-page__urltest-details { + box-sizing: border-box; + width: min(760px, calc(100vw - 56px)); + max-width: 100%; + padding-top: 10px; +} + +.fkp_dashboard-page__urltest-details__params { + display: grid; + grid-template-columns: minmax(120px, max-content) minmax(0, 1fr); + gap: 8px 16px; + margin: 0 0 18px; +} + +.fkp_dashboard-page__urltest-details__param { + display: contents; +} + +.fkp_dashboard-page__urltest-details__param dt { + color: var(--text-color-medium, #666); + line-height: 1.35; +} + +.fkp_dashboard-page__urltest-details__param dd { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + margin: 0; +} + +.fkp_dashboard-page__urltest-details__param dd span { + min-width: 0; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__urltest-details__url { + min-width: 0; + color: var(--primary-color-high, #337ab7); + text-decoration: none; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__urltest-details__url:hover { + text-decoration: underline; +} + +.fkp_dashboard-page__urltest-details__selected-value { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + max-width: 100%; + padding: 0; + border: 0; + color: inherit; + background: transparent; + box-sizing: border-box; + line-height: 1.3; +} + +.fkp_dashboard-page__urltest-details__selected-name { + min-width: 0; + font-weight: 600; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__urltest-details__selected-type { + color: var(--text-color-medium, #666); +} + +.fkp_dashboard-page__urltest-details__outbounds-title { + margin-bottom: 8px; + font-weight: 600; +} + +.fkp_dashboard-page__urltest-details__table { + display: grid; + gap: 6px; + width: calc(100% + 14px); + box-sizing: border-box; + max-height: min(46vh, 460px); + overflow-x: hidden; + overflow-y: auto; + padding-right: 14px; + scrollbar-gutter: auto; +} + +.fkp_dashboard-page__urltest-details__row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(54px, max-content) 20px; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + padding: 7px 8px; + box-sizing: border-box; + border: 1px solid transparent; + border-bottom: 1px solid var(--border-color-low, #eee); + border-radius: 4px; +} + +.fkp_dashboard-page__urltest-details__row--active { + border-color: var(--success-color-low, #2d7d46); + background: transparent; +} + +.fkp_dashboard-page__urltest-details__row-name, +.fkp_dashboard-page__urltest-details__row-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + line-height: 1.3; +} + +.fkp_dashboard-page__urltest-details__row-name { + flex-wrap: wrap; +} + +.fkp_dashboard-page__urltest-details__row-name b { + min-width: 0; + overflow-wrap: anywhere; + line-height: 1.3; +} + +.fkp_dashboard-page__urltest-details__priority-name { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 2px 0; +} + +.fkp_dashboard-page__urltest-details__priority-number { + margin-right: 6px; + color: var(--text-color-medium, #aaa); + font-family: monospace; + font-size: 13px; + font-weight: 600; +} + +.fkp_dashboard-page__urltest-details__priority-level { + margin-right: 8px; + padding: 2px 6px; + border-radius: 4px; + color: var(--text-color-medium, #aaa); + background: rgba(128, 128, 128, 0.15); + font-size: 11px; + font-weight: 400; +} + +.fkp_dashboard-page__urltest-details__country-badge { + display: inline-flex; + align-items: center; + user-select: none; + margin-right: 6px; + padding: 2px 4px; + border: 1px solid rgba(128, 128, 128, 0.25); + border-radius: 4px; + background: rgba(128, 128, 128, 0.15); + line-height: 1; +} + +.fkp_dashboard-page__urltest-details__priority-node { + color: var(--text-color-high, #fff); + font-weight: 600; +} + +.fkp_dashboard-page__urltest-details__row-type, +.fkp_dashboard-page__urltest-details__row-meta { + color: var(--text-color-medium, #666); +} + +.fkp_dashboard-page__urltest-details__row-type { + white-space: nowrap; + line-height: 1.3; +} + +.fkp_dashboard-page__urltest-details__row-meta { + justify-content: flex-end; + white-space: nowrap; +} + +.fkp_dashboard-page__urltest-details__copy-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 20px; + width: 20px; + min-width: 20px; + height: 20px; + padding: 0; + box-sizing: border-box; +} + +.fkp_dashboard-page__urltest-details__copy-button svg { + width: 12px; + height: 12px; +} + +.fkp_dashboard-page__urltest-details__copy-placeholder { + display: block; + width: 20px; + min-width: 20px; + height: 1px; +} + +.fkp_dashboard-page__urltest-details__empty { + margin-top: 4px; + padding: 24px 0; + border: 1px dashed var(--border-color-high, #555); + border-radius: 4px; + color: var(--text-color-medium, #888); + background: rgba(128, 128, 128, 0.02); + font-style: italic; + text-align: center; +} + +.fkp_dashboard-page__urltest-details__footer { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + +@media (max-width: 560px) { + .fkp_dashboard-page__urltest-details__params { + grid-template-columns: 1fr; + } + + .fkp_dashboard-page__urltest-details__row { + grid-template-columns: minmax(0, 1fr) 20px; + } + + .fkp_dashboard-page__urltest-details__row-meta { + grid-column: 1 / -1; + justify-content: flex-start; + } +} + +`; diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/contstants.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/contstants.ts new file mode 100644 index 00000000..7c1ba832 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/contstants.ts @@ -0,0 +1,64 @@ +import { getCheckTitle } from '../helpers/getCheckTitle'; + +export enum DIAGNOSTICS_CHECKS { + DNS = 'DNS', + SINGBOX = 'SINGBOX', + NFT = 'NFT', + ZAPRET = 'ZAPRET', + ZAPRET2 = 'ZAPRET2', + BYEDPI = 'BYEDPI', + FAKEIP = 'FAKEIP', + OUTBOUNDS = 'OUTBOUNDS', + INBOUNDS = 'INBOUNDS', +} + +export const DIAGNOSTICS_CHECKS_MAP: Record< + DIAGNOSTICS_CHECKS, + { order: number; title: string; code: DIAGNOSTICS_CHECKS } +> = { + [DIAGNOSTICS_CHECKS.DNS]: { + order: 1, + title: getCheckTitle('DNS'), + code: DIAGNOSTICS_CHECKS.DNS, + }, + [DIAGNOSTICS_CHECKS.SINGBOX]: { + order: 2, + title: getCheckTitle('Sing-box'), + code: DIAGNOSTICS_CHECKS.SINGBOX, + }, + [DIAGNOSTICS_CHECKS.NFT]: { + order: 4, + title: getCheckTitle('Nftables'), + code: DIAGNOSTICS_CHECKS.NFT, + }, + [DIAGNOSTICS_CHECKS.ZAPRET]: { + order: 5, + title: getCheckTitle('Zapret'), + code: DIAGNOSTICS_CHECKS.ZAPRET, + }, + [DIAGNOSTICS_CHECKS.BYEDPI]: { + order: 7, + title: getCheckTitle('ByeDPI'), + code: DIAGNOSTICS_CHECKS.BYEDPI, + }, + [DIAGNOSTICS_CHECKS.ZAPRET2]: { + order: 6, + title: getCheckTitle('Zapret2'), + code: DIAGNOSTICS_CHECKS.ZAPRET2, + }, + [DIAGNOSTICS_CHECKS.OUTBOUNDS]: { + order: 8, + title: getCheckTitle('Outbounds'), + code: DIAGNOSTICS_CHECKS.OUTBOUNDS, + }, + [DIAGNOSTICS_CHECKS.FAKEIP]: { + order: 9, + title: getCheckTitle('FakeIP'), + code: DIAGNOSTICS_CHECKS.FAKEIP, + }, + [DIAGNOSTICS_CHECKS.INBOUNDS]: { + order: 3, + title: getCheckTitle('Inbounds'), + code: DIAGNOSTICS_CHECKS.INBOUNDS, + }, +}; diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts new file mode 100644 index 00000000..8f165a5f --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts @@ -0,0 +1,25 @@ +import { IDiagnosticsChecksItem } from '../../../services'; + +export function getCheckItemsMeta(items: IDiagnosticsChecksItem[]): { + description: string; + state: 'warning' | 'success' | 'error'; +} { + if (items.some((item) => item.state === 'error')) { + return { + state: 'error', + description: _('Checks failed'), + }; + } + + if (items.some((item) => item.state === 'warning')) { + return { + state: 'warning', + description: _('Issues detected'), + }; + } + + return { + state: 'success', + description: _('Checks passed'), + }; +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts new file mode 100644 index 00000000..a2fd0e2f --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts @@ -0,0 +1,45 @@ +import type { Tachyon } from '../../../types'; +import { getMeta } from '../helpers/getMeta'; + +type DnsCheckState = 'error' | 'success' | 'warning'; + +export function getDnsCheckPresentation(data: Tachyon.DnsCheckResult) { + const dhcpManagedManually = Boolean(data.dont_touch_dhcp); + const dhcpCheckOk = dhcpManagedManually || Boolean(data.dhcp_config_status); + + const allGood = + Boolean(data.dns_on_router) && + dhcpCheckOk && + Boolean(data.bootstrap_dns_status) && + Boolean(data.dns_status); + + const atLeastOneGood = + Boolean(data.dns_on_router) || + dhcpCheckOk || + Boolean(data.bootstrap_dns_status) || + Boolean(data.dns_status); + + const meta = getMeta({ atLeastOneGood, allGood }); + const state: DnsCheckState = + dhcpManagedManually && meta.state === 'success' ? 'warning' : meta.state; + const description = + dhcpManagedManually && meta.state === 'success' + ? _('Checks passed with manual DHCP') + : meta.description; + + const dhcpItemState: DnsCheckState = dhcpManagedManually + ? 'warning' + : data.dhcp_config_status + ? 'success' + : 'error'; + const dhcpItemKey = dhcpManagedManually + ? _('DHCP is managed manually') + : _('DHCP has DNS server'); + + return { + state, + description, + dhcpItemState, + dhcpItemKey, + }; +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts new file mode 100644 index 00000000..50ae572a --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts @@ -0,0 +1,137 @@ +import { DIAGNOSTICS_CHECKS_MAP } from './contstants'; +import { TachyonShellMethods } from '../../../methods'; +import { updateCheckStore } from './updateCheckStore'; +import { IDiagnosticsChecksItem } from '../../../services'; +import { getCheckItemsMeta } from './getCheckItemsMeta'; + +export async function runByedpiCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.BYEDPI; + + updateCheckStore({ + order, + code, + title, + description: _('Checking, please wait'), + state: 'loading', + items: [], + }); + + const byedpiStatus = await TachyonShellMethods.getByedpiStatus(); + + if (!byedpiStatus.success) { + updateCheckStore({ + order, + code, + title, + description: _('Cannot receive checks result'), + state: 'error', + items: [], + }); + + throw new Error('ByeDPI checks failed'); + } + + const data = byedpiStatus.data; + const providerAvailable = Boolean(data.provider_available ?? data.installed); + const packageInstalled = Boolean(data.package_installed); + const hasByedpiRules = Number(data.enabled_rule_count || 0) > 0; + const expectedProcesses = Number(data.expected_process_count || 0); + const runningProcesses = Number(data.running_process_count || 0); + const supervisorProcesses = Number(data.supervisor_process_count || 0); + const restartCount = Number(data.restart_count || 0); + const runtimeUnstable = Boolean(data.runtime_unstable); + const tachyonRuntimeReady = + !hasByedpiRules || + (runningProcesses === expectedProcesses && + supervisorProcesses === expectedProcesses); + const unexpectedRuntime = + !hasByedpiRules && (runningProcesses > 0 || supervisorProcesses > 0); + const outboundsConfigured = Boolean(data.outbounds_configured); + const standaloneServiceEnabled = Boolean(data.standalone_service_enabled); + const standaloneServiceRunning = Boolean(data.standalone_service_running); + const standaloneConflict = hasByedpiRules && standaloneServiceRunning; + const standaloneAutostartRisk = + hasByedpiRules && standaloneServiceEnabled && !standaloneServiceRunning; + + const items: Array = [ + { + state: providerAvailable + ? 'success' + : hasByedpiRules + ? 'error' + : 'warning', + key: providerAvailable + ? _('ByeDPI provider binary is available') + : _('ByeDPI provider binary is not available'), + value: data.provider_path || '', + }, + { + state: packageInstalled ? 'success' : 'warning', + key: packageInstalled + ? _('ByeDPI package is installed') + : _('ByeDPI package is not installed'), + value: '', + }, + { + state: hasByedpiRules && !providerAvailable ? 'error' : 'success', + key: hasByedpiRules + ? _('There are rules using ByeDPI') + : _('No rules use ByeDPI'), + value: '', + }, + { + state: + unexpectedRuntime || !tachyonRuntimeReady + ? 'error' + : runtimeUnstable + ? 'warning' + : 'success', + key: hasByedpiRules + ? runtimeUnstable + ? _('Tachyon-managed ciadpi runtime has restarted') + : tachyonRuntimeReady + ? _('Tachyon-managed ciadpi runtime is ready') + : _('Tachyon-managed ciadpi runtime is not ready') + : unexpectedRuntime + ? _('Unexpected Tachyon-managed ciadpi runtime is running') + : _('Tachyon-managed ciadpi runtime is not running'), + value: hasByedpiRules + ? runtimeUnstable + ? `${restartCount}` + : `${runningProcesses}/${expectedProcesses}` + : '', + }, + { + state: !hasByedpiRules || outboundsConfigured ? 'success' : 'error', + key: outboundsConfigured + ? _('ByeDPI sing-box outbound is configured') + : _('ByeDPI sing-box outbound is not configured'), + value: `${data.listen_address}:${Number(data.port_base || 0)}`, + }, + { + state: standaloneConflict + ? 'error' + : standaloneAutostartRisk + ? 'warning' + : 'success', + key: standaloneServiceRunning + ? hasByedpiRules + ? _('Standalone ByeDPI is active together with Tachyon ByeDPI rules') + : _('Standalone ByeDPI service is active') + : standaloneAutostartRisk + ? _('Standalone ByeDPI autostart is enabled') + : _('Standalone ByeDPI service is inactive'), + value: '', + }, + ]; + const { state, description } = getCheckItemsMeta(items); + + updateCheckStore({ + order, + code, + title, + description, + state, + items, + }); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts new file mode 100644 index 00000000..ce0f4abe --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts @@ -0,0 +1,89 @@ +import { insertIf } from '../../../../helpers'; +import { DIAGNOSTICS_CHECKS_MAP } from './contstants'; +import { TachyonShellMethods } from '../../../methods'; +import type { IDiagnosticsChecksItem } from '../../../services'; +import { updateCheckStore } from './updateCheckStore'; +import { getDnsCheckPresentation } from './getDnsCheckPresentation'; + +export async function runDnsCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.DNS; + + updateCheckStore({ + order, + code, + title, + description: _('Checking, please wait'), + state: 'loading', + items: [], + }); + + const dnsChecks = await TachyonShellMethods.checkDNSAvailable(); + + if (!dnsChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _('Cannot receive checks result'), + state: 'error', + items: [], + }); + + throw new Error('DNS checks failed'); + } + + const data = dnsChecks.data; + const { state, description, dhcpItemState, dhcpItemKey } = + getDnsCheckPresentation(data); + + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + ...insertIf( + data.dns_type === 'doh' || + data.dns_type === 'dot' || + data.bootstrap_dns_server_count > 1 || + !data.bootstrap_dns_status, + [ + { + state: data.bootstrap_dns_status ? 'success' : 'error', + key: + data.bootstrap_dns_server_count > 1 + ? _('Active Bootstrap DNS') + : _('Bootstrap DNS'), + value: + data.bootstrap_dns_server_count > 1 + ? `${data.bootstrap_dns_server} (${data.bootstrap_dns_server_index + 1}/${data.bootstrap_dns_server_count})` + : data.bootstrap_dns_server, + }, + ], + ), + { + state: data.dns_status ? 'success' : 'error', + key: data.dns_server_count > 1 ? _('Active Main DNS') : _('Main DNS'), + value: + data.dns_server_count > 1 + ? `${data.dns_server} [${data.dns_type}] (${data.dns_server_index + 1}/${data.dns_server_count})` + : `${data.dns_server} [${data.dns_type}]`, + }, + { + state: data.dns_on_router ? 'success' : 'error', + key: _('DNS on router'), + value: '', + }, + { + state: dhcpItemState, + key: dhcpItemKey, + value: '', + }, + ], + }); + + if (state === 'error') { + throw new Error('DNS checks failed'); + } +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts new file mode 100644 index 00000000..1d898d51 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts @@ -0,0 +1,98 @@ +import { insertIf } from '../../../../helpers'; +import { DIAGNOSTICS_CHECKS_MAP } from './contstants'; +import { TachyonShellMethods, RemoteFakeIPMethods } from '../../../methods'; +import type { IDiagnosticsChecksItem } from '../../../services'; +import { updateCheckStore } from './updateCheckStore'; +import { getMeta } from '../helpers/getMeta'; + +export async function runFakeIPCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.FAKEIP; + + updateCheckStore({ + order, + code, + title, + description: _('Checking, please wait'), + state: 'loading', + items: [], + }); + + const routerFakeIPResponse = await TachyonShellMethods.checkFakeIP(); + const checkFakeIPResponse = await RemoteFakeIPMethods.getFakeIpCheck(); + const checkIPResponse = await RemoteFakeIPMethods.getIpCheck(); + const browserFakeIPCheckUnavailable = !checkFakeIPResponse.success; + const browserFakeIPCheckMessage = checkFakeIPResponse.success + ? '' + : checkFakeIPResponse.message; + + const checks = { + singBoxFakeIP: + routerFakeIPResponse.success && routerFakeIPResponse.data.fakeip, + browserFakeIP: + checkFakeIPResponse.success && checkFakeIPResponse.data.fakeip, + canComparePublicIP: checkFakeIPResponse.success && checkIPResponse.success, + differentIP: + checkFakeIPResponse.success && + checkIPResponse.success && + checkFakeIPResponse.data.IP !== checkIPResponse.data.IP, + }; + + const fakeIPWorks = checks.singBoxFakeIP && checks.browserFakeIP; + const { state, description } = fakeIPWorks + ? checks.differentIP + ? { state: 'success' as const, description: _('Checks passed') } + : { + state: 'warning' as const, + description: _('FakeIP works; public IP comparison is inconclusive'), + } + : browserFakeIPCheckUnavailable && checks.singBoxFakeIP + ? { + state: 'warning' as const, + description: _('Browser FakeIP check could not be completed'), + } + : getMeta({ + allGood: false, + atLeastOneGood: checks.singBoxFakeIP || checks.browserFakeIP, + }); + + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + { + state: checks.singBoxFakeIP ? 'success' : 'error', + key: checks.singBoxFakeIP + ? _('Sing-box FakeIP DNS works') + : _('Sing-box FakeIP DNS does not work'), + value: routerFakeIPResponse.success ? routerFakeIPResponse.data.IP : '', + }, + { + state: browserFakeIPCheckUnavailable + ? 'warning' + : checks.browserFakeIP + ? 'success' + : 'error', + key: browserFakeIPCheckUnavailable + ? _('Browser FakeIP check could not be completed') + : checks.browserFakeIP + ? _('Browser is using FakeIP correctly') + : _('Browser is not using FakeIP'), + value: browserFakeIPCheckMessage, + }, + ...insertIf(checks.browserFakeIP, [ + { + state: checks.differentIP ? 'success' : 'warning', + key: !checks.canComparePublicIP + ? _('Could not compare FakeIP and control public IPs') + : checks.differentIP + ? _('FakeIP and control checks use different public IPs') + : _('FakeIP and control checks use the same public IP'), + value: '', + }, + ]), + ], + }); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts new file mode 100644 index 00000000..5332273a --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts @@ -0,0 +1,223 @@ +import { DIAGNOSTICS_CHECKS_MAP } from './contstants'; +import { TachyonShellMethods } from '../../../methods'; +import { updateCheckStore } from './updateCheckStore'; +import { getMeta } from '../helpers/getMeta'; +import { IDiagnosticsChecksItem } from '../../../services'; +import { Tachyon } from '../../../types'; + +function serverPrefix(item: Tachyon.InboundCheckItem) { + return `${item.label}:`; +} + +function formatListen(item: Tachyon.InboundCheckItem) { + return `${item.listen}:${Number(item.listen_port || 0)} [${item.required_proto}]`; +} + +function getPortConflictItem( + item: Tachyon.InboundCheckItem, +): IDiagnosticsChecksItem { + const key = `${serverPrefix(item)} ${_('Port conflict')}`; + + if (item.port_conflict === 1) { + return { + state: 'error', + key, + value: `${_('Used by')}: ${item.port_conflict_owners || _('unknown')}`, + }; + } + + return { + state: 'success', + key, + value: _('No conflict detected'), + }; +} + +function getPublicHostItem( + item: Tachyon.InboundCheckItem, + wanIp: string, +): IDiagnosticsChecksItem { + const key = `${serverPrefix(item)} ${_('Public host')}`; + + if (!item.public_host) { + return { + state: 'warning', + key, + value: _('Not configured'), + }; + } + + if (item.public_host_resolved === 0) { + return { + state: 'warning', + key, + value: `${item.public_host} (${_('Does not resolve')})`, + }; + } + + if (item.public_host_public === 0) { + return { + state: 'warning', + key, + value: `${item.public_host} (${_('Not public')})`, + }; + } + + if (item.public_host_matches_wan === 0) { + return { + state: 'warning', + key, + value: `${item.public_host_ips || item.public_host} / ${_('WAN')}: ${wanIp || _('Not detected')}`, + }; + } + + return { + state: 'success', + key, + value: item.public_host, + }; +} + +function getServerItems( + item: Tachyon.InboundCheckItem, + wanIp: string, +): IDiagnosticsChecksItem[] { + const prefix = serverPrefix(item); + const items: IDiagnosticsChecksItem[] = [ + { + state: item.runtime_ok ? 'success' : 'error', + key: `${prefix} ${_('Generated inbound')}`, + value: `${item.tag} [${item.protocol}]`, + }, + ]; + + if (item.protocol === 'tailscale') { + items.push( + { + state: 'success', + key: `${prefix} ${_('Tailscale endpoint')}`, + value: _('No public firewall port required'), + }, + { + state: item.routes_configured ? 'success' : 'warning', + key: `${prefix} ${_('Routing rules')}`, + value: item.routing_mode, + }, + ); + + return items; + } + + if (item.protocol === 'json_inbound') { + items.push({ + state: item.routes_configured ? 'success' : 'warning', + key: `${prefix} ${_('Routing rules')}`, + value: item.routing_mode, + }); + + return items; + } + + items.push( + { + state: + item.listening === 1 && item.port_conflict !== 1 ? 'success' : 'error', + key: `${prefix} ${_('Listening port')}`, + value: formatListen(item), + }, + getPortConflictItem(item), + { + state: + item.firewall_required === 0 + ? 'warning' + : item.firewall_open === 1 + ? 'success' + : 'error', + key: `${prefix} ${_('Firewall WAN port')}`, + value: + item.firewall_required === 0 + ? _('Not required for this listen address') + : `${item.required_proto}/${Number(item.listen_port || 0)}`, + }, + { + state: item.routes_configured ? 'success' : 'warning', + key: `${prefix} ${_('Routing rules')}`, + value: item.routing_mode, + }, + getPublicHostItem(item, wanIp), + ); + + return items; +} + +export async function runInboundsCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.INBOUNDS; + + updateCheckStore({ + order, + code, + title, + description: _('Checking, please wait'), + state: 'loading', + items: [], + }); + + const inboundsChecks = await TachyonShellMethods.checkInbounds(); + + if (!inboundsChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _('Cannot receive checks result'), + state: 'error', + items: [], + }); + + throw new Error('Inbounds checks failed'); + } + + const data = inboundsChecks.data; + + if (!data.enabled_count) { + updateCheckStore({ + order, + code, + title, + description: _('No enabled server inbounds configured'), + state: 'skipped', + items: [], + }); + + return; + } + + const items: IDiagnosticsChecksItem[] = [ + { + state: data.wan_public ? 'success' : 'warning', + key: _('WAN public IP'), + value: data.wan_ip || _('Not detected'), + }, + ]; + + data.items.forEach((item) => { + items.push(...getServerItems(item, data.wan_ip)); + }); + + const allGood = items.every((item) => item.state === 'success'); + const atLeastOneGood = items.some((item) => item.state !== 'error'); + const { state, description } = getMeta({ atLeastOneGood, allGood }); + + updateCheckStore({ + order, + code, + title, + description, + state, + items, + }); + + if (!atLeastOneGood) { + throw new Error('Inbounds checks failed'); + } +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runNftCheck.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runNftCheck.ts new file mode 100644 index 00000000..85a944e9 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runNftCheck.ts @@ -0,0 +1,115 @@ +import { DIAGNOSTICS_CHECKS_MAP } from './contstants'; +import { RemoteFakeIPMethods, TachyonShellMethods } from '../../../methods'; +import { updateCheckStore } from './updateCheckStore'; +import { getMeta } from '../helpers/getMeta'; + +export async function runNftCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.NFT; + + updateCheckStore({ + order, + code, + title, + description: _('Checking, please wait'), + state: 'loading', + items: [], + }); + + await RemoteFakeIPMethods.getFakeIpCheck(); + await RemoteFakeIPMethods.getIpCheck(); + + const nftablesChecks = await TachyonShellMethods.checkNftRules(); + + if (!nftablesChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _('Cannot receive checks result'), + state: 'error', + items: [], + }); + + throw new Error('Nftables checks failed'); + } + + const data = nftablesChecks.data; + + const allGood = + Boolean(data.table_exist) && + Boolean(data.rules_mangle_exist) && + Boolean(data.rules_mangle_counters) && + Boolean(data.rules_mangle_output_exist) && + Boolean(data.rules_mangle_output_counters) && + Boolean(data.rules_proxy_exist) && + Boolean(data.rules_proxy_counters) && + !data.rules_other_mark_exist; + + const atLeastOneGood = + Boolean(data.table_exist) || + Boolean(data.rules_mangle_exist) || + Boolean(data.rules_mangle_counters) || + Boolean(data.rules_mangle_output_exist) || + Boolean(data.rules_mangle_output_counters) || + Boolean(data.rules_proxy_exist) || + Boolean(data.rules_proxy_counters) || + !data.rules_other_mark_exist; + + const { state, description } = getMeta({ atLeastOneGood, allGood }); + + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + { + state: data.table_exist ? 'success' : 'error', + key: _('Table exist'), + value: '', + }, + { + state: data.rules_mangle_exist ? 'success' : 'error', + key: _('Rules mangle exist'), + value: '', + }, + { + state: data.rules_mangle_counters ? 'success' : 'error', + key: _('Rules mangle counters'), + value: '', + }, + { + state: data.rules_mangle_output_exist ? 'success' : 'error', + key: _('Rules mangle output exist'), + value: '', + }, + { + state: data.rules_mangle_output_counters ? 'success' : 'error', + key: _('Rules mangle output counters'), + value: '', + }, + { + state: data.rules_proxy_exist ? 'success' : 'error', + key: _('Rules proxy exist'), + value: '', + }, + { + state: data.rules_proxy_counters ? 'success' : 'error', + key: _('Rules proxy counters'), + value: '', + }, + { + state: !data.rules_other_mark_exist ? 'success' : 'warning', + key: !data.rules_other_mark_exist + ? _('No other marking rules found') + : _('Additional marking rules found'), + value: '', + }, + ], + }); + + if (!atLeastOneGood) { + throw new Error('Nftables checks failed'); + } +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts new file mode 100644 index 00000000..50aeac35 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts @@ -0,0 +1,182 @@ +import { DIAGNOSTICS_CHECKS_MAP } from './contstants'; +import { TachyonShellMethods } from '../../../methods'; +import { updateCheckStore } from './updateCheckStore'; +import { getMeta } from '../helpers/getMeta'; +import { getDashboardSections } from '../../../methods/custom/getDashboardSections'; +import { IDiagnosticsChecksItem } from '../../../services'; + +type SectionCheckState = IDiagnosticsChecksItem['state']; + +function getSubscriptionLatencyState( + latencyValues: unknown[], +): SectionCheckState { + const hasAvailableLatency = latencyValues.some((item) => Boolean(item)); + const hasUnavailableLatency = latencyValues.some((item) => !item); + + if (!hasAvailableLatency) { + return 'error'; + } + + if (hasUnavailableLatency) { + return 'warning'; + } + + return 'success'; +} + +export async function runSectionsCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS; + + updateCheckStore({ + order, + code, + title, + description: _('Checking, please wait'), + state: 'loading', + items: [], + }); + + const sections = await getDashboardSections({ + includeSubscriptionCopyState: false, + }); + + if (!sections.success) { + updateCheckStore({ + order, + code, + title, + description: _('Cannot receive checks result'), + state: 'error', + items: [], + }); + + throw new Error('Rule outbounds checks failed'); + } + + const items: Array = []; + + for (const section of sections.data) { + async function getLatency(): Promise<{ + state: SectionCheckState; + latency: string; + }> { + if (section.withTagSelect) { + const selectedOutbound = + section.outbounds.find((item) => item.selected) ?? + section.outbounds.find( + (item) => item.type?.toLowerCase() === 'urltest', + ) ?? + section.outbounds[0]; + + const isSubscription = section.proxyConfigType === 'subscription'; + + if (selectedOutbound?.code) { + const latencyProxy = await TachyonShellMethods.getClashApiProxyLatency( + selectedOutbound.code, + section.latencyTestTimeout, + ); + const proxySuccess = + latencyProxy.success && !latencyProxy.data.message; + + if (proxySuccess) { + return { + state: 'success', + latency: `[${selectedOutbound.displayName ?? ''}] ${latencyProxy.data.delay}ms`, + }; + } + + return { + state: 'error', + latency: `[${selectedOutbound.displayName ?? ''}] ${_('Not responding')}`, + }; + } + + const latencyGroup = await TachyonShellMethods.getClashApiGroupLatency( + section.code, + ); + const success = latencyGroup.success && !latencyGroup.data.message; + + if (success) { + const latencyValues = Object.values(latencyGroup.data); + const sectionState = isSubscription + ? getSubscriptionLatencyState(latencyValues) + : 'success'; + + const selectedProxyDelay = + latencyGroup.data?.[selectedOutbound?.code ?? '']; + + if (selectedProxyDelay) { + return { + state: sectionState, + latency: `[${selectedOutbound?.displayName ?? ''}] ${selectedProxyDelay}ms`, + }; + } + + return { + state: 'error', + latency: `[${selectedOutbound?.displayName ?? ''}] ${_('Not responding')}`, + }; + } + + return { + state: 'error', + latency: _('Not responding'), + }; + } + + const selectedOutbound = section.outbounds[0]; + const latencyProxy = await TachyonShellMethods.getClashApiProxyLatency( + section.code, + section.latencyTestTimeout, + ); + + const success = latencyProxy.success && !latencyProxy.data.message; + + if (success) { + return { + state: 'success', + latency: `${latencyProxy.data.delay} ms`, + }; + } + + if (section.action === 'vpn' && selectedOutbound?.runtimeAvailable) { + return { + state: 'warning', + latency: `[${selectedOutbound.displayName || section.code}] ${_('Connectivity probe failed')}`, + }; + } + + return { + state: 'error', + latency: _('Not responding'), + }; + } + + const { latency, state } = await getLatency(); + + items.push({ + state, + key: section.displayName, + value: latency, + }); + } + + const allGood = items.every((item) => item.state === 'success'); + + const atLeastOneGood = items.some((item) => item.state !== 'error'); + + const { state, description } = getMeta({ atLeastOneGood, allGood }); + + updateCheckStore({ + order, + code, + title, + description, + state, + items, + }); + + if (!atLeastOneGood) { + throw new Error('Rule outbounds checks failed'); + } +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts new file mode 100644 index 00000000..e96b652c --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts @@ -0,0 +1,96 @@ +import { DIAGNOSTICS_CHECKS_MAP } from './contstants'; +import { TachyonShellMethods } from '../../../methods'; +import { updateCheckStore } from './updateCheckStore'; +import { getMeta } from '../helpers/getMeta'; + +export async function runSingBoxCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.SINGBOX; + + updateCheckStore({ + order, + code, + title, + description: _('Checking, please wait'), + state: 'loading', + items: [], + }); + + const singBoxChecks = await TachyonShellMethods.checkSingBox(); + + if (!singBoxChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _('Cannot receive checks result'), + state: 'error', + items: [], + }); + + throw new Error('Sing-box checks failed'); + } + + const data = singBoxChecks.data; + + const allGood = + Boolean(data.sing_box_installed) && + Boolean(data.sing_box_version_ok) && + Boolean(data.sing_box_service_exist) && + Boolean(data.sing_box_autostart_disabled) && + Boolean(data.sing_box_process_running) && + Boolean(data.sing_box_ports_listening); + + const atLeastOneGood = + Boolean(data.sing_box_installed) || + Boolean(data.sing_box_version_ok) || + Boolean(data.sing_box_service_exist) || + Boolean(data.sing_box_autostart_disabled) || + Boolean(data.sing_box_process_running) || + Boolean(data.sing_box_ports_listening); + + const { state, description } = getMeta({ atLeastOneGood, allGood }); + + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + { + state: data.sing_box_installed ? 'success' : 'error', + key: _('Sing-box installed'), + value: '', + }, + { + state: data.sing_box_version_ok ? 'success' : 'error', + key: _('Sing-box version is compatible (newer than 1.12.4)'), + value: '', + }, + { + state: data.sing_box_service_exist ? 'success' : 'error', + key: _('Sing-box service exist'), + value: '', + }, + { + state: data.sing_box_autostart_disabled ? 'success' : 'error', + key: _('Sing-box autostart disabled'), + value: '', + }, + { + state: data.sing_box_process_running ? 'success' : 'error', + key: _('Sing-box process running'), + value: '', + }, + { + state: data.sing_box_ports_listening ? 'success' : 'error', + key: _('Sing-box listening ports'), + value: '', + }, + ], + }); + + if (!atLeastOneGood || !data.sing_box_process_running) { + throw new Error('Sing-box checks failed'); + } +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts new file mode 100644 index 00000000..ad87d7e7 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts @@ -0,0 +1,136 @@ +import { DIAGNOSTICS_CHECKS_MAP } from './contstants'; +import { TachyonShellMethods } from '../../../methods'; +import { updateCheckStore } from './updateCheckStore'; +import { IDiagnosticsChecksItem } from '../../../services'; +import { getCheckItemsMeta } from './getCheckItemsMeta'; + +export async function runZapret2Check() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.ZAPRET2; + + updateCheckStore({ + order, + code, + title, + description: _('Checking, please wait'), + state: 'loading', + items: [], + }); + + const zapret2Status = await TachyonShellMethods.getZapret2Status(); + + if (!zapret2Status.success) { + updateCheckStore({ + order, + code, + title, + description: _('Cannot receive checks result'), + state: 'error', + items: [], + }); + + throw new Error('Zapret2 checks failed'); + } + + const data = zapret2Status.data; + const providerAvailable = Boolean(data.provider_available ?? data.installed); + const packageInstalled = Boolean(data.package_installed); + const hasZapret2Rules = Number(data.enabled_rule_count || 0) > 0; + const queueOverlap = Boolean(data.queue_overlap); + const expectedProcesses = Number(data.expected_process_count || 0); + const runningProcesses = Number(data.running_process_count || 0); + const supervisorProcesses = Number(data.supervisor_process_count || 0); + const tachyonRuntimeReady = + !hasZapret2Rules || + (runningProcesses === expectedProcesses && + supervisorProcesses === expectedProcesses); + const unexpectedRuntime = + !hasZapret2Rules && (runningProcesses > 0 || supervisorProcesses > 0); + const outboundsConfigured = Boolean(data.outbounds_configured); + const standaloneServiceEnabled = Boolean(data.standalone_service_enabled); + const standaloneServiceRunning = Boolean(data.standalone_service_running); + const standaloneConflict = hasZapret2Rules && standaloneServiceRunning; + const standaloneAutostartRisk = + hasZapret2Rules && standaloneServiceEnabled && !standaloneServiceRunning; + + const items: Array = [ + { + state: providerAvailable + ? 'success' + : hasZapret2Rules + ? 'error' + : 'warning', + key: providerAvailable + ? _('Zapret2 provider binary is available') + : _('Zapret2 provider binary is not available'), + value: data.provider_path || '', + }, + { + state: packageInstalled + ? 'success' + : hasZapret2Rules + ? 'error' + : 'warning', + key: packageInstalled + ? _('Zapret2 package is installed') + : _('Zapret2 package is not installed'), + value: '', + }, + { + state: hasZapret2Rules && !providerAvailable ? 'error' : 'success', + key: hasZapret2Rules + ? _('There are rules using Zapret2') + : _('No rules use Zapret2'), + value: '', + }, + { + state: unexpectedRuntime || !tachyonRuntimeReady ? 'error' : 'success', + key: hasZapret2Rules + ? tachyonRuntimeReady + ? _('Tachyon-managed nfqws2 runtime is ready') + : _('Tachyon-managed nfqws2 runtime is not ready') + : unexpectedRuntime + ? _('Unexpected Tachyon-managed nfqws2 runtime is running') + : _('Tachyon-managed nfqws2 runtime is not running'), + value: hasZapret2Rules ? `${runningProcesses}/${expectedProcesses}` : '', + }, + { + state: queueOverlap ? 'error' : 'success', + key: queueOverlap + ? _('NFQUEUE range overlaps with another rule') + : _('NFQUEUE range is available'), + value: `${Number(data.queue_base || 0)}-${Number(data.queue_range_end || 0)}`, + }, + { + state: !hasZapret2Rules || outboundsConfigured ? 'success' : 'error', + key: outboundsConfigured + ? _('Zapret2 sing-box outbound is configured') + : _('Zapret2 sing-box outbound is not configured'), + value: '', + }, + { + state: standaloneConflict + ? 'error' + : standaloneAutostartRisk + ? 'warning' + : 'success', + key: standaloneServiceRunning + ? hasZapret2Rules + ? _('Standalone Zapret2 is active together with Tachyon Zapret2 rules') + : _('Standalone Zapret2 service is active') + : standaloneAutostartRisk + ? _('Standalone Zapret2 autostart is enabled') + : _('Standalone Zapret2 service is inactive'), + value: '', + }, + ]; + const { state, description } = getCheckItemsMeta(items); + + updateCheckStore({ + order, + code, + title, + description, + state, + items, + }); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts new file mode 100644 index 00000000..a08e30bf --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts @@ -0,0 +1,127 @@ +import { DIAGNOSTICS_CHECKS_MAP } from './contstants'; +import { TachyonShellMethods } from '../../../methods'; +import { updateCheckStore } from './updateCheckStore'; +import { IDiagnosticsChecksItem } from '../../../services'; +import { getCheckItemsMeta } from './getCheckItemsMeta'; + +export async function runZapretCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.ZAPRET; + + updateCheckStore({ + order, + code, + title, + description: _('Checking, please wait'), + state: 'loading', + items: [], + }); + + const zapretStatus = await TachyonShellMethods.getZapretStatus(); + + if (!zapretStatus.success) { + updateCheckStore({ + order, + code, + title, + description: _('Cannot receive checks result'), + state: 'error', + items: [], + }); + + throw new Error('Zapret checks failed'); + } + + const data = zapretStatus.data; + const providerAvailable = Boolean(data.provider_available ?? data.installed); + const packageInstalled = Boolean(data.package_installed); + const hasZapretRules = Number(data.enabled_rule_count || 0) > 0; + const queueOverlap = Boolean(data.queue_overlap); + const standaloneServiceRunning = Boolean(data.standalone_service_running); + const standaloneConflict = hasZapretRules && standaloneServiceRunning; + const expectedProcesses = Number(data.expected_process_count || 0); + const runningProcesses = Number(data.running_process_count || 0); + const supervisorProcesses = Number(data.supervisor_process_count || 0); + const tachyonRuntimeReady = + !hasZapretRules || + (runningProcesses === expectedProcesses && + supervisorProcesses === expectedProcesses); + const unexpectedRuntime = + !hasZapretRules && (runningProcesses > 0 || supervisorProcesses > 0); + const outboundsConfigured = Boolean(data.outbounds_configured); + + const items: Array = [ + { + state: providerAvailable + ? 'success' + : hasZapretRules + ? 'error' + : 'warning', + key: providerAvailable + ? _('Zapret provider binary is available') + : _('Zapret provider binary is not available'), + value: data.provider_path || '', + }, + { + state: packageInstalled + ? 'success' + : hasZapretRules + ? 'error' + : 'warning', + key: packageInstalled + ? _('Zapret package is installed') + : _('Zapret package is not installed'), + value: '', + }, + { + state: hasZapretRules && !providerAvailable ? 'error' : 'success', + key: hasZapretRules + ? _('There are rules using Zapret') + : _('No rules use Zapret'), + value: '', + }, + { + state: unexpectedRuntime || !tachyonRuntimeReady ? 'error' : 'success', + key: hasZapretRules + ? tachyonRuntimeReady + ? _('Tachyon-managed nfqws runtime is ready') + : _('Tachyon-managed nfqws runtime is not ready') + : unexpectedRuntime + ? _('Unexpected Tachyon-managed nfqws runtime is running') + : _('Tachyon-managed nfqws runtime is not running'), + value: hasZapretRules ? `${runningProcesses}/${expectedProcesses}` : '', + }, + { + state: queueOverlap ? 'error' : 'success', + key: queueOverlap + ? _('NFQUEUE range overlaps with another rule') + : _('NFQUEUE range is available'), + value: `${Number(data.queue_base || 0)}-${Number(data.queue_range_end || 0)}`, + }, + { + state: !hasZapretRules || outboundsConfigured ? 'success' : 'error', + key: outboundsConfigured + ? _('Zapret sing-box outbound is configured') + : _('Zapret sing-box outbound is not configured'), + value: '', + }, + { + state: standaloneConflict ? 'warning' : 'success', + key: standaloneServiceRunning + ? hasZapretRules + ? _('Standalone Zapret is active together with Tachyon Zapret rules') + : _('Standalone Zapret service is active') + : _('Standalone Zapret service is inactive'), + value: '', + }, + ]; + const { state, description } = getCheckItemsMeta(items); + + updateCheckStore({ + order, + code, + title, + description, + state, + items, + }); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/tests/getDnsCheckPresentation.test.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/tests/getDnsCheckPresentation.test.ts new file mode 100644 index 00000000..246c3e0a --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/tests/getDnsCheckPresentation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { getDnsCheckPresentation } from '../getDnsCheckPresentation'; + +const baseDnsResult = { + dns_type: 'udp' as const, + dns_server: '77.88.8.8', + dns_server_index: 0, + dns_server_count: 1, + dns_status: 1 as const, + dns_on_router: 1 as const, + bootstrap_dns_server: '77.88.8.8', + bootstrap_dns_server_index: 0, + bootstrap_dns_server_count: 1, + bootstrap_dns_status: 1 as const, + dhcp_config_status: 0 as const, + dont_touch_dhcp: 0 as const, +}; + +describe('getDnsCheckPresentation', () => { + it('keeps manual DHCP mode as a warning instead of a DNS failure', () => { + expect( + getDnsCheckPresentation({ + ...baseDnsResult, + dont_touch_dhcp: 1, + }), + ).toMatchObject({ + state: 'warning', + description: 'Checks passed with manual DHCP', + dhcpItemState: 'warning', + dhcpItemKey: 'DHCP is managed manually', + }); + }); + + it('keeps a mismatched managed DHCP config as an error item', () => { + expect(getDnsCheckPresentation(baseDnsResult)).toMatchObject({ + state: 'warning', + description: 'Issues detected', + dhcpItemState: 'error', + dhcpItemKey: 'DHCP has DNS server', + }); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/tests/runProviderChecks.test.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/tests/runProviderChecks.test.ts new file mode 100644 index 00000000..b4b5771d --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/tests/runProviderChecks.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getZapretStatus: vi.fn(), + getZapret2Status: vi.fn(), + getByedpiStatus: vi.fn(), + updateCheckStore: vi.fn(), +})); + +vi.mock('../../../../methods', () => ({ + TachyonShellMethods: { + getZapretStatus: mocks.getZapretStatus, + getZapret2Status: mocks.getZapret2Status, + getByedpiStatus: mocks.getByedpiStatus, + }, +})); + +vi.mock('../updateCheckStore', () => ({ + updateCheckStore: mocks.updateCheckStore, +})); + +import { runByedpiCheck } from '../runByedpiCheck'; +import { runZapret2Check } from '../runZapret2Check'; +import { runZapretCheck } from '../runZapretCheck'; + +const zapretOkData = { + provider_available: true, + package_installed: true, + enabled_rule_count: 1, + expected_process_count: 1, + running_process_count: 1, + supervisor_process_count: 1, + queue_overlap: false, + queue_base: 200, + queue_range_end: 200, + standalone_service_running: false, + outbounds_configured: true, + routes_configured: false, + ready: false, +}; + +describe('provider diagnostics checks', () => { + beforeEach(() => { + mocks.getZapretStatus.mockReset(); + mocks.getZapret2Status.mockReset(); + mocks.getByedpiStatus.mockReset(); + mocks.updateCheckStore.mockReset(); + }); + + it('shows Zapret provider path and does not fail on hidden route status', async () => { + mocks.getZapretStatus.mockResolvedValue({ + success: true, + data: { + ...zapretOkData, + provider_path: '/usr/bin/nfqws', + }, + }); + + await expect(runZapretCheck()).resolves.toBeUndefined(); + + const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0]; + + expect(result).toMatchObject({ + state: 'success', + description: 'Checks passed', + }); + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'Zapret provider binary is available', + value: '/usr/bin/nfqws', + }), + expect.objectContaining({ + state: 'success', + key: 'Zapret sing-box outbound is configured', + }), + ]), + ); + expect( + result.items.some((item: { key: string }) => + item.key.includes('route rules'), + ), + ).toBe(false); + }); + + it('keeps Zapret outbound failures visible and red', async () => { + mocks.getZapretStatus.mockResolvedValue({ + success: true, + data: { + ...zapretOkData, + provider_path: '/usr/bin/nfqws', + outbounds_configured: false, + }, + }); + + await expect(runZapretCheck()).resolves.toBeUndefined(); + + const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0]; + + expect(result).toMatchObject({ + state: 'error', + description: 'Checks failed', + }); + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + state: 'error', + key: 'Zapret sing-box outbound is not configured', + }), + ]), + ); + }); + + it('removes Zapret2 route checks while keeping provider path visible', async () => { + mocks.getZapret2Status.mockResolvedValue({ + success: true, + data: { + ...zapretOkData, + provider_path: '/usr/bin/nfqws2', + }, + }); + + await expect(runZapret2Check()).resolves.toBeUndefined(); + + const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0]; + + expect(result).toMatchObject({ + state: 'success', + description: 'Checks passed', + }); + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'Zapret2 provider binary is available', + value: '/usr/bin/nfqws2', + }), + expect.objectContaining({ + state: 'success', + key: 'Zapret2 sing-box outbound is configured', + }), + ]), + ); + expect( + result.items.some((item: { key: string }) => + item.key.includes('route rules'), + ), + ).toBe(false); + }); + + it('removes ByeDPI route checks from the visible and aggregate status', async () => { + mocks.getByedpiStatus.mockResolvedValue({ + success: true, + data: { + provider_available: true, + package_installed: true, + enabled_rule_count: 1, + expected_process_count: 1, + running_process_count: 1, + supervisor_process_count: 1, + restart_count: 0, + runtime_unstable: false, + standalone_service_enabled: false, + standalone_service_running: false, + listen_address: '127.0.0.1', + port_base: 1080, + outbounds_configured: true, + routes_configured: false, + ready: false, + }, + }); + + await expect(runByedpiCheck()).resolves.toBeUndefined(); + + const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0]; + + expect(result).toMatchObject({ + state: 'success', + description: 'Checks passed', + }); + expect( + result.items.some((item: { key: string }) => + item.key.includes('route rules'), + ), + ).toBe(false); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/tests/runSectionsCheck.test.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/tests/runSectionsCheck.test.ts new file mode 100644 index 00000000..3dacaa6f --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/tests/runSectionsCheck.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getDashboardSections: vi.fn(), + getClashApiProxyLatency: vi.fn(), + getClashApiGroupLatency: vi.fn(), + updateCheckStore: vi.fn(), +})); + +vi.mock('../../../../methods/custom/getDashboardSections', () => ({ + getDashboardSections: mocks.getDashboardSections, +})); + +vi.mock('../../../../methods', () => ({ + TachyonShellMethods: { + getClashApiProxyLatency: mocks.getClashApiProxyLatency, + getClashApiGroupLatency: mocks.getClashApiGroupLatency, + }, +})); + +vi.mock('../updateCheckStore', () => ({ + updateCheckStore: mocks.updateCheckStore, +})); + +import { runSectionsCheck } from '../runSectionsCheck'; + +describe('runSectionsCheck', () => { + beforeEach(() => { + mocks.getDashboardSections.mockReset(); + mocks.getClashApiProxyLatency.mockReset(); + mocks.getClashApiGroupLatency.mockReset(); + mocks.updateCheckStore.mockReset(); + }); + + it('keeps VPN interface probe failures as warnings when the runtime outbound exists', async () => { + mocks.getDashboardSections.mockResolvedValue({ + success: true, + data: [ + { + withTagSelect: false, + code: 'AWG-out', + sectionName: 'AWG', + displayName: 'AWG', + action: 'vpn', + latencyTestTimeout: '10000', + outbounds: [ + { + code: 'AWG-out', + displayName: 'awg1', + latency: 0, + type: 'Direct', + selected: true, + runtimeAvailable: true, + }, + ], + }, + ], + }); + mocks.getClashApiProxyLatency.mockResolvedValue({ + success: true, + data: { message: 'context deadline exceeded' }, + }); + + await expect(runSectionsCheck()).resolves.toBeUndefined(); + + expect(mocks.getClashApiProxyLatency).toHaveBeenCalledWith( + 'AWG-out', + '10000', + ); + expect(mocks.updateCheckStore).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: 'warning', + description: 'Issues detected', + items: [ + { + state: 'warning', + key: 'AWG', + value: '[awg1] Connectivity probe failed', + }, + ], + }), + ); + }); + + it('checks only the selected concrete outbound for selectable proxy sections', async () => { + mocks.getDashboardSections.mockResolvedValue({ + success: true, + data: [ + { + withTagSelect: true, + code: 'main-out', + sectionName: 'main', + displayName: 'Main', + latencyTestTimeout: '7000', + outbounds: [ + { + code: 'main-1-out', + displayName: 'Selected', + latency: 0, + type: 'VLESS', + selected: true, + }, + { + code: 'main-2-out', + displayName: 'Other', + latency: 0, + type: 'VLESS', + selected: false, + }, + ], + }, + ], + }); + mocks.getClashApiProxyLatency.mockResolvedValue({ + success: true, + data: { delay: 123 }, + }); + + await expect(runSectionsCheck()).resolves.toBeUndefined(); + + expect(mocks.getClashApiProxyLatency).toHaveBeenCalledWith( + 'main-1-out', + '7000', + ); + expect(mocks.getClashApiGroupLatency).not.toHaveBeenCalled(); + expect(mocks.updateCheckStore).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: 'success', + items: [ + { + state: 'success', + key: 'Main', + value: '[Selected] 123ms', + }, + ], + }), + ); + }); + + it('checks the selected URLTest outbound directly', async () => { + mocks.getDashboardSections.mockResolvedValue({ + success: true, + data: [ + { + withTagSelect: true, + code: 'main-out', + sectionName: 'main', + displayName: 'Main', + proxyConfigType: 'selector', + outbounds: [ + { + code: 'main-urltest-out', + displayName: 'Fastest', + latency: 10, + type: 'URLTest', + selected: true, + }, + { + code: 'main-1-out', + displayName: 'One', + latency: 0, + type: 'VLESS', + selected: false, + }, + { + code: 'main-2-out', + displayName: 'Two', + latency: 0, + type: 'VLESS', + selected: false, + }, + ], + }, + ], + }); + mocks.getClashApiProxyLatency.mockResolvedValue({ + success: true, + data: { delay: 10 }, + }); + + await expect(runSectionsCheck()).resolves.toBeUndefined(); + + expect(mocks.getClashApiProxyLatency).toHaveBeenCalledWith( + 'main-urltest-out', + undefined, + ); + expect(mocks.getClashApiGroupLatency).not.toHaveBeenCalled(); + expect(mocks.updateCheckStore).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: 'success', + items: [ + { + state: 'success', + key: 'Main', + value: '[Fastest] 10ms', + }, + ], + }), + ); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/updateCheckStore.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/updateCheckStore.ts new file mode 100644 index 00000000..85583b81 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/checks/updateCheckStore.ts @@ -0,0 +1,20 @@ +import { IDiagnosticsChecksStoreItem, store } from '../../../services'; + +export function updateCheckStore( + check: IDiagnosticsChecksStoreItem, + minified?: boolean, +) { + const diagnosticsChecks = store.get().diagnosticsChecks; + const other = diagnosticsChecks.filter((item) => item.code !== check.code); + + const smallCheck: IDiagnosticsChecksStoreItem = { + ...check, + items: check.items.filter((item) => item.state !== 'success'), + }; + + const targetCheck = minified ? smallCheck : check; + + store.set({ + diagnosticsChecks: [...other, targetCheck], + }); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnostic.store.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnostic.store.ts new file mode 100644 index 00000000..670da416 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnostic.store.ts @@ -0,0 +1,152 @@ +import { + DIAGNOSTICS_CHECKS, + DIAGNOSTICS_CHECKS_MAP, +} from './checks/contstants'; +import { IDiagnosticsChecksStoreItem, StoreType } from '../../services'; + +export interface DiagnosticsProviderOptions { + includeZapret?: boolean; + includeZapret2?: boolean; + includeByedpi?: boolean; + includeInbounds?: boolean; +} + +function createDiagnosticCheck( + code: DIAGNOSTICS_CHECKS, + description: string, +): IDiagnosticsChecksStoreItem { + const meta = DIAGNOSTICS_CHECKS_MAP[code]; + + return { + code, + title: meta.title, + order: meta.order, + description, + items: [], + state: 'skipped', + }; +} + +export function getDiagnosticsChecks( + description: string, + options: DiagnosticsProviderOptions = {}, +): Array { + const checks = [DIAGNOSTICS_CHECKS.DNS, DIAGNOSTICS_CHECKS.SINGBOX]; + + if (options.includeInbounds === true) { + checks.push(DIAGNOSTICS_CHECKS.INBOUNDS); + } + + checks.push(DIAGNOSTICS_CHECKS.NFT); + + if (options.includeZapret) { + checks.push(DIAGNOSTICS_CHECKS.ZAPRET); + } + + if (options.includeZapret2) { + checks.push(DIAGNOSTICS_CHECKS.ZAPRET2); + } + + if (options.includeByedpi) { + checks.push(DIAGNOSTICS_CHECKS.BYEDPI); + } + + checks.push(DIAGNOSTICS_CHECKS.OUTBOUNDS, DIAGNOSTICS_CHECKS.FAKEIP); + + return checks.map((code) => createDiagnosticCheck(code, description)); +} + +export function getLoadingDiagnosticsChecks( + options: DiagnosticsProviderOptions = {}, +): Pick { + return { + diagnosticsChecks: getDiagnosticsChecks(_('Pending'), options), + }; +} + +export const initialDiagnosticStore: Pick< + StoreType, + | 'diagnosticsChecks' + | 'diagnosticsRunAction' + | 'diagnosticsActions' + | 'diagnosticsSystemInfo' + | 'updatesActions' + | 'updatesChecks' +> = { + diagnosticsSystemInfo: { + loading: true, + loaded: false, + providerInfoLoaded: false, + tachyon_version: 'loading', + tachyon_latest_version: 'loading', + luci_app_version: 'loading', + sing_box_version: 'loading', + sing_box_extended: 0, + sing_box_tiny: 0, + sing_box_compressed: 0, + sing_box_tailscale: 1, + zapret_version: 'loading', + zapret_installed: 0, + zapret2_version: 'loading', + zapret2_installed: 0, + byedpi_version: 'loading', + byedpi_installed: 0, + server_inbounds_enabled_count: -1, + openwrt_version: 'loading', + device_model: 'loading', + }, + diagnosticsActions: { + restart: { + loading: false, + }, + start: { + loading: false, + }, + stop: { + loading: false, + }, + enable: { + loading: false, + }, + disable: { + loading: false, + }, + globalCheck: { + loading: false, + }, + viewLogs: { + loading: false, + }, + showSingBoxConfig: { + loading: false, + }, + }, + diagnosticsRunAction: { loading: false }, + diagnosticsChecks: getDiagnosticsChecks(_('Not running')), + updatesActions: { + tachyonCheck: { loading: false }, + tachyonInstall: { loading: false }, + singBoxCheck: { loading: false }, + singBoxInstall: { loading: false }, + singBoxInstallExtended: { loading: false }, + singBoxInstallExtendedCompressed: { loading: false }, + singBoxInstallTiny: { loading: false }, + singBoxInstallStable: { loading: false }, + zapretCheck: { loading: false }, + zapretInstall: { loading: false }, + zapretRemove: { loading: false }, + zapret2Check: { loading: false }, + zapret2Install: { loading: false }, + zapret2Remove: { loading: false }, + byedpiCheck: { loading: false }, + byedpiInstall: { loading: false }, + byedpiRemove: { loading: false }, + }, + updatesChecks: { + tachyon: { status: null, latest_version: '', release_url: '' }, + sing_box: { status: null, latest_version: '', release_url: '' }, + zapret: { status: null, latest_version: '', release_url: '' }, + zapret2: { status: null, latest_version: '', release_url: '' }, + byedpi: { status: null, latest_version: '', release_url: '' }, + }, +}; diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnosticRunPersistence.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnosticRunPersistence.ts new file mode 100644 index 00000000..d15e1d00 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnosticRunPersistence.ts @@ -0,0 +1,155 @@ +import type { StoreType } from '../../services'; +import type { DiagnosticsProviderOptions } from './diagnostic.store'; +import { DIAGNOSTICS_CHECKS } from './checks/contstants'; + +const DIAGNOSTIC_RUN_STORAGE_KEY = 'tachyon:diagnostic-run:v1'; +const DIAGNOSTIC_RUN_TTL_MS = 30 * 60 * 1000; +const CHECK_STATES = ['loading', 'warning', 'success', 'error', 'skipped']; +const CHECK_ITEM_STATES = ['error', 'warning', 'success']; + +export interface PersistedDiagnosticRun { + nextRunnerIndex: number; + providerOptions: DiagnosticsProviderOptions; + diagnosticsChecks: StoreType['diagnosticsChecks']; + updatedAt: number; +} + +function getSessionStorage(): Storage | null { + if (typeof window === 'undefined') { + return null; + } + + try { + return window.sessionStorage; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object'; +} + +function isOptionalBoolean(value: unknown) { + return value === undefined || typeof value === 'boolean'; +} + +function isDiagnosticsProviderOptions( + value: unknown, +): value is DiagnosticsProviderOptions { + if (!isRecord(value)) { + return false; + } + + return ( + isOptionalBoolean(value.includeZapret) && + isOptionalBoolean(value.includeZapret2) && + isOptionalBoolean(value.includeByedpi) && + isOptionalBoolean(value.includeInbounds) + ); +} + +function isDiagnosticCheckItem(value: unknown) { + return ( + isRecord(value) && + CHECK_ITEM_STATES.includes(String(value.state)) && + typeof value.key === 'string' && + typeof value.value === 'string' + ); +} + +function isDiagnosticCheck(value: unknown) { + return ( + isRecord(value) && + Number.isFinite(value.order) && + Object.values(DIAGNOSTICS_CHECKS).includes( + value.code as DIAGNOSTICS_CHECKS, + ) && + typeof value.title === 'string' && + typeof value.description === 'string' && + CHECK_STATES.includes(String(value.state)) && + Array.isArray(value.items) && + value.items.every(isDiagnosticCheckItem) + ); +} + +function isPersistedDiagnosticRun( + value: unknown, +): value is PersistedDiagnosticRun { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.nextRunnerIndex === 'number' && + Number.isInteger(value.nextRunnerIndex) && + value.nextRunnerIndex >= 0 && + isDiagnosticsProviderOptions(value.providerOptions) && + Array.isArray(value.diagnosticsChecks) && + value.diagnosticsChecks.every(isDiagnosticCheck) && + Number.isFinite(value.updatedAt) + ); +} + +function isExpired(run: PersistedDiagnosticRun, now = Date.now()) { + return now - run.updatedAt > DIAGNOSTIC_RUN_TTL_MS; +} + +export function readPersistedDiagnosticRun( + storage: Storage | null = getSessionStorage(), +): PersistedDiagnosticRun | null { + if (!storage) { + return null; + } + + try { + const parsed = JSON.parse( + storage.getItem(DIAGNOSTIC_RUN_STORAGE_KEY) || 'null', + ); + + if (!isPersistedDiagnosticRun(parsed) || isExpired(parsed)) { + storage.removeItem(DIAGNOSTIC_RUN_STORAGE_KEY); + return null; + } + + return parsed; + } catch { + storage.removeItem(DIAGNOSTIC_RUN_STORAGE_KEY); + return null; + } +} + +export function savePersistedDiagnosticRun( + run: Omit, + storage: Storage | null = getSessionStorage(), +) { + if (!storage) { + return; + } + + try { + storage.setItem( + DIAGNOSTIC_RUN_STORAGE_KEY, + JSON.stringify({ + ...run, + updatedAt: Date.now(), + }), + ); + } catch { + // Diagnostics still runs normally; persistence is only for reload recovery. + } +} + +export function clearPersistedDiagnosticRun( + storage: Storage | null = getSessionStorage(), +) { + if (!storage) { + return; + } + + try { + storage.removeItem(DIAGNOSTIC_RUN_STORAGE_KEY); + } catch { + // Ignore storage failures. + } +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/helpers/getCheckTitle.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/helpers/getCheckTitle.ts new file mode 100644 index 00000000..cb7f7a65 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/helpers/getCheckTitle.ts @@ -0,0 +1,3 @@ +export function getCheckTitle(name: string) { + return `${name} ${_('checks')}`; +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/helpers/getMeta.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/helpers/getMeta.ts new file mode 100644 index 00000000..5d79dc08 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/helpers/getMeta.ts @@ -0,0 +1,28 @@ +interface IGetMetaProps { + allGood: boolean; + atLeastOneGood: boolean; +} + +export function getMeta({ allGood, atLeastOneGood }: IGetMetaProps): { + description: string; + state: 'warning' | 'success' | 'error'; +} { + if (allGood) { + return { + state: 'success', + description: _('Checks passed'), + }; + } + + if (atLeastOneGood) { + return { + state: 'warning', + description: _('Issues detected'), + }; + } + + return { + state: 'error', + description: _('Checks failed'), + }; +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/helpers/maskDiagnostics.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/helpers/maskDiagnostics.ts new file mode 100644 index 00000000..484c770d --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/helpers/maskDiagnostics.ts @@ -0,0 +1,235 @@ +const MASKED_VALUE = 'MASKED'; + +const SING_BOX_MASKED_KEYS = new Set([ + 'auth_key', + 'control_url', + 'exit_node', + 'hostname', + 'listen', + 'listen_port', + 'username', + 'uuid', + 'server', + 'server_name', + 'secret', + 'password', + 'private_key', + 'public_key', + 'short_id', + 'fingerprint', + 'server_port', + 'server_ports', + 'advertise_routes', + 'domain', + 'domain_suffix', + 'domain_keyword', + 'domain_regex', + 'ip_cidr', + 'source_ip_cidr', +]); + +const TACHYON_MASK_AFTER_TOKEN = [ + 'option proxy_string', + 'option hwid', + 'option subscription_url', + 'list subscription_urls', + 'list urltest_proxy_links', + 'list selector_proxy_links', + 'list server_users', + 'option server_uuid', + 'option server_username', + 'option server_password', + 'option mtproto_secret', + 'option hysteria2_obfs_password', + 'option reality_private_key', + 'option reality_public_key', + 'option reality_short_id', + 'list reality_short_id', + 'option yacd_secret_key', +]; + +const TACHYON_MASK_AFTER_TOKEN_SPACE = [ + 'option outbound_json', + 'list domain', + 'list domain_suffix', + 'list domain_keyword', + 'list domain_regex', + 'list ip_cidr', + 'list source_ip_cidr', + 'list fully_routed_ips', + 'option dns_server', + 'option bootstrap_dns_server', + 'list dns_server', + 'list bootstrap_dns_server', + 'option listen', + 'option listen_port', + 'option public_host', + 'option mtproto_faketls', + 'option mtproto_domain_fronting_ip', + 'option tls_server_name', + 'option reality_handshake_server', + 'option reality_handshake_server_port', + 'option transport_host', + 'list transport_hosts', + 'option tailscale_auth_key', + 'option tailscale_control_url', + 'option tailscale_hostname', + 'list tailscale_advertise_routes', + 'option tailscale_ephemeral', + 'option tailscale_exit_node', + 'option tailscale_exit_node_allow_lan_access', + 'option mixed_proxy_username', + 'option mixed_proxy_password', + 'option ipaddr', + 'option netmask', + 'option gateway', + 'option username', + 'option password', +]; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isSpaceChar(value: string) { + return value === ' ' || value === '\t' || value === '\r' || value === '\n'; +} + +function maskAfterToken(line: string, token: string) { + const position = line.indexOf(token); + + return position < 0 + ? line + : `${line.slice(0, position)}${token} '${MASKED_VALUE}'`; +} + +function maskAfterTokenSpace(line: string, token: string) { + const position = line.indexOf(token); + + if (position < 0) { + return line; + } + + const spacePosition = position + token.length; + + if ( + spacePosition >= line.length || + !isSpaceChar(line.slice(spacePosition, spacePosition + 1)) + ) { + return line; + } + + return `${line.slice(0, spacePosition + 1)}'${MASKED_VALUE}'`; +} + +function maskOptionPath(line: string, token: string) { + const position = line.indexOf(token); + + if (position < 0) { + return line; + } + + const slashOffset = line.slice(position + token.length).indexOf('/'); + + if (slashOffset < 0) { + return line; + } + + const slash = slashOffset + position + token.length; + const quoteOffset = line.slice(slash + 1).indexOf("'"); + + if (quoteOffset < 0) { + return line; + } + + const quote = quoteOffset + slash + 1; + + return `${line.slice(0, slash)}/MASKED'${line.slice(quote + 1)}`; +} + +function maskGlobalCheckLine(line: string) { + let maskedLine = line; + + for (const token of TACHYON_MASK_AFTER_TOKEN) { + maskedLine = maskAfterToken(maskedLine, token); + } + + for (const token of TACHYON_MASK_AFTER_TOKEN_SPACE) { + maskedLine = maskAfterTokenSpace(maskedLine, token); + } + + maskedLine = maskOptionPath(maskedLine, "option dns_server '"); + maskedLine = maskOptionPath(maskedLine, "list dns_server '"); + return maskedLine; +} + +function maskMultilineContinuation(line: string) { + const leadingSpace = line.match(/^\s*/)?.[0] ?? ''; + const hasClosingQuote = line.includes("'"); + + return `${leadingSpace}${MASKED_VALUE}${hasClosingQuote ? "'" : ''}`; +} + +export function maskSingBoxConfigValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => maskSingBoxConfigValue(item)); + } + + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + SING_BOX_MASKED_KEYS.has(key) + ? MASKED_VALUE + : maskSingBoxConfigValue(item), + ]), + ); + } + + return value; +} + +export function stringifySingBoxConfig(value: unknown) { + return typeof value === 'string' ? value : JSON.stringify(value, null, 2); +} + +export function formatMaskedSingBoxConfig(value: unknown) { + if (typeof value === 'string') { + try { + return JSON.stringify(maskSingBoxConfigValue(JSON.parse(value)), null, 2); + } catch (_error) { + return value; + } + } + + return JSON.stringify(maskSingBoxConfigValue(value), null, 2); +} + +export function maskGlobalCheckText(text: string = '') { + let inMaskedMultiline = false; + + return `${text}` + .split('\n') + .map((line) => { + if (inMaskedMultiline) { + if (line.includes("'")) { + inMaskedMultiline = false; + } + + return maskMultilineContinuation(line); + } + + const maskedLine = maskGlobalCheckLine(line); + + if (line.includes('option outbound_json')) { + const firstQuote = line.indexOf("'"); + + if (firstQuote >= 0 && line.slice(firstQuote + 1).indexOf("'") < 0) { + inMaskedMultiline = true; + } + } + + return maskedLine; + }) + .join('\n'); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/index.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/index.ts new file mode 100644 index 00000000..2275b00b --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/index.ts @@ -0,0 +1,9 @@ +import { render } from './renderDiagnostic'; +import { initController } from './initController'; +import { styles } from './styles'; + +export const DiagnosticTab = { + render, + initController, + styles, +}; diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/initController.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/initController.ts new file mode 100644 index 00000000..4704ea12 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/initController.ts @@ -0,0 +1,1304 @@ +import { onMount, preserveScrollForPage } from '../../../helpers'; +import { runDnsCheck } from './checks/runDnsCheck'; +import { runSingBoxCheck } from './checks/runSingBoxCheck'; +import { runInboundsCheck } from './checks/runInboundsCheck'; +import { runNftCheck } from './checks/runNftCheck'; +import { runFakeIPCheck } from './checks/runFakeIPCheck'; +import { runZapretCheck } from './checks/runZapretCheck'; +import { runZapret2Check } from './checks/runZapret2Check'; +import { runByedpiCheck } from './checks/runByedpiCheck'; +import { + DIAGNOSTICS_CHECKS, + DIAGNOSTICS_CHECKS_MAP, +} from './checks/contstants'; +import { + DiagnosticsProviderOptions, + getDiagnosticsChecks, + getLoadingDiagnosticsChecks, +} from './diagnostic.store'; +import { + logger, + getCachedRuntimeUiState, + refreshRuntimeUiState, + setLocalServiceAction, + store, + StoreType, + subscribeRuntimeUiState, +} from '../../services'; +import { ensureSystemInfo } from '../../services/systemInfo.service'; +import { + renderAvailableActions, + renderCheckSection, + renderRunAction, + renderSystemInfo, +} from './partials'; +import { TachyonShellMethods } from '../../methods'; +import { fetchServicesInfo } from '../../fetchers/fetchServicesInfo'; +import { normalizeCompiledVersion } from '../../../helpers/normalizeCompiledVersion'; +import { renderModal } from '../../../partials'; +import { TACHYON_LUCI_APP_VERSION } from '../../../constants'; +import { renderWikiDisclaimer } from './partials/renderWikiDisclaimer'; +import { runSectionsCheck } from './checks/runSectionsCheck'; +import { Tachyon } from '../../types'; +import { + getAvailableActionsDisabledState, + getServiceTransition, + hasComponentActionLoading, + hasLocalMutatingServiceActionLoading, + isServiceTransitionStatus, + shouldResetDiagnosticsChecks, + shouldDisableDiagnosticRunAction, + shouldSkipServicesInfoAutoRefresh, + shouldShowRestartAction, + shouldShowStartAction, + shouldShowStopAction, +} from './serviceTransition'; +import { isActiveLuciTab } from '../../helpers/isActiveLuciTab'; +import { + formatSingBoxVersion, + normalizeSingBoxVariantFields, +} from '../../helpers/singBoxVariant'; +import { + clearPersistedDiagnosticRun, + PersistedDiagnosticRun, + readPersistedDiagnosticRun, + savePersistedDiagnosticRun, +} from './diagnosticRunPersistence'; +import { + formatMaskedSingBoxConfig, + maskGlobalCheckText, + stringifySingBoxConfig, +} from './helpers/maskDiagnostics'; + +const SERVICE_STATUS_REFRESH_INTERVAL_MS = 2000; +const SERVICE_ACTION_STATUS_TIMEOUT_MS = 45000; + +let latestProviderInfoRequestId = 0; +let diagnosticLifecycleRegistered = false; +let diagnosticControllerInitialized = false; +let diagnosticMounted = false; +let diagnosticMountId = 0; +let diagnosticCompletedWhileHidden = false; +let servicesInfoStateUnsubscribe: (() => void) | null = null; +let servicesInfoRefreshPromise: Promise | null = null; +const followedServiceActionJobs = new Set(); +const handledServiceActionJobs = new Set(); + +type ServiceRuntimeAction = 'restart' | 'start' | 'stop'; +type DiagnosticRunner = { + code: DIAGNOSTICS_CHECKS; + run: () => Promise; +}; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getDiagnosticsProviderOptions( + systemInfo: Pick< + StoreType['diagnosticsSystemInfo'], + | 'zapret_installed' + | 'zapret2_installed' + | 'byedpi_installed' + | 'server_inbounds_enabled_count' + > = store.get().diagnosticsSystemInfo, +): DiagnosticsProviderOptions { + return { + includeZapret: Boolean(systemInfo.zapret_installed), + includeZapret2: Boolean(systemInfo.zapret2_installed), + includeByedpi: Boolean(systemInfo.byedpi_installed), + includeInbounds: systemInfo.server_inbounds_enabled_count > 0, + }; +} + +function getNotRunningDiagnosticsChecks() { + return getDiagnosticsChecks( + _('Not running'), + getDiagnosticsProviderOptions(), + ); +} + +function resetDiagnosticsChecks() { + store.set({ + diagnosticsChecks: getNotRunningDiagnosticsChecks(), + }); +} + +function setDiagnosticActionLoading( + action: keyof StoreType['diagnosticsActions'], + loading: boolean, + local = false, +) { + if (local || !loading) { + setLocalServiceAction(action, loading && local); + } + + const diagnosticsActions = store.get().diagnosticsActions; + + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + [action]: { loading }, + }, + }); +} + +function isDiagnosticMountActive(mountId = diagnosticMountId) { + return diagnosticMounted && diagnosticMountId === mountId; +} + +function isLocalMutatingServiceActionLoading() { + const actions = store.get().diagnosticsActions; + + return hasLocalMutatingServiceActionLoading(actions); +} + +function isMutatingServiceActionLoading() { + return ( + isLocalMutatingServiceActionLoading() || + isServiceTransitionStatus(store.get().servicesInfoWidget.data.tachyonStatus) + ); +} + +function getTachyonStatusText(running: boolean, enabled: boolean) { + if (running) { + return enabled ? 'running & enabled' : 'running but disabled'; + } + + return enabled ? 'stopped but enabled' : 'stopped & disabled'; +} + +function setDisplayedTachyonRunning(running: boolean) { + const servicesInfoWidget = store.get().servicesInfoWidget; + const enabled = Boolean(servicesInfoWidget.data.tachyonEnabled); + + store.set({ + servicesInfoWidget: { + ...servicesInfoWidget, + loading: false, + data: { + ...servicesInfoWidget.data, + tachyonRunning: running ? 1 : 0, + tachyonStatus: getTachyonStatusText(running, enabled), + }, + }, + }); +} + +async function refreshDiagnosticServicesInfo({ + force = false, + mountId = diagnosticMountId, + allowInactive = false, +}: { + force?: boolean; + mountId?: number; + allowInactive?: boolean; +} = {}) { + if (!allowInactive && !isDiagnosticMountActive(mountId)) { + return; + } + + if ( + shouldSkipServicesInfoAutoRefresh({ + force, + localMutatingActionLoading: isLocalMutatingServiceActionLoading(), + }) + ) { + return; + } + + if (servicesInfoRefreshPromise) { + return servicesInfoRefreshPromise; + } + + const promise = fetchServicesInfo() + .then((uiState) => { + followServiceActionsFromUiState(uiState); + }) + .catch((error) => { + logger.error( + '[DIAGNOSTIC]', + 'refreshDiagnosticServicesInfo failed', + error, + ); + }) + .finally(() => { + if (servicesInfoRefreshPromise === promise) { + servicesInfoRefreshPromise = null; + } + }); + + servicesInfoRefreshPromise = promise; + return promise; +} + +async function waitForTachyonRunningState(expectedRunning: boolean) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < SERVICE_ACTION_STATUS_TIMEOUT_MS) { + await refreshDiagnosticServicesInfo({ force: true, allowInactive: true }); + + const tachyonRunning = Boolean( + store.get().servicesInfoWidget.data.tachyonRunning, + ); + + if (tachyonRunning === expectedRunning) { + return true; + } + + await sleep(SERVICE_STATUS_REFRESH_INTERVAL_MS); + } + + return false; +} + +function startServiceActionStateWatcher() { + if (servicesInfoStateUnsubscribe) { + return; + } + + servicesInfoStateUnsubscribe = subscribeRuntimeUiState((uiState) => { + if (diagnosticMounted) { + followServiceActionsFromUiState(uiState); + } + }); +} + +function stopServiceActionStateWatcher() { + if (!servicesInfoStateUnsubscribe) { + return; + } + + servicesInfoStateUnsubscribe(); + servicesInfoStateUnsubscribe = null; +} + +function isVisibleServiceRuntimeAction( + action: Tachyon.ServiceActionState['action'], +): action is ServiceRuntimeAction { + return action === 'restart' || action === 'start' || action === 'stop'; +} + +function setServiceActionStateLoading( + state: Tachyon.ServiceActionState, + loading: boolean, +) { + if (!isVisibleServiceRuntimeAction(state.action)) { + return; + } + + setDiagnosticActionLoading(state.action, loading); +} + +async function followServiceActionState(state: Tachyon.ServiceActionState) { + const jobId = state.job_id; + + if (!jobId || followedServiceActionJobs.has(jobId)) { + return; + } + + if (!state.running && handledServiceActionJobs.has(jobId)) { + return; + } + + followedServiceActionJobs.add(jobId); + if (state.running) { + setServiceActionStateLoading(state, true); + } + + try { + if (state.running) { + await TachyonShellMethods.waitServiceActionJob(jobId); + } + } catch (error) { + logger.error('[DIAGNOSTIC]', 'followServiceActionState failed', error); + } finally { + handledServiceActionJobs.add(jobId); + setServiceActionStateLoading(state, false); + await refreshDiagnosticServicesInfo({ force: true, allowInactive: true }); + void TachyonShellMethods.uiActionAck('service', jobId); + followedServiceActionJobs.delete(jobId); + resetDiagnosticsChecks(); + } +} + +function followServiceActionsFromUiState(uiState?: Tachyon.UiState) { + if (!uiState) { + return; + } + + for (const action of uiState.actions.service || []) { + if (action.job_id) { + void followServiceActionState(action); + } + } +} + +async function fetchSystemInfo() { + const systemInfo = await ensureSystemInfo(); + + if (store.get().diagnosticsRunAction.loading) { + return; + } + + store.set({ + diagnosticsChecks: getDiagnosticsChecks( + _('Not running'), + getDiagnosticsProviderOptions(systemInfo), + ), + }); +} + +async function fetchDiagnosticsProviderInfo({ + resetChecks = true, +}: { resetChecks?: boolean } = {}) { + const requestId = ++latestProviderInfoRequestId; + + try { + const uiState = await refreshRuntimeUiState({ force: true }); + + if (requestId !== latestProviderInfoRequestId) { + return; + } + + if (uiState) { + const currentSystemInfo = store.get().diagnosticsSystemInfo; + const nextSystemInfo = normalizeSingBoxVariantFields({ + ...currentSystemInfo, + providerInfoLoaded: true, + sing_box_extended: uiState.capabilities.sing_box_extended, + sing_box_tiny: uiState.capabilities.sing_box_tiny, + sing_box_compressed: uiState.capabilities.sing_box_compressed, + sing_box_tailscale: uiState.capabilities.sing_box_tailscale, + zapret_installed: uiState.capabilities.zapret_installed, + zapret2_installed: uiState.capabilities.zapret2_installed, + byedpi_installed: uiState.capabilities.byedpi_installed, + server_inbounds_enabled_count: + uiState.capabilities.server_inbounds_enabled_count, + }); + + if (!nextSystemInfo.zapret_installed) { + nextSystemInfo.zapret_version = 'not installed'; + } + + if (!nextSystemInfo.zapret2_installed) { + nextSystemInfo.zapret2_version = 'not installed'; + } + + if (!nextSystemInfo.byedpi_installed) { + nextSystemInfo.byedpi_version = 'not installed'; + } + + const nextState: Partial = { + diagnosticsSystemInfo: nextSystemInfo, + }; + + if ( + shouldResetDiagnosticsChecks({ + resetChecks, + diagnosticsRunLoading: store.get().diagnosticsRunAction.loading, + }) + ) { + nextState.diagnosticsChecks = getDiagnosticsChecks( + _('Not running'), + getDiagnosticsProviderOptions(nextSystemInfo), + ); + } + + store.set(nextState); + return; + } + + const [zapretRuntime, zapret2Runtime, byedpiRuntime, inboundsConfig] = + await Promise.all([ + TachyonShellMethods.checkZapretRuntime(), + TachyonShellMethods.checkZapret2Runtime(), + TachyonShellMethods.checkByedpiRuntime(), + TachyonShellMethods.checkInboundsConfig(), + ]); + + if (requestId !== latestProviderInfoRequestId) { + return; + } + + const currentSystemInfo = store.get().diagnosticsSystemInfo; + const nextSystemInfo = { + ...currentSystemInfo, + providerInfoLoaded: true, + zapret_installed: zapretRuntime.success + ? zapretRuntime.data.zapret_installed + : currentSystemInfo.zapret_installed, + zapret2_installed: zapret2Runtime.success + ? zapret2Runtime.data.zapret2_installed + : currentSystemInfo.zapret2_installed, + byedpi_installed: byedpiRuntime.success + ? byedpiRuntime.data.byedpi_installed + : currentSystemInfo.byedpi_installed, + server_inbounds_enabled_count: inboundsConfig.success + ? inboundsConfig.data.enabled_count + : -1, + }; + + if (!zapretRuntime.success) { + logger.error('[DIAGNOSTIC]', 'fetchZapretRuntime failed', zapretRuntime); + } + + if (!zapret2Runtime.success) { + logger.error( + '[DIAGNOSTIC]', + 'fetchZapret2Runtime failed', + zapret2Runtime, + ); + } + + if (!byedpiRuntime.success) { + logger.error('[DIAGNOSTIC]', 'fetchByedpiRuntime failed', byedpiRuntime); + } + + if (!inboundsConfig.success) { + logger.error( + '[DIAGNOSTIC]', + 'fetchInboundsConfig failed', + inboundsConfig, + ); + } + + if (!nextSystemInfo.zapret_installed) { + nextSystemInfo.zapret_version = 'not installed'; + } + + if (!nextSystemInfo.zapret2_installed) { + nextSystemInfo.zapret2_version = 'not installed'; + } + + if (!nextSystemInfo.byedpi_installed) { + nextSystemInfo.byedpi_version = 'not installed'; + } + + const nextState: Partial = { + diagnosticsSystemInfo: nextSystemInfo, + }; + + if ( + shouldResetDiagnosticsChecks({ + resetChecks, + diagnosticsRunLoading: store.get().diagnosticsRunAction.loading, + }) + ) { + nextState.diagnosticsChecks = getDiagnosticsChecks( + _('Not running'), + getDiagnosticsProviderOptions(nextSystemInfo), + ); + } + + store.set(nextState); + } catch (error) { + logger.error('[DIAGNOSTIC]', 'fetchDiagnosticsProviderInfo failed', error); + + if (requestId === latestProviderInfoRequestId) { + const currentSystemInfo = store.get().diagnosticsSystemInfo; + + store.set({ + diagnosticsSystemInfo: { + ...currentSystemInfo, + providerInfoLoaded: true, + server_inbounds_enabled_count: -1, + }, + }); + } + } +} + +function renderDiagnosticsChecks() { + logger.debug('[DIAGNOSTIC]', 'renderDiagnosticsChecks'); + const diagnosticsChecks = [...store.get().diagnosticsChecks].sort( + (a, b) => a.order - b.order, + ); + const container = document.getElementById('fkp_diagnostic-page-checks'); + + const renderedDiagnosticsChecks = diagnosticsChecks.map((check) => + renderCheckSection(check), + ); + + return preserveScrollForPage(() => { + container!.replaceChildren(...renderedDiagnosticsChecks); + }); +} + +function renderDiagnosticRunActionWidget() { + logger.debug('[DIAGNOSTIC]', 'renderDiagnosticRunActionWidget'); + + const { loading } = store.get().diagnosticsRunAction; + const providerInfoLoaded = + store.get().diagnosticsSystemInfo.providerInfoLoaded; + const servicesInfoWidget = store.get().servicesInfoWidget; + const tachyonRunning = Boolean(servicesInfoWidget.data.tachyonRunning); + const container = document.getElementById('fkp_diagnostic-page-run-check'); + + const renderedAction = renderRunAction({ + loading, + disabled: shouldDisableDiagnosticRunAction({ + providerInfoLoaded, + servicesInfoLoading: servicesInfoWidget.loading, + tachyonRunning, + mutatingServiceActionLoading: isMutatingServiceActionLoading(), + }), + click: () => runChecks(), + }); + + return preserveScrollForPage(() => { + container!.replaceChildren(renderedAction); + }); +} + +async function handleServiceRuntimeAction({ + action, + expectedRunning, + optimisticRunning, +}: { + action: ServiceRuntimeAction; + expectedRunning: boolean; + optimisticRunning?: boolean; +}) { + setDiagnosticActionLoading(action, true, true); + let jobId = ''; + let ownsJobFollow = false; + let delegatedToWatcher = false; + + if (optimisticRunning !== undefined) { + setDisplayedTachyonRunning(optimisticRunning); + } + + try { + const startResponse = await TachyonShellMethods.serviceActionStart(action); + + if (!startResponse.success) { + throw new Error(startResponse.error); + } + + jobId = startResponse.data.job_id; + if (followedServiceActionJobs.has(jobId)) { + delegatedToWatcher = true; + return; + } + + followedServiceActionJobs.add(jobId); + ownsJobFollow = true; + const result = await TachyonShellMethods.waitServiceActionJob(jobId); + + if (!result.success) { + throw new Error(result.error); + } + + if (result.data.success === false) { + throw new Error(result.data.message || _('Service action failed')); + } + + await waitForTachyonRunningState(expectedRunning); + } catch (e) { + logger.error('[DIAGNOSTIC]', `handleServiceRuntimeAction(${action})`, e); + } finally { + if (!delegatedToWatcher) { + if (ownsJobFollow) { + followedServiceActionJobs.delete(jobId); + } + + setDiagnosticActionLoading(action, false); + await refreshDiagnosticServicesInfo({ force: true, allowInactive: true }); + if (jobId) { + handledServiceActionJobs.add(jobId); + void TachyonShellMethods.uiActionAck('service', jobId); + } + resetDiagnosticsChecks(); + } + } +} + +async function handleRestart() { + await handleServiceRuntimeAction({ + action: 'restart', + expectedRunning: true, + optimisticRunning: false, + }); +} + +async function handleStart() { + await handleServiceRuntimeAction({ + action: 'start', + expectedRunning: true, + }); +} + +async function handleStop() { + await handleServiceRuntimeAction({ + action: 'stop', + expectedRunning: false, + }); +} + +async function handleEnable() { + setDiagnosticActionLoading('enable', true); + + try { + await TachyonShellMethods.enable(); + } catch (e) { + logger.error('[DIAGNOSTIC]', 'handleEnable - e', e); + } finally { + await refreshDiagnosticServicesInfo({ + force: true, + allowInactive: true, + }); + setDiagnosticActionLoading('enable', false); + } +} + +async function handleDisable() { + setDiagnosticActionLoading('disable', true); + + try { + await TachyonShellMethods.disable(); + } catch (e) { + logger.error('[DIAGNOSTIC]', 'handleDisable - e', e); + } finally { + await refreshDiagnosticServicesInfo({ + force: true, + allowInactive: true, + }); + setDiagnosticActionLoading('disable', false); + } +} + +async function handleShowGlobalCheck() { + setDiagnosticActionLoading('globalCheck', true); + + try { + const globalCheck = await TachyonShellMethods.globalCheck(false); + + if (globalCheck.success) { + const rawGlobalCheckText = (globalCheck.data as string) ?? ''; + const maskedGlobalCheckText = maskGlobalCheckText(rawGlobalCheckText); + + ui.showModal( + _('Global check'), + renderModal(rawGlobalCheckText, 'global_check', { + maskText: () => maskedGlobalCheckText, + initialAutoRefresh: false, + showMaskValuesToggle: true, + }), + ); + } else { + logger.error('[DIAGNOSTIC]', 'handleShowGlobalCheck - e', globalCheck); + } + } catch (e) { + logger.error('[DIAGNOSTIC]', 'handleShowGlobalCheck - e', e); + } finally { + setDiagnosticActionLoading('globalCheck', false); + } +} + +async function handleViewLogs() { + setDiagnosticActionLoading('viewLogs', true); + + try { + const viewLogs = await TachyonShellMethods.checkLogs(); + + if (viewLogs.success) { + const getLatestLogs = async () => { + const latestLogs = await TachyonShellMethods.checkLogs(); + + if (!latestLogs.success) { + throw latestLogs; + } + + return (latestLogs.data as string) ?? ''; + }; + + ui.showModal( + _('View logs'), + renderModal(viewLogs.data as string, 'view_logs', { + getText: getLatestLogs, + refreshMs: 250, + initialAutoRefresh: true, + showAutoRefreshToggle: true, + startAtEnd: true, + }), + ); + } else { + logger.error('[DIAGNOSTIC]', 'handleViewLogs - e', viewLogs); + } + } catch (e) { + logger.error('[DIAGNOSTIC]', 'handleViewLogs - e', e); + } finally { + setDiagnosticActionLoading('viewLogs', false); + } +} + +async function handleShowSingBoxConfig() { + setDiagnosticActionLoading('showSingBoxConfig', true); + + try { + const showSingBoxConfig = await TachyonShellMethods.showSingBoxConfig(false); + + if (showSingBoxConfig.success) { + const rawSingBoxConfigText = stringifySingBoxConfig( + showSingBoxConfig.data, + ); + const maskedSingBoxConfigText = formatMaskedSingBoxConfig( + showSingBoxConfig.data, + ); + + ui.showModal( + _('Show sing-box config'), + renderModal(rawSingBoxConfigText, 'show_sing_box_config', { + maskText: () => maskedSingBoxConfigText, + initialAutoRefresh: false, + showMaskValuesToggle: true, + }), + ); + } else { + logger.error( + '[DIAGNOSTIC]', + 'handleShowSingBoxConfig - e', + showSingBoxConfig, + ); + } + } catch (e) { + logger.error('[DIAGNOSTIC]', 'handleShowSingBoxConfig - e', e); + } finally { + setDiagnosticActionLoading('showSingBoxConfig', false); + } +} + +function renderWikiDisclaimerWidget() { + const diagnosticsChecks = store.get().diagnosticsChecks; + + function getWikiKind() { + const allResults = diagnosticsChecks.map((check) => check.state); + + if (allResults.includes('error')) { + return 'error'; + } + + if (allResults.includes('warning')) { + return 'warning'; + } + + return 'default'; + } + + const container = document.getElementById('fkp_diagnostic-page-wiki'); + + return preserveScrollForPage(() => { + container!.replaceChildren(renderWikiDisclaimer(getWikiKind())); + }); +} + +function renderDiagnosticAvailableActionsWidget() { + const diagnosticsActions = store.get().diagnosticsActions; + const updatesActions = store.get().updatesActions; + const servicesInfoWidget = store.get().servicesInfoWidget; + logger.debug('[DIAGNOSTIC]', 'renderDiagnosticAvailableActionsWidget'); + + const tachyonEnabled = Boolean(servicesInfoWidget.data.tachyonEnabled); + const tachyonRunning = Boolean(servicesInfoWidget.data.tachyonRunning); + const serviceTransition = getServiceTransition( + servicesInfoWidget.data.tachyonStatus, + ); + const restartLoading = + diagnosticsActions.restart.loading || serviceTransition.restarting; + const startLoading = + diagnosticsActions.start.loading || serviceTransition.starting; + const stopLoading = + diagnosticsActions.stop.loading || serviceTransition.stopping; + const atLeastOneMutatingActionLoading = + restartLoading || + startLoading || + stopLoading || + diagnosticsActions.enable.loading || + diagnosticsActions.disable.loading; + const componentActionLoading = hasComponentActionLoading(updatesActions); + const { serviceControlsDisabled, utilityActionsDisabled, viewLogsDisabled } = + getAvailableActionsDisabledState({ + servicesInfoLoading: servicesInfoWidget.loading, + mutatingServiceActionLoading: atLeastOneMutatingActionLoading, + componentActionLoading, + }); + const startVisible = shouldShowStartAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading, + }); + const stopVisible = shouldShowStopAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading, + }); + + const container = document.getElementById('fkp_diagnostic-page-actions'); + + const renderedActions = renderAvailableActions({ + restart: { + loading: restartLoading, + visible: shouldShowRestartAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading, + }), + onClick: handleRestart, + disabled: serviceControlsDisabled, + }, + start: { + loading: startLoading, + visible: startVisible, + onClick: handleStart, + disabled: serviceControlsDisabled, + }, + stop: { + loading: stopLoading, + visible: stopVisible, + onClick: handleStop, + disabled: serviceControlsDisabled, + }, + enable: { + loading: diagnosticsActions.enable.loading, + visible: !tachyonEnabled, + onClick: handleEnable, + disabled: serviceControlsDisabled, + }, + disable: { + loading: diagnosticsActions.disable.loading, + visible: tachyonEnabled, + onClick: handleDisable, + disabled: serviceControlsDisabled, + }, + globalCheck: { + loading: diagnosticsActions.globalCheck.loading, + visible: true, + onClick: handleShowGlobalCheck, + disabled: utilityActionsDisabled, + }, + viewLogs: { + loading: diagnosticsActions.viewLogs.loading, + visible: true, + onClick: handleViewLogs, + disabled: viewLogsDisabled, + }, + showSingBoxConfig: { + loading: diagnosticsActions.showSingBoxConfig.loading, + visible: true, + onClick: handleShowSingBoxConfig, + disabled: utilityActionsDisabled, + }, + }); + + return preserveScrollForPage(() => { + container!.replaceChildren(renderedActions); + }); +} + +function renderDiagnosticSystemInfoWidget() { + logger.debug('[DIAGNOSTIC]', 'renderDiagnosticSystemInfoWidget'); + const diagnosticsSystemInfo = store.get().diagnosticsSystemInfo; + + const container = document.getElementById('fkp_diagnostic-page-system-info'); + + const items = [ + { + key: 'Tachyon', + value: normalizeCompiledVersion(diagnosticsSystemInfo.tachyon_version), + }, + { + key: 'Luci App', + value: normalizeCompiledVersion(TACHYON_LUCI_APP_VERSION), + }, + { + key: 'Sing-box', + value: formatSingBoxVersion(diagnosticsSystemInfo), + }, + ]; + + if (diagnosticsSystemInfo.zapret_installed) { + items.push({ + key: 'Zapret', + value: diagnosticsSystemInfo.zapret_version, + }); + } + + if (diagnosticsSystemInfo.zapret2_installed) { + items.push({ + key: 'Zapret2', + value: diagnosticsSystemInfo.zapret2_version, + }); + } + + if (diagnosticsSystemInfo.byedpi_installed) { + items.push({ + key: 'ByeDPI', + value: diagnosticsSystemInfo.byedpi_version, + }); + } + + items.push( + { + key: 'OS', + value: diagnosticsSystemInfo.openwrt_version, + }, + { + key: 'Device', + value: diagnosticsSystemInfo.device_model, + }, + ); + + const renderedSystemInfo = renderSystemInfo({ + items, + }); + + return preserveScrollForPage(() => { + container!.replaceChildren(renderedSystemInfo); + }); +} + +async function onStoreUpdate( + _next: StoreType, + _prev: StoreType, + diff: Partial, +) { + if (diff.diagnosticsChecks) { + renderDiagnosticsChecks(); + renderWikiDisclaimerWidget(); + } + + if (diff.diagnosticsRunAction) { + renderDiagnosticRunActionWidget(); + } + + if ( + diff.diagnosticsActions || + diff.servicesInfoWidget || + diff.updatesActions + ) { + renderDiagnosticAvailableActionsWidget(); + } + + if (diff.diagnosticsActions || diff.servicesInfoWidget) { + renderDiagnosticRunActionWidget(); + } + + if (diff.diagnosticsSystemInfo) { + renderDiagnosticSystemInfoWidget(); + renderDiagnosticRunActionWidget(); + } +} + +function persistDiagnosticRunProgress({ + providerOptions, + nextRunnerIndex, +}: { + providerOptions: DiagnosticsProviderOptions; + nextRunnerIndex: number; +}) { + savePersistedDiagnosticRun({ + providerOptions, + nextRunnerIndex, + diagnosticsChecks: store.get().diagnosticsChecks, + }); +} + +function setDiagnosticCheckLoading(code: DIAGNOSTICS_CHECKS) { + const meta = DIAGNOSTICS_CHECKS_MAP[code]; + const diagnosticsChecks = store.get().diagnosticsChecks; + const other = diagnosticsChecks.filter((item) => item.code !== code); + + store.set({ + diagnosticsChecks: [ + ...other, + { + order: meta.order, + code: meta.code, + title: meta.title, + description: _('Checking, please wait'), + state: 'loading', + items: [], + }, + ], + }); +} + +function getDiagnosticRunners( + providerOptions: DiagnosticsProviderOptions, +): DiagnosticRunner[] { + return [ + { code: DIAGNOSTICS_CHECKS.DNS, run: runDnsCheck }, + { code: DIAGNOSTICS_CHECKS.SINGBOX, run: runSingBoxCheck }, + ...(providerOptions.includeInbounds + ? [{ code: DIAGNOSTICS_CHECKS.INBOUNDS, run: runInboundsCheck }] + : []), + { code: DIAGNOSTICS_CHECKS.NFT, run: runNftCheck }, + ...(providerOptions.includeZapret + ? [{ code: DIAGNOSTICS_CHECKS.ZAPRET, run: runZapretCheck }] + : []), + ...(providerOptions.includeZapret2 + ? [{ code: DIAGNOSTICS_CHECKS.ZAPRET2, run: runZapret2Check }] + : []), + ...(providerOptions.includeByedpi + ? [{ code: DIAGNOSTICS_CHECKS.BYEDPI, run: runByedpiCheck }] + : []), + { code: DIAGNOSTICS_CHECKS.OUTBOUNDS, run: runSectionsCheck }, + { code: DIAGNOSTICS_CHECKS.FAKEIP, run: runFakeIPCheck }, + ]; +} + +async function runChecks({ resume }: { resume?: PersistedDiagnosticRun } = {}) { + if (store.get().diagnosticsRunAction.loading && !resume) { + return; + } + + let providerOptions = + resume?.providerOptions ?? getDiagnosticsProviderOptions(); + let nextRunnerIndex = resume?.nextRunnerIndex ?? 0; + + store.set({ + diagnosticsRunAction: { loading: true }, + diagnosticsChecks: + resume?.diagnosticsChecks ?? + getLoadingDiagnosticsChecks(providerOptions).diagnosticsChecks, + }); + persistDiagnosticRunProgress({ + providerOptions, + nextRunnerIndex, + }); + + try { + if (!resume) { + await fetchDiagnosticsProviderInfo({ resetChecks: false }); + + providerOptions = getDiagnosticsProviderOptions(); + nextRunnerIndex = 0; + + store.set({ + diagnosticsChecks: + getLoadingDiagnosticsChecks(providerOptions).diagnosticsChecks, + }); + persistDiagnosticRunProgress({ + providerOptions, + nextRunnerIndex, + }); + } + + const runners = getDiagnosticRunners(providerOptions); + + for (let index = nextRunnerIndex; index < runners.length; index += 1) { + const runner = runners[index]; + + setDiagnosticCheckLoading(runner.code); + persistDiagnosticRunProgress({ + providerOptions, + nextRunnerIndex: index, + }); + + try { + await runner.run(); + } catch (e) { + logger.error( + '[DIAGNOSTIC]', + `runChecks - ${runner.run.name} failed`, + e, + ); + } + + persistDiagnosticRunProgress({ + providerOptions, + nextRunnerIndex: index + 1, + }); + } + } catch (e) { + logger.error('[DIAGNOSTIC]', 'runChecks - e', e); + } finally { + clearPersistedDiagnosticRun(); + store.set({ diagnosticsRunAction: { loading: false } }); + if (!diagnosticMounted) { + diagnosticCompletedWhileHidden = true; + } + } +} + +async function loadInitialDiagnosticData() { + const diagnosticStatus = document.getElementById('diagnostic-status'); + + if (diagnosticStatus?.isConnected && diagnosticStatus.offsetParent !== null) { + if (store.get().diagnosticsRunAction.loading) { + return; + } + + await fetchSystemInfo(); + await fetchDiagnosticsProviderInfo(); + } +} + +function restorePersistedDiagnosticRun() { + const persistedRun = readPersistedDiagnosticRun(); + + if (!persistedRun) { + return false; + } + + store.set({ + diagnosticsRunAction: { loading: true }, + diagnosticsChecks: persistedRun.diagnosticsChecks, + }); + void runChecks({ resume: persistedRun }); + return true; +} + +async function onPageMount() { + const preserveHiddenResult = diagnosticCompletedWhileHidden; + + // Cleanup before mount + onPageUnmount({ + preserveCompletedResult: preserveHiddenResult, + preservePersistedRun: true, + }); + + diagnosticMounted = true; + diagnosticMountId += 1; + const mountId = diagnosticMountId; + const hasRuntimeSnapshot = Boolean(getCachedRuntimeUiState()); + + if (!hasRuntimeSnapshot) { + const uiState = await refreshRuntimeUiState({ force: true }); + + if (!diagnosticMounted || mountId !== diagnosticMountId) { + return; + } + + if (!uiState) { + void refreshDiagnosticServicesInfo({ force: true }); + } + } + + const restoredPersistedRun = + !preserveHiddenResult && restorePersistedDiagnosticRun(); + + if (preserveHiddenResult) { + diagnosticCompletedWhileHidden = false; + } else if ( + !restoredPersistedRun && + !store.get().diagnosticsRunAction.loading + ) { + store.reset(['diagnosticsRunAction']); + resetDiagnosticsChecks(); + } + + // Add new listener + store.subscribe(onStoreUpdate); + startServiceActionStateWatcher(); + + // Initial checks render + renderDiagnosticsChecks(); + + // Initial run checks action render + renderDiagnosticRunActionWidget(); + + // Initial available actions render + renderDiagnosticAvailableActionsWidget(); + + // Initial system info render + renderDiagnosticSystemInfoWidget(); + + // Initial Wiki disclaimer render + renderWikiDisclaimerWidget(); + + if (hasRuntimeSnapshot) { + void refreshRuntimeUiState({ force: true }); + } + if (!preserveHiddenResult && !restoredPersistedRun) { + void loadInitialDiagnosticData(); + } +} + +function onPageUnmount({ + preserveCompletedResult = false, + preservePersistedRun = false, +}: { + preserveCompletedResult?: boolean; + preservePersistedRun?: boolean; +} = {}) { + diagnosticMounted = false; + diagnosticMountId += 1; + stopServiceActionStateWatcher(); + servicesInfoRefreshPromise = null; + + // Remove old listener + store.unsubscribe(onStoreUpdate); + + if (!preserveCompletedResult && !store.get().diagnosticsRunAction.loading) { + if (!preservePersistedRun) { + clearPersistedDiagnosticRun(); + } + store.reset(['diagnosticsRunAction']); + resetDiagnosticsChecks(); + diagnosticCompletedWhileHidden = false; + } +} + +function registerLifecycleListeners() { + if (diagnosticLifecycleRegistered) { + return; + } + + diagnosticLifecycleRegistered = true; + + store.subscribe((next, prev, diff) => { + if ( + diff.tabService && + next.tabService.current !== prev.tabService.current + ) { + logger.debug( + '[DIAGNOSTIC]', + 'active tab diff event, active tab:', + diff.tabService.current, + ); + const isDIAGNOSTICVisible = next.tabService.current === 'diagnostic'; + + if (isDIAGNOSTICVisible) { + logger.debug( + '[DIAGNOSTIC]', + 'registerLifecycleListeners', + 'onPageMount', + ); + return onPageMount(); + } + + if (!isDIAGNOSTICVisible) { + logger.debug( + '[DIAGNOSTIC]', + 'registerLifecycleListeners', + 'onPageUnmount', + ); + return onPageUnmount(); + } + } + }); +} + +export async function initController(): Promise { + if (diagnosticControllerInitialized) { + return; + } + + diagnosticControllerInitialized = true; + + onMount('diagnostic-status').then(() => { + logger.debug('[DIAGNOSTIC]', 'initController', 'onMount'); + registerLifecycleListeners(); + if ( + store.get().tabService.current === 'diagnostic' || + isActiveLuciTab('diagnostic') + ) { + onPageMount(); + } + }); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/index.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/index.ts new file mode 100644 index 00000000..517093a3 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/index.ts @@ -0,0 +1,4 @@ +export * from './renderAvailableActions'; +export * from './renderCheckSection'; +export * from './renderRunAction'; +export * from './renderSystemInfo'; diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts new file mode 100644 index 00000000..ec636de2 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts @@ -0,0 +1,122 @@ +import { renderButton } from '../../../../partials'; +import { + renderCircleCheckBigIcon24, + renderCirclePlayIcon24, + renderCircleStopIcon24, + renderCogIcon24, + renderPauseIcon24, + renderPlayIcon24, + renderRotateCcwIcon24, + renderSquareChartGanttIcon24, +} from '../../../../icons'; +import { insertIf } from '../../../../helpers'; + +interface ActionProps { + loading: boolean; + visible: boolean; + disabled: boolean; + onClick: () => void; +} + +interface IRenderAvailableActionsProps { + restart: ActionProps; + start: ActionProps; + stop: ActionProps; + enable: ActionProps; + disable: ActionProps; + globalCheck: ActionProps; + viewLogs: ActionProps; + showSingBoxConfig: ActionProps; +} + +export function renderAvailableActions({ + restart, + start, + stop, + enable, + disable, + globalCheck, + viewLogs, + showSingBoxConfig, +}: IRenderAvailableActionsProps) { + return E('div', { class: 'fkp_diagnostic-page__right-bar__actions' }, [ + E('b', {}, _('Available actions')), + ...insertIf(restart.visible, [ + renderButton({ + classNames: ['cbi-button-apply'], + onClick: restart.onClick, + icon: renderRotateCcwIcon24, + text: _('Restart Tachyon'), + loading: restart.loading, + disabled: restart.disabled, + }), + ]), + ...insertIf(stop.visible, [ + renderButton({ + classNames: ['cbi-button-remove'], + onClick: stop.onClick, + icon: renderCircleStopIcon24, + text: _('Stop Tachyon'), + loading: stop.loading, + disabled: stop.disabled, + }), + ]), + ...insertIf(start.visible, [ + renderButton({ + classNames: ['cbi-button-save'], + onClick: start.onClick, + icon: renderCirclePlayIcon24, + text: _('Start Tachyon'), + loading: start.loading, + disabled: start.disabled, + }), + ]), + ...insertIf(disable.visible, [ + renderButton({ + classNames: ['cbi-button-remove'], + onClick: disable.onClick, + icon: renderPauseIcon24, + text: _('Disable autostart'), + loading: disable.loading, + disabled: disable.disabled, + }), + ]), + ...insertIf(enable.visible, [ + renderButton({ + classNames: ['cbi-button-save'], + onClick: enable.onClick, + icon: renderPlayIcon24, + text: _('Enable autostart'), + loading: enable.loading, + disabled: enable.disabled, + }), + ]), + ...insertIf(globalCheck.visible, [ + renderButton({ + onClick: globalCheck.onClick, + icon: renderCircleCheckBigIcon24, + text: _('Get global check'), + loading: globalCheck.loading, + disabled: globalCheck.disabled, + }), + ]), + ...insertIf(viewLogs.visible, [ + renderButton({ + onClick: viewLogs.onClick, + icon: renderSquareChartGanttIcon24, + text: _('View logs'), + loading: viewLogs.loading, + disabled: viewLogs.disabled, + }), + ]), + ...insertIf(showSingBoxConfig.visible, [ + renderButton({ + onClick: showSingBoxConfig.onClick, + icon: renderCogIcon24, + text: _('Show sing-box config'), + loading: showSingBoxConfig.loading, + disabled: showSingBoxConfig.disabled, + }), + ]), + ]); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderCheckSection.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderCheckSection.ts new file mode 100644 index 00000000..f1a49beb --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderCheckSection.ts @@ -0,0 +1,190 @@ +import { + renderCheckIcon24, + renderCircleAlertIcon24, + renderCircleCheckIcon24, + renderCircleSlashIcon24, + renderCircleXIcon24, + renderLoaderCircleIcon24, + renderTriangleAlertIcon24, + renderXIcon24, +} from '../../../../icons'; +import { IDiagnosticsChecksStoreItem } from '../../../services'; + +type IRenderCheckSectionProps = IDiagnosticsChecksStoreItem; + +function renderCheckSummary(items: IRenderCheckSectionProps['items']) { + if (!items.length) { + return E('div', {}, ''); + } + + const renderedItems = items.map((item) => { + function getIcon() { + const iconWrap = E('span', { + class: 'fkp_diagnostic_alert__summary__item__icon', + }); + + if (item.state === 'success') { + iconWrap.appendChild(renderCheckIcon24()); + } + + if (item.state === 'warning') { + iconWrap.appendChild(renderTriangleAlertIcon24()); + } + + if (item.state === 'error') { + iconWrap.appendChild(renderXIcon24()); + } + + return iconWrap; + } + + return E( + 'div', + { + class: `fkp_diagnostic_alert__summary__item fkp_diagnostic_alert__summary__item--${item.state}`, + }, + [getIcon(), E('b', {}, item.key), E('div', {}, item.value)], + ); + }); + + return E('div', { class: 'fkp_diagnostic_alert__summary' }, renderedItems); +} + +function renderLoadingState(props: IRenderCheckSectionProps) { + const iconWrap = E('span', { class: 'fkp_diagnostic_alert__icon' }); + iconWrap.appendChild(renderLoaderCircleIcon24()); + + return E( + 'div', + { class: 'fkp_diagnostic_alert fkp_diagnostic_alert--loading' }, + [ + iconWrap, + E('div', { class: 'fkp_diagnostic_alert__content' }, [ + E('b', { class: 'fkp_diagnostic_alert__title' }, props.title), + E( + 'div', + { class: 'fkp_diagnostic_alert__description' }, + props.description, + ), + ]), + E('div', {}, ''), + renderCheckSummary(props.items), + ], + ); +} + +function renderWarningState(props: IRenderCheckSectionProps) { + const iconWrap = E('span', { class: 'fkp_diagnostic_alert__icon' }); + iconWrap.appendChild(renderCircleAlertIcon24()); + + return E( + 'div', + { class: 'fkp_diagnostic_alert fkp_diagnostic_alert--warning' }, + [ + iconWrap, + E('div', { class: 'fkp_diagnostic_alert__content' }, [ + E('b', { class: 'fkp_diagnostic_alert__title' }, props.title), + E( + 'div', + { class: 'fkp_diagnostic_alert__description' }, + props.description, + ), + ]), + E('div', {}, ''), + renderCheckSummary(props.items), + ], + ); +} + +function renderErrorState(props: IRenderCheckSectionProps) { + const iconWrap = E('span', { class: 'fkp_diagnostic_alert__icon' }); + iconWrap.appendChild(renderCircleXIcon24()); + + return E( + 'div', + { class: 'fkp_diagnostic_alert fkp_diagnostic_alert--error' }, + [ + iconWrap, + E('div', { class: 'fkp_diagnostic_alert__content' }, [ + E('b', { class: 'fkp_diagnostic_alert__title' }, props.title), + E( + 'div', + { class: 'fkp_diagnostic_alert__description' }, + props.description, + ), + ]), + E('div', {}, ''), + renderCheckSummary(props.items), + ], + ); +} + +function renderSuccessState(props: IRenderCheckSectionProps) { + const iconWrap = E('span', { class: 'fkp_diagnostic_alert__icon' }); + iconWrap.appendChild(renderCircleCheckIcon24()); + + return E( + 'div', + { class: 'fkp_diagnostic_alert fkp_diagnostic_alert--success' }, + [ + iconWrap, + E('div', { class: 'fkp_diagnostic_alert__content' }, [ + E('b', { class: 'fkp_diagnostic_alert__title' }, props.title), + E( + 'div', + { class: 'fkp_diagnostic_alert__description' }, + props.description, + ), + ]), + E('div', {}, ''), + renderCheckSummary(props.items), + ], + ); +} + +function renderSkippedState(props: IRenderCheckSectionProps) { + const iconWrap = E('span', { class: 'fkp_diagnostic_alert__icon' }); + iconWrap.appendChild(renderCircleSlashIcon24()); + + return E( + 'div', + { class: 'fkp_diagnostic_alert fkp_diagnostic_alert--skipped' }, + [ + iconWrap, + E('div', { class: 'fkp_diagnostic_alert__content' }, [ + E('b', { class: 'fkp_diagnostic_alert__title' }, props.title), + E( + 'div', + { class: 'fkp_diagnostic_alert__description' }, + props.description, + ), + ]), + E('div', {}, ''), + renderCheckSummary(props.items), + ], + ); +} + +export function renderCheckSection(props: IRenderCheckSectionProps) { + if (props.state === 'loading') { + return renderLoadingState(props); + } + + if (props.state === 'warning') { + return renderWarningState(props); + } + + if (props.state === 'error') { + return renderErrorState(props); + } + + if (props.state === 'success') { + return renderSuccessState(props); + } + + if (props.state === 'skipped') { + return renderSkippedState(props); + } + + return E('div', {}, _('Not implement yet')); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderRunAction.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderRunAction.ts new file mode 100644 index 00000000..2706efde --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderRunAction.ts @@ -0,0 +1,25 @@ +import { renderButton } from '../../../../partials'; +import { renderSearchIcon24 } from '../../../../icons'; + +interface IRenderDiagnosticRunActionProps { + loading: boolean; + disabled?: boolean; + click: () => void; +} + +export function renderRunAction({ + loading, + disabled, + click, +}: IRenderDiagnosticRunActionProps) { + return E('div', { class: 'fkp_diagnostic-page__run_check_wrapper' }, [ + renderButton({ + text: _('Run Diagnostic'), + onClick: click, + icon: renderSearchIcon24, + loading, + disabled, + classNames: ['cbi-button-apply'], + }), + ]); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderSystemInfo.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderSystemInfo.ts new file mode 100644 index 00000000..02128cee --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderSystemInfo.ts @@ -0,0 +1,52 @@ +import { insertIf } from '../../../../helpers'; + +interface IRenderSystemInfoRow { + key: string; + value: string; + tag?: { + label: string; + kind: 'neutral' | 'warning' | 'success'; + }; +} + +interface IRenderSystemInfoProps { + items: Array; +} + +export function renderSystemInfo({ items }: IRenderSystemInfoProps) { + return E('div', { class: 'fkp_diagnostic-page__right-bar__system-info' }, [ + E( + 'b', + { class: 'fkp_diagnostic-page__right-bar__system-info__title' }, + _('System information'), + ), + ...items.map((item) => { + const tagClass = [ + 'fkp_diagnostic-page__right-bar__system-info__row__tag', + ...insertIf(item.tag?.kind === 'neutral', [ + 'fkp_diagnostic-page__right-bar__system-info__row__tag--neutral', + ]), + ...insertIf(item.tag?.kind === 'warning', [ + 'fkp_diagnostic-page__right-bar__system-info__row__tag--warning', + ]), + ...insertIf(item.tag?.kind === 'success', [ + 'fkp_diagnostic-page__right-bar__system-info__row__tag--success', + ]), + ] + .filter(Boolean) + .join(' '); + + return E( + 'div', + { class: 'fkp_diagnostic-page__right-bar__system-info__row' }, + [ + E('b', {}, item.key), + E('div', {}, [ + E('span', {}, item.value), + E('span', { class: tagClass }, item?.tag?.label), + ]), + ], + ); + }), + ]); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts new file mode 100644 index 00000000..4ea0e4ef --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts @@ -0,0 +1,40 @@ +import { renderBookOpenTextIcon24 } from '../../../../icons'; +import { renderButton } from '../../../../partials'; +import { insertIf } from '../../../../helpers'; + +export function renderWikiDisclaimer(kind: 'default' | 'error' | 'warning') { + const iconWrap = E('span', { + class: 'fkp_diagnostic-page__right-bar__wiki__icon', + }); + iconWrap.appendChild(renderBookOpenTextIcon24()); + + const className = [ + 'fkp_diagnostic-page__right-bar__wiki', + ...insertIf(kind === 'error', [ + 'fkp_diagnostic-page__right-bar__wiki--error', + ]), + ...insertIf(kind === 'warning', [ + 'fkp_diagnostic-page__right-bar__wiki--warning', + ]), + ].join(' '); + + return E('div', { class: className }, [ + E('div', { class: 'fkp_diagnostic-page__right-bar__wiki__content' }, [ + iconWrap, + E('div', { class: 'fkp_diagnostic-page__right-bar__wiki__texts' }, [ + E('b', {}, _('Troubleshooting')), + E('div', {}, _('Do not panic, everything can be fixed, just...')), + ]), + ]), + renderButton({ + classNames: ['cbi-button-save'], + text: _('Open Project Page'), + onClick: () => + window.open( + 'https://github.com/ushan0v/tachyon#readme', + '_blank', + 'noopener,noreferrer', + ), + }), + ]); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/renderDiagnostic.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/renderDiagnostic.ts new file mode 100644 index 00000000..258aaa91 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/renderDiagnostic.ts @@ -0,0 +1,16 @@ +export function render() { + return E('div', { id: 'diagnostic-status', class: 'fkp_diagnostic-page' }, [ + E('div', { class: 'fkp_diagnostic-page__left-bar' }, [ + E('div', { id: 'fkp_diagnostic-page-run-check' }), + E('div', { + class: 'fkp_diagnostic-page__checks', + id: 'fkp_diagnostic-page-checks', + }), + ]), + E('div', { class: 'fkp_diagnostic-page__right-bar' }, [ + E('div', { id: 'fkp_diagnostic-page-wiki' }), + E('div', { id: 'fkp_diagnostic-page-actions' }), + E('div', { id: 'fkp_diagnostic-page-system-info' }), + ]), + ]); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/serviceTransition.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/serviceTransition.ts new file mode 100644 index 00000000..48372894 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/serviceTransition.ts @@ -0,0 +1,142 @@ +type LoadingActionState = { + loading: boolean; +}; + +type DiagnosticServiceActions = { + restart: LoadingActionState; + start: LoadingActionState; + stop: LoadingActionState; + enable: LoadingActionState; + disable: LoadingActionState; +}; + +type ComponentActions = Record; + +export function isServiceTransitionStatus(status: string) { + return ['starting', 'stopping', 'restarting', 'reloading'].includes(status); +} + +export function getServiceTransition(status: string) { + return { + starting: status === 'starting', + stopping: status === 'stopping', + restarting: status === 'restarting' || status === 'reloading', + }; +} + +export function hasLocalMutatingServiceActionLoading( + actions: DiagnosticServiceActions, +) { + return ( + actions.restart.loading || + actions.start.loading || + actions.stop.loading || + actions.enable.loading || + actions.disable.loading + ); +} + +export function shouldSkipServicesInfoAutoRefresh({ + force, + localMutatingActionLoading, +}: { + force: boolean; + localMutatingActionLoading: boolean; +}) { + return !force && localMutatingActionLoading; +} + +export function shouldResetDiagnosticsChecks({ + resetChecks, + diagnosticsRunLoading, +}: { + resetChecks: boolean; + diagnosticsRunLoading: boolean; +}) { + return resetChecks && !diagnosticsRunLoading; +} + +export function shouldDisableDiagnosticRunAction({ + providerInfoLoaded, + servicesInfoLoading, + tachyonRunning, + mutatingServiceActionLoading, +}: { + providerInfoLoaded: boolean; + servicesInfoLoading: boolean; + tachyonRunning: boolean; + mutatingServiceActionLoading: boolean; +}) { + return ( + !providerInfoLoaded || + servicesInfoLoading || + !tachyonRunning || + mutatingServiceActionLoading + ); +} + +export function hasComponentActionLoading(actions: ComponentActions) { + return Object.values(actions).some((action) => action.loading); +} + +export function getAvailableActionsDisabledState({ + servicesInfoLoading, + mutatingServiceActionLoading, + componentActionLoading, +}: { + servicesInfoLoading: boolean; + mutatingServiceActionLoading: boolean; + componentActionLoading: boolean; +}) { + return { + serviceControlsDisabled: + servicesInfoLoading || + mutatingServiceActionLoading || + componentActionLoading, + utilityActionsDisabled: + mutatingServiceActionLoading || componentActionLoading, + viewLogsDisabled: false, + }; +} + +export function shouldShowRestartAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading, +}: { + tachyonRunning: boolean; + restartLoading: boolean; + startLoading: boolean; + stopLoading: boolean; +}) { + return restartLoading || (tachyonRunning && !startLoading && !stopLoading); +} + +export function shouldShowStartAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading, +}: { + tachyonRunning: boolean; + restartLoading: boolean; + startLoading: boolean; + stopLoading: boolean; +}) { + return startLoading || (!restartLoading && !tachyonRunning && !stopLoading); +} + +export function shouldShowStopAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading, +}: { + tachyonRunning: boolean; + restartLoading: boolean; + startLoading: boolean; + stopLoading: boolean; +}) { + return stopLoading || restartLoading || (tachyonRunning && !startLoading); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/styles.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/styles.ts new file mode 100644 index 00000000..f55ea7e0 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/styles.ts @@ -0,0 +1,203 @@ +// language=CSS +import { TACHYON_UCI_PACKAGE as TACHYON_CBI_PREFIX } from '../../../constants'; + +export const styles = ` + +#cbi-${TACHYON_CBI_PREFIX}-diagnostic-_mount_node > div { + width: 100%; +} + +#cbi-${TACHYON_CBI_PREFIX}-diagnostic > h3 { + display: none; +} + +.fkp_diagnostic-page { + display: grid; + grid-template-columns: 2fr 1fr; + grid-column-gap: 10px; + align-items: start; +} + +@media (max-width: 800px) { + .fkp_diagnostic-page { + grid-template-columns: 1fr; + } +} + +.fkp_diagnostic-page__right-bar { + display: grid; + grid-template-columns: 1fr; + grid-row-gap: 10px; +} + +.fkp_diagnostic-page__right-bar__wiki { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + + display: grid; + grid-template-columns: auto; + grid-row-gap: 10px; +} + +.fkp_diagnostic-page__right-bar__wiki--warning { + border: 2px var(--warn-color-medium, orange) solid; +} +.fkp_diagnostic-page__right-bar__wiki--error { + border: 2px var(--error-color-medium, red) solid; +} + +.fkp_diagnostic-page__right-bar__wiki__content { + display: grid; + grid-template-columns: 1fr 5fr; + grid-column-gap: 10px; +} + +.fkp_diagnostic-page__right-bar__wiki__texts {} + +.fkp_diagnostic-page__right-bar__actions { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + + display: grid; + grid-template-columns: auto; + grid-row-gap: 10px; + +} + +.fkp_diagnostic-page__right-bar__actions > .fkp-partial-button { + width: 100%; + min-width: 0; + margin-left: 0; +} + +.fkp_diagnostic-page__right-bar__system-info { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + + display: grid; + grid-template-columns: auto; + grid-row-gap: 10px; +} + +.fkp_diagnostic-page__right-bar__system-info__title { + +} + +.fkp_diagnostic-page__right-bar__system-info__row { + display: grid; + grid-template-columns: auto 1fr; + grid-column-gap: 5px; +} + +.fkp_diagnostic-page__right-bar__system-info__row__tag { + padding: 2px 4px; + border: 1px transparent solid; + border-radius: 4px; + margin-left: 5px; +} + +.fkp_diagnostic-page__right-bar__system-info__row__tag--neutral { + border: 1px var(--background-color-high, gray) solid; + color: var(--text-color-medium, gray); +} + +.fkp_diagnostic-page__right-bar__system-info__row__tag--warning { + border: 1px var(--warn-color-medium, orange) solid; + color: var(--warn-color-medium, orange); +} + +.fkp_diagnostic-page__right-bar__system-info__row__tag--success { + border: 1px var(--success-color-medium, green) solid; + color: var(--success-color-medium, green); +} + +.fkp_diagnostic-page__left-bar { + display: grid; + grid-template-columns: 1fr; + grid-row-gap: 10px; +} + +.fkp_diagnostic-page__run_check_wrapper {} + +.fkp_diagnostic-page__run_check_wrapper button { + width: 100%; +} + +.fkp_diagnostic-page__checks { + display: grid; + grid-template-columns: 1fr; + grid-row-gap: 10px; +} + +.fkp_diagnostic_alert { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + + display: grid; + grid-template-columns: 24px 1fr; + grid-column-gap: 10px; + align-items: center; + padding: 10px; +} + +.fkp_diagnostic_alert--loading { + border: 2px var(--primary-color-high, dodgerblue) solid; +} + +.fkp_diagnostic_alert--warning { + border: 2px var(--warn-color-medium, orange) solid; + color: var(--warn-color-medium, orange); +} + +.fkp_diagnostic_alert--error { + border: 2px var(--error-color-medium, red) solid; + color: var(--error-color-medium, red); +} + +.fkp_diagnostic_alert--success { + border: 2px var(--success-color-medium, green) solid; + color: var(--success-color-medium, green); +} + +.fkp_diagnostic_alert--skipped {} + +.fkp_diagnostic_alert__icon {} + +.fkp_diagnostic_alert__content {} + +.fkp_diagnostic_alert__title { + display: block; +} + +.fkp_diagnostic_alert__description {} + +.fkp_diagnostic_alert__summary { + margin-top: 10px; +} + +.fkp_diagnostic_alert__summary__item { + display: grid; + grid-template-columns: 16px auto 1fr; + grid-column-gap: 10px; +} + +.fkp_diagnostic_alert__summary__item--error { + color: var(--error-color-medium, red); +} + +.fkp_diagnostic_alert__summary__item--warning { + color: var(--warn-color-medium, orange); +} + +.fkp_diagnostic_alert__summary__item--success { + color: var(--success-color-medium, green); +} + +.fkp_diagnostic_alert__summary__item__icon { + width: 16px; + height: 16px; +} +`; diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/diagnosticRunPersistence.test.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/diagnosticRunPersistence.test.ts new file mode 100644 index 00000000..f88a1279 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/diagnosticRunPersistence.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + clearPersistedDiagnosticRun, + readPersistedDiagnosticRun, + savePersistedDiagnosticRun, +} from '../diagnosticRunPersistence'; + +class MemoryStorage implements Storage { + private values = new Map(); + + get length() { + return this.values.size; + } + + clear() { + this.values.clear(); + } + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + key(index: number) { + return Array.from(this.values.keys())[index] ?? null; + } + + removeItem(key: string) { + this.values.delete(key); + } + + setItem(key: string, value: string) { + this.values.set(key, value); + } +} + +describe('diagnostic run persistence', () => { + it('stores and restores a running diagnostic snapshot', () => { + const storage = new MemoryStorage(); + + savePersistedDiagnosticRun( + { + nextRunnerIndex: 2, + providerOptions: { includeZapret: true }, + diagnosticsChecks: [ + { + order: 1, + code: 'DNS', + title: 'DNS', + description: 'Checks passed', + state: 'success', + items: [], + }, + ], + }, + storage, + ); + + expect(readPersistedDiagnosticRun(storage)).toMatchObject({ + nextRunnerIndex: 2, + providerOptions: { includeZapret: true }, + diagnosticsChecks: [ + { + code: 'DNS', + state: 'success', + }, + ], + }); + }); + + it('removes expired snapshots', () => { + const storage = new MemoryStorage(); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-07T00:00:00Z')); + savePersistedDiagnosticRun( + { + nextRunnerIndex: 0, + providerOptions: {}, + diagnosticsChecks: [], + }, + storage, + ); + + vi.setSystemTime(new Date('2026-06-07T00:31:00Z')); + + expect(readPersistedDiagnosticRun(storage)).toBeNull(); + expect(storage.length).toBe(0); + vi.useRealTimers(); + }); + + it('removes invalid snapshots', () => { + const storage = new MemoryStorage(); + storage.setItem('tachyon:diagnostic-run:v1', '{broken'); + + expect(readPersistedDiagnosticRun(storage)).toBeNull(); + expect(storage.length).toBe(0); + }); + + it('clears persisted snapshots', () => { + const storage = new MemoryStorage(); + + savePersistedDiagnosticRun( + { + nextRunnerIndex: 0, + providerOptions: {}, + diagnosticsChecks: [], + }, + storage, + ); + + clearPersistedDiagnosticRun(storage); + + expect(readPersistedDiagnosticRun(storage)).toBeNull(); + }); + + it('removes snapshots with unsupported check shape', () => { + const storage = new MemoryStorage(); + + storage.setItem( + 'tachyon:diagnostic-run:v1', + JSON.stringify({ + nextRunnerIndex: 0, + providerOptions: {}, + diagnosticsChecks: [ + { + order: 1, + code: 'UNKNOWN', + title: 'Unknown', + description: 'Checking', + state: 'loading', + items: [], + }, + ], + updatedAt: Date.now(), + }), + ); + + expect(readPersistedDiagnosticRun(storage)).toBeNull(); + expect(storage.length).toBe(0); + }); + + it('removes snapshots with invalid numeric fields', () => { + const storage = new MemoryStorage(); + + storage.setItem( + 'tachyon:diagnostic-run:v1', + JSON.stringify({ + nextRunnerIndex: 0, + providerOptions: {}, + diagnosticsChecks: [ + { + order: Number.NaN, + code: 'DNS', + title: 'DNS', + description: 'Checking', + state: 'loading', + items: [], + }, + ], + updatedAt: Date.now(), + }), + ); + + expect(readPersistedDiagnosticRun(storage)).toBeNull(); + expect(storage.length).toBe(0); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/diagnosticStoreZapret2.test.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/diagnosticStoreZapret2.test.ts new file mode 100644 index 00000000..6b80a6b4 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/diagnosticStoreZapret2.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { DIAGNOSTICS_CHECKS } from '../checks/contstants'; +import { getDiagnosticsChecks } from '../diagnostic.store'; + +describe('diagnostic store provider checks', () => { + it('adds Zapret2 as a separate provider check between Zapret and ByeDPI', () => { + const checks = getDiagnosticsChecks('Pending', { + includeZapret: true, + includeZapret2: true, + includeByedpi: true, + includeInbounds: false, + }); + + expect(checks.map((check) => check.code)).toEqual([ + DIAGNOSTICS_CHECKS.DNS, + DIAGNOSTICS_CHECKS.SINGBOX, + DIAGNOSTICS_CHECKS.NFT, + DIAGNOSTICS_CHECKS.ZAPRET, + DIAGNOSTICS_CHECKS.ZAPRET2, + DIAGNOSTICS_CHECKS.BYEDPI, + DIAGNOSTICS_CHECKS.OUTBOUNDS, + DIAGNOSTICS_CHECKS.FAKEIP, + ]); + }); + + it('keeps Zapret2 hidden when the provider is not available', () => { + const checks = getDiagnosticsChecks('Pending', { + includeZapret: true, + includeZapret2: false, + includeByedpi: true, + includeInbounds: false, + }); + + expect(checks.map((check) => check.code)).not.toContain( + DIAGNOSTICS_CHECKS.ZAPRET2, + ); + }); + + it('keeps Inbounds hidden until enabled inbounds are confirmed', () => { + const checks = getDiagnosticsChecks('Pending'); + + expect(checks.map((check) => check.code)).not.toContain( + DIAGNOSTICS_CHECKS.INBOUNDS, + ); + }); + + it('shows Inbounds when enabled inbounds are confirmed', () => { + const checks = getDiagnosticsChecks('Pending', { + includeInbounds: true, + }); + + expect(checks.map((check) => check.code)).toContain( + DIAGNOSTICS_CHECKS.INBOUNDS, + ); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/maskDiagnostics.test.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/maskDiagnostics.test.ts new file mode 100644 index 00000000..325453b1 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/maskDiagnostics.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { + formatMaskedSingBoxConfig, + maskGlobalCheckText, + maskSingBoxConfigValue, +} from '../helpers/maskDiagnostics'; + +describe('diagnostic masking', () => { + it('masks sensitive sing-box keys without mutating the original config', () => { + const config = { + outbounds: [ + { + type: 'vless', + tag: 'proxy', + server: 'example.com', + server_port: 443, + uuid: 'client-id', + tls: { + enabled: true, + server_name: 'example.com', + }, + }, + ], + route: { + rules: [{ domain_suffix: ['example.org'], outbound: 'proxy' }], + }, + }; + + expect(maskSingBoxConfigValue(config)).toEqual({ + outbounds: [ + { + type: 'vless', + tag: 'proxy', + server: 'MASKED', + server_port: 'MASKED', + uuid: 'MASKED', + tls: { + enabled: true, + server_name: 'MASKED', + }, + }, + ], + route: { + rules: [{ domain_suffix: 'MASKED', outbound: 'proxy' }], + }, + }); + expect(config.outbounds[0].server).toBe('example.com'); + }); + + it('formats masked sing-box config from a raw JSON string', () => { + const masked = formatMaskedSingBoxConfig( + '{"inbounds":[{"listen":"127.0.0.1","listen_port":2080}]}', + ); + + expect(masked).toContain('"listen": "MASKED"'); + expect(masked).toContain('"listen_port": "MASKED"'); + }); + + it('masks sensitive global check UCI values while keeping visible structure stable', () => { + const raw = [ + "config section 'main'", + "\toption proxy_string 'vless://secret@example.com:443'", + "\toption hwid 'device-secret'", + "\tlist domain 'example.com'", + "\toption outbound_json '{", + ' "server": "example.com",', + "}'", + "config interface 'lan'", + "\toption ipaddr '192.168.1.1'", + "\toption netmask '255.255.255.0'", + "config interface 'wan'", + "\toption username 'provider-user'", + "\toption password 'provider-password'", + '', + ].join('\n'); + + const masked = maskGlobalCheckText(raw); + + expect(masked.split('\n')).toHaveLength(raw.split('\n').length); + expect(masked).not.toContain('vless://secret'); + expect(masked).not.toContain('device-secret'); + expect(masked).not.toContain('example.com",'); + expect(masked).not.toContain('192.168.1.1'); + expect(masked).not.toContain('provider-password'); + expect(masked).toContain("option proxy_string 'MASKED'"); + expect(masked).toContain("option ipaddr 'MASKED'"); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/serviceTransition.test.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/serviceTransition.test.ts new file mode 100644 index 00000000..61f18bcf --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/tests/serviceTransition.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; +import { + getAvailableActionsDisabledState, + getServiceTransition, + hasComponentActionLoading, + hasLocalMutatingServiceActionLoading, + isServiceTransitionStatus, + shouldDisableDiagnosticRunAction, + shouldResetDiagnosticsChecks, + shouldSkipServicesInfoAutoRefresh, + shouldShowRestartAction, + shouldShowStartAction, + shouldShowStopAction, +} from '../serviceTransition'; + +const idleActions = { + restart: { loading: false }, + start: { loading: false }, + stop: { loading: false }, + enable: { loading: false }, + disable: { loading: false }, +}; + +describe('diagnostic service transitions', () => { + it('treats service transition status as a UI transition', () => { + expect(isServiceTransitionStatus('starting')).toBe(true); + expect(isServiceTransitionStatus('reloading')).toBe(true); + expect(isServiceTransitionStatus('running & enabled')).toBe(false); + }); + + it('maps reload to the restart/reload control state', () => { + expect(getServiceTransition('reloading')).toEqual({ + starting: false, + stopping: false, + restarting: true, + }); + }); + + it('detects only local button loading as local mutation', () => { + expect(hasLocalMutatingServiceActionLoading(idleActions)).toBe(false); + expect( + hasLocalMutatingServiceActionLoading({ + ...idleActions, + start: { loading: true }, + }), + ).toBe(true); + }); + + it('does not let backend transition status block polling forever', () => { + expect( + shouldSkipServicesInfoAutoRefresh({ + force: false, + localMutatingActionLoading: false, + }), + ).toBe(false); + }); + + it('does not reset diagnostics checks while a run is active', () => { + expect( + shouldResetDiagnosticsChecks({ + resetChecks: true, + diagnosticsRunLoading: true, + }), + ).toBe(false); + expect( + shouldResetDiagnosticsChecks({ + resetChecks: true, + diagnosticsRunLoading: false, + }), + ).toBe(true); + expect( + shouldResetDiagnosticsChecks({ + resetChecks: false, + diagnosticsRunLoading: false, + }), + ).toBe(false); + }); + + it('still lets local button actions suppress non-forced polling', () => { + expect( + shouldSkipServicesInfoAutoRefresh({ + force: false, + localMutatingActionLoading: true, + }), + ).toBe(true); + expect( + shouldSkipServicesInfoAutoRefresh({ + force: true, + localMutatingActionLoading: true, + }), + ).toBe(false); + }); + + it('shows restart while the service is running even when autostart is disabled', () => { + expect( + shouldShowRestartAction({ + tachyonRunning: true, + restartLoading: false, + startLoading: false, + stopLoading: false, + }), + ).toBe(true); + expect( + shouldShowRestartAction({ + tachyonRunning: false, + restartLoading: false, + startLoading: false, + stopLoading: false, + }), + ).toBe(false); + expect( + shouldShowRestartAction({ + tachyonRunning: false, + restartLoading: true, + startLoading: false, + stopLoading: false, + }), + ).toBe(true); + }); + + it('does not show restart from transient running state while start or stop is active', () => { + expect( + shouldShowRestartAction({ + tachyonRunning: true, + restartLoading: false, + startLoading: true, + stopLoading: false, + }), + ).toBe(false); + expect( + shouldShowRestartAction({ + tachyonRunning: true, + restartLoading: false, + startLoading: false, + stopLoading: true, + }), + ).toBe(false); + }); + + it('allows diagnostics while the service is running even when autostart is disabled', () => { + expect( + shouldDisableDiagnosticRunAction({ + providerInfoLoaded: true, + servicesInfoLoading: false, + tachyonRunning: true, + mutatingServiceActionLoading: false, + }), + ).toBe(false); + expect( + shouldDisableDiagnosticRunAction({ + providerInfoLoaded: true, + servicesInfoLoading: false, + tachyonRunning: false, + mutatingServiceActionLoading: false, + }), + ).toBe(true); + }); + + it('detects running component actions', () => { + expect( + hasComponentActionLoading({ + tachyonCheck: { loading: false }, + zapretInstall: { loading: true }, + }), + ).toBe(true); + }); + + it('blocks mutating available actions while component actions are running', () => { + expect( + getAvailableActionsDisabledState({ + servicesInfoLoading: false, + mutatingServiceActionLoading: false, + componentActionLoading: true, + }), + ).toEqual({ + serviceControlsDisabled: true, + utilityActionsDisabled: true, + viewLogsDisabled: false, + }); + }); + + it('keeps logs available during service-only mutations', () => { + expect( + getAvailableActionsDisabledState({ + servicesInfoLoading: false, + mutatingServiceActionLoading: true, + componentActionLoading: false, + }), + ).toEqual({ + serviceControlsDisabled: true, + utilityActionsDisabled: true, + viewLogsDisabled: false, + }); + }); + + it('keeps the lower service action visible while restart is running', () => { + expect( + shouldShowStopAction({ + tachyonRunning: false, + restartLoading: true, + startLoading: false, + stopLoading: false, + }), + ).toBe(true); + expect( + shouldShowStartAction({ + tachyonRunning: false, + restartLoading: true, + startLoading: false, + stopLoading: false, + }), + ).toBe(false); + }); + + it('keeps the normal start and stop visibility outside restart', () => { + expect( + shouldShowStartAction({ + tachyonRunning: false, + restartLoading: false, + startLoading: false, + stopLoading: false, + }), + ).toBe(true); + expect( + shouldShowStopAction({ + tachyonRunning: false, + restartLoading: false, + startLoading: false, + stopLoading: false, + }), + ).toBe(false); + expect( + shouldShowStopAction({ + tachyonRunning: true, + restartLoading: false, + startLoading: false, + stopLoading: false, + }), + ).toBe(true); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/tabs/monitoring/index.ts b/fe-app-tachyon/src/tachyon/tabs/monitoring/index.ts new file mode 100644 index 00000000..9a9f3c12 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/monitoring/index.ts @@ -0,0 +1,9 @@ +import { render } from './render'; +import { initController } from './initController'; +import { styles } from './styles'; + +export const MonitoringTab = { + render, + initController, + styles, +}; diff --git a/fe-app-tachyon/src/tachyon/tabs/monitoring/initController.ts b/fe-app-tachyon/src/tachyon/tabs/monitoring/initController.ts new file mode 100644 index 00000000..3f79b76d --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/monitoring/initController.ts @@ -0,0 +1,1672 @@ +import { canUseDirectClashApi, getClashWsUrl, onMount } from '../../../helpers'; +import { prettyBytes } from '../../../helpers/prettyBytes'; +import { showToast } from '../../../helpers/showToast'; +import { + renderPauseIcon24, + renderPlayIcon24, + renderSearchIcon24, + renderXIcon24, +} from '../../../icons'; +import { CustomTachyonMethods, TachyonShellMethods } from '../../methods'; +import { getOutboundTagBySection } from '../../runtimeTags'; +import { getClashApiSecret } from '../../methods/custom/getClashApiSecret'; +import { logger, socket, store, StoreType } from '../../services'; +import { Tachyon } from '../../types'; +import { + getCachedRuntimeUiState, + refreshRuntimeUiState, + subscribeRuntimeUiState, +} from '../../services/runtimeUiState.service'; +import { + getServiceAvailability, + type ServiceAvailability, +} from '../../helpers/serviceAvailability'; + +type MonitoringTabId = 'active' | 'closed'; + +type LocalDeviceChoices = Record; + +interface MonitoringControllerDependencies { + loadLocalDeviceChoices?: () => Promise; +} + +interface ClashConnectionMetadata { + destinationIP?: string; + destinationPort?: string | number; + host?: string; + network?: string; + processPath?: string; + sourceIP?: string; + sourcePort?: string | number; + type?: string; +} + +interface ClashConnection { + chains?: string[]; + download?: number; + id?: string; + metadata?: ClashConnectionMetadata; + rule?: string; + rulePayload?: string; + start?: string; + upload?: number; +} + +interface ClashConnectionsPayload { + connections?: ClashConnection[]; +} + +interface MonitoredConnection extends ClashConnection { + id: string; + closedAt?: number; + lastSeenAt: number; +} + +function normalizeConnectionsPayload(value: unknown): ClashConnectionsPayload { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return value as ClashConnectionsPayload; +} + +const RENDER_INTERVAL_MS = 500; +const CONNECTIONS_RPC_POLL_INTERVAL_MS = 1500; +const CLOSED_CONNECTION_LIMIT = 300; +const ALL_FILTER_VALUE = 'all'; + +let dependencies: MonitoringControllerDependencies = {}; +let monitoringMounted = false; +let monitoringMountId = 0; +let monitoringLifecycleRegistered = false; +let monitoringControllerInitialized = false; +let serviceStateUnsubscribe: (() => void) | null = null; +let renderTimer: ReturnType | null = null; +let connectionsPollTimer: ReturnType | null = null; +let connectionsSocketUrl = ''; +let connectionsUpdatesId = 0; +let renderSkippedForSelection = false; +let pendingConnectionsPayload: ClashConnectionsPayload | null = null; +let pollingConnections = false; + +let activeTab: MonitoringTabId = 'active'; +let selectedDeviceFilter = ALL_FILTER_VALUE; +let searchQuery = ''; +let localDeviceChoices: LocalDeviceChoices = {}; +let routeDisplayNames: Record = {}; +let routeSections: Array<{ sectionName: string; displayName: string }> = []; +let serverDisplayNames: Record = {}; +let lastDeviceFilterSignature = ''; +let loading = true; +let failed = false; +let closingAll = false; +let monitoringPaused = false; +let monitoringPausedAt: number | null = null; +let serviceAvailability: ServiceAvailability = 'loading'; + +const activeConnections = new Map(); +const closedConnections = new Map(); +const closingConnectionIds = new Set(); + +function normalizeString(value?: string | number | null): string { + return value == null ? '' : String(value).trim(); +} + +function getListValues(value?: string[] | string) { + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeString(item)).filter(Boolean); + } + + return normalizeString(value) + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function getUrlTestIds(section: Tachyon.ConfigSection) { + const values = getListValues(section.urltests); + return values.length + ? values + : section.urltest_enabled === '1' + ? ['urltest'] + : []; +} + +function getUrlTestTag(sectionName: string, id: string) { + return getOutboundTagBySection( + id === 'urltest' + ? `${sectionName}-urltest` + : `${sectionName}-urltest-${id}`, + ); +} + +function formatEndpoint(address?: string, port?: string | number): string { + const normalizedAddress = normalizeString(address); + const normalizedPort = normalizeString(port); + + if (!normalizedAddress) { + return '-'; + } + + if (!normalizedPort) { + return normalizedAddress; + } + + if (normalizedPort === '443') { + return normalizedAddress; + } + + if (normalizedAddress.includes(':') && !normalizedAddress.startsWith('[')) { + return `[${normalizedAddress}]:${normalizedPort}`; + } + + return `${normalizedAddress}:${normalizedPort}`; +} + +function getDisplayName(section: Tachyon.ConfigSection) { + return normalizeString(section.label) || section['.name']; +} + +function buildRouteDisplayNames(sections: Tachyon.ConfigSection[]) { + const map: Record = { + 'bypass-out': 'Bypass', + 'direct-out': 'direct', + }; + const serverMap: Record = {}; + const routeSectionItems: Array<{ sectionName: string; displayName: string }> = + []; + const urltestsBySection = new Map(); + + sections + .filter((section) => section['.type'] === 'urltest') + .forEach((section) => { + const owner = normalizeString(section.section); + const id = normalizeString(section.id) || section['.name']; + if (!owner || !id) { + return; + } + + urltestsBySection.set(owner, [ + ...(urltestsBySection.get(owner) || []), + id, + ]); + }); + + sections + .filter((section) => section['.type'] === 'section') + .filter((section) => section.enabled !== '0') + .forEach((section) => { + const sectionName = section['.name']; + const displayName = getDisplayName(section); + + if (!sectionName || !displayName) { + return; + } + + routeSectionItems.push({ sectionName, displayName }); + map[getOutboundTagBySection(sectionName)] = displayName; + const urltestIds = + urltestsBySection.get(sectionName) || getUrlTestIds(section); + urltestIds.forEach((id) => { + map[getUrlTestTag(sectionName, id)] = displayName; + }); + }); + + sections + .filter((section) => section['.type'] === 'server') + .filter((section) => section.enabled !== '0') + .forEach((section) => { + const sectionName = section['.name']; + const displayName = getDisplayName(section); + + if (!sectionName || !displayName) { + return; + } + + serverMap[`server-${sectionName}-in`] = displayName; + }); + + routeDisplayNames = map; + serverDisplayNames = serverMap; + routeSections = routeSectionItems.sort( + (a, b) => b.sectionName.length - a.sectionName.length, + ); +} + +function getRouteDisplayNameByTag(tag: string): string { + if (!tag) { + return ''; + } + + if (routeDisplayNames[tag]) { + return routeDisplayNames[tag]; + } + + const manualSection = routeSections.find(({ sectionName }) => { + if (!tag.startsWith(`${sectionName}-`) || !tag.endsWith('-out')) { + return false; + } + + const middle = tag.slice(sectionName.length + 1, -4); + return /^\d+(?:-\d+)?$/.test(middle); + }); + + return manualSection?.displayName || ''; +} + +function getRouteTagFromRule(rule?: string): string { + const match = normalizeString(rule).match(/=>\s*route\(([^)]+)\)/); + return normalizeString(match?.[1]).replace(/^['"]|['"]$/g, ''); +} + +function parseStartedAt(connection: MonitoredConnection): number { + const startedAt = Date.parse(connection.start || ''); + return Number.isFinite(startedAt) ? startedAt : connection.lastSeenAt; +} + +function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const pad = (value: number) => String(value).padStart(2, '0'); + + if (hours > 0) { + return `${hours}:${pad(minutes)}:${pad(seconds)}`; + } + + return `${minutes}:${pad(seconds)}`; +} + +function formatConnectionDuration(connection: MonitoredConnection): string { + const startedAt = parseStartedAt(connection); + const finishedAt = connection.closedAt || monitoringPausedAt || Date.now(); + + return formatDuration(finishedAt - startedAt); +} + +function formatBytes(value?: number): string { + return prettyBytes(Number.isFinite(value) ? Number(value) : 0); +} + +function getConnectionSourceIp(connection: ClashConnection): string { + return normalizeString(connection.metadata?.sourceIP); +} + +function getConnectionInboundTag(connection: ClashConnection): string { + const metadataType = normalizeString(connection.metadata?.type); + const metadataTypeParts = metadataType.split('/'); + const metadataTag = normalizeString( + metadataTypeParts.length > 1 + ? metadataTypeParts[metadataTypeParts.length - 1] + : metadataType, + ); + + if (metadataTag) { + return metadataTag; + } + + const ruleInbound = normalizeString(connection.rule).match( + /(?:^|\s)inbound=([^\s]+)/, + ); + + return normalizeString(ruleInbound?.[1]); +} + +function getServerDisplayNameByInboundTag(tag: string): string { + return normalizeString(serverDisplayNames[tag]); +} + +function getDeviceName(ip: string): string { + return normalizeString(localDeviceChoices[ip]); +} + +function getServerSourceNameByIp(ip: string): string { + if (!ip) { + return ''; + } + + const connections = [ + ...Array.from(activeConnections.values()), + ...Array.from(closedConnections.values()), + ]; + + for (const connection of connections) { + if (getConnectionSourceIp(connection) !== ip) { + continue; + } + + const serverName = getServerDisplayNameByInboundTag( + getConnectionInboundTag(connection), + ); + + if (serverName) { + return serverName; + } + } + + return ''; +} + +function getDeviceFilterLabel(ip: string): string { + const serverName = getServerSourceNameByIp(ip); + if (serverName) { + return serverName; + } + + const deviceName = getDeviceName(ip); + return deviceName || ip; +} + +function getSourceCellParts(connection: MonitoredConnection) { + const ip = getConnectionSourceIp(connection); + const inboundTag = getConnectionInboundTag(connection); + const serverName = getServerDisplayNameByInboundTag(inboundTag); + + if (serverName) { + return { + primary: serverName, + ip: '', + copyValue: serverName, + searchValue: [serverName, ip, inboundTag].filter(Boolean).join(' '), + }; + } + + const deviceName = getDeviceName(ip); + + if (deviceName) { + return { + primary: deviceName, + ip: '', + copyValue: deviceName, + searchValue: `${deviceName} ${ip}`, + }; + } + + return { + primary: ip || '-', + ip: '', + copyValue: ip || '-', + searchValue: ip, + }; +} + +function getTargetCellParts(connection: MonitoredConnection): { + primary: string; + searchValue: string; +} { + const metadata = connection.metadata || {}; + const host = normalizeString(metadata.host); + const destinationIp = normalizeString(metadata.destinationIP); + const port = metadata.destinationPort; + const primaryTarget = host || destinationIp; + const primary = primaryTarget ? formatEndpoint(primaryTarget, port) : '-'; + + return { + primary, + searchValue: [primary, host, destinationIp].filter(Boolean).join(' '), + }; +} + +function getRoute(connection: MonitoredConnection): string { + const chains = Array.isArray(connection.chains) ? connection.chains : []; + const routeTag = [...chains].reverse().find(getRouteDisplayNameByTag); + const fallbackRouteTag = getRouteTagFromRule(connection.rule); + const route = + getRouteDisplayNameByTag(routeTag || '') || + getRouteDisplayNameByTag(fallbackRouteTag) || + normalizeString(routeTag) || + normalizeString(fallbackRouteTag); + + return route || '-'; +} + +function getNetwork(connection: MonitoredConnection): string { + return normalizeString(connection.metadata?.network).toLowerCase() || '-'; +} + +function sortConnections( + connections: MonitoredConnection[], + tab: MonitoringTabId, +): MonitoredConnection[] { + return [...connections].sort((a, b) => { + if (tab === 'closed') { + return (b.closedAt || 0) - (a.closedAt || 0); + } + + return parseStartedAt(b) - parseStartedAt(a); + }); +} + +function getConnectionsForActiveTab(): MonitoredConnection[] { + const source = + activeTab === 'active' + ? Array.from(activeConnections.values()) + : Array.from(closedConnections.values()); + + return sortConnections(source, activeTab); +} + +function normalizeSearchValue(value: string): string { + return value.toLowerCase().replace(/\s+/g, ' ').trim(); +} + +function getSearchValues(connection: MonitoredConnection): string[] { + const target = getTargetCellParts(connection); + const source = getSourceCellParts(connection); + + return [ + connection.id, + target.primary, + getNetwork(connection), + getRoute(connection), + formatConnectionDuration(connection), + formatBytes(connection.download), + formatBytes(connection.upload), + source.primary, + source.copyValue, + source.searchValue, + ].filter(Boolean); +} + +function getVisibleConnections(): MonitoredConnection[] { + const normalizedSearch = normalizeSearchValue(searchQuery); + + return getConnectionsForActiveTab().filter((connection) => { + const sourceIp = getConnectionSourceIp(connection); + if ( + selectedDeviceFilter !== ALL_FILTER_VALUE && + sourceIp !== selectedDeviceFilter + ) { + return false; + } + + if (!normalizedSearch) { + return true; + } + + return getSearchValues(connection).some((value) => + normalizeSearchValue(value).includes(normalizedSearch), + ); + }); +} + +function moveConnectionToClosed(connection: MonitoredConnection, now: number) { + closedConnections.set(connection.id, { + ...connection, + closedAt: now, + lastSeenAt: now, + }); +} + +function trimClosedConnections() { + const sorted = sortConnections( + Array.from(closedConnections.values()), + 'closed', + ); + + sorted.slice(CLOSED_CONNECTION_LIMIT).forEach((connection) => { + closedConnections.delete(connection.id); + }); +} + +function applyConnectionsPayload(payload: ClashConnectionsPayload) { + if (monitoringPaused) { + pendingConnectionsPayload = payload; + return; + } + + const mountId = monitoringMountId; + const now = Date.now(); + const incomingIds = new Set(); + const rawConnections = Array.isArray(payload.connections) + ? payload.connections + : []; + + rawConnections.forEach((rawConnection) => { + const id = normalizeString(rawConnection.id); + if (!id) { + return; + } + + incomingIds.add(id); + closedConnections.delete(id); + activeConnections.set(id, { + ...rawConnection, + id, + lastSeenAt: now, + }); + }); + + Array.from(activeConnections.entries()).forEach(([id, connection]) => { + if (!incomingIds.has(id)) { + activeConnections.delete(id); + moveConnectionToClosed(connection, now); + } + }); + + trimClosedConnections(); + loading = false; + failed = false; + + if (monitoringMounted && mountId === monitoringMountId) { + renderControls(); + renderConnections(); + } +} + +function setTab(tab: MonitoringTabId) { + if (activeTab === tab) { + return; + } + + activeTab = tab; + renderControls(); + renderConnections(); +} + +function getKnownSourceIps(): string[] { + const ips = new Set(); + + activeConnections.forEach((connection) => { + const ip = getConnectionSourceIp(connection); + if (ip) { + ips.add(ip); + } + }); + + closedConnections.forEach((connection) => { + const ip = getConnectionSourceIp(connection); + if (ip) { + ips.add(ip); + } + }); + + return Array.from(ips).sort((a, b) => { + const byLabel = getDeviceFilterLabel(a).localeCompare( + getDeviceFilterLabel(b), + ); + return byLabel || a.localeCompare(b); + }); +} + +function renderDeviceFilterOptions() { + const select = document.getElementById( + 'monitoring-device-filter', + ) as HTMLSelectElement | null; + + if (!select) { + return; + } + + const sourceIps = getKnownSourceIps(); + if ( + selectedDeviceFilter !== ALL_FILTER_VALUE && + !sourceIps.includes(selectedDeviceFilter) + ) { + selectedDeviceFilter = ALL_FILTER_VALUE; + } + + const signature = [ + selectedDeviceFilter, + ...sourceIps.map((ip) => `${ip}:${getDeviceFilterLabel(ip)}`), + ].join('|'); + if (signature === lastDeviceFilterSignature) { + select.value = selectedDeviceFilter; + return; + } + + lastDeviceFilterSignature = signature; + + const options = [ + E('option', { value: ALL_FILTER_VALUE }, _('All')), + ...sourceIps.map((ip) => + E('option', { value: ip }, getDeviceFilterLabel(ip)), + ), + ]; + + select.replaceChildren(...options); + select.value = selectedDeviceFilter; +} + +function setButtonActive(button: HTMLElement | null, active: boolean) { + if (!button) { + return; + } + + button.classList.toggle('fkp_monitoring-page__tab--active', active); +} + +function renderTabButtonContent(label: string, count: number) { + return [ + E('span', { class: 'fkp_monitoring-page__tab-label' }, label), + E('span', { class: 'fkp_monitoring-page__tab-badge' }, String(count)), + ]; +} + +function renderControls() { + const activeButton = document.getElementById( + 'monitoring-tab-active', + ) as HTMLButtonElement | null; + const closedButton = document.getElementById( + 'monitoring-tab-closed', + ) as HTMLButtonElement | null; + const closeAllButton = document.getElementById( + 'monitoring-close-all', + ) as HTMLButtonElement | null; + const pauseToggleButton = document.getElementById( + 'monitoring-pause-toggle', + ) as HTMLButtonElement | null; + + if (activeButton) { + activeButton.replaceChildren( + ...renderTabButtonContent(_('Active'), activeConnections.size), + ); + activeButton.disabled = serviceAvailability === 'stopped'; + } + + if (closedButton) { + closedButton.replaceChildren( + ...renderTabButtonContent(_('Closed'), closedConnections.size), + ); + closedButton.disabled = serviceAvailability === 'stopped'; + } + + setButtonActive(activeButton, activeTab === 'active'); + setButtonActive(closedButton, activeTab === 'closed'); + + if (closeAllButton) { + closeAllButton.replaceChildren(renderXIcon24()); + closeAllButton.disabled = + serviceAvailability === 'stopped' || + activeConnections.size === 0 || + closingAll; + } + + if (pauseToggleButton) { + const title = monitoringPaused ? _('Resume updates') : _('Pause updates'); + pauseToggleButton.replaceChildren( + monitoringPaused ? renderPlayIcon24() : renderPauseIcon24(), + ); + pauseToggleButton.title = title; + pauseToggleButton.setAttribute('aria-label', title); + pauseToggleButton.disabled = serviceAvailability === 'stopped'; + pauseToggleButton.classList.toggle( + 'fkp_monitoring-page__icon-button--active', + monitoringPaused, + ); + } + + const searchIcon = document.querySelector( + '.fkp_monitoring-page__search-icon', + ); + if (searchIcon && searchIcon.childNodes.length === 0) { + searchIcon.replaceChildren(renderSearchIcon24()); + } + + renderDeviceFilterOptions(); + + const select = document.getElementById( + 'monitoring-device-filter', + ) as HTMLSelectElement | null; + const searchInput = document.getElementById( + 'monitoring-search', + ) as HTMLInputElement | null; + + if (select) { + select.disabled = serviceAvailability === 'stopped'; + } + + if (searchInput) { + searchInput.disabled = serviceAvailability === 'stopped'; + } +} + +function renderValue(value: string, className = '') { + const text = value || '-'; + const element = E( + 'span', + { + class: ['fkp_monitoring-page__value', className] + .filter(Boolean) + .join(' '), + title: text, + }, + text, + ); + + element.setAttribute('data-copy-value', text); + + return element; +} + +function renderSourceValue(source: ReturnType) { + const fullText = source.copyValue || source.primary || '-'; + + if (!source.ip) { + const element = E( + 'span', + { + class: + 'fkp_monitoring-page__value fkp_monitoring-page__source-value fkp_monitoring-page__source-value--ip-only', + title: fullText, + }, + source.primary || '-', + ); + + element.setAttribute('data-copy-value', fullText); + + return element; + } + + const element = E( + 'span', + { + class: 'fkp_monitoring-page__value fkp_monitoring-page__source-value', + title: fullText, + }, + [ + E('span', { class: 'fkp_monitoring-page__source-name' }, source.primary), + E('span', { class: 'fkp_monitoring-page__source-ip' }, source.ip), + ], + ); + + element.setAttribute('data-copy-value', fullText); + + return element; +} + +function renderTableCell(label: string, children: (Node | string)[]) { + const cell = E('td', {}, children); + cell.setAttribute('data-label', label); + return cell; +} + +function renderConnectionRow(connection: MonitoredConnection) { + const target = getTargetCellParts(connection); + const source = getSourceCellParts(connection); + const isClosing = closingConnectionIds.has(connection.id); + const closeButton = + activeTab === 'active' + ? E( + 'button', + { + class: 'btn cbi-button fkp_monitoring-page__row-action', + title: _('Close connection'), + 'aria-label': _('Close connection'), + type: 'button', + value: connection.id, + ...(isClosing ? { disabled: true } : {}), + }, + [renderXIcon24()], + ) + : E('span', {}, '-'); + + return E( + 'tr', + { + class: isClosing ? 'fkp_monitoring-page__row--closing' : '', + }, + [ + renderTableCell(_('Host'), [renderValue(target.primary)]), + renderTableCell(_('Type'), [ + renderValue(getNetwork(connection), 'fkp_monitoring-page__network'), + ]), + renderTableCell(_('Route'), [ + renderValue(getRoute(connection), 'fkp_monitoring-page__route'), + ]), + renderTableCell(_('Time'), [ + renderValue(formatConnectionDuration(connection)), + ]), + renderTableCell(_('Downloaded'), [ + renderValue(formatBytes(connection.download)), + ]), + renderTableCell(_('Uploaded'), [ + renderValue(formatBytes(connection.upload)), + ]), + renderTableCell(_('Source'), [renderSourceValue(source)]), + renderTableCell(_('Close'), [closeButton]), + ], + ); +} + +function renderStateRow(text: string, className = '') { + return E('tr', { class: 'fkp_monitoring-page__state-row' }, [ + E( + 'td', + { + class: 'fkp_monitoring-page__state-cell', + colSpan: 8, + }, + [ + E( + 'div', + { + class: ['fkp_monitoring-page__state', className] + .filter(Boolean) + .join(' '), + }, + text, + ), + ], + ), + ]); +} + +function renderConnectionsTable( + connections: MonitoredConnection[], + state?: { text: string; className?: string }, +) { + const rows = state + ? [renderStateRow(state.text, state.className)] + : connections.map(renderConnectionRow); + + return E('div', { class: 'fkp_monitoring-page__table-wrap' }, [ + E( + 'table', + { class: 'table cbi-section-table fkp_monitoring-page__table' }, + [ + E('thead', {}, [ + E('tr', {}, [ + E('th', {}, _('Host')), + E('th', {}, _('Type')), + E('th', {}, _('Route')), + E('th', {}, _('Time')), + E('th', {}, `\u2193 ${_('Downloaded')}`), + E('th', {}, `\u2191 ${_('Uploaded')}`), + E('th', {}, _('Source')), + E('th', {}, _('Close')), + ]), + ]), + E('tbody', {}, rows), + ], + ), + ]); +} + +function isNodeInsideMonitoring(node: Node | null): boolean { + const root = document.getElementById('monitoring-status'); + return Boolean(root && node && root.contains(node)); +} + +function isTextSelectionInsideMonitoring(): boolean { + const selection = window.getSelection?.(); + if (!selection || selection.isCollapsed) { + return false; + } + + return ( + isNodeInsideMonitoring(selection.anchorNode) || + isNodeInsideMonitoring(selection.focusNode) + ); +} + +function renderConnections(options: { force?: boolean } = {}) { + const container = document.getElementById('monitoring-connections'); + if (!container) { + return; + } + + if (!options.force && isTextSelectionInsideMonitoring()) { + renderSkippedForSelection = true; + return; + } + + renderSkippedForSelection = false; + const previousScrollLeft = container.scrollLeft; + + if (serviceAvailability === 'stopped') { + container.replaceChildren( + renderConnectionsTable([], { + text: _( + 'Tachyon service is stopped. Start the service to display connections.', + ), + }), + ); + return; + } + + if (loading) { + container.replaceChildren( + renderConnectionsTable([], { + text: _('Loading connections'), + className: 'fkp_monitoring-page__state--loading', + }), + ); + return; + } + + if (failed) { + container.replaceChildren( + renderConnectionsTable([], { + text: _('Connections are unavailable'), + className: 'fkp_monitoring-page__state--error', + }), + ); + return; + } + + const visibleConnections = getVisibleConnections(); + + if (visibleConnections.length === 0) { + container.replaceChildren( + renderConnectionsTable([], { + text: + activeTab === 'active' + ? _('No active connections') + : _('No closed connections'), + }), + ); + return; + } + + container.replaceChildren(renderConnectionsTable(visibleConnections)); + container.scrollLeft = previousScrollLeft; +} + +function flushRenderAfterSelection() { + if (!renderSkippedForSelection || isTextSelectionInsideMonitoring()) { + return; + } + + renderConnections({ force: true }); +} + +function setMonitoringPaused(paused: boolean) { + if (monitoringPaused === paused) { + return; + } + + monitoringPaused = paused; + monitoringPausedAt = paused ? Date.now() : null; + renderSkippedForSelection = false; + renderControls(); + + if (!paused) { + const payload = pendingConnectionsPayload; + pendingConnectionsPayload = null; + + if (payload) { + applyConnectionsPayload(payload); + return; + } + + if (connectionsPollTimer) { + void pollConnectionsSnapshot(); + return; + } + } + + renderConnections(); +} + +function isElementOverflowing(element: HTMLElement): boolean { + return element.scrollWidth > element.clientWidth + 1; +} + +function getMonitoringValueOverflowElements( + element: HTMLElement, +): HTMLElement[] { + return [ + element, + ...Array.from(element.querySelectorAll('*')), + ].filter(isElementOverflowing); +} + +function getElementCopyText(element: HTMLElement, fallback: string): string { + return ( + element.getAttribute('data-copy-value') || element.textContent || fallback + ); +} + +function compactMonitoringText(value: string): string { + return value + .replace(/\u2026/g, '') + .trim() + .replace(/\s+/g, ''); +} + +function getMonitoringValueTextElements(element: HTMLElement): HTMLElement[] { + const children = Array.from(element.children).filter( + (child): child is HTMLElement => child instanceof HTMLElement, + ); + + if (children.length === 0) { + return [element]; + } + + const textElements = children + .flatMap(getMonitoringValueTextElements) + .filter((child) => compactMonitoringText(getElementCopyText(child, ''))); + + return textElements.length > 0 ? textElements : [element]; +} + +function estimateVisibleMonitoringTextLength( + element: HTMLElement, + fallbackText: string, +): number { + const text = compactMonitoringText(getElementCopyText(element, fallbackText)); + + if (!text) { + return 0; + } + + if (!isElementOverflowing(element)) { + return text.length; + } + + return Math.floor( + (element.clientWidth / Math.max(element.scrollWidth, 1)) * text.length, + ); +} + +function getEstimatedVisibleMonitoringTextLength( + element: HTMLElement, + fallbackText: string, +): number { + const textElements = getMonitoringValueTextElements(element); + + if (textElements.length === 1 && textElements[0] === element) { + return estimateVisibleMonitoringTextLength(element, fallbackText); + } + + return textElements.reduce( + (total, textElement) => + total + estimateVisibleMonitoringTextLength(textElement, fallbackText), + 0, + ); +} + +function isCompactTextSubsequence(needle: string, haystack: string): boolean { + let haystackIndex = 0; + + for (let needleIndex = 0; needleIndex < needle.length; needleIndex += 1) { + haystackIndex = haystack.indexOf(needle[needleIndex], haystackIndex); + + if (haystackIndex === -1) { + return false; + } + + haystackIndex += 1; + } + + return true; +} + +function getSelectionValueElements(selection: Selection): HTMLElement[] { + const root = document.getElementById('monitoring-status'); + if (!root) { + return []; + } + + return Array.from( + root.querySelectorAll( + '.fkp_monitoring-page__value[data-copy-value]', + ), + ).filter((element) => { + for (let index = 0; index < selection.rangeCount; index += 1) { + try { + if (selection.getRangeAt(index).intersectsNode(element)) { + return true; + } + } catch (_error) { + return false; + } + } + + return false; + }); +} + +function shouldCopyFullMonitoringValue( + element: HTMLElement, + selectedText: string, + fullText: string, +): boolean { + const normalizedSelectedText = selectedText.replace(/\u2026/g, '').trim(); + const normalizedFullText = fullText.trim(); + const compactSelectedText = compactMonitoringText(selectedText); + const compactFullText = compactMonitoringText(fullText); + const overflowElements = getMonitoringValueOverflowElements(element); + const hasCompositeText = getMonitoringValueTextElements(element).length > 1; + + if (!normalizedSelectedText || !normalizedFullText) { + return false; + } + + if (normalizedSelectedText === normalizedFullText) { + return true; + } + + if (overflowElements.length === 0) { + return false; + } + + if (hasCompositeText) { + const selectedPrefix = compactSelectedText.slice( + 0, + Math.min(4, compactSelectedText.length), + ); + + if ( + !compactFullText.startsWith(selectedPrefix) || + !isCompactTextSubsequence(compactSelectedText, compactFullText) + ) { + return false; + } + } else if (!compactFullText.startsWith(compactSelectedText)) { + return false; + } + + const estimatedVisibleChars = getEstimatedVisibleMonitoringTextLength( + element, + normalizedFullText, + ); + + return compactSelectedText.length >= Math.max(4, estimatedVisibleChars - 2); +} + +function handleMonitoringValueCopy(event: ClipboardEvent) { + const selection = window.getSelection?.(); + if (!selection || selection.isCollapsed) { + return; + } + + const valueElements = getSelectionValueElements(selection); + if (valueElements.length !== 1) { + return; + } + + const valueElement = valueElements[0]; + const fullText = + valueElement.getAttribute('data-copy-value') || + valueElement.textContent || + ''; + const selectedText = selection.toString(); + + if (!shouldCopyFullMonitoringValue(valueElement, selectedText, fullText)) { + return; + } + + event.clipboardData?.setData('text/plain', fullText); + event.preventDefault(); +} + +async function closeConnection(connectionId: string) { + if (!connectionId || closingConnectionIds.has(connectionId)) { + return; + } + + closingConnectionIds.add(connectionId); + renderConnections(); + + try { + const response = + await TachyonShellMethods.closeClashApiConnection(connectionId); + + if (!response.success) { + showToast(_('Failed to close connection'), 'error'); + return; + } + + const now = Date.now(); + const connection = activeConnections.get(connectionId); + if (connection) { + activeConnections.delete(connectionId); + moveConnectionToClosed(connection, now); + trimClosedConnections(); + pendingConnectionsPayload = null; + renderControls(); + } + } catch (error) { + logger.error('[MONITORING]', 'closeConnection: failed', error); + showToast(_('Failed to close connection'), 'error'); + } finally { + closingConnectionIds.delete(connectionId); + renderConnections(); + } +} + +async function closeAllConnections() { + if (activeConnections.size === 0 || closingAll) { + return; + } + + closingAll = true; + renderControls(); + + try { + const response = await TachyonShellMethods.closeAllClashApiConnections(); + + if (!response.success) { + showToast(_('Failed to close connections'), 'error'); + return; + } + + const now = Date.now(); + activeConnections.forEach((connection) => { + moveConnectionToClosed(connection, now); + }); + activeConnections.clear(); + pendingConnectionsPayload = null; + trimClosedConnections(); + } catch (error) { + logger.error('[MONITORING]', 'closeAllConnections: failed', error); + showToast(_('Failed to close connections'), 'error'); + } finally { + closingAll = false; + renderControls(); + renderConnections(); + } +} + +function bindControls() { + const activeButton = document.getElementById('monitoring-tab-active'); + const closedButton = document.getElementById('monitoring-tab-closed'); + const select = document.getElementById( + 'monitoring-device-filter', + ) as HTMLSelectElement | null; + const searchInput = document.getElementById( + 'monitoring-search', + ) as HTMLInputElement | null; + const closeAllButton = document.getElementById('monitoring-close-all'); + const pauseToggleButton = document.getElementById('monitoring-pause-toggle'); + const connectionsContainer = document.getElementById( + 'monitoring-connections', + ); + + if (activeButton) { + activeButton.onclick = () => setTab('active'); + } + + if (closedButton) { + closedButton.onclick = () => setTab('closed'); + } + + if (closeAllButton) { + closeAllButton.onclick = () => { + void closeAllConnections(); + }; + } + + if (pauseToggleButton) { + pauseToggleButton.onclick = () => { + setMonitoringPaused(!monitoringPaused); + pauseToggleButton.blur(); + }; + } + + if (select) { + select.onchange = () => { + selectedDeviceFilter = select.value || ALL_FILTER_VALUE; + renderConnections(); + }; + } + + if (searchInput) { + searchInput.oninput = () => { + searchQuery = searchInput.value; + renderConnections(); + }; + } + + if (connectionsContainer) { + connectionsContainer.onclick = (event) => { + const target = event.target as HTMLElement | null; + const button = target?.closest( + '.fkp_monitoring-page__row-action', + ) as HTMLButtonElement | null; + + if (button?.value) { + void closeConnection(button.value); + } + }; + } +} + +async function loadLocalDevices() { + try { + localDeviceChoices = (await dependencies.loadLocalDeviceChoices?.()) || {}; + } catch (error) { + logger.warn('[MONITORING]', 'loadLocalDevices: failed', error); + localDeviceChoices = {}; + } finally { + renderControls(); + renderConnections(); + } +} + +async function loadRouteDisplayNames() { + try { + buildRouteDisplayNames(await CustomTachyonMethods.getConfigSections()); + } catch (error) { + logger.warn('[MONITORING]', 'loadRouteDisplayNames: failed', error); + buildRouteDisplayNames([]); + } finally { + renderControls(); + renderConnections(); + } +} + +async function pollConnectionsSnapshot() { + if ( + pollingConnections || + !monitoringMounted || + monitoringPaused || + serviceAvailability !== 'running' + ) { + return; + } + + const mountId = monitoringMountId; + pollingConnections = true; + + try { + const response = await TachyonShellMethods.getClashApiConnections(); + + if ( + !monitoringMounted || + mountId !== monitoringMountId || + serviceAvailability !== 'running' + ) { + return; + } + + if (!response.success) { + failed = true; + loading = false; + renderConnections(); + return; + } + + applyConnectionsPayload(normalizeConnectionsPayload(response.data)); + } catch (error) { + if ( + !monitoringMounted || + mountId !== monitoringMountId || + serviceAvailability !== 'running' + ) { + return; + } + + logger.error('[MONITORING]', 'connections polling failed', error); + failed = true; + loading = false; + renderConnections(); + } finally { + pollingConnections = false; + } +} + +function startConnectionsPolling() { + if (connectionsPollTimer) { + return; + } + + void pollConnectionsSnapshot(); + connectionsPollTimer = setInterval(() => { + void pollConnectionsSnapshot(); + }, CONNECTIONS_RPC_POLL_INTERVAL_MS); +} + +async function connectToConnectionsSocket(updatesId: number) { + const mountId = monitoringMountId; + const clashApiSecret = await getClashApiSecret(); + + if ( + !monitoringMounted || + mountId !== monitoringMountId || + updatesId !== connectionsUpdatesId || + serviceAvailability !== 'running' + ) { + return; + } + + connectionsSocketUrl = `${getClashWsUrl()}/connections?token=${clashApiSecret}`; + + socket.subscribe( + connectionsSocketUrl, + (msg) => { + if ( + updatesId !== connectionsUpdatesId || + serviceAvailability !== 'running' + ) { + return; + } + + try { + applyConnectionsPayload(JSON.parse(msg) as ClashConnectionsPayload); + } catch (error) { + logger.error('[MONITORING]', 'connections socket parse failed', error); + } + }, + (_err) => { + if ( + !monitoringMounted || + mountId !== monitoringMountId || + updatesId !== connectionsUpdatesId || + serviceAvailability !== 'running' + ) { + return; + } + + failed = true; + loading = false; + renderConnections(); + }, + ); +} + +function startConnectionsUpdates() { + if (serviceAvailability !== 'running') { + return; + } + + if (canUseDirectClashApi()) { + const updatesId = ++connectionsUpdatesId; + void connectToConnectionsSocket(updatesId); + return; + } + + startConnectionsPolling(); +} + +function stopConnectionsUpdates() { + connectionsUpdatesId += 1; + + if (connectionsPollTimer) { + clearInterval(connectionsPollTimer); + connectionsPollTimer = null; + } + + if (connectionsSocketUrl) { + socket.disconnect(connectionsSocketUrl); + connectionsSocketUrl = ''; + } +} + +function setServiceAvailability(next: ServiceAvailability) { + if (serviceAvailability === next) { + return; + } + + serviceAvailability = next; + + if (next === 'running') { + loading = true; + failed = false; + startConnectionsUpdates(); + } else { + stopConnectionsUpdates(); + pendingConnectionsPayload = null; + + if (next === 'stopped') { + loading = false; + failed = false; + activeConnections.clear(); + closedConnections.clear(); + closingConnectionIds.clear(); + } else if (next === 'unavailable') { + loading = false; + failed = true; + } + } + + renderControls(); + renderConnections(); +} + +function watchServiceState() { + serviceStateUnsubscribe?.(); + serviceStateUnsubscribe = subscribeRuntimeUiState((uiState) => { + if (!monitoringMounted) { + return; + } + + setServiceAvailability( + getServiceAvailability({ + loading: false, + failed: false, + running: uiState.service.tachyon.running, + }), + ); + }); +} + +function resetMonitoringState() { + activeTab = 'active'; + selectedDeviceFilter = ALL_FILTER_VALUE; + searchQuery = ''; + lastDeviceFilterSignature = ''; + loading = true; + failed = false; + closingAll = false; + monitoringPaused = false; + monitoringPausedAt = null; + serviceAvailability = 'loading'; + pendingConnectionsPayload = null; + activeConnections.clear(); + closedConnections.clear(); + closingConnectionIds.clear(); + + const searchInput = document.getElementById( + 'monitoring-search', + ) as HTMLInputElement | null; + if (searchInput) { + searchInput.value = ''; + } +} + +async function onPageMount() { + onPageUnmount(); + + monitoringMounted = true; + monitoringMountId += 1; + const mountId = monitoringMountId; + + resetMonitoringState(); + bindControls(); + renderControls(); + renderConnections(); + watchServiceState(); + + void loadLocalDevices(); + void loadRouteDisplayNames(); + + if (getCachedRuntimeUiState()) { + void refreshRuntimeUiState({ force: true }); + } else { + const uiState = await refreshRuntimeUiState({ force: true }); + + if (!monitoringMounted || mountId !== monitoringMountId) { + return; + } + + if (!uiState && serviceAvailability === 'loading') { + setServiceAvailability('unavailable'); + } + } + + document.addEventListener('selectionchange', flushRenderAfterSelection); + document.addEventListener('copy', handleMonitoringValueCopy); + + renderTimer = setInterval(() => { + if (monitoringPaused) { + return; + } + + renderConnections(); + }, RENDER_INTERVAL_MS); +} + +function onPageUnmount() { + monitoringMounted = false; + monitoringMountId += 1; + + if (renderTimer) { + clearInterval(renderTimer); + renderTimer = null; + } + + stopConnectionsUpdates(); + serviceStateUnsubscribe?.(); + serviceStateUnsubscribe = null; + + document.removeEventListener('selectionchange', flushRenderAfterSelection); + document.removeEventListener('copy', handleMonitoringValueCopy); +} + +function registerLifecycleListeners() { + if (monitoringLifecycleRegistered) { + return; + } + + monitoringLifecycleRegistered = true; + + store.subscribe( + (next: StoreType, prev: StoreType, diff: Partial) => { + if ( + diff.tabService && + next.tabService.current !== prev.tabService.current + ) { + const isMonitoringVisible = next.tabService.current === 'monitoring'; + + if (isMonitoringVisible) { + return onPageMount(); + } + + if (!isMonitoringVisible) { + return onPageUnmount(); + } + } + }, + ); +} + +export async function initController( + controllerDependencies: MonitoringControllerDependencies = {}, +): Promise { + dependencies = { + ...dependencies, + ...controllerDependencies, + }; + + if (monitoringControllerInitialized) { + return; + } + + monitoringControllerInitialized = true; + + onMount('monitoring-status').then(() => { + registerLifecycleListeners(); + + if (store.get().tabService.current === 'monitoring') { + onPageMount(); + } + }); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/monitoring/render.ts b/fe-app-tachyon/src/tachyon/tabs/monitoring/render.ts new file mode 100644 index 00000000..e7a63c3e --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/monitoring/render.ts @@ -0,0 +1,95 @@ +export function render() { + return E( + 'div', + { + id: 'monitoring-status', + class: 'fkp_monitoring-page', + }, + [ + E('div', { class: 'fkp_monitoring-page__panel' }, [ + E('div', { class: 'fkp_monitoring-page__controls' }, [ + E('div', { class: 'fkp_monitoring-page__tabs' }, [ + E( + 'button', + { + id: 'monitoring-tab-active', + class: + 'btn cbi-button fkp_monitoring-page__tab fkp_monitoring-page__tab--active', + type: 'button', + }, + `${_('Active')} 0`, + ), + E( + 'button', + { + id: 'monitoring-tab-closed', + class: 'btn cbi-button fkp_monitoring-page__tab', + type: 'button', + }, + `${_('Closed')} 0`, + ), + ]), + E('div', { class: 'fkp_monitoring-page__filters' }, [ + E( + 'select', + { + id: 'monitoring-device-filter', + class: 'cbi-input-select fkp_monitoring-page__device-filter', + }, + [E('option', { value: 'all' }, _('All'))], + ), + E('label', { class: 'fkp_monitoring-page__search' }, [ + E('span', { class: 'fkp_monitoring-page__search-icon' }, []), + E('input', { + id: 'monitoring-search', + class: 'cbi-input-text fkp_monitoring-page__search-input', + type: 'search', + placeholder: _('Search'), + autocomplete: 'off', + }), + ]), + ]), + E('div', { class: 'fkp_monitoring-page__actions' }, [ + E( + 'button', + { + id: 'monitoring-close-all', + class: 'btn cbi-button fkp_monitoring-page__icon-button', + title: _('Close all connections'), + 'aria-label': _('Close all connections'), + type: 'button', + disabled: true, + }, + [], + ), + E( + 'button', + { + id: 'monitoring-pause-toggle', + class: 'btn cbi-button fkp_monitoring-page__icon-button', + title: _('Pause updates'), + 'aria-label': _('Pause updates'), + type: 'button', + }, + [], + ), + ]), + ]), + E( + 'div', + { id: 'monitoring-connections', class: 'fkp_monitoring-page__body' }, + [ + E( + 'div', + { + class: + 'fkp_monitoring-page__state fkp_monitoring-page__state--loading', + }, + _('Loading connections'), + ), + ], + ), + ]), + ], + ); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/monitoring/styles.ts b/fe-app-tachyon/src/tachyon/tabs/monitoring/styles.ts new file mode 100644 index 00000000..ccc4397d --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/monitoring/styles.ts @@ -0,0 +1,634 @@ +// language=CSS +import { TACHYON_UCI_PACKAGE as TACHYON_CBI_PREFIX } from '../../../constants'; + +export const styles = ` +#cbi-${TACHYON_CBI_PREFIX}-monitoring-_mount_node { + margin: 16px 0 22px; + padding: 0; +} + +#cbi-${TACHYON_CBI_PREFIX}-monitoring-_mount_node > .cbi-value-title { + display: none; +} + +#cbi-${TACHYON_CBI_PREFIX}-monitoring-_mount_node > .cbi-value-field { + margin-left: 0; + width: 100%; +} + +#cbi-${TACHYON_CBI_PREFIX}-monitoring-_mount_node > div { + width: 100%; +} + +#cbi-${TACHYON_CBI_PREFIX}-monitoring > h3 { + display: none; +} + +.fkp_monitoring-page { + --fkp-monitoring-control-height: 34px; + --fkp-monitoring-row-action-size: 24px; + --fkp-monitoring-divider-color: rgba(127, 127, 127, 0.22); + --fkp-monitoring-soft-bg: rgba(127, 127, 127, 0.08); + --fkp-monitoring-soft-bg-hover: rgba(127, 127, 127, 0.14); + --fkp-monitoring-danger-color: var(--error-color-medium, #d32f2f); + --fkp-monitoring-success-color: var(--success-color-medium, #2e7d32); + --fkp-monitoring-paused-color: var(--primary-color-high, #1976d2); + + width: 100%; + min-width: 0; +} + +.fkp_monitoring-page__panel { + margin-top: 0; + border: 0; + border-radius: 0; + padding: 0; + background: transparent; + box-sizing: border-box; + width: 100%; + min-width: 0; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__icon-button { + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; + padding: 0; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; + margin: 0; + border: 1px solid var(--fkp-monitoring-divider-color) !important; + border-radius: 6px; + background: var(--fkp-monitoring-soft-bg) !important; + color: var(--text-color-medium) !important; + box-shadow: none; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__icon-button:hover:not(:disabled) { + background: var(--fkp-monitoring-soft-bg-hover) !important; + color: var(--text-color-high) !important; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__icon-button--active { + background: rgba(25, 118, 210, 0.16) !important; + color: var(--primary-color-high, #1976d2) !important; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__icon-button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.fkp_monitoring-page #monitoring-close-all.btn.fkp_monitoring-page__icon-button { + order: 2; + border-color: rgba(217, 83, 79, 0.4) !important; + background: transparent !important; + color: #d9534f !important; +} + +.fkp_monitoring-page #monitoring-close-all.btn.fkp_monitoring-page__icon-button:hover:not(:disabled) { + border-color: rgba(217, 83, 79, 0.6) !important; + background: transparent !important; + color: #d9534f !important; +} + +.fkp_monitoring-page #monitoring-pause-toggle.btn.fkp_monitoring-page__icon-button, +.fkp_monitoring-page #monitoring-pause-toggle.btn.fkp_monitoring-page__icon-button--active { + order: 1; + border-color: rgba(128, 128, 128, 0.3) !important; + background: transparent !important; + color: var(--text-color-medium, #888) !important; +} + +.fkp_monitoring-page #monitoring-pause-toggle.btn.fkp_monitoring-page__icon-button:hover:not(:disabled), +.fkp_monitoring-page #monitoring-pause-toggle.btn.fkp_monitoring-page__icon-button--active:hover:not(:disabled) { + border-color: rgba(128, 128, 128, 0.6) !important; + background: transparent !important; + color: var(--text-color-high, #eee) !important; +} + +.fkp_monitoring-page__icon-button svg, +.fkp_monitoring-page__row-action svg { + width: 16px; + height: 16px; + display: block; + flex: 0 0 auto; +} + +.fkp_monitoring-page__controls { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; + width: 100%; + min-width: 0; +} + +.fkp_monitoring-page__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + +.fkp_monitoring-page__tabs { + display: inline-flex; + align-items: center; + gap: 2px; + width: max-content; + padding: 2px; + border: 1px solid var(--fkp-monitoring-divider-color); + border-radius: 6px; + background: var(--fkp-monitoring-soft-bg); + box-sizing: border-box; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__tab { + height: calc(var(--fkp-monitoring-control-height) - 6px); + min-height: calc(var(--fkp-monitoring-control-height) - 6px); + margin: 0; + padding: 0 12px; + border: 0 !important; + border-radius: 4px; + background: transparent !important; + color: var(--text-color-medium) !important; + box-shadow: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 600; + line-height: 1; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__tab:hover { + background: var(--fkp-monitoring-soft-bg-hover) !important; + color: var(--text-color-high) !important; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__tab--active { + background: rgba(25, 118, 210, 0.16) !important; + color: var(--primary-color-high, #1976d2) !important; + font-weight: 700; +} + +.fkp_monitoring-page__tab-label { + display: inline-block; +} + +.fkp_monitoring-page__tab-badge { + min-width: 18px; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: rgba(127, 127, 127, 0.22); + color: var(--text-color-medium); + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + font-size: 12px; + font-weight: 700; + line-height: 1; +} + +.fkp_monitoring-page__tab--active .fkp_monitoring-page__tab-badge { + background: rgba(25, 118, 210, 0.22); + color: var(--primary-color-high, #1976d2); +} + +.fkp_monitoring-page__filters { + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 12px; + min-width: 0; +} + +.fkp_monitoring-page__device-filter { + width: min(220px, 100%); + min-width: 0; + height: var(--fkp-monitoring-control-height) !important; + min-height: var(--fkp-monitoring-control-height) !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + margin: 0 !important; + box-sizing: border-box; + line-height: calc(var(--fkp-monitoring-control-height) - 2px) !important; +} + +.fkp_monitoring-page__search { + position: relative; + display: flex; + align-items: center; + width: min(320px, 100%); + min-width: 0; + height: var(--fkp-monitoring-control-height); + margin: 0; +} + +.fkp_monitoring-page__search-icon { + position: absolute; + left: 8px; + width: 16px; + height: 16px; + color: var(--text-color-medium); + pointer-events: none; +} + +.fkp_monitoring-page__search-icon svg { + width: 16px; + height: 16px; + display: block; +} + +.fkp_monitoring-page__search-input { + width: 100%; + height: var(--fkp-monitoring-control-height) !important; + min-height: var(--fkp-monitoring-control-height) !important; + padding-left: 30px !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + margin: 0 !important; + box-sizing: border-box; + line-height: calc(var(--fkp-monitoring-control-height) - 2px) !important; +} + +.fkp_monitoring-page__body { + margin-top: 0; + width: 100%; + min-width: 0; +} + +.fkp_monitoring-page__table-wrap { + width: 100%; + overflow-x: auto; + margin-bottom: 0; +} + +.fkp_monitoring-page__table { + width: 100%; + min-width: 840px; + table-layout: fixed; + border-collapse: collapse; + border-spacing: 0; + margin-bottom: 0; +} + +.fkp_monitoring-page__table th, +.fkp_monitoring-page__table td { + padding: 8px 6px; + border-bottom: 1px solid var(--fkp-monitoring-divider-color); + box-sizing: border-box; + text-align: left; + vertical-align: middle; + overflow: hidden; + white-space: nowrap; +} + +.fkp_monitoring-page__table th { + color: var(--text-color-medium); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + white-space: nowrap; + border-bottom-color: rgba(127, 127, 127, 0.32); +} + +.fkp_monitoring-page__table th:nth-child(1) { + width: 28%; +} + +.fkp_monitoring-page__table th:nth-child(2) { + width: 6%; +} + +.fkp_monitoring-page__table th:nth-child(3) { + width: 16%; +} + +.fkp_monitoring-page__table th:nth-child(4) { + width: 8%; +} + +.fkp_monitoring-page__table th:nth-child(5) { + width: 9.5%; +} + +.fkp_monitoring-page__table th:nth-child(6) { + width: 8.5%; +} + +.fkp_monitoring-page__table th:nth-child(7) { + width: 16%; +} + +.fkp_monitoring-page__table th:nth-child(8) { + width: 8%; +} + +.fkp_monitoring-page__table tbody tr:last-child td { + border-bottom: 0; +} + +.fkp_monitoring-page__table td:last-child { + padding-top: 0; + padding-bottom: 0; +} + +.fkp_monitoring-page__table th:nth-child(4), +.fkp_monitoring-page__table td:nth-child(4), +.fkp_monitoring-page__table th:nth-child(5), +.fkp_monitoring-page__table td:nth-child(5), +.fkp_monitoring-page__table th:nth-child(6), +.fkp_monitoring-page__table td:nth-child(6) { + text-align: right; +} + +.fkp_monitoring-page__table th:nth-child(7), +.fkp_monitoring-page__table td:nth-child(7) { + text-align: left; +} + +.fkp_monitoring-page__table th:last-child, +.fkp_monitoring-page__table td:last-child { + text-align: center; +} + +.fkp_monitoring-page__value { + display: block; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + line-height: 1.3; + color: var(--text-color-high); + font-size: 13px; + user-select: text; +} + +.fkp_monitoring-page__source-value { + display: flex; + align-items: baseline; + justify-content: flex-start; + gap: 5px; +} + +.fkp_monitoring-page__source-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fkp_monitoring-page__source-ip { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-color-medium); + font-size: 12px; +} + +.fkp_monitoring-page__source-value--ip-only { + color: var(--text-color-high); +} + +.fkp_monitoring-page__cell-main { + color: var(--text-color-high); + font-weight: 600; + line-height: 1.25; +} + +.fkp_monitoring-page__cell-secondary { + margin-top: 2px; + color: var(--text-color-medium); + font-size: 12px; + line-height: 1.25; +} + +.fkp_monitoring-page__route { + display: inline-block; + width: auto; + padding: 2px 6px; + border-radius: 4px; + background: rgba(128, 128, 128, 0.15); + color: var(--text-color-high, #eee); + font-size: 11px; + font-weight: 500; +} + +.fkp_monitoring-page__network { + background: transparent; + border: 0; + padding: 0; + color: var(--text-color-medium, #bbb); + font-family: inherit; + font-size: 13px; + text-transform: lowercase; +} + +.fkp_monitoring-page__table td:nth-child(4) .fkp_monitoring-page__value, +.fkp_monitoring-page__table td:nth-child(5) .fkp_monitoring-page__value, +.fkp_monitoring-page__table td:nth-child(6) .fkp_monitoring-page__value { + color: var(--text-color-medium, #bbb); + font-family: inherit; + text-align: right; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__row-action { + width: var(--fkp-monitoring-row-action-size); + height: var(--fkp-monitoring-row-action-size); + min-width: var(--fkp-monitoring-row-action-size); + min-height: var(--fkp-monitoring-row-action-size); + padding: 0; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + margin: 0; + border: 0 !important; + border-radius: 999px; + background: transparent !important; + color: var(--fkp-monitoring-danger-color) !important; + box-shadow: none; + cursor: pointer; +} + +.fkp_monitoring-page__row-action svg { + width: 14px; + height: 14px; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__row-action:hover:not(:disabled) { + background: var(--fkp-monitoring-soft-bg-hover) !important; + color: var(--fkp-monitoring-danger-color) !important; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__row-action:disabled { + opacity: 0.45; + cursor: wait; +} + +.fkp_monitoring-page__row--closing { + opacity: 0.55; +} + +.fkp_monitoring-page__state { + min-height: 90px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-color-medium); + text-align: center; + box-sizing: border-box; +} + +.fkp_monitoring-page__state-cell { + padding: 0 !important; +} + +.fkp_monitoring-page__state--error { + color: var(--error-color-medium, #d32f2f); +} + +@media (max-width: 900px) { + .fkp_monitoring-page__controls { + align-items: center; + } + + .fkp_monitoring-page__tabs { + flex: 1 0 100%; + } + + .fkp_monitoring-page__filters { + flex: 1 1 0; + } + + .fkp_monitoring-page__device-filter, + .fkp_monitoring-page__search { + max-width: none; + } + + .fkp_monitoring-page__table { + min-width: 0; + } + + .fkp_monitoring-page__table thead { + display: none; + } + + .fkp_monitoring-page__table, + .fkp_monitoring-page__table tbody, + .fkp_monitoring-page__table tr, + .fkp_monitoring-page__table td { + display: block; + width: 100%; + } + + .fkp_monitoring-page__table tr { + border: 1px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 8px; + box-sizing: border-box; + margin-bottom: 8px; + } + + .fkp_monitoring-page__table td { + display: grid; + grid-template-columns: minmax(92px, 34%) minmax(0, 1fr); + gap: 8px; + border: 0; + border-bottom: 1px solid var(--fkp-monitoring-divider-color); + padding: 4px 0; + box-sizing: border-box; + text-align: left; + } + + .fkp_monitoring-page__table td::before { + content: attr(data-label); + color: var(--text-color-medium); + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .fkp_monitoring-page__table td:last-child { + grid-template-columns: minmax(92px, 34%) minmax(0, 1fr); + align-items: center; + border-bottom: 0; + min-height: var(--fkp-monitoring-row-action-size); + padding: 0; + } + + .fkp_monitoring-page__value { + text-align: right; + } + + .fkp_monitoring-page__source-value { + justify-content: flex-end; + } + + .fkp_monitoring-page__state-row td::before { + display: none; + } +} + +@media (max-width: 520px) { + .fkp_monitoring-page__controls, + .fkp_monitoring-page__filters { + align-items: stretch; + } + + .fkp_monitoring-page__tabs, + .fkp_monitoring-page__filters, + .fkp_monitoring-page__device-filter, + .fkp_monitoring-page__search { + width: 100%; + } + + .fkp_monitoring-page__controls, + .fkp_monitoring-page__filters { + flex-direction: column; + } + + .fkp_monitoring-page__actions { + align-self: flex-end; + } + + .fkp_monitoring-page__tabs { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + width: 100%; + } + + .fkp_monitoring-page__table td { + grid-template-columns: 1fr; + gap: 2px; + } + + .fkp_monitoring-page__value { + text-align: left; + } + + .fkp_monitoring-page__source-value { + justify-content: flex-start; + } +} +`; diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/checkResultLifecycle.ts b/fe-app-tachyon/src/tachyon/tabs/updates/checkResultLifecycle.ts new file mode 100644 index 00000000..8806bcff --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/updates/checkResultLifecycle.ts @@ -0,0 +1,45 @@ +import type { Tachyon } from '../../types'; + +export function shouldPreserveCompletedCheckResultOnNextMount({ + action, + mounted, +}: { + action: Tachyon.ComponentAction; + mounted: boolean; +}) { + return action === 'check_update' && !mounted; +} + +export function shouldResetCheckResultsOnMount({ + anyActionLoading, + preserveCheckResultsOnNextMount, + persistentCacheEnabled = false, +}: { + anyActionLoading: boolean; + preserveCheckResultsOnNextMount: boolean; + persistentCacheEnabled?: boolean; +}) { + return ( + !persistentCacheEnabled && + !anyActionLoading && + !preserveCheckResultsOnNextMount + ); +} + +export function shouldRefreshComponentStateBeforeRender( + uiState?: Pick, +) { + return Boolean( + uiState?.actions.component.some((state) => state.running === true), + ); +} + +export function shouldExposeCheckResults({ + mounted, + cacheResolved, +}: { + mounted: boolean; + cacheResolved: boolean; +}) { + return mounted && cacheResolved; +} diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/componentActionCompletion.ts b/fe-app-tachyon/src/tachyon/tabs/updates/componentActionCompletion.ts new file mode 100644 index 00000000..6f09c1c7 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/updates/componentActionCompletion.ts @@ -0,0 +1,8 @@ +import type { Tachyon } from '../../types'; + +export function shouldApplyCompletedComponentActionResult( + result: Pick, + notify: boolean, +) { + return result.action !== 'check_update' || notify; +} diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/index.ts b/fe-app-tachyon/src/tachyon/tabs/updates/index.ts new file mode 100644 index 00000000..31c2b9fa --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/updates/index.ts @@ -0,0 +1,9 @@ +import { render } from './render'; +import { initController } from './initController'; +import { styles } from './styles'; + +export const UpdatesTab = { + render, + initController, + styles, +}; diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts b/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts new file mode 100644 index 00000000..c7548719 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts @@ -0,0 +1,1352 @@ +import { onMount, preserveScrollForPage } from '../../../helpers'; +import { TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT } from '../../../constants'; +import { normalizeCompiledVersion } from '../../../helpers/normalizeCompiledVersion'; +import { showToast } from '../../../helpers/showToast'; +import { + renderDownloadIcon24, + renderRotateCcwIcon24, + renderSearchIcon24, + renderXIcon24, +} from '../../../icons'; +import { renderButton } from '../../../partials'; +import { getComponentActionKey } from '../../helpers/getComponentActionKey'; +import type { UpdatesActionKey } from '../../helpers/getComponentActionKey'; +import { isTransientRpcError } from '../../helpers/isTransientRpcError'; +import { isActiveLuciTab } from '../../helpers/isActiveLuciTab'; +import { shouldShowLoadingForRestoredAction } from '../../helpers/restoredActionLoading'; +import { + formatSingBoxVersion, + normalizeSingBoxVariantFields, +} from '../../helpers/singBoxVariant'; +import { + hasLocalMutatingServiceActionLoading, + isServiceTransitionStatus, +} from '../diagnostic/serviceTransition'; +import { shouldApplyCompletedComponentActionResult } from './componentActionCompletion'; +import { + shouldPreserveCompletedCheckResultOnNextMount, + shouldExposeCheckResults, + shouldRefreshComponentStateBeforeRender, + shouldResetCheckResultsOnMount, +} from './checkResultLifecycle'; +import { TachyonShellMethods } from '../../methods'; +import { + logger, + markUiActionOwned, + setLocalComponentAction, + shouldNotifyOwnedUiAction, + store, + StoreType, +} from '../../services'; +import { ensureSystemInfo } from '../../services/systemInfo.service'; +import { + getCachedRuntimeUiState, + refreshRuntimeUiState, + subscribeRuntimeUiState, +} from '../../services/runtimeUiState.service'; +import { Tachyon } from '../../types'; + +type UpdateStatus = StoreType['updatesChecks'][Tachyon.ComponentName]['status']; + +interface ComponentActionButton { + key: UpdatesActionKey; + text: string; + icon: () => SVGSVGElement; + component: Tachyon.ComponentName; + action: Tachyon.ComponentAction; +} + +interface ComponentCard { + component: Tachyon.ComponentName; + column: 0 | 1; + title: string; + version: string; + latestVersion?: string; + releaseUrl?: string; + actions: ComponentActionButton[]; +} + +let updatesLifecycleRegistered = false; +let updatesControllerInitialized = false; +let updatesMounted = false; +let updatesMountId = 0; +let pageUnloading = false; +let preserveCheckResultsOnNextMount = false; +let componentUpdateCheckCacheResolved = false; +let componentUpdateCheckCacheSnapshot: Tachyon.ComponentUpdateCheckCache | null = + null; +let componentUpdateCheckCachePromise: Promise | null = + null; +let componentActionStateUnsubscribe: (() => void) | null = null; +let componentActionStateRefreshPromise: Promise | null = null; +const followedComponentJobs = new Set(); +const handledComponentJobs = new Set(); + +if (typeof window !== 'undefined') { + window.addEventListener('pagehide', () => { + pageUnloading = true; + }); + window.addEventListener('pageshow', () => { + pageUnloading = false; + }); +} + +function isNotInstalled(version: string | undefined) { + return !version || version === 'not installed'; +} + +function shouldShowInstallAfterCheck(component: Tachyon.ComponentName) { + const status = getVisibleCheckResult(component)?.status; + + return status === 'outdated' || status === 'dev'; +} + +function getVisibleCheckResult(component: Tachyon.ComponentName) { + if ( + !shouldExposeCheckResults({ + mounted: updatesMounted, + cacheResolved: componentUpdateCheckCacheResolved, + }) + ) { + return null; + } + + return store.get().updatesChecks[component]; +} + +function getLatestVersion(component: Tachyon.ComponentName) { + const checkResult = getVisibleCheckResult(component); + + if (!checkResult || !shouldShowInstallAfterCheck(component)) { + return undefined; + } + + return checkResult.latest_version || undefined; +} + +function getGitHubReleaseUrl(component: Tachyon.ComponentName) { + const checkResult = getVisibleCheckResult(component); + + if ( + !checkResult || + !shouldShowInstallAfterCheck(component) || + !checkResult.release_url + ) { + return undefined; + } + + return checkResult.release_url; +} + +function isAnyActionLoading() { + return Object.values(store.get().updatesActions).some((item) => item.loading); +} + +function isServiceRuntimeActionLoading() { + const state = store.get(); + + return ( + hasLocalMutatingServiceActionLoading(state.diagnosticsActions) || + isServiceTransitionStatus(state.servicesInfoWidget.data.tachyonStatus) + ); +} + +function isSystemInfoLoading() { + const systemInfo = store.get().diagnosticsSystemInfo; + + return systemInfo.loading || !systemInfo.loaded; +} + +function setActionLoading( + action: UpdatesActionKey, + loading: boolean, + local = false, +) { + if (local || !loading) { + setLocalComponentAction(action, loading && local); + } + + const updatesActions = store.get().updatesActions; + + store.set({ + updatesActions: { + ...updatesActions, + [action]: { loading }, + }, + }); +} + +function beginComponentAction(button: ComponentActionButton) { + if (isAnyActionLoading()) { + return false; + } + + setActionLoading(button.key, true, true); + return true; +} + +function setCheckResult( + component: Tachyon.ComponentName, + status: UpdateStatus, + latestVersion: string, + releaseUrl: string = '', +) { + const updatesChecks = store.get().updatesChecks; + + store.set({ + updatesChecks: { + ...updatesChecks, + [component]: { + status, + latest_version: latestVersion, + release_url: releaseUrl, + }, + }, + }); +} + +function resetCheckResult(component: Tachyon.ComponentName) { + setCheckResult(component, null, ''); +} + +function applyCachedCheckResults(results: Tachyon.ComponentActionResult[]) { + results.forEach((result) => { + const status = result.status || null; + + if (status === 'latest' || status === 'outdated' || status === 'dev') { + setCheckResult( + result.component, + status, + result.latest_version || '', + result.release_url || '', + ); + } + }); +} + +function loadComponentUpdateCheckCache({ force = false } = {}) { + if (!force && componentUpdateCheckCacheSnapshot) { + return Promise.resolve(componentUpdateCheckCacheSnapshot); + } + + if (componentUpdateCheckCachePromise) { + return componentUpdateCheckCachePromise; + } + + const promise = TachyonShellMethods.componentUpdateCheckCache() + .then((response) => + response.success + ? response.data + : ({ + enabled: false, + results: [], + } satisfies Tachyon.ComponentUpdateCheckCache), + ) + .then((cache) => { + componentUpdateCheckCacheSnapshot = cache; + return cache; + }) + .finally(() => { + if (componentUpdateCheckCachePromise === promise) { + componentUpdateCheckCachePromise = null; + } + }); + + componentUpdateCheckCachePromise = promise; + return promise; +} + +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +async function ackComponentActionJob(jobId: string) { + try { + const response = await TachyonShellMethods.uiActionAck('component', jobId); + + if (!response.success) { + logger.debug('[UPDATES]', 'component action ack failed', response.error); + } + } catch (error) { + logger.debug('[UPDATES]', 'component action ack failed', error); + } +} + +function getExpectedLatestVersionForAction(button: ComponentActionButton) { + if (button.component !== 'tachyon' || button.action !== 'install') { + return undefined; + } + + return ( + store.get().updatesChecks[button.component].latest_version || undefined + ); +} + +function getCheckToastMessage(status: UpdateStatus) { + if (status === 'outdated') { + return _('Update is available'); + } + + if (status === 'dev') { + return _('Installed version is newer than release'); + } + + return _('Latest version is installed'); +} + +async function refreshSystemInfoAfterMutation() { + await ensureSystemInfo({ force: true, silent: true }); +} + +function notifyActionProvidersAvailabilityChanged( + systemInfo: StoreType['diagnosticsSystemInfo'], +) { + if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') { + return; + } + + window.dispatchEvent( + new CustomEvent(TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT, { + detail: { + zapretInstalled: Boolean(systemInfo.zapret_installed), + zapret2Installed: Boolean(systemInfo.zapret2_installed), + byedpiInstalled: Boolean(systemInfo.byedpi_installed), + }, + }), + ); +} + +function reloadPageAfterTachyonUpdate() { + window.setTimeout(() => { + window.location.reload(); + }, 1200); +} + +function patchSystemInfoAfterMutation(result: Tachyon.ComponentActionResult) { + const systemInfo = store.get().diagnosticsSystemInfo; + const nextSystemInfo = { ...systemInfo, loading: false, loaded: true }; + const version = + result.current_version || result.latest_version || _('unknown'); + + if (result.component === 'tachyon' && result.action === 'install') { + nextSystemInfo.tachyon_version = version; + } + + if (result.component === 'sing_box') { + nextSystemInfo.sing_box_version = version; + + if (result.action === 'install_extended') { + nextSystemInfo.sing_box_extended = 1; + nextSystemInfo.sing_box_tiny = 0; + nextSystemInfo.sing_box_compressed = 0; + nextSystemInfo.sing_box_tailscale = 1; + } + + if (result.action === 'install_extended_compressed') { + nextSystemInfo.sing_box_extended = 1; + nextSystemInfo.sing_box_tiny = 0; + nextSystemInfo.sing_box_compressed = 1; + nextSystemInfo.sing_box_tailscale = 1; + } + + if (result.action === 'install_stable') { + nextSystemInfo.sing_box_extended = 0; + nextSystemInfo.sing_box_tiny = 0; + nextSystemInfo.sing_box_compressed = 0; + nextSystemInfo.sing_box_tailscale = 1; + } + + if (result.action === 'install_tiny') { + nextSystemInfo.sing_box_extended = 0; + nextSystemInfo.sing_box_tiny = 1; + nextSystemInfo.sing_box_compressed = 0; + nextSystemInfo.sing_box_tailscale = 0; + } + } + + if (result.component === 'zapret') { + nextSystemInfo.providerInfoLoaded = true; + + if (result.action === 'remove') { + nextSystemInfo.zapret_installed = 0; + nextSystemInfo.zapret_version = 'not installed'; + } else { + nextSystemInfo.zapret_installed = 1; + nextSystemInfo.zapret_version = version; + } + } + + if (result.component === 'zapret2') { + nextSystemInfo.providerInfoLoaded = true; + + if (result.action === 'remove') { + nextSystemInfo.zapret2_installed = 0; + nextSystemInfo.zapret2_version = 'not installed'; + } else { + nextSystemInfo.zapret2_installed = 1; + nextSystemInfo.zapret2_version = version; + } + } + + if (result.component === 'byedpi') { + nextSystemInfo.providerInfoLoaded = true; + + if (result.action === 'remove') { + nextSystemInfo.byedpi_installed = 0; + nextSystemInfo.byedpi_version = 'not installed'; + } else { + nextSystemInfo.byedpi_installed = 1; + nextSystemInfo.byedpi_version = version; + } + } + + const normalizedSystemInfo = normalizeSingBoxVariantFields(nextSystemInfo); + + store.set({ + diagnosticsSystemInfo: normalizedSystemInfo, + }); + + if ( + result.component === 'zapret' || + result.component === 'zapret2' || + result.component === 'byedpi' + ) { + notifyActionProvidersAvailabilityChanged(normalizedSystemInfo); + } +} + +async function applyCompletedComponentAction({ + key, + result, + notify, +}: { + key: UpdatesActionKey; + result: Tachyon.ComponentActionResult; + notify: boolean; +}) { + if (result.action === 'check_update') { + setActionLoading(key, false); + + if (!shouldApplyCompletedComponentActionResult(result, notify)) { + return; + } + + if ( + shouldPreserveCompletedCheckResultOnNextMount({ + action: result.action, + mounted: updatesMounted, + }) + ) { + preserveCheckResultsOnNextMount = true; + } + + const status = result.status || null; + + if (status === 'latest' || status === 'outdated' || status === 'dev') { + setCheckResult( + result.component, + status, + result.latest_version || '', + result.release_url || '', + ); + } + + if (notify) { + showToast(getCheckToastMessage(status), 'success'); + } + return; + } + + if (result.action === 'install' || result.action.startsWith('install_')) { + setCheckResult(result.component, 'latest', result.latest_version || ''); + } else { + resetCheckResult(result.component); + } + + patchSystemInfoAfterMutation(result); + setActionLoading(key, false); + + if (result.component === 'tachyon' && result.action === 'install') { + if (notify && result.message) { + showToast(result.message, 'success', 1200); + } + + if (notify) { + reloadPageAfterTachyonUpdate(); + } + return; + } + + if (notify && result.message) { + showToast(result.message, 'success'); + } + + void refreshSystemInfoAfterMutation(); +} + +async function completeComponentActionJob( + key: UpdatesActionKey, + jobId: string, + response: Tachyon.MethodResponse, +) { + if (pageUnloading) { + setActionLoading(key, false); + return; + } + + const alreadyHandled = handledComponentJobs.has(jobId); + + if (alreadyHandled) { + setActionLoading(key, false); + return; + } + + const shouldNotify = shouldNotifyOwnedUiAction('component', jobId); + + if (!response.success || response.data.success === false) { + const message = response.success + ? response.data.message || _('Failed to execute') + : response.error || _('Failed to execute'); + + if (isTransientRpcError(message)) { + setActionLoading(key, false); + void refreshComponentActionState(); + return; + } + + handledComponentJobs.add(jobId); + setActionLoading(key, false); + if (shouldNotify) { + showToast(message, 'error'); + } + await ackComponentActionJob(jobId); + return; + } + + handledComponentJobs.add(jobId); + await ackComponentActionJob(jobId); + await applyCompletedComponentAction({ + key, + result: response.data, + notify: shouldNotify, + }); +} + +async function followComponentActionState(state: Tachyon.ComponentActionResult) { + const jobId = state.job_id; + const key = getComponentActionKey(state.component, state.action); + + if (!jobId || !key || followedComponentJobs.has(jobId)) { + return; + } + + if (!state.running && handledComponentJobs.has(jobId)) { + return; + } + + followedComponentJobs.add(jobId); + if (shouldShowLoadingForRestoredAction(state)) { + setActionLoading(key, true); + } + + try { + const response = state.running + ? await TachyonShellMethods.waitComponentActionJob( + jobId, + state.component, + state.action, + state.latest_version || undefined, + ) + : ({ + success: true, + data: state, + } as Tachyon.MethodSuccessResponse); + + await completeComponentActionJob(key, jobId, response); + } catch (error) { + logger.error('[UPDATES]', 'followComponentActionState failed', error); + if (!pageUnloading) { + const message = getErrorMessage(error, _('Failed to execute')); + + setActionLoading(key, false); + if (!isTransientRpcError(message)) { + showToast(message, 'error'); + } + } + } finally { + followedComponentJobs.delete(jobId); + } +} + +async function followAlreadyRunningComponentAction( + button: ComponentActionButton, +) { + const uiState = await refreshRuntimeUiState({ force: true }); + + if (!uiState) { + return false; + } + + const state = uiState.actions.component.find( + (item) => + item.running && + item.component === button.component && + item.action === button.action, + ); + + if (!state) { + return false; + } + + if (state.job_id) { + markUiActionOwned('component', state.job_id); + } + await followComponentActionState(state); + return true; +} + +function isComponentActionAlreadyRunningError(message: string | undefined) { + return Boolean( + message && message.includes('Another component action is already running'), + ); +} + +function handleComponentUiState(uiState: Tachyon.UiState) { + for (const state of uiState.actions.component || []) { + void followComponentActionState(state); + } +} + +async function refreshComponentActionState() { + if (componentActionStateRefreshPromise) { + return componentActionStateRefreshPromise; + } + + componentActionStateRefreshPromise = (async () => { + if (!updatesMounted) { + return; + } + + const state = await refreshRuntimeUiState({ force: true }); + + if (!state) { + return; + } + + handleComponentUiState(state); + })().finally(() => { + componentActionStateRefreshPromise = null; + }); + + return componentActionStateRefreshPromise; +} + +function startComponentActionStateWatcher() { + if (componentActionStateUnsubscribe) { + return; + } + + componentActionStateUnsubscribe = subscribeRuntimeUiState((uiState) => { + if (updatesMounted) { + handleComponentUiState(uiState); + } + }); +} + +function stopComponentActionStateWatcher() { + if (!componentActionStateUnsubscribe) { + return; + } + + componentActionStateUnsubscribe(); + componentActionStateUnsubscribe = null; +} + +async function handleComponentAction(button: ComponentActionButton) { + if (!beginComponentAction(button)) { + return; + } + + let jobId = ''; + let ownsJobFollow = false; + + try { + const startResponse = await TachyonShellMethods.componentActionStart( + button.component, + button.action, + ); + + if (!startResponse.success) { + if (isComponentActionAlreadyRunningError(startResponse.error)) { + setActionLoading(button.key, false); + if (!(await followAlreadyRunningComponentAction(button))) { + await refreshComponentActionState(); + } + return; + } + + if (isTransientRpcError(startResponse.error)) { + if (!(await followAlreadyRunningComponentAction(button))) { + setActionLoading(button.key, false); + await refreshComponentActionState(); + } + return; + } + + throw new Error(startResponse.error); + } + + jobId = startResponse.data.job_id; + markUiActionOwned('component', jobId); + if (followedComponentJobs.has(jobId)) { + return; + } + + followedComponentJobs.add(jobId); + ownsJobFollow = true; + + const response = await TachyonShellMethods.waitComponentActionJob( + jobId, + button.component, + button.action, + getExpectedLatestVersionForAction(button), + ); + + await completeComponentActionJob(button.key, jobId, response); + } catch (error) { + logger.error('[UPDATES]', 'handleComponentAction failed', error); + if (!pageUnloading) { + const message = getErrorMessage(error, _('Failed to execute')); + + setActionLoading(button.key, false); + if (!isTransientRpcError(message)) { + showToast(message, 'error'); + } + void refreshComponentActionState(); + } + } finally { + if (ownsJobFollow) { + followedComponentJobs.delete(jobId); + } + } +} + +function getCheckAction( + component: Tachyon.ComponentName, + key: UpdatesActionKey, +): ComponentActionButton { + return { + key, + text: _('Check update'), + icon: renderSearchIcon24, + component, + action: 'check_update', + }; +} + +function getInstallAction( + component: Tachyon.ComponentName, + key: UpdatesActionKey, + installed: boolean, +): ComponentActionButton { + return { + key, + text: installed ? _('Update') : _('Install'), + icon: installed ? renderRotateCcwIcon24 : renderDownloadIcon24, + component, + action: 'install', + }; +} + +function getInstalledUpdateActions( + component: Tachyon.ComponentName, + checkKey: UpdatesActionKey, + installKey: UpdatesActionKey, + installed = true, +) { + if (!installed) { + return []; + } + + const actions = [getCheckAction(component, checkKey)]; + if (shouldShowInstallAfterCheck(component)) { + actions.push(getInstallAction(component, installKey, true)); + } + return actions; +} + +function getOptionalComponentActions({ + component, + installed, + checkKey, + installKey, + removeKey, +}: { + component: 'zapret' | 'zapret2' | 'byedpi'; + installed: boolean; + checkKey: UpdatesActionKey; + installKey: UpdatesActionKey; + removeKey: UpdatesActionKey; +}) { + if (!installed) { + return [getInstallAction(component, installKey, false)]; + } + + return [ + ...getInstalledUpdateActions(component, checkKey, installKey), + { + key: removeKey, + text: _('Remove'), + icon: renderXIcon24, + component, + action: 'remove' as const, + }, + ]; +} + +function getComponentCards(): ComponentCard[] { + const systemInfo = normalizeSingBoxVariantFields( + store.get().diagnosticsSystemInfo, + ); + const systemInfoLoading = isSystemInfoLoading(); + const zapretInstalled = Boolean(systemInfo.zapret_installed); + const zapret2Installed = Boolean(systemInfo.zapret2_installed); + const byedpiInstalled = Boolean(systemInfo.byedpi_installed); + const singBoxInstalled = !isNotInstalled(systemInfo.sing_box_version); + const singBoxStable = + singBoxInstalled && + !systemInfo.sing_box_extended && + !systemInfo.sing_box_tiny; + const singBoxExtended = + Boolean(systemInfo.sing_box_extended) && !systemInfo.sing_box_compressed; + const singBoxExtendedCompressed = + Boolean(systemInfo.sing_box_extended) && + Boolean(systemInfo.sing_box_compressed); + const singBoxTiny = Boolean(systemInfo.sing_box_tiny); + + const tachyonActions = getInstalledUpdateActions( + 'tachyon', + 'tachyonCheck', + 'tachyonInstall', + ); + const singBoxActions = getInstalledUpdateActions( + 'sing_box', + 'singBoxCheck', + 'singBoxInstall', + singBoxInstalled, + ); + + // Add Sing-box variant actions (only show names, no 'Install' prefix) + if (!singBoxStable) { + singBoxActions.push({ + key: 'singBoxInstallStable', + text: 'Stable', + icon: renderDownloadIcon24, + component: 'sing_box', + action: 'install_stable', + }); + } + if (!singBoxTiny) { + singBoxActions.push({ + key: 'singBoxInstallTiny', + text: 'Tiny', + icon: renderDownloadIcon24, + component: 'sing_box', + action: 'install_tiny', + }); + } + if (!singBoxExtended) { + singBoxActions.push({ + key: 'singBoxInstallExtended', + text: 'Extended', + icon: renderDownloadIcon24, + component: 'sing_box', + action: 'install_extended', + }); + } + if (!singBoxExtendedCompressed) { + singBoxActions.push({ + key: 'singBoxInstallExtendedCompressed', + text: 'Extended compressed', + icon: renderDownloadIcon24, + component: 'sing_box', + action: 'install_extended_compressed', + }); + } + + const zapretActions = getOptionalComponentActions({ + component: 'zapret', + installed: zapretInstalled, + checkKey: 'zapretCheck', + installKey: 'zapretInstall', + removeKey: 'zapretRemove', + }); + const zapret2Actions = getOptionalComponentActions({ + component: 'zapret2', + installed: zapret2Installed, + checkKey: 'zapret2Check', + installKey: 'zapret2Install', + removeKey: 'zapret2Remove', + }); + const byedpiActions = getOptionalComponentActions({ + component: 'byedpi', + installed: byedpiInstalled, + checkKey: 'byedpiCheck', + installKey: 'byedpiInstall', + removeKey: 'byedpiRemove', + }); + + return [ + { + component: 'tachyon', + column: 0, + title: 'Tachyon', + version: systemInfoLoading + ? _('Loading...') + : normalizeCompiledVersion(systemInfo.tachyon_version), + latestVersion: getLatestVersion('tachyon'), + releaseUrl: getGitHubReleaseUrl('tachyon'), + actions: tachyonActions, + }, + { + component: 'sing_box', + column: 0, + title: 'Sing-box', + version: systemInfoLoading + ? _('Loading...') + : formatSingBoxVersion(systemInfo), + latestVersion: getLatestVersion('sing_box'), + releaseUrl: getGitHubReleaseUrl('sing_box'), + actions: singBoxActions, + }, + { + component: 'zapret', + column: 1, + title: 'Zapret', + version: systemInfoLoading + ? _('Loading...') + : zapretInstalled + ? systemInfo.zapret_version + : _('Not installed'), + latestVersion: getLatestVersion('zapret'), + releaseUrl: getGitHubReleaseUrl('zapret'), + actions: zapretActions, + }, + { + component: 'zapret2', + column: 1, + title: 'Zapret2', + version: systemInfoLoading + ? _('Loading...') + : zapret2Installed + ? systemInfo.zapret2_version + : _('Not installed'), + latestVersion: getLatestVersion('zapret2'), + releaseUrl: getGitHubReleaseUrl('zapret2'), + actions: zapret2Actions, + }, + { + component: 'byedpi', + column: 1, + title: 'ByeDPI', + version: systemInfoLoading + ? _('Loading...') + : byedpiInstalled + ? systemInfo.byedpi_version + : _('Not installed'), + latestVersion: getLatestVersion('byedpi'), + releaseUrl: getGitHubReleaseUrl('byedpi'), + actions: byedpiActions, + }, + ]; +} + +function renderComponentCard(card: ComponentCard) { + const updatesActions = store.get().updatesActions; + const anyActionLoading = isAnyActionLoading(); + const serviceRuntimeActionLoading = isServiceRuntimeActionLoading(); + const systemInfoLoading = isSystemInfoLoading(); + + // 1. Header (displays Title, Current Version, no badges) + const headerChildren: Node[] = [ + E('b', { class: 'fkp_updates-page__component__title' }, card.title), + E( + 'span', + { class: 'fkp_updates-page__component__header-version' }, + card.version, + ), + ]; + const header = E( + 'div', + { class: 'fkp_updates-page__component__header' }, + headerChildren, + ); + + // 2. Details (renders status messages for check results) + const detailsChildren: Node[] = []; + const checkResult = getVisibleCheckResult(card.component); + + if (checkResult && checkResult.status) { + let labelText = ''; + const latestValueNodes: Node[] = []; + + if (checkResult.status === 'outdated') { + labelText = _('Update is available:'); + const versionToShow = + checkResult.latest_version || card.latestVersion || card.version; + + if (checkResult.release_url) { + latestValueNodes.push( + E( + 'a', + { + class: 'fkp_updates-page__component__release-version-link', + href: checkResult.release_url, + target: '_blank', + rel: 'noopener noreferrer', + }, + versionToShow || _('Open'), + ), + ); + } else if (versionToShow) { + latestValueNodes.push(document.createTextNode(versionToShow)); + } + } else if (checkResult.status === 'latest') { + labelText = _('Latest version is installed'); + } else if (checkResult.status === 'dev') { + labelText = `${_('Installed version is newer than release')}. ${_('Latest version:')}`; + const versionToShow = checkResult.latest_version || card.latestVersion; + + if (checkResult.release_url) { + latestValueNodes.push( + E( + 'a', + { + class: 'fkp_updates-page__component__release-version-link', + href: checkResult.release_url, + target: '_blank', + rel: 'noopener noreferrer', + }, + versionToShow || _('Open'), + ), + ); + } else if (versionToShow) { + latestValueNodes.push(document.createTextNode(versionToShow)); + } + } + + if (labelText) { + const rowChildren: Node[] = [ + E( + 'span', + { class: 'fkp_updates-page__component__info-label' }, + labelText, + ), + ]; + if (latestValueNodes.length > 0) { + rowChildren.push( + E( + 'span', + { + class: + 'fkp_updates-page__component__info-value fkp_updates-page__component__info-value--latest', + }, + latestValueNodes, + ), + ); + } + + detailsChildren.push( + E( + 'div', + { class: 'fkp_updates-page__component__info-row' }, + rowChildren, + ), + ); + } + } + + const detailsContainer = + detailsChildren.length > 0 + ? E( + 'div', + { class: 'fkp_updates-page__component__details' }, + detailsChildren, + ) + : null; + + // 3. Actions classification + const primaryActions: ComponentActionButton[] = []; + const dangerActions: ComponentActionButton[] = []; + const variantActions: ComponentActionButton[] = []; + + card.actions.forEach((action) => { + if (action.action === 'remove') { + dangerActions.push(action); + } else if (action.action.startsWith('install_')) { + variantActions.push(action); + } else { + primaryActions.push(action); + } + }); + + const actionElements: Node[] = []; + + // Render primary and danger buttons in a main row + const primaryButtons = primaryActions.map((action) => { + const loading = updatesActions[action.key].loading; + const isUpdateOrInstall = action.action === 'install'; + + return renderButton({ + classNames: isUpdateOrInstall ? ['cbi-button-save'] : [], + text: action.text, + icon: action.icon, + loading, + disabled: + systemInfoLoading || + serviceRuntimeActionLoading || + (anyActionLoading && !loading), + onClick: () => void handleComponentAction(action), + }); + }); + + const dangerButtons = dangerActions.map((action) => { + const loading = updatesActions[action.key].loading; + + return renderButton({ + classNames: ['cbi-button-remove'], + text: action.text, + icon: action.icon, + loading, + disabled: + systemInfoLoading || + serviceRuntimeActionLoading || + (anyActionLoading && !loading), + onClick: () => void handleComponentAction(action), + }); + }); + + if (primaryButtons.length > 0 || dangerButtons.length > 0) { + actionElements.push( + E('div', { class: 'fkp_updates-page__component__actions-main' }, [ + ...primaryButtons, + ...dangerButtons, + ]), + ); + } + + // Render variant buttons if any + if (variantActions.length > 0) { + const variantButtons = variantActions.map((action) => { + const loading = updatesActions[action.key].loading; + return renderButton({ + text: action.text, + icon: action.icon, + loading, + disabled: + systemInfoLoading || + serviceRuntimeActionLoading || + (anyActionLoading && !loading), + onClick: () => void handleComponentAction(action), + }); + }); + + actionElements.push( + E('div', { class: 'fkp_updates-page__component__variants' }, [ + E( + 'div', + { class: 'fkp_updates-page__component__variants-title' }, + _('Install another build:'), + ), + E( + 'div', + { class: 'fkp_updates-page__component__variants-buttons' }, + variantButtons, + ), + ]), + ); + } + + const actionsContainer = E( + 'div', + { + class: [ + 'fkp_updates-page__component__actions', + detailsContainer + ? 'fkp_updates-page__component__actions--with-details' + : '', + ] + .filter(Boolean) + .join(' '), + }, + actionElements, + ); + + const cardChildren: Node[] = [header]; + if (detailsContainer) { + cardChildren.push(detailsContainer); + } + cardChildren.push(actionsContainer); + + return E('div', { class: 'fkp_updates-page__component' }, cardChildren); +} + +function renderUpdatesComponents() { + const container = document.getElementById('fkp_updates-components'); + + if (!container) { + return; + } + + const columns = [[], []] as Node[][]; + getComponentCards().forEach((card) => { + columns[card.column].push(renderComponentCard(card)); + }); + + return preserveScrollForPage(() => { + container.replaceChildren( + E('div', { class: 'fkp_updates-page__components-column' }, columns[0]), + E('div', { class: 'fkp_updates-page__components-column' }, columns[1]), + ); + }); +} + +function onStoreUpdate( + _next: StoreType, + _prev: StoreType, + diff: Partial, +) { + if ( + diff.diagnosticsSystemInfo || + diff.updatesActions || + diff.updatesChecks || + diff.diagnosticsActions || + diff.servicesInfoWidget + ) { + renderUpdatesComponents(); + } +} + +function applyComponentUpdateCheckCache( + componentUpdateCheckCache: Tachyon.ComponentUpdateCheckCache, +) { + componentUpdateCheckCacheResolved = true; + + if (componentUpdateCheckCache.enabled) { + store.reset(['updatesChecks']); + applyCachedCheckResults(componentUpdateCheckCache.results); + } + + if ( + shouldResetCheckResultsOnMount({ + anyActionLoading: isAnyActionLoading(), + preserveCheckResultsOnNextMount, + persistentCacheEnabled: componentUpdateCheckCache.enabled, + }) + ) { + store.reset(['updatesChecks']); + } +} + +async function onPageMount() { + onPageUnmount(); + + updatesMounted = true; + updatesMountId += 1; + const mountId = updatesMountId; + const cachedRuntimeState = getCachedRuntimeUiState(); + const hasRuntimeSnapshot = Boolean(cachedRuntimeState); + const needsFreshStateBeforeRender = + shouldRefreshComponentStateBeforeRender(cachedRuntimeState); + const runtimeStateRefreshPromise = + !hasRuntimeSnapshot || needsFreshStateBeforeRender + ? refreshRuntimeUiState({ force: true }) + : null; + const prefetchedComponentUpdateCheckCache = componentUpdateCheckCacheSnapshot; + + if (prefetchedComponentUpdateCheckCache) { + applyComponentUpdateCheckCache(prefetchedComponentUpdateCheckCache); + } + + renderUpdatesComponents(); + + const componentUpdateCheckCache = await loadComponentUpdateCheckCache({ + force: Boolean(prefetchedComponentUpdateCheckCache), + }); + + if (!updatesMounted || mountId !== updatesMountId) { + return; + } + + applyComponentUpdateCheckCache(componentUpdateCheckCache); + preserveCheckResultsOnNextMount = false; + renderUpdatesComponents(); + + if (runtimeStateRefreshPromise) { + await runtimeStateRefreshPromise; + + if (!updatesMounted || mountId !== updatesMountId) { + return; + } + } + + store.subscribe(onStoreUpdate); + startComponentActionStateWatcher(); + renderUpdatesComponents(); + void ensureSystemInfo(); + if (hasRuntimeSnapshot) { + void refreshRuntimeUiState({ force: true }); + } +} + +function onPageUnmount() { + updatesMounted = false; + updatesMountId += 1; + stopComponentActionStateWatcher(); + store.unsubscribe(onStoreUpdate); +} + +function registerLifecycleListeners() { + if (updatesLifecycleRegistered) { + return; + } + + updatesLifecycleRegistered = true; + + store.subscribe((next, prev, diff) => { + if ( + diff.tabService && + next.tabService.current !== prev.tabService.current + ) { + const isUpdatesVisible = next.tabService.current === 'updates'; + + if (isUpdatesVisible) { + return onPageMount(); + } + + if (updatesMounted) { + return onPageUnmount(); + } + } + }); +} + +export async function initController(): Promise { + if (updatesControllerInitialized) { + return; + } + + updatesControllerInitialized = true; + void loadComponentUpdateCheckCache(); + + onMount('updates-status').then(() => { + logger.debug('[UPDATES]', 'initController', 'onMount'); + registerLifecycleListeners(); + if ( + store.get().tabService.current === 'updates' || + isActiveLuciTab('updates') + ) { + onPageMount(); + } + }); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/render.ts b/fe-app-tachyon/src/tachyon/tabs/updates/render.ts new file mode 100644 index 00000000..1815e361 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/updates/render.ts @@ -0,0 +1,8 @@ +export function render() { + return E('div', { id: 'updates-status', class: 'fkp_updates-page' }, [ + E('div', { + id: 'fkp_updates-components', + class: 'fkp_updates-page__components', + }), + ]); +} diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/styles.ts b/fe-app-tachyon/src/tachyon/tabs/updates/styles.ts new file mode 100644 index 00000000..8dbcff87 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/updates/styles.ts @@ -0,0 +1,162 @@ +// language=CSS +import { TACHYON_UCI_PACKAGE as TACHYON_CBI_PREFIX } from '../../../constants'; + +export const styles = ` +#cbi-${TACHYON_CBI_PREFIX}-updates-_mount_node > div { + width: 100%; +} + +#cbi-${TACHYON_CBI_PREFIX}-updates > h3 { + display: none; +} + +.fkp_updates-page { + width: 100%; +} + +.fkp_updates-page__components { + display: flex; + align-items: flex-start; + gap: 10px; + width: 100%; + flex-wrap: wrap; +} + +.fkp_updates-page__components-column { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 10px; + min-width: max-content; +} + +@media (max-width: 760px) { + .fkp_updates-page__components { + flex-direction: column; + } + + .fkp_updates-page__components-column { + width: 100%; + min-width: 0; + } +} + +.fkp_updates-page__component { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + min-width: max-content; +} + +.fkp_updates-page__component__header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + border-bottom: 1px var(--background-color-low, lightgray) solid; + padding-bottom: 8px; + margin-bottom: 2px; +} + +.fkp_updates-page__component__title { + color: var(--text-color-high); + font-size: 16px; + font-weight: bold; + line-height: 1.2; +} + +.fkp_updates-page__component__header-version { + color: var(--text-color-medium, #888); + font-size: 13px; + font-weight: normal; +} + +.fkp_updates-page__component__details { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fkp_updates-page__component__info-row { + display: flex; + justify-content: flex-start; + align-items: center; + min-height: 24px; + gap: 8px; + white-space: nowrap; +} + +.fkp_updates-page__component__info-label { + color: var(--text-color-medium, #888); + font-size: 12px; +} + +.fkp_updates-page__component__info-value { + color: var(--text-color-high, #000); + font-weight: 500; + font-size: 13px; + text-align: left; + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow-wrap: anywhere; +} + +.fkp_updates-page__component__info-value--latest { + flex-wrap: wrap; + justify-content: flex-start; +} + +.fkp_updates-page__component__release-version-link { + color: var(--link-color, #3498db) !important; + text-decoration: underline; + font-weight: bold; +} + +.fkp_updates-page__component__release-version-link:hover { + color: var(--link-color-dark, #2980b9) !important; +} + +.fkp_updates-page__component__actions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: auto; +} + +.fkp_updates-page__component__actions--with-details { + border-top: 1px var(--background-color-low, lightgray) solid; + padding-top: 10px; +} + +.fkp_updates-page__component__actions-main { + display: flex; + justify-content: flex-start; + align-items: center; + flex-wrap: nowrap; + gap: 6px; +} + +.fkp_updates-page__component__variants { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} + +.fkp_updates-page__component__variants-title { + font-size: 11px; + font-weight: bold; + color: var(--text-color-medium, gray); +} + +.fkp_updates-page__component__variants-buttons { + display: flex; + flex-wrap: nowrap; + gap: 6px; +} +`; diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/tests/checkResultLifecycle.test.ts b/fe-app-tachyon/src/tachyon/tabs/updates/tests/checkResultLifecycle.test.ts new file mode 100644 index 00000000..b0058adc --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/updates/tests/checkResultLifecycle.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { + shouldPreserveCompletedCheckResultOnNextMount, + shouldExposeCheckResults, + shouldRefreshComponentStateBeforeRender, + shouldResetCheckResultsOnMount, +} from '../checkResultLifecycle'; + +describe('updates check result lifecycle', () => { + it('preserves a check result that completed while the Components tab was hidden', () => { + expect( + shouldPreserveCompletedCheckResultOnNextMount({ + action: 'check_update', + mounted: false, + }), + ).toBe(true); + + expect( + shouldResetCheckResultsOnMount({ + anyActionLoading: false, + preserveCheckResultsOnNextMount: true, + }), + ).toBe(false); + }); + + it('resets check results on the next ordinary reopen', () => { + expect( + shouldResetCheckResultsOnMount({ + anyActionLoading: false, + preserveCheckResultsOnNextMount: false, + }), + ).toBe(true); + }); + + it('does not reset while an action is still loading', () => { + expect( + shouldResetCheckResultsOnMount({ + anyActionLoading: true, + preserveCheckResultsOnNextMount: false, + }), + ).toBe(false); + }); + + it('keeps cached results on every reopen when automatic checks are enabled', () => { + expect( + shouldResetCheckResultsOnMount({ + anyActionLoading: false, + preserveCheckResultsOnNextMount: false, + persistentCacheEnabled: true, + }), + ).toBe(false); + }); + + it('requires a fresh state read before rendering cached running component actions', () => { + expect( + shouldRefreshComponentStateBeforeRender({ + actions: { + service: [], + latency: [], + subscription: [], + component: [ + { + success: true, + running: true, + component: 'zapret', + action: 'check_update', + message: 'Component action is running', + job_id: 'job-1', + current_version: '', + latest_version: '', + changed: false, + }, + ], + }, + }), + ).toBe(true); + }); + + it('exposes check results immediately on remount once cache state is resolved', () => { + expect( + shouldExposeCheckResults({ + mounted: true, + cacheResolved: false, + }), + ).toBe(false); + + expect( + shouldExposeCheckResults({ + mounted: true, + cacheResolved: true, + }), + ).toBe(true); + + expect( + shouldExposeCheckResults({ + mounted: false, + cacheResolved: true, + }), + ).toBe(false); + + expect( + shouldExposeCheckResults({ + mounted: true, + cacheResolved: true, + }), + ).toBe(true); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/tests/componentActionCompletion.test.ts b/fe-app-tachyon/src/tachyon/tabs/updates/tests/componentActionCompletion.test.ts new file mode 100644 index 00000000..2603ae79 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/updates/tests/componentActionCompletion.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldApplyCompletedComponentActionResult } from '../componentActionCompletion'; + +describe('shouldApplyCompletedComponentActionResult', () => { + it('does not restore completed check results that were already handled before reload', () => { + expect( + shouldApplyCompletedComponentActionResult( + { action: 'check_update' }, + false, + ), + ).toBe(false); + }); + + it('applies completed check results when this UI still owns the pending notification', () => { + expect( + shouldApplyCompletedComponentActionResult( + { action: 'check_update' }, + true, + ), + ).toBe(true); + }); + + it('always applies completed mutating actions because they reflect real system state', () => { + expect( + shouldApplyCompletedComponentActionResult({ action: 'install' }, false), + ).toBe(true); + expect( + shouldApplyCompletedComponentActionResult({ action: 'remove' }, false), + ).toBe(true); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/tests/runtimeTags.test.ts b/fe-app-tachyon/src/tachyon/tests/runtimeTags.test.ts new file mode 100644 index 00000000..37e16b7a --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tests/runtimeTags.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +import { getOutboundTagBySection } from '../runtimeTags'; + +describe('runtimeTags', () => { + it('uses the same direct section collision format as the backend generator', () => { + expect(getOutboundTagBySection('proxy')).toBe('proxy-out'); + expect(getOutboundTagBySection('direct')).toBe('direct-out-1'); + expect(getOutboundTagBySection('bypass')).toBe('bypass-out-1'); + expect(getOutboundTagBySection('direct-1')).toBe('direct-1-out'); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/types.ts b/fe-app-tachyon/src/tachyon/types.ts new file mode 100644 index 00000000..ea0f8316 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/types.ts @@ -0,0 +1,721 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace ClashAPI { + interface ProxyHistoryEntry { + time: string; + delay: number; + } + + export interface ProxyBase { + type: string; + name: string; + udp: boolean; + history: ProxyHistoryEntry[]; + now?: string; + all?: string[]; + } + + export interface Proxies { + proxies: Record; + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Tachyon { + // Available commands: + // start Start tachyon service + // stop Stop tachyon service + // reload Reload tachyon configuration + // restart Restart tachyon service + // enable Enable tachyon autostart + // disable Disable tachyon autostart + // uninstall Remove tachyon files installed outside opkg/apk + // main Run main tachyon process + // list_update Update domain lists + // check_proxy Check proxy connectivity + // check_nft Check NFT rules + // check_nft_rules Check NFT rules status + // check_sing_box Check sing-box installation and status + // check_inbounds_config Check whether enabled server inbounds are configured + // check_inbounds Check server inbounds from the Servers tab + // check_logs Show tachyon logs from system journal + // check_sing_box_logs Show sing-box logs + // check_fakeip Test sing-box FakeIP DNS + // clash_api Clash API interface for managing proxies and groups + // show_config Display current tachyon configuration + // show_version Show tachyon version + // show_sing_box_config Show sing-box configuration + // show_sing_box_version Show sing-box version + // get_status Get tachyon service status + // get_sing_box_status Get sing-box service status + // get_ui_capabilities Get lightweight UI capabilities + // check_dns_available Check DNS server availability + // global_check Run global system check + + export enum AvailableMethods { + CHECK_DNS_AVAILABLE = 'check_dns_available', + CHECK_FAKEIP = 'check_fakeip', + CHECK_NFT_RULES = 'check_nft_rules', + CHECK_ZAPRET_RUNTIME = 'check_zapret_runtime', + CHECK_ZAPRET2_RUNTIME = 'check_zapret2_runtime', + CHECK_BYEDPI_RUNTIME = 'check_byedpi_runtime', + CHECK_INBOUNDS_CONFIG = 'check_inbounds_config', + GET_STATUS = 'get_status', + GET_OUTBOUND_METADATA = 'get_outbound_metadata', + GET_SUBSCRIPTION_METADATA = 'get_subscription_metadata', + CHECK_SING_BOX = 'check_sing_box', + CHECK_INBOUNDS = 'check_inbounds', + GET_SING_BOX_STATUS = 'get_sing_box_status', + GET_ZAPRET_STATUS = 'get_zapret_status', + GET_ZAPRET2_STATUS = 'get_zapret2_status', + GET_BYEDPI_STATUS = 'get_byedpi_status', + CLASH_API = 'clash_api', + ENABLE = 'enable', + DISABLE = 'disable', + GLOBAL_CHECK = 'global_check', + SHOW_SING_BOX_CONFIG = 'show_sing_box_config', + CHECK_LOGS = 'check_logs', + CHECK_SING_BOX_LOGS = 'check_sing_box_logs', + GET_SYSTEM_INFO = 'get_system_info', + GET_SERVER_CAPABILITIES = 'get_server_capabilities', + GET_UI_CAPABILITIES = 'get_ui_capabilities', + GET_UI_STATE = 'get_ui_state', + SERVICE_ACTION_ASYNC = 'service_action_async', + SERVICE_ACTION_STATUS = 'service_action_status', + LATENCY_TEST_ASYNC = 'latency_test_async', + LATENCY_TEST_STATUS = 'latency_test_status', + UI_ACTION_ACK = 'ui_action_ack', + COMPONENT_ACTION_ASYNC = 'component_action_async', + COMPONENT_ACTION_STATUS = 'component_action_status', + COMPONENT_UPDATE_CHECK_CACHE = 'component_update_check_cache', + SUBSCRIPTION_UPDATE_ASYNC = 'subscription_update_async', + SUBSCRIPTION_UPDATE_STATUS = 'subscription_update_status', + } + + export enum AvailableClashAPIMethods { + GET_PROXIES = 'get_proxies', + GET_CONNECTIONS = 'get_connections', + GET_PROXY_LATENCY = 'get_proxy_latency', + GET_PROXY_LATENCIES = 'get_proxy_latencies', + GET_GROUP_LATENCY = 'get_group_latency', + SET_GROUP_PROXY = 'set_group_proxy', + CLOSE_CONNECTION = 'close_connection', + CLOSE_ALL_CONNECTIONS = 'close_all_connections', + } + + export interface Outbound { + code: string; + displayName: string; + latency: number; + type: string; + selected: boolean; + link?: string; + canCopyLink?: boolean; + country?: string; + runtimeAvailable?: boolean; + urlTestInfo?: UrlTestInfo; + priorityInfo?: PriorityInfo; + } + + export interface UrlTestMember { + code: string; + displayName: string; + latency: number; + type: string; + selected: boolean; + link?: string; + canCopyLink?: boolean; + country?: string; + } + + export interface UrlTestInfo { + code: string; + displayName: string; + selectedCode?: string; + selectedName?: string; + url?: string; + interval?: string; + tolerance?: string | number; + idleTimeout?: string; + interruptExistConnections?: boolean; + outbounds: UrlTestMember[]; + } + + export interface PriorityMember extends UrlTestMember { + levelIndex: number; + levelName: string; + levelId?: string; + } + + export interface PriorityInfo { + code: string; + displayName: string; + selectedCode?: string; + selectedName?: string; + healthUrl?: string; + activeCheckInterval?: string; + checkTimeout?: string; + recoveryCheckInterval?: string; + pickFastest?: boolean; + switchToFasterSamePriority?: boolean; + fastestCheckInterval?: string; + interruptExistConnections?: boolean; + outbounds: PriorityMember[]; + } + + export interface OutboundGroup { + withTagSelect: boolean; + code: string; + sectionName: string; + displayName: string; + action?: ConfigSection['action']; + latencyTestCode?: string; + latencyTestCodes?: string[]; + latencyTestTimeout?: string; + proxyConfigType?: ProxyConfigType; + subscriptionSourceCount?: number; + subscriptionMetadata?: SubscriptionMetadata[]; + outbounds: Outbound[]; + } + + export interface LatencyActionProgress { + completed: number; + total: number; + failed?: number; + } + + interface SubscriptionTraffic { + upload?: number; + download?: number; + used?: number; + total?: number; + remaining?: number; + isUnlimited?: boolean; + } + + export interface SubscriptionMetadata { + version?: number; + title?: string; + traffic?: SubscriptionTraffic; + expire?: number; + refillDate?: number; + webPageUrl?: string; + supportUrl?: string; + announce?: string; + announceUrl?: string; + fileName?: string; + sourceIndex?: number; + sourceSection?: string; + } + + type RuleAction = + | 'connection' + | 'proxy' + | 'outbound' + | 'vpn' + | 'bypass' + | 'block' + | 'zapret' + | 'zapret2' + | 'byedpi'; + type LegacyConnectionType = 'proxy' | 'vpn' | 'block' | 'exclusion'; + type ProxyConfigType = + | 'urltest' + | 'selector' + | 'url' + | 'outbound' + | 'interface' + | 'subscription'; + + export interface ConfigSection { + '.name': string; + '.type': + | 'settings' + | 'rule' + | 'node' + | 'ruleset' + | 'section' + | 'server' + | 'subscription_url' + | 'section_interface' + | 'urltest' + | 'priority_group' + | 'priority_level'; + label?: string; + enabled?: string; + action?: RuleAction; + connection_type?: LegacyConnectionType; + proxy_config_type?: ProxyConfigType; + node?: string; + rule_set?: string[]; + rule_set_with_subnets?: string[]; + domain_ip_lists?: string[]; + ports?: string[]; + update_interval?: string; + proxy_string?: string; + nfqws_opt?: string; + nfqws2_opt?: string; + byedpi_cmd_opts?: string; + cmd_opts?: string; + selector_proxy_links?: string[]; + subscription_urls?: string[]; + interfaces?: string[]; + outbound_jsons?: string[]; + subscription_url_settings?: string; + urltests?: string[]; + urltest_settings?: string; + priority_groups?: string[]; + priority_group_settings?: string; + dashboard_filter_mode?: 'disabled' | 'exclude' | 'include' | 'mixed'; + dashboard_detect_server_country?: 'flag_emoji' | 'country_is'; + dashboard_include_countries?: string[]; + dashboard_include_outbounds?: string[]; + dashboard_include_regex?: string[]; + dashboard_include_proxy_parameters?: '0' | '1'; + dashboard_include_protocols?: string[]; + dashboard_include_transports?: string[]; + dashboard_include_securities?: string[]; + dashboard_include_groups?: string[]; + dashboard_exclude_countries?: string[]; + dashboard_exclude_outbounds?: string[]; + dashboard_exclude_regex?: string[]; + dashboard_exclude_proxy_parameters?: '0' | '1'; + dashboard_exclude_protocols?: string[]; + dashboard_exclude_transports?: string[]; + dashboard_exclude_securities?: string[]; + dashboard_exclude_groups?: string[]; + urltest_proxy_links?: string[]; + subscription_url?: string; + subscription_user_agent?: string; + subscription_update_enabled?: '0' | '1'; + subscription_update_interval?: string; + subscription_update_interval_disabled?: '0' | '1'; + urltest_enabled?: '0' | '1'; + sort_by_latency?: '0' | '1'; + urltest_check_interval_disabled?: '0' | '1'; + detect_server_country?: '0' | '1' | 'flag_emoji' | 'country_is'; + urltest_filter_mode?: 'disabled' | 'exclude' | 'include' | 'mixed'; + urltest_hide_filtered_outbounds?: '0' | '1'; + urltest_exclude_countries?: string[]; + urltest_exclude_outbounds?: string[]; + urltest_exclude_regex?: string[]; + urltest_include_countries?: string[]; + urltest_include_outbounds?: string[]; + urltest_include_regex?: string[]; + outbound_json?: string; + interface?: string; + section?: string; + id?: string; + url?: string; + name?: string; + display_name?: string; + check_interval?: string; + tolerance?: string; + testing_url?: string; + idle_timeout?: string; + interrupt_exist_connections?: '0' | '1'; + pin_dashboard?: '0' | '1'; + filter_mode?: 'disabled' | 'exclude' | 'include' | 'mixed'; + include_countries?: string[]; + include_outbounds?: string[]; + include_regex?: string[]; + exclude_countries?: string[]; + exclude_outbounds?: string[]; + exclude_regex?: string[]; + group?: string; + order?: string; + direct?: '0' | '1'; + health_url?: string; + active_check_interval?: string; + check_timeout?: string; + recovery_check_interval?: string; + pick_fastest?: '0' | '1'; + switch_to_faster_same_priority?: '0' | '1'; + fastest_check_interval?: string; + country?: string[]; + server_name?: string[]; + regex?: string[]; + outbound_detour_enabled?: '0' | '1'; + outbound_detour_section?: string; + download_via_proxy_enabled?: '0' | '1'; + download_via_proxy_section?: string; + auto_user_agent?: '0' | '1'; + user_agent?: string; + auto_hwid?: '0' | '1'; + hwid?: string; + show_dashboard_metadata?: '0' | '1'; + prefix_nodes?: '0' | '1'; + node_prefix?: string; + include_urltest_groups?: '0' | '1'; + hide_urltest_group_outbounds?: '0' | '1'; + hide_detour_outbounds?: '0' | '1'; + yacd_secret_key?: string; + } + + export interface MethodSuccessResponse { + success: true; + data: T; + } + + export interface MethodFailureResponse { + success: false; + error: string; + } + + export type MethodResponse = + | MethodSuccessResponse + | MethodFailureResponse; + + export interface DnsCheckResult { + dns_type: 'udp' | 'doh' | 'dot'; + dns_server: string; + dns_server_index: number; + dns_server_count: number; + dns_status: 0 | 1; + dns_on_router: 0 | 1; + bootstrap_dns_server: string; + bootstrap_dns_server_index: number; + bootstrap_dns_server_count: number; + bootstrap_dns_status: 0 | 1; + dhcp_config_status: 0 | 1; + dont_touch_dhcp: 0 | 1; + } + + export interface NftRulesCheckResult { + table_exist: 0 | 1; + rules_mangle_exist: 0 | 1; + rules_mangle_counters: 0 | 1; + rules_mangle_output_exist: 0 | 1; + rules_mangle_output_counters: 0 | 1; + rules_proxy_exist: 0 | 1; + rules_proxy_counters: 0 | 1; + rules_other_mark_exist: 0 | 1; + } + + export interface SingBoxCheckResult { + sing_box_installed: 0 | 1; + sing_box_version_ok: 0 | 1; + sing_box_service_exist: 0 | 1; + sing_box_autostart_disabled: 0 | 1; + sing_box_process_running: 0 | 1; + sing_box_ports_listening: 0 | 1; + } + + export interface InboundCheckItem { + section: string; + label: string; + protocol: string; + routing_mode: string; + tag: string; + listen: string; + listen_port: number; + public_host: string; + public_host_ips: string; + expected_type: string; + required_proto: string; + runtime_exists: 0 | 1; + runtime_type: string; + runtime_listen: string; + runtime_port: number; + runtime_ok: 0 | 1; + listening: -1 | 0 | 1; + firewall_required: 0 | 1; + firewall_open: -1 | 0 | 1; + port_conflict: 0 | 1; + port_conflict_owners: string; + routes_configured: 0 | 1; + public_host_resolved: -1 | 0 | 1; + public_host_public: -1 | 0 | 1; + public_host_matches_wan: -1 | 0 | 1; + } + + export interface InboundsCheckResult { + enabled_count: number; + config_path: string; + wan_ip: string; + wan_public: 0 | 1; + items: InboundCheckItem[]; + } + + export interface InboundsConfigCheckResult { + enabled_count: number; + } + + export interface FakeIPCheckResult { + fakeip: boolean; + IP: string; + } + + export interface GetStatus { + running: number; + enabled: number; + status: string; + dns_configured?: number; + } + + export interface GetOutboundMetadata { + names?: Record; + countries?: Record; + } + + export interface GetSingBoxStatus { + running: number; + enabled: number; + status: string; + } + + export interface GetSystemInfo { + tachyon_version: string; + tachyon_latest_version: string; + luci_app_version: string; + sing_box_version: string; + sing_box_extended: 0 | 1; + sing_box_tiny: 0 | 1; + sing_box_compressed: 0 | 1; + sing_box_tailscale: 0 | 1; + zapret_version: string; + zapret_installed: 0 | 1; + zapret2_version: string; + zapret2_installed: 0 | 1; + byedpi_version: string; + byedpi_installed: 0 | 1; + openwrt_version: string; + device_model: string; + generated_at?: number; + } + + export interface GetServerCapabilities { + sing_box_extended: 0 | 1; + sing_box_tiny: 0 | 1; + sing_box_tailscale: 0 | 1; + } + + export interface GetUiCapabilities { + sing_box_extended: 0 | 1; + sing_box_tiny: 0 | 1; + sing_box_compressed: 0 | 1; + sing_box_tailscale: 0 | 1; + zapret_installed: 0 | 1; + zapret2_installed: 0 | 1; + byedpi_installed: 0 | 1; + server_inbounds_enabled_count: number; + } + + export type ServiceAction = 'start' | 'stop' | 'restart' | 'reload'; + + export interface UiActionStartResult { + success: boolean; + job_id: string; + message: string; + } + + export interface UiActionState { + success: boolean; + running?: boolean; + kind?: string; + message?: string; + pid?: string | null; + started_at?: number; + updated_at?: number | null; + exit_code?: number | null; + job_id?: string; + } + + export interface ServiceActionState extends UiActionState { + kind: 'service'; + action: ServiceAction; + source?: string; + } + + export interface LatencyActionState extends UiActionState { + kind: 'latency'; + latency_type: 'group' | 'proxy' | 'proxy_list'; + section: string; + tag: string; + progress?: LatencyActionProgress; + } + + export interface UiState { + service: { + tachyon: GetStatus; + sing_box: GetSingBoxStatus; + }; + capabilities: GetUiCapabilities; + actions: { + service: ServiceActionState[]; + latency: LatencyActionState[]; + component: ComponentActionResult[]; + subscription: SubscriptionUpdateJobState[]; + }; + } + + export type ComponentName = + | 'tachyon' + | 'sing_box' + | 'zapret' + | 'zapret2' + | 'byedpi'; + + export type ComponentAction = + | 'check_update' + | 'install' + | 'remove' + | 'install_extended' + | 'install_extended_compressed' + | 'install_tiny' + | 'install_stable'; + + export interface ComponentActionResult { + success: boolean; + running?: boolean; + kind?: 'component'; + job_id?: string; + component: ComponentName; + action: ComponentAction; + message: string; + current_version: string; + latest_version: string; + release_url?: string; + changed: boolean; + status?: 'latest' | 'outdated' | 'dev' | ''; + pid?: string | null; + started_at?: number; + updated_at?: number | null; + exit_code?: number | null; + } + + export interface ComponentUpdateCheckCache { + enabled: boolean; + results: ComponentActionResult[]; + } + + export type ComponentActionStartResult = UiActionStartResult; + + export type SubscriptionUpdateStartResult = UiActionStartResult; + + export interface SubscriptionUpdateJobState extends UiActionState { + kind?: 'subscription'; + success: boolean; + running?: boolean; + message?: string; + section?: string; + source_index?: string; + pid?: string | null; + started_at?: number; + exit_code?: number | null; + updated_at?: number | null; + } + + export interface GetZapretStatus { + installed: 0 | 1; + package_installed: 0 | 1; + provider_available: 0 | 1; + provider_path: string; + files_available: 0 | 1; + ipset_available: 0 | 1; + version: string; + configured: 0 | 1; + enabled_rule_count: number; + expected_process_count: number; + running_process_count: number; + supervisor_process_count: number; + restart_count: number; + runtime_unstable: 0 | 1; + standalone_service_enabled: 0 | 1; + standalone_service_running: 0 | 1; + standalone_config_present: 0 | 1; + standalone_conflict: 0 | 1; + luci_app_installed: 0 | 1; + queue_base: number; + queue_range_end: number; + queue_overlap: 0 | 1; + legacy_runtime_present: 0 | 1; + ready: 0 | 1; + conflict: 0 | 1; + outbounds_configured: 0 | 1; + routes_configured: 0 | 1; + status_message: string; + } + + export interface ZapretCheckResult { + zapret_installed: 0 | 1; + zapret_package_installed: 0 | 1; + zapret_provider_path: string; + } + + export interface GetZapret2Status { + installed: 0 | 1; + package_installed: 0 | 1; + provider_available: 0 | 1; + provider_path: string; + files_available: 0 | 1; + ipset_available: 0 | 1; + version: string; + configured: 0 | 1; + enabled_rule_count: number; + expected_process_count: number; + running_process_count: number; + supervisor_process_count: number; + standalone_service_enabled: 0 | 1; + standalone_service_running: 0 | 1; + standalone_config_present: 0 | 1; + standalone_conflict: 0 | 1; + luci_app_installed: 0 | 1; + queue_base: number; + queue_range_end: number; + queue_overlap: 0 | 1; + ready: 0 | 1; + conflict: 0 | 1; + outbounds_configured: 0 | 1; + routes_configured: 0 | 1; + status_message: string; + } + + export interface Zapret2CheckResult { + zapret2_installed: 0 | 1; + zapret2_package_installed: 0 | 1; + zapret2_provider_path: string; + } + + export interface GetByedpiStatus { + installed: 0 | 1; + package_installed: 0 | 1; + provider_available: 0 | 1; + provider_path: string; + version: string; + configured: 0 | 1; + enabled_rule_count: number; + expected_process_count: number; + running_process_count: number; + supervisor_process_count: number; + restart_count: number; + runtime_unstable: 0 | 1; + standalone_service_enabled: 0 | 1; + standalone_service_running: 0 | 1; + listen_address: string; + port_base: number; + outbounds_configured: 0 | 1; + routes_configured: 0 | 1; + ready: 0 | 1; + conflict: 0 | 1; + status_message: string; + } + + export interface ByedpiCheckResult { + byedpi_installed: 0 | 1; + byedpi_package_installed: 0 | 1; + byedpi_provider_path: string; + } + + export interface GetClashApiProxyLatency { + delay: number; + message?: string; + } + + export interface GetClashApiProxyLatencies { + success: boolean; + count: number; + failed: boolean; + } + + export type GetClashApiGroupLatency = Record; +} diff --git a/fe-app-tachyon/src/validators/bulkValidate.ts b/fe-app-tachyon/src/validators/bulkValidate.ts new file mode 100644 index 00000000..fce12c25 --- /dev/null +++ b/fe-app-tachyon/src/validators/bulkValidate.ts @@ -0,0 +1,13 @@ +import { BulkValidationResult, ValidationResult } from './types'; + +export function bulkValidate( + values: T[], + validate: (value: T) => ValidationResult, +): BulkValidationResult { + const results = values.map((value) => ({ ...validate(value), value })); + + return { + valid: results.every((r) => r.valid), + results, + }; +} diff --git a/fe-app-tachyon/src/validators/hostPort.ts b/fe-app-tachyon/src/validators/hostPort.ts new file mode 100644 index 00000000..f504aa47 --- /dev/null +++ b/fe-app-tachyon/src/validators/hostPort.ts @@ -0,0 +1,62 @@ +import { validateDomain } from './validateDomain'; +import { isIPv4, isIPv6 } from './validateIp'; + +export type ParsedHostPort = { + host: string; + port: string; +}; + +export function unbracketHost(host: string): string { + if (host.startsWith('[') && host.endsWith(']')) { + return host.slice(1, -1); + } + + return host; +} + +export function isValidHost(host: string): boolean { + const normalizedHost = unbracketHost(host); + + return ( + isIPv4(normalizedHost) || + isIPv6(normalizedHost) || + validateDomain(normalizedHost).valid + ); +} + +export function isValidPort(port: unknown): boolean { + const normalized = String(port ?? ''); + const value = Number(normalized); + return /^\d+$/.test(normalized) && value >= 1 && value <= 65535; +} + +export function parseHostPort(value: string): ParsedHostPort | null { + if (!value) { + return null; + } + + if (value.startsWith('[')) { + const end = value.indexOf(']'); + if (end <= 1 || value.slice(end + 1, end + 2) !== ':') { + return null; + } + + const parsed = { + host: value.slice(1, end), + port: value.slice(end + 2), + }; + return isValidHost(parsed.host) ? parsed : null; + } + + const firstColon = value.indexOf(':'); + const lastColon = value.lastIndexOf(':'); + if (firstColon < 0 || firstColon !== lastColon) { + return null; + } + + const parsed = { + host: value.slice(0, firstColon), + port: value.slice(firstColon + 1), + }; + return isValidHost(parsed.host) ? parsed : null; +} diff --git a/fe-app-tachyon/src/validators/tests/validateDns.test.js b/fe-app-tachyon/src/validators/tests/validateDns.test.js new file mode 100644 index 00000000..7e553463 --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateDns.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { validateDNS } from '../validateDns.js'; +import { invalidIPs, validIPs } from './validateIp.test'; +import { invalidDomains, validDomains } from './validateDomain.test'; + +export const additionalValidDns = [ + ['Google DNS (port 53)', '8.8.8.8:53'], + ['Google DNS (port 5353)', '8.8.8.8:5353'], + ['Cloudflare DNS (port 853)', '1.1.1.1:853'], + ['Cloudflare domain (port 853)', 'cloudflare-dns.com:853'], + ['DoH IP', '1.1.1.1/dns-query'], + ['DoH IP with port 443', '1.1.1.1:443/dns-query'], + ['DoH domain', 'cloudflare-dns.com/dns-query'], + ['DoH domain with port 443', 'cloudflare-dns.com:443/dns-query'], + ['IPv6 DNS', '2606:4700:4700::1111'], + ['IPv6 DNS with port', '[2606:4700:4700::1111]:853'], + ['IPv6 DoH with port', '[2606:4700:4700::1111]:443/dns-query'], +]; + +const validDns = [...validIPs, ...validDomains, ...additionalValidDns]; + +const invalidDns = [ + ...invalidIPs, + ...invalidDomains, + ['Non-numeric port', '8.8.8.8:abc'], + ['Port too high', 'dns.example.com:99999'], + ['Zero IPv6 port', '[2606:4700:4700::1111]:0'], +]; + +describe('validateDns', () => { + describe.each(validDns)('Valid dns: %s', (_desc, domain) => { + it(`returns valid=true for "${domain}"`, () => { + const res = validateDNS(domain); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidDns)('Invalid dns: %s', (_desc, domain) => { + it(`returns valid=false for "${domain}"`, () => { + const res = validateDNS(domain); + expect(res.valid).toBe(false); + }); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateDomain.test.js b/fe-app-tachyon/src/validators/tests/validateDomain.test.js new file mode 100644 index 00000000..e006265a --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateDomain.test.js @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { validateDomain } from '../validateDomain'; + +export const validDomains = [ + ['Simple domain', 'example.com'], + ['Subdomain', 'sub.example.com'], + ['With dash', 'my-site.org'], + ['With numbers', 'site123.net'], + ['Deep subdomain', 'a.b.c.example.co.uk'], + ['With path', 'example.com/path/to/resource'], + ['Punycode RU', 'xn--d1acufc.xn--p1ai'], + ['Unicode RU', 'сайт.рф'], + ['Unicode RU uppercase', 'САЙТ.РФ'], + ['Unicode test domain', 'пример.испытание'], + ['Unicode latin label', 'münich.example'], + ['Adguard dns', 'dns.adguard-dns.com'], + ['Nextdns dns', 'dns.nextdns.io/xxxxxxx'], + ['Long domain (63 chars in label)', 'a'.repeat(63) + '.com'], +]; + +export const invalidDomains = [ + ['No TLD', 'localhost'], + ['Only TLD', '.com'], + ['Double dot', 'example..com'], + ['Illegal chars', 'exa!mple.com'], + ['Space inside', 'exa mple.com'], + ['Ending with dash', 'example-.com'], + ['Starting with dash', '-example.com'], + ['Trailing dot', 'example.com.'], + ['Too short TLD', 'example.c'], + ['With protocol (not allowed)', 'http://example.com'], + ['Too long label (>63 chars)', 'a'.repeat(64) + '.com'], + ['Too long domain (>253 chars)', Array(40).fill('abcdef').join('.') + '.com'], +]; + +export const dotTLDTests = [ + ['Dot TLD allowed (.net)', '.net', true, true], + ['Dot TLD allowed (.рф)', '.рф', true, true], + ['Dot TLD not allowed (.net)', '.net', false, false], + ['Invalid with double dot', '..net', true, false], + ['Invalid dot domain', '.example.com', true, false], + ['Invalid single word TLD (net)', 'net', true, false], +]; + +describe('validateDomain', () => { + describe.each(validDomains)('Valid domain: %s', (_desc, domain) => { + it(`returns valid=true for "${domain}"`, () => { + const res = validateDomain(domain); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidDomains)('Invalid domain: %s', (_desc, domain) => { + it(`returns valid=false for "${domain}"`, () => { + const res = validateDomain(domain); + expect(res.valid).toBe(false); + }); + }); + + describe.each(dotTLDTests)( + 'Dot TLD toggle: %s', + (_desc, domain, allowDotTLD, expected) => { + it(`"${domain}" with allowDotTLD=${allowDotTLD} → valid=${expected}`, () => { + const res = validateDomain(domain, allowDotTLD); + expect(res.valid).toBe(expected); + }); + }, + ); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateHttpProxyUrl.test.js b/fe-app-tachyon/src/validators/tests/validateHttpProxyUrl.test.js new file mode 100644 index 00000000..23539efb --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateHttpProxyUrl.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { validateHttpProxyUrl } from '../validateHttpProxyUrl'; + +const validUrls = [ + ['http ipv4', 'http://127.0.0.1:80'], + ['http domain', 'http://proxy.example.com:8080'], + ['https auth', 'https://user:pass@example.com:443'], + ['uppercase auth and host', 'https://USER:PASSWORD@Example.COM:8443'], + ['https ipv6', 'https://user:pass@[2001:db8::1]:8443'], +]; + +const invalidUrls = [ + ['missing port', 'http://example.com'], + ['missing host', 'http://:8080'], + ['invalid port', 'http://example.com:99999'], + ['path present', 'https://example.com:443/path'], + ['query present', 'https://example.com:443?token=abc'], + ['space present', 'http://exa mple.com:80'], + ['empty username', 'http://:pass@example.com:80'], + ['unsupported scheme', 'socks5://example.com:1080'], + ['unbracketed ipv6 with port', 'http://2001:db8::1:8080'], +]; + +describe('validateHttpProxyUrl', () => { + describe.each(validUrls)('valid URL: %s', (_desc, url) => { + it('returns valid=true', () => { + expect(validateHttpProxyUrl(url).valid).toBe(true); + }); + }); + + describe.each(invalidUrls)('invalid URL: %s', (_desc, url) => { + it('returns valid=false', () => { + expect(validateHttpProxyUrl(url).valid).toBe(false); + }); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateHysteriaUrl.test.js b/fe-app-tachyon/src/validators/tests/validateHysteriaUrl.test.js new file mode 100644 index 00000000..d449508c --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateHysteriaUrl.test.js @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { validateHysteria2Url } from '../validateHysteriaUrl.js'; + +const validUrls = [ + // Basic password-only + ['password basic', 'hysteria2://pass@example.com:443/#hy2-basic'], + + // insecure=1 + [ + 'insecure allowed', + 'hysteria2://pass@example.com:443/?insecure=1#hy2-insecure', + ], + + // SNI + ['SNI param', 'hysteria2://pass@example.com:443/?sni=google.com#hy2-sni'], + + // Obfuscation + [ + 'Obfs + password', + 'hysteria2://mypassword@1.1.1.1:8443/?obfs=salamander&obfs-password=abc123#hy2-obfs', + ], + + // All params + [ + 'All options combined', + 'hysteria2://pw@8.8.8.8:8443/?sni=example.com&obfs=salamander&obfs-password=hello&insecure=1#hy2-full', + ], + + // Explicit obfs=none (valid) + ['obfs none = ok', 'hysteria2://pw@example.com:443/?obfs=none#hy2-none'], + + [ + 'port range 123,5000-6000', + 'hysteria2://letmein@example.com:123,5000-6000/?insecure=1&obfs=salamander&obfs-password=gawrgura&pinSHA256=deadbeef&sni=real.example.com', + ], + + [ + 'mport range', + 'hysteria2://letmein@example.com:2053/?mport=20000-50000&insecure=1&obfs=salamander&obfs-password=gawrgura&sni=real.example.com', + ], + ['IPv6 host', 'hysteria2://pass@[2001:db8::1]:443/#hy2-ipv6'], +]; + +const invalidUrls = [ + ['No prefix', 'pw@example.com:443'], + ['Missing password', 'hysteria2://@example.com:443/'], + ['Missing host', 'hysteria2://pw@:443/'], + ['Missing port', 'hysteria2://pw@example.com/'], + ['Invalid host', 'hysteria2://pw@bad_host:443/'], + ['Non-numeric port', 'hysteria2://pw@example.com:port/'], + ['Port out of range', 'hysteria2://pw@example.com:99999/'], + ['Invalid port range order', 'hysteria2://pw@example.com:6000-5000/'], + ['Invalid port range end', 'hysteria2://pw@example.com:5000-/'], + ['Unbracketed IPv6 with port', 'hysteria2://pw@2001:db8::1:443/'], + + // Obfuscation errors + ['Unknown obfs type', 'hysteria2://pw@example.com:443/?obfs=weird'], + [ + 'obfs without obfs-password', + 'hysteria2://pw@example.com:443/?obfs=salamander', + ], + + // insecure only accepts 0/1 + ['invalid insecure', 'hysteria2://pw@example.com:443/?insecure=5'], + + // SNI empty + ['empty sni', 'hysteria2://pw@example.com:443/?sni='], + + ['Invalid mport range end', 'hysteria2://pw@example.com:443/?mport=5000-'], +]; + +describe('validateHysteria2Url', () => { + describe.each(validUrls)('Valid HY2 URL: %s', (_desc, url) => { + it(`returns valid=true for "${url}"`, () => { + const res = validateHysteria2Url(url); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidUrls)('Invalid HY2 URL: %s', (_desc, url) => { + it(`returns valid=false for "${url}"`, () => { + const res = validateHysteria2Url(url); + expect(res.valid).toBe(false); + }); + }); + + it('detects invalid port range', () => { + const res = validateHysteria2Url('hysteria2://pw@example.com:70000/'); + expect(res.valid).toBe(false); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateIp.test.js b/fe-app-tachyon/src/validators/tests/validateIp.test.js new file mode 100644 index 00000000..ffd7ed60 --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateIp.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { validateIP } from '../validateIp'; + +export const validIPs = [ + ['Private LAN', '192.168.1.1'], + ['All zeros', '0.0.0.0'], + ['Broadcast', '255.255.255.255'], + ['Simple', '1.2.3.4'], + ['Loopback', '127.0.0.1'], +]; + +export const invalidIPs = [ + ['Octet too large', '256.0.0.1'], + ['Too few octets', '192.168.1'], + ['Too many octets', '1.2.3.4.5'], + ['Leading zero (1st octet)', '01.2.3.4'], + ['Leading zero (2nd octet)', '1.02.3.4'], + ['Leading zero (3rd octet)', '1.2.003.4'], + ['Leading zero (4th octet)', '1.2.3.004'], + ['Four digits in octet', '1.2.3.0004'], + ['Trailing dot', '1.2.3.'], +]; + +describe('validateIP IPv4', () => { + describe.each(validIPs)('Valid IP: %s', (_desc, ip) => { + it(`returns {valid:true} for "${ip}"`, () => { + const res = validateIP(ip); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidIPs)('Invalid IP: %s', (_desc, ip) => { + it(`returns {valid:false} for "${ip}"`, () => { + const res = validateIP(ip); + expect(res.valid).toBe(false); + }); + }); +}); + +describe('validateIP IPv6', () => { + const validIPv6Addresses = [ + ['Loopback', '::1'], + ['ULA', 'fc00::1'], + ['Documentation prefix', '2001:db8::1'], + ['Full form', '2001:0db8:0000:0000:0000:ff00:0042:8329'], + ]; + + const invalidIPv6Addresses = [ + ['Bad hex', '2001:db8::zzzz'], + ['Two compression markers', '2001::db8::1'], + ['Zone id', 'fe80::1%eth0'], + ]; + + describe.each(validIPv6Addresses)('Valid IPv6: %s', (_desc, ip) => { + it(`returns {valid:true} for "${ip}"`, () => { + expect(validateIP(ip).valid).toBe(true); + }); + }); + + describe.each(invalidIPv6Addresses)('Invalid IPv6: %s', (_desc, ip) => { + it(`returns {valid:false} for "${ip}"`, () => { + expect(validateIP(ip).valid).toBe(false); + }); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateOutboundJson.test.js b/fe-app-tachyon/src/validators/tests/validateOutboundJson.test.js new file mode 100644 index 00000000..9960405e --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateOutboundJson.test.js @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { validateOutboundJson } from '../validateOutboundJson'; + +describe('validateOutboundJson', () => { + it('accepts a direct outbound with type and tag', () => { + expect(validateOutboundJson('{"type":"direct","tag":"direct"}').valid).toBe( + true, + ); + }); + + it('accepts a server outbound with server and numeric server_port', () => { + const result = validateOutboundJson( + '{"type":"socks","tag":"proxy","server":"127.0.0.1","server_port":1080}', + ); + + expect(result.valid).toBe(true); + }); + + it('accepts a URLTest outbound with referenced outbounds', () => { + const result = validateOutboundJson( + '{"type":"urltest","tag":"auto","outbounds":["proxy-1"],"url":"https://www.gstatic.com/generate_204"}', + ); + + expect(result.valid).toBe(true); + }); + + it('rejects empty input', () => { + const result = validateOutboundJson(''); + + expect(result.valid).toBe(false); + expect(result.message).toBe('JSON outbound cannot be empty'); + }); + + it('rejects arrays', () => { + const result = validateOutboundJson('[{"type":"direct","tag":"direct"}]'); + + expect(result.valid).toBe(false); + expect(result.message).toBe('JSON outbound must be a JSON object'); + }); + + it('requires a non-empty type field', () => { + const result = validateOutboundJson('{"tag":"proxy"}'); + + expect(result.valid).toBe(false); + expect(result.message).toBe( + 'JSON outbound must contain a non-empty type field', + ); + }); + + it('requires a non-empty tag field', () => { + const result = validateOutboundJson('{"type":"direct"}'); + + expect(result.valid).toBe(false); + expect(result.message).toBe( + 'JSON outbound must contain a non-empty tag field', + ); + }); + + it('rejects a tag already used by another JSON outbound', () => { + const result = validateOutboundJson('{"type":"direct","tag":"duplicate"}', [ + 'first', + 'duplicate', + ]); + + expect(result.valid).toBe(false); + expect(result.message).toBe('Duplicate JSON outbound tag'); + }); + + it('requires server fields for common server outbound types', () => { + const result = validateOutboundJson( + '{"type":"socks","tag":"proxy","server":"127.0.0.1"}', + ); + + expect(result.valid).toBe(false); + expect(result.message).toBe( + 'Server outbound must contain a numeric server_port from 1 to 65535', + ); + }); + + it('requires group outbounds for selector-like types', () => { + const result = validateOutboundJson('{"type":"selector","tag":"select"}'); + + expect(result.valid).toBe(false); + expect(result.message).toBe( + 'Selector and URLTest outbounds must contain a non-empty outbounds array', + ); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validatePath.test.js b/fe-app-tachyon/src/validators/tests/validatePath.test.js new file mode 100644 index 00000000..5f43be4d --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validatePath.test.js @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { validatePath } from '../validatePath'; + +export const validPaths = [ + ['Single level', '/etc'], + ['Nested path', '/usr/local/bin'], + ['With dash', '/var/log/nginx-access'], + ['With underscore', '/opt/my_app/config'], + ['With numbers', '/data123/files'], + ['With dots', '/home/user/.config'], + ['Deep nested', '/a/b/c/d/e/f/g'], +]; + +export const invalidPaths = [ + ['Empty string', ''], + ['Missing starting slash', 'usr/local'], + ['Only dot', '.'], + ['Space inside', '/path with space'], + ['Illegal char', '/path$'], + ['Backslash not allowed', '\\windows\\path'], + ['Relative path ./', './relative'], + ['Relative path ../', '../parent'], +]; + +describe('validatePath', () => { + describe.each(validPaths)('Valid path: %s', (_desc, path) => { + it(`returns valid=true for "${path}"`, () => { + const res = validatePath(path); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidPaths)('Invalid path: %s', (_desc, path) => { + it(`returns valid=false for "${path}"`, () => { + const res = validatePath(path); + expect(res.valid).toBe(false); + }); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateProxyUrl.test.js b/fe-app-tachyon/src/validators/tests/validateProxyUrl.test.js new file mode 100644 index 00000000..155f99d5 --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateProxyUrl.test.js @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { validateProxyUrl } from '../validateProxyUrl'; + +const validUrls = [ + ['shadowsocks', 'ss://YWVzLTI1Ni1nY206cGFzcw@example.com:8388'], + [ + 'vless', + 'vless://94792286-7bbe-4f33-8b36-18d1bbf70723@example.com:443?type=tcp&encryption=none&security=none', + ], + [ + 'vmess', + `vmess://${Buffer.from( + JSON.stringify({ + v: '2', + ps: 'Example VMess', + add: 'example.com', + port: '443', + id: '94792286-7bbe-4f33-8b36-18d1bbf70723', + aid: '0', + scy: 'auto', + net: 'ws', + type: 'none', + host: 'example.com', + path: '/ws', + tls: 'tls', + sni: 'example.com', + }), + ).toString('base64')}`, + ], + ['trojan', 'trojan://password@example.com:443'], + ['socks4', 'socks4://127.0.0.1:1080'], + ['socks4a', 'socks4a://example.com:1080'], + ['socks5', 'socks5://user:pass@example.com:1080'], + ['http proxy', 'http://127.0.0.1:80'], + ['https proxy', 'https://user:pass@example.com:443'], + ['hysteria2', 'hysteria2://password@example.com:443'], + ['hy2', 'hy2://password@example.com:443'], +]; + +describe('validateProxyUrl', () => { + describe.each(validUrls)('Valid proxy URL: %s', (_desc, url) => { + it(`returns valid=true for "${url}"`, () => { + const res = validateProxyUrl(url); + expect(res.valid).toBe(true); + }); + }); + + it('trims surrounding whitespace before dispatching', () => { + const res = validateProxyUrl(' socks5://user:pass@example.com:1080 '); + + expect(res.valid).toBe(true); + }); + + it('returns a clear error for unsupported protocols', () => { + const res = validateProxyUrl('wireguard://example.com'); + + expect(res.valid).toBe(false); + expect(res.message).toContain('vmess://'); + expect(res.message).toContain('socks4a://'); + expect(res.message).toContain('http://'); + expect(res.message).toContain('hy2://'); + }); + + it('rejects HTTP URLs that look like subscription URLs rather than proxy endpoints', () => { + const res = validateProxyUrl('https://example.com/subscription'); + + expect(res.valid).toBe(false); + expect(res.message).toContain('path'); + }); + + it('rejects VMess configs with an invalid server host', () => { + const config = Buffer.from( + JSON.stringify({ add: 'bad_host', port: '443', id: 'uuid' }), + ).toString('base64'); + + expect(validateProxyUrl(`vmess://${config}`).valid).toBe(false); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateShadowsocksUrl.test.js b/fe-app-tachyon/src/validators/tests/validateShadowsocksUrl.test.js new file mode 100644 index 00000000..912bd224 --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateShadowsocksUrl.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { validateShadowsocksUrl } from '../validateShadowsocksUrl'; + +const validUrls = [ + [ + 'no-client', + 'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206ZG1DbHkvWmgxNVd3OStzK0dGWGlGVElrcHc3Yy9xQ0lTYUJyYWk3V2hoWT0@127.0.0.1:25144?type=tcp#shadowsocks-no-client', + ], + [ + 'client', + 'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206S3FiWXZiNkhwb1RmTUt0N2VGcUZQSmJNNXBXaHlFU0ZKTXY2dEp1Ym1Fdz06dzRNMEx5RU9OTGQ5SWlkSGc0endTbzN2R3h4NS9aQ3hId0FpaWlxck5hcz0@127.0.0.1:26627?type=tcp#shadowsocks-client', + ], + [ + 'plain-user', + 'ss://2022-blake3-aes-256-gcm:dmCly/Zh15Ww9+s+GFXiFTIkpw7c/qCISaBrai7WhhY=@127.0.0.1:27214?type=tcp#shadowsocks-plain-user', + ], + [ + 'plain-user-ipv6', + 'ss://2022-blake3-aes-256-gcm:dmCly/Zh15Ww9+s+GFXiFTIkpw7c/qCISaBrai7WhhY=@[2001:db8::1]:27214?type=tcp#shadowsocks-ipv6', + ], +]; + +const invalidUrls = [ + ['No prefix', 'uuid@127.0.0.1:443?type=tcp'], + ['No host', 'ss://password@:443?type=tcp'], + ['No port', 'ss://password@127.0.0.1?type=tcp'], + ['Invalid host', 'ss://password@bad_host:443?type=tcp'], + ['Invalid port', 'ss://password@127.0.0.1:abc?type=tcp'], + ['Missing type', 'ss://password@127.0.0.1:443'], + ['Contains space', 'ss://password@127.0.0.1:443?type=tcp #extra'], + ['Unbracketed IPv6 with port', 'ss://password@2001:db8::1:443?type=tcp'], +]; + +describe('validateShadowsocksUrl', () => { + describe.each(validUrls)('Valid URL: %s', (_desc, url) => { + it(`returns valid=true for "${url}"`, () => { + const res = validateShadowsocksUrl(url); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => { + it(`returns valid=false for "${url}"`, () => { + const res = validateShadowsocksUrl(url); + expect(res.valid).toBe(false); + }); + }); + + it('detects invalid port range', () => { + const res = validateShadowsocksUrl( + 'ss://password@127.0.0.1:99999?type=tcp', + ); + expect(res.valid).toBe(false); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateSocksUrl.test.js b/fe-app-tachyon/src/validators/tests/validateSocksUrl.test.js new file mode 100644 index 00000000..de5a4a45 --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateSocksUrl.test.js @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { validateSocksUrl } from '../validateSocksUrl'; + +const validUrls = [ + ['socks4 basic', 'socks4://127.0.0.1:1080'], + ['socks4a basic', 'socks4a://127.0.0.1:1080'], + ['socks5 basic', 'socks5://127.0.0.1:1080'], + ['socks5 with username', 'socks5://user@127.0.0.1:1080'], + ['socks5 with username/password', 'socks5://user:pass@127.0.0.1:1080'], + ['socks5 with domain', 'socks5://user:pass@my.proxy.com:1080'], + ['socks5 with dash in domain', 'socks5://user:pass@fast-proxy.net:8080'], + ['socks5 with uppercase domain', 'socks5://USER:PASSWORD@Example.COM:1080'], + ['socks5 with ipv6', 'socks5://user:pass@[2001:db8::1]:1080'], +]; + +const invalidUrls = [ + ['no prefix', '127.0.0.1:1080'], + ['wrong prefix', 'http://127.0.0.1:1080'], + ['missing host', 'socks5://user:pass@:1080'], + ['missing port', 'socks5://127.0.0.1'], + ['invalid port (non-numeric)', 'socks5://127.0.0.1:abc'], + ['invalid port (too high)', 'socks5://127.0.0.1:99999'], + ['space in url', 'socks5://127.0. 0.1:1080'], + ['missing username when auth provided', 'socks5://:pass@127.0.0.1:1080'], + ['invalid domain chars', 'socks5://user:pass@exa_mple.com:1080'], + ['extra symbol', 'socks5:///127.0.0.1:1080'], + ['unbracketed ipv6 with port', 'socks5://2001:db8::1:1080'], +]; + +describe('validateSocksUrl', () => { + describe.each(validUrls)('Valid URL: %s', (_desc, url) => { + it(`returns valid=true for "${url}"`, () => { + const res = validateSocksUrl(url); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => { + it(`returns valid=false for "${url}"`, () => { + const res = validateSocksUrl(url); + expect(res.valid).toBe(false); + }); + }); + + it('detects invalid port range (0)', () => { + const res = validateSocksUrl('socks5://127.0.0.1:0'); + expect(res.valid).toBe(false); + }); + + it('detects invalid port range (65536)', () => { + const res = validateSocksUrl('socks5://127.0.0.1:65536'); + expect(res.valid).toBe(false); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateSubnet.test.js b/fe-app-tachyon/src/validators/tests/validateSubnet.test.js new file mode 100644 index 00000000..8f548e1b --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateSubnet.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { validateSubnet } from '../validateSubnet'; + +export const validSubnets = [ + ['Simple IP', '192.168.1.1'], + ['With CIDR /24', '192.168.1.1/24'], + ['CIDR /0', '10.0.0.1/0'], + ['Zero network with CIDR', '0.0.0.0/3'], + ['CIDR /32', '172.16.0.1/32'], + ['Loopback', '127.0.0.1'], + ['Broadcast with mask', '255.255.255.255/32'], + ['IPv6 address', '2001:db8::1'], + ['IPv6 CIDR /64', '2001:db8::/64'], + ['IPv6 default route', '::/0'], + ['IPv6 host /128', 'fc00::1/128'], +]; + +export const invalidSubnets = [ + ['Empty string', ''], + ['Bad format letters', 'abc.def.ghi.jkl'], + ['Octet too large', '300.1.1.1'], + ['Negative octet', '-1.2.3.4'], + ['Too many octets', '1.2.3.4.5'], + ['Not enough octets', '192.168.1'], + ['Leading zero octet', '01.2.3.4'], + ['Invalid CIDR (too high)', '192.168.1.1/33'], + ['Invalid CIDR (negative)', '192.168.1.1/-1'], + ['CIDR not number', '192.168.1.1/abc'], + ['Forbidden 0.0.0.0', '0.0.0.0'], + ['Forbidden ::', '::'], + ['Invalid IPv6 CIDR', '2001:db8::1/129'], + ['Invalid IPv6 address', '2001:db8::zzzz/64'], +]; + +describe('validateSubnet', () => { + describe.each(validSubnets)('Valid subnet: %s', (_desc, subnet) => { + it(`returns {valid:true} for "${subnet}"`, () => { + const res = validateSubnet(subnet); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidSubnets)('Invalid subnet: %s', (_desc, subnet) => { + it(`returns {valid:false} for "${subnet}"`, () => { + const res = validateSubnet(subnet); + expect(res.valid).toBe(false); + }); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateTrojanUrl.test.js b/fe-app-tachyon/src/validators/tests/validateTrojanUrl.test.js new file mode 100644 index 00000000..73fd84b4 --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateTrojanUrl.test.js @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { validateTrojanUrl } from '../validateTrojanUrl'; + +const validUrls = [ + // TCP + [ + 'tcp + none', + 'trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none', + ], + [ + 'tcp + reality', + 'trojan://cME3ZlUrYF@127.0.0.1:43772?type=tcp&security=reality&pbk=DckTwU6p6pTX9QxFXOi6vH4Vzt_RCE1vMCnj2c6hvjw&fp=chrome&sni=google.com&sid=221a80cf94&spx=%2F#trojan-tcp-reality', + ], + [ + 'tcp + tls', + 'trojan://EJjpAj02lg@127.0.0.1:11381?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-tcp-tls', + ], + [ + 'tcp + tls + insecure', + 'trojan://ZP2Ik5sxN3@127.0.0.1:16247?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-tcp-tls-insecure', + ], + [ + 'tcp + tls + ech', + 'trojan://90caP481ay@127.0.0.1:59708?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACC2y%2BAe4dqthLNpfvmtE6g%2BnaJ%2FciK6P%2BREbRLkR%2Fg%2FEgAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-tcp-tls-ech', + ], + + // mKCP + [ + 'mKCP + none', + 'trojan://N5v7iIOe9G@127.0.0.1:36319?type=kcp&headerType=none&seed=P91wFIfjzZ&security=none#trojan-mKCP', + ], + + // WebSocket + [ + 'ws + none', + 'trojan://G3cE9phv1g@127.0.0.1:57370?type=ws&path=%2Fwspath&host=google.com&security=none#trojan-websocket-none', + ], + [ + 'ws + tls', + 'trojan://FBok41WczO@127.0.0.1:59919?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-websocket-tls', + ], + [ + 'ws + tls + insecure', + 'trojan://bhwvndUBPA@127.0.0.1:22969?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-websocket-tls-insecure', + ], + [ + 'ws + tls + ech', + 'trojan://pwiduqFUWO@127.0.0.1:46765?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCFcQYEtwrFOidJJLYHvSiN%2BljRgaAIrNHoVnio3uXAOwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-websocket-tls-ech', + ], + + // gRPC + [ + 'grpc + none', + 'trojan://WMR7qkKhsV@127.0.0.1:27897?type=grpc&serviceName=TunService&authority=authority&security=none#trojan-gRPC-none', + ], + [ + 'grpc + reality', + 'trojan://KVuRNsu6KG@127.0.0.1:46077?type=grpc&serviceName=TunService&authority=authority&security=reality&pbk=Xn59i4gum3ppCICS6-_NuywrhHIVVAH54b2mjd5CFkE&fp=chrome&sni=google.com&sid=e5be&spx=%2F#trojan-gRPC-reality', + ], + [ + 'grpc + tls', + 'trojan://7BJtbywy8h@127.0.0.1:10627?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-gRPC-tls', + ], + [ + 'grpc + tls + insecure', + 'trojan://TI3PakvtP4@127.0.0.1:10435?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-gRPC-tls-insecure', + ], + [ + 'grpc + tls + ech', + 'trojan://mbzoVKL27h@127.0.0.1:38681?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCq72Ru3VbFlDpKttl3LccmInu8R2oAsCr8wzyxB0vZZQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-gRPC-tls-ech', + ], + + // HTTPUpgrade + [ + 'httpupgrade + none', + 'trojan://uc44gBwOKQ@127.0.0.1:29085?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=none#trojan-httpupgrade-none', + ], + [ + 'httpupgrade + tls', + 'trojan://MhNxbcVB14@127.0.0.1:32700?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-httpupgrade-tls', + ], + [ + 'httpupgrade + tls + insecure', + 'trojan://7SOQFUpLob@127.0.0.1:28474?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-httpupgrade-tls-insecure', + ], + [ + 'httpupgrade + tls + ech', + 'trojan://ou8pLSyx9N@127.0.0.1:17737?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACB%2FlkIkit%2BblFzE7PtbYDVF3NXK8olXJ5a7YwY%2Biy9QQwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-httpupgrade-tls-ech', + ], + + // XHTTP + [ + 'xhttp + none', + 'trojan://VEetltxLtw@127.0.0.1:59072?type=xhttp&path=%2Fxhttppath&host=google.com&mode=auto&security=none#trojan-xhttp', + ], + [ + 'tcp + ipv6', + 'trojan://04agAQapcl@[2001:db8::1]:33641?type=tcp&security=none#trojan-ipv6', + ], +]; + +const invalidUrls = [ + ['No prefix', 'uuid@host:443?type=tcp&security=tls'], + ['No password', 'trojan://@127.0.0.1:443?type=tcp&security=tls'], + ['No host', 'trojan://pass@:443?type=tcp&security=tls'], + ['No port', 'trojan://pass@127.0.0.1?type=tcp&security=tls'], + ['Invalid host', 'trojan://pass@bad_host:443?type=tcp&security=tls'], + ['Invalid port', 'trojan://pass@127.0.0.1:abc?type=tcp&security=tls'], + [ + 'Unbracketed IPv6 with port', + 'trojan://pass@2001:db8::1:443?type=tcp&security=tls', + ], + [ + 'tcp + reality + unexpected spaces', + 'trojan://cME3ZlUrYF@127.0.0.1:43772?type=tcp&security=reality&pbk=DckTwU6p6pTX9QxFXOi6vH4Vzt_RCE1vMCnj2c6hvjw&fp=chrome&sni= google.com&sid=221a80cf94&spx=%2F#trojan-tcp-reality', + ], +]; + +describe('validateTrojanUrl', () => { + describe.each(validUrls)('Valid URL: %s', (_desc, url) => { + it(`returns valid=true for "${url}"`, () => { + const res = validateTrojanUrl(url); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => { + it(`returns valid=false for "${url}"`, () => { + const res = validateTrojanUrl(url); + expect(res.valid).toBe(false); + }); + }); + + it('detects invalid port range', () => { + const res = validateTrojanUrl( + 'trojan://pass@127.0.0.1:99999?type=tcp&security=tls', + ); + expect(res.valid).toBe(false); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateUrl.test.js b/fe-app-tachyon/src/validators/tests/validateUrl.test.js new file mode 100644 index 00000000..1bbcddf5 --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateUrl.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { validateUrl } from '../validateUrl'; + +const validUrls = [ + ['Simple HTTP', 'http://example.com'], + ['Simple HTTPS', 'https://example.com'], + ['With path', 'https://example.com/path/to/page'], + ['With query', 'https://example.com/?q=test'], + ['With port', 'http://example.com:8080'], + ['With subdomain', 'https://sub.example.com'], + ['IPv4 host', 'http://127.0.0.1/tachyon-subscription-test.json'], + ['IPv4 host with port', 'http://192.168.1.1:8080/sub'], + ['IPv6 host', 'https://[2001:db8::1]/sub'], + ['IPv6 host with port', 'https://[2001:db8::1]:8443/sub'], + ['localhost host', 'http://localhost/sub'], +]; + +const invalidUrls = [ + ['Invalid format', 'not a url'], + ['Missing protocol', 'example.com'], + ['Unsupported protocol (ftp)', 'ftp://example.com'], + ['Unsupported protocol (ws)', 'ws://example.com'], + ['Empty string', ''], + ['Without tld', 'https://google'], + ['Invalid IPv4 host', 'http://999.0.0.1/sub'], + ['Unbracketed IPv6 host with port', 'http://2001:db8::1:8080/sub'], +]; + +describe('validateUrl', () => { + describe.each(validUrls)('Valid URL: %s', (_desc, url) => { + it(`returns valid=true for "${url}"`, () => { + const res = validateUrl(url); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => { + it(`returns valid=false for "${url}"`, () => { + const res = validateUrl(url); + expect(res.valid).toBe(false); + }); + }); + + it('allows custom protocol list (ftp)', () => { + const res = validateUrl('ftp://example.com', ['ftp:']); + expect(res.valid).toBe(true); + }); +}); diff --git a/fe-app-tachyon/src/validators/tests/validateVlessUrl.test.js b/fe-app-tachyon/src/validators/tests/validateVlessUrl.test.js new file mode 100644 index 00000000..428587ce --- /dev/null +++ b/fe-app-tachyon/src/validators/tests/validateVlessUrl.test.js @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { validateVlessUrl } from '../validateVlessUrl'; + +const validUrls = [ + // TCP + [ + 'tcp + none', + 'vless://94792286-7bbe-4f33-8b36-18d1bbf70723@127.0.0.1:34520?type=tcp&encryption=none&security=none#vless-tcp-none', + ], + [ + 'tcp + reality', + 'vless://e95163dc-905e-480a-afe5-20b146288679@127.0.0.1:16399?type=tcp&encryption=none&security=reality&pbk=tqhSkeDR6jsqC-BYCnZWBrdL33g705ba8tV5-ZboWTM&fp=chrome&sni=google.com&sid=f6&spx=%2F#vless-tcp-reality', + ], + [ + 'tcp implied + Amnezia reality', + 'vless://2b173dc1-e7e7-4824-aadc-d072dcefeaca@127.0.0.1:443?encryption=none&security=reality&flow=xtls-rprx-vision&sni=www.googletagmanager.com&fp=chrome&pbk=R3BKE4AvPMYy_JPKB2mB1cLEL8jX-qXR1hYE3Iarsmk&sid=92ca63541ff03a57#AmneziaVPN', + ], + [ + 'tcp + tls', + 'vless://2e9e8288-060e-4da2-8b9f-a1c81826feb7@127.0.0.1:19316?type=tcp&encryption=none&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#vless-tcp-tls', + ], + // mKCP + [ + 'mKCP + none', + 'vless://72e201d7-7841-4a32-b266-4aa3eb776d51@127.0.0.1:17270?type=kcp&encryption=none&headerType=none&seed=AirziWi4ng&security=none#vless-mKCP', + ], + // WebSocket + [ + 'ws + none', + 'vless://d86daef7-565b-4ecd-a9ee-bac847ad38e6@127.0.0.1:12928?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=none#vless-websocket-none', + ], + [ + 'ws + tls', + 'vless://fe0f0941-09a9-4e46-bc69-e00190d7bb9c@127.0.0.1:10156?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=tls&fp=chrome&sni=google.com#vless-websocket-tls', + ], + // gRPC + [ + 'grpc + none', + 'vless://974b39e3-f7bf-42b9-933c-16699c635e77@127.0.0.1:15633?type=grpc&encryption=none&serviceName=TunService&security=none#vless-gRPC-none', + ], + [ + 'grpc + reality', + 'vless://651e7eca-5152-46f1-baf2-d502e0af7b27@127.0.0.1:28535?type=grpc&encryption=none&serviceName=TunService&security=reality&pbk=nhZ7NiKfcqESa5ZeBFfsq9o18W-OWOAHLln9UmuVXSk&fp=chrome&sni=google.com&sid=11cbaeaa&spx=%2F#vless-gRPC-reality', + ], + // HTTPUpgrade + [ + 'httpupgrade + none', + 'vless://2b98f144-847f-42f7-8798-e1a32d27bdc7@127.0.0.1:47154?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=none#vless-httpupgrade-none', + ], + [ + 'httpupgrade + tls', + 'vless://76dbd0ff-1a35-4f0c-a9ba-3c5890b7dea6@127.0.0.1:50639?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=tls&sni=google.com#vless-httpupgrade-tls', + ], + // XHTTP + [ + 'xhttp + none', + 'vless://c2841505-ec32-4b8d-b6dd-3e19d648c321@127.0.0.1:45507?type=xhttp&encryption=none&path=%2Fxhttppath&host=xhttp&mode=auto&security=none#vless-xhttp', + ], + [ + 'tcp + ipv6', + 'vless://94792286-7bbe-4f33-8b36-18d1bbf70723@[2001:db8::1]:34520?type=tcp&encryption=none&security=none#vless-ipv6', + ], +]; + +const invalidUrls = [ + ['No prefix', 'uuid@host:443?type=tcp&security=tls'], + ['No uuid', 'vless://@127.0.0.1:443?type=tcp&security=tls'], + ['No host', 'vless://uuid@:443?type=tcp&security=tls'], + ['No port', 'vless://uuid@127.0.0.1?type=tcp&security=tls'], + ['Invalid host', 'vless://uuid@bad_host:443?type=tcp&security=tls'], + ['Invalid port', 'vless://uuid@127.0.0.1:abc?type=tcp&security=tls'], + [ + 'Unbracketed IPv6 with port', + 'vless://uuid@2001:db8::1:443?type=tcp&security=tls', + ], + ['Unsupported type', 'vless://uuid@127.0.0.1:443?type=quic&security=tls'], + ['Missing security', 'vless://uuid@127.0.0.1:443?type=tcp'], + [ + 'reality without pbk', + 'vless://uuid@127.0.0.1:443?type=tcp&security=reality&fp=chrome', + ], + [ + 'reality without fp', + 'vless://uuid@127.0.0.1:443?type=tcp&security=reality&pbk=abc', + ], + [ + 'tcp + reality + unexpected spaces', + 'vless://e95163dc-905e-480a-afe5-20b146288679@127.0.0.1:16399?type=tcp&encryption=none&security=reality&pbk=tqhSkeDR6jsqC-BYCnZWBrdL33g705ba8tV5-ZboWTM&fp=chrome&sni= google.com&sid=f6&spx=%2F#vless-tcp-reality', + ], +]; + +describe('validateVlessUrl', () => { + describe.each(validUrls)('Valid URL: %s', (_desc, url) => { + it(`returns valid=true for "${url}"`, () => { + const res = validateVlessUrl(url); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => { + it(`returns valid=false for "${url}"`, () => { + const res = validateVlessUrl(url); + expect(res.valid).toBe(false); + }); + }); + + it('detects invalid port range', () => { + const res = validateVlessUrl( + 'vless://uuid@127.0.0.1:99999?type=tcp&security=tls', + ); + expect(res.valid).toBe(false); + }); +}); diff --git a/fe-app-tachyon/src/validators/types.ts b/fe-app-tachyon/src/validators/types.ts new file mode 100644 index 00000000..160e909d --- /dev/null +++ b/fe-app-tachyon/src/validators/types.ts @@ -0,0 +1,13 @@ +export interface ValidationResult { + valid: boolean; + message: string; +} + +interface BulkValidationResultItem extends ValidationResult { + value: T; +} + +export interface BulkValidationResult { + valid: boolean; + results: BulkValidationResultItem[]; +} diff --git a/fe-app-tachyon/src/validators/validateDns.ts b/fe-app-tachyon/src/validators/validateDns.ts new file mode 100644 index 00000000..6e0ae4b2 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateDns.ts @@ -0,0 +1,38 @@ +import { validateDomain } from './validateDomain'; +import { isValidPort, parseHostPort, unbracketHost } from './hostPort'; +import { validateIP } from './validateIp'; +import { ValidationResult } from './types'; + +export function validateDNS(value: string): ValidationResult { + if (!value) { + return { valid: false, message: _('DNS server address cannot be empty') }; + } + + const [addressPart, ...pathParts] = value.split('/'); + const parsedHostPort = parseHostPort(addressPart); + const host = parsedHostPort + ? parsedHostPort.host + : unbracketHost(addressPart); + const domainValue = parsedHostPort + ? host + (pathParts.length > 0 ? `/${pathParts.join('/')}` : '') + : value.replace(/:(\d+)(?=\/|$)/, ''); + + if (parsedHostPort && !isValidPort(parsedHostPort.port)) { + return { valid: false, message: _('Invalid DNS server port') }; + } + + if (validateIP(host).valid) { + return { valid: true, message: _('Valid') }; + } + + if (validateDomain(domainValue).valid) { + return { valid: true, message: _('Valid') }; + } + + return { + valid: false, + message: _( + 'Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH', + ), + }; +} diff --git a/fe-app-tachyon/src/validators/validateDomain.ts b/fe-app-tachyon/src/validators/validateDomain.ts new file mode 100644 index 00000000..067e4373 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateDomain.ts @@ -0,0 +1,77 @@ +import { ValidationResult } from './types'; + +function asciiHostname(hostname: string): string | null { + if ( + !hostname || + /[\s@:/]/.test(hostname) || + hostname.startsWith('.') || + hostname.endsWith('.') + ) { + return null; + } + + try { + return new URL(`http://${hostname}`).hostname; + } catch { + return null; + } +} + +function validAsciiDomain(hostname: string, requireDot = true): boolean { + if (!hostname || hostname.length > 253) { + return false; + } + + const parts = hostname.split('.'); + + if (parts.some((part) => !part || part.length > 63)) { + return false; + } + + if (parts.some((part) => !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part))) { + return false; + } + + if (!requireDot) { + return true; + } + + if (parts.length < 2) { + return false; + } + + const tld = parts[parts.length - 1]; + return /^(?:[a-z]{2,}|xn--[a-z0-9-]{2,59})$/.test(tld); +} + +export function validateDomain( + domain: string, + allowDotTLD = false, +): ValidationResult { + const normalized = `${domain || ''}`.trim(); + + if (allowDotTLD) { + const dotTld = normalized.startsWith('.') ? normalized.slice(1) : ''; + const ascii = asciiHostname(dotTld); + if (ascii && !ascii.includes('.') && validAsciiDomain(ascii, false)) { + return { valid: true, message: _('Valid') }; + } + } + + const slashIndex = normalized.indexOf('/'); + const hostname = + slashIndex >= 0 ? normalized.slice(0, slashIndex) : normalized; + const path = slashIndex >= 0 ? normalized.slice(slashIndex) : ''; + + if (path && /\s/.test(path)) { + return { valid: false, message: _('Invalid domain address') }; + } + + const ascii = asciiHostname(hostname); + + if (!ascii || !validAsciiDomain(ascii)) { + return { valid: false, message: _('Invalid domain address') }; + } + + return { valid: true, message: _('Valid') }; +} diff --git a/fe-app-tachyon/src/validators/validateHttpProxyUrl.ts b/fe-app-tachyon/src/validators/validateHttpProxyUrl.ts new file mode 100644 index 00000000..d256f396 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateHttpProxyUrl.ts @@ -0,0 +1,90 @@ +import { ValidationResult } from './types'; +import { isValidPort, parseHostPort } from './hostPort'; + +export function validateHttpProxyUrl(url: string): ValidationResult { + try { + if (!/^https?:\/\//.test(url)) { + return { + valid: false, + message: _( + 'Invalid HTTP proxy URL: must start with http:// or https://', + ), + }; + } + + if (!url || /\s/.test(url)) { + return { + valid: false, + message: _('Invalid HTTP proxy URL: must not contain spaces'), + }; + } + + const body = url.replace(/^https?:\/\//, ''); + if (/[/?#]/.test(body)) { + return { + valid: false, + message: _( + 'Invalid HTTP proxy URL: path, query, and fragment are not supported', + ), + }; + } + + const atIndex = body.lastIndexOf('@'); + const credentials = atIndex >= 0 ? body.slice(0, atIndex) : ''; + const hostPortPart = atIndex >= 0 ? body.slice(atIndex + 1) : body; + + if (credentials) { + const [username] = credentials.split(':'); + if (!username) { + return { + valid: false, + message: _('Invalid HTTP proxy URL: missing username'), + }; + } + } + + if (!hostPortPart) { + return { + valid: false, + message: _('Invalid HTTP proxy URL: missing host and port'), + }; + } + + const parsedHostPort = parseHostPort(hostPortPart); + if (!parsedHostPort) { + return { + valid: false, + message: _('Invalid HTTP proxy URL: invalid host and port'), + }; + } + const { host, port } = parsedHostPort; + + if (!host) { + return { + valid: false, + message: _('Invalid HTTP proxy URL: missing hostname or IP'), + }; + } + + if (!port) { + return { + valid: false, + message: _('Invalid HTTP proxy URL: missing port'), + }; + } + + if (!isValidPort(port)) { + return { + valid: false, + message: _('Invalid HTTP proxy URL: invalid port number'), + }; + } + } catch (_e) { + return { + valid: false, + message: _('Invalid HTTP proxy URL: parsing failed'), + }; + } + + return { valid: true, message: _('Valid') }; +} diff --git a/fe-app-tachyon/src/validators/validateHysteriaUrl.ts b/fe-app-tachyon/src/validators/validateHysteriaUrl.ts new file mode 100644 index 00000000..4e7dfe68 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateHysteriaUrl.ts @@ -0,0 +1,155 @@ +import { ValidationResult } from './types'; +import { parseQueryString } from '../helpers/parseQueryString'; +import { isValidPort, parseHostPort } from './hostPort'; + +export function validateHysteria2Url(url: string): ValidationResult { + try { + const isHY2 = url.startsWith('hysteria2://'); + const isHY2Short = url.startsWith('hy2://'); + + if (!isHY2 && !isHY2Short) { + return { + valid: false, + message: _('Invalid HY2 URL: must start with hysteria2:// or hy2://'), + }; + } + + if (/\s/.test(url)) { + return { + valid: false, + message: _('Invalid HY2 URL: must not contain spaces'), + }; + } + + const prefix = isHY2 ? 'hysteria2://' : 'hy2://'; + const body = url.slice(prefix.length); + + const [mainPart] = body.split('#'); + const [authHostPort, queryString] = mainPart.split('?'); + + if (!authHostPort) + return { + valid: false, + message: _('Invalid HY2 URL: missing credentials/server'), + }; + + const [passwordPart, hostPortPart] = authHostPort.split('@'); + + if (!passwordPart) + return { valid: false, message: _('Invalid HY2 URL: missing password') }; + + if (!hostPortPart) + return { + valid: false, + message: _('Invalid HY2 URL: missing host & port'), + }; + + const parsedHostPort = parseHostPort(hostPortPart); + if (!parsedHostPort) { + return { + valid: false, + message: _('Invalid HY2 URL: invalid host & port'), + }; + } + const { host, port } = parsedHostPort; + + if (!host) { + return { valid: false, message: _('Invalid HY2 URL: missing host') }; + } + + if (!port) { + return { valid: false, message: _('Invalid HY2 URL: missing port') }; + } + + const cleanedPort = port.replace('/', ''); + const portEntries = cleanedPort.split(','); + + const isValidPortEntry = (entry: string) => { + if (!entry) { + return false; + } + + if (!entry.includes('-')) { + return isValidPort(entry); + } + + const rangeParts = entry.split('-'); + if (rangeParts.length !== 2) { + return false; + } + + const [start, end] = rangeParts; + return ( + isValidPort(start) && isValidPort(end) && Number(start) <= Number(end) + ); + }; + + if (!portEntries.every(isValidPortEntry)) { + return { + valid: false, + message: _('Invalid HY2 URL: invalid port number'), + }; + } + + if (queryString) { + const params = parseQueryString(queryString); + const paramsKeys = Object.keys(params); + + if ( + paramsKeys.includes('insecure') && + !['0', '1'].includes(params.insecure) + ) { + return { + valid: false, + message: _('Invalid HY2 URL: insecure must be 0 or 1'), + }; + } + + const validObfsTypes = ['none', 'salamander']; + + if ( + paramsKeys.includes('obfs') && + !validObfsTypes.includes(params.obfs) + ) { + return { + valid: false, + message: _('Invalid HY2 URL: unsupported obfs type'), + }; + } + + if ( + paramsKeys.includes('obfs') && + params.obfs !== 'none' && + !params['obfs-password'] + ) { + return { + valid: false, + message: _( + 'Invalid HY2 URL: obfs-password required when obfs is set', + ), + }; + } + + if (paramsKeys.includes('sni') && !params.sni) { + return { + valid: false, + message: _('Invalid HY2 URL: sni cannot be empty'), + }; + } + + if ( + paramsKeys.includes('mport') && + (!params.mport || !params.mport.split(',').every(isValidPortEntry)) + ) { + return { + valid: false, + message: _('Invalid HY2 URL: invalid port number'), + }; + } + } + + return { valid: true, message: _('Valid') }; + } catch (_e) { + return { valid: false, message: _('Invalid HY2 URL: parsing failed') }; + } +} diff --git a/fe-app-tachyon/src/validators/validateIp.ts b/fe-app-tachyon/src/validators/validateIp.ts new file mode 100644 index 00000000..9d24f542 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateIp.ts @@ -0,0 +1,29 @@ +import { ValidationResult } from './types'; + +export function isIPv4(ip: string): boolean { + const ipRegex = + /^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/; + + return ipRegex.test(ip); +} + +export function isIPv6(ip: string): boolean { + if (!ip.includes(':') || ip.includes('%')) { + return false; + } + + try { + new URL(`http://[${ip}]/`); + return true; + } catch (_e) { + return false; + } +} + +export function validateIP(ip: string): ValidationResult { + if (isIPv4(ip) || isIPv6(ip)) { + return { valid: true, message: _('Valid') }; + } + + return { valid: false, message: _('Invalid IP address') }; +} diff --git a/fe-app-tachyon/src/validators/validateOutboundJson.ts b/fe-app-tachyon/src/validators/validateOutboundJson.ts new file mode 100644 index 00000000..6fc54750 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateOutboundJson.ts @@ -0,0 +1,119 @@ +import { ValidationResult } from './types'; + +const SERVER_OUTBOUND_TYPES = new Set([ + 'vless', + 'vmess', + 'trojan', + 'shadowsocks', + 'socks', + 'http', + 'hysteria2', + 'hysteria', +]); + +function invalid(message: string): ValidationResult { + return { valid: false, message }; +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function validateServerPort(value: unknown): boolean { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value >= 1 && + value <= 65535 + ); +} + +function validateOutbounds(value: unknown): boolean { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((item) => nonEmptyString(item)) + ); +} + +export function validateOutboundJson( + value: string, + usedTags: string[] = [], +): ValidationResult { + const normalized = `${value || ''}`.trim(); + + if (!normalized.length) { + return invalid(_('JSON outbound cannot be empty')); + } + + let parsed: unknown; + + try { + parsed = JSON.parse(normalized); + } catch { + return { valid: false, message: _('Invalid JSON format') }; + } + + if (!isPlainObject(parsed)) { + return invalid(_('JSON outbound must be a JSON object')); + } + + if (!nonEmptyString(parsed.type)) { + return invalid(_('JSON outbound must contain a non-empty type field')); + } + + if (!nonEmptyString(parsed.tag)) { + return invalid(_('JSON outbound must contain a non-empty tag field')); + } + + const tag = parsed.tag.trim(); + if (usedTags.some((usedTag) => `${usedTag || ''}`.trim() === tag)) { + return invalid(_('Duplicate JSON outbound tag')); + } + + const type = parsed.type.trim().toLowerCase(); + + if ( + (type === 'selector' || type === 'urltest') && + !validateOutbounds(parsed.outbounds) + ) { + return invalid( + _( + 'Selector and URLTest outbounds must contain a non-empty outbounds array', + ), + ); + } + + if (SERVER_OUTBOUND_TYPES.has(type)) { + if (!nonEmptyString(parsed.server)) { + return invalid( + _('Server outbound must contain a non-empty server field'), + ); + } + + if (!validateServerPort(parsed.server_port)) { + return invalid( + _('Server outbound must contain a numeric server_port from 1 to 65535'), + ); + } + } else if ( + parsed.server_port !== undefined && + !validateServerPort(parsed.server_port) + ) { + return invalid(_('server_port must be a number from 1 to 65535')); + } + + if (parsed.outbounds !== undefined && !validateOutbounds(parsed.outbounds)) { + return invalid(_('outbounds must be a non-empty array of strings')); + } + + if (parsed.detour !== undefined && !nonEmptyString(parsed.detour)) { + return invalid(_('detour must be a non-empty string')); + } + + return { valid: true, message: _('Valid') }; +} diff --git a/fe-app-tachyon/src/validators/validatePath.ts b/fe-app-tachyon/src/validators/validatePath.ts new file mode 100644 index 00000000..d08773f7 --- /dev/null +++ b/fe-app-tachyon/src/validators/validatePath.ts @@ -0,0 +1,26 @@ +import { ValidationResult } from './types'; + +export function validatePath(value: string): ValidationResult { + if (!value) { + return { + valid: false, + message: _('Path cannot be empty'), + }; + } + + const pathRegex = /^\/[a-zA-Z0-9_\-/.]+$/; + + if (pathRegex.test(value)) { + return { + valid: true, + message: _('Valid'), + }; + } + + return { + valid: false, + message: _( + 'Invalid path format. Path must start with "/" and contain valid characters', + ), + }; +} diff --git a/fe-app-tachyon/src/validators/validateProxyUrl.ts b/fe-app-tachyon/src/validators/validateProxyUrl.ts new file mode 100644 index 00000000..bd719576 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateProxyUrl.ts @@ -0,0 +1,51 @@ +import { ValidationResult } from './types'; +import { validateShadowsocksUrl } from './validateShadowsocksUrl'; +import { validateVlessUrl } from './validateVlessUrl'; +import { validateVmessUrl } from './validateVmessUrl'; +import { validateTrojanUrl } from './validateTrojanUrl'; +import { validateSocksUrl } from './validateSocksUrl'; +import { validateHysteria2Url } from './validateHysteriaUrl'; +import { validateHttpProxyUrl } from './validateHttpProxyUrl'; + +// TODO refactor current validation and add tests +export function validateProxyUrl(url: string): ValidationResult { + const trimmedUrl = url.trim(); + + if (trimmedUrl.startsWith('ss://')) { + return validateShadowsocksUrl(trimmedUrl); + } + + if (trimmedUrl.startsWith('vless://')) { + return validateVlessUrl(trimmedUrl); + } + + if (trimmedUrl.startsWith('vmess://')) { + return validateVmessUrl(trimmedUrl); + } + + if (trimmedUrl.startsWith('trojan://')) { + return validateTrojanUrl(trimmedUrl); + } + + if (/^socks(4|4a|5):\/\//.test(trimmedUrl)) { + return validateSocksUrl(trimmedUrl); + } + + if (/^https?:\/\//.test(trimmedUrl)) { + return validateHttpProxyUrl(trimmedUrl); + } + + if ( + trimmedUrl.startsWith('hysteria2://') || + trimmedUrl.startsWith('hy2://') + ) { + return validateHysteria2Url(trimmedUrl); + } + + return { + valid: false, + message: _( + 'URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://', + ), + }; +} diff --git a/fe-app-tachyon/src/validators/validateShadowsocksUrl.ts b/fe-app-tachyon/src/validators/validateShadowsocksUrl.ts new file mode 100644 index 00000000..d1728091 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateShadowsocksUrl.ts @@ -0,0 +1,102 @@ +import { ValidationResult } from './types'; +import { isValidPort, parseHostPort } from './hostPort'; + +// TODO refactor current validation and add tests +export function validateShadowsocksUrl(url: string): ValidationResult { + if (!url.startsWith('ss://')) { + return { + valid: false, + message: _('Invalid Shadowsocks URL: must start with ss://'), + }; + } + + try { + if (!url || /\s/.test(url)) { + return { + valid: false, + message: _('Invalid Shadowsocks URL: must not contain spaces'), + }; + } + + const mainPart = url.includes('?') ? url.split('?')[0] : url.split('#')[0]; + + const encryptedPart = mainPart.split('/')[2]?.split('@')[0]; + + if (!encryptedPart) { + return { + valid: false, + message: _('Invalid Shadowsocks URL: missing credentials'), + }; + } + + try { + const decoded = atob(encryptedPart); + + if (!decoded.includes(':')) { + return { + valid: false, + message: _( + 'Invalid Shadowsocks URL: decoded credentials must contain method:password', + ), + }; + } + } catch (_e) { + if (!encryptedPart.includes(':') && !encryptedPart.includes('-')) { + return { + valid: false, + message: _( + 'Invalid Shadowsocks URL: missing method and password separator ":"', + ), + }; + } + } + + const serverPart = url.split('@')[1]; + + if (!serverPart) { + return { + valid: false, + message: _('Invalid Shadowsocks URL: missing server address'), + }; + } + + const parsedHostPort = parseHostPort(serverPart); + if (!parsedHostPort) { + return { + valid: false, + message: _('Invalid Shadowsocks URL: invalid server and port'), + }; + } + const { host: server, port: portAndRest } = parsedHostPort; + + if (!server) { + return { + valid: false, + message: _('Invalid Shadowsocks URL: missing server'), + }; + } + + const port = portAndRest ? portAndRest.split(/[?#]/)[0] : null; + + if (!port) { + return { + valid: false, + message: _('Invalid Shadowsocks URL: missing port'), + }; + } + + if (!isValidPort(port)) { + return { + valid: false, + message: _('Invalid port number. Must be between 1 and 65535'), + }; + } + } catch (_e) { + return { + valid: false, + message: _('Invalid Shadowsocks URL: parsing failed'), + }; + } + + return { valid: true, message: _('Valid') }; +} diff --git a/fe-app-tachyon/src/validators/validateSocksUrl.ts b/fe-app-tachyon/src/validators/validateSocksUrl.ts new file mode 100644 index 00000000..911cb8d3 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateSocksUrl.ts @@ -0,0 +1,76 @@ +import { ValidationResult } from './types'; +import { isValidPort, parseHostPort } from './hostPort'; + +export function validateSocksUrl(url: string): ValidationResult { + try { + if (!/^socks(4|4a|5):\/\//.test(url)) { + return { + valid: false, + message: _( + 'Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://', + ), + }; + } + + if (!url || /\s/.test(url)) { + return { + valid: false, + message: _('Invalid SOCKS URL: must not contain spaces'), + }; + } + + const body = url.replace(/^socks(4|4a|5):\/\//, ''); + const [authAndHost] = body.split('#'); // отбрасываем hash, если есть + const [credentials, hostPortPart] = authAndHost.includes('@') + ? authAndHost.split('@') + : [null, authAndHost]; + + if (credentials) { + const [username, _password] = credentials.split(':'); + if (!username) { + return { + valid: false, + message: _('Invalid SOCKS URL: missing username'), + }; + } + } + + if (!hostPortPart) { + return { + valid: false, + message: _('Invalid SOCKS URL: missing host and port'), + }; + } + + const parsedHostPort = parseHostPort(hostPortPart); + if (!parsedHostPort) { + return { + valid: false, + message: _('Invalid SOCKS URL: invalid host and port'), + }; + } + const { host, port } = parsedHostPort; + + if (!host) { + return { + valid: false, + message: _('Invalid SOCKS URL: missing hostname or IP'), + }; + } + + if (!port) { + return { valid: false, message: _('Invalid SOCKS URL: missing port') }; + } + + if (!isValidPort(port)) { + return { + valid: false, + message: _('Invalid SOCKS URL: invalid port number'), + }; + } + } catch (_e) { + return { valid: false, message: _('Invalid SOCKS URL: parsing failed') }; + } + + return { valid: true, message: _('Valid') }; +} diff --git a/fe-app-tachyon/src/validators/validateSubnet.ts b/fe-app-tachyon/src/validators/validateSubnet.ts new file mode 100644 index 00000000..27247b25 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateSubnet.ts @@ -0,0 +1,43 @@ +import { ValidationResult } from './types'; +import { isIPv4, isIPv6 } from './validateIp'; + +export function validateSubnet(value: string): ValidationResult { + const [ip, cidr, extra] = value.split('/'); + + if (!ip || extra !== undefined || (!isIPv4(ip) && !isIPv6(ip))) { + return { + valid: false, + message: _('Invalid format. Use an IP address or CIDR subnet'), + }; + } + + if ((ip === '0.0.0.0' || ip === '::') && cidr == null) { + return { + valid: false, + message: _('Unspecified IP address is not allowed'), + }; + } + + if (cidr) { + if (!/^\d+$/.test(cidr)) { + return { + valid: false, + message: _('Invalid CIDR prefix'), + }; + } + + const cidrNum = parseInt(cidr, 10); + const maxPrefix = isIPv6(ip) ? 128 : 32; + + if (cidrNum < 0 || cidrNum > maxPrefix) { + return { + valid: false, + message: _( + 'CIDR must be between 0 and 32 for IPv4 or 0 and 128 for IPv6', + ), + }; + } + } + + return { valid: true, message: _('Valid') }; +} diff --git a/fe-app-tachyon/src/validators/validateTrojanUrl.ts b/fe-app-tachyon/src/validators/validateTrojanUrl.ts new file mode 100644 index 00000000..6c2501b5 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateTrojanUrl.ts @@ -0,0 +1,65 @@ +import { ValidationResult } from './types'; +import { isValidPort, parseHostPort } from './hostPort'; + +export function validateTrojanUrl(url: string): ValidationResult { + try { + if (!url.startsWith('trojan://')) { + return { + valid: false, + message: _('Invalid Trojan URL: must start with trojan://'), + }; + } + + if (!url || /\s/.test(url)) { + return { + valid: false, + message: _('Invalid Trojan URL: must not contain spaces'), + }; + } + + const body = url.slice('trojan://'.length); + const [mainPart] = body.split('#'); + const [userHostPort] = mainPart.split('?'); + + const [userPart, hostPortPart] = userHostPort.split('@'); + + if (!userHostPort) + return { + valid: false, + message: 'Invalid Trojan URL: missing credentials and host', + }; + + if (!userPart) + return { valid: false, message: 'Invalid Trojan URL: missing password' }; + + if (!hostPortPart) + return { + valid: false, + message: 'Invalid Trojan URL: missing hostname and port', + }; + + const parsedHostPort = parseHostPort(hostPortPart); + if (!parsedHostPort) + return { + valid: false, + message: 'Invalid Trojan URL: invalid host and port', + }; + const { host, port } = parsedHostPort; + + if (!host) + return { valid: false, message: 'Invalid Trojan URL: missing hostname' }; + + if (!port) + return { valid: false, message: 'Invalid Trojan URL: missing port' }; + + if (!isValidPort(port)) + return { + valid: false, + message: 'Invalid Trojan URL: invalid port number', + }; + } catch (_e) { + return { valid: false, message: _('Invalid Trojan URL: parsing failed') }; + } + + return { valid: true, message: _('Valid') }; +} diff --git a/fe-app-tachyon/src/validators/validateUrl.ts b/fe-app-tachyon/src/validators/validateUrl.ts new file mode 100644 index 00000000..0c4e08df --- /dev/null +++ b/fe-app-tachyon/src/validators/validateUrl.ts @@ -0,0 +1,39 @@ +import { ValidationResult } from './types'; +import { isValidHost, isValidPort } from './hostPort'; + +export function validateUrl( + url: string, + protocols = ['http:', 'https:'], +): ValidationResult { + if (!url.length) { + return { valid: false, message: _('Invalid URL format') }; + } + + const hasValidProtocol = protocols.some((p) => url.indexOf(p + '//') === 0); + + if (!hasValidProtocol) + return { + valid: false, + message: + _('URL must use one of the following protocols:') + + ' ' + + protocols.join(', '), + }; + + try { + const parsed = new URL(url); + const host = parsed.hostname.startsWith('[') + ? parsed.hostname.slice(1, -1) + : parsed.hostname; + if ( + (isValidHost(host) || host === 'localhost') && + (!parsed.port || isValidPort(parsed.port)) + ) { + return { valid: true, message: _('Valid') }; + } + } catch (_e) { + return { valid: false, message: _('Invalid URL format') }; + } + + return { valid: false, message: _('Invalid URL format') }; +} diff --git a/fe-app-tachyon/src/validators/validateVlessUrl.ts b/fe-app-tachyon/src/validators/validateVlessUrl.ts new file mode 100644 index 00000000..0b7afd11 --- /dev/null +++ b/fe-app-tachyon/src/validators/validateVlessUrl.ts @@ -0,0 +1,119 @@ +import { ValidationResult } from './types'; +import { parseQueryString } from '../helpers/parseQueryString'; +import { isValidPort, parseHostPort } from './hostPort'; + +export function validateVlessUrl(url: string): ValidationResult { + try { + if (!url.startsWith('vless://')) + return { + valid: false, + message: 'Invalid VLESS URL: must start with vless://', + }; + + if (/\s/.test(url)) + return { + valid: false, + message: 'Invalid VLESS URL: must not contain spaces', + }; + + const body = url.slice('vless://'.length); + + const [mainPart] = body.split('#'); + + const [userHostPort, queryString] = mainPart.split('?'); + + if (!userHostPort) + return { + valid: false, + message: 'Invalid VLESS URL: missing host and UUID', + }; + + const [userPart, hostPortPart] = userHostPort.split('@'); + + if (!userPart) + return { valid: false, message: 'Invalid VLESS URL: missing UUID' }; + + if (!hostPortPart) + return { valid: false, message: 'Invalid VLESS URL: missing server' }; + + const parsedHostPort = parseHostPort(hostPortPart); + if (!parsedHostPort) + return { + valid: false, + message: 'Invalid VLESS URL: invalid host and port', + }; + const { host, port } = parsedHostPort; + + if (!host) + return { valid: false, message: 'Invalid VLESS URL: missing hostname' }; + + if (!port) + return { valid: false, message: 'Invalid VLESS URL: missing port' }; + + const cleanedPort = port.replace('/', ''); + if (!isValidPort(cleanedPort)) + return { + valid: false, + message: 'Invalid VLESS URL: invalid port number', + }; + + if (!queryString) + return { + valid: false, + message: 'Invalid VLESS URL: missing query parameters', + }; + + const params = parseQueryString(queryString); + + const validTypes = [ + 'tcp', + 'raw', + 'udp', + 'grpc', + 'http', + 'httpupgrade', + 'xhttp', + 'ws', + 'kcp', + ]; + const validSecurities = ['tls', 'reality', 'none']; + const transportType = params.type || 'tcp'; + + if (!validTypes.includes(transportType)) + return { + valid: false, + message: 'Invalid VLESS URL: unsupported or missing type', + }; + + if (!params.security || !validSecurities.includes(params.security)) + return { + valid: false, + message: 'Invalid VLESS URL: unsupported or missing security', + }; + + if (params.security === 'reality') { + if (!params.pbk) + return { + valid: false, + message: 'Invalid VLESS URL: missing pbk for reality', + }; + if (!params.fp) + return { + valid: false, + message: 'Invalid VLESS URL: missing fp for reality', + }; + } + + if (params.flow === 'xtls-rprx-vision-udp443') { + return { + valid: false, + message: + 'Invalid VLESS URL: flow xtls-rprx-vision-udp443 is not supported', + }; + } + + return { valid: true, message: _('Valid') }; + } catch (_e) { + return { valid: false, message: _('Invalid VLESS URL: parsing failed') }; + } +} diff --git a/fe-app-tachyon/src/validators/validateVmessUrl.ts b/fe-app-tachyon/src/validators/validateVmessUrl.ts new file mode 100644 index 00000000..e8e808ca --- /dev/null +++ b/fe-app-tachyon/src/validators/validateVmessUrl.ts @@ -0,0 +1,70 @@ +import { ValidationResult } from './types'; +import { isValidHost, isValidPort } from './hostPort'; + +function decodeBase64Json(value: string): unknown { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + '=', + ); + const binary = atob(padded); + const decoded = decodeURIComponent( + Array.from(binary) + .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`) + .join(''), + ); + + return JSON.parse(decoded); +} + +export function validateVmessUrl(url: string): ValidationResult { + try { + if (!url.startsWith('vmess://')) { + return { + valid: false, + message: 'Invalid VMess URL: must start with vmess://', + }; + } + + if (/\s/.test(url)) { + return { + valid: false, + message: 'Invalid VMess URL: must not contain spaces', + }; + } + + const body = url.slice('vmess://'.length); + const [encoded] = body.split('#'); + if (!encoded) { + return { + valid: false, + message: 'Invalid VMess URL: missing encoded config', + }; + } + + const config = decodeBase64Json(encoded); + if (!config || typeof config !== 'object') { + return { valid: false, message: 'Invalid VMess URL: invalid config' }; + } + + const { add, port, id } = config as Record; + if (!add || typeof add !== 'string' || !isValidHost(add)) { + return { valid: false, message: 'Invalid VMess URL: invalid server' }; + } + + if (!isValidPort(port)) { + return { + valid: false, + message: 'Invalid VMess URL: invalid port number', + }; + } + + if (!id || typeof id !== 'string') { + return { valid: false, message: 'Invalid VMess URL: missing UUID' }; + } + + return { valid: true, message: _('Valid') }; + } catch (_e) { + return { valid: false, message: _('Invalid VMess URL: parsing failed') }; + } +} diff --git a/fe-app-tachyon/tests/setup/global-mocks.ts b/fe-app-tachyon/tests/setup/global-mocks.ts new file mode 100644 index 00000000..8f93270d --- /dev/null +++ b/fe-app-tachyon/tests/setup/global-mocks.ts @@ -0,0 +1,2 @@ +// tests/setup/global-mocks.ts +globalThis._ = (key: string) => key; diff --git a/fe-app-tachyon/tsconfig.json b/fe-app-tachyon/tsconfig.json new file mode 100644 index 00000000..3c1f3716 --- /dev/null +++ b/fe-app-tachyon/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/fe-app-tachyon/tsup.config.ts b/fe-app-tachyon/tsup.config.ts new file mode 100644 index 00000000..1ee9a1ac --- /dev/null +++ b/fe-app-tachyon/tsup.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'tsup'; +import fs from 'fs'; +import path from 'path'; + +export default defineConfig({ + entry: ['src/main.ts'], + format: ['esm'], // пусть tsup генерит export {...} + outDir: '../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon', + outExtension: () => ({ js: '.js' }), + dts: false, + clean: false, + sourcemap: false, + banner: { + js: `// This file is autogenerated, please don't change manually \n"use strict";`, + }, + esbuildOptions(options) { + options.legalComments = 'none'; + }, + onSuccess: () => { + const outDir = + '../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon'; + const file = path.join(outDir, 'main.js'); + let code = fs.readFileSync(file, 'utf8'); + + code = code.replace( + /export\s*{([\s\S]*?)}/, + (match, group) => { + return `return baseclass.extend({${group}})`; + } + ); + + fs.writeFileSync(file, code, 'utf8'); + console.log(`✅ Patched LuCI build: ${file}`); + }, +}); diff --git a/fe-app-tachyon/vitest.config.js b/fe-app-tachyon/vitest.config.js new file mode 100644 index 00000000..a8eb8684 --- /dev/null +++ b/fe-app-tachyon/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + setupFiles: ['./tests/setup/global-mocks.ts'], + }, +}); diff --git a/fe-app-tachyon/watch-upload.js b/fe-app-tachyon/watch-upload.js new file mode 100644 index 00000000..ae2ee80c --- /dev/null +++ b/fe-app-tachyon/watch-upload.js @@ -0,0 +1,113 @@ +import 'dotenv/config'; +import chokidar from 'chokidar'; +import SFTPClient from 'ssh2-sftp-client'; +import path from 'path'; +import fs from 'fs'; +import { glob } from 'glob'; + +const sftp = new SFTPClient(); + +const config = { + host: process.env.SFTP_HOST, + port: Number(process.env.SFTP_PORT || 22), + username: process.env.SFTP_USER, + ...(process.env.SFTP_PRIVATE_KEY + ? { privateKey: fs.readFileSync(process.env.SFTP_PRIVATE_KEY) } + : { password: process.env.SFTP_PASS }), +}; + +const syncDirs = [ + { + local: path.resolve( + process.env.LOCAL_DIR_FE ?? + '../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon', + ), + remote: + process.env.REMOTE_DIR_FE ?? '/www/luci-static/resources/view/tachyon', + }, + { + local: path.resolve( + process.env.LOCAL_DIR_BIN ?? '../tachyon/files/usr/bin/', + ), + remote: process.env.REMOTE_DIR_BIN ?? '/usr/bin/', + }, + { + local: path.resolve( + process.env.LOCAL_DIR_LIB ?? '../tachyon/files/usr/lib/', + ), + remote: process.env.REMOTE_DIR_LIB ?? '/usr/lib/tachyon/', + }, + { + local: path.resolve( + process.env.LOCAL_DIR_INIT ?? '../tachyon/files/etc/init.d/', + ), + remote: process.env.REMOTE_DIR_INIT ?? '/etc/init.d/', + }, +]; + +async function uploadFile(filePath, baseDir, remoteBase) { + const relativePath = path.relative(baseDir, filePath); + const remotePath = path.posix.join(remoteBase, relativePath); + + console.log(`↑ Uploading: ${relativePath} -> ${remotePath}`); + try { + await sftp.fastPut(filePath, remotePath); + console.log(`✓ Uploaded: ${relativePath}`); + } catch (err) { + console.error(`✗ Failed: ${relativePath}: ${err.message}`); + } +} + +async function deleteFile(filePath, baseDir, remoteBase) { + const relativePath = path.relative(baseDir, filePath); + const remotePath = path.posix.join(remoteBase, relativePath); + + console.log(`⨯ Removing: ${relativePath}`); + try { + await sftp.delete(remotePath); + console.log(`✓ Removed: ${relativePath}`); + } catch (err) { + console.warn(`⚠ Could not delete ${relativePath}: ${err.message}`); + } +} + +async function uploadAllFiles() { + for (const { local, remote } of syncDirs) { + console.log(`📂 Uploading all from ${local}`); + const files = await glob(`${local}/**/*`, { nodir: true }); + for (const file of files) { + await uploadFile(file, local, remote); + } + } + console.log('✅ Initial upload complete!'); +} + +async function main() { + await sftp.connect(config); + console.log(`🔌 Connected to ${config.host}`); + + await uploadAllFiles(); + + for (const { local, remote } of syncDirs) { + chokidar + .watch(local, { ignoreInitial: true }) + .on('all', async (event, filePath) => { + if (event === 'add' || event === 'change') { + await uploadFile(filePath, local, remote); + } else if (event === 'unlink') { + await deleteFile(filePath, local, remote); + } + }); + } + + process.on('SIGINT', async () => { + console.log('👋 Disconnecting...'); + await sftp.end(); + process.exit(); + }); +} + +main().catch((err) => { + process.exitCode = 1; + console.error('💥 Fatal:', err); +}); diff --git a/fe-app-tachyon/yarn.lock b/fe-app-tachyon/yarn.lock new file mode 100644 index 00000000..91d19272 --- /dev/null +++ b/fe-app-tachyon/yarn.lock @@ -0,0 +1,2107 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/generator@^7.28.3": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e" + integrity sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw== + dependencies: + "@babel/parser" "^7.28.3" + "@babel/types" "^7.28.2" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" + integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== + +"@babel/parser@7.28.4", "@babel/parser@^7.27.2", "@babel/parser@^7.28.3", "@babel/parser@^7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8" + integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg== + dependencies: + "@babel/types" "^7.28.4" + +"@babel/template@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + +"@babel/traverse@7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.4.tgz#8d456101b96ab175d487249f60680221692b958b" + integrity sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.3" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.4" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.4" + debug "^4.3.1" + +"@babel/types@7.28.4", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" + integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + +"@esbuild/aix-ppc64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz#ee6b7163a13528e099ecf562b972f2bcebe0aa97" + integrity sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw== + +"@esbuild/android-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz#115fc76631e82dd06811bfaf2db0d4979c16e2cb" + integrity sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg== + +"@esbuild/android-arm@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.10.tgz#8d5811912da77f615398611e5bbc1333fe321aa9" + integrity sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w== + +"@esbuild/android-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.10.tgz#e3e96516b2d50d74105bb92594c473e30ddc16b1" + integrity sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg== + +"@esbuild/darwin-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz#6af6bb1d05887dac515de1b162b59dc71212ed76" + integrity sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA== + +"@esbuild/darwin-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz#99ae82347fbd336fc2d28ffd4f05694e6e5b723d" + integrity sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg== + +"@esbuild/freebsd-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz#0c6d5558a6322b0bdb17f7025c19bd7d2359437d" + integrity sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg== + +"@esbuild/freebsd-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz#8c35873fab8c0857a75300a3dcce4324ca0b9844" + integrity sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA== + +"@esbuild/linux-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz#3edc2f87b889a15b4cedaf65f498c2bed7b16b90" + integrity sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ== + +"@esbuild/linux-arm@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz#86501cfdfb3d110176d80c41b27ed4611471cde7" + integrity sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg== + +"@esbuild/linux-ia32@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz#e6589877876142537c6864680cd5d26a622b9d97" + integrity sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ== + +"@esbuild/linux-loong64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz#11119e18781f136d8083ea10eb6be73db7532de8" + integrity sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg== + +"@esbuild/linux-mips64el@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz#3052f5436b0c0c67a25658d5fc87f045e7def9e6" + integrity sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA== + +"@esbuild/linux-ppc64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz#2f098920ee5be2ce799f35e367b28709925a8744" + integrity sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA== + +"@esbuild/linux-riscv64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz#fa51d7fd0a22a62b51b4b94b405a3198cf7405dd" + integrity sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA== + +"@esbuild/linux-s390x@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz#a27642e36fc282748fdb38954bd3ef4f85791e8a" + integrity sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew== + +"@esbuild/linux-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz#9d9b09c0033d17529570ced6d813f98315dfe4e9" + integrity sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA== + +"@esbuild/netbsd-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz#25c09a659c97e8af19e3f2afd1c9190435802151" + integrity sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A== + +"@esbuild/netbsd-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz#7fa5f6ffc19be3a0f6f5fd32c90df3dc2506937a" + integrity sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig== + +"@esbuild/openbsd-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz#8faa6aa1afca0c6d024398321d6cb1c18e72a1c3" + integrity sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw== + +"@esbuild/openbsd-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz#a42979b016f29559a8453d32440d3c8cd420af5e" + integrity sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw== + +"@esbuild/openharmony-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz#fd87bfeadd7eeb3aa384bbba907459ffa3197cb1" + integrity sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag== + +"@esbuild/sunos-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz#3a18f590e36cb78ae7397976b760b2b8c74407f4" + integrity sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ== + +"@esbuild/win32-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz#e71741a251e3fd971408827a529d2325551f530c" + integrity sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw== + +"@esbuild/win32-ia32@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz#c6f010b5d3b943d8901a0c87ea55f93b8b54bf94" + integrity sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw== + +"@esbuild/win32-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz#e4b3e255a1b4aea84f6e1d2ae0b73f826c3785bd" + integrity sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw== + +"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0": + version "4.9.0" + resolved "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" + integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1": + version "4.12.1" + resolved "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + +"@eslint/config-array@^0.21.0": + version "0.21.0" + resolved "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.0.tgz#abdbcbd16b124c638081766392a4d6b509f72636" + integrity sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ== + dependencies: + "@eslint/object-schema" "^2.1.6" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.3.1": + version "0.3.1" + resolved "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.3.1.tgz#d316e47905bd0a1a931fa50e669b9af4104d1617" + integrity sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA== + +"@eslint/core@^0.15.2": + version "0.15.2" + resolved "https://registry.npmmirror.com/@eslint/core/-/core-0.15.2.tgz#59386327d7862cc3603ebc7c78159d2dcc4a868f" + integrity sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1": + version "3.3.1" + resolved "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964" + integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@9.36.0": + version "9.36.0" + resolved "https://registry.npmmirror.com/@eslint/js/-/js-9.36.0.tgz#b1a3893dd6ce2defed5fd49de805ba40368e8fef" + integrity sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw== + +"@eslint/object-schema@^2.1.6": + version "2.1.6" + resolved "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f" + integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== + +"@eslint/plugin-kit@^0.3.5": + version "0.3.5" + resolved "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz#fd8764f0ee79c8ddab4da65460c641cefee017c5" + integrity sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w== + dependencies: + "@eslint/core" "^0.15.2" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@isaacs/balanced-match@^4.0.1": + version "4.0.1" + resolved "https://registry.npmmirror.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" + integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== + +"@isaacs/brace-expansion@^5.0.0": + version "5.0.0" + resolved "https://registry.npmmirror.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" + integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== + dependencies: + "@isaacs/balanced-match" "^4.0.1" + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2": + version "0.3.13" + resolved "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@rollup/rollup-android-arm-eabi@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz#7050c2acdc1214a730058e21f613ab0e1fe1ced9" + integrity sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw== + +"@rollup/rollup-android-arm64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz#3f5b2afbfcbe9021649701cf6ff0d54b1fb7e4a5" + integrity sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw== + +"@rollup/rollup-darwin-arm64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz#70a1679fb4393ba7bafb730ee56a5278cbcdafb0" + integrity sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg== + +"@rollup/rollup-darwin-x64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz#ae75aec88fa72069de9bca3a3ec22bf4e6a962bf" + integrity sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A== + +"@rollup/rollup-freebsd-arm64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz#8a2bda997faa1d7e335ce1961ce71d1a76ac6288" + integrity sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ== + +"@rollup/rollup-freebsd-x64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz#fc287bcc39b9a9c0df97336d68fd5f4458f87977" + integrity sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A== + +"@rollup/rollup-linux-arm-gnueabihf@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz#5b5a2a55dffaa64d7c7a231e80e491219e33d4f3" + integrity sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA== + +"@rollup/rollup-linux-arm-musleabihf@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz#979eab95003c21837ea0fdd8a721aa3e69fa4aa3" + integrity sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA== + +"@rollup/rollup-linux-arm64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz#53b89f1289cbeca5ed9b6ca1602a6fe1a29dd4e2" + integrity sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ== + +"@rollup/rollup-linux-arm64-musl@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz#3bbcf5e13c09d0c4c55bd9c75ec6a7aeee56fe28" + integrity sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw== + +"@rollup/rollup-linux-loong64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz#1cc71838465a8297f92ccc5cc9c29756b71f6e73" + integrity sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg== + +"@rollup/rollup-linux-ppc64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz#fe3fdf2ef57dc2d58fedd4f1e0678660772c843a" + integrity sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw== + +"@rollup/rollup-linux-riscv64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz#eebc99e75832891d58532501879ca749b1592f93" + integrity sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg== + +"@rollup/rollup-linux-riscv64-musl@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz#9a2df234d61763a44601eba17c36844a18f20539" + integrity sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg== + +"@rollup/rollup-linux-s390x-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz#f0e45ea7e41ee473c85458b1ec8fab9572cc1834" + integrity sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg== + +"@rollup/rollup-linux-x64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz#ed63dec576799fa5571eee5b2040f65faa82b49b" + integrity sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA== + +"@rollup/rollup-linux-x64-musl@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz#755c56ac79b17fbdf0359bce7e2293a11de30ad0" + integrity sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw== + +"@rollup/rollup-openharmony-arm64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz#84b4170fe28c2b41e406add6ccf8513bf91195ea" + integrity sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA== + +"@rollup/rollup-win32-arm64-msvc@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz#4fb0cd004183da819bec804eba70f1ef6936ccbf" + integrity sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA== + +"@rollup/rollup-win32-ia32-msvc@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz#1788ba80313477a31e6214390906201604ee38eb" + integrity sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g== + +"@rollup/rollup-win32-x64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz#867222f288a9557487900c7836998123ebbadc9d" + integrity sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ== + +"@rollup/rollup-win32-x64-msvc@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz#3f55b6e8fe809a7d29959d6bc686cce1804581f0" + integrity sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA== + +"@types/chai@^5.2.2": + version "5.2.2" + resolved "https://registry.npmmirror.com/@types/chai/-/chai-5.2.2.tgz#6f14cea18180ffc4416bc0fd12be05fdd73bdd6b" + integrity sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg== + dependencies: + "@types/deep-eql" "*" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6": + version "1.0.8" + resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@typescript-eslint/eslint-plugin@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz#9f251d4e85ec5089e7cccb09257ce93dbf0d7744" + integrity sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "8.45.0" + "@typescript-eslint/type-utils" "8.45.0" + "@typescript-eslint/utils" "8.45.0" + "@typescript-eslint/visitor-keys" "8.45.0" + graphemer "^1.4.0" + ignore "^7.0.0" + natural-compare "^1.4.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/parser@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.45.0.tgz#571660c98824aefb4a6ec3b3766655d1348520a4" + integrity sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ== + dependencies: + "@typescript-eslint/scope-manager" "8.45.0" + "@typescript-eslint/types" "8.45.0" + "@typescript-eslint/typescript-estree" "8.45.0" + "@typescript-eslint/visitor-keys" "8.45.0" + debug "^4.3.4" + +"@typescript-eslint/project-service@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.45.0.tgz#f83dda1bca31dae2fd6821f9131daf1edebfd46c" + integrity sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.45.0" + "@typescript-eslint/types" "^8.45.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz#59615ba506a9e3479d1efb0d09d6ab52f2a19142" + integrity sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA== + dependencies: + "@typescript-eslint/types" "8.45.0" + "@typescript-eslint/visitor-keys" "8.45.0" + +"@typescript-eslint/tsconfig-utils@8.45.0", "@typescript-eslint/tsconfig-utils@^8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz#63d38282790a2566c571bad423e7c1cad1f3d64c" + integrity sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w== + +"@typescript-eslint/type-utils@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz#04004bdf2598844faa29fb936fb6b0ee10d6d3f3" + integrity sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A== + dependencies: + "@typescript-eslint/types" "8.45.0" + "@typescript-eslint/typescript-estree" "8.45.0" + "@typescript-eslint/utils" "8.45.0" + debug "^4.3.4" + ts-api-utils "^2.1.0" + +"@typescript-eslint/types@8.45.0", "@typescript-eslint/types@^8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.45.0.tgz#fc01cd2a4690b9713b02f895e82fb43f7d960684" + integrity sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA== + +"@typescript-eslint/typescript-estree@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz#3498500f109a89b104d2770497c707e56dfe062d" + integrity sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA== + dependencies: + "@typescript-eslint/project-service" "8.45.0" + "@typescript-eslint/tsconfig-utils" "8.45.0" + "@typescript-eslint/types" "8.45.0" + "@typescript-eslint/visitor-keys" "8.45.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/utils@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.45.0.tgz#6e68e92d99019fdf56018d0e6664c76a70470c95" + integrity sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg== + dependencies: + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.45.0" + "@typescript-eslint/types" "8.45.0" + "@typescript-eslint/typescript-estree" "8.45.0" + +"@typescript-eslint/visitor-keys@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz#4e3bcc55da64ac61069ebfe62ca240567ac7d784" + integrity sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag== + dependencies: + "@typescript-eslint/types" "8.45.0" + eslint-visitor-keys "^4.2.1" + +"@vitest/expect@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" + integrity sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.4.tgz#4471c4efbd62db0d4fa203e65cc6b058a85cabd3" + integrity sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ== + dependencies: + "@vitest/spy" "3.2.4" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz#3c102f79e82b204a26c7a5921bf47d534919d3b4" + integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.4.tgz#5ce0274f24a971f6500f6fc166d53d8382430766" + integrity sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ== + dependencies: + "@vitest/utils" "3.2.4" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.4.tgz#40a8bc0346ac0aee923c0eefc2dc005d90bc987c" + integrity sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ== + dependencies: + "@vitest/pretty-format" "3.2.4" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599" + integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz#c0813bc42d99527fb8c5b138c7a88516bca46fea" + integrity sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA== + dependencies: + "@vitest/pretty-format" "3.2.4" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.15.0: + version "8.15.0" + resolved "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +asn1@^0.2.6: + version "0.2.6" + resolved "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buildcheck@~0.0.6: + version "0.0.6" + resolved "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238" + integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A== + +bundle-require@^5.1.0: + version "5.1.0" + resolved "https://registry.npmmirror.com/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee" + integrity sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA== + dependencies: + load-tsconfig "^0.2.3" + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.npmmirror.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== + +chokidar@4.0.3, chokidar@^4.0.3: + version "4.0.3" + resolved "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +commander@^4.0.0: + version "4.1.1" + resolved "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +concat-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + +confbox@^0.1.8: + version "0.1.8" + resolved "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" + integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== + +consola@^3.4.0: + version "3.4.2" + resolved "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz#5af110145397bb67afdab77013fdc34cae590ea7" + integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== + +cpu-features@~0.0.10: + version "0.0.10" + resolved "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" + integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== + dependencies: + buildcheck "~0.0.6" + nan "^2.19.0" + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.0, debug@^4.4.1: + version "4.4.3" + resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +dotenv@17.2.3: + version "17.2.3" + resolved "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.3.tgz#ad995d6997f639b11065f419a22fabf567cdb9a2" + integrity sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w== + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + +esbuild@^0.25.0: + version "0.25.10" + resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.10.tgz#37f5aa5cd14500f141be121c01b096ca83ac34a9" + integrity sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.10" + "@esbuild/android-arm" "0.25.10" + "@esbuild/android-arm64" "0.25.10" + "@esbuild/android-x64" "0.25.10" + "@esbuild/darwin-arm64" "0.25.10" + "@esbuild/darwin-x64" "0.25.10" + "@esbuild/freebsd-arm64" "0.25.10" + "@esbuild/freebsd-x64" "0.25.10" + "@esbuild/linux-arm" "0.25.10" + "@esbuild/linux-arm64" "0.25.10" + "@esbuild/linux-ia32" "0.25.10" + "@esbuild/linux-loong64" "0.25.10" + "@esbuild/linux-mips64el" "0.25.10" + "@esbuild/linux-ppc64" "0.25.10" + "@esbuild/linux-riscv64" "0.25.10" + "@esbuild/linux-s390x" "0.25.10" + "@esbuild/linux-x64" "0.25.10" + "@esbuild/netbsd-arm64" "0.25.10" + "@esbuild/netbsd-x64" "0.25.10" + "@esbuild/openbsd-arm64" "0.25.10" + "@esbuild/openbsd-x64" "0.25.10" + "@esbuild/openharmony-arm64" "0.25.10" + "@esbuild/sunos-x64" "0.25.10" + "@esbuild/win32-arm64" "0.25.10" + "@esbuild/win32-ia32" "0.25.10" + "@esbuild/win32-x64" "0.25.10" + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-config-prettier@10.1.8: + version "10.1.8" + resolved "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97" + integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== + +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@9.36.0: + version "9.36.0" + resolved "https://registry.npmmirror.com/eslint/-/eslint-9.36.0.tgz#9cc5cbbfb9c01070425d9bfed81b4e79a1c09088" + integrity sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.21.0" + "@eslint/config-helpers" "^0.3.1" + "@eslint/core" "^0.15.2" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.36.0" + "@eslint/plugin-kit" "^0.3.5" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + esquery "^1.5.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== + dependencies: + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" + +esquery@^1.5.0: + version "1.6.0" + resolved "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +expect-type@^1.2.1: + version "1.2.2" + resolved "https://registry.npmmirror.com/expect-type/-/expect-type-1.2.2.tgz#c030a329fb61184126c8447585bc75a7ec6fbff3" + integrity sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@3.3.3, fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fastq@^1.6.0: + version "1.19.1" + resolved "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== + dependencies: + reusify "^1.0.4" + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +fix-dts-default-cjs-exports@^1.0.0: + version "1.0.1" + resolved "https://registry.npmmirror.com/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz#955cb6b3d519691c57828b078adadf2cb92e9549" + integrity sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg== + dependencies: + magic-string "^0.30.17" + mlly "^1.7.4" + rollup "^4.34.8" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + +foreground-child@^3.1.0, foreground-child@^3.3.1: + version "3.3.1" + resolved "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@11.0.3: + version "11.0.3" + resolved "https://registry.npmmirror.com/glob/-/glob-11.0.3.tgz#9d8087e6d72ddb3c4707b1d2778f80ea3eaefcd6" + integrity sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA== + dependencies: + foreground-child "^3.3.1" + jackspeak "^4.1.1" + minimatch "^10.0.3" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^2.0.0" + +glob@^10.3.10: + version "10.4.5" + resolved "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.0: + version "7.0.5" + resolved "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + +import-fresh@^3.2.1: + version "3.3.1" + resolved "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inherits@^2.0.3: + version "2.0.4" + resolved "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jackspeak@^4.1.1: + version "4.1.1" + resolved "https://registry.npmmirror.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae" + integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + +joycon@^3.1.1: + version "3.1.1" + resolved "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" + integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lilconfig@^3.1.1: + version "3.1.3" + resolved "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" + integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +load-tsconfig@^0.2.3: + version "0.2.5" + resolved "https://registry.npmmirror.com/load-tsconfig/-/load-tsconfig-0.2.5.tgz#453b8cd8961bfb912dea77eb6c168fe8cca3d3a1" + integrity sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg== + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.npmmirror.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== + +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-cache@^11.0.0: + version "11.2.2" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.2.tgz#40fd37edffcfae4b2940379c0722dc6eeaa75f24" + integrity sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg== + +magic-string@^0.30.17: + version "0.30.19" + resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.19.tgz#cebe9f104e565602e5d2098c5f2e79a77cc86da9" + integrity sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +minimatch@^10.0.3: + version "10.0.3" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa" + integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== + dependencies: + "@isaacs/brace-expansion" "^5.0.0" + +minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +mlly@^1.7.4: + version "1.8.0" + resolved "https://registry.npmmirror.com/mlly/-/mlly-1.8.0.tgz#e074612b938af8eba1eaf43299cbc89cb72d824e" + integrity sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g== + dependencies: + acorn "^8.15.0" + pathe "^2.0.3" + pkg-types "^1.3.1" + ufo "^1.6.1" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +mz@^2.7.0: + version "2.7.0" + resolved "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nan@^2.19.0, nan@^2.23.0: + version "2.23.0" + resolved "https://registry.npmmirror.com/nan/-/nan-2.23.0.tgz#24aa4ddffcc37613a2d2935b97683c1ec96093c6" + integrity sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +object-assign@^4.0.1: + version "4.1.1" + resolved "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-scurry@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.0.tgz#9f052289f23ad8bf9397a2a0425e7b8615c58580" + integrity sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + +pathe@^2.0.1, pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^4.0.2, picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + +pirates@^4.0.1: + version "4.0.7" + resolved "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +pkg-types@^1.3.1: + version "1.3.1" + resolved "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" + integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== + dependencies: + confbox "^0.1.8" + mlly "^1.7.4" + pathe "^2.0.1" + +postcss-load-config@^6.0.1: + version "6.0.1" + resolved "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz#6fd7dcd8ae89badcf1b2d644489cbabf83aa8096" + integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g== + dependencies: + lilconfig "^3.1.1" + +postcss@^8.5.6: + version "8.5.6" + resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier@3.6.2: + version "3.6.2" + resolved "https://registry.npmmirror.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393" + integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +readable-stream@^3.0.2: + version "3.6.2" + resolved "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +rollup@^4.34.8, rollup@^4.43.0: + version "4.52.3" + resolved "https://registry.npmmirror.com/rollup/-/rollup-4.52.3.tgz#cc5c28d772b022ce48b235a97b347ccd9d88c1a3" + integrity sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.52.3" + "@rollup/rollup-android-arm64" "4.52.3" + "@rollup/rollup-darwin-arm64" "4.52.3" + "@rollup/rollup-darwin-x64" "4.52.3" + "@rollup/rollup-freebsd-arm64" "4.52.3" + "@rollup/rollup-freebsd-x64" "4.52.3" + "@rollup/rollup-linux-arm-gnueabihf" "4.52.3" + "@rollup/rollup-linux-arm-musleabihf" "4.52.3" + "@rollup/rollup-linux-arm64-gnu" "4.52.3" + "@rollup/rollup-linux-arm64-musl" "4.52.3" + "@rollup/rollup-linux-loong64-gnu" "4.52.3" + "@rollup/rollup-linux-ppc64-gnu" "4.52.3" + "@rollup/rollup-linux-riscv64-gnu" "4.52.3" + "@rollup/rollup-linux-riscv64-musl" "4.52.3" + "@rollup/rollup-linux-s390x-gnu" "4.52.3" + "@rollup/rollup-linux-x64-gnu" "4.52.3" + "@rollup/rollup-linux-x64-musl" "4.52.3" + "@rollup/rollup-openharmony-arm64" "4.52.3" + "@rollup/rollup-win32-arm64-msvc" "4.52.3" + "@rollup/rollup-win32-ia32-msvc" "4.52.3" + "@rollup/rollup-win32-x64-gnu" "4.52.3" + "@rollup/rollup-win32-x64-msvc" "4.52.3" + fsevents "~2.3.2" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +semver@^7.6.0: + version "7.7.2" + resolved "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map@0.8.0-beta.0: + version "0.8.0-beta.0" + resolved "https://registry.npmmirror.com/source-map/-/source-map-0.8.0-beta.0.tgz#d4c1bb42c3f7ee925f005927ba10709e0d1d1f11" + integrity sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA== + dependencies: + whatwg-url "^7.0.0" + +ssh2-sftp-client@12.0.1: + version "12.0.1" + resolved "https://registry.npmmirror.com/ssh2-sftp-client/-/ssh2-sftp-client-12.0.1.tgz#926764878954dbed85f6f9233ce7980bfc94fdd4" + integrity sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg== + dependencies: + concat-stream "^2.0.0" + ssh2 "^1.16.0" + +ssh2@^1.16.0: + version "1.17.0" + resolved "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz#dc686e8e3abdbd4ad95d46fa139615903c12258c" + integrity sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ== + dependencies: + asn1 "^0.2.6" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.10" + nan "^2.23.0" + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.9.0: + version "3.9.0" + resolved "https://registry.npmmirror.com/std-env/-/std-env-3.9.0.tgz#1a6f7243b339dca4c9fd55e1c7504c77ef23e8f1" + integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +strip-literal@^3.0.0: + version "3.1.0" + resolved "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" + integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== + dependencies: + js-tokens "^9.0.1" + +sucrase@^3.35.0: + version "3.35.0" + resolved "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263" + integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA== + dependencies: + "@jridgewell/gen-mapping" "^0.3.2" + commander "^4.0.0" + glob "^10.3.10" + lines-and-columns "^1.1.6" + mz "^2.7.0" + pirates "^4.0.1" + ts-interface-checker "^0.1.9" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + +tinyglobby@^0.2.11, tinyglobby@^0.2.14, tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.4" + resolved "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" + integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA== + dependencies: + punycode "^2.1.0" + +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + +ts-api-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" + integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== + +ts-interface-checker@^0.1.9: + version "0.1.13" + resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" + integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== + +tsup@8.5.0: + version "8.5.0" + resolved "https://registry.npmmirror.com/tsup/-/tsup-8.5.0.tgz#4b1e25b1a8f4e4f89b764207bf37cfe2d7411d31" + integrity sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ== + dependencies: + bundle-require "^5.1.0" + cac "^6.7.14" + chokidar "^4.0.3" + consola "^3.4.0" + debug "^4.4.0" + esbuild "^0.25.0" + fix-dts-default-cjs-exports "^1.0.0" + joycon "^3.1.1" + picocolors "^1.1.1" + postcss-load-config "^6.0.1" + resolve-from "^5.0.0" + rollup "^4.34.8" + source-map "0.8.0-beta.0" + sucrase "^3.35.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.11" + tree-kill "^1.2.2" + +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + +typescript-eslint@8.45.0: + version "8.45.0" + resolved "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.45.0.tgz#98ab164234dc04c112747ec0a4ae29a94efe123b" + integrity sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg== + dependencies: + "@typescript-eslint/eslint-plugin" "8.45.0" + "@typescript-eslint/parser" "8.45.0" + "@typescript-eslint/typescript-estree" "8.45.0" + "@typescript-eslint/utils" "8.45.0" + +typescript@5.9.3: + version "5.9.3" + resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +ufo@^1.6.1: + version "1.6.1" + resolved "https://registry.npmmirror.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b" + integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== + dependencies: + cac "^6.7.14" + debug "^4.4.1" + es-module-lexer "^1.7.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": + version "7.1.8" + resolved "https://registry.npmmirror.com/vite/-/vite-7.1.8.tgz#ff208eee01658c5b407b6a1afa9b92b25750832e" + integrity sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ== + dependencies: + esbuild "^0.25.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +vitest@3.2.4: + version "3.2.4" + resolved "https://registry.npmmirror.com/vitest/-/vitest-3.2.4.tgz#0637b903ad79d1539a25bc34c0ed54b5c67702ea" + integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.4" + "@vitest/mocker" "3.2.4" + "@vitest/pretty-format" "^3.2.4" + "@vitest/runner" "3.2.4" + "@vitest/snapshot" "3.2.4" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + debug "^4.4.1" + expect-type "^1.2.1" + magic-string "^0.30.17" + pathe "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" + +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + +whatwg-url@^7.0.0: + version "7.1.0" + resolved "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" + integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.npmmirror.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/install.sh b/install.sh index 04ab78ea..bf939839 100755 --- a/install.sh +++ b/install.sh @@ -1,8 +1,8 @@ -#!/bin/sh +#!/bin/sh # shellcheck shell=dash REPO_OWNER="ushan0v" -REPO_NAME="forkop" +REPO_NAME="tachyon" REQUIRED_SPACE_KB=15360 CONNECT_TIMEOUT_SECONDS=15 @@ -12,25 +12,25 @@ DOWNLOAD_TIMEOUT_SECONDS=600 PKG_IS_APK=0 FETCHER="" TMP_DIR="" -FORKOP_WAS_ENABLED=0 -FORKOP_WAS_RUNNING=0 -FORKOP_LEGACY_DETECTED=0 -FORKOP_I18N_REQUESTED=0 +TACHYON_WAS_ENABLED=0 +TACHYON_WAS_RUNNING=0 +TACHYON_LEGACY_DETECTED=0 +TACHYON_I18N_REQUESTED=0 INSTALLER_LANG="en" SING_BOX_INSTALL_VARIANT="" -FORKOP_RELEASE_JSON="" -FORKOP_RELEASE_TAG="" -FORKOP_BACKEND_URL="" -FORKOP_BACKEND_NAME="" -FORKOP_BACKEND_FILE="" -FORKOP_APP_URL="" -FORKOP_APP_NAME="" -FORKOP_APP_FILE="" -FORKOP_I18N_URL="" -FORKOP_I18N_NAME="" -FORKOP_I18N_FILE="" -FORKOP_PACKAGE_VERSION="" +TACHYON_RELEASE_JSON="" +TACHYON_RELEASE_TAG="" +TACHYON_BACKEND_URL="" +TACHYON_BACKEND_NAME="" +TACHYON_BACKEND_FILE="" +TACHYON_APP_URL="" +TACHYON_APP_NAME="" +TACHYON_APP_FILE="" +TACHYON_I18N_URL="" +TACHYON_I18N_NAME="" +TACHYON_I18N_FILE="" +TACHYON_PACKAGE_VERSION="" LEGACY_BRAND="$(printf '\160\157\144\153\157\160')" LEGACY_BACKEND_PACKAGE="${LEGACY_BRAND}-plus" LEGACY_CONFIG_PACKAGE_ALT="${LEGACY_BRAND}_plus" @@ -55,10 +55,10 @@ usage() { cat </dev/null)" - [ -n "$FORKOP_RELEASE_TAG" ] || fail "Failed to detect the Forkop release tag" + TACHYON_RELEASE_JSON="$(fetch_github_latest_release_json "$REPO_OWNER" "$REPO_NAME")" + TACHYON_RELEASE_TAG="$(printf '%s' "$TACHYON_RELEASE_JSON" | install_json_ucode release-tag 2>/dev/null)" + [ -n "$TACHYON_RELEASE_TAG" ] || fail "Failed to detect the Tachyon release tag" - FORKOP_BACKEND_URL="$(printf '%s' "$FORKOP_RELEASE_JSON" | install_json_ucode release-asset-url backend "$asset_ext" 2>/dev/null)" - [ -n "$FORKOP_BACKEND_URL" ] || fail "The Forkop release does not contain a forkop .$asset_ext package" + TACHYON_BACKEND_URL="$(printf '%s' "$TACHYON_RELEASE_JSON" | install_json_ucode release-asset-url backend "$asset_ext" 2>/dev/null)" + [ -n "$TACHYON_BACKEND_URL" ] || fail "The Tachyon release does not contain a tachyon .$asset_ext package" - FORKOP_APP_URL="$(printf '%s' "$FORKOP_RELEASE_JSON" | install_json_ucode release-asset-url app "$asset_ext" 2>/dev/null)" - [ -n "$FORKOP_APP_URL" ] || fail "The Forkop release does not contain a luci-app-forkop .$asset_ext package" + TACHYON_APP_URL="$(printf '%s' "$TACHYON_RELEASE_JSON" | install_json_ucode release-asset-url app "$asset_ext" 2>/dev/null)" + [ -n "$TACHYON_APP_URL" ] || fail "The Tachyon release does not contain a luci-app-tachyon .$asset_ext package" - FORKOP_BACKEND_NAME="$(basename "$FORKOP_BACKEND_URL")" - FORKOP_APP_NAME="$(basename "$FORKOP_APP_URL")" - FORKOP_PACKAGE_VERSION="$(printf '%s\n' "$FORKOP_BACKEND_NAME" | sed 's/^forkop_//;s/\.ipk$//;s/\.apk$//')" + TACHYON_BACKEND_NAME="$(basename "$TACHYON_BACKEND_URL")" + TACHYON_APP_NAME="$(basename "$TACHYON_APP_URL")" + TACHYON_PACKAGE_VERSION="$(printf '%s\n' "$TACHYON_BACKEND_NAME" | sed 's/^tachyon_//;s/\.ipk$//;s/\.apk$//')" - FORKOP_I18N_URL="" - FORKOP_I18N_NAME="" + TACHYON_I18N_URL="" + TACHYON_I18N_NAME="" - if [ "$FORKOP_I18N_REQUESTED" -eq 1 ]; then - FORKOP_I18N_URL="$(printf '%s' "$FORKOP_RELEASE_JSON" | install_json_ucode release-asset-url i18n "$asset_ext" 2>/dev/null)" - [ -n "$FORKOP_I18N_URL" ] || fail "The Forkop release does not contain a luci-i18n-forkop-ru .$asset_ext package" - FORKOP_I18N_NAME="$(basename "$FORKOP_I18N_URL")" + if [ "$TACHYON_I18N_REQUESTED" -eq 1 ]; then + TACHYON_I18N_URL="$(printf '%s' "$TACHYON_RELEASE_JSON" | install_json_ucode release-asset-url i18n "$asset_ext" 2>/dev/null)" + [ -n "$TACHYON_I18N_URL" ] || fail "The Tachyon release does not contain a luci-i18n-tachyon-ru .$asset_ext package" + TACHYON_I18N_NAME="$(basename "$TACHYON_I18N_URL")" fi } @@ -1746,11 +1746,11 @@ select_sing_box_installation() { answer="" default_choice=1 - if [ "$FORKOP_LEGACY_DETECTED" -eq 1 ] && + if [ "$TACHYON_LEGACY_DETECTED" -eq 1 ] && [ -r /etc/init.d/sing-box ] && grep -Fq 'managed sing-box service for binary variants' /etc/init.d/sing-box; then SING_BOX_INSTALL_VARIANT="extended-compressed" - msg "The legacy binary-managed sing-box variant will be reinstalled for Forkop" + msg "The legacy binary-managed sing-box variant will be reinstalled for Tachyon" return 0 fi @@ -1809,9 +1809,9 @@ install_selected_sing_box() { ;; esac - [ -x /usr/bin/forkop ] || fail "forkop backend must be installed before sing-box component action" - msg "Installing selected sing-box variant through Forkop ucode backend" - if ! /usr/bin/forkop component_action sing_box "$action" >"$output_file" 2>&1; then + [ -x /usr/bin/tachyon ] || fail "tachyon backend must be installed before sing-box component action" + msg "Installing selected sing-box variant through Tachyon ucode backend" + if ! /usr/bin/tachyon component_action sing_box "$action" >"$output_file" 2>&1; then cat "$output_file" >&2 2>/dev/null || true fail "Failed to install selected sing-box variant" fi @@ -1821,19 +1821,19 @@ cleanup_legacy_installation() { state_file="$TMP_DIR/install-state.env" install_json_ucode installer-cleanup-legacy >"$state_file" || - fail "Failed to prepare the system before Forkop package installation" + fail "Failed to prepare the system before Tachyon package installation" # shellcheck disable=SC1090 . "$state_file" } detect_legacy_installation() { - FORKOP_LEGACY_DETECTED=0 + TACHYON_LEGACY_DETECTED=0 LEGACY_CONFIG_BACKUP="" pkg_is_installed "$LEGACY_BACKEND_PACKAGE" || return 0 - FORKOP_LEGACY_DETECTED=1 + TACHYON_LEGACY_DETECTED=1 for legacy_config_path in \ "/etc/config/$LEGACY_BACKEND_PACKAGE" \ "/etc/config/$LEGACY_CONFIG_PACKAGE_ALT"; do @@ -1853,22 +1853,22 @@ decide_i18n_installation() { detect_installer_language - if pkg_is_installed "luci-i18n-forkop-ru"; then - FORKOP_I18N_REQUESTED=1 + if pkg_is_installed "luci-i18n-tachyon-ru"; then + TACHYON_I18N_REQUESTED=1 msg "$(installer_text i18n_installed)" return 0 fi - if [ "$FORKOP_LEGACY_DETECTED" -eq 1 ] && + if [ "$TACHYON_LEGACY_DETECTED" -eq 1 ] && pkg_is_installed "luci-i18n-${LEGACY_BACKEND_PACKAGE}-ru"; then - FORKOP_I18N_REQUESTED=1 + TACHYON_I18N_REQUESTED=1 msg "$(installer_text i18n_installed)" return 0 fi case "$luci_lang" in ru|ru_*|ru-*) - FORKOP_I18N_REQUESTED=1 + TACHYON_I18N_REQUESTED=1 INSTALLER_LANG="ru" msg "$(installer_text luci_ru)" return 0 @@ -1876,7 +1876,7 @@ decide_i18n_installation() { esac if numbered_yes_no_prompt "$(installer_text i18n_prompt)"; then - FORKOP_I18N_REQUESTED=1 + TACHYON_I18N_REQUESTED=1 INSTALLER_LANG="ru" return 0 fi @@ -1884,42 +1884,42 @@ decide_i18n_installation() { warn "$(installer_text i18n_skip)" } -download_forkop_packages() { - FORKOP_BACKEND_FILE="$TMP_DIR/$FORKOP_BACKEND_NAME" - FORKOP_APP_FILE="$TMP_DIR/$FORKOP_APP_NAME" - FORKOP_I18N_FILE="" +download_tachyon_packages() { + TACHYON_BACKEND_FILE="$TMP_DIR/$TACHYON_BACKEND_NAME" + TACHYON_APP_FILE="$TMP_DIR/$TACHYON_APP_NAME" + TACHYON_I18N_FILE="" - download_with_retry "$FORKOP_BACKEND_URL" "$FORKOP_BACKEND_FILE" "$FORKOP_BACKEND_NAME" || fail "Failed to download $FORKOP_BACKEND_NAME" - download_with_retry "$FORKOP_APP_URL" "$FORKOP_APP_FILE" "$FORKOP_APP_NAME" || fail "Failed to download $FORKOP_APP_NAME" + download_with_retry "$TACHYON_BACKEND_URL" "$TACHYON_BACKEND_FILE" "$TACHYON_BACKEND_NAME" || fail "Failed to download $TACHYON_BACKEND_NAME" + download_with_retry "$TACHYON_APP_URL" "$TACHYON_APP_FILE" "$TACHYON_APP_NAME" || fail "Failed to download $TACHYON_APP_NAME" - if [ -n "$FORKOP_I18N_URL" ]; then - FORKOP_I18N_FILE="$TMP_DIR/$FORKOP_I18N_NAME" - download_with_retry "$FORKOP_I18N_URL" "$FORKOP_I18N_FILE" "$FORKOP_I18N_NAME" || fail "Failed to download $FORKOP_I18N_NAME" + if [ -n "$TACHYON_I18N_URL" ]; then + TACHYON_I18N_FILE="$TMP_DIR/$TACHYON_I18N_NAME" + download_with_retry "$TACHYON_I18N_URL" "$TACHYON_I18N_FILE" "$TACHYON_I18N_NAME" || fail "Failed to download $TACHYON_I18N_NAME" fi } install_backend_package() { - pkg_install_files "$FORKOP_BACKEND_FILE" || fail "forkop installation failed" + pkg_install_files "$TACHYON_BACKEND_FILE" || fail "tachyon installation failed" } migrate_legacy_configuration() { - [ "$FORKOP_LEGACY_DETECTED" -eq 1 ] || return 0 + [ "$TACHYON_LEGACY_DETECTED" -eq 1 ] || return 0 if [ -n "$LEGACY_CONFIG_BACKUP" ]; then - cp "$LEGACY_CONFIG_BACKUP" /etc/config/forkop || + cp "$LEGACY_CONFIG_BACKUP" /etc/config/tachyon || fail "Failed to restore the legacy configuration for migration" - chmod 0644 /etc/config/forkop || - fail "Failed to set permissions on the Forkop configuration" + chmod 0644 /etc/config/tachyon || + fail "Failed to set permissions on the Tachyon configuration" - msg "Migrating the legacy configuration to Forkop" - if ! FORKOP_CONFIG_NAME="forkop" \ - FORKOP_LIB="/usr/lib/forkop" \ - ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate-podkop; then - cp "$LEGACY_CONFIG_BACKUP" /etc/config/forkop 2>/dev/null || true + msg "Migrating the legacy configuration to Tachyon" + if ! TACHYON_CONFIG_NAME="tachyon" \ + TACHYON_LIB="/usr/lib/tachyon" \ + ucode -L /usr/lib/tachyon /usr/lib/tachyon/config/migration.uc migrate-podkop; then + cp "$LEGACY_CONFIG_BACKUP" /etc/config/tachyon 2>/dev/null || true fail "Legacy configuration migration failed; the original configuration was restored" fi else - warn "The legacy package had no readable configuration; Forkop defaults will be used" + warn "The legacy package had no readable configuration; Tachyon defaults will be used" fi install_json_ucode installer-finalize-legacy || @@ -1927,17 +1927,17 @@ migrate_legacy_configuration() { } install_ui_packages() { - pkg_install_files "$FORKOP_APP_FILE" || fail "luci-app-forkop installation failed" + pkg_install_files "$TACHYON_APP_FILE" || fail "luci-app-tachyon installation failed" - if [ -n "$FORKOP_I18N_FILE" ]; then - pkg_install_files "$FORKOP_I18N_FILE" || fail "luci-i18n-forkop-ru installation failed" + if [ -n "$TACHYON_I18N_FILE" ]; then + pkg_install_files "$TACHYON_I18N_FILE" || fail "luci-i18n-tachyon-ru installation failed" fi } post_install() { - FORKOP_WAS_ENABLED="$FORKOP_WAS_ENABLED" FORKOP_WAS_RUNNING="$FORKOP_WAS_RUNNING" \ + TACHYON_WAS_ENABLED="$TACHYON_WAS_ENABLED" TACHYON_WAS_RUNNING="$TACHYON_WAS_RUNNING" \ install_json_ucode installer-post-install || - fail "Failed to complete Forkop post-install actions" + fail "Failed to complete Tachyon post-install actions" } main() { @@ -1960,8 +1960,8 @@ main() { pkg_list_update || fail "Failed to update package lists" ensure_bootstrap_ucode_runtime - resolve_forkop_release - download_forkop_packages + resolve_tachyon_release + download_tachyon_packages cleanup_legacy_installation install_backend_package @@ -1970,9 +1970,9 @@ main() { install_selected_sing_box post_install - msg "Forkop $FORKOP_PACKAGE_VERSION has been installed successfully" - msg "Source release: ${REPO_OWNER}/${REPO_NAME}@${FORKOP_RELEASE_TAG}" - warn "Open LuCI and review your rules before enabling Forkop" + msg "Tachyon $TACHYON_PACKAGE_VERSION has been installed successfully" + msg "Source release: ${REPO_OWNER}/${REPO_NAME}@${TACHYON_RELEASE_TAG}" + warn "Open LuCI and review your rules before enabling Tachyon" } main "$@" diff --git a/luci-app-tachyon/Makefile b/luci-app-tachyon/Makefile new file mode 100644 index 00000000..5ca8f3be --- /dev/null +++ b/luci-app-tachyon/Makefile @@ -0,0 +1,36 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-tachyon + +TACHYON_SOURCE_VERSION:=$(or $(TACHYON_PACKAGE_VERSION),$(TACHYON_VERSION)) +TACHYON_HAS_RELEASE_VERSION:=$(if $(filter dev,$(TACHYON_SOURCE_VERSION)),,$(TACHYON_SOURCE_VERSION)) +TACHYON_COMPILED_VERSION:=$(if $(TACHYON_HAS_RELEASE_VERSION),$(TACHYON_SOURCE_VERSION),$(or $(TACHYON_SOURCE_VERSION),dev)) +TACHYON_RELEASE_VERSION_VALID:=$(if $(TACHYON_HAS_RELEASE_VERSION),$(shell printf '%s\n' '$(TACHYON_SOURCE_VERSION)' | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$$' && printf '1'),1) + +ifeq ($(TACHYON_RELEASE_VERSION_VALID),) +$(error TACHYON_PACKAGE_VERSION and TACHYON_VERSION must use x.y.z format) +endif + +PKG_VERSION:=$(or $(TACHYON_HAS_RELEASE_VERSION),0.0.0) +PKG_RELEASE:= +PKG_PO_VERSION:=$(PKG_VERSION) + +LUCI_TITLE:=LuCI Tachyon app +LUCI_DEPENDS:=+luci-base +tachyon +LUCI_PKGARCH:=all +LUCI_LANG.ru:=Русский (Russian) +LUCI_LANG.en:=English +LUCI_MAINTAINER:=ushan0v +LUCI_DESCRIPTION:=Rule-based Tachyon LuCI app with hybrid sing-box + zapret orchestration +LUCI_MINIFY_JS:=0 + +PKG_LICENSE:=GPL-2.0-or-later +LUCI_LANGUAGES:=en ru + +define Build/Prepare/$(PKG_NAME) + sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(TACHYON_COMPILED_VERSION)/g' $(PKG_BUILD_DIR)/htdocs/luci-static/resources/view/tachyon/main.js +endef + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/dashboard.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/dashboard.js new file mode 100644 index 00000000..6083eeea --- /dev/null +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/dashboard.js @@ -0,0 +1,22 @@ +"use strict"; +"require baseclass"; +"require form"; +"require ui"; +"require uci"; +"require fs"; +"require view.tachyon.main as main"; + +function createDashboardContent(section) { + const o = section.option(form.DummyValue, "_mount_node"); + o.rawhtml = true; + o.cfgvalue = () => { + main.DashboardTab.initController(); + return main.DashboardTab.render(); + }; +} + +const EntryPoint = { + createDashboardContent, +}; + +return baseclass.extend(EntryPoint); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/diagnostic.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/diagnostic.js new file mode 100644 index 00000000..703417b5 --- /dev/null +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/diagnostic.js @@ -0,0 +1,22 @@ +"use strict"; +"require baseclass"; +"require form"; +"require ui"; +"require uci"; +"require fs"; +"require view.tachyon.main as main"; + +function createDiagnosticContent(section) { + const o = section.option(form.DummyValue, "_mount_node"); + o.rawhtml = true; + o.cfgvalue = () => { + main.DiagnosticTab.initController(); + return main.DiagnosticTab.render(); + }; +} + +const EntryPoint = { + createDiagnosticContent, +}; + +return baseclass.extend(EntryPoint); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/local_devices.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/local_devices.js new file mode 100644 index 00000000..fb676275 --- /dev/null +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/local_devices.js @@ -0,0 +1,260 @@ +"use strict"; +"require baseclass"; +"require rpc"; +"require ui"; +"require view.tachyon.main as main"; + +const callHostHints = rpc.declare({ + object: "luci-rpc", + method: "getHostHints", + expect: { "": {} }, +}); +const callDHCPLeases = rpc.declare({ + object: "luci-rpc", + method: "getDHCPLeases", + expect: { "": {} }, +}); +const callNetworkInterfaceDump = rpc.declare({ + object: "network.interface", + method: "dump", + expect: { interface: [] }, +}); + +let localDeviceChoicesCache = null; +let localDeviceChoicesPromise = null; + +function normalizeOptionValues(value) { + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return value + .filter(Boolean) + .map((item) => `${item}`.trim()) + .filter(Boolean); + } + + return `${value}` + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeLocalDeviceName(name) { + return `${name || ""}`.trim().replace(/\.lan$/i, ""); +} + +function addLocalDeviceChoice(choices, ip, name) { + const normalizedIp = `${ip || ""}`.trim(); + const normalizedName = normalizeLocalDeviceName(name); + + if (!normalizedIp || !normalizedName) { + return; + } + + if (!main.validateIP(normalizedIp).valid) { + return; + } + + choices[normalizedIp] = normalizedName; +} + +function addRouterIp(routerIps, ip) { + const normalizedIp = `${ip || ""}`.trim(); + + if (!normalizedIp || !main.validateIP(normalizedIp).valid) { + return; + } + + routerIps[normalizedIp] = true; +} + +function buildRouterIpMap(networkInterfaces) { + const routerIps = {}; + + if (!Array.isArray(networkInterfaces)) { + return routerIps; + } + + networkInterfaces.forEach((networkInterface) => { + const ipAddresses = []; + const ipv4Addresses = + networkInterface && + typeof networkInterface === "object" && + Array.isArray(networkInterface["ipv4-address"]) + ? networkInterface["ipv4-address"] + : []; + const ipv6Addresses = + networkInterface && + typeof networkInterface === "object" && + Array.isArray(networkInterface["ipv6-address"]) + ? networkInterface["ipv6-address"] + : []; + + ipAddresses.push(...ipv4Addresses, ...ipv6Addresses); + ipAddresses.forEach((address) => { + addRouterIp( + routerIps, + address && typeof address === "object" ? address.address : address, + ); + }); + }); + + return routerIps; +} + +function buildLocalDeviceChoices(hostHints, dhcpLeases, networkInterfaces) { + const choices = {}; + const routerIps = buildRouterIpMap(networkInterfaces); + + if (hostHints && typeof hostHints === "object") { + Object.values(hostHints).forEach((hint) => { + if (!hint || typeof hint !== "object") { + return; + } + + [ + ...normalizeOptionValues(hint.ipaddrs), + ...normalizeOptionValues(hint.ipv4), + ...normalizeOptionValues(hint.ipv6), + ].forEach((ip) => { + addLocalDeviceChoice(choices, ip, hint.name); + }); + }); + } + + if (dhcpLeases && Array.isArray(dhcpLeases.dhcp_leases)) { + dhcpLeases.dhcp_leases.forEach((lease) => { + if (!lease || typeof lease !== "object") { + return; + } + + addLocalDeviceChoice(choices, lease.ipaddr, lease.hostname); + }); + } + + Object.keys(routerIps).forEach((ip) => { + delete choices[ip]; + }); + + return choices; +} + +function loadLocalDeviceChoices() { + if (localDeviceChoicesCache) { + return Promise.resolve(localDeviceChoicesCache); + } + + if (localDeviceChoicesPromise) { + return localDeviceChoicesPromise; + } + + localDeviceChoicesPromise = Promise.all([ + callHostHints().catch(() => ({})), + callDHCPLeases().catch(() => ({})), + callNetworkInterfaceDump().catch(() => []), + ]) + .then(([hostHints, dhcpLeases, networkInterfaces]) => { + localDeviceChoicesCache = buildLocalDeviceChoices( + hostHints, + dhcpLeases, + networkInterfaces, + ); + return localDeviceChoicesCache; + }) + .finally(() => { + localDeviceChoicesPromise = null; + }); + + return localDeviceChoicesPromise; +} + +function sortLocalDeviceChoiceValues(choices) { + return Object.keys(choices).sort((a, b) => { + const byName = `${choices[a]}`.localeCompare(`${choices[b]}`); + return byName || a.localeCompare(b); + }); +} + +function hasSingleIpValue(values) { + return normalizeOptionValues(values).some( + (value) => main.validateIP(value).valid, + ); +} + +function preloadLocalDeviceChoicesForValues(values) { + return hasSingleIpValue(values) + ? loadLocalDeviceChoices() + : Promise.resolve(null); +} + +function createLocalDeviceDynamicListWidget(option, section_id, cfgvalue) { + const values = normalizeOptionValues( + cfgvalue != null ? cfgvalue : option.default, + ); + const shouldResolveExistingLabels = hasSingleIpValue(values); + + return ( + shouldResolveExistingLabels ? loadLocalDeviceChoices() : Promise.resolve({}) + ).then((initialChoices) => { + const choices = localDeviceChoicesCache || initialChoices || {}; + const widget = new ui.DynamicList(values, choices, { + id: option.cbid(section_id), + sort: sortLocalDeviceChoiceValues(choices), + optional: option.optional || option.rmempty, + datatype: option.datatype, + placeholder: option.placeholder, + validate: option.validate.bind(option, section_id), + disabled: option.readonly != null ? option.readonly : option.map.readonly, + }); + const node = widget.render(); + let choicesLoaded = Boolean(localDeviceChoicesCache); + let choicesLoading = false; + + const loadChoices = () => { + if (choicesLoaded || choicesLoading) { + return; + } + + choicesLoading = true; + loadLocalDeviceChoices() + .then((loadedChoices) => { + widget.clearChoices(); + widget.addChoices( + sortLocalDeviceChoiceValues(loadedChoices), + loadedChoices, + ); + choicesLoaded = true; + }) + .finally(() => { + choicesLoading = false; + }); + }; + + const maybeLoadChoices = (ev) => { + if ( + ev.target && + typeof ev.target.closest === "function" && + ev.target.closest(".cbi-dropdown") + ) { + loadChoices(); + } + }; + + node.addEventListener("mousedown", maybeLoadChoices, true); + node.addEventListener("focusin", maybeLoadChoices, true); + + return node; + }); +} + +const EntryPoint = { + createLocalDeviceDynamicListWidget, + hasSingleIpValue, + loadLocalDeviceChoices, + normalizeOptionValues, + preloadLocalDeviceChoicesForValues, +}; + +return baseclass.extend(EntryPoint); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js new file mode 100644 index 00000000..a84e483f --- /dev/null +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js @@ -0,0 +1,14229 @@ +// This file is autogenerated, please don't change manually +"use strict"; +"require baseclass"; +"require fs"; +"require uci"; +"require ui"; + +// src/validators/validateIp.ts +function isIPv4(ip) { + const ipRegex = /^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/; + return ipRegex.test(ip); +} +function isIPv6(ip) { + if (!ip.includes(":") || ip.includes("%")) { + return false; + } + try { + new URL(`http://[${ip}]/`); + return true; + } catch (_e) { + return false; + } +} +function validateIP(ip) { + if (isIPv4(ip) || isIPv6(ip)) { + return { valid: true, message: _("Valid") }; + } + return { valid: false, message: _("Invalid IP address") }; +} + +// src/validators/validateDomain.ts +function asciiHostname(hostname) { + if (!hostname || /[\s@:/]/.test(hostname) || hostname.startsWith(".") || hostname.endsWith(".")) { + return null; + } + try { + return new URL(`http://${hostname}`).hostname; + } catch { + return null; + } +} +function validAsciiDomain(hostname, requireDot = true) { + if (!hostname || hostname.length > 253) { + return false; + } + const parts = hostname.split("."); + if (parts.some((part) => !part || part.length > 63)) { + return false; + } + if (parts.some((part) => !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part))) { + return false; + } + if (!requireDot) { + return true; + } + if (parts.length < 2) { + return false; + } + const tld = parts[parts.length - 1]; + return /^(?:[a-z]{2,}|xn--[a-z0-9-]{2,59})$/.test(tld); +} +function validateDomain(domain, allowDotTLD = false) { + const normalized = `${domain || ""}`.trim(); + if (allowDotTLD) { + const dotTld = normalized.startsWith(".") ? normalized.slice(1) : ""; + const ascii2 = asciiHostname(dotTld); + if (ascii2 && !ascii2.includes(".") && validAsciiDomain(ascii2, false)) { + return { valid: true, message: _("Valid") }; + } + } + const slashIndex = normalized.indexOf("/"); + const hostname = slashIndex >= 0 ? normalized.slice(0, slashIndex) : normalized; + const path = slashIndex >= 0 ? normalized.slice(slashIndex) : ""; + if (path && /\s/.test(path)) { + return { valid: false, message: _("Invalid domain address") }; + } + const ascii = asciiHostname(hostname); + if (!ascii || !validAsciiDomain(ascii)) { + return { valid: false, message: _("Invalid domain address") }; + } + return { valid: true, message: _("Valid") }; +} + +// src/validators/hostPort.ts +function unbracketHost(host) { + if (host.startsWith("[") && host.endsWith("]")) { + return host.slice(1, -1); + } + return host; +} +function isValidHost(host) { + const normalizedHost = unbracketHost(host); + return isIPv4(normalizedHost) || isIPv6(normalizedHost) || validateDomain(normalizedHost).valid; +} +function isValidPort(port) { + const normalized = String(port ?? ""); + const value = Number(normalized); + return /^\d+$/.test(normalized) && value >= 1 && value <= 65535; +} +function parseHostPort(value) { + if (!value) { + return null; + } + if (value.startsWith("[")) { + const end = value.indexOf("]"); + if (end <= 1 || value.slice(end + 1, end + 2) !== ":") { + return null; + } + const parsed2 = { + host: value.slice(1, end), + port: value.slice(end + 2) + }; + return isValidHost(parsed2.host) ? parsed2 : null; + } + const firstColon = value.indexOf(":"); + const lastColon = value.lastIndexOf(":"); + if (firstColon < 0 || firstColon !== lastColon) { + return null; + } + const parsed = { + host: value.slice(0, firstColon), + port: value.slice(firstColon + 1) + }; + return isValidHost(parsed.host) ? parsed : null; +} + +// src/validators/validateDns.ts +function validateDNS(value) { + if (!value) { + return { valid: false, message: _("DNS server address cannot be empty") }; + } + const [addressPart, ...pathParts] = value.split("/"); + const parsedHostPort = parseHostPort(addressPart); + const host = parsedHostPort ? parsedHostPort.host : unbracketHost(addressPart); + const domainValue = parsedHostPort ? host + (pathParts.length > 0 ? `/${pathParts.join("/")}` : "") : value.replace(/:(\d+)(?=\/|$)/, ""); + if (parsedHostPort && !isValidPort(parsedHostPort.port)) { + return { valid: false, message: _("Invalid DNS server port") }; + } + if (validateIP(host).valid) { + return { valid: true, message: _("Valid") }; + } + if (validateDomain(domainValue).valid) { + return { valid: true, message: _("Valid") }; + } + return { + valid: false, + message: _( + "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" + ) + }; +} + +// src/validators/validateUrl.ts +function validateUrl(url, protocols = ["http:", "https:"]) { + if (!url.length) { + return { valid: false, message: _("Invalid URL format") }; + } + const hasValidProtocol = protocols.some((p) => url.indexOf(p + "//") === 0); + if (!hasValidProtocol) + return { + valid: false, + message: _("URL must use one of the following protocols:") + " " + protocols.join(", ") + }; + try { + const parsed = new URL(url); + const host = parsed.hostname.startsWith("[") ? parsed.hostname.slice(1, -1) : parsed.hostname; + if ((isValidHost(host) || host === "localhost") && (!parsed.port || isValidPort(parsed.port))) { + return { valid: true, message: _("Valid") }; + } + } catch (_e) { + return { valid: false, message: _("Invalid URL format") }; + } + return { valid: false, message: _("Invalid URL format") }; +} + +// src/validators/validatePath.ts +function validatePath(value) { + if (!value) { + return { + valid: false, + message: _("Path cannot be empty") + }; + } + const pathRegex = /^\/[a-zA-Z0-9_\-/.]+$/; + if (pathRegex.test(value)) { + return { + valid: true, + message: _("Valid") + }; + } + return { + valid: false, + message: _( + 'Invalid path format. Path must start with "/" and contain valid characters' + ) + }; +} + +// src/validators/validateSubnet.ts +function validateSubnet(value) { + const [ip, cidr, extra] = value.split("/"); + if (!ip || extra !== void 0 || !isIPv4(ip) && !isIPv6(ip)) { + return { + valid: false, + message: _("Invalid format. Use an IP address or CIDR subnet") + }; + } + if ((ip === "0.0.0.0" || ip === "::") && cidr == null) { + return { + valid: false, + message: _("Unspecified IP address is not allowed") + }; + } + if (cidr) { + if (!/^\d+$/.test(cidr)) { + return { + valid: false, + message: _("Invalid CIDR prefix") + }; + } + const cidrNum = parseInt(cidr, 10); + const maxPrefix = isIPv6(ip) ? 128 : 32; + if (cidrNum < 0 || cidrNum > maxPrefix) { + return { + valid: false, + message: _( + "CIDR must be between 0 and 32 for IPv4 or 0 and 128 for IPv6" + ) + }; + } + } + return { valid: true, message: _("Valid") }; +} + +// src/validators/bulkValidate.ts +function bulkValidate(values, validate) { + const results = values.map((value) => ({ ...validate(value), value })); + return { + valid: results.every((r) => r.valid), + results + }; +} + +// src/validators/validateOutboundJson.ts +var SERVER_OUTBOUND_TYPES = /* @__PURE__ */ new Set([ + "vless", + "vmess", + "trojan", + "shadowsocks", + "socks", + "http", + "hysteria2", + "hysteria" +]); +function invalid(message) { + return { valid: false, message }; +} +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} +function nonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} +function validateServerPort(value) { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; +} +function validateOutbounds(value) { + return Array.isArray(value) && value.length > 0 && value.every((item) => nonEmptyString(item)); +} +function validateOutboundJson(value, usedTags = []) { + const normalized = `${value || ""}`.trim(); + if (!normalized.length) { + return invalid(_("JSON outbound cannot be empty")); + } + let parsed; + try { + parsed = JSON.parse(normalized); + } catch { + return { valid: false, message: _("Invalid JSON format") }; + } + if (!isPlainObject(parsed)) { + return invalid(_("JSON outbound must be a JSON object")); + } + if (!nonEmptyString(parsed.type)) { + return invalid(_("JSON outbound must contain a non-empty type field")); + } + if (!nonEmptyString(parsed.tag)) { + return invalid(_("JSON outbound must contain a non-empty tag field")); + } + const tag = parsed.tag.trim(); + if (usedTags.some((usedTag) => `${usedTag || ""}`.trim() === tag)) { + return invalid(_("Duplicate JSON outbound tag")); + } + const type = parsed.type.trim().toLowerCase(); + if ((type === "selector" || type === "urltest") && !validateOutbounds(parsed.outbounds)) { + return invalid( + _( + "Selector and URLTest outbounds must contain a non-empty outbounds array" + ) + ); + } + if (SERVER_OUTBOUND_TYPES.has(type)) { + if (!nonEmptyString(parsed.server)) { + return invalid( + _("Server outbound must contain a non-empty server field") + ); + } + if (!validateServerPort(parsed.server_port)) { + return invalid( + _("Server outbound must contain a numeric server_port from 1 to 65535") + ); + } + } else if (parsed.server_port !== void 0 && !validateServerPort(parsed.server_port)) { + return invalid(_("server_port must be a number from 1 to 65535")); + } + if (parsed.outbounds !== void 0 && !validateOutbounds(parsed.outbounds)) { + return invalid(_("outbounds must be a non-empty array of strings")); + } + if (parsed.detour !== void 0 && !nonEmptyString(parsed.detour)) { + return invalid(_("detour must be a non-empty string")); + } + return { valid: true, message: _("Valid") }; +} + +// src/validators/validateShadowsocksUrl.ts +function validateShadowsocksUrl(url) { + if (!url.startsWith("ss://")) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: must start with ss://") + }; + } + try { + if (!url || /\s/.test(url)) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: must not contain spaces") + }; + } + const mainPart = url.includes("?") ? url.split("?")[0] : url.split("#")[0]; + const encryptedPart = mainPart.split("/")[2]?.split("@")[0]; + if (!encryptedPart) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: missing credentials") + }; + } + try { + const decoded = atob(encryptedPart); + if (!decoded.includes(":")) { + return { + valid: false, + message: _( + "Invalid Shadowsocks URL: decoded credentials must contain method:password" + ) + }; + } + } catch (_e) { + if (!encryptedPart.includes(":") && !encryptedPart.includes("-")) { + return { + valid: false, + message: _( + 'Invalid Shadowsocks URL: missing method and password separator ":"' + ) + }; + } + } + const serverPart = url.split("@")[1]; + if (!serverPart) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: missing server address") + }; + } + const parsedHostPort = parseHostPort(serverPart); + if (!parsedHostPort) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: invalid server and port") + }; + } + const { host: server, port: portAndRest } = parsedHostPort; + if (!server) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: missing server") + }; + } + const port = portAndRest ? portAndRest.split(/[?#]/)[0] : null; + if (!port) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: missing port") + }; + } + if (!isValidPort(port)) { + return { + valid: false, + message: _("Invalid port number. Must be between 1 and 65535") + }; + } + } catch (_e) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: parsing failed") + }; + } + return { valid: true, message: _("Valid") }; +} + +// src/helpers/parseQueryString.ts +function parseQueryString(query) { + const clean = query.startsWith("?") ? query.slice(1) : query; + return clean.split("&").filter(Boolean).reduce( + (acc, pair) => { + const [rawKey, rawValue = ""] = pair.split("="); + if (!rawKey) { + return acc; + } + const key = decodeURIComponent(rawKey); + const value = decodeURIComponent(rawValue); + return { ...acc, [key]: value }; + }, + {} + ); +} + +// src/validators/validateVlessUrl.ts +function validateVlessUrl(url) { + try { + if (!url.startsWith("vless://")) + return { + valid: false, + message: "Invalid VLESS URL: must start with vless://" + }; + if (/\s/.test(url)) + return { + valid: false, + message: "Invalid VLESS URL: must not contain spaces" + }; + const body = url.slice("vless://".length); + const [mainPart] = body.split("#"); + const [userHostPort, queryString] = mainPart.split("?"); + if (!userHostPort) + return { + valid: false, + message: "Invalid VLESS URL: missing host and UUID" + }; + const [userPart, hostPortPart] = userHostPort.split("@"); + if (!userPart) + return { valid: false, message: "Invalid VLESS URL: missing UUID" }; + if (!hostPortPart) + return { valid: false, message: "Invalid VLESS URL: missing server" }; + const parsedHostPort = parseHostPort(hostPortPart); + if (!parsedHostPort) + return { + valid: false, + message: "Invalid VLESS URL: invalid host and port" + }; + const { host, port } = parsedHostPort; + if (!host) + return { valid: false, message: "Invalid VLESS URL: missing hostname" }; + if (!port) + return { valid: false, message: "Invalid VLESS URL: missing port" }; + const cleanedPort = port.replace("/", ""); + if (!isValidPort(cleanedPort)) + return { + valid: false, + message: "Invalid VLESS URL: invalid port number" + }; + if (!queryString) + return { + valid: false, + message: "Invalid VLESS URL: missing query parameters" + }; + const params = parseQueryString(queryString); + const validTypes = [ + "tcp", + "raw", + "udp", + "grpc", + "http", + "httpupgrade", + "xhttp", + "ws", + "kcp" + ]; + const validSecurities = ["tls", "reality", "none"]; + const transportType = params.type || "tcp"; + if (!validTypes.includes(transportType)) + return { + valid: false, + message: "Invalid VLESS URL: unsupported or missing type" + }; + if (!params.security || !validSecurities.includes(params.security)) + return { + valid: false, + message: "Invalid VLESS URL: unsupported or missing security" + }; + if (params.security === "reality") { + if (!params.pbk) + return { + valid: false, + message: "Invalid VLESS URL: missing pbk for reality" + }; + if (!params.fp) + return { + valid: false, + message: "Invalid VLESS URL: missing fp for reality" + }; + } + if (params.flow === "xtls-rprx-vision-udp443") { + return { + valid: false, + message: "Invalid VLESS URL: flow xtls-rprx-vision-udp443 is not supported" + }; + } + return { valid: true, message: _("Valid") }; + } catch (_e) { + return { valid: false, message: _("Invalid VLESS URL: parsing failed") }; + } +} + +// src/validators/validateVmessUrl.ts +function decodeBase64Json(value) { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd( + normalized.length + (4 - normalized.length % 4) % 4, + "=" + ); + const binary = atob(padded); + const decoded = decodeURIComponent( + Array.from(binary).map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`).join("") + ); + return JSON.parse(decoded); +} +function validateVmessUrl(url) { + try { + if (!url.startsWith("vmess://")) { + return { + valid: false, + message: "Invalid VMess URL: must start with vmess://" + }; + } + if (/\s/.test(url)) { + return { + valid: false, + message: "Invalid VMess URL: must not contain spaces" + }; + } + const body = url.slice("vmess://".length); + const [encoded] = body.split("#"); + if (!encoded) { + return { + valid: false, + message: "Invalid VMess URL: missing encoded config" + }; + } + const config = decodeBase64Json(encoded); + if (!config || typeof config !== "object") { + return { valid: false, message: "Invalid VMess URL: invalid config" }; + } + const { add, port, id } = config; + if (!add || typeof add !== "string" || !isValidHost(add)) { + return { valid: false, message: "Invalid VMess URL: invalid server" }; + } + if (!isValidPort(port)) { + return { + valid: false, + message: "Invalid VMess URL: invalid port number" + }; + } + if (!id || typeof id !== "string") { + return { valid: false, message: "Invalid VMess URL: missing UUID" }; + } + return { valid: true, message: _("Valid") }; + } catch (_e) { + return { valid: false, message: _("Invalid VMess URL: parsing failed") }; + } +} + +// src/validators/validateTrojanUrl.ts +function validateTrojanUrl(url) { + try { + if (!url.startsWith("trojan://")) { + return { + valid: false, + message: _("Invalid Trojan URL: must start with trojan://") + }; + } + if (!url || /\s/.test(url)) { + return { + valid: false, + message: _("Invalid Trojan URL: must not contain spaces") + }; + } + const body = url.slice("trojan://".length); + const [mainPart] = body.split("#"); + const [userHostPort] = mainPart.split("?"); + const [userPart, hostPortPart] = userHostPort.split("@"); + if (!userHostPort) + return { + valid: false, + message: "Invalid Trojan URL: missing credentials and host" + }; + if (!userPart) + return { valid: false, message: "Invalid Trojan URL: missing password" }; + if (!hostPortPart) + return { + valid: false, + message: "Invalid Trojan URL: missing hostname and port" + }; + const parsedHostPort = parseHostPort(hostPortPart); + if (!parsedHostPort) + return { + valid: false, + message: "Invalid Trojan URL: invalid host and port" + }; + const { host, port } = parsedHostPort; + if (!host) + return { valid: false, message: "Invalid Trojan URL: missing hostname" }; + if (!port) + return { valid: false, message: "Invalid Trojan URL: missing port" }; + if (!isValidPort(port)) + return { + valid: false, + message: "Invalid Trojan URL: invalid port number" + }; + } catch (_e) { + return { valid: false, message: _("Invalid Trojan URL: parsing failed") }; + } + return { valid: true, message: _("Valid") }; +} + +// src/validators/validateSocksUrl.ts +function validateSocksUrl(url) { + try { + if (!/^socks(4|4a|5):\/\//.test(url)) { + return { + valid: false, + message: _( + "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://" + ) + }; + } + if (!url || /\s/.test(url)) { + return { + valid: false, + message: _("Invalid SOCKS URL: must not contain spaces") + }; + } + const body = url.replace(/^socks(4|4a|5):\/\//, ""); + const [authAndHost] = body.split("#"); + const [credentials, hostPortPart] = authAndHost.includes("@") ? authAndHost.split("@") : [null, authAndHost]; + if (credentials) { + const [username, _password] = credentials.split(":"); + if (!username) { + return { + valid: false, + message: _("Invalid SOCKS URL: missing username") + }; + } + } + if (!hostPortPart) { + return { + valid: false, + message: _("Invalid SOCKS URL: missing host and port") + }; + } + const parsedHostPort = parseHostPort(hostPortPart); + if (!parsedHostPort) { + return { + valid: false, + message: _("Invalid SOCKS URL: invalid host and port") + }; + } + const { host, port } = parsedHostPort; + if (!host) { + return { + valid: false, + message: _("Invalid SOCKS URL: missing hostname or IP") + }; + } + if (!port) { + return { valid: false, message: _("Invalid SOCKS URL: missing port") }; + } + if (!isValidPort(port)) { + return { + valid: false, + message: _("Invalid SOCKS URL: invalid port number") + }; + } + } catch (_e) { + return { valid: false, message: _("Invalid SOCKS URL: parsing failed") }; + } + return { valid: true, message: _("Valid") }; +} + +// src/validators/validateHysteriaUrl.ts +function validateHysteria2Url(url) { + try { + const isHY2 = url.startsWith("hysteria2://"); + const isHY2Short = url.startsWith("hy2://"); + if (!isHY2 && !isHY2Short) { + return { + valid: false, + message: _("Invalid HY2 URL: must start with hysteria2:// or hy2://") + }; + } + if (/\s/.test(url)) { + return { + valid: false, + message: _("Invalid HY2 URL: must not contain spaces") + }; + } + const prefix = isHY2 ? "hysteria2://" : "hy2://"; + const body = url.slice(prefix.length); + const [mainPart] = body.split("#"); + const [authHostPort, queryString] = mainPart.split("?"); + if (!authHostPort) + return { + valid: false, + message: _("Invalid HY2 URL: missing credentials/server") + }; + const [passwordPart, hostPortPart] = authHostPort.split("@"); + if (!passwordPart) + return { valid: false, message: _("Invalid HY2 URL: missing password") }; + if (!hostPortPart) + return { + valid: false, + message: _("Invalid HY2 URL: missing host & port") + }; + const parsedHostPort = parseHostPort(hostPortPart); + if (!parsedHostPort) { + return { + valid: false, + message: _("Invalid HY2 URL: invalid host & port") + }; + } + const { host, port } = parsedHostPort; + if (!host) { + return { valid: false, message: _("Invalid HY2 URL: missing host") }; + } + if (!port) { + return { valid: false, message: _("Invalid HY2 URL: missing port") }; + } + const cleanedPort = port.replace("/", ""); + const portEntries = cleanedPort.split(","); + const isValidPortEntry = (entry) => { + if (!entry) { + return false; + } + if (!entry.includes("-")) { + return isValidPort(entry); + } + const rangeParts = entry.split("-"); + if (rangeParts.length !== 2) { + return false; + } + const [start, end] = rangeParts; + return isValidPort(start) && isValidPort(end) && Number(start) <= Number(end); + }; + if (!portEntries.every(isValidPortEntry)) { + return { + valid: false, + message: _("Invalid HY2 URL: invalid port number") + }; + } + if (queryString) { + const params = parseQueryString(queryString); + const paramsKeys = Object.keys(params); + if (paramsKeys.includes("insecure") && !["0", "1"].includes(params.insecure)) { + return { + valid: false, + message: _("Invalid HY2 URL: insecure must be 0 or 1") + }; + } + const validObfsTypes = ["none", "salamander"]; + if (paramsKeys.includes("obfs") && !validObfsTypes.includes(params.obfs)) { + return { + valid: false, + message: _("Invalid HY2 URL: unsupported obfs type") + }; + } + if (paramsKeys.includes("obfs") && params.obfs !== "none" && !params["obfs-password"]) { + return { + valid: false, + message: _( + "Invalid HY2 URL: obfs-password required when obfs is set" + ) + }; + } + if (paramsKeys.includes("sni") && !params.sni) { + return { + valid: false, + message: _("Invalid HY2 URL: sni cannot be empty") + }; + } + if (paramsKeys.includes("mport") && (!params.mport || !params.mport.split(",").every(isValidPortEntry))) { + return { + valid: false, + message: _("Invalid HY2 URL: invalid port number") + }; + } + } + return { valid: true, message: _("Valid") }; + } catch (_e) { + return { valid: false, message: _("Invalid HY2 URL: parsing failed") }; + } +} + +// src/validators/validateHttpProxyUrl.ts +function validateHttpProxyUrl(url) { + try { + if (!/^https?:\/\//.test(url)) { + return { + valid: false, + message: _( + "Invalid HTTP proxy URL: must start with http:// or https://" + ) + }; + } + if (!url || /\s/.test(url)) { + return { + valid: false, + message: _("Invalid HTTP proxy URL: must not contain spaces") + }; + } + const body = url.replace(/^https?:\/\//, ""); + if (/[/?#]/.test(body)) { + return { + valid: false, + message: _( + "Invalid HTTP proxy URL: path, query, and fragment are not supported" + ) + }; + } + const atIndex = body.lastIndexOf("@"); + const credentials = atIndex >= 0 ? body.slice(0, atIndex) : ""; + const hostPortPart = atIndex >= 0 ? body.slice(atIndex + 1) : body; + if (credentials) { + const [username] = credentials.split(":"); + if (!username) { + return { + valid: false, + message: _("Invalid HTTP proxy URL: missing username") + }; + } + } + if (!hostPortPart) { + return { + valid: false, + message: _("Invalid HTTP proxy URL: missing host and port") + }; + } + const parsedHostPort = parseHostPort(hostPortPart); + if (!parsedHostPort) { + return { + valid: false, + message: _("Invalid HTTP proxy URL: invalid host and port") + }; + } + const { host, port } = parsedHostPort; + if (!host) { + return { + valid: false, + message: _("Invalid HTTP proxy URL: missing hostname or IP") + }; + } + if (!port) { + return { + valid: false, + message: _("Invalid HTTP proxy URL: missing port") + }; + } + if (!isValidPort(port)) { + return { + valid: false, + message: _("Invalid HTTP proxy URL: invalid port number") + }; + } + } catch (_e) { + return { + valid: false, + message: _("Invalid HTTP proxy URL: parsing failed") + }; + } + return { valid: true, message: _("Valid") }; +} + +// src/validators/validateProxyUrl.ts +function validateProxyUrl(url) { + const trimmedUrl = url.trim(); + if (trimmedUrl.startsWith("ss://")) { + return validateShadowsocksUrl(trimmedUrl); + } + if (trimmedUrl.startsWith("vless://")) { + return validateVlessUrl(trimmedUrl); + } + if (trimmedUrl.startsWith("vmess://")) { + return validateVmessUrl(trimmedUrl); + } + if (trimmedUrl.startsWith("trojan://")) { + return validateTrojanUrl(trimmedUrl); + } + if (/^socks(4|4a|5):\/\//.test(trimmedUrl)) { + return validateSocksUrl(trimmedUrl); + } + if (/^https?:\/\//.test(trimmedUrl)) { + return validateHttpProxyUrl(trimmedUrl); + } + if (trimmedUrl.startsWith("hysteria2://") || trimmedUrl.startsWith("hy2://")) { + return validateHysteria2Url(trimmedUrl); + } + return { + valid: false, + message: _( + "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://" + ) + }; +} + +// src/helpers/parseValueList.ts +function parseValueList(value) { + return value.split(/\n/).map((line) => line.split("//")[0].split("#")[0]).join(" ").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean); +} + +// src/helpers/getProxyUrlName.ts +function getProxyUrlName(url) { + try { + const [_link, hash] = url.split("#"); + if (!hash) { + return ""; + } + return decodeURIComponent(hash); + } catch { + return ""; + } +} + +// src/helpers/downloadAsTxt.ts +function downloadAsTxt(text, filename) { + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + const safeName = filename.endsWith(".txt") ? filename : `${filename}.txt`; + link.download = safeName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); +} + +// src/tachyon/services/logger.service.ts +var Logger = class { + constructor() { + this.logs = []; + this.levels = ["debug", "info", "warn", "error"]; + } + format(level, ...args) { + return `[${level.toUpperCase()}] ${args.join(" ")}`; + } + push(level, ...args) { + if (!this.levels.includes(level)) level = "info"; + const message = this.format(level, ...args); + this.logs.push(message); + switch (level) { + case "error": + console.error(message); + break; + case "warn": + console.warn(message); + break; + case "info": + console.info(message); + break; + default: + console.log(message); + } + } + debug(...args) { + this.push("debug", ...args); + } + info(...args) { + this.push("info", ...args); + } + warn(...args) { + this.push("warn", ...args); + } + error(...args) { + this.push("error", ...args); + } + clear() { + this.logs = []; + } + getLogs() { + return this.logs.join("\n"); + } + download(filename = "logs.txt") { + if (typeof document === "undefined") { + console.warn("Logger.download() \u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D \u0442\u043E\u043B\u044C\u043A\u043E \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0435"); + return; + } + downloadAsTxt(this.getLogs(), filename); + } +}; +var logger = new Logger(); + +// src/helpers/withTimeout.ts +async function withTimeout(promise, timeoutMs, operationName, timeoutMessage = _("Operation timed out")) { + let timeoutId; + const start = performance.now(); + const timeoutPromise = new Promise((_2, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutId); + const elapsed = performance.now() - start; + logger.info("[SHELL]", `[${operationName}] took ${elapsed.toFixed(2)} ms`); + } +} + +// src/constants.ts +var TACHYON_UCI_PACKAGE = "tachyon"; +var TACHYON_LUCI_APP_VERSION = "__COMPILED_VERSION_VARIABLE__"; +var TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT = "tachyon:action-providers-availability"; +var FAKEIP_CHECK_DOMAIN = "fakeip.podkop.fyi"; +var IP_CHECK_DOMAIN = "ip.podkop.fyi"; +var DEFAULT_LATENCY_TEST_URL = "https://www.gstatic.com/generate_204"; +var LATENCY_TEST_URL_OPTIONS = [ + DEFAULT_LATENCY_TEST_URL, + "https://cp.cloudflare.com/generate_204", + "https://captive.apple.com", + "https://connectivity-check.ubuntu.com" +]; +var REGIONAL_OPTIONS = [ + "russia_inside", + "russia_outside", + "ukraine_inside" +]; +var ALLOWED_WITH_RUSSIA_INSIDE = [ + "russia_inside", + "meta", + "twitter", + "discord", + "telegram", + "cloudflare", + "google_ai", + "google_play", + "hetzner", + "ovh", + "hodca", + "roblox", + "digitalocean", + "cloudfront" +]; +var DOMAIN_LIST_OPTIONS = { + russia_inside: "Russia inside", + russia_outside: "Russia outside", + ukraine_inside: "Ukraine", + geoblock: "Geo Block", + block: "Block", + porn: "Porn", + news: "News", + anime: "Anime", + youtube: "Youtube", + discord: "Discord", + meta: "Meta", + twitter: "Twitter (X)", + hdrezka: "HDRezka", + tiktok: "Tik-Tok", + telegram: "Telegram", + cloudflare: "Cloudflare", + google_ai: "Google AI", + google_play: "Google Play", + hodca: "H.O.D.C.A", + roblox: "Roblox", + ads_hagezi_pro: "Ads (Hagezi Pro)", + supercell: "Supercell", + github: "GitHub", + hetzner: "Hetzner ASN", + ovh: "OVH ASN", + digitalocean: "Digital Ocean ASN", + cloudfront: "CloudFront ASN" +}; +var DNS_SERVER_OPTIONS = { + "1.1.1.1": "1.1.1.1 (Cloudflare)", + "8.8.8.8": "8.8.8.8 (Google)", + "9.9.9.9": "9.9.9.9 (Quad9)", + "dns.adguard-dns.com": "dns.adguard-dns.com (AdGuard Default)", + "unfiltered.adguard-dns.com": "unfiltered.adguard-dns.com (AdGuard Unfiltered)", + "family.adguard-dns.com": "family.adguard-dns.com (AdGuard Family)" +}; +var BOOTSTRAP_DNS_SERVER_OPTIONS = { + "77.88.8.8": "77.88.8.8 (Yandex DNS)", + "77.88.8.1": "77.88.8.1 (Yandex DNS)", + "1.1.1.1": "1.1.1.1 (Cloudflare DNS)", + "1.0.0.1": "1.0.0.1 (Cloudflare DNS)", + "8.8.8.8": "8.8.8.8 (Google DNS)", + "8.8.4.4": "8.8.4.4 (Google DNS)", + "9.9.9.9": "9.9.9.9 (Quad9 DNS)", + "9.9.9.11": "9.9.9.11 (Quad9 DNS)" +}; +var COMMAND_TIMEOUT = 1e4; + +// src/helpers/executeShellCommand.ts +async function executeShellCommand({ + command, + args, + timeout = COMMAND_TIMEOUT +}) { + try { + return await withTimeout( + fs.exec(command, args), + timeout, + [command, ...args].join(" ") + ); + } catch (err) { + const error = err; + const code = typeof error?.code === "number" ? error.code : 1; + return { stdout: "", stderr: error?.message, code }; + } +} + +// src/helpers/onMount.ts +function getTarget(target) { + if (typeof target === "string") { + return document.getElementById(target); + } + return target; +} +async function onMount(target) { + return new Promise((resolve) => { + let observer = null; + const resolveIfMountedAndVisible = () => { + const mountedTarget = getTarget(target); + if (mountedTarget && mountedTarget.isConnected && mountedTarget.offsetParent !== null) { + observer?.disconnect(); + resolve(mountedTarget); + return true; + } + return false; + }; + if (resolveIfMountedAndVisible()) { + return; + } + observer = new MutationObserver(() => { + resolveIfMountedAndVisible(); + }); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["class", "style", "hidden"] + }); + }); +} + +// src/helpers/getClashApiUrl.ts +function getWindowLocation() { + return typeof window !== "undefined" ? window.location : void 0; +} +function canUseDirectClashApi() { + const location = getWindowLocation(); + return typeof location?.hostname === "string" && location.hostname !== "" && location.protocol !== "https:"; +} +function getClashWsUrl() { + const { hostname } = window.location; + return `ws://${hostname}:9090`; +} +function getClashHttpUrl() { + const { hostname } = window.location; + return `http://${hostname}:9090`; +} +function getClashUIUrl() { + const { hostname } = window.location; + return `http://${hostname}:9090/ui`; +} + +// src/helpers/preserveScrollForPage.ts +function preserveScrollForPage(renderFn) { + const scrollY = window.scrollY; + renderFn(); + requestAnimationFrame(() => { + window.scrollTo({ top: scrollY }); + }); +} + +// src/helpers/svgEl.ts +function svgEl(tag, attrs = {}, children = []) { + const NS = "http://www.w3.org/2000/svg"; + const el = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) { + if (v != null) el.setAttribute(k, String(v)); + } + (Array.isArray(children) ? children : [children]).filter(Boolean).forEach((ch) => el.appendChild(ch)); + return el; +} + +// src/helpers/insertIf.ts +function insertIf(condition, elements) { + return condition ? elements : []; +} + +// src/helpers/isCopyableProxyLink.ts +var COPYABLE_PROXY_URI_RE = /^(vless|vmess|trojan|ss|ssr|hysteria2|hy2|tuic|socks4|socks4a|socks5):\/\//i; +var HTTP_PROXY_URI_RE = /^https?:\/\/(?:[^/@\s]+(?::[^/@\s]*)?@)?[^:/?#@\s]+:(\d{1,5})$/i; +function isCopyableProxyLink(link) { + const value = (link || "").trim(); + if (COPYABLE_PROXY_URI_RE.test(value)) { + return true; + } + const httpProxy = value.match(HTTP_PROXY_URI_RE); + if (!httpProxy) { + return false; + } + const port = Number(httpProxy[1]); + return Number.isInteger(port) && port >= 1 && port <= 65535; +} + +// src/icons/renderLoaderCircleIcon24.ts +function renderLoaderCircleIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-loader-circle rotate" + }, + [ + svgEl("path", { + d: "M21 12a9 9 0 1 1-6.219-8.56" + }), + svgEl("animateTransform", { + attributeName: "transform", + attributeType: "XML", + type: "rotate", + from: "0 12 12", + to: "360 12 12", + dur: "1s", + repeatCount: "indefinite" + }) + ] + ); +} + +// src/icons/renderCircleAlertIcon24.ts +function renderCircleAlertIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-alert-icon lucide-circle-alert" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("line", { + x1: "12", + y1: "8", + x2: "12", + y2: "12" + }), + svgEl("line", { + x1: "12", + y1: "16", + x2: "12.01", + y2: "16" + }) + ] + ); +} + +// src/icons/renderCircleCheckIcon24.ts +function renderCircleCheckIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-check-icon lucide-circle-check" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("path", { + d: "M9 12l2 2 4-4" + }) + ] + ); +} + +// src/icons/renderCircleSlashIcon24.ts +function renderCircleSlashIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-slash-icon lucide-circle-slash" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("line", { + x1: "9", + y1: "15", + x2: "15", + y2: "9" + }) + ] + ); +} + +// src/icons/renderCircleXIcon24.ts +function renderCircleXIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-x-icon lucide-circle-x" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("path", { + d: "M15 9L9 15" + }), + svgEl("path", { + d: "M9 9L15 15" + }) + ] + ); +} + +// src/icons/renderCheckIcon24.ts +function renderCheckIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-check-icon lucide-check" + }, + [ + svgEl("path", { + d: "M20 6 9 17l-5-5" + }) + ] + ); +} + +// src/icons/renderXIcon24.ts +function renderXIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-x-icon lucide-x" + }, + [svgEl("path", { d: "M18 6 6 18" }), svgEl("path", { d: "m6 6 12 12" })] + ); +} + +// src/icons/renderTriangleAlertIcon24.ts +function renderTriangleAlertIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-triangle-alert-icon lucide-triangle-alert" + }, + [ + svgEl("path", { + d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" + }), + svgEl("path", { d: "M12 9v4" }), + svgEl("path", { d: "M12 17h.01" }) + ] + ); +} + +// src/icons/renderPauseIcon24.ts +function renderPauseIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-pause-icon lucide-pause" + }, + [ + svgEl("rect", { + x: "14", + y: "3", + width: "5", + height: "18", + rx: "1" + }), + svgEl("rect", { + x: "5", + y: "3", + width: "5", + height: "18", + rx: "1" + }) + ] + ); +} + +// src/icons/renderPlayIcon24.ts +function renderPlayIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-play-icon lucide-play" + }, + [ + svgEl("path", { + d: "M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" + }) + ] + ); +} + +// src/icons/renderRotateCcwIcon24.ts +function renderRotateCcwIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-rotate-ccw-icon lucide-rotate-ccw" + }, + [ + svgEl("path", { + d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" + }), + svgEl("path", { + d: "M3 3v5h5" + }) + ] + ); +} + +// src/icons/renderCircleStopIcon24.ts +function renderCircleStopIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-stop-icon lucide-circle-stop" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("rect", { + x: "9", + y: "9", + width: "6", + height: "6", + rx: "1" + }) + ] + ); +} + +// src/icons/renderCirclePlayIcon24.ts +function renderCirclePlayIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-play-icon lucide-circle-play" + }, + [ + svgEl("path", { + d: "M9 9.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997A1 1 0 0 1 9 14.996z" + }), + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }) + ] + ); +} + +// src/icons/renderCircleCheckBigIcon24.ts +function renderCircleCheckBigIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-check-big-icon lucide-circle-check-big" + }, + [ + svgEl("path", { + d: "M21.801 10A10 10 0 1 1 17 3.335" + }), + svgEl("path", { + d: "m9 11 3 3L22 4" + }) + ] + ); +} + +// src/icons/renderSquareChartGanttIcon24.ts +function renderSquareChartGanttIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-square-chart-gantt-icon lucide-square-chart-gantt" + }, + [ + svgEl("rect", { + width: "18", + height: "18", + x: "3", + y: "3", + rx: "2" + }), + svgEl("path", { d: "M9 8h7" }), + svgEl("path", { d: "M8 12h6" }), + svgEl("path", { d: "M11 16h5" }) + ] + ); +} + +// src/icons/renderCogIcon24.ts +function renderCogIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-cog-icon lucide-cog" + }, + [ + svgEl("path", { d: "M11 10.27 7 3.34" }), + svgEl("path", { d: "m11 13.73-4 6.93" }), + svgEl("path", { d: "M12 22v-2" }), + svgEl("path", { d: "M12 2v2" }), + svgEl("path", { d: "M14 12h8" }), + svgEl("path", { d: "m17 20.66-1-1.73" }), + svgEl("path", { d: "m17 3.34-1 1.73" }), + svgEl("path", { d: "M2 12h2" }), + svgEl("path", { d: "m20.66 17-1.73-1" }), + svgEl("path", { d: "m20.66 7-1.73 1" }), + svgEl("path", { d: "m3.34 17 1.73-1" }), + svgEl("path", { d: "m3.34 7 1.73 1" }), + svgEl("circle", { cx: "12", cy: "12", r: "2" }), + svgEl("circle", { cx: "12", cy: "12", r: "8" }) + ] + ); +} + +// src/icons/renderSearchIcon24.ts +function renderSearchIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-search-icon lucide-search" + }, + [ + svgEl("path", { d: "m21 21-4.34-4.34" }), + svgEl("circle", { cx: "11", cy: "11", r: "8" }) + ] + ); +} + +// src/icons/renderBookOpenTextIcon24.ts +function renderBookOpenTextIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-book-open-text-icon lucide-book-open-text" + }, + [ + svgEl("path", { d: "M12 7v14" }), + svgEl("path", { d: "M16 12h2" }), + svgEl("path", { d: "M16 8h2" }), + svgEl("path", { + d: "M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" + }), + svgEl("path", { d: "M6 12h2" }), + svgEl("path", { d: "M6 8h2" }) + ] + ); +} + +// src/icons/renderCopyIcon24.ts +function renderCopyIcon24() { + return svgEl( + "svg", + { + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-copy-icon lucide-copy" + }, + [ + svgEl("rect", { + width: "14", + height: "14", + x: "8", + y: "8", + rx: "2", + ry: "2" + }), + svgEl("path", { + d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" + }) + ] + ); +} + +// src/icons/renderLinkIcon24.ts +function renderLinkIcon24() { + return svgEl( + "svg", + { + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-link-icon lucide-link" + }, + [ + svgEl("path", { + d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" + }), + svgEl("path", { + d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" + }) + ] + ); +} + +// src/icons/renderDownloadIcon24.ts +function renderDownloadIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-download-icon lucide-download" + }, + [ + svgEl("path", { + d: "M12 15V3" + }), + svgEl("path", { + d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" + }), + svgEl("path", { + d: "m7 10 5 5 5-5" + }) + ] + ); +} + +// src/icons/renderInfoIcon24.ts +function renderInfoIcon24() { + return svgEl( + "svg", + { + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-info-icon lucide-info" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("path", { + d: "M12 16v-4" + }), + svgEl("path", { + d: "M12 8h.01" + }) + ] + ); +} + +// src/helpers/prettyBytes.ts +function prettyBytes(n) { + const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + if (n < 1e3) { + return n + " B"; + } + const exponent = Math.min(Math.floor(Math.log10(n) / 3), UNITS.length - 1); + n = Number((n / Math.pow(1e3, exponent)).toPrecision(3)); + const unit = UNITS[exponent]; + return n + " " + unit; +} + +// src/tachyon/tabs/dashboard/partials/renderSections.ts +function renderFailedState() { + return E( + "div", + { + class: "fkp_dashboard-page__outbound-section centered", + style: "height: 127px" + }, + E("span", {}, [E("span", {}, _("Dashboard currently unavailable"))]) + ); +} +function renderLoadingState() { + return E("div", { + id: "dashboard-sections-grid-skeleton", + class: "fkp_dashboard-page__outbound-section skeleton", + style: "height: 127px" + }); +} +function isValidHttpUrl(url) { + return Boolean(url && /^https?:\/\/\S+$/i.test(url)); +} +function formatBytes(value) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return void 0; + } + return prettyBytes(value); +} +function formatDate(seconds) { + if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) { + return void 0; + } + const date = new Date(seconds * 1e3); + if (Number.isNaN(date.getTime())) { + return void 0; + } + return date.toLocaleDateString(void 0, { + year: "numeric", + month: "2-digit", + day: "2-digit" + }); +} +function renderMetadataAction(label, url) { + if (!isValidHttpUrl(url)) { + return void 0; + } + return E( + "a", + { + class: "btn fkp_dashboard-page__subscription-meta__action", + href: url, + target: "_blank", + rel: "noopener noreferrer", + title: label, + "aria-label": label + }, + renderLinkIcon24() + ); +} +function renderSubscriptionMetadata(metadata) { + if (!metadata || Object.keys(metadata).length <= 1) { + return void 0; + } + const title = metadata.title || metadata.fileName; + const traffic = metadata.traffic; + const used = formatBytes(traffic?.used) || "0 B"; + const total = traffic?.isUnlimited ? "\u221E" : formatBytes(traffic?.total) || "0 B"; + const expire = formatDate(metadata.expire); + const refillDate = formatDate(metadata.refillDate); + const rows = [ + traffic ? { + label: _("Traffic"), + value: `${used} / ${total}` + } : void 0, + expire ? { label: _("Expires"), value: expire } : void 0, + refillDate ? { label: _("Refill"), value: refillDate } : void 0 + ].filter(Boolean); + const actions = [ + renderMetadataAction("Profile", metadata.webPageUrl), + renderMetadataAction("Support", metadata.supportUrl), + renderMetadataAction("More details", metadata.announceUrl) + ].filter(Boolean); + return E("div", { class: "fkp_dashboard-page__subscription-meta" }, [ + E("div", { class: "fkp_dashboard-page__subscription-meta__main" }, [ + E( + "div", + { class: "fkp_dashboard-page__subscription-meta__heading" }, + _("Subscription info:") + ), + title ? E( + "div", + { class: "fkp_dashboard-page__subscription-meta__title" }, + title + ) : "", + rows.length ? E( + "div", + { class: "fkp_dashboard-page__subscription-meta__facts" }, + rows.map( + (row) => E( + "div", + { class: "fkp_dashboard-page__subscription-meta__fact" }, + [ + E( + "span", + { + class: "fkp_dashboard-page__subscription-meta__fact-key" + }, + row.label + ), + E( + "span", + { + class: "fkp_dashboard-page__subscription-meta__fact-value" + }, + row.value + ) + ] + ) + ) + ) : "", + actions.length ? E( + "div", + { class: "fkp_dashboard-page__subscription-meta__actions" }, + actions + ) : "" + ]), + metadata.announce ? E( + "blockquote", + { class: "fkp_dashboard-page__subscription-meta__announce" }, + metadata.announce + ) : "" + ]); +} +function renderSubscriptionUpdateAction(section, subscriptionUpdating, onUpdateSubscription) { + if (!section.subscriptionSourceCount) { + return void 0; + } + return E( + "button", + { + type: "button", + class: "btn fkp_dashboard-page__outbound-section__subscription-update", + "aria-label": _("Update subscriptions"), + disabled: subscriptionUpdating ? true : void 0, + click: (event) => { + event.preventDefault(); + event.stopPropagation(); + if (subscriptionUpdating) { + return; + } + onUpdateSubscription(section); + } + }, + subscriptionUpdating ? [renderLoaderCircleIcon24(), _("Update subscriptions")] : _("Update subscriptions") + ); +} +function getLatencyTestLabel(latencyProgress) { + const total = Math.trunc(Number(latencyProgress?.total ?? 0)); + if (!Number.isFinite(total) || total <= 0) { + return _("Test latency"); + } + const completedValue = Number(latencyProgress?.completed ?? 0); + const completed = Number.isFinite(completedValue) ? Math.trunc(completedValue) : 0; + return `${_("Test latency")}: ${Math.min( + Math.max(0, completed), + total + )}/${total}`; +} +function renderDefaultState({ + section, + onChooseOutbound, + onCopyOutbound, + onShowUrlTestInfo, + onShowPriorityInfo, + onTestLatency, + onUpdateSubscription, + latencyFetching, + latencyProgress, + subscriptionUpdating, + selectorSwitchingTag +}) { + function testLatency() { + if (section.withTagSelect) { + return onTestLatency( + section.latencyTestCodes?.length ? section.latencyTestCodes : section.latencyTestCode || section.code + ); + } + if (section.outbounds.length) { + return onTestLatency(section.outbounds[0].code); + } + } + function renderOutbound(outbound) { + function getLatencyClass() { + if (!outbound.latency) { + return "fkp_dashboard-page__outbound-grid__item__latency--empty"; + } + if (outbound.latency < 800) { + return "fkp_dashboard-page__outbound-grid__item__latency--green"; + } + if (outbound.latency < 1500) { + return "fkp_dashboard-page__outbound-grid__item__latency--yellow"; + } + return "fkp_dashboard-page__outbound-grid__item__latency--red"; + } + const canCopyLink = Boolean(outbound.canCopyLink) || isCopyableProxyLink(outbound.link); + const selectorSwitching = Boolean(selectorSwitchingTag); + const outboundSwitching = selectorSwitchingTag === outbound.code; + const canChooseOutbound = section.withTagSelect && outbound.runtimeAvailable !== false && !selectorSwitching && !outbound.selected; + const className = [ + "fkp_dashboard-page__outbound-grid__item", + outbound.selected ? "fkp_dashboard-page__outbound-grid__item--active" : "", + canChooseOutbound ? "fkp_dashboard-page__outbound-grid__item--selectable" : "", + section.withTagSelect && !canChooseOutbound ? "fkp_dashboard-page__outbound-grid__item--disabled" : "", + outboundSwitching ? "fkp_dashboard-page__outbound-grid__item--switching" : "" + ].filter(Boolean).join(" "); + return E( + "div", + { + class: className, + "aria-busy": outboundSwitching ? "true" : void 0, + "aria-disabled": section.withTagSelect && !canChooseOutbound ? "true" : void 0, + click: () => canChooseOutbound && onChooseOutbound(section.sectionName, section.code, outbound.code) + }, + [ + ...outboundSwitching ? [ + svgEl( + "svg", + { class: "fkp_dashboard-page__outbound-grid__item__snake" }, + [ + svgEl("rect", { + width: "100%", + height: "100%", + fill: "none", + rx: 4, + ry: 4, + pathLength: 100 + }) + ] + ) + ] : [], + E("div", { class: "fkp_dashboard-page__outbound-grid__item__header" }, [ + E("b", {}, outbound.displayName), + ...canCopyLink ? [ + E( + "button", + { + type: "button", + class: "btn fkp_dashboard-page__outbound-grid__item__copy-button", + title: _("Copy proxy link"), + "aria-label": _("Copy proxy link"), + click: (event) => { + event.stopPropagation(); + onCopyOutbound(section, outbound); + } + }, + renderCopyIcon24() + ) + ] : [], + ...outbound.urlTestInfo ? [ + E( + "button", + { + type: "button", + class: "btn fkp_dashboard-page__outbound-grid__item__copy-button", + title: _("URLTest details"), + "aria-label": _("URLTest details"), + click: (event) => { + event.stopPropagation(); + onShowUrlTestInfo(outbound); + } + }, + renderInfoIcon24() + ) + ] : [], + ...outbound.priorityInfo ? [ + E( + "button", + { + type: "button", + class: "btn fkp_dashboard-page__outbound-grid__item__copy-button", + title: _("Priority details"), + "aria-label": _("Priority details"), + click: (event) => { + event.stopPropagation(); + onShowPriorityInfo(outbound); + } + }, + renderInfoIcon24() + ) + ] : [] + ]), + E("div", { class: "fkp_dashboard-page__outbound-grid__item__footer" }, [ + E( + "div", + { class: "fkp_dashboard-page__outbound-grid__item__type" }, + [outbound.type].filter(Boolean) + ), + E( + "div", + { class: getLatencyClass() }, + outbound.latency ? `${outbound.latency}ms` : "N/A" + ) + ]) + ] + ); + } + const metadataNodes = (section.subscriptionMetadata || []).map((metadata) => renderSubscriptionMetadata(metadata)).filter(Boolean); + const subscriptionUpdateAction = renderSubscriptionUpdateAction( + section, + subscriptionUpdating, + onUpdateSubscription + ); + return E("div", { class: "fkp_dashboard-page__outbound-section" }, [ + // Title with test latency + E("div", { class: "fkp_dashboard-page__outbound-section__title-section" }, [ + E( + "div", + { + class: "fkp_dashboard-page__outbound-section__title-section__title" + }, + section.displayName + ), + E( + "div", + { + class: "fkp_dashboard-page__outbound-section__title-section__actions" + }, + [ + ...subscriptionUpdateAction ? [subscriptionUpdateAction] : [], + E( + "button", + { + type: "button", + class: "btn dashboard-sections-grid-item-test-latency", + "data-latency-section": section.sectionName, + disabled: latencyFetching ? true : void 0, + click: (event) => { + event.preventDefault(); + event.stopPropagation(); + if (latencyFetching) { + return; + } + testLatency(); + } + }, + latencyFetching ? [ + renderLoaderCircleIcon24(), + E( + "span", + { + class: "dashboard-sections-grid-item-test-latency__label" + }, + getLatencyTestLabel(latencyProgress) + ) + ] : E( + "span", + { + class: "dashboard-sections-grid-item-test-latency__label" + }, + _("Test latency") + ) + ) + ] + ) + ]), + E("div", { class: "fkp_dashboard-page__outbound-grid" }, [ + ...metadataNodes, + ...section.outbounds.map((outbound) => renderOutbound(outbound)) + ]) + ]); +} +function renderSections(props) { + if (props.failed) { + return renderFailedState(); + } + if (props.loading) { + return renderLoadingState(); + } + return renderDefaultState(props); +} + +// src/tachyon/tabs/dashboard/partials/renderWidget.ts +function renderFailedState2() { + return E( + "div", + { + id: "", + style: "height: 78px", + class: "fkp_dashboard-page__widgets-section__item centered" + }, + _("Currently unavailable") + ); +} +function renderLoadingState2() { + return E( + "div", + { + id: "", + style: "height: 78px", + class: "fkp_dashboard-page__widgets-section__item skeleton" + }, + "" + ); +} +function renderDefaultState2({ title, items }) { + return E("div", { class: "fkp_dashboard-page__widgets-section__item" }, [ + E( + "b", + { class: "fkp_dashboard-page__widgets-section__item__title" }, + title + ), + ...items.map( + (item) => E( + "div", + { + class: `fkp_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ""}` + }, + [ + E( + "span", + { class: "fkp_dashboard-page__widgets-section__item__row__key" }, + `${item.key}: ` + ), + E( + "span", + { class: "fkp_dashboard-page__widgets-section__item__row__value" }, + item.value + ) + ] + ) + ) + ]); +} +function renderWidget(props) { + if (props.loading) { + return renderLoadingState2(); + } + if (props.failed) { + return renderFailedState2(); + } + return renderDefaultState2(props); +} + +// src/tachyon/tabs/dashboard/render.ts +function render() { + return E( + "div", + { + id: "dashboard-status", + class: "fkp_dashboard-page" + }, + [ + E( + "div", + { + class: "fkp_dashboard-page__service-stopped", + role: "status" + }, + _( + "Tachyon service is stopped. Start the service to display the dashboard." + ) + ), + E("div", { class: "fkp_dashboard-page__content" }, [ + // Widgets section + E("div", { class: "fkp_dashboard-page__widgets-section" }, [ + E( + "div", + { id: "dashboard-widget-traffic" }, + renderWidget({ + loading: true, + failed: false, + title: "", + items: [] + }) + ), + E( + "div", + { id: "dashboard-widget-traffic-total" }, + renderWidget({ + loading: true, + failed: false, + title: "", + items: [] + }) + ), + E( + "div", + { id: "dashboard-widget-system-info" }, + renderWidget({ + loading: true, + failed: false, + title: "", + items: [] + }) + ), + E( + "div", + { id: "dashboard-widget-service-info" }, + renderWidget({ + loading: true, + failed: false, + title: "", + items: [] + }) + ) + ]), + // All outbounds + E( + "div", + { id: "dashboard-sections-grid" }, + renderSections({ + loading: true, + failed: false, + section: { + code: "", + sectionName: "", + displayName: "", + outbounds: [], + withTagSelect: false + }, + onTestLatency: () => { + }, + onChooseOutbound: () => { + }, + onCopyOutbound: () => { + }, + onShowUrlTestInfo: () => { + }, + onShowPriorityInfo: () => { + }, + onUpdateSubscription: () => { + }, + latencyFetching: false, + latencyProgress: void 0, + subscriptionUpdating: false, + selectorSwitchingTag: void 0 + }) + ) + ]) + ] + ); +} + +// src/helpers/showToast.ts +function showToast(message, type, duration = 3e3) { + let container = document.querySelector(".toast-container"); + if (!container) { + container = document.createElement("div"); + container.className = "toast-container"; + document.body.appendChild(container); + } + const toast = document.createElement("div"); + toast.className = `toast toast-${type}`; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => toast.classList.add("visible"), 100); + setTimeout(() => { + toast.classList.remove("visible"); + setTimeout(() => toast.remove(), 300); + }, duration); +} + +// src/helpers/copyToClipboard.ts +function copyToClipboard(text) { + const textarea = document.createElement("textarea"); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand("copy"); + showToast(_("Copied"), "success"); + } catch (_err) { + showToast(_("Failed to copy!"), "error"); + console.error("copyToClipboard - e", _err); + } + document.body.removeChild(textarea); +} + +// src/tachyon/methods/custom/getConfigSections.ts +async function getConfigSections() { + return uci.load(TACHYON_UCI_PACKAGE).then(() => uci.sections(TACHYON_UCI_PACKAGE)); +} + +// src/tachyon/runtimeTags.ts +var RESERVED_RUNTIME_TAGS = /* @__PURE__ */ new Set([ + "dns-server", + "fakeip-server", + "bootstrap-dns-server", + "fakeip-dns-rule-tag", + "fakeip-ruleset-dns-rule-tag", + "service-fakeip-dns-rule-tag", + "tproxy-in", + "tproxy6-in", + "dns-in", + "service-mixed-in", + "direct-out", + "bypass-out" +]); +function allocateRuntimeTag(base, postfix) { + let suffix = 1; + let candidate = `${base}-${postfix}`; + while (RESERVED_RUNTIME_TAGS.has(candidate)) { + candidate = `${base}-${postfix}-${suffix}`; + suffix += 1; + } + return candidate; +} +function getOutboundTagBySection(sectionName) { + return allocateRuntimeTag(sectionName, "out"); +} + +// src/tachyon/methods/shell/callBaseMethod.ts +async function callBaseMethod(method, args = [], command = "/usr/bin/tachyon", options = {}) { + try { + const response = await executeShellCommand({ + command, + args: [method, ...args], + timeout: options.timeout ?? 15e3 + }); + const exitCode = response.code ?? 0; + if (exitCode !== 0 && !(options.allowNonZeroWithStdout && response.stdout)) { + return { + success: false, + error: response.stderr || response.stdout || "" + }; + } + if (response.stdout) { + try { + return { + success: true, + data: JSON.parse(response.stdout) + }; + } catch (_e) { + return { + success: true, + data: response.stdout + }; + } + } + return { + success: false, + error: response.stderr || "" + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "" + }; + } +} + +// src/tachyon/types.ts +var Tachyon; +((Tachyon2) => { + let AvailableMethods; + ((AvailableMethods2) => { + AvailableMethods2["CHECK_DNS_AVAILABLE"] = "check_dns_available"; + AvailableMethods2["CHECK_FAKEIP"] = "check_fakeip"; + AvailableMethods2["CHECK_NFT_RULES"] = "check_nft_rules"; + AvailableMethods2["CHECK_ZAPRET_RUNTIME"] = "check_zapret_runtime"; + AvailableMethods2["CHECK_ZAPRET2_RUNTIME"] = "check_zapret2_runtime"; + AvailableMethods2["CHECK_BYEDPI_RUNTIME"] = "check_byedpi_runtime"; + AvailableMethods2["CHECK_INBOUNDS_CONFIG"] = "check_inbounds_config"; + AvailableMethods2["GET_STATUS"] = "get_status"; + AvailableMethods2["GET_OUTBOUND_METADATA"] = "get_outbound_metadata"; + AvailableMethods2["GET_SUBSCRIPTION_METADATA"] = "get_subscription_metadata"; + AvailableMethods2["CHECK_SING_BOX"] = "check_sing_box"; + AvailableMethods2["CHECK_INBOUNDS"] = "check_inbounds"; + AvailableMethods2["GET_SING_BOX_STATUS"] = "get_sing_box_status"; + AvailableMethods2["GET_ZAPRET_STATUS"] = "get_zapret_status"; + AvailableMethods2["GET_ZAPRET2_STATUS"] = "get_zapret2_status"; + AvailableMethods2["GET_BYEDPI_STATUS"] = "get_byedpi_status"; + AvailableMethods2["CLASH_API"] = "clash_api"; + AvailableMethods2["ENABLE"] = "enable"; + AvailableMethods2["DISABLE"] = "disable"; + AvailableMethods2["GLOBAL_CHECK"] = "global_check"; + AvailableMethods2["SHOW_SING_BOX_CONFIG"] = "show_sing_box_config"; + AvailableMethods2["CHECK_LOGS"] = "check_logs"; + AvailableMethods2["CHECK_SING_BOX_LOGS"] = "check_sing_box_logs"; + AvailableMethods2["GET_SYSTEM_INFO"] = "get_system_info"; + AvailableMethods2["GET_SERVER_CAPABILITIES"] = "get_server_capabilities"; + AvailableMethods2["GET_UI_CAPABILITIES"] = "get_ui_capabilities"; + AvailableMethods2["GET_UI_STATE"] = "get_ui_state"; + AvailableMethods2["SERVICE_ACTION_ASYNC"] = "service_action_async"; + AvailableMethods2["SERVICE_ACTION_STATUS"] = "service_action_status"; + AvailableMethods2["LATENCY_TEST_ASYNC"] = "latency_test_async"; + AvailableMethods2["LATENCY_TEST_STATUS"] = "latency_test_status"; + AvailableMethods2["UI_ACTION_ACK"] = "ui_action_ack"; + AvailableMethods2["COMPONENT_ACTION_ASYNC"] = "component_action_async"; + AvailableMethods2["COMPONENT_ACTION_STATUS"] = "component_action_status"; + AvailableMethods2["COMPONENT_UPDATE_CHECK_CACHE"] = "component_update_check_cache"; + AvailableMethods2["SUBSCRIPTION_UPDATE_ASYNC"] = "subscription_update_async"; + AvailableMethods2["SUBSCRIPTION_UPDATE_STATUS"] = "subscription_update_status"; + })(AvailableMethods = Tachyon2.AvailableMethods || (Tachyon2.AvailableMethods = {})); + let AvailableClashAPIMethods; + ((AvailableClashAPIMethods2) => { + AvailableClashAPIMethods2["GET_PROXIES"] = "get_proxies"; + AvailableClashAPIMethods2["GET_CONNECTIONS"] = "get_connections"; + AvailableClashAPIMethods2["GET_PROXY_LATENCY"] = "get_proxy_latency"; + AvailableClashAPIMethods2["GET_PROXY_LATENCIES"] = "get_proxy_latencies"; + AvailableClashAPIMethods2["GET_GROUP_LATENCY"] = "get_group_latency"; + AvailableClashAPIMethods2["SET_GROUP_PROXY"] = "set_group_proxy"; + AvailableClashAPIMethods2["CLOSE_CONNECTION"] = "close_connection"; + AvailableClashAPIMethods2["CLOSE_ALL_CONNECTIONS"] = "close_all_connections"; + })(AvailableClashAPIMethods = Tachyon2.AvailableClashAPIMethods || (Tachyon2.AvailableClashAPIMethods = {})); +})(Tachyon || (Tachyon = {})); + +// src/tachyon/helpers/isTransientRpcError.ts +var TRANSIENT_RPC_ERROR_PATTERNS = [ + "no related rpc reply", + "request aborted", + "operation was aborted" +]; +function isTransientRpcError(message) { + if (!message) { + return false; + } + const normalized = message.toLowerCase(); + return TRANSIENT_RPC_ERROR_PATTERNS.some( + (pattern) => normalized.includes(pattern) + ); +} + +// src/tachyon/methods/shell/index.ts +var SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS = 15e3; +var SUBSCRIPTION_UPDATE_POLL_INTERVAL_MS = 1500; +var UI_ACTION_RPC_TIMEOUT_MS = 15e3; +var UI_ACTION_TRANSIENT_RPC_GRACE_MS = 3e4; +var SERVICE_ACTION_TIMEOUT_MS = 2 * 60 * 1e3; +var SERVICE_ACTION_POLL_INTERVAL_MS = 1e3; +var LATENCY_TEST_TIMEOUT_MS = 30 * 1e3; +var LATENCY_TEST_POLL_INTERVAL_MS = 1e3; +var COMPONENT_ACTION_RPC_TIMEOUT_MS = 15e3; +var COMPONENT_ACTION_POLL_INTERVAL_MS = 1500; +var COMPONENT_ACTION_STATUS_REFRESH_INTERVAL_MS = 15e3; +var COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS = 3e4; +var COMPONENT_ACTION_TRANSIENT_RPC_GRACE_MS = 3e4; +var COMPONENT_ACTION_STATE_DIR = "/var/run/tachyon/component-actions"; +var GET_UI_STATE_RPC_TIMEOUT_MS = 3e3; +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +function translate(message) { + return typeof _ === "function" ? _(message) : message; +} +function parseJsonObjectOutput(output) { + if (!output) { + return null; + } + try { + return JSON.parse(output); + } catch (_error) { + const jsonMatch = output.match(/(\{[\s\S]*\})\s*$/); + if (!jsonMatch) { + return null; + } + try { + return JSON.parse(jsonMatch[1]); + } catch (_jsonError) { + return null; + } + } +} +function parseComponentActionOutput(output) { + return parseJsonObjectOutput(output); +} +function parseComponentActionResult(response) { + return parseComponentActionOutput(response.stdout); +} +function parseComponentActionStartResult(response) { + const parsedResponse = parseComponentActionResult(response); + if (!parsedResponse) { + return null; + } + return parsedResponse; +} +function parseSubscriptionUpdateStartResult(response) { + return parseJsonObjectOutput( + response.stdout + ); +} +function parseSubscriptionUpdateJobState(response) { + return parseJsonObjectOutput( + response.stdout + ); +} +function parseUiActionStartResult(response) { + return parseJsonObjectOutput(response.stdout); +} +function parseServiceActionState(response) { + return parseJsonObjectOutput(response.stdout); +} +function parseLatencyActionState(response) { + return parseJsonObjectOutput(response.stdout); +} +function isComponentActionJobId(jobId) { + return /^[A-Za-z0-9._-]+$/.test(jobId) && jobId !== "." && jobId !== ".."; +} +async function readComponentActionState(jobId) { + if (!isComponentActionJobId(jobId)) { + return null; + } + try { + return parseComponentActionOutput( + await fs.read(`${COMPONENT_ACTION_STATE_DIR}/${jobId}.json`) + ); + } catch (_error) { + return null; + } +} +async function readTachyonVersion() { + const response = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: ["show_version"], + timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS + }); + if ((response.code ?? 0) !== 0 || !response.stdout) { + return ""; + } + return response.stdout.trim(); +} +async function isComponentActionStillRunning(jobId, component, action) { + const response = await callBaseMethod( + Tachyon.AvailableMethods.GET_UI_STATE, + [], + "/usr/bin/tachyon", + { timeout: GET_UI_STATE_RPC_TIMEOUT_MS } + ); + return response.success && response.data.actions.component.some( + (state) => state.job_id === jobId && state.component === component && state.action === action && state.running === true + ); +} +function componentActionFailure(response, parsedResponse) { + return { + success: false, + error: parsedResponse?.message || response.stderr || _("Failed to execute") + }; +} +function uiActionFailure(response, parsedResponse, fallback = _("Failed to execute")) { + return { + success: false, + error: parsedResponse?.message || response.stderr || fallback + }; +} +function createTransientRpcGraceTracker(graceMs) { + let failureStartedAt = 0; + return { + reset() { + failureStartedAt = 0; + }, + shouldContinue(error) { + if (!isTransientRpcError(error)) { + failureStartedAt = 0; + return false; + } + if (!failureStartedAt) { + failureStartedAt = Date.now(); + } + return Date.now() - failureStartedAt < graceMs; + } + }; +} +var TachyonShellMethods = { + checkDNSAvailable: async () => callBaseMethod( + Tachyon.AvailableMethods.CHECK_DNS_AVAILABLE + ), + checkFakeIP: async () => callBaseMethod( + Tachyon.AvailableMethods.CHECK_FAKEIP + ), + checkNftRules: async () => callBaseMethod( + Tachyon.AvailableMethods.CHECK_NFT_RULES + ), + checkZapretRuntime: async () => callBaseMethod( + Tachyon.AvailableMethods.CHECK_ZAPRET_RUNTIME + ), + checkZapret2Runtime: async () => callBaseMethod( + Tachyon.AvailableMethods.CHECK_ZAPRET2_RUNTIME + ), + checkByedpiRuntime: async () => callBaseMethod( + Tachyon.AvailableMethods.CHECK_BYEDPI_RUNTIME + ), + checkInboundsConfig: async () => callBaseMethod( + Tachyon.AvailableMethods.CHECK_INBOUNDS_CONFIG + ), + getStatus: async () => callBaseMethod(Tachyon.AvailableMethods.GET_STATUS), + getOutboundMetadata: async (section) => callBaseMethod( + Tachyon.AvailableMethods.GET_OUTBOUND_METADATA, + [section] + ), + getSubscriptionMetadata: async (section) => callBaseMethod( + Tachyon.AvailableMethods.GET_SUBSCRIPTION_METADATA, + [section] + ), + checkSingBox: async () => callBaseMethod( + Tachyon.AvailableMethods.CHECK_SING_BOX + ), + checkInbounds: async () => callBaseMethod( + Tachyon.AvailableMethods.CHECK_INBOUNDS + ), + getSingBoxStatus: async () => callBaseMethod( + Tachyon.AvailableMethods.GET_SING_BOX_STATUS + ), + getZapretStatus: async () => callBaseMethod( + Tachyon.AvailableMethods.GET_ZAPRET_STATUS + ), + getZapret2Status: async () => callBaseMethod( + Tachyon.AvailableMethods.GET_ZAPRET2_STATUS + ), + getByedpiStatus: async () => callBaseMethod( + Tachyon.AvailableMethods.GET_BYEDPI_STATUS + ), + getClashApiProxies: async () => callBaseMethod(Tachyon.AvailableMethods.CLASH_API, [ + Tachyon.AvailableClashAPIMethods.GET_PROXIES + ]), + getClashApiConnections: async () => callBaseMethod(Tachyon.AvailableMethods.CLASH_API, [ + Tachyon.AvailableClashAPIMethods.GET_CONNECTIONS + ]), + getClashApiProxyLatency: async (tag, timeout = "5000") => callBaseMethod( + Tachyon.AvailableMethods.CLASH_API, + [Tachyon.AvailableClashAPIMethods.GET_PROXY_LATENCY, tag, timeout] + ), + getClashApiProxyLatencies: async (tags) => callBaseMethod( + Tachyon.AvailableMethods.CLASH_API, + [ + Tachyon.AvailableClashAPIMethods.GET_PROXY_LATENCIES, + JSON.stringify(tags), + "5000" + ] + ), + getClashApiGroupLatency: async (tag) => callBaseMethod( + Tachyon.AvailableMethods.CLASH_API, + [Tachyon.AvailableClashAPIMethods.GET_GROUP_LATENCY, tag, "10000"] + ), + setClashApiGroupProxy: async (group, proxy) => callBaseMethod(Tachyon.AvailableMethods.CLASH_API, [ + Tachyon.AvailableClashAPIMethods.SET_GROUP_PROXY, + group, + proxy + ]), + closeClashApiConnection: async (connectionId) => callBaseMethod(Tachyon.AvailableMethods.CLASH_API, [ + Tachyon.AvailableClashAPIMethods.CLOSE_CONNECTION, + connectionId + ]), + closeAllClashApiConnections: async () => callBaseMethod(Tachyon.AvailableMethods.CLASH_API, [ + Tachyon.AvailableClashAPIMethods.CLOSE_ALL_CONNECTIONS + ]), + enable: async () => callBaseMethod( + Tachyon.AvailableMethods.ENABLE, + [], + "/etc/init.d/tachyon" + ), + disable: async () => callBaseMethod( + Tachyon.AvailableMethods.DISABLE, + [], + "/etc/init.d/tachyon" + ), + globalCheck: async (masked = true) => callBaseMethod(Tachyon.AvailableMethods.GLOBAL_CHECK, [ + masked ? "masked" : "raw" + ]), + showSingBoxConfig: async (masked = true) => callBaseMethod(Tachyon.AvailableMethods.SHOW_SING_BOX_CONFIG, [ + masked ? "masked" : "raw" + ]), + checkLogs: async () => callBaseMethod(Tachyon.AvailableMethods.CHECK_LOGS), + checkSingBoxLogs: async () => callBaseMethod(Tachyon.AvailableMethods.CHECK_SING_BOX_LOGS), + getSystemInfo: async () => callBaseMethod( + Tachyon.AvailableMethods.GET_SYSTEM_INFO + ), + getServerCapabilities: async () => callBaseMethod( + Tachyon.AvailableMethods.GET_SERVER_CAPABILITIES + ), + getUiCapabilities: async () => callBaseMethod( + Tachyon.AvailableMethods.GET_UI_CAPABILITIES + ), + getUiState: async () => callBaseMethod( + Tachyon.AvailableMethods.GET_UI_STATE, + [], + "/usr/bin/tachyon", + { timeout: GET_UI_STATE_RPC_TIMEOUT_MS } + ), + serviceActionStart: async (action) => { + const response = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: [Tachyon.AvailableMethods.SERVICE_ACTION_ASYNC, action], + timeout: UI_ACTION_RPC_TIMEOUT_MS + }); + const parsedResponse = parseUiActionStartResult(response); + if ((response.code ?? 0) !== 0 || !parsedResponse?.success || !parsedResponse.job_id) { + return uiActionFailure( + response, + parsedResponse, + _("Service action failed") + ); + } + return { + success: true, + data: parsedResponse + }; + }, + serviceActionStatus: async (jobId) => { + const response = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: [Tachyon.AvailableMethods.SERVICE_ACTION_STATUS, jobId], + timeout: UI_ACTION_RPC_TIMEOUT_MS + }); + const parsedResponse = parseServiceActionState(response); + if ((response.code ?? 0) !== 0 || !parsedResponse) { + return uiActionFailure( + response, + parsedResponse, + _("Service action failed") + ); + } + return { + success: true, + data: parsedResponse + }; + }, + waitServiceActionJob: async (jobId, startedAt = Date.now()) => { + while (Date.now() - startedAt < SERVICE_ACTION_TIMEOUT_MS) { + await sleep(SERVICE_ACTION_POLL_INTERVAL_MS); + const response = await TachyonShellMethods.serviceActionStatus(jobId); + if (!response.success) { + return response; + } + if (response.data.running) { + continue; + } + return response; + } + return { + success: false, + error: _("Operation timed out") + }; + }, + latencyTestStart: async (latencyType, section, tag, timeout) => { + const response = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: [ + Tachyon.AvailableMethods.LATENCY_TEST_ASYNC, + latencyType, + section, + tag, + ...timeout ? [timeout] : [] + ], + timeout: UI_ACTION_RPC_TIMEOUT_MS + }); + const parsedResponse = parseUiActionStartResult(response); + if ((response.code ?? 0) !== 0 || !parsedResponse?.success || !parsedResponse.job_id) { + return uiActionFailure( + response, + parsedResponse, + _("Latency test failed") + ); + } + return { + success: true, + data: parsedResponse + }; + }, + latencyTestStatus: async (jobId) => { + const response = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: [Tachyon.AvailableMethods.LATENCY_TEST_STATUS, jobId], + timeout: UI_ACTION_RPC_TIMEOUT_MS + }); + const parsedResponse = parseLatencyActionState(response); + if ((response.code ?? 0) !== 0 || !parsedResponse) { + return uiActionFailure( + response, + parsedResponse, + _("Latency test failed") + ); + } + return { + success: true, + data: parsedResponse + }; + }, + waitLatencyTestJob: async (jobId, startedAt = Date.now()) => { + const transientRpc = createTransientRpcGraceTracker( + UI_ACTION_TRANSIENT_RPC_GRACE_MS + ); + while (Date.now() - startedAt < LATENCY_TEST_TIMEOUT_MS) { + await sleep(LATENCY_TEST_POLL_INTERVAL_MS); + const response = await TachyonShellMethods.latencyTestStatus(jobId); + if (!response.success) { + if (transientRpc.shouldContinue(response.error)) { + continue; + } + return response; + } + transientRpc.reset(); + if (response.data.running) { + continue; + } + return response; + } + return { + success: false, + error: _("Operation timed out") + }; + }, + uiActionAck: async (kind, jobId) => { + const response = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: [Tachyon.AvailableMethods.UI_ACTION_ACK, kind, jobId], + timeout: UI_ACTION_RPC_TIMEOUT_MS + }); + const parsedResponse = parseUiActionStartResult(response); + if ((response.code ?? 0) !== 0 || !parsedResponse?.success) { + return uiActionFailure(response, parsedResponse); + } + return { + success: true, + data: parsedResponse + }; + }, + componentActionStart: async (component, action) => { + const response = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: [Tachyon.AvailableMethods.COMPONENT_ACTION_ASYNC, component, action], + timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS + }); + const parsedResponse = parseComponentActionStartResult(response); + if ((response.code ?? 0) !== 0 || !parsedResponse?.success || !parsedResponse.job_id) { + return componentActionFailure(response, parsedResponse); + } + return { + success: true, + data: parsedResponse + }; + }, + componentActionStatus: async (jobId) => { + const response = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: [Tachyon.AvailableMethods.COMPONENT_ACTION_STATUS, jobId], + timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS + }); + const parsedResponse = parseComponentActionResult(response); + if ((response.code ?? 0) !== 0 || !parsedResponse) { + return componentActionFailure(response, parsedResponse); + } + return { + success: true, + data: parsedResponse + }; + }, + componentUpdateCheckCache: async () => callBaseMethod( + Tachyon.AvailableMethods.COMPONENT_UPDATE_CHECK_CACHE + ), + waitComponentActionJob: async (jobId, component, action, expectedLatestVersion) => { + let selfUpdateVersionMatchedAt = 0; + let lastStatusRefreshAt = 0; + const transientRpc = createTransientRpcGraceTracker( + COMPONENT_ACTION_TRANSIENT_RPC_GRACE_MS + ); + while (true) { + await sleep(COMPONENT_ACTION_POLL_INTERVAL_MS); + const stateResponse = await readComponentActionState(jobId); + if (stateResponse) { + if (!stateResponse.running) { + transientRpc.reset(); + return { + success: true, + data: stateResponse + }; + } + if (Date.now() - lastStatusRefreshAt < COMPONENT_ACTION_STATUS_REFRESH_INTERVAL_MS) { + continue; + } + } + lastStatusRefreshAt = Date.now(); + const statusResponse = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: [Tachyon.AvailableMethods.COMPONENT_ACTION_STATUS, jobId], + timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS + }); + const parsedResponse = parseComponentActionResult(statusResponse); + if ((statusResponse.code ?? 0) !== 0 || !parsedResponse) { + if (stateResponse?.running) { + transientRpc.reset(); + continue; + } + if (await isComponentActionStillRunning(jobId, component, action)) { + transientRpc.reset(); + continue; + } + const failure = componentActionFailure(statusResponse, parsedResponse); + if (transientRpc.shouldContinue(failure.error)) { + continue; + } + if (component === "tachyon" && action === "install") { + const installedVersion = expectedLatestVersion ? await readTachyonVersion() : ""; + if (expectedLatestVersion && installedVersion === expectedLatestVersion) { + if (!selfUpdateVersionMatchedAt) { + selfUpdateVersionMatchedAt = Date.now(); + } + if (Date.now() - selfUpdateVersionMatchedAt >= COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS) { + return { + success: true, + data: { + success: true, + component, + action, + message: translate("Tachyon has been installed"), + current_version: installedVersion, + latest_version: expectedLatestVersion, + changed: true, + status: "latest" + } + }; + } + } + continue; + } + return failure; + } + transientRpc.reset(); + if (parsedResponse.running) { + continue; + } + return { + success: true, + data: parsedResponse + }; + } + }, + subscriptionUpdateStart: async (section, sourceIndex) => { + const startArgs = [ + Tachyon.AvailableMethods.SUBSCRIPTION_UPDATE_ASYNC, + ...section ? [section] : [], + ...section && sourceIndex !== void 0 ? [String(sourceIndex)] : [] + ]; + const response = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: startArgs, + timeout: SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS + }); + const parsedResponse = parseSubscriptionUpdateStartResult(response); + if ((response.code ?? 0) !== 0 || !parsedResponse?.success || !parsedResponse.job_id) { + return { + success: false, + error: parsedResponse?.message || response.stderr || _("Subscription update failed") + }; + } + return { + success: true, + data: parsedResponse + }; + }, + subscriptionUpdateStatus: async (jobId) => { + const response = await executeShellCommand({ + command: "/usr/bin/tachyon", + args: [Tachyon.AvailableMethods.SUBSCRIPTION_UPDATE_STATUS, jobId], + timeout: SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS + }); + const parsedResponse = parseSubscriptionUpdateJobState(response); + if ((response.code ?? 0) !== 0 || !parsedResponse) { + return { + success: false, + error: response.stderr || _("Subscription update failed") + }; + } + return { + success: true, + data: parsedResponse + }; + }, + waitSubscriptionUpdateJob: async (jobId) => { + const transientRpc = createTransientRpcGraceTracker( + UI_ACTION_TRANSIENT_RPC_GRACE_MS + ); + while (true) { + await sleep(SUBSCRIPTION_UPDATE_POLL_INTERVAL_MS); + const response = await TachyonShellMethods.subscriptionUpdateStatus(jobId); + if (!response.success) { + if (transientRpc.shouldContinue(response.error)) { + continue; + } + return response; + } + transientRpc.reset(); + if (response.data.running) { + continue; + } + return response; + } + } +}; + +// src/tachyon/methods/custom/getDashboardSections.ts +var DASHBOARD_SECTION_CACHE_DIR = "/var/run/tachyon/section-cache"; +function getDisplayName(section) { + return section.label || section[".name"]; +} +function getSettingsSection(configSections) { + return configSections.find((section) => section[".type"] === "settings"); +} +function getClashApiSecret(configSections) { + return getSettingsSection(configSections)?.yacd_secret_key || ""; +} +function canFetchClashApiDirectly() { + return canUseDirectClashApi() && typeof fetch === "function"; +} +async function getClashApiProxies(configSections) { + if (canFetchClashApiDirectly()) { + const secret = getClashApiSecret(configSections); + try { + const response = await fetch(`${getClashHttpUrl()}/proxies`, { + headers: secret ? { Authorization: `Bearer ${secret}` } : void 0 + }); + if (response.ok) { + return { + success: true, + data: await response.json() + }; + } + } catch (_error) { + } + } + return TachyonShellMethods.getClashApiProxies(); +} +function getListValues(value) { + if (!value) { + return []; + } + if (Array.isArray(value)) { + return value.map((item) => `${item}`.trim()).filter(Boolean); + } + return `${value}`.split(/\s+/).map((item) => item.trim()).filter(Boolean); +} +function childSections(configSections, type) { + return configSections.filter((section) => section[".type"] === type); +} +function ownedChildSections(parent, children) { + return children.filter( + (section) => section.section === parent[".name"] + ); +} +function childSectionsByOwner(children, ownerKey, ownerValue) { + return children.filter((section) => section[ownerKey] === ownerValue); +} +function compactSettingsMap(settings) { + return Object.keys(settings).length ? JSON.stringify(settings) : void 0; +} +function hydrateConfigSections(configSections) { + const subscriptionUrls = childSections(configSections, "subscription_url"); + const interfaces = childSections(configSections, "section_interface"); + const urltests = childSections(configSections, "urltest"); + const priorityGroups = childSections(configSections, "priority_group"); + const priorityLevels = childSections(configSections, "priority_level"); + return configSections.map((section) => { + if (section[".type"] !== "section") { + return section; + } + const next = { ...section }; + const subscriptionUrlItems = ownedChildSections(next, subscriptionUrls); + const interfaceItems = ownedChildSections(next, interfaces); + const urltestItems = ownedChildSections(next, urltests); + const priorityGroupItems = ownedChildSections(next, priorityGroups); + if (subscriptionUrlItems.length) { + const settings = {}; + next.subscription_urls = subscriptionUrlItems.map((item) => item.url || "").filter(Boolean); + subscriptionUrlItems.forEach((item) => { + if (!item.url) { + return; + } + settings[item.url] = { + subscription_update_enabled: item.subscription_update_enabled, + subscription_update_interval: item.subscription_update_interval, + download_via_proxy_enabled: item.download_via_proxy_enabled, + download_via_proxy_section: item.download_via_proxy_section, + auto_user_agent: item.auto_user_agent, + user_agent: item.user_agent, + auto_hwid: item.auto_hwid, + hwid: item.hwid, + show_dashboard_metadata: item.show_dashboard_metadata, + prefix_nodes: item.prefix_nodes, + node_prefix: item.node_prefix, + include_urltest_groups: item.include_urltest_groups, + hide_urltest_group_outbounds: item.hide_urltest_group_outbounds, + hide_detour_outbounds: item.hide_detour_outbounds + }; + }); + next.subscription_url_settings = compactSettingsMap(settings); + } + if (interfaceItems.length) { + next.interfaces = interfaceItems.map((item) => item.name || "").filter(Boolean); + } + if (urltestItems.length) { + const settings = {}; + next.urltests = urltestItems.map((item) => item[".name"]); + urltestItems.forEach((item) => { + settings[item[".name"]] = { + display_name: item.name || item.display_name, + urltest_check_interval: item.check_interval, + urltest_tolerance: item.tolerance, + urltest_testing_url: item.testing_url, + idle_timeout: item.idle_timeout, + interrupt_exist_connections: item.interrupt_exist_connections, + pin_dashboard: item.pin_dashboard, + urltest_filter_mode: item.filter_mode, + detect_server_country: item.detect_server_country, + urltest_include_countries: item.include_countries, + urltest_include_outbounds: item.include_outbounds, + urltest_include_regex: item.include_regex, + urltest_exclude_countries: item.exclude_countries, + urltest_exclude_outbounds: item.exclude_outbounds, + urltest_exclude_regex: item.exclude_regex + }; + }); + next.urltest_settings = compactSettingsMap(settings); + } + if (priorityGroupItems.length) { + const settings = {}; + next.priority_groups = priorityGroupItems.map((item) => item[".name"]); + priorityGroupItems.forEach((item) => { + const groupId = item[".name"]; + const levels = childSectionsByOwner(priorityLevels, "group", groupId).map( + (level, index) => ({ + id: level[".name"], + displayName: level.name || level[".name"], + order: Number.parseInt(level.order || `${index}`, 10) || 0, + direct: level.direct === "1", + filterMode: level.filter_mode || "include", + detectServerCountry: level.detect_server_country || "flag_emoji", + country: getListValues(level.country), + serverName: getListValues(level.server_name), + regex: getListValues(level.regex), + excludeCountries: getListValues(level.exclude_countries), + excludeOutbounds: getListValues(level.exclude_outbounds), + excludeRegex: getListValues(level.exclude_regex) + }) + ).sort( + (left, right) => left.order === right.order ? left.id.localeCompare(right.id) : left.order - right.order + ); + settings[groupId] = { + name: item.name, + health_url: item.health_url, + active_check_interval: item.active_check_interval, + check_timeout: item.check_timeout, + recovery_check_interval: item.recovery_check_interval, + pick_fastest: item.pick_fastest, + switch_to_faster_same_priority: item.switch_to_faster_same_priority, + fastest_check_interval: item.fastest_check_interval, + interrupt_exist_connections: item.interrupt_exist_connections, + pin_dashboard: item.pin_dashboard, + levels + }; + }); + next.priority_group_settings = compactSettingsMap(settings); + } + return next; + }); +} +function getManualProxyLinks(section) { + return getListValues(section.selector_proxy_links); +} +function getConnectionInterfaces(section) { + const values = getListValues(section.interfaces); + return values.length ? values : getListValues(section.interface); +} +function getJsonOutbounds(section) { + const values = getListValues(section.outbound_jsons); + return values.length ? values : getListValues(section.outbound_json); +} +function isConnectionAction(action) { + return Boolean( + action && ["connection", "proxy", "outbound", "vpn"].includes(action) + ); +} +function hasSubscriptionSources(section) { + return getSubscriptionSourceCount(section) > 0; +} +function getSubscriptionSourceCount(section) { + return getListValues(section.subscription_urls).length; +} +function shouldSortByLatency(section) { + return section.sort_by_latency === "1"; +} +function hasConfiguredUrlTestList(section) { + return getListValues(section.urltests).length > 0; +} +function hasConfiguredPriorityList(section) { + return getListValues(section.priority_groups).length > 0; +} +function getUrlTestIds(section) { + const values = getListValues(section.urltests); + return values.length ? values : section.urltest_enabled === "1" ? ["urltest"] : []; +} +function isUrlTestEnabled(section) { + return getUrlTestIds(section).length > 0; +} +function shouldUseProxyGroup(section) { + return getManualProxyLinks(section).length > 0 || hasSubscriptionSources(section) || getConnectionInterfaces(section).length > 0 || getJsonOutbounds(section).length > 0 || isUrlTestEnabled(section) || hasConfiguredPriorityList(section); +} +function getSectionProxyConfigType(section) { + if (hasSubscriptionSources(section)) { + return "subscription"; + } + if (isUrlTestEnabled(section) && shouldUseProxyGroup(section)) { + return "urltest"; + } + if (getManualProxyLinks(section).length > 0) { + return "selector"; + } + if (getJsonOutbounds(section).length > 0) { + return "outbound"; + } + if (getConnectionInterfaces(section).length > 0) { + return "interface"; + } + if (hasConfiguredPriorityList(section)) { + return "selector"; + } + return void 0; +} +function getJsonOutboundDisplayName(section) { + try { + const parsedOutbound = JSON.parse(section.outbound_json || "{}"); + return parsedOutbound?.tag ? decodeURIComponent(parsedOutbound.tag) : ""; + } catch (_error) { + return ""; + } +} +function buildManualLinkByCode(section) { + const sectionName = section[".name"]; + return new Map( + getManualProxyLinks(section).map((link, index) => [ + getOutboundTagBySection(`${sectionName}-${index + 1}`), + link + ]) + ); +} +function getProxyEntryByCode(proxies) { + return new Map(proxies.map((proxy) => [proxy.code, proxy])); +} +function uniqueCodes(codes) { + return Array.from(new Set(codes.filter(Boolean))); +} +function isSelectorOutbound(outbound) { + return outbound.type?.toLowerCase() === "selector"; +} +function isUrlTestProxyEntry(entry) { + return entry?.value?.type?.toLowerCase() === "urltest"; +} +function getLatencySortValue(outbound) { + const latency = Number(outbound.latency); + return Number.isFinite(latency) && latency > 0 ? latency : Number.POSITIVE_INFINITY; +} +function sortOutboundsForDashboard(outbounds, options = {}) { + const pinnedCodes = [ + ...options.pinnedCode ? [options.pinnedCode] : [], + ...options.pinnedCodes || [] + ].filter(Boolean); + const pinnedRank = new Map( + pinnedCodes.map((code, index) => [code, index]) + ); + const sortByLatency = options.sortByLatency === true; + return outbounds.map((outbound, index) => ({ outbound, index })).sort((left, right) => { + const leftPinned = pinnedRank.has(left.outbound.code); + const rightPinned = pinnedRank.has(right.outbound.code); + if (leftPinned !== rightPinned) { + return leftPinned ? -1 : 1; + } + if (leftPinned && rightPinned) { + const rankDiff = (pinnedRank.get(left.outbound.code) ?? 0) - (pinnedRank.get(right.outbound.code) ?? 0); + if (rankDiff !== 0) { + return rankDiff; + } + } + if (sortByLatency) { + const latencyDiff = getLatencySortValue(left.outbound) - getLatencySortValue(right.outbound); + if (latencyDiff !== 0) { + return latencyDiff; + } + } + return left.index - right.index; + }).map((item) => item.outbound); +} +function sortUrlTestMembers(outbounds) { + return outbounds.map((outbound, index) => ({ outbound, index })).sort((left, right) => { + if (left.outbound.selected !== right.outbound.selected) { + return left.outbound.selected ? -1 : 1; + } + const latencyDiff = getLatencySortValue(left.outbound) - getLatencySortValue(right.outbound); + if (latencyDiff !== 0) { + return latencyDiff; + } + return left.index - right.index; + }).map((item) => item.outbound); +} +function isSafeSectionName(sectionName) { + return /^[A-Za-z0-9_-]+$/.test(sectionName); +} +function objectMap(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return Object.fromEntries( + Object.entries(value).filter(([, item]) => typeof item === "string").map(([key, item]) => [key, item]) + ); +} +function itemSettingsMap(value) { + if (!value) { + return {}; + } + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return Object.fromEntries( + Object.entries(parsed).filter( + ([, item]) => item && typeof item === "object" && !Array.isArray(item) + ) + ); + } catch (_error) { + return {}; + } +} +function itemSettingString(settings, key, fallback = "") { + const value = settings?.[key]; + return typeof value === "string" ? value : fallback; +} +function itemSettingBoolean(settings, key, fallback) { + const value = settings?.[key]; + if (value === void 0 || value === null || value === "") { + return fallback; + } + return value === "1" || value === "true"; +} +function isUrlTestFilteringEnabled(settings) { + return ["exclude", "include", "mixed"].includes( + itemSettingString(settings, "urltest_filter_mode", "disabled") + ); +} +function getUrlTestTag(sectionName, id) { + return getOutboundTagBySection( + id === "urltest" ? `${sectionName}-urltest` : `${sectionName}-urltest-${id}` + ); +} +function getPriorityTag(sectionName, id) { + return getOutboundTagBySection(`${sectionName}-priority-${id}`); +} +function getUrlTestDisplayName(section, id, settings) { + return itemSettingString( + settings, + "display_name", + id === "urltest" && !hasConfiguredUrlTestList(section) ? _("Fastest") : id + ); +} +function getUrlTestConfigs(section) { + const settingsMap = itemSettingsMap(section.urltest_settings); + const sectionName = section[".name"]; + return getUrlTestIds(section).map((id) => { + const settings = settingsMap[id] || {}; + const filteringEnabled = isUrlTestFilteringEnabled(settings); + return { + id, + code: getUrlTestTag(sectionName, id), + displayName: getUrlTestDisplayName(section, id, settings), + settings, + pinDashboard: itemSettingBoolean(settings, "pin_dashboard", true), + showDetectedCountries: filteringEnabled && itemSettingString(settings, "detect_server_country", "flag_emoji") === "country_is" + }; + }); +} +function priorityLevelConfigsFromSettings(settings) { + const value = settings?.levels; + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((item, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return []; + } + const level = item; + const id = `${level.id || ""}`.trim(); + if (!id) { + return []; + } + return [ + { + id, + displayName: `${level.displayName || id}`, + order: typeof level.order === "number" && Number.isFinite(level.order) ? level.order : index, + direct: Boolean(level.direct), + filterMode: `${level.filterMode || "include"}` || "include", + detectServerCountry: `${level.detectServerCountry || "flag_emoji"}` || "flag_emoji", + country: getListValues(level.country), + serverName: getListValues(level.serverName), + regex: getListValues(level.regex), + excludeCountries: getListValues( + level.excludeCountries + ), + excludeOutbounds: getListValues( + level.excludeOutbounds + ), + excludeRegex: getListValues(level.excludeRegex), + outbounds: getListValues(level.outbounds) + } + ]; + }).sort( + (left, right) => left.order === right.order ? left.id.localeCompare(right.id) : left.order - right.order + ); +} +function getPriorityGroupIds(section) { + return getListValues(section.priority_groups); +} +function getPriorityConfigs(section) { + const settingsMap = itemSettingsMap(section.priority_group_settings); + const sectionName = section[".name"]; + return getPriorityGroupIds(section).map((id) => { + const settings = settingsMap[id] || {}; + const levels = priorityLevelConfigsFromSettings(settings); + return { + id, + code: getPriorityTag(sectionName, id), + displayName: itemSettingString(settings, "name", id), + settings, + pinDashboard: itemSettingBoolean(settings, "pin_dashboard", true), + healthUrl: itemSettingString( + settings, + "health_url", + "https://www.gstatic.com/generate_204" + ), + activeCheckInterval: itemSettingString( + settings, + "active_check_interval", + "5s" + ), + checkTimeout: itemSettingString(settings, "check_timeout", "2s"), + recoveryCheckInterval: itemSettingString( + settings, + "recovery_check_interval", + "15s" + ), + pickFastest: itemSettingBoolean(settings, "pick_fastest", false), + switchToFasterSamePriority: itemSettingBoolean( + settings, + "switch_to_faster_same_priority", + false + ), + fastestCheckInterval: itemSettingString( + settings, + "fastest_check_interval", + "3m" + ), + interruptExistConnections: itemSettingBoolean( + settings, + "interrupt_exist_connections", + true + ), + showDetectedCountries: levels.some( + (level) => level.detectServerCountry === "country_is" + ), + levels + }; + }); +} +async function readDashboardSectionCache(sectionName) { + if (!isSafeSectionName(sectionName)) { + return void 0; + } + try { + const raw = await fs.read( + `${DASHBOARD_SECTION_CACHE_DIR}/${sectionName}.json` + ); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return void 0; + } + return parsed; + } catch (_error) { + return void 0; + } +} +function getUrlTestGroups(dashboardCache) { + const groups = dashboardCache?.urltestGroups; + if (!groups || typeof groups !== "object" || Array.isArray(groups)) { + return {}; + } + return groups; +} +function getPriorityGroups(dashboardCache) { + const groups = dashboardCache?.priorityGroups; + if (!groups || typeof groups !== "object" || Array.isArray(groups)) { + return {}; + } + return groups; +} +function getOutboundDisplayName(code, entry, link, outboundMetadata, preferMetadata = false) { + const metadataName = outboundMetadata?.names?.[code]; + return (preferMetadata ? metadataName : getProxyUrlName(link)) || (preferMetadata ? getProxyUrlName(link) : metadataName) || entry?.value?.name || code; +} +function buildUrlTestInfo({ + code, + displayName, + entry, + groupCache, + proxyByCode, + manualLinkByCode, + cachedProxyLinks, + outboundMetadata, + showDetectedCountries +}) { + const childCodes = uniqueCodes( + groupCache?.outbounds?.length ? groupCache.outbounds : entry?.value.all || [] + ); + const selectedCode = entry?.value.now || ""; + const outbounds = sortUrlTestMembers( + childCodes.flatMap((childCode) => { + const childEntry = proxyByCode.get(childCode); + const link = manualLinkByCode.get(childCode) || cachedProxyLinks.get(childCode) || ""; + const canCopyLink = isCopyableProxyLink(link); + return [ + { + code: childCode, + displayName: getOutboundDisplayName( + childCode, + childEntry, + link, + outboundMetadata, + cachedProxyLinks.has(childCode) + ), + latency: childEntry?.value?.history?.[0]?.delay || 0, + type: childEntry?.value?.type || "", + selected: selectedCode === childCode, + link, + canCopyLink, + country: showDetectedCountries ? outboundMetadata?.countries?.[childCode] : void 0 + } + ]; + }) + ); + const selectedName = outbounds.find((outbound) => outbound.code === selectedCode)?.displayName || selectedCode; + return { + code, + displayName: groupCache?.displayName || displayName, + selectedCode: selectedCode || void 0, + selectedName: selectedName || void 0, + url: groupCache?.url, + interval: groupCache?.interval, + tolerance: groupCache?.tolerance, + idleTimeout: groupCache?.idle_timeout || "30m", + interruptExistConnections: groupCache?.interrupt_exist_connections, + outbounds + }; +} +function buildPriorityInfo({ + config, + entry, + groupCache, + proxyByCode, + manualLinkByCode, + cachedProxyLinks, + outboundMetadata, + showDetectedCountries +}) { + const selectedCode = entry?.value.now || ""; + const cacheLevels = Array.isArray(groupCache?.levels) ? groupCache.levels : []; + const configLevelById = new Map( + config.levels.map((level) => [level.id, level]) + ); + const levels = (cacheLevels.length ? cacheLevels.map((level, index) => { + const id = `${level.id || ""}`; + const configLevel = id ? configLevelById.get(id) : void 0; + return { + id: id || configLevel?.id || `level-${index + 1}`, + displayName: level.displayName || configLevel?.displayName || `${index + 1}`, + order: typeof level.order === "number" && Number.isFinite(level.order) ? level.order : configLevel?.order ?? index, + direct: level.direct ?? configLevel?.direct ?? false, + filterMode: level.filter_mode || configLevel?.filterMode || "include", + detectServerCountry: level.detect_server_country || configLevel?.detectServerCountry || "flag_emoji", + country: configLevel?.country || [], + serverName: configLevel?.serverName || [], + regex: configLevel?.regex || [], + excludeCountries: configLevel?.excludeCountries || [], + excludeOutbounds: configLevel?.excludeOutbounds || [], + excludeRegex: configLevel?.excludeRegex || [], + outbounds: uniqueCodes(level.outbounds || []) + }; + }) : config.levels.map((level) => ({ + ...level, + outbounds: uniqueCodes(level.outbounds || []) + }))).sort( + (left, right) => left.order === right.order ? left.id.localeCompare(right.id) : left.order - right.order + ); + const outbounds = levels.flatMap((level, levelIndex) => { + const members = uniqueCodes(level.outbounds || []).map((childCode) => { + const childEntry = proxyByCode.get(childCode); + const link = manualLinkByCode.get(childCode) || cachedProxyLinks.get(childCode) || ""; + const canCopyLink = isCopyableProxyLink(link); + return { + code: childCode, + displayName: getOutboundDisplayName( + childCode, + childEntry, + link, + outboundMetadata, + cachedProxyLinks.has(childCode) + ), + latency: childEntry?.value?.history?.[0]?.delay || 0, + type: childEntry?.value?.type || "", + selected: selectedCode === childCode, + link, + canCopyLink, + country: showDetectedCountries ? outboundMetadata?.countries?.[childCode] : void 0, + levelIndex, + levelName: level.displayName, + levelId: level.id + }; + }); + if (!config.pickFastest) { + return members; + } + return members.map((outbound, index) => ({ outbound, index })).sort((left, right) => { + const latencyDiff = getLatencySortValue(left.outbound) - getLatencySortValue(right.outbound); + return latencyDiff !== 0 ? latencyDiff : left.index - right.index; + }).map((item) => item.outbound); + }); + const selectedName = outbounds.find((outbound) => outbound.code === selectedCode)?.displayName || selectedCode; + return { + code: config.code, + displayName: groupCache?.displayName || config.displayName, + selectedCode: selectedCode || void 0, + selectedName: selectedName || void 0, + healthUrl: groupCache?.health_url || config.healthUrl, + activeCheckInterval: groupCache?.active_check_interval || config.activeCheckInterval, + checkTimeout: groupCache?.check_timeout || config.checkTimeout, + recoveryCheckInterval: groupCache?.recovery_check_interval || config.recoveryCheckInterval, + pickFastest: groupCache?.pick_fastest ?? config.pickFastest, + switchToFasterSamePriority: groupCache?.switch_to_faster_same_priority ?? config.switchToFasterSamePriority, + fastestCheckInterval: groupCache?.fastest_check_interval || config.fastestCheckInterval, + interruptExistConnections: groupCache?.interrupt_exist_connections ?? config.interruptExistConnections, + outbounds + }; +} +function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGroups = {}, priorityGroups = {}, cachedProxyLinks = /* @__PURE__ */ new Map()) { + const sectionName = section[".name"]; + const proxyByCode = getProxyEntryByCode(proxies); + const selectorTag = getOutboundTagBySection(sectionName); + const selector = proxyByCode.get(selectorTag); + const urlTestConfigs = getUrlTestConfigs(section); + const urlTestConfigByCode = new Map( + urlTestConfigs.map((config) => [config.code, config]) + ); + const priorityConfigs = getPriorityConfigs(section); + const priorityConfigByCode = new Map( + priorityConfigs.map((config) => [config.code, config]) + ); + const urlTestEntries = urlTestConfigs.map((config) => ({ + config, + entry: proxyByCode.get(config.code) + })); + const priorityEntries = priorityConfigs.map((config) => ({ + config, + entry: proxyByCode.get(config.code) + })); + const manualLinkByCode = buildManualLinkByCode(section); + const selectorCodes = selector?.value?.all ?? []; + const urlTestCodes = urlTestConfigs.map((config) => config.code); + const priorityCodes = priorityConfigs.map((config) => config.code); + const showDetectedCountries = urlTestConfigs.some((config) => config.showDetectedCountries) || priorityConfigs.some((config) => config.showDetectedCountries); + const builtInUrltestCode = urlTestCodes[0] || ""; + const fallbackCodes = uniqueCodes([ + ...urlTestCodes, + ...priorityCodes, + ...urlTestEntries.flatMap(({ entry }) => entry?.value.all || []), + ...priorityEntries.flatMap(({ entry }) => entry?.value.all || []) + ]); + const groupCodes = uniqueCodes([ + ...selectorCodes.length ? selectorCodes : fallbackCodes, + ...urlTestCodes, + ...priorityCodes + ]); + const outbounds = uniqueCodes(groupCodes).flatMap((code) => { + const item = proxyByCode.get(code); + const urlTestConfig = urlTestConfigByCode.get(code); + const priorityConfig = priorityConfigByCode.get(code); + if (!item && !urlTestConfig && !priorityConfig) { + return []; + } + const link = manualLinkByCode.get(code) || cachedProxyLinks.get(code) || ""; + const canCopyLink = isCopyableProxyLink(link); + const displayName = priorityConfig?.displayName || urlTestConfig?.displayName || getOutboundDisplayName( + code, + item, + link, + outboundMetadata, + cachedProxyLinks.has(code) + ); + const isRuntimeUrlTest = isUrlTestProxyEntry(item); + return [ + { + code, + displayName, + latency: item?.value.history?.[0]?.delay || 0, + type: priorityConfig ? "Priority" : item?.value.type || "URLTest", + selected: selector?.value?.now === code, + link, + canCopyLink, + country: showDetectedCountries ? outboundMetadata?.countries?.[code] : void 0, + runtimeAvailable: item ? void 0 : false, + urlTestInfo: urlTestConfig || isRuntimeUrlTest ? buildUrlTestInfo({ + code, + displayName, + entry: item, + groupCache: urltestGroups[code], + proxyByCode, + manualLinkByCode, + cachedProxyLinks, + outboundMetadata, + showDetectedCountries: urlTestConfig?.showDetectedCountries || showDetectedCountries + }) : void 0, + priorityInfo: priorityConfig ? buildPriorityInfo({ + config: priorityConfig, + entry: item, + groupCache: priorityGroups[code], + proxyByCode, + manualLinkByCode, + cachedProxyLinks, + outboundMetadata, + showDetectedCountries: priorityConfig.showDetectedCountries + }) : void 0 + } + ]; + }); + const sortedOutbounds = sortOutboundsForDashboard(outbounds, { + pinnedCodes: [ + ...urlTestEntries.filter(({ config }) => config.pinDashboard).map(({ config }) => config.code), + ...priorityEntries.filter(({ config }) => config.pinDashboard).map(({ config }) => config.code) + ], + sortByLatency: shouldSortByLatency(section) + }); + const latencyTestCodes = sortedOutbounds.filter( + (outbound) => outbound.runtimeAvailable !== false && !isSelectorOutbound(outbound) && !outbound.priorityInfo + ).map((outbound) => outbound.code); + return { + selector, + latencyTestCode: selector?.code || builtInUrltestCode, + latencyTestCodes: latencyTestCodes.length > 0 ? latencyTestCodes : void 0, + outbounds: sortedOutbounds + }; +} +function metadataMatchesCurrentSource(sectionName, sourceCount, metadata) { + const legacyMetadata = metadata; + const sourceIndex = metadata.sourceIndex ?? legacyMetadata.source_index; + const sourceSection = metadata.sourceSection || legacyMetadata.source_section || ""; + const hasSourceIndex = typeof sourceIndex === "number"; + const hasSourceSection = sourceSection !== ""; + if (!hasSourceIndex && !hasSourceSection) { + return sourceCount <= 1; + } + if (sourceCount > 1 && !hasSourceSection) { + return false; + } + if (hasSourceIndex && (sourceIndex < 1 || sourceIndex > sourceCount)) { + return false; + } + if (hasSourceSection) { + const expectedSourcePrefix = `${sectionName}-subscription-`; + if (!sourceSection.startsWith(expectedSourcePrefix)) { + return false; + } + const sourceSectionIndex = Number( + sourceSection.slice(expectedSourcePrefix.length) + ); + if (!Number.isInteger(sourceSectionIndex) || sourceSectionIndex < 1 || sourceSectionIndex > sourceCount) { + return false; + } + if (hasSourceIndex && sourceIndex !== sourceSectionIndex) { + return false; + } + } + return true; +} +function getSubscriptionMetadataSourceIndex(sectionName, sourceCount, metadata) { + const legacyMetadata = metadata; + const sourceIndex = metadata.sourceIndex ?? legacyMetadata.source_index; + if (typeof sourceIndex === "number") { + return sourceIndex; + } + const sourceSection = metadata.sourceSection || legacyMetadata.source_section || ""; + const expectedSourcePrefix = `${sectionName}-subscription-`; + if (sourceSection.startsWith(expectedSourcePrefix)) { + const parsed = Number(sourceSection.slice(expectedSourcePrefix.length)); + return Number.isInteger(parsed) ? parsed : void 0; + } + return sourceCount <= 1 ? 1 : void 0; +} +function isSubscriptionMetadataVisible(section, sourceCount, metadata) { + const sourceIndex = getSubscriptionMetadataSourceIndex( + section[".name"], + sourceCount, + metadata + ); + if (!sourceIndex || sourceIndex < 1 || sourceIndex > sourceCount) { + return true; + } + const sourceEntry = getListValues(section.subscription_urls)[sourceIndex - 1]; + const settings = itemSettingsMap(section.subscription_url_settings)[sourceEntry]; + return settings?.show_dashboard_metadata !== "0"; +} +function getSubscriptionMetadata(section, sourceCount, dashboardCache) { + if (!dashboardCache?.subscriptionMetadata) { + return void 0; + } + const metadataItems = Array.isArray(dashboardCache.subscriptionMetadata) ? dashboardCache.subscriptionMetadata : [dashboardCache.subscriptionMetadata]; + const visibleMetadataItems = metadataItems.filter( + (metadata) => metadata && Object.keys(metadata).length > 1 && metadataMatchesCurrentSource(section[".name"], sourceCount, metadata) && isSubscriptionMetadataVisible(section, sourceCount, metadata) + ); + if (visibleMetadataItems.length > 0) { + return visibleMetadataItems; + } + return void 0; +} +function getOutboundMetadata(dashboardCache) { + const metadata = dashboardCache?.outboundMetadata; + if (!metadata || typeof metadata !== "object") { + return void 0; + } + return { + names: objectMap(metadata.names), + countries: objectMap(metadata.countries) + }; +} +function getCachedProxyLinks(dashboardCache) { + return new Map( + Object.entries(objectMap(dashboardCache?.links)).filter( + ([, link]) => isCopyableProxyLink(link) + ) + ); +} +async function getDashboardSections(options = {}) { + const includeSubscriptionCopyState = options.includeSubscriptionCopyState ?? true; + const configSections = hydrateConfigSections(await getConfigSections()); + const clashProxies = await getClashApiProxies(configSections); + if (!clashProxies.success || !clashProxies.data?.proxies) { + return { + success: false, + data: [] + }; + } + const proxies = Object.entries(clashProxies.data.proxies).map( + ([key, value]) => ({ + code: key, + value + }) + ); + const data = await Promise.all( + configSections.filter( + (section) => section.enabled !== "0" && isConnectionAction(section.action) + ).map(async (section) => { + const displayName = getDisplayName(section); + const sectionName = section[".name"]; + const sectionAction = section.action; + const proxyConfigType = getSectionProxyConfigType(section); + if (isConnectionAction(sectionAction) && shouldUseProxyGroup(section)) { + const subscriptionSourceCount = getSubscriptionSourceCount(section); + const subscriptionEnabled = subscriptionSourceCount > 0; + const dashboardCache = await readDashboardSectionCache(sectionName); + const outboundMetadata = getOutboundMetadata(dashboardCache); + const subscriptionMetadata = subscriptionEnabled ? getSubscriptionMetadata( + section, + subscriptionSourceCount, + dashboardCache + ) : void 0; + const cachedProxyLinks = includeSubscriptionCopyState ? getCachedProxyLinks(dashboardCache) : /* @__PURE__ */ new Map(); + const urltestGroups = getUrlTestGroups(dashboardCache); + const priorityGroups = getPriorityGroups(dashboardCache); + const { selector, latencyTestCode, latencyTestCodes, outbounds } = buildProxyGroupOutbounds( + section, + proxies, + outboundMetadata, + urltestGroups, + priorityGroups, + cachedProxyLinks + ); + return { + withTagSelect: true, + code: selector?.code || sectionName, + sectionName, + displayName, + action: sectionAction, + latencyTestCode, + latencyTestCodes, + proxyConfigType, + subscriptionSourceCount, + subscriptionMetadata, + outbounds + }; + } + if (sectionAction === "vpn") { + const outboundTag = getOutboundTagBySection(sectionName); + const outbound = proxies.find((proxy) => proxy.code === outboundTag); + return { + withTagSelect: false, + code: outbound?.code || sectionName, + sectionName, + displayName, + action: sectionAction, + latencyTestTimeout: "10000", + outbounds: [ + { + code: outbound?.code || sectionName, + displayName: section.interface || outbound?.value?.name || "", + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: true, + canCopyLink: false, + runtimeAvailable: Boolean(outbound) + } + ] + }; + } + if (sectionAction === "outbound") { + const outboundTag = getOutboundTagBySection(sectionName); + const outbound = proxies.find((proxy) => proxy.code === outboundTag); + return { + withTagSelect: false, + code: outbound?.code || sectionName, + sectionName, + displayName, + action: sectionAction, + outbounds: [ + { + code: outbound?.code || sectionName, + displayName: getJsonOutboundDisplayName(section) || outbound?.value?.name || "", + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: true, + canCopyLink: false + } + ] + }; + } + return { + withTagSelect: false, + code: sectionName, + sectionName, + displayName, + action: sectionAction, + outbounds: [] + }; + }) + ); + return { + success: true, + data + }; +} + +// src/tachyon/methods/custom/getClashApiSecret.ts +async function getClashApiSecret2() { + const sections = await getConfigSections(); + const settings = sections.find((section) => section[".type"] === "settings"); + return settings?.yacd_secret_key || ""; +} + +// src/tachyon/methods/custom/index.ts +var CustomTachyonMethods = { + getConfigSections, + getDashboardSections, + getClashApiSecret: getClashApiSecret2 +}; + +// src/tachyon/api.ts +async function createBaseApiRequest(fetchFn, options) { + const wrappedFn = () => options?.timeoutMs && options?.operationName ? withTimeout( + fetchFn(), + options.timeoutMs, + options.operationName, + options.timeoutMessage + ) : fetchFn(); + try { + const response = await wrappedFn(); + if (!response.ok) { + return { + success: false, + message: `${_("HTTP error")} ${response.status}: ${response.statusText}` + }; + } + const data = await response.json(); + return { + success: true, + data + }; + } catch (e) { + return { + success: false, + message: e instanceof Error ? e.message : _("Unknown error") + }; + } +} + +// src/tachyon/methods/fakeip/getFakeIpCheck.ts +async function getFakeIpCheck() { + return createBaseApiRequest( + () => fetch(`https://${FAKEIP_CHECK_DOMAIN}/check`, { + method: "GET", + headers: { "Content-Type": "application/json" } + }), + { + operationName: "getFakeIpCheck", + timeoutMs: 5e3 + } + ); +} + +// src/tachyon/methods/fakeip/getIpCheck.ts +async function getIpCheck() { + return createBaseApiRequest( + () => fetch(`https://${IP_CHECK_DOMAIN}/check`, { + method: "GET", + headers: { "Content-Type": "application/json" } + }), + { + operationName: "getIpCheck", + timeoutMs: 5e3 + } + ); +} + +// src/tachyon/methods/fakeip/index.ts +var RemoteFakeIPMethods = { + getFakeIpCheck, + getIpCheck +}; + +// src/tachyon/services/tab.service.ts +var TabService = class _TabService { + constructor() { + this.observer = null; + this.lastActiveId = null; + this.init(); + } + static getInstance() { + if (!_TabService.instance) { + _TabService.instance = new _TabService(); + } + return _TabService.instance; + } + init() { + this.observer = new MutationObserver(() => this.handleMutations()); + this.observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["class"] + }); + this.notify(); + } + handleMutations() { + this.notify(); + } + getTabsInfo() { + const tabs = Array.from( + document.querySelectorAll(".cbi-tab, .cbi-tab-disabled") + ); + return tabs.map((el) => ({ + el, + id: el.dataset.tab || "", + active: el.classList.contains("cbi-tab") && !el.classList.contains("cbi-tab-disabled") + })); + } + getActiveTabId() { + const active = document.querySelector( + ".cbi-tab:not(.cbi-tab-disabled)" + ); + return active?.dataset.tab || null; + } + notify() { + const tabs = this.getTabsInfo(); + const activeId = this.getActiveTabId(); + if (activeId !== this.lastActiveId) { + this.lastActiveId = activeId; + this.callback?.(activeId, tabs); + } + } + onChange(callback) { + this.callback = callback; + this.notify(); + } +}; +var TabServiceInstance = TabService.getInstance(); + +// src/tachyon/tabs/diagnostic/helpers/getCheckTitle.ts +function getCheckTitle(name) { + return `${name} ${_("checks")}`; +} + +// src/tachyon/tabs/diagnostic/checks/contstants.ts +var DIAGNOSTICS_CHECKS = /* @__PURE__ */ ((DIAGNOSTICS_CHECKS2) => { + DIAGNOSTICS_CHECKS2["DNS"] = "DNS"; + DIAGNOSTICS_CHECKS2["SINGBOX"] = "SINGBOX"; + DIAGNOSTICS_CHECKS2["NFT"] = "NFT"; + DIAGNOSTICS_CHECKS2["ZAPRET"] = "ZAPRET"; + DIAGNOSTICS_CHECKS2["ZAPRET2"] = "ZAPRET2"; + DIAGNOSTICS_CHECKS2["BYEDPI"] = "BYEDPI"; + DIAGNOSTICS_CHECKS2["FAKEIP"] = "FAKEIP"; + DIAGNOSTICS_CHECKS2["OUTBOUNDS"] = "OUTBOUNDS"; + DIAGNOSTICS_CHECKS2["INBOUNDS"] = "INBOUNDS"; + return DIAGNOSTICS_CHECKS2; +})(DIAGNOSTICS_CHECKS || {}); +var DIAGNOSTICS_CHECKS_MAP = { + ["DNS" /* DNS */]: { + order: 1, + title: getCheckTitle("DNS"), + code: "DNS" /* DNS */ + }, + ["SINGBOX" /* SINGBOX */]: { + order: 2, + title: getCheckTitle("Sing-box"), + code: "SINGBOX" /* SINGBOX */ + }, + ["NFT" /* NFT */]: { + order: 4, + title: getCheckTitle("Nftables"), + code: "NFT" /* NFT */ + }, + ["ZAPRET" /* ZAPRET */]: { + order: 5, + title: getCheckTitle("Zapret"), + code: "ZAPRET" /* ZAPRET */ + }, + ["BYEDPI" /* BYEDPI */]: { + order: 7, + title: getCheckTitle("ByeDPI"), + code: "BYEDPI" /* BYEDPI */ + }, + ["ZAPRET2" /* ZAPRET2 */]: { + order: 6, + title: getCheckTitle("Zapret2"), + code: "ZAPRET2" /* ZAPRET2 */ + }, + ["OUTBOUNDS" /* OUTBOUNDS */]: { + order: 8, + title: getCheckTitle("Outbounds"), + code: "OUTBOUNDS" /* OUTBOUNDS */ + }, + ["FAKEIP" /* FAKEIP */]: { + order: 9, + title: getCheckTitle("FakeIP"), + code: "FAKEIP" /* FAKEIP */ + }, + ["INBOUNDS" /* INBOUNDS */]: { + order: 3, + title: getCheckTitle("Inbounds"), + code: "INBOUNDS" /* INBOUNDS */ + } +}; + +// src/tachyon/tabs/diagnostic/diagnostic.store.ts +function createDiagnosticCheck(code, description) { + const meta = DIAGNOSTICS_CHECKS_MAP[code]; + return { + code, + title: meta.title, + order: meta.order, + description, + items: [], + state: "skipped" + }; +} +function getDiagnosticsChecks(description, options = {}) { + const checks = ["DNS" /* DNS */, "SINGBOX" /* SINGBOX */]; + if (options.includeInbounds === true) { + checks.push("INBOUNDS" /* INBOUNDS */); + } + checks.push("NFT" /* NFT */); + if (options.includeZapret) { + checks.push("ZAPRET" /* ZAPRET */); + } + if (options.includeZapret2) { + checks.push("ZAPRET2" /* ZAPRET2 */); + } + if (options.includeByedpi) { + checks.push("BYEDPI" /* BYEDPI */); + } + checks.push("OUTBOUNDS" /* OUTBOUNDS */, "FAKEIP" /* FAKEIP */); + return checks.map((code) => createDiagnosticCheck(code, description)); +} +function getLoadingDiagnosticsChecks(options = {}) { + return { + diagnosticsChecks: getDiagnosticsChecks(_("Pending"), options) + }; +} +var initialDiagnosticStore = { + diagnosticsSystemInfo: { + loading: true, + loaded: false, + providerInfoLoaded: false, + tachyon_version: "loading", + tachyon_latest_version: "loading", + luci_app_version: "loading", + sing_box_version: "loading", + sing_box_extended: 0, + sing_box_tiny: 0, + sing_box_compressed: 0, + sing_box_tailscale: 1, + zapret_version: "loading", + zapret_installed: 0, + zapret2_version: "loading", + zapret2_installed: 0, + byedpi_version: "loading", + byedpi_installed: 0, + server_inbounds_enabled_count: -1, + openwrt_version: "loading", + device_model: "loading" + }, + diagnosticsActions: { + restart: { + loading: false + }, + start: { + loading: false + }, + stop: { + loading: false + }, + enable: { + loading: false + }, + disable: { + loading: false + }, + globalCheck: { + loading: false + }, + viewLogs: { + loading: false + }, + showSingBoxConfig: { + loading: false + } + }, + diagnosticsRunAction: { loading: false }, + diagnosticsChecks: getDiagnosticsChecks(_("Not running")), + updatesActions: { + tachyonCheck: { loading: false }, + tachyonInstall: { loading: false }, + singBoxCheck: { loading: false }, + singBoxInstall: { loading: false }, + singBoxInstallExtended: { loading: false }, + singBoxInstallExtendedCompressed: { loading: false }, + singBoxInstallTiny: { loading: false }, + singBoxInstallStable: { loading: false }, + zapretCheck: { loading: false }, + zapretInstall: { loading: false }, + zapretRemove: { loading: false }, + zapret2Check: { loading: false }, + zapret2Install: { loading: false }, + zapret2Remove: { loading: false }, + byedpiCheck: { loading: false }, + byedpiInstall: { loading: false }, + byedpiRemove: { loading: false } + }, + updatesChecks: { + tachyon: { status: null, latest_version: "", release_url: "" }, + sing_box: { status: null, latest_version: "", release_url: "" }, + zapret: { status: null, latest_version: "", release_url: "" }, + zapret2: { status: null, latest_version: "", release_url: "" }, + byedpi: { status: null, latest_version: "", release_url: "" } + } +}; + +// src/tachyon/services/store.service.ts +function jsonStableStringify(obj) { + return JSON.stringify(obj, (_2, value) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + return Object.keys(value).sort().reduce( + (acc, key) => { + acc[key] = value[key]; + return acc; + }, + {} + ); + } + return value; + }); +} +function jsonEqual(a, b) { + try { + return jsonStableStringify(a) === jsonStableStringify(b); + } catch { + return false; + } +} +var StoreService = class { + constructor(initial) { + this.listeners = /* @__PURE__ */ new Set(); + this.value = initial; + this.initial = structuredClone(initial); + } + get() { + return this.value; + } + set(next) { + const prev = this.value; + const diff = {}; + for (const key in next) { + if (!jsonEqual(next[key], prev[key])) diff[key] = next[key]; + } + if (Object.keys(diff).length === 0) { + return; + } + const merged = { ...prev, ...next }; + this.value = merged; + this.listeners.forEach((cb) => cb(this.value, prev, diff)); + } + reset(keys) { + const prev = this.value; + const next = structuredClone(this.value); + if (keys && keys.length > 0) { + keys.forEach((key) => { + next[key] = structuredClone(this.initial[key]); + }); + } else { + Object.assign(next, structuredClone(this.initial)); + } + if (jsonEqual(prev, next)) return; + this.value = next; + const diff = {}; + for (const key in next) { + if (!jsonEqual(next[key], prev[key])) diff[key] = next[key]; + } + this.listeners.forEach((cb) => cb(this.value, prev, diff)); + } + subscribe(cb) { + this.listeners.add(cb); + cb(this.value, this.value, {}); + return () => this.listeners.delete(cb); + } + unsubscribe(cb) { + this.listeners.delete(cb); + } + patch(key, value) { + this.set({ [key]: value }); + } + getKey(key) { + return this.value[key]; + } + subscribeKey(key, cb) { + let prev = this.value[key]; + const wrapper = (val) => { + if (!jsonEqual(val[key], prev)) { + prev = val[key]; + cb(val[key]); + } + }; + this.listeners.add(wrapper); + return () => this.listeners.delete(wrapper); + } +}; +var initialStore = { + tabService: { + current: "", + all: [] + }, + bandwidthWidget: { + loading: true, + failed: false, + data: { up: 0, down: 0 } + }, + trafficTotalWidget: { + loading: true, + failed: false, + data: { downloadTotal: 0, uploadTotal: 0 } + }, + systemInfoWidget: { + loading: true, + failed: false, + data: { connections: 0, memory: 0 } + }, + servicesInfoWidget: { + loading: true, + failed: false, + data: { + singbox: 0, + tachyonRunning: 0, + tachyonEnabled: 0, + tachyonStatus: "" + } + }, + sectionsWidget: { + loading: true, + failed: false, + latencyFetchingSections: {}, + latencyProgressSections: {}, + selectorSwitchingSections: {}, + subscriptionUpdatingSections: {}, + data: [] + }, + ...initialDiagnosticStore +}; +var store = new StoreService(initialStore); + +// src/tachyon/services/tachyonLogWatcher.service.ts +var TachyonLogWatcher = class _TachyonLogWatcher { + constructor() { + this.intervalMs = 5e3; + this.lastLines = /* @__PURE__ */ new Set(); + this.maxTrackedLines = 500; + this.running = false; + this.paused = false; + this.checking = false; + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", () => { + if (document.hidden) this.pause(); + else this.resume(); + }); + } + } + static getInstance() { + if (!_TachyonLogWatcher.instance) { + _TachyonLogWatcher.instance = new _TachyonLogWatcher(); + } + return _TachyonLogWatcher.instance; + } + init(fetcher, options) { + this.fetcher = fetcher; + this.onNewLog = options?.onNewLog; + this.intervalMs = options?.intervalMs ?? 5e3; + this.maxTrackedLines = options?.maxTrackedLines ?? 500; + this.lastLines = /* @__PURE__ */ new Set(); + logger.info( + "[TachyonLogWatcher]", + `initialized (interval: ${this.intervalMs}ms)` + ); + } + normalizeLines(raw) { + return raw.split("\n").filter(Boolean).slice(-this.maxTrackedLines); + } + async checkOnce() { + if (!this.fetcher) { + logger.warn("[TachyonLogWatcher]", "fetcher not found"); + return; + } + if (this.paused) { + logger.debug("[TachyonLogWatcher]", "skipped check \u2014 tab not visible"); + return; + } + if (this.checking) { + logger.debug( + "[TachyonLogWatcher]", + "skipped check \u2014 previous check is running" + ); + return; + } + this.checking = true; + try { + const raw = await this.fetcher(); + const lines = this.normalizeLines(raw); + for (const line of lines) { + if (this.lastLines.has(line)) { + continue; + } + this.lastLines.add(line); + this.onNewLog?.(line); + } + if (this.lastLines.size > this.maxTrackedLines) { + this.lastLines = new Set( + Array.from(this.lastLines).slice(-this.maxTrackedLines) + ); + } + } catch (err) { + logger.error("[TachyonLogWatcher]", "failed to read logs:", err); + } finally { + this.checking = false; + } + } + start() { + if (this.running) return; + if (!this.fetcher) { + logger.warn("[TachyonLogWatcher]", "attempted to start without fetcher"); + return; + } + this.running = true; + void this.checkOnce(); + this.timer = setInterval(() => this.checkOnce(), this.intervalMs); + logger.info( + "[TachyonLogWatcher]", + `started (interval: ${this.intervalMs}ms)` + ); + } + stop() { + if (!this.running) return; + this.running = false; + if (this.timer) clearInterval(this.timer); + logger.info("[TachyonLogWatcher]", "stopped"); + } + pause() { + if (!this.running || this.paused) return; + this.paused = true; + logger.info("[TachyonLogWatcher]", "paused (tab not visible)"); + } + resume() { + if (!this.running || !this.paused) return; + this.paused = false; + logger.info("[TachyonLogWatcher]", "resumed (tab active)"); + void this.checkOnce(); + } + reset() { + this.lastLines = /* @__PURE__ */ new Set(); + this.checking = false; + logger.info("[TachyonLogWatcher]", "log history reset"); + } +}; + +// src/tachyon/services/logNotificationDeduper.service.ts +var LOG_NOTIFICATION_STORAGE_KEY = "tachyon:shown-log-error-notifications:v1"; +var MAX_STORED_LOG_NOTIFICATIONS = 500; +function getSessionStorage() { + if (typeof window === "undefined") { + return null; + } + try { + return window.sessionStorage; + } catch { + return null; + } +} +function readStoredKeys(storage) { + if (!storage) { + return []; + } + try { + const parsed = JSON.parse( + storage.getItem(LOG_NOTIFICATION_STORAGE_KEY) || "[]" + ); + return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []; + } catch { + return []; + } +} +function writeStoredKeys(storage, keys) { + if (!storage) { + return; + } + try { + storage.setItem( + LOG_NOTIFICATION_STORAGE_KEY, + JSON.stringify(keys.slice(-MAX_STORED_LOG_NOTIFICATIONS)) + ); + } catch { + } +} +function isErrorLogLine(line) { + const lower = line.toLowerCase(); + return lower.includes("[error]") || lower.includes("[fatal]"); +} +function getTachyonLogNotification(line) { + if (isErrorLogLine(line)) { + return { kind: "error", line }; + } + const update = line.match( + /\[component-update\]\s+(tachyon|sing_box|zapret|zapret2|byedpi)\s+(\S+)/i + ); + if (!update) { + return null; + } + return { + kind: "component-update", + line, + component: update[1].toLowerCase(), + version: update[2] + }; +} +function getLogNotificationKey(line) { + return line.trim(); +} +var LogNotificationDeduper = class { + constructor(storage = getSessionStorage()) { + this.storage = storage; + this.seenKeys = new Set(readStoredKeys(storage)); + } + shouldNotify(line) { + if (!getTachyonLogNotification(line)) { + return false; + } + const key = getLogNotificationKey(line); + if (!key || this.seenKeys.has(key)) { + return false; + } + this.seenKeys.add(key); + writeStoredKeys(this.storage, Array.from(this.seenKeys)); + return true; + } +}; + +// src/tachyon/helpers/getComponentActionKey.ts +var componentActionKeyMap = { + "tachyon:check_update": "tachyonCheck", + "tachyon:install": "tachyonInstall", + "sing_box:check_update": "singBoxCheck", + "sing_box:install": "singBoxInstall", + "sing_box:install_extended": "singBoxInstallExtended", + "sing_box:install_extended_compressed": "singBoxInstallExtendedCompressed", + "sing_box:install_tiny": "singBoxInstallTiny", + "sing_box:install_stable": "singBoxInstallStable", + "zapret:check_update": "zapretCheck", + "zapret:install": "zapretInstall", + "zapret:remove": "zapretRemove", + "zapret2:check_update": "zapret2Check", + "zapret2:install": "zapret2Install", + "zapret2:remove": "zapret2Remove", + "byedpi:check_update": "byedpiCheck", + "byedpi:install": "byedpiInstall", + "byedpi:remove": "byedpiRemove" +}; +function getComponentActionKey(component, action) { + return componentActionKeyMap[`${component}:${action}`]; +} + +// src/tachyon/helpers/singBoxVariant.ts +function isExtendedSingBoxVersion(version) { + return String(version || "").includes("extended"); +} +function isVersionPlaceholder(version) { + const normalized = String(version || "").trim().toLowerCase(); + if (!normalized || normalized === "loading" || normalized === "unknown" || normalized === "not installed") { + return true; + } + return typeof _ === "function" && normalized === _("unknown").toLowerCase() || typeof _ === "function" && normalized === _("Not installed").toLowerCase(); +} +function formatSingBoxVersion(value) { + const version = String(value.sing_box_version || ""); + if (!version || version === "not installed") { + return _("Not installed"); + } + if (isVersionPlaceholder(version)) { + return version; + } + const normalizedValue = normalizeSingBoxVariantFields(value); + let variant = ""; + if (normalizedValue.sing_box_extended && normalizedValue.sing_box_compressed) { + variant = _("compressed"); + } else if (normalizedValue.sing_box_tiny) { + variant = _("tiny"); + } + return variant ? `${version} (${variant})` : version; +} +function normalizeSingBoxVariantFields(value) { + const versionExtended = isExtendedSingBoxVersion(value.sing_box_version); + const singBoxExtended = Boolean(value.sing_box_extended) || versionExtended; + return { + ...value, + sing_box_extended: singBoxExtended ? 1 : 0, + sing_box_tiny: singBoxExtended ? 0 : value.sing_box_tiny ? 1 : 0, + sing_box_compressed: singBoxExtended && value.sing_box_compressed ? 1 : 0, + sing_box_tailscale: singBoxExtended || value.sing_box_tailscale ? 1 : 0 + }; +} + +// src/tachyon/services/localActionOverlay.service.ts +var componentActions = /* @__PURE__ */ new Set(); +var subscriptionSections = /* @__PURE__ */ new Set(); +var latencySections = /* @__PURE__ */ new Set(); +var serviceActions = /* @__PURE__ */ new Set(); +function setMembership(set, value, active) { + if (active) { + set.add(value); + } else { + set.delete(value); + } +} +function setLocalComponentAction(action, active) { + setMembership(componentActions, action, active); +} +function setLocalSubscriptionAction(section, active) { + if (section) { + setMembership(subscriptionSections, section, active); + } +} +function setLocalLatencyAction(section, active) { + if (section) { + setMembership(latencySections, section, active); + } +} +function setLocalServiceAction(action, active) { + if (action === "restart" || action === "start" || action === "stop") { + setMembership(serviceActions, action, active); + } +} +function getLocalActionOverlay() { + return { + componentActions: new Set(componentActions), + subscriptionSections: new Set(subscriptionSections), + latencySections: new Set(latencySections), + serviceActions: new Set(serviceActions) + }; +} + +// src/tachyon/services/uiState.service.ts +function isRunningAction(state) { + return state.running === true; +} +function getEmptyUpdatesActions() { + return { + tachyonCheck: { loading: false }, + tachyonInstall: { loading: false }, + singBoxCheck: { loading: false }, + singBoxInstall: { loading: false }, + singBoxInstallExtended: { loading: false }, + singBoxInstallExtendedCompressed: { loading: false }, + singBoxInstallTiny: { loading: false }, + singBoxInstallStable: { loading: false }, + zapretCheck: { loading: false }, + zapretInstall: { loading: false }, + zapretRemove: { loading: false }, + zapret2Check: { loading: false }, + zapret2Install: { loading: false }, + zapret2Remove: { loading: false }, + byedpiCheck: { loading: false }, + byedpiInstall: { loading: false }, + byedpiRemove: { loading: false } + }; +} +function getEmptyDiagnosticsActions() { + return { + ...store.get().diagnosticsActions, + restart: { loading: false }, + start: { loading: false }, + stop: { loading: false } + }; +} +function normalizeLatencyProgress(progress) { + const total = Math.trunc(Number(progress?.total ?? 0)); + if (!Number.isFinite(total) || total <= 0) { + return void 0; + } + const completedValue = Number(progress?.completed ?? 0); + const failedValue = Number(progress?.failed ?? 0); + const completed = Number.isFinite(completedValue) ? Math.trunc(completedValue) : 0; + const failed2 = Number.isFinite(failedValue) ? Math.trunc(failedValue) : 0; + return { + completed: Math.min(Math.max(0, completed), total), + total, + failed: Math.max(0, failed2) + }; +} +function applyServiceState(uiState) { + const currentSystemInfo = store.get().diagnosticsSystemInfo; + const nextSystemInfo = { + ...currentSystemInfo, + providerInfoLoaded: true, + zapret_installed: uiState.capabilities.zapret_installed, + zapret2_installed: uiState.capabilities.zapret2_installed, + byedpi_installed: uiState.capabilities.byedpi_installed, + server_inbounds_enabled_count: uiState.capabilities.server_inbounds_enabled_count + }; + nextSystemInfo.sing_box_extended = uiState.capabilities.sing_box_extended; + nextSystemInfo.sing_box_tiny = uiState.capabilities.sing_box_tiny; + nextSystemInfo.sing_box_compressed = uiState.capabilities.sing_box_compressed; + nextSystemInfo.sing_box_tailscale = uiState.capabilities.sing_box_tailscale; + store.set({ + servicesInfoWidget: { + loading: false, + failed: false, + data: { + singbox: uiState.service.sing_box.running, + tachyonRunning: uiState.service.tachyon.running, + tachyonEnabled: uiState.service.tachyon.enabled, + tachyonStatus: uiState.service.tachyon.status + } + }, + diagnosticsSystemInfo: normalizeSingBoxVariantFields(nextSystemInfo) + }); +} +function applyActionState(actions = {}) { + const current = store.get(); + const localOverlay = getLocalActionOverlay(); + const currentLatencyProgressSections = current.sectionsWidget.latencyProgressSections; + const subscriptionUpdatingSections = {}; + const latencyFetchingSections = {}; + const latencyProgressSections = {}; + const updatesActions = getEmptyUpdatesActions(); + const diagnosticsActions = getEmptyDiagnosticsActions(); + for (const state of actions.subscription || []) { + if (isRunningAction(state) && state.section) { + subscriptionUpdatingSections[state.section] = true; + } + } + for (const state of actions.latency || []) { + if (isRunningAction(state) && state.section) { + latencyFetchingSections[state.section] = true; + const progress = normalizeLatencyProgress(state.progress); + if (progress) { + latencyProgressSections[state.section] = progress; + } else if (currentLatencyProgressSections[state.section]) { + latencyProgressSections[state.section] = currentLatencyProgressSections[state.section]; + } + } + } + for (const state of actions.component || []) { + if (!isRunningAction(state)) { + continue; + } + const key = getComponentActionKey(state.component, state.action); + if (key) { + updatesActions[key] = { loading: true }; + } + } + for (const state of actions.service || []) { + if (!isRunningAction(state)) { + continue; + } + if (state.action === "start") { + diagnosticsActions.start = { loading: true }; + } else if (state.action === "stop") { + diagnosticsActions.stop = { loading: true }; + } else if (state.action === "restart" || state.action === "reload") { + diagnosticsActions.restart = { loading: true }; + } + } + for (const section of localOverlay.subscriptionSections) { + subscriptionUpdatingSections[section] = true; + } + for (const section of localOverlay.latencySections) { + latencyFetchingSections[section] = true; + if (!latencyProgressSections[section] && currentLatencyProgressSections[section]) { + latencyProgressSections[section] = currentLatencyProgressSections[section]; + } + } + for (const key of localOverlay.componentActions) { + updatesActions[key] = { loading: true }; + } + for (const action of localOverlay.serviceActions) { + diagnosticsActions[action] = { loading: true }; + } + store.set({ + sectionsWidget: { + ...current.sectionsWidget, + subscriptionUpdatingSections, + latencyFetchingSections, + latencyProgressSections + }, + updatesActions, + diagnosticsActions + }); +} +function applyUiStateToStore(uiState) { + applyServiceState(uiState); + applyActionState(uiState.actions); +} + +// src/tachyon/services/runtimeUiState.service.ts +var RUNTIME_UI_STATE_REFRESH_MIN_INTERVAL_MS = 500; +var RUNTIME_UI_STATE_IDLE_POLL_INTERVAL_MS = 1e3; +var RUNTIME_UI_STATE_ACTIVE_POLL_INTERVAL_MS = 500; +var runtimeUiStateRefreshPromise = null; +var lastRuntimeUiStateRefreshAt = 0; +var lastRuntimeUiState; +var runtimeStateResumeRefreshRegistered = false; +var runtimeStatePollTimer = null; +var runtimeStatePollingStarted = false; +var runtimeStateHasRunningAction = false; +var runtimeUiStateListeners = /* @__PURE__ */ new Set(); +function isDocumentVisible() { + return typeof document === "undefined" || !document.visibilityState || document.visibilityState === "visible"; +} +function hasRunningAction(uiState) { + return Object.values(uiState.actions).some( + (actions) => actions.some((action) => action.running) + ); +} +function getNextPollDelay() { + return runtimeStateHasRunningAction ? RUNTIME_UI_STATE_ACTIVE_POLL_INTERVAL_MS : RUNTIME_UI_STATE_IDLE_POLL_INTERVAL_MS; +} +function scheduleRuntimeUiStatePoll(delay = getNextPollDelay()) { + if (!runtimeStatePollingStarted || runtimeStatePollTimer || typeof window === "undefined") { + return; + } + runtimeStatePollTimer = window.setTimeout(() => { + runtimeStatePollTimer = null; + void refreshRuntimeUiState().catch(() => void 0).finally(() => { + scheduleRuntimeUiStatePoll(); + }); + }, delay); +} +function notifyRuntimeUiStateListeners(uiState) { + for (const listener of runtimeUiStateListeners) { + try { + listener(uiState); + } catch (error) { + logger.error("[RUNTIME_UI_STATE]", "listener failed", error); + } + } +} +async function refreshRuntimeUiState({ + force = false +} = {}) { + if (!isDocumentVisible()) { + return void 0; + } + if (runtimeUiStateRefreshPromise) { + return runtimeUiStateRefreshPromise; + } + const now = Date.now(); + if (!force && now - lastRuntimeUiStateRefreshAt < RUNTIME_UI_STATE_REFRESH_MIN_INTERVAL_MS) { + return void 0; + } + lastRuntimeUiStateRefreshAt = now; + const promise = TachyonShellMethods.getUiState().then((response) => { + if (!response.success) { + return void 0; + } + applyUiStateToStore(response.data); + lastRuntimeUiState = response.data; + runtimeStateHasRunningAction = hasRunningAction(response.data); + notifyRuntimeUiStateListeners(response.data); + return response.data; + }).catch((error) => { + logger.error("[RUNTIME_UI_STATE]", "refresh failed", error); + return void 0; + }).finally(() => { + if (runtimeUiStateRefreshPromise === promise) { + runtimeUiStateRefreshPromise = null; + } + }); + runtimeUiStateRefreshPromise = promise; + return runtimeUiStateRefreshPromise; +} +function subscribeRuntimeUiState(listener) { + runtimeUiStateListeners.add(listener); + if (lastRuntimeUiState) { + try { + listener(lastRuntimeUiState); + } catch (error) { + logger.error("[RUNTIME_UI_STATE]", "listener failed", error); + } + } + return () => { + runtimeUiStateListeners.delete(listener); + }; +} +function getCachedRuntimeUiState() { + return lastRuntimeUiState; +} +function registerRuntimeStateResumeRefresh() { + if (runtimeStateResumeRefreshRegistered || typeof window === "undefined") { + return; + } + runtimeStateResumeRefreshRegistered = true; + const refreshOnResume = () => { + if (!isDocumentVisible()) { + return; + } + void refreshRuntimeUiState({ force: true }); + }; + document.addEventListener("visibilitychange", refreshOnResume); + window.addEventListener("pageshow", refreshOnResume); + window.addEventListener("focus", refreshOnResume); +} +function startRuntimeUiStatePolling() { + if (runtimeStatePollingStarted || typeof window === "undefined") { + return; + } + runtimeStatePollingStarted = true; + void refreshRuntimeUiState({ force: true }).finally(() => { + scheduleRuntimeUiStatePoll(); + }); +} + +// src/tachyon/services/core.service.ts +var LOG_WATCHER_INTERVAL_MS = 1e4; +var LOG_WATCHER_START_DELAY_MS = 5e3; +function componentDisplayName(component) { + const names = { + tachyon: "Tachyon", + sing_box: "sing-box", + zapret: "Zapret", + zapret2: "Zapret2", + byedpi: "ByeDPI" + }; + return names[component] || component; +} +function showLogNotification(notification) { + if (notification.kind === "component-update") { + const message = _("New version %s is available for %s").replace("%s", notification.version).replace("%s", componentDisplayName(notification.component)); + ui.addNotification( + _("Component update available"), + E("div", {}, message), + "warning", + "fkp-component-update-notification" + ); + return; + } + ui.addNotification( + _("Tachyon Error"), + E("div", {}, notification.line), + "error", + "fkp-log-error-notification" + ); +} +function coreService(options = {}) { + TabServiceInstance.onChange((activeId, tabs) => { + logger.info("[TAB]", activeId); + store.set({ + tabService: { + current: activeId || "", + all: tabs.map((tab) => tab.id) + } + }); + }); + const watcher = TachyonLogWatcher.getInstance(); + const logNotificationDeduper = new LogNotificationDeduper(); + watcher.init( + async () => { + const logs = await TachyonShellMethods.checkLogs(); + if (logs.success) { + return logs.data; + } + return ""; + }, + { + intervalMs: LOG_WATCHER_INTERVAL_MS, + onNewLog: (line) => { + if (logNotificationDeduper.shouldNotify(line)) { + const notification = getTachyonLogNotification(line); + if (notification) { + showLogNotification(notification); + } + } + } + } + ); + const startWatcher = async () => { + if (options.waitForLogWatcherStart) { + await Promise.resolve().then(() => options.waitForLogWatcherStart?.()).catch(() => null); + } + watcher.start(); + }; + const scheduleStartWatcher = () => window.setTimeout(() => { + void startWatcher(); + }, options.logWatcherStartDelayMs ?? LOG_WATCHER_START_DELAY_MS); + if (typeof window !== "undefined") { + scheduleStartWatcher(); + } else { + void startWatcher(); + } + registerRuntimeStateResumeRefresh(); + startRuntimeUiStatePolling(); +} + +// src/tachyon/services/socket.service.ts +var SocketManager = class _SocketManager { + constructor() { + this.sockets = /* @__PURE__ */ new Map(); + this.listeners = /* @__PURE__ */ new Map(); + this.connected = /* @__PURE__ */ new Map(); + this.errorListeners = /* @__PURE__ */ new Map(); + } + static getInstance() { + if (!_SocketManager.instance) { + _SocketManager.instance = new _SocketManager(); + } + return _SocketManager.instance; + } + resetAll() { + for (const [url, ws] of this.sockets.entries()) { + try { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } catch (err) { + logger.error( + "[SOCKET]", + `resetAll: failed to close socket ${url}`, + err + ); + } + } + this.sockets.clear(); + this.listeners.clear(); + this.errorListeners.clear(); + this.connected.clear(); + logger.info("[SOCKET]", "All connections and state have been reset."); + } + connect(url) { + if (this.sockets.has(url)) return; + let ws; + try { + ws = new WebSocket(url); + } catch (err) { + logger.error( + "[SOCKET]", + `failed to construct WebSocket for ${url}:`, + err + ); + this.triggerError(url, err instanceof Event ? err : String(err)); + return; + } + this.sockets.set(url, ws); + this.connected.set(url, false); + if (!this.listeners.has(url)) this.listeners.set(url, /* @__PURE__ */ new Set()); + if (!this.errorListeners.has(url)) this.errorListeners.set(url, /* @__PURE__ */ new Set()); + ws.addEventListener("open", () => { + this.connected.set(url, true); + logger.info("[SOCKET]", "Connected to", url); + }); + ws.addEventListener("message", (event) => { + const handlers = this.listeners.get(url); + if (handlers) { + for (const handler of handlers) { + try { + handler(event.data); + } catch (err) { + logger.error("[SOCKET]", `Handler error for ${url}:`, err); + } + } + } + }); + ws.addEventListener("close", () => { + this.connected.set(url, false); + logger.warn("[SOCKET]", `Disconnected: ${url}`); + this.triggerError(url, "Connection closed"); + }); + ws.addEventListener("error", (err) => { + logger.error("[SOCKET]", `Socket error for ${url}:`, err); + this.triggerError(url, err); + }); + } + subscribe(url, listener, onError) { + if (!this.errorListeners.has(url)) { + this.errorListeners.set(url, /* @__PURE__ */ new Set()); + } + if (onError) { + this.errorListeners.get(url)?.add(onError); + } + if (!this.sockets.has(url)) { + this.connect(url); + } + if (!this.listeners.has(url)) { + this.listeners.set(url, /* @__PURE__ */ new Set()); + } + this.listeners.get(url)?.add(listener); + } + unsubscribe(url, listener, onError) { + this.listeners.get(url)?.delete(listener); + if (onError) { + this.errorListeners.get(url)?.delete(onError); + } + } + // eslint-disable-next-line + send(url, data) { + const ws = this.sockets.get(url); + if (ws && this.connected.get(url)) { + ws.send(typeof data === "string" ? data : JSON.stringify(data)); + } else { + logger.warn("[SOCKET]", `Cannot send: not connected to ${url}`); + this.triggerError(url, "Not connected"); + } + } + disconnect(url) { + const ws = this.sockets.get(url); + if (ws) { + ws.close(); + this.sockets.delete(url); + this.listeners.delete(url); + this.errorListeners.delete(url); + this.connected.delete(url); + } + } + disconnectAll() { + for (const url of this.sockets.keys()) { + this.disconnect(url); + } + } + triggerError(url, err) { + const handlers = this.errorListeners.get(url); + if (handlers) { + for (const cb of handlers) { + try { + cb(err); + } catch (e) { + logger.error("[SOCKET]", `Error handler threw for ${url}:`, e); + } + } + } + } +}; +var socket = SocketManager.getInstance(); + +// src/tachyon/services/uiActionNotification.service.ts +var UI_ACTION_NOTIFICATION_STORAGE_KEY = "tachyon:owned-ui-action-notifications:v1"; +var MAX_STORED_UI_ACTION_NOTIFICATIONS = 100; +function getSessionStorage2() { + if (typeof window === "undefined") { + return null; + } + try { + return window.sessionStorage; + } catch { + return null; + } +} +function getNotificationKey(kind, jobId) { + return `${kind}:${jobId}`; +} +function isStoredNotification(value) { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value; + return (candidate.kind === "component" || candidate.kind === "subscription") && typeof candidate.jobId === "string" && typeof candidate.notified === "boolean" && typeof candidate.updatedAt === "number"; +} +function readStoredNotifications(storage) { + if (!storage) { + return []; + } + try { + const parsed = JSON.parse( + storage.getItem(UI_ACTION_NOTIFICATION_STORAGE_KEY) || "[]" + ); + return Array.isArray(parsed) ? parsed.filter(isStoredNotification) : []; + } catch { + return []; + } +} +function writeStoredNotifications(storage, notifications) { + if (!storage) { + return; + } + try { + storage.setItem( + UI_ACTION_NOTIFICATION_STORAGE_KEY, + JSON.stringify( + notifications.sort((a, b) => a.updatedAt - b.updatedAt).slice(-MAX_STORED_UI_ACTION_NOTIFICATIONS) + ) + ); + } catch { + } +} +var UiActionNotificationTracker = class { + constructor(storage = getSessionStorage2()) { + this.notifications = /* @__PURE__ */ new Map(); + this.storage = storage; + for (const notification of readStoredNotifications(storage)) { + this.notifications.set( + getNotificationKey(notification.kind, notification.jobId), + notification + ); + } + } + markOwned(kind, jobId) { + if (!jobId) { + return; + } + const key = getNotificationKey(kind, jobId); + const current = this.notifications.get(key); + this.notifications.set(key, { + kind, + jobId, + notified: current?.notified ?? false, + updatedAt: Date.now() + }); + this.persist(); + } + shouldNotify(kind, jobId) { + if (!jobId) { + return false; + } + const key = getNotificationKey(kind, jobId); + const current = this.notifications.get(key); + if (!current || current.notified) { + return false; + } + this.notifications.set(key, { + ...current, + notified: true, + updatedAt: Date.now() + }); + this.persist(); + return true; + } + persist() { + writeStoredNotifications( + this.storage, + Array.from(this.notifications.values()) + ); + } +}; +var uiActionNotifications = new UiActionNotificationTracker(); +function markUiActionOwned(kind, jobId) { + uiActionNotifications.markOwned(kind, jobId); +} +function shouldNotifyOwnedUiAction(kind, jobId) { + return uiActionNotifications.shouldNotify(kind, jobId); +} + +// src/tachyon/fetchers/fetchServicesInfo.ts +var latestServicesInfoRequestId = 0; +function getSettledMethodResponse(scope, result) { + if (result.status === "fulfilled") { + return result.value; + } + logger.error("[SERVICES_INFO]", `${scope} failed`, result.reason); + return { + success: false, + error: result.reason instanceof Error ? result.reason.message : "" + }; +} +async function fetchServicesInfo() { + const requestId = ++latestServicesInfoRequestId; + const uiState = await refreshRuntimeUiState({ force: true }); + if (requestId !== latestServicesInfoRequestId) { + return; + } + if (uiState) { + return uiState; + } + const [tachyonResult, singboxResult] = await Promise.allSettled([ + TachyonShellMethods.getStatus(), + TachyonShellMethods.getSingBoxStatus() + ]); + if (requestId !== latestServicesInfoRequestId) { + return; + } + const tachyon = getSettledMethodResponse("getStatus", tachyonResult); + const singbox = getSettledMethodResponse("getSingBoxStatus", singboxResult); + const previousData = store.get().servicesInfoWidget.data; + store.set({ + servicesInfoWidget: { + loading: false, + failed: !tachyon.success || !singbox.success, + data: { + singbox: singbox.success ? singbox.data.running : previousData.singbox, + tachyonRunning: tachyon.success ? tachyon.data.running : previousData.tachyonRunning, + tachyonEnabled: tachyon.success ? tachyon.data.enabled : previousData.tachyonEnabled, + tachyonStatus: tachyon.success ? tachyon.data.status : previousData.tachyonStatus + } + } + }); + return void 0; +} + +// src/tachyon/helpers/isActiveLuciTab.ts +function isActiveLuciTab(tabId) { + if (typeof document === "undefined") { + return false; + } + return Boolean( + document.querySelector( + `.cbi-tab[data-tab="${tabId}"]:not(.cbi-tab-disabled)` + ) + ); +} + +// src/tachyon/helpers/restoredActionLoading.ts +function shouldShowLoadingForRestoredAction(state) { + return state.running === true; +} + +// src/tachyon/helpers/serviceAvailability.ts +function getServiceAvailability({ + loading: loading2, + failed: failed2, + running +}) { + if (loading2) { + return "loading"; + } + if (failed2) { + return "unavailable"; + } + return running ? "running" : "stopped"; +} + +// src/tachyon/tabs/dashboard/initController.ts +var SECTIONS_REFRESH_INTERVAL_MS = 1e4; +var LATENCY_TEST_BUTTON_CLASS = "dashboard-sections-grid-item-test-latency"; +var LATENCY_TEST_BUTTON_LABEL_CLASS = "dashboard-sections-grid-item-test-latency__label"; +var sectionsRefreshTimer = null; +var sectionsRefreshPromise = null; +var sectionsRefreshQueued = false; +var actionStateUnsubscribe = null; +var dashboardMounted = false; +var dashboardMountId = 0; +var dashboardDataUpdatesStarted = false; +var dashboardDataUpdatesId = 0; +var pageUnloading = false; +var followedSubscriptionJobs = /* @__PURE__ */ new Set(); +var followedLatencyJobs = /* @__PURE__ */ new Set(); +var handledSubscriptionJobs = /* @__PURE__ */ new Set(); +var handledLatencyJobs = /* @__PURE__ */ new Set(); +if (typeof window !== "undefined") { + window.addEventListener("pagehide", () => { + pageUnloading = true; + }); + window.addEventListener("pageshow", () => { + pageUnloading = false; + }); +} +async function fetchDashboardSectionsOnce(mountId) { + if (getDashboardServiceAvailability() === "stopped") { + return false; + } + const prev = store.get().sectionsWidget; + const hasRenderedData = prev.data.length > 0; + store.set({ + sectionsWidget: { + ...prev, + failed: false, + loading: prev.loading && !hasRenderedData + } + }); + try { + const { data, success } = await CustomTachyonMethods.getDashboardSections(); + if (!dashboardMounted || mountId !== dashboardMountId || getDashboardServiceAvailability() === "stopped") { + return false; + } + if (!success) { + throw new Error("failed to fetch dashboard sections"); + } + const current = store.get().sectionsWidget; + store.set({ + sectionsWidget: { + ...current, + loading: false, + failed: false, + data + } + }); + return true; + } catch (error) { + logger.error("[DASHBOARD]", "fetchDashboardSections: failed", error); + if (!dashboardMounted || mountId !== dashboardMountId || getDashboardServiceAvailability() === "stopped") { + return false; + } + const current = store.get().sectionsWidget; + store.set({ + sectionsWidget: { + ...current, + loading: false, + failed: current.data.length === 0, + data: current.data + } + }); + return false; + } +} +async function fetchDashboardSections(options = {}) { + if (sectionsRefreshPromise) { + if (options.force) { + sectionsRefreshQueued = true; + } + return sectionsRefreshPromise; + } + const mountId = dashboardMountId; + const promise = (async () => { + let success = false; + do { + sectionsRefreshQueued = false; + success = await fetchDashboardSectionsOnce(mountId); + } while (sectionsRefreshQueued && dashboardMounted && mountId === dashboardMountId); + return success; + })(); + sectionsRefreshPromise = promise; + try { + return await promise; + } finally { + if (sectionsRefreshPromise === promise) { + sectionsRefreshPromise = null; + } + } +} +function setSubscriptionUpdating(sectionName, updating, local = false) { + if (local || !updating) { + setLocalSubscriptionAction(sectionName, updating && local); + } + const sectionsWidget = store.get().sectionsWidget; + const subscriptionUpdatingSections = { + ...sectionsWidget.subscriptionUpdatingSections + }; + if (updating) { + subscriptionUpdatingSections[sectionName] = true; + } else { + delete subscriptionUpdatingSections[sectionName]; + } + store.set({ + sectionsWidget: { + ...sectionsWidget, + subscriptionUpdatingSections + } + }); +} +function setSelectorSwitching(sectionName, tag) { + const sectionsWidget = store.get().sectionsWidget; + const selectorSwitchingSections = { + ...sectionsWidget.selectorSwitchingSections + }; + if (tag) { + selectorSwitchingSections[sectionName] = tag; + } else { + delete selectorSwitchingSections[sectionName]; + } + store.set({ + sectionsWidget: { + ...sectionsWidget, + selectorSwitchingSections + } + }); +} +function setLatencyFetching(sectionName, fetching, local = false, progress) { + if (local || !fetching) { + setLocalLatencyAction(sectionName, fetching && local); + } + const sectionsWidget = store.get().sectionsWidget; + const latencyFetchingSections = { + ...sectionsWidget.latencyFetchingSections + }; + const latencyProgressSections = { + ...sectionsWidget.latencyProgressSections + }; + if (fetching) { + latencyFetchingSections[sectionName] = true; + if (progress) { + latencyProgressSections[sectionName] = progress; + } + } else { + delete latencyFetchingSections[sectionName]; + delete latencyProgressSections[sectionName]; + } + store.set({ + sectionsWidget: { + ...sectionsWidget, + latencyFetchingSections, + latencyProgressSections + } + }); +} +async function completeSubscriptionUpdateJob(jobId, sectionName, response) { + if (pageUnloading) { + setSubscriptionUpdating(sectionName, false); + return; + } + if (jobId && handledSubscriptionJobs.has(jobId)) { + setSubscriptionUpdating(sectionName, false); + return; + } + const shouldNotify = jobId ? shouldNotifyOwnedUiAction("subscription", jobId) : false; + const failed2 = !response.success || response.data.success === false; + const message = response.success ? response.data.message || _("Failed to update subscriptions") : response.error || _("Failed to update subscriptions"); + if (failed2 && isTransientRpcError(message)) { + void refreshRuntimeUiState({ force: true }); + return; + } + if (jobId) { + handledSubscriptionJobs.add(jobId); + } + setSubscriptionUpdating(sectionName, false); + if (jobId && response.success) { + void TachyonShellMethods.uiActionAck("subscription", jobId); + } + if (failed2) { + if (shouldNotify) { + showToast(_("Failed to update subscriptions"), "error"); + } + return; + } + if (shouldNotify) { + showToast(_("Subscriptions updated"), "success"); + } + void fetchDashboardSections({ force: true }); + void fetchServicesInfo(); +} +async function followSubscriptionUpdateState(state) { + const jobId = state.job_id; + const sectionName = state.section || ""; + if (!jobId || !sectionName || followedSubscriptionJobs.has(jobId)) { + return; + } + if (!state.running && handledSubscriptionJobs.has(jobId)) { + return; + } + followedSubscriptionJobs.add(jobId); + if (shouldShowLoadingForRestoredAction(state)) { + setSubscriptionUpdating(sectionName, true); + } + try { + const response = state.running ? await TachyonShellMethods.waitSubscriptionUpdateJob(jobId) : { + success: true, + data: state + }; + await completeSubscriptionUpdateJob(jobId, sectionName, response); + } catch (error) { + logger.error("[DASHBOARD]", "followSubscriptionUpdateState failed", error); + if (!pageUnloading) { + const message = error instanceof Error ? error.message : _("Failed to update subscriptions"); + setSubscriptionUpdating(sectionName, false); + if (!isTransientRpcError(message)) { + showToast(_("Failed to update subscriptions"), "error"); + } + } + } finally { + followedSubscriptionJobs.delete(jobId); + } +} +async function completeLatencyTestJob(jobId, sectionName) { + setLatencyFetching(sectionName, false); + if (pageUnloading) { + return; + } + if (jobId && handledLatencyJobs.has(jobId)) { + return; + } + if (jobId) { + handledLatencyJobs.add(jobId); + } + if (jobId) { + void TachyonShellMethods.uiActionAck("latency", jobId); + } + void fetchDashboardSections({ force: true }); +} +async function followLatencyTestState(state) { + const jobId = state.job_id; + const sectionName = state.section || ""; + if (!jobId || !sectionName || followedLatencyJobs.has(jobId)) { + return; + } + if (!state.running && handledLatencyJobs.has(jobId)) { + return; + } + followedLatencyJobs.add(jobId); + if (shouldShowLoadingForRestoredAction(state)) { + setLatencyFetching(sectionName, true); + } + try { + if (state.running) { + await TachyonShellMethods.waitLatencyTestJob(jobId); + } + await completeLatencyTestJob(jobId, sectionName); + } catch (error) { + logger.error("[DASHBOARD]", "followLatencyTestState failed", error); + if (!pageUnloading) { + setLatencyFetching(sectionName, false); + } + } finally { + followedLatencyJobs.delete(jobId); + } +} +function followDashboardActionsFromUiState(uiState) { + for (const state of uiState.actions.subscription || []) { + if (state.running || state.job_id && state.section) { + void followSubscriptionUpdateState(state); + } else if (state.job_id && !handledSubscriptionJobs.has(state.job_id)) { + handledSubscriptionJobs.add(state.job_id); + void TachyonShellMethods.uiActionAck("subscription", state.job_id); + } + } + for (const state of uiState.actions.latency || []) { + if (state.running || state.job_id && state.section) { + void followLatencyTestState(state); + } else if (state.job_id && !handledLatencyJobs.has(state.job_id)) { + handledLatencyJobs.add(state.job_id); + void TachyonShellMethods.uiActionAck("latency", state.job_id); + } + } +} +function startActionStateWatcher() { + if (actionStateUnsubscribe) { + return; + } + actionStateUnsubscribe = subscribeRuntimeUiState((uiState) => { + if (dashboardMounted) { + followDashboardActionsFromUiState(uiState); + } + }); +} +function stopActionStateWatcher() { + if (!actionStateUnsubscribe) { + return; + } + actionStateUnsubscribe(); + actionStateUnsubscribe = null; +} +async function connectToClashSockets(dataUpdatesId) { + const mountId = dashboardMountId; + const clashApiSecret = await getClashApiSecret2(); + if (!dashboardMounted || mountId !== dashboardMountId || dataUpdatesId !== dashboardDataUpdatesId || getDashboardServiceAvailability() === "stopped") { + return; + } + socket.subscribe( + `${getClashWsUrl()}/traffic?token=${clashApiSecret}`, + (msg) => { + if (dataUpdatesId !== dashboardDataUpdatesId || getDashboardServiceAvailability() === "stopped") { + return; + } + const parsedMsg = JSON.parse(msg); + store.set({ + bandwidthWidget: { + loading: false, + failed: false, + data: { up: parsedMsg.up, down: parsedMsg.down } + } + }); + }, + (_err) => { + if (dataUpdatesId !== dashboardDataUpdatesId || getDashboardServiceAvailability() === "stopped") { + return; + } + logger.error( + "[DASHBOARD]", + "connectToClashSockets - traffic: failed to connect to", + getClashWsUrl() + ); + store.set({ + bandwidthWidget: { + loading: false, + failed: true, + data: { up: 0, down: 0 } + } + }); + } + ); + socket.subscribe( + `${getClashWsUrl()}/connections?token=${clashApiSecret}`, + (msg) => { + if (dataUpdatesId !== dashboardDataUpdatesId || getDashboardServiceAvailability() === "stopped") { + return; + } + const parsedMsg = JSON.parse(msg); + store.set({ + trafficTotalWidget: { + loading: false, + failed: false, + data: { + downloadTotal: parsedMsg.downloadTotal, + uploadTotal: parsedMsg.uploadTotal + } + }, + systemInfoWidget: { + loading: false, + failed: false, + data: { + connections: parsedMsg.connections?.length, + memory: parsedMsg.memory + } + } + }); + }, + (_err) => { + if (dataUpdatesId !== dashboardDataUpdatesId || getDashboardServiceAvailability() === "stopped") { + return; + } + logger.error( + "[DASHBOARD]", + "connectToClashSockets - connections: failed to connect to", + getClashWsUrl() + ); + store.set({ + trafficTotalWidget: { + loading: false, + failed: true, + data: { downloadTotal: 0, uploadTotal: 0 } + }, + systemInfoWidget: { + loading: false, + failed: true, + data: { + connections: 0, + memory: 0 + } + } + }); + } + ); +} +function getDashboardServiceAvailability() { + const service = store.get().servicesInfoWidget; + return getServiceAvailability({ + loading: service.loading, + failed: service.failed, + running: service.data.tachyonRunning + }); +} +function stopDashboardDataUpdates() { + dashboardDataUpdatesStarted = false; + dashboardDataUpdatesId += 1; + if (sectionsRefreshTimer) { + clearInterval(sectionsRefreshTimer); + sectionsRefreshTimer = null; + } + sectionsRefreshQueued = false; + socket.resetAll(); +} +function startDashboardDataUpdates() { + if (dashboardDataUpdatesStarted || !dashboardMounted || getDashboardServiceAvailability() === "stopped") { + return; + } + dashboardDataUpdatesStarted = true; + const dataUpdatesId = ++dashboardDataUpdatesId; + void fetchDashboardSections({ force: true }); + void connectToClashSockets(dataUpdatesId); + sectionsRefreshTimer = setInterval(() => { + void fetchDashboardSections(); + }, SECTIONS_REFRESH_INTERVAL_MS); +} +function syncDashboardServiceAvailability() { + const availability = getDashboardServiceAvailability(); + const stopped = availability === "stopped"; + const container = document.getElementById("dashboard-status"); + container?.classList.toggle("fkp_dashboard-page--service-stopped", stopped); + if (stopped || availability === "loading") { + stopDashboardDataUpdates(); + return; + } + startDashboardDataUpdates(); +} +async function handleChooseOutbound(sectionName, selector, tag) { + const sectionsWidget = store.get().sectionsWidget; + const section = sectionsWidget.data.find( + (item) => item.sectionName === sectionName + ); + if (!section?.withTagSelect || sectionsWidget.selectorSwitchingSections[sectionName] || section.outbounds.some( + (outbound) => outbound.code === tag && outbound.selected + )) { + return; + } + setSelectorSwitching(sectionName, tag); + try { + await TachyonShellMethods.setClashApiGroupProxy(selector, tag); + await fetchDashboardSections({ force: true }); + } finally { + setSelectorSwitching(sectionName); + } +} +function getInitialLatencyProgress(latencyType, tag) { + if (latencyType !== "proxy_list") { + return void 0; + } + try { + const tags = JSON.parse(tag); + if (!Array.isArray(tags)) { + return void 0; + } + const total = tags.filter( + (item) => typeof item === "string" && item.length > 0 + ).length; + return total > 0 ? { completed: 0, total, failed: 0 } : void 0; + } catch { + return void 0; + } +} +async function handleTestLatency(latencyType, sectionName, tag, timeout) { + if (store.get().sectionsWidget.latencyFetchingSections[sectionName]) { + return; + } + setLatencyFetching( + sectionName, + true, + true, + getInitialLatencyProgress(latencyType, tag) + ); + let jobId = ""; + let ownsJobFollow = false; + let completed = false; + try { + const startResponse = await TachyonShellMethods.latencyTestStart( + latencyType, + sectionName, + tag, + timeout + ); + if (!startResponse.success) { + throw new Error(startResponse.error); + } + jobId = startResponse.data.job_id; + if (followedLatencyJobs.has(jobId)) { + completed = true; + return; + } + followedLatencyJobs.add(jobId); + ownsJobFollow = true; + await TachyonShellMethods.waitLatencyTestJob(jobId); + await completeLatencyTestJob(jobId, sectionName); + completed = true; + } catch (error) { + logger.error("[DASHBOARD]", "handleTestLatency: failed", error); + } finally { + if (ownsJobFollow) { + followedLatencyJobs.delete(jobId); + } + if (!completed) { + setLatencyFetching(sectionName, false); + } + } +} +function handleCopyOutbound(outbound) { + const link = outbound.link; + if (link && isCopyableProxyLink(link)) { + copyToClipboard(link); + return; + } + showToast(_("Proxy link is unavailable"), "error"); +} +function formatUrlTestModalValue(value) { + if (typeof value === "boolean") { + return value ? _("Yes") : _("No"); + } + const text = `${value ?? ""}`.trim(); + return text || _("No"); +} +function getUrlTestLatencyClass(latency) { + if (!latency) { + return "fkp_dashboard-page__outbound-grid__item__latency--empty"; + } + if (latency < 800) { + return "fkp_dashboard-page__outbound-grid__item__latency--green"; + } + if (latency < 1500) { + return "fkp_dashboard-page__outbound-grid__item__latency--yellow"; + } + return "fkp_dashboard-page__outbound-grid__item__latency--red"; +} +function formatUrlTestLatency(latency) { + return latency ? `${latency}ms` : "N/A"; +} +function renderDetailsUrl(value) { + const url = `${value ?? ""}`.trim(); + if (!/^https?:\/\//i.test(url)) { + return E("span", {}, formatUrlTestModalValue(value)); + } + return E( + "a", + { + class: "fkp_dashboard-page__urltest-details__url", + href: url, + target: "_blank", + rel: "noopener noreferrer" + }, + url + ); +} +function getDetectedCountryFlag(country) { + const code = `${country || ""}`.trim().toUpperCase(); + if (!/^[A-Z]{2}$/.test(code)) { + return ""; + } + return String.fromCodePoint( + ...code.split("").map((char) => 127462 + char.charCodeAt(0) - 65) + ); +} +function renderDetailsMemberName(member) { + const countryFlag = getDetectedCountryFlag(member.country); + if (!countryFlag) { + return member.displayName; + } + return [ + E( + "span", + { class: "fkp_dashboard-page__urltest-details__country-badge" }, + countryFlag + ), + member.displayName + ]; +} +function renderUrlTestSelectedValue(info) { + const selectedMember = info.outbounds.find((member) => member.selected); + const selectedName = selectedMember?.displayName || info.selectedName || info.selectedCode || ""; + const name = formatUrlTestModalValue(selectedName); + if (name === _("No")) { + return E("span", {}, name); + } + return E( + "span", + { class: "fkp_dashboard-page__urltest-details__selected-value" }, + [ + E( + "span", + { class: "fkp_dashboard-page__urltest-details__selected-name" }, + selectedMember ? renderDetailsMemberName(selectedMember) : name + ), + ...selectedMember?.type ? [ + E( + "span", + { class: "fkp_dashboard-page__urltest-details__selected-type" }, + selectedMember.type + ) + ] : [], + ...selectedMember ? [ + E( + "span", + { class: getUrlTestLatencyClass(selectedMember.latency) }, + formatUrlTestLatency(selectedMember.latency) + ) + ] : [] + ] + ); +} +function renderUrlTestCopyButton(title, onClick) { + return E( + "button", + { + type: "button", + class: "btn fkp_dashboard-page__urltest-details__copy-button", + title, + "aria-label": title, + click: onClick + }, + renderCopyIcon24() + ); +} +function renderUrlTestInfoModal(outbound) { + const info = outbound.urlTestInfo; + if (!info) { + return E("div", {}, _("URLTest details are unavailable")); + } + const fields = [ + { + label: _("Selected"), + children: [renderUrlTestSelectedValue(info)] + }, + { label: _("Testing URL"), children: [renderDetailsUrl(info.url)] }, + { label: _("Interval"), value: info.interval }, + { label: _("Tolerance"), value: info.tolerance }, + { label: _("Idle timeout"), value: info.idleTimeout }, + { + label: _("Interrupt connections"), + value: info.interruptExistConnections + } + ]; + return E("div", { class: "fkp_dashboard-page__urltest-details" }, [ + E( + "dl", + { class: "fkp_dashboard-page__urltest-details__params" }, + fields.map( + ({ label, value, children }) => E("div", { class: "fkp_dashboard-page__urltest-details__param" }, [ + E("dt", {}, label), + E( + "dd", + {}, + children || [E("span", {}, formatUrlTestModalValue(value))] + ) + ]) + ) + ), + E("div", { class: "fkp_dashboard-page__urltest-details__outbounds" }, [ + E( + "div", + { class: "fkp_dashboard-page__urltest-details__outbounds-title" }, + _("Nodes") + ), + E( + "div", + { class: "fkp_dashboard-page__urltest-details__table" }, + info.outbounds.length ? info.outbounds.map( + (member) => E( + "div", + { + class: [ + "fkp_dashboard-page__urltest-details__row", + member.selected ? "fkp_dashboard-page__urltest-details__row--active" : "" + ].filter(Boolean).join(" ") + }, + [ + E( + "div", + { + class: "fkp_dashboard-page__urltest-details__row-name" + }, + [ + E("b", {}, renderDetailsMemberName(member)), + ...member.type ? [ + E( + "span", + { + class: "fkp_dashboard-page__urltest-details__row-type" + }, + member.type + ) + ] : [] + ] + ), + E( + "div", + { + class: "fkp_dashboard-page__urltest-details__row-meta" + }, + [ + E( + "span", + { class: getUrlTestLatencyClass(member.latency) }, + formatUrlTestLatency(member.latency) + ) + ] + ), + member.canCopyLink ? renderUrlTestCopyButton(_("Copy proxy link"), (event) => { + event.preventDefault(); + handleCopyOutbound(member); + }) : E("span", { + class: "fkp_dashboard-page__urltest-details__copy-placeholder" + }) + ] + ) + ) : [ + E( + "div", + { class: "fkp_dashboard-page__urltest-details__empty" }, + _("Node list is empty") + ) + ] + ) + ]), + E("div", { class: "fkp_dashboard-page__urltest-details__footer" }, [ + E( + "button", + { + type: "button", + class: "btn cbi-button cbi-button-neutral", + click: () => { + ui.hideModal(); + } + }, + _("Close") + ) + ]) + ]); +} +function handleShowUrlTestInfo(outbound) { + if (!outbound.urlTestInfo) { + return; + } + ui.showModal( + `${_("URLTest details")}: ${outbound.urlTestInfo.displayName || outbound.displayName}`, + renderUrlTestInfoModal(outbound) + ); +} +function renderPrioritySelectedValue(info) { + const selectedMember = info.outbounds.find((member) => member.selected); + const selectedName = selectedMember?.displayName || info.selectedName || info.selectedCode || ""; + const name = formatUrlTestModalValue(selectedName); + if (name === _("No")) { + return E("span", {}, name); + } + return E( + "span", + { class: "fkp_dashboard-page__urltest-details__selected-value" }, + [ + E( + "span", + { + class: [ + "fkp_dashboard-page__urltest-details__selected-name", + selectedMember ? "fkp_dashboard-page__urltest-details__priority-name" : "" + ].filter(Boolean).join(" ") + }, + selectedMember ? renderPriorityMemberName(selectedMember) : name + ), + ...selectedMember?.type ? [ + E( + "span", + { class: "fkp_dashboard-page__urltest-details__selected-type" }, + selectedMember.type + ) + ] : [], + ...selectedMember ? [ + E( + "span", + { class: getUrlTestLatencyClass(selectedMember.latency) }, + formatUrlTestLatency(selectedMember.latency) + ) + ] : [] + ] + ); +} +function renderPriorityMemberName(member) { + const levelName = member.levelName || _("Level"); + return [ + E( + "span", + { class: "fkp_dashboard-page__urltest-details__priority-number" }, + `#${member.levelIndex + 1}` + ), + E( + "span", + { class: "fkp_dashboard-page__urltest-details__priority-level" }, + levelName + ), + E( + "span", + { class: "fkp_dashboard-page__urltest-details__priority-node" }, + renderDetailsMemberName(member) + ) + ]; +} +function renderPriorityInfoModal(outbound) { + const info = outbound.priorityInfo; + if (!info) { + return E("div", {}, _("Priority details are unavailable")); + } + const fields = [ + { + label: _("Selected"), + children: [renderPrioritySelectedValue(info)] + }, + { label: _("Check URL"), children: [renderDetailsUrl(info.healthUrl)] }, + { + label: _("Check interval"), + value: info.activeCheckInterval + }, + { label: _("Unavailability timeout"), value: info.checkTimeout }, + { + label: _("Higher-level check interval"), + value: info.recoveryCheckInterval + }, + { + label: _("Select the fastest node"), + value: info.pickFastest + }, + { + label: _("Automatically select the fastest node in the current level"), + value: info.switchToFasterSamePriority + }, + ...info.switchToFasterSamePriority ? [ + { + label: _("Faster server search interval"), + value: info.fastestCheckInterval + } + ] : [], + { + label: _("Interrupt connections"), + value: info.interruptExistConnections + } + ]; + return E("div", { class: "fkp_dashboard-page__urltest-details" }, [ + E( + "dl", + { class: "fkp_dashboard-page__urltest-details__params" }, + fields.map( + ({ label, value, children }) => E("div", { class: "fkp_dashboard-page__urltest-details__param" }, [ + E("dt", {}, label), + E( + "dd", + {}, + children || [E("span", {}, formatUrlTestModalValue(value))] + ) + ]) + ) + ), + E("div", { class: "fkp_dashboard-page__urltest-details__outbounds" }, [ + E( + "div", + { class: "fkp_dashboard-page__urltest-details__outbounds-title" }, + _("Nodes") + ), + E( + "div", + { class: "fkp_dashboard-page__urltest-details__table" }, + info.outbounds.length ? info.outbounds.map( + (member) => E( + "div", + { + class: [ + "fkp_dashboard-page__urltest-details__row", + member.selected ? "fkp_dashboard-page__urltest-details__row--active" : "" + ].filter(Boolean).join(" ") + }, + [ + E( + "div", + { + class: "fkp_dashboard-page__urltest-details__row-name" + }, + [ + E( + "b", + { + class: "fkp_dashboard-page__urltest-details__priority-name" + }, + renderPriorityMemberName(member) + ), + ...member.type ? [ + E( + "span", + { + class: "fkp_dashboard-page__urltest-details__row-type" + }, + member.type + ) + ] : [] + ] + ), + E( + "div", + { + class: "fkp_dashboard-page__urltest-details__row-meta" + }, + [ + E( + "span", + { class: getUrlTestLatencyClass(member.latency) }, + formatUrlTestLatency(member.latency) + ) + ] + ), + member.canCopyLink ? renderUrlTestCopyButton(_("Copy proxy link"), (event) => { + event.preventDefault(); + handleCopyOutbound(member); + }) : E("span", { + class: "fkp_dashboard-page__urltest-details__copy-placeholder" + }) + ] + ) + ) : [ + E( + "div", + { class: "fkp_dashboard-page__urltest-details__empty" }, + _("Node list is empty") + ) + ] + ) + ]), + E("div", { class: "fkp_dashboard-page__urltest-details__footer" }, [ + E( + "button", + { + type: "button", + class: "btn cbi-button cbi-button-neutral", + click: () => { + ui.hideModal(); + } + }, + _("Close") + ) + ]) + ]); +} +function handleShowPriorityInfo(outbound) { + if (!outbound.priorityInfo) { + return; + } + ui.showModal( + `${_("Priority details")}: ${outbound.priorityInfo.displayName || outbound.displayName}`, + renderPriorityInfoModal(outbound) + ); +} +async function handleUpdateSubscription(section) { + if (store.get().sectionsWidget.subscriptionUpdatingSections[section.sectionName]) { + return; + } + setSubscriptionUpdating(section.sectionName, true, true); + let jobId = ""; + let ownsJobFollow = false; + try { + const startResponse = await TachyonShellMethods.subscriptionUpdateStart( + section.sectionName + ); + if (!startResponse.success) { + throw new Error(startResponse.error); + } + jobId = startResponse.data.job_id; + markUiActionOwned("subscription", jobId); + if (followedSubscriptionJobs.has(jobId)) { + return; + } + followedSubscriptionJobs.add(jobId); + ownsJobFollow = true; + const response = await TachyonShellMethods.waitSubscriptionUpdateJob(jobId); + await completeSubscriptionUpdateJob(jobId, section.sectionName, response); + } catch (error) { + logger.error("[DASHBOARD]", "handleUpdateSubscription: failed", error); + if (!pageUnloading) { + const message = error instanceof Error ? error.message : _("Failed to update subscriptions"); + setSubscriptionUpdating(section.sectionName, false); + if (!isTransientRpcError(message)) { + showToast(_("Failed to update subscriptions"), "error"); + } + } + } finally { + if (ownsJobFollow) { + followedSubscriptionJobs.delete(jobId); + } + } +} +function shallowRecordEqual(left, right) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + return leftKeys.every((key) => left[key] === right[key]); +} +function canUpdateLatencyProgressInline(prev, next) { + return prev.loading === next.loading && prev.failed === next.failed && prev.data === next.data && shallowRecordEqual( + prev.latencyFetchingSections, + next.latencyFetchingSections + ) && shallowRecordEqual( + prev.subscriptionUpdatingSections, + next.subscriptionUpdatingSections + ) && shallowRecordEqual( + prev.selectorSwitchingSections, + next.selectorSwitchingSections + ); +} +function findLatencyTestButton(container, sectionName) { + return Array.from( + container.querySelectorAll( + `.${LATENCY_TEST_BUTTON_CLASS}` + ) + ).find((button) => button.dataset.latencySection === sectionName); +} +function updateLatencyProgressInline(sectionsWidget) { + const container = document.getElementById("dashboard-sections-grid"); + if (!container) { + return false; + } + for (const section of sectionsWidget.data) { + if (!sectionsWidget.latencyFetchingSections[section.sectionName]) { + continue; + } + const button = findLatencyTestButton(container, section.sectionName); + const label = button?.querySelector( + `.${LATENCY_TEST_BUTTON_LABEL_CLASS}` + ); + if (!label) { + return false; + } + const text = getLatencyTestLabel( + sectionsWidget.latencyProgressSections[section.sectionName] + ); + if (label.textContent !== text) { + label.textContent = text; + } + } + return true; +} +async function renderSectionsWidget() { + logger.debug("[DASHBOARD]", "renderSectionsWidget"); + const sectionsWidget = store.get().sectionsWidget; + const container = document.getElementById("dashboard-sections-grid"); + if (!container) { + return; + } + if (sectionsWidget.loading || sectionsWidget.failed) { + const renderedWidget = renderSections({ + loading: sectionsWidget.loading, + failed: sectionsWidget.failed, + section: { + code: "", + sectionName: "", + displayName: "", + outbounds: [], + withTagSelect: false + }, + onTestLatency: () => { + }, + onChooseOutbound: () => { + }, + onCopyOutbound: () => { + }, + onShowUrlTestInfo: () => { + }, + onShowPriorityInfo: () => { + }, + onUpdateSubscription: () => { + }, + latencyFetching: false, + latencyProgress: void 0, + subscriptionUpdating: false, + selectorSwitchingTag: void 0 + }); + return preserveScrollForPage(() => { + container.replaceChildren(renderedWidget); + }); + } + const renderedWidgets = sectionsWidget.data.map( + (section) => renderSections({ + loading: sectionsWidget.loading, + failed: sectionsWidget.failed, + section, + latencyFetching: Boolean( + sectionsWidget.latencyFetchingSections[section.sectionName] + ), + latencyProgress: sectionsWidget.latencyProgressSections[section.sectionName], + subscriptionUpdating: Boolean( + sectionsWidget.subscriptionUpdatingSections[section.sectionName] + ), + selectorSwitchingTag: sectionsWidget.selectorSwitchingSections[section.sectionName], + onTestLatency: (tag) => { + if (section.withTagSelect) { + if (Array.isArray(tag)) { + return handleTestLatency( + "proxy_list", + section.sectionName, + JSON.stringify(tag) + ); + } + return handleTestLatency("group", section.sectionName, tag); + } + return handleTestLatency( + "proxy", + section.sectionName, + Array.isArray(tag) ? JSON.stringify(tag) : tag, + section.latencyTestTimeout + ); + }, + onChooseOutbound: (sectionName, selector, tag) => { + void handleChooseOutbound(sectionName, selector, tag); + }, + onCopyOutbound: (_section, outbound) => { + handleCopyOutbound(outbound); + }, + onShowUrlTestInfo: (outbound) => { + handleShowUrlTestInfo(outbound); + }, + onShowPriorityInfo: (outbound) => { + handleShowPriorityInfo(outbound); + }, + onUpdateSubscription: (section2) => { + void handleUpdateSubscription(section2); + } + }) + ); + return preserveScrollForPage(() => { + container.replaceChildren(...renderedWidgets); + }); +} +async function renderBandwidthWidget() { + logger.debug("[DASHBOARD]", "renderBandwidthWidget"); + const traffic = store.get().bandwidthWidget; + const container = document.getElementById("dashboard-widget-traffic"); + if (!container) { + return; + } + if (traffic.loading || traffic.failed) { + const renderedWidget2 = renderWidget({ + loading: traffic.loading, + failed: traffic.failed, + title: "", + items: [] + }); + return container.replaceChildren(renderedWidget2); + } + const renderedWidget = renderWidget({ + loading: traffic.loading, + failed: traffic.failed, + title: _("Traffic"), + items: [ + { key: _("Uplink"), value: `${prettyBytes(traffic.data.up)}/s` }, + { key: _("Downlink"), value: `${prettyBytes(traffic.data.down)}/s` } + ] + }); + container.replaceChildren(renderedWidget); +} +async function renderTrafficTotalWidget() { + logger.debug("[DASHBOARD]", "renderTrafficTotalWidget"); + const trafficTotalWidget = store.get().trafficTotalWidget; + const container = document.getElementById("dashboard-widget-traffic-total"); + if (!container) { + return; + } + if (trafficTotalWidget.loading || trafficTotalWidget.failed) { + const renderedWidget2 = renderWidget({ + loading: trafficTotalWidget.loading, + failed: trafficTotalWidget.failed, + title: "", + items: [] + }); + return container.replaceChildren(renderedWidget2); + } + const renderedWidget = renderWidget({ + loading: trafficTotalWidget.loading, + failed: trafficTotalWidget.failed, + title: _("Traffic Total"), + items: [ + { + key: _("Uplink"), + value: String(prettyBytes(trafficTotalWidget.data.uploadTotal)) + }, + { + key: _("Downlink"), + value: String(prettyBytes(trafficTotalWidget.data.downloadTotal)) + } + ] + }); + container.replaceChildren(renderedWidget); +} +async function renderSystemInfoWidget() { + logger.debug("[DASHBOARD]", "renderSystemInfoWidget"); + const systemInfoWidget = store.get().systemInfoWidget; + const container = document.getElementById("dashboard-widget-system-info"); + if (!container) { + return; + } + if (systemInfoWidget.loading || systemInfoWidget.failed) { + const renderedWidget2 = renderWidget({ + loading: systemInfoWidget.loading, + failed: systemInfoWidget.failed, + title: "", + items: [] + }); + return container.replaceChildren(renderedWidget2); + } + const renderedWidget = renderWidget({ + loading: systemInfoWidget.loading, + failed: systemInfoWidget.failed, + title: _("System info"), + items: [ + { + key: _("Active Connections"), + value: String(systemInfoWidget.data.connections) + }, + { + key: _("Memory Usage"), + value: String(prettyBytes(systemInfoWidget.data.memory)) + } + ] + }); + container.replaceChildren(renderedWidget); +} +async function renderServicesInfoWidget() { + logger.debug("[DASHBOARD]", "renderServicesInfoWidget"); + const servicesInfoWidget = store.get().servicesInfoWidget; + const container = document.getElementById("dashboard-widget-service-info"); + if (!container) { + return; + } + if (servicesInfoWidget.loading || servicesInfoWidget.failed) { + const renderedWidget2 = renderWidget({ + loading: servicesInfoWidget.loading, + failed: servicesInfoWidget.failed, + title: "", + items: [] + }); + return container.replaceChildren(renderedWidget2); + } + const renderedWidget = renderWidget({ + loading: servicesInfoWidget.loading, + failed: servicesInfoWidget.failed, + title: _("Services info"), + items: [ + { + key: "Tachyon", + value: servicesInfoWidget.data.tachyonRunning ? _("\u2714 Running") : _("\u2718 Stopped"), + attributes: { + class: servicesInfoWidget.data.tachyonRunning ? "fkp_dashboard-page__widgets-section__item__row--success" : "fkp_dashboard-page__widgets-section__item__row--error" + } + }, + { + key: "Sing-box", + value: servicesInfoWidget.data.singbox ? _("\u2714 Running") : _("\u2718 Stopped"), + attributes: { + class: servicesInfoWidget.data.singbox ? "fkp_dashboard-page__widgets-section__item__row--success" : "fkp_dashboard-page__widgets-section__item__row--error" + } + } + ] + }); + container.replaceChildren(renderedWidget); +} +async function onStoreUpdate(next, prev, diff) { + if (diff.sectionsWidget) { + const inlineUpdated = canUpdateLatencyProgressInline( + prev.sectionsWidget, + next.sectionsWidget + ) && updateLatencyProgressInline(next.sectionsWidget); + if (!inlineUpdated) { + renderSectionsWidget(); + } + } + if (diff.bandwidthWidget) { + renderBandwidthWidget(); + } + if (diff.trafficTotalWidget) { + renderTrafficTotalWidget(); + } + if (diff.systemInfoWidget) { + renderSystemInfoWidget(); + } + if (diff.servicesInfoWidget) { + syncDashboardServiceAvailability(); + renderServicesInfoWidget(); + } +} +async function onPageMount() { + onPageUnmount(); + dashboardMounted = true; + dashboardMountId += 1; + const mountId = dashboardMountId; + const hasRuntimeSnapshot = Boolean(getCachedRuntimeUiState()); + if (!hasRuntimeSnapshot) { + const uiState = await refreshRuntimeUiState({ force: true }); + if (!dashboardMounted || mountId !== dashboardMountId) { + return; + } + if (!uiState) { + void fetchServicesInfo(); + } + } + store.subscribe(onStoreUpdate); + startActionStateWatcher(); + void renderSectionsWidget(); + void renderBandwidthWidget(); + void renderTrafficTotalWidget(); + void renderSystemInfoWidget(); + void renderServicesInfoWidget(); + syncDashboardServiceAvailability(); + if (hasRuntimeSnapshot) { + void refreshRuntimeUiState({ force: true }); + } +} +function onPageUnmount() { + dashboardMounted = false; + dashboardMountId += 1; + stopDashboardDataUpdates(); + stopActionStateWatcher(); + sectionsRefreshQueued = false; + sectionsRefreshPromise = null; + store.unsubscribe(onStoreUpdate); + store.reset(["bandwidthWidget", "trafficTotalWidget", "systemInfoWidget"]); +} +var dashboardLifecycleRegistered = false; +var dashboardControllerInitialized = false; +function registerLifecycleListeners() { + if (dashboardLifecycleRegistered) { + return; + } + dashboardLifecycleRegistered = true; + store.subscribe((next, prev, diff) => { + if (diff.tabService && next.tabService.current !== prev.tabService.current) { + logger.debug( + "[DASHBOARD]", + "active tab diff event, active tab:", + diff.tabService.current + ); + const isDashboardVisible = next.tabService.current === "dashboard"; + if (isDashboardVisible) { + logger.debug( + "[DASHBOARD]", + "registerLifecycleListeners", + "onPageMount" + ); + return onPageMount(); + } + if (!isDashboardVisible) { + logger.debug( + "[DASHBOARD]", + "registerLifecycleListeners", + "onPageUnmount" + ); + return onPageUnmount(); + } + } + }); +} +async function initController() { + if (dashboardControllerInitialized) { + return; + } + dashboardControllerInitialized = true; + onMount("dashboard-status").then(() => { + logger.debug("[DASHBOARD]", "initController", "onMount"); + registerLifecycleListeners(); + if (store.get().tabService.current === "dashboard" || isActiveLuciTab("dashboard")) { + onPageMount(); + } + }); +} + +// src/tachyon/tabs/dashboard/styles.ts +var styles = ` +#cbi-${TACHYON_UCI_PACKAGE}-dashboard-_mount_node > .cbi-value-title { + display: none; +} + +#cbi-${TACHYON_UCI_PACKAGE}-dashboard-_mount_node > .cbi-value-field { + margin-left: 0; + width: 100%; +} + +#cbi-${TACHYON_UCI_PACKAGE}-dashboard-_mount_node > div { + width: 100%; +} + +#cbi-${TACHYON_UCI_PACKAGE}-dashboard > h3 { + display: none; +} + +.fkp_dashboard-page { + width: 100%; + --dashboard-grid-columns: 4; + --dashboard-grid-min-width: 180px; +} + +.fkp_dashboard-page__service-stopped { + display: none; + width: 100%; + min-height: 180px; + margin-top: 10px; + align-items: center; + justify-content: center; + padding: 20px; + box-sizing: border-box; + border: 1px dashed var(--border-color-high, #555); + border-radius: 6px; + color: var(--text-color-medium, #888); + background: transparent; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + font-style: italic; + text-align: center; +} + +.fkp_dashboard-page--service-stopped { + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), minmax(var(--dashboard-grid-min-width), 1fr)); + gap: 10px; +} + +.fkp_dashboard-page--service-stopped .fkp_dashboard-page__service-stopped { + display: flex; + grid-column: 1 / -1; +} + +.fkp_dashboard-page--service-stopped .fkp_dashboard-page__content { + display: none; +} + +@media (max-width: 900px) { + .fkp_dashboard-page { + --dashboard-grid-columns: 2; + } +} + +@media (max-width: 560px) { + .fkp_dashboard-page { + --dashboard-grid-columns: 1; + --dashboard-grid-min-width: 0; + } +} + +.fkp_dashboard-page__widgets-section { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), minmax(var(--dashboard-grid-min-width), 1fr)); + grid-gap: 10px; +} + +.fkp_dashboard-page__widgets-section__item { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + min-width: 0; +} + +.fkp_dashboard-page__widgets-section__item__title {} + +.fkp_dashboard-page__widgets-section__item__row {} + +.fkp_dashboard-page__widgets-section__item__row--success .fkp_dashboard-page__widgets-section__item__row__value { + color: var(--success-color-medium, green); +} + +.fkp_dashboard-page__widgets-section__item__row--error .fkp_dashboard-page__widgets-section__item__row__value { + color: var(--error-color-medium, red); +} + +.fkp_dashboard-page__widgets-section__item__row__key {} + +.fkp_dashboard-page__widgets-section__item__row__value {} + +.fkp_dashboard-page__outbound-section { + margin-top: 10px; + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; +} + +.fkp_dashboard-page__outbound-section__title-section { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px 10px; + min-width: 0; +} + +.fkp_dashboard-page__outbound-section__title-section__title { + color: var(--text-color-high); + font-weight: 700; + min-width: 0; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__outbound-section__title-section__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex: 0 0 auto; +} + +.fkp_dashboard-page .btn.fkp_dashboard-page__outbound-section__subscription-update { + min-width: 130px; + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.fkp_dashboard-page__outbound-section__subscription-update svg { + width: 15px; + height: 15px; + display: block; + flex: 0 0 auto; +} + +.fkp_dashboard-page__outbound-section__subscription-update[disabled] { + cursor: not-allowed; + opacity: 0.65; +} + +.fkp_dashboard-page .btn.dashboard-sections-grid-item-test-latency { + min-width: 99px; + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.fkp_dashboard-page .btn.dashboard-sections-grid-item-test-latency svg { + width: 15px; + height: 15px; + display: block; + flex: 0 0 auto; +} + +.fkp_dashboard-page .btn.dashboard-sections-grid-item-test-latency[disabled] { + cursor: not-allowed; + opacity: 0.65; +} + +.fkp_dashboard-page__outbound-grid { + margin-top: 5px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), minmax(var(--dashboard-grid-min-width), 1fr)); + grid-gap: 10px; +} + +.fkp_dashboard-page__subscription-meta { + --subscription-meta-action-size: 28px; + --subscription-meta-action-gap: 6px; + grid-column: 1 / -1; + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 8px 10px; + background: var(--background-color-high, transparent); +} + +.fkp_dashboard-page__subscription-meta__main { + display: flex; + align-items: center; + gap: 6px 10px; + min-width: 0; +} + +.fkp_dashboard-page__subscription-meta__heading { + flex: 0 0 auto; + color: var(--text-color-high); + font-weight: 700; + line-height: 1.25; + white-space: nowrap; +} + +.fkp_dashboard-page__subscription-meta__title { + flex: 0 1 auto; + width: max-content; + max-width: min(28ch, 30%); + min-width: min-content; + color: var(--text-color-high); + font-weight: 700; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__subscription-meta__facts { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px 12px; +} + +.fkp_dashboard-page__subscription-meta__fact { + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; + line-height: 1.25; +} + +.fkp_dashboard-page__subscription-meta__fact-key { + color: var(--text-color-medium); + font-size: 12px; + white-space: nowrap; +} + +.fkp_dashboard-page__subscription-meta__fact-value { + color: var(--text-color-high); + font-weight: 600; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__subscription-meta__actions { + flex: 0 0 auto; + margin-left: auto; + display: flex; + justify-content: flex-end; + gap: var(--subscription-meta-action-gap); +} + +.fkp_dashboard-page .btn.fkp_dashboard-page__subscription-meta__action { + width: var(--subscription-meta-action-size); + height: var(--subscription-meta-action-size); + min-width: var(--subscription-meta-action-size); + min-height: var(--subscription-meta-action-size); + padding: 2px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; + margin: 0; +} + +.fkp_dashboard-page__subscription-meta__action svg { + width: 15px; + height: 15px; + display: block; + flex: 0 0 auto; +} + +.fkp_dashboard-page__subscription-meta__announce { + margin: 6px 0 0; + border-left: 3px solid var(--primary-color-medium, dodgerblue); + padding: 4px 8px; + background: var(--background-color-low, rgba(0, 0, 0, 0.04)); + color: var(--text-color-medium); + font-style: italic; + line-height: 1.25; + overflow-wrap: anywhere; +} + +@media (max-width: 700px) { + .fkp_dashboard-page__subscription-meta__main { + align-items: flex-start; + flex-wrap: wrap; + } + + .fkp_dashboard-page__subscription-meta__heading, + .fkp_dashboard-page__subscription-meta__title { + order: 1; + } + + .fkp_dashboard-page__subscription-meta__actions { + order: 2; + } + + .fkp_dashboard-page__subscription-meta__facts { + order: 3; + flex-basis: 100%; + } + + .fkp_dashboard-page__subscription-meta__title { + max-width: calc(100% - 42px); + } +} + +.fkp_dashboard-page__outbound-grid__item { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + transition: border 0.2s ease; + min-width: 0; + position: relative; +} + +.fkp_dashboard-page__outbound-grid__item--selectable { + cursor: pointer; +} + +.fkp_dashboard-page__outbound-grid__item--selectable:hover { + border-color: var(--primary-color-high, dodgerblue); +} + +.fkp_dashboard-page__outbound-grid__item--active { + border-color: var(--success-color-medium, green); +} + +.fkp_dashboard-page__outbound-grid__item--disabled { + cursor: default; +} + +.fkp_dashboard-page__outbound-grid__item--switching { + border-color: transparent !important; + overflow: hidden; + cursor: wait; +} + +.fkp_dashboard-page__outbound-grid__item__snake { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; + box-sizing: border-box; +} + +.fkp_dashboard-page__outbound-grid__item__snake rect { + stroke: var(--primary-color-high, dodgerblue); + stroke-width: 4; + animation: fkp-dashboard-selector-snake-svg 1.2s linear infinite; +} + +@keyframes fkp-dashboard-selector-snake-svg { + 0% { + stroke-dasharray: 30 70; + stroke-dashoffset: 100; + } + 100% { + stroke-dasharray: 30 70; + stroke-dashoffset: 0; + } +} + +.fkp_dashboard-page__outbound-grid__item__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.fkp_dashboard-page__outbound-grid__item__header b { + min-width: 0; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page .btn.fkp_dashboard-page__outbound-grid__item__copy-button { + width: 22px; + height: 22px; + min-width: 22px; + min-height: 22px; + padding: 1px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; +} + +.fkp_dashboard-page__outbound-grid__item__copy-button svg { + width: 13px; + height: 13px; + display: block; + flex: 0 0 auto; +} + +.fkp_dashboard-page__outbound-grid__item__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 10px; +} + +.fkp_dashboard-page__outbound-grid__item__type { + min-width: 0; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__outbound-grid__item__latency--empty { + color: var(--primary-color-low, lightgray); +} + +.fkp_dashboard-page__outbound-grid__item__latency--green { + color: var(--success-color-medium, green); +} + +.fkp_dashboard-page__outbound-grid__item__latency--yellow { + color: var(--warn-color-medium, orange); +} + +.fkp_dashboard-page__outbound-grid__item__latency--red { + color: var(--error-color-medium, red); +} + +.fkp_dashboard-page__urltest-details { + box-sizing: border-box; + width: min(760px, calc(100vw - 56px)); + max-width: 100%; + padding-top: 10px; +} + +.fkp_dashboard-page__urltest-details__params { + display: grid; + grid-template-columns: minmax(120px, max-content) minmax(0, 1fr); + gap: 8px 16px; + margin: 0 0 18px; +} + +.fkp_dashboard-page__urltest-details__param { + display: contents; +} + +.fkp_dashboard-page__urltest-details__param dt { + color: var(--text-color-medium, #666); + line-height: 1.35; +} + +.fkp_dashboard-page__urltest-details__param dd { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + margin: 0; +} + +.fkp_dashboard-page__urltest-details__param dd span { + min-width: 0; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__urltest-details__url { + min-width: 0; + color: var(--primary-color-high, #337ab7); + text-decoration: none; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__urltest-details__url:hover { + text-decoration: underline; +} + +.fkp_dashboard-page__urltest-details__selected-value { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + max-width: 100%; + padding: 0; + border: 0; + color: inherit; + background: transparent; + box-sizing: border-box; + line-height: 1.3; +} + +.fkp_dashboard-page__urltest-details__selected-name { + min-width: 0; + font-weight: 600; + overflow-wrap: anywhere; +} + +.fkp_dashboard-page__urltest-details__selected-type { + color: var(--text-color-medium, #666); +} + +.fkp_dashboard-page__urltest-details__outbounds-title { + margin-bottom: 8px; + font-weight: 600; +} + +.fkp_dashboard-page__urltest-details__table { + display: grid; + gap: 6px; + width: calc(100% + 14px); + box-sizing: border-box; + max-height: min(46vh, 460px); + overflow-x: hidden; + overflow-y: auto; + padding-right: 14px; + scrollbar-gutter: auto; +} + +.fkp_dashboard-page__urltest-details__row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(54px, max-content) 20px; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + padding: 7px 8px; + box-sizing: border-box; + border: 1px solid transparent; + border-bottom: 1px solid var(--border-color-low, #eee); + border-radius: 4px; +} + +.fkp_dashboard-page__urltest-details__row--active { + border-color: var(--success-color-low, #2d7d46); + background: transparent; +} + +.fkp_dashboard-page__urltest-details__row-name, +.fkp_dashboard-page__urltest-details__row-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + line-height: 1.3; +} + +.fkp_dashboard-page__urltest-details__row-name { + flex-wrap: wrap; +} + +.fkp_dashboard-page__urltest-details__row-name b { + min-width: 0; + overflow-wrap: anywhere; + line-height: 1.3; +} + +.fkp_dashboard-page__urltest-details__priority-name { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 2px 0; +} + +.fkp_dashboard-page__urltest-details__priority-number { + margin-right: 6px; + color: var(--text-color-medium, #aaa); + font-family: monospace; + font-size: 13px; + font-weight: 600; +} + +.fkp_dashboard-page__urltest-details__priority-level { + margin-right: 8px; + padding: 2px 6px; + border-radius: 4px; + color: var(--text-color-medium, #aaa); + background: rgba(128, 128, 128, 0.15); + font-size: 11px; + font-weight: 400; +} + +.fkp_dashboard-page__urltest-details__country-badge { + display: inline-flex; + align-items: center; + user-select: none; + margin-right: 6px; + padding: 2px 4px; + border: 1px solid rgba(128, 128, 128, 0.25); + border-radius: 4px; + background: rgba(128, 128, 128, 0.15); + line-height: 1; +} + +.fkp_dashboard-page__urltest-details__priority-node { + color: var(--text-color-high, #fff); + font-weight: 600; +} + +.fkp_dashboard-page__urltest-details__row-type, +.fkp_dashboard-page__urltest-details__row-meta { + color: var(--text-color-medium, #666); +} + +.fkp_dashboard-page__urltest-details__row-type { + white-space: nowrap; + line-height: 1.3; +} + +.fkp_dashboard-page__urltest-details__row-meta { + justify-content: flex-end; + white-space: nowrap; +} + +.fkp_dashboard-page__urltest-details__copy-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 20px; + width: 20px; + min-width: 20px; + height: 20px; + padding: 0; + box-sizing: border-box; +} + +.fkp_dashboard-page__urltest-details__copy-button svg { + width: 12px; + height: 12px; +} + +.fkp_dashboard-page__urltest-details__copy-placeholder { + display: block; + width: 20px; + min-width: 20px; + height: 1px; +} + +.fkp_dashboard-page__urltest-details__empty { + margin-top: 4px; + padding: 24px 0; + border: 1px dashed var(--border-color-high, #555); + border-radius: 4px; + color: var(--text-color-medium, #888); + background: rgba(128, 128, 128, 0.02); + font-style: italic; + text-align: center; +} + +.fkp_dashboard-page__urltest-details__footer { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + +@media (max-width: 560px) { + .fkp_dashboard-page__urltest-details__params { + grid-template-columns: 1fr; + } + + .fkp_dashboard-page__urltest-details__row { + grid-template-columns: minmax(0, 1fr) 20px; + } + + .fkp_dashboard-page__urltest-details__row-meta { + grid-column: 1 / -1; + justify-content: flex-start; + } +} + +`; + +// src/tachyon/tabs/dashboard/index.ts +var DashboardTab = { + render, + initController, + styles +}; + +// src/tachyon/tabs/diagnostic/renderDiagnostic.ts +function render2() { + return E("div", { id: "diagnostic-status", class: "fkp_diagnostic-page" }, [ + E("div", { class: "fkp_diagnostic-page__left-bar" }, [ + E("div", { id: "fkp_diagnostic-page-run-check" }), + E("div", { + class: "fkp_diagnostic-page__checks", + id: "fkp_diagnostic-page-checks" + }) + ]), + E("div", { class: "fkp_diagnostic-page__right-bar" }, [ + E("div", { id: "fkp_diagnostic-page-wiki" }), + E("div", { id: "fkp_diagnostic-page-actions" }), + E("div", { id: "fkp_diagnostic-page-system-info" }) + ]) + ]); +} + +// src/tachyon/tabs/diagnostic/checks/updateCheckStore.ts +function updateCheckStore(check, minified) { + const diagnosticsChecks = store.get().diagnosticsChecks; + const other = diagnosticsChecks.filter((item) => item.code !== check.code); + const smallCheck = { + ...check, + items: check.items.filter((item) => item.state !== "success") + }; + const targetCheck = minified ? smallCheck : check; + store.set({ + diagnosticsChecks: [...other, targetCheck] + }); +} + +// src/tachyon/tabs/diagnostic/helpers/getMeta.ts +function getMeta({ allGood, atLeastOneGood }) { + if (allGood) { + return { + state: "success", + description: _("Checks passed") + }; + } + if (atLeastOneGood) { + return { + state: "warning", + description: _("Issues detected") + }; + } + return { + state: "error", + description: _("Checks failed") + }; +} + +// src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts +function getDnsCheckPresentation(data) { + const dhcpManagedManually = Boolean(data.dont_touch_dhcp); + const dhcpCheckOk = dhcpManagedManually || Boolean(data.dhcp_config_status); + const allGood = Boolean(data.dns_on_router) && dhcpCheckOk && Boolean(data.bootstrap_dns_status) && Boolean(data.dns_status); + const atLeastOneGood = Boolean(data.dns_on_router) || dhcpCheckOk || Boolean(data.bootstrap_dns_status) || Boolean(data.dns_status); + const meta = getMeta({ atLeastOneGood, allGood }); + const state = dhcpManagedManually && meta.state === "success" ? "warning" : meta.state; + const description = dhcpManagedManually && meta.state === "success" ? _("Checks passed with manual DHCP") : meta.description; + const dhcpItemState = dhcpManagedManually ? "warning" : data.dhcp_config_status ? "success" : "error"; + const dhcpItemKey = dhcpManagedManually ? _("DHCP is managed manually") : _("DHCP has DNS server"); + return { + state, + description, + dhcpItemState, + dhcpItemKey + }; +} + +// src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts +async function runDnsCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.DNS; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const dnsChecks = await TachyonShellMethods.checkDNSAvailable(); + if (!dnsChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("DNS checks failed"); + } + const data = dnsChecks.data; + const { state, description, dhcpItemState, dhcpItemKey } = getDnsCheckPresentation(data); + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + ...insertIf( + data.dns_type === "doh" || data.dns_type === "dot" || data.bootstrap_dns_server_count > 1 || !data.bootstrap_dns_status, + [ + { + state: data.bootstrap_dns_status ? "success" : "error", + key: data.bootstrap_dns_server_count > 1 ? _("Active Bootstrap DNS") : _("Bootstrap DNS"), + value: data.bootstrap_dns_server_count > 1 ? `${data.bootstrap_dns_server} (${data.bootstrap_dns_server_index + 1}/${data.bootstrap_dns_server_count})` : data.bootstrap_dns_server + } + ] + ), + { + state: data.dns_status ? "success" : "error", + key: data.dns_server_count > 1 ? _("Active Main DNS") : _("Main DNS"), + value: data.dns_server_count > 1 ? `${data.dns_server} [${data.dns_type}] (${data.dns_server_index + 1}/${data.dns_server_count})` : `${data.dns_server} [${data.dns_type}]` + }, + { + state: data.dns_on_router ? "success" : "error", + key: _("DNS on router"), + value: "" + }, + { + state: dhcpItemState, + key: dhcpItemKey, + value: "" + } + ] + }); + if (state === "error") { + throw new Error("DNS checks failed"); + } +} + +// src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts +async function runSingBoxCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.SINGBOX; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const singBoxChecks = await TachyonShellMethods.checkSingBox(); + if (!singBoxChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("Sing-box checks failed"); + } + const data = singBoxChecks.data; + const allGood = Boolean(data.sing_box_installed) && Boolean(data.sing_box_version_ok) && Boolean(data.sing_box_service_exist) && Boolean(data.sing_box_autostart_disabled) && Boolean(data.sing_box_process_running) && Boolean(data.sing_box_ports_listening); + const atLeastOneGood = Boolean(data.sing_box_installed) || Boolean(data.sing_box_version_ok) || Boolean(data.sing_box_service_exist) || Boolean(data.sing_box_autostart_disabled) || Boolean(data.sing_box_process_running) || Boolean(data.sing_box_ports_listening); + const { state, description } = getMeta({ atLeastOneGood, allGood }); + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + { + state: data.sing_box_installed ? "success" : "error", + key: _("Sing-box installed"), + value: "" + }, + { + state: data.sing_box_version_ok ? "success" : "error", + key: _("Sing-box version is compatible (newer than 1.12.4)"), + value: "" + }, + { + state: data.sing_box_service_exist ? "success" : "error", + key: _("Sing-box service exist"), + value: "" + }, + { + state: data.sing_box_autostart_disabled ? "success" : "error", + key: _("Sing-box autostart disabled"), + value: "" + }, + { + state: data.sing_box_process_running ? "success" : "error", + key: _("Sing-box process running"), + value: "" + }, + { + state: data.sing_box_ports_listening ? "success" : "error", + key: _("Sing-box listening ports"), + value: "" + } + ] + }); + if (!atLeastOneGood || !data.sing_box_process_running) { + throw new Error("Sing-box checks failed"); + } +} + +// src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts +function serverPrefix(item) { + return `${item.label}:`; +} +function formatListen(item) { + return `${item.listen}:${Number(item.listen_port || 0)} [${item.required_proto}]`; +} +function getPortConflictItem(item) { + const key = `${serverPrefix(item)} ${_("Port conflict")}`; + if (item.port_conflict === 1) { + return { + state: "error", + key, + value: `${_("Used by")}: ${item.port_conflict_owners || _("unknown")}` + }; + } + return { + state: "success", + key, + value: _("No conflict detected") + }; +} +function getPublicHostItem(item, wanIp) { + const key = `${serverPrefix(item)} ${_("Public host")}`; + if (!item.public_host) { + return { + state: "warning", + key, + value: _("Not configured") + }; + } + if (item.public_host_resolved === 0) { + return { + state: "warning", + key, + value: `${item.public_host} (${_("Does not resolve")})` + }; + } + if (item.public_host_public === 0) { + return { + state: "warning", + key, + value: `${item.public_host} (${_("Not public")})` + }; + } + if (item.public_host_matches_wan === 0) { + return { + state: "warning", + key, + value: `${item.public_host_ips || item.public_host} / ${_("WAN")}: ${wanIp || _("Not detected")}` + }; + } + return { + state: "success", + key, + value: item.public_host + }; +} +function getServerItems(item, wanIp) { + const prefix = serverPrefix(item); + const items = [ + { + state: item.runtime_ok ? "success" : "error", + key: `${prefix} ${_("Generated inbound")}`, + value: `${item.tag} [${item.protocol}]` + } + ]; + if (item.protocol === "tailscale") { + items.push( + { + state: "success", + key: `${prefix} ${_("Tailscale endpoint")}`, + value: _("No public firewall port required") + }, + { + state: item.routes_configured ? "success" : "warning", + key: `${prefix} ${_("Routing rules")}`, + value: item.routing_mode + } + ); + return items; + } + if (item.protocol === "json_inbound") { + items.push({ + state: item.routes_configured ? "success" : "warning", + key: `${prefix} ${_("Routing rules")}`, + value: item.routing_mode + }); + return items; + } + items.push( + { + state: item.listening === 1 && item.port_conflict !== 1 ? "success" : "error", + key: `${prefix} ${_("Listening port")}`, + value: formatListen(item) + }, + getPortConflictItem(item), + { + state: item.firewall_required === 0 ? "warning" : item.firewall_open === 1 ? "success" : "error", + key: `${prefix} ${_("Firewall WAN port")}`, + value: item.firewall_required === 0 ? _("Not required for this listen address") : `${item.required_proto}/${Number(item.listen_port || 0)}` + }, + { + state: item.routes_configured ? "success" : "warning", + key: `${prefix} ${_("Routing rules")}`, + value: item.routing_mode + }, + getPublicHostItem(item, wanIp) + ); + return items; +} +async function runInboundsCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.INBOUNDS; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const inboundsChecks = await TachyonShellMethods.checkInbounds(); + if (!inboundsChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("Inbounds checks failed"); + } + const data = inboundsChecks.data; + if (!data.enabled_count) { + updateCheckStore({ + order, + code, + title, + description: _("No enabled server inbounds configured"), + state: "skipped", + items: [] + }); + return; + } + const items = [ + { + state: data.wan_public ? "success" : "warning", + key: _("WAN public IP"), + value: data.wan_ip || _("Not detected") + } + ]; + data.items.forEach((item) => { + items.push(...getServerItems(item, data.wan_ip)); + }); + const allGood = items.every((item) => item.state === "success"); + const atLeastOneGood = items.some((item) => item.state !== "error"); + const { state, description } = getMeta({ atLeastOneGood, allGood }); + updateCheckStore({ + order, + code, + title, + description, + state, + items + }); + if (!atLeastOneGood) { + throw new Error("Inbounds checks failed"); + } +} + +// src/tachyon/tabs/diagnostic/checks/runNftCheck.ts +async function runNftCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.NFT; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + await RemoteFakeIPMethods.getFakeIpCheck(); + await RemoteFakeIPMethods.getIpCheck(); + const nftablesChecks = await TachyonShellMethods.checkNftRules(); + if (!nftablesChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("Nftables checks failed"); + } + const data = nftablesChecks.data; + const allGood = Boolean(data.table_exist) && Boolean(data.rules_mangle_exist) && Boolean(data.rules_mangle_counters) && Boolean(data.rules_mangle_output_exist) && Boolean(data.rules_mangle_output_counters) && Boolean(data.rules_proxy_exist) && Boolean(data.rules_proxy_counters) && !data.rules_other_mark_exist; + const atLeastOneGood = Boolean(data.table_exist) || Boolean(data.rules_mangle_exist) || Boolean(data.rules_mangle_counters) || Boolean(data.rules_mangle_output_exist) || Boolean(data.rules_mangle_output_counters) || Boolean(data.rules_proxy_exist) || Boolean(data.rules_proxy_counters) || !data.rules_other_mark_exist; + const { state, description } = getMeta({ atLeastOneGood, allGood }); + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + { + state: data.table_exist ? "success" : "error", + key: _("Table exist"), + value: "" + }, + { + state: data.rules_mangle_exist ? "success" : "error", + key: _("Rules mangle exist"), + value: "" + }, + { + state: data.rules_mangle_counters ? "success" : "error", + key: _("Rules mangle counters"), + value: "" + }, + { + state: data.rules_mangle_output_exist ? "success" : "error", + key: _("Rules mangle output exist"), + value: "" + }, + { + state: data.rules_mangle_output_counters ? "success" : "error", + key: _("Rules mangle output counters"), + value: "" + }, + { + state: data.rules_proxy_exist ? "success" : "error", + key: _("Rules proxy exist"), + value: "" + }, + { + state: data.rules_proxy_counters ? "success" : "error", + key: _("Rules proxy counters"), + value: "" + }, + { + state: !data.rules_other_mark_exist ? "success" : "warning", + key: !data.rules_other_mark_exist ? _("No other marking rules found") : _("Additional marking rules found"), + value: "" + } + ] + }); + if (!atLeastOneGood) { + throw new Error("Nftables checks failed"); + } +} + +// src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts +async function runFakeIPCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.FAKEIP; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const routerFakeIPResponse = await TachyonShellMethods.checkFakeIP(); + const checkFakeIPResponse = await RemoteFakeIPMethods.getFakeIpCheck(); + const checkIPResponse = await RemoteFakeIPMethods.getIpCheck(); + const browserFakeIPCheckUnavailable = !checkFakeIPResponse.success; + const browserFakeIPCheckMessage = checkFakeIPResponse.success ? "" : checkFakeIPResponse.message; + const checks = { + singBoxFakeIP: routerFakeIPResponse.success && routerFakeIPResponse.data.fakeip, + browserFakeIP: checkFakeIPResponse.success && checkFakeIPResponse.data.fakeip, + canComparePublicIP: checkFakeIPResponse.success && checkIPResponse.success, + differentIP: checkFakeIPResponse.success && checkIPResponse.success && checkFakeIPResponse.data.IP !== checkIPResponse.data.IP + }; + const fakeIPWorks = checks.singBoxFakeIP && checks.browserFakeIP; + const { state, description } = fakeIPWorks ? checks.differentIP ? { state: "success", description: _("Checks passed") } : { + state: "warning", + description: _("FakeIP works; public IP comparison is inconclusive") + } : browserFakeIPCheckUnavailable && checks.singBoxFakeIP ? { + state: "warning", + description: _("Browser FakeIP check could not be completed") + } : getMeta({ + allGood: false, + atLeastOneGood: checks.singBoxFakeIP || checks.browserFakeIP + }); + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + { + state: checks.singBoxFakeIP ? "success" : "error", + key: checks.singBoxFakeIP ? _("Sing-box FakeIP DNS works") : _("Sing-box FakeIP DNS does not work"), + value: routerFakeIPResponse.success ? routerFakeIPResponse.data.IP : "" + }, + { + state: browserFakeIPCheckUnavailable ? "warning" : checks.browserFakeIP ? "success" : "error", + key: browserFakeIPCheckUnavailable ? _("Browser FakeIP check could not be completed") : checks.browserFakeIP ? _("Browser is using FakeIP correctly") : _("Browser is not using FakeIP"), + value: browserFakeIPCheckMessage + }, + ...insertIf(checks.browserFakeIP, [ + { + state: checks.differentIP ? "success" : "warning", + key: !checks.canComparePublicIP ? _("Could not compare FakeIP and control public IPs") : checks.differentIP ? _("FakeIP and control checks use different public IPs") : _("FakeIP and control checks use the same public IP"), + value: "" + } + ]) + ] + }); +} + +// src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts +function getCheckItemsMeta(items) { + if (items.some((item) => item.state === "error")) { + return { + state: "error", + description: _("Checks failed") + }; + } + if (items.some((item) => item.state === "warning")) { + return { + state: "warning", + description: _("Issues detected") + }; + } + return { + state: "success", + description: _("Checks passed") + }; +} + +// src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts +async function runZapretCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.ZAPRET; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const zapretStatus = await TachyonShellMethods.getZapretStatus(); + if (!zapretStatus.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("Zapret checks failed"); + } + const data = zapretStatus.data; + const providerAvailable = Boolean(data.provider_available ?? data.installed); + const packageInstalled = Boolean(data.package_installed); + const hasZapretRules = Number(data.enabled_rule_count || 0) > 0; + const queueOverlap = Boolean(data.queue_overlap); + const standaloneServiceRunning = Boolean(data.standalone_service_running); + const standaloneConflict = hasZapretRules && standaloneServiceRunning; + const expectedProcesses = Number(data.expected_process_count || 0); + const runningProcesses = Number(data.running_process_count || 0); + const supervisorProcesses = Number(data.supervisor_process_count || 0); + const tachyonRuntimeReady = !hasZapretRules || runningProcesses === expectedProcesses && supervisorProcesses === expectedProcesses; + const unexpectedRuntime = !hasZapretRules && (runningProcesses > 0 || supervisorProcesses > 0); + const outboundsConfigured = Boolean(data.outbounds_configured); + const items = [ + { + state: providerAvailable ? "success" : hasZapretRules ? "error" : "warning", + key: providerAvailable ? _("Zapret provider binary is available") : _("Zapret provider binary is not available"), + value: data.provider_path || "" + }, + { + state: packageInstalled ? "success" : hasZapretRules ? "error" : "warning", + key: packageInstalled ? _("Zapret package is installed") : _("Zapret package is not installed"), + value: "" + }, + { + state: hasZapretRules && !providerAvailable ? "error" : "success", + key: hasZapretRules ? _("There are rules using Zapret") : _("No rules use Zapret"), + value: "" + }, + { + state: unexpectedRuntime || !tachyonRuntimeReady ? "error" : "success", + key: hasZapretRules ? tachyonRuntimeReady ? _("Tachyon-managed nfqws runtime is ready") : _("Tachyon-managed nfqws runtime is not ready") : unexpectedRuntime ? _("Unexpected Tachyon-managed nfqws runtime is running") : _("Tachyon-managed nfqws runtime is not running"), + value: hasZapretRules ? `${runningProcesses}/${expectedProcesses}` : "" + }, + { + state: queueOverlap ? "error" : "success", + key: queueOverlap ? _("NFQUEUE range overlaps with another rule") : _("NFQUEUE range is available"), + value: `${Number(data.queue_base || 0)}-${Number(data.queue_range_end || 0)}` + }, + { + state: !hasZapretRules || outboundsConfigured ? "success" : "error", + key: outboundsConfigured ? _("Zapret sing-box outbound is configured") : _("Zapret sing-box outbound is not configured"), + value: "" + }, + { + state: standaloneConflict ? "warning" : "success", + key: standaloneServiceRunning ? hasZapretRules ? _("Standalone Zapret is active together with Tachyon Zapret rules") : _("Standalone Zapret service is active") : _("Standalone Zapret service is inactive"), + value: "" + } + ]; + const { state, description } = getCheckItemsMeta(items); + updateCheckStore({ + order, + code, + title, + description, + state, + items + }); +} + +// src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts +async function runZapret2Check() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.ZAPRET2; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const zapret2Status = await TachyonShellMethods.getZapret2Status(); + if (!zapret2Status.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("Zapret2 checks failed"); + } + const data = zapret2Status.data; + const providerAvailable = Boolean(data.provider_available ?? data.installed); + const packageInstalled = Boolean(data.package_installed); + const hasZapret2Rules = Number(data.enabled_rule_count || 0) > 0; + const queueOverlap = Boolean(data.queue_overlap); + const expectedProcesses = Number(data.expected_process_count || 0); + const runningProcesses = Number(data.running_process_count || 0); + const supervisorProcesses = Number(data.supervisor_process_count || 0); + const tachyonRuntimeReady = !hasZapret2Rules || runningProcesses === expectedProcesses && supervisorProcesses === expectedProcesses; + const unexpectedRuntime = !hasZapret2Rules && (runningProcesses > 0 || supervisorProcesses > 0); + const outboundsConfigured = Boolean(data.outbounds_configured); + const standaloneServiceEnabled = Boolean(data.standalone_service_enabled); + const standaloneServiceRunning = Boolean(data.standalone_service_running); + const standaloneConflict = hasZapret2Rules && standaloneServiceRunning; + const standaloneAutostartRisk = hasZapret2Rules && standaloneServiceEnabled && !standaloneServiceRunning; + const items = [ + { + state: providerAvailable ? "success" : hasZapret2Rules ? "error" : "warning", + key: providerAvailable ? _("Zapret2 provider binary is available") : _("Zapret2 provider binary is not available"), + value: data.provider_path || "" + }, + { + state: packageInstalled ? "success" : hasZapret2Rules ? "error" : "warning", + key: packageInstalled ? _("Zapret2 package is installed") : _("Zapret2 package is not installed"), + value: "" + }, + { + state: hasZapret2Rules && !providerAvailable ? "error" : "success", + key: hasZapret2Rules ? _("There are rules using Zapret2") : _("No rules use Zapret2"), + value: "" + }, + { + state: unexpectedRuntime || !tachyonRuntimeReady ? "error" : "success", + key: hasZapret2Rules ? tachyonRuntimeReady ? _("Tachyon-managed nfqws2 runtime is ready") : _("Tachyon-managed nfqws2 runtime is not ready") : unexpectedRuntime ? _("Unexpected Tachyon-managed nfqws2 runtime is running") : _("Tachyon-managed nfqws2 runtime is not running"), + value: hasZapret2Rules ? `${runningProcesses}/${expectedProcesses}` : "" + }, + { + state: queueOverlap ? "error" : "success", + key: queueOverlap ? _("NFQUEUE range overlaps with another rule") : _("NFQUEUE range is available"), + value: `${Number(data.queue_base || 0)}-${Number(data.queue_range_end || 0)}` + }, + { + state: !hasZapret2Rules || outboundsConfigured ? "success" : "error", + key: outboundsConfigured ? _("Zapret2 sing-box outbound is configured") : _("Zapret2 sing-box outbound is not configured"), + value: "" + }, + { + state: standaloneConflict ? "error" : standaloneAutostartRisk ? "warning" : "success", + key: standaloneServiceRunning ? hasZapret2Rules ? _("Standalone Zapret2 is active together with Tachyon Zapret2 rules") : _("Standalone Zapret2 service is active") : standaloneAutostartRisk ? _("Standalone Zapret2 autostart is enabled") : _("Standalone Zapret2 service is inactive"), + value: "" + } + ]; + const { state, description } = getCheckItemsMeta(items); + updateCheckStore({ + order, + code, + title, + description, + state, + items + }); +} + +// src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts +async function runByedpiCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.BYEDPI; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const byedpiStatus = await TachyonShellMethods.getByedpiStatus(); + if (!byedpiStatus.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("ByeDPI checks failed"); + } + const data = byedpiStatus.data; + const providerAvailable = Boolean(data.provider_available ?? data.installed); + const packageInstalled = Boolean(data.package_installed); + const hasByedpiRules = Number(data.enabled_rule_count || 0) > 0; + const expectedProcesses = Number(data.expected_process_count || 0); + const runningProcesses = Number(data.running_process_count || 0); + const supervisorProcesses = Number(data.supervisor_process_count || 0); + const restartCount = Number(data.restart_count || 0); + const runtimeUnstable = Boolean(data.runtime_unstable); + const tachyonRuntimeReady = !hasByedpiRules || runningProcesses === expectedProcesses && supervisorProcesses === expectedProcesses; + const unexpectedRuntime = !hasByedpiRules && (runningProcesses > 0 || supervisorProcesses > 0); + const outboundsConfigured = Boolean(data.outbounds_configured); + const standaloneServiceEnabled = Boolean(data.standalone_service_enabled); + const standaloneServiceRunning = Boolean(data.standalone_service_running); + const standaloneConflict = hasByedpiRules && standaloneServiceRunning; + const standaloneAutostartRisk = hasByedpiRules && standaloneServiceEnabled && !standaloneServiceRunning; + const items = [ + { + state: providerAvailable ? "success" : hasByedpiRules ? "error" : "warning", + key: providerAvailable ? _("ByeDPI provider binary is available") : _("ByeDPI provider binary is not available"), + value: data.provider_path || "" + }, + { + state: packageInstalled ? "success" : "warning", + key: packageInstalled ? _("ByeDPI package is installed") : _("ByeDPI package is not installed"), + value: "" + }, + { + state: hasByedpiRules && !providerAvailable ? "error" : "success", + key: hasByedpiRules ? _("There are rules using ByeDPI") : _("No rules use ByeDPI"), + value: "" + }, + { + state: unexpectedRuntime || !tachyonRuntimeReady ? "error" : runtimeUnstable ? "warning" : "success", + key: hasByedpiRules ? runtimeUnstable ? _("Tachyon-managed ciadpi runtime has restarted") : tachyonRuntimeReady ? _("Tachyon-managed ciadpi runtime is ready") : _("Tachyon-managed ciadpi runtime is not ready") : unexpectedRuntime ? _("Unexpected Tachyon-managed ciadpi runtime is running") : _("Tachyon-managed ciadpi runtime is not running"), + value: hasByedpiRules ? runtimeUnstable ? `${restartCount}` : `${runningProcesses}/${expectedProcesses}` : "" + }, + { + state: !hasByedpiRules || outboundsConfigured ? "success" : "error", + key: outboundsConfigured ? _("ByeDPI sing-box outbound is configured") : _("ByeDPI sing-box outbound is not configured"), + value: `${data.listen_address}:${Number(data.port_base || 0)}` + }, + { + state: standaloneConflict ? "error" : standaloneAutostartRisk ? "warning" : "success", + key: standaloneServiceRunning ? hasByedpiRules ? _("Standalone ByeDPI is active together with Tachyon ByeDPI rules") : _("Standalone ByeDPI service is active") : standaloneAutostartRisk ? _("Standalone ByeDPI autostart is enabled") : _("Standalone ByeDPI service is inactive"), + value: "" + } + ]; + const { state, description } = getCheckItemsMeta(items); + updateCheckStore({ + order, + code, + title, + description, + state, + items + }); +} + +// src/tachyon/services/systemInfo.service.ts +var UNKNOWN_SYSTEM_INFO = { + loading: false, + loaded: false, + providerInfoLoaded: false, + tachyon_version: _("unknown"), + tachyon_latest_version: _("unknown"), + luci_app_version: _("unknown"), + sing_box_version: _("unknown"), + sing_box_extended: 0, + sing_box_tiny: 0, + sing_box_compressed: 0, + sing_box_tailscale: 1, + zapret_version: _("unknown"), + zapret_installed: 0, + zapret2_version: _("unknown"), + zapret2_installed: 0, + byedpi_version: _("unknown"), + byedpi_installed: 0, + server_inbounds_enabled_count: -1, + openwrt_version: _("unknown"), + device_model: _("unknown") +}; +var systemInfoPromise = null; +var latestSystemInfoRequestId = 0; +function hasLoadedSystemInfo() { + const systemInfo = store.get().diagnosticsSystemInfo; + return Boolean(systemInfo.loaded) && !systemInfo.loading; +} +async function ensureSystemInfo({ + force = false, + silent = false +} = {}) { + if (!force && hasLoadedSystemInfo()) { + return store.get().diagnosticsSystemInfo; + } + if (systemInfoPromise && !force) { + return systemInfoPromise; + } + const requestId = ++latestSystemInfoRequestId; + const currentSystemInfo = store.get().diagnosticsSystemInfo; + if (!silent) { + store.set({ + diagnosticsSystemInfo: { + ...currentSystemInfo, + loading: true + } + }); + } + const promise = (async () => { + try { + const systemInfo = await TachyonShellMethods.getSystemInfo(); + if (requestId !== latestSystemInfoRequestId) { + return store.get().diagnosticsSystemInfo; + } + if (systemInfo.success) { + const nextSystemInfo = normalizeSingBoxVariantFields({ + ...UNKNOWN_SYSTEM_INFO, + loading: false, + loaded: true, + providerInfoLoaded: true, + server_inbounds_enabled_count: currentSystemInfo.server_inbounds_enabled_count, + ...systemInfo.data + }); + store.set({ + diagnosticsSystemInfo: nextSystemInfo + }); + return nextSystemInfo; + } + } catch (error) { + logger.error("[SYSTEM_INFO]", "ensureSystemInfo failed", error); + } + if (requestId === latestSystemInfoRequestId && !silent) { + const latestSystemInfo = store.get().diagnosticsSystemInfo; + const nextSystemInfo = { + ...UNKNOWN_SYSTEM_INFO, + loading: false, + loaded: false, + providerInfoLoaded: latestSystemInfo.providerInfoLoaded, + zapret_installed: latestSystemInfo.zapret_installed, + zapret2_installed: latestSystemInfo.zapret2_installed, + byedpi_installed: latestSystemInfo.byedpi_installed, + server_inbounds_enabled_count: latestSystemInfo.server_inbounds_enabled_count + }; + store.set({ + diagnosticsSystemInfo: nextSystemInfo + }); + return nextSystemInfo; + } + return store.get().diagnosticsSystemInfo; + })(); + systemInfoPromise = promise; + try { + return await promise; + } finally { + if (systemInfoPromise === promise) { + systemInfoPromise = null; + } + } +} + +// src/partials/button/styles.ts +var styles2 = ` +.fkp-partial-button { + text-align: center; +} + +.fkp-partial-button--with-icon { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; +} + +.fkp-partial-button--loading { +} + +.fkp-partial-button--disabled { +} + +.fkp-partial-button__icon { + flex: 0 0 auto; +} + +.fkp-partial-button__icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.fkp-partial-button__icon svg { + width: 16px; + height: 16px; + display: block; + flex: 0 0 auto; +} +`; + +// src/partials/modal/styles.ts +var styles3 = ` + +.fkp-partial-modal__body {} + +.fkp-partial-modal__content { + max-height: 70vh; + overflow: scroll; + border-radius: 4px; +} + +.fkp-partial-modal__footer { + display: flex; + justify-content: flex-end; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.fkp-partial-modal__footer button { + margin-left: 0; +} + +.fkp-partial-modal__checkbox { + display: inline-flex; + align-items: center; + gap: 8px; + margin-right: auto; + cursor: pointer; + user-select: none; +} + +.fkp-partial-modal__checkbox-text { + line-height: 1.2; +} +`; + +// src/partials/button/renderButton.ts +function renderButton({ + classNames = [], + disabled, + loading: loading2, + onClick, + text, + icon +}) { + const hasIcon = !!loading2 || !!icon; + function getWrappedIcon() { + const iconWrap = E("span", { + class: "fkp-partial-button__icon" + }); + if (loading2) { + iconWrap.appendChild(renderLoaderCircleIcon24()); + return iconWrap; + } + if (icon) { + iconWrap.appendChild(icon()); + return iconWrap; + } + return iconWrap; + } + function getClass() { + return [ + "btn", + "fkp-partial-button", + ...insertIf(Boolean(disabled), ["fkp-partial-button--disabled"]), + ...insertIf(Boolean(loading2), ["fkp-partial-button--loading"]), + ...insertIf(Boolean(hasIcon), ["fkp-partial-button--with-icon"]), + ...classNames + ].filter(Boolean).join(" "); + } + function getDisabled() { + if (loading2 || disabled) { + return true; + } + return void 0; + } + return E( + "button", + { + type: "button", + class: getClass(), + disabled: getDisabled(), + click: onClick + }, + [...insertIf(hasIcon, [getWrappedIcon()]), E("span", {}, text)] + ); +} + +// src/partials/modal/renderModal.ts +function renderModal(text, name, options) { + let rawText = text ?? ""; + let currentText = ""; + let refreshInFlight = false; + let pendingRefresh = false; + let pendingForcedRefresh = false; + let refreshSessionId = 0; + let timer; + let observer; + let autoRefreshEnabled = options?.initialAutoRefresh ?? Boolean(options?.getText); + let maskValuesEnabled = options?.initialMaskValues ?? true; + let shouldScrollToBottomOnMount = Boolean(options?.startAtEnd); + let autoRefreshInput; + let maskValuesInput; + const getDisplayText = (value) => { + if (maskValuesEnabled && options?.maskText) { + return options.maskText(value); + } + return value; + }; + const codeEl = E("code", {}, ""); + const contentEl = E( + "pre", + { class: "fkp-partial-modal__content" }, + codeEl + ); + const stopRefreshTimer = () => { + if (timer) { + clearInterval(timer); + timer = void 0; + } + }; + const destroyLiveRefresh = () => { + refreshSessionId += 1; + pendingRefresh = false; + pendingForcedRefresh = false; + stopRefreshTimer(); + observer?.disconnect(); + observer = void 0; + }; + const scrollToBottom = () => { + contentEl.scrollTop = contentEl.scrollHeight; + }; + const scheduleInitialScrollToBottom = () => { + if (!shouldScrollToBottomOnMount || !body.isConnected) { + return; + } + shouldScrollToBottomOnMount = false; + requestAnimationFrame(() => { + scrollToBottom(); + }); + }; + const updateText = (nextText) => { + const normalizedText = nextText ?? ""; + const shouldStickToBottom = shouldScrollToBottomOnMount || contentEl.scrollTop + contentEl.clientHeight >= contentEl.scrollHeight - 16; + if (normalizedText === currentText) { + if (shouldStickToBottom) { + requestAnimationFrame(() => { + scrollToBottom(); + }); + } + return; + } + currentText = normalizedText; + codeEl.textContent = currentText; + if (shouldStickToBottom) { + requestAnimationFrame(() => { + scrollToBottom(); + }); + } + }; + const updateDisplayedTextFromRaw = () => { + updateText(getDisplayText(rawText)); + }; + const refreshText = async (force = false) => { + if (!options?.getText || !force && !autoRefreshEnabled || refreshInFlight) { + return; + } + if (!body.isConnected) { + return; + } + refreshInFlight = true; + const sessionId = refreshSessionId; + try { + const nextText = await options.getText({ + maskValues: options.maskText ? false : maskValuesEnabled + }); + if (!body.isConnected || !force && !autoRefreshEnabled) { + return; + } + if (sessionId !== refreshSessionId) { + return; + } + const normalizedText = nextText ?? ""; + rawText = normalizedText; + updateText(getDisplayText(normalizedText)); + } catch (error) { + console.warn("[renderModal] failed to refresh modal content", error); + } finally { + refreshInFlight = false; + if (pendingRefresh) { + const shouldForceRefresh = pendingForcedRefresh; + pendingRefresh = false; + pendingForcedRefresh = false; + if ((shouldForceRefresh || autoRefreshEnabled) && body.isConnected) { + void refreshText(shouldForceRefresh); + } + } + } + }; + const requestRefresh = () => { + if (!options?.getText || !autoRefreshEnabled) { + return; + } + if (refreshInFlight) { + pendingRefresh = true; + return; + } + void refreshText(); + }; + const requestForcedRefresh = () => { + if (!options?.getText) { + return; + } + if (refreshInFlight) { + pendingRefresh = true; + pendingForcedRefresh = true; + return; + } + void refreshText(true); + }; + const startRefreshTimer = () => { + if (!options?.getText || !autoRefreshEnabled || timer || typeof document === "undefined") { + return; + } + timer = setInterval(() => { + requestRefresh(); + }, options.refreshMs ?? 3e3); + }; + const setAutoRefreshEnabled = (nextValue) => { + autoRefreshEnabled = nextValue; + refreshSessionId += 1; + pendingRefresh = false; + pendingForcedRefresh = false; + if (autoRefreshInput) { + autoRefreshInput.checked = nextValue; + } + if (nextValue) { + startRefreshTimer(); + requestRefresh(); + return; + } + stopRefreshTimer(); + }; + const setMaskValuesEnabled = (nextValue) => { + maskValuesEnabled = nextValue; + refreshSessionId += 1; + pendingRefresh = false; + pendingForcedRefresh = false; + if (maskValuesInput) { + maskValuesInput.checked = nextValue; + } + if (options?.maskText) { + updateDisplayedTextFromRaw(); + return; + } + requestForcedRefresh(); + }; + const footerChildren = [ + renderButton({ + classNames: ["cbi-button-apply"], + text: _("Download"), + onClick: () => downloadAsTxt(currentText, name) + }), + renderButton({ + classNames: ["cbi-button-apply"], + text: _("Copy"), + onClick: () => copyToClipboard(`\`\`\`${name} +${currentText} +\`\`\``) + }), + renderButton({ + classNames: ["cbi-button-remove"], + text: _("Close"), + onClick: () => { + destroyLiveRefresh(); + ui.hideModal(); + } + }) + ]; + if (options?.getText && options?.showAutoRefreshToggle) { + autoRefreshInput = document.createElement("input"); + autoRefreshInput.type = "checkbox"; + autoRefreshInput.className = "cbi-input-checkbox"; + autoRefreshInput.checked = autoRefreshEnabled; + autoRefreshInput.addEventListener("change", () => { + setAutoRefreshEnabled(autoRefreshInput.checked); + }); + footerChildren.unshift( + E("label", { class: "fkp-partial-modal__checkbox" }, [ + autoRefreshInput, + E( + "span", + { class: "fkp-partial-modal__checkbox-text" }, + options.autoRefreshLabel ?? _("Auto refresh") + ) + ]) + ); + } + if ((options?.getText || options?.maskText) && options?.showMaskValuesToggle) { + maskValuesInput = document.createElement("input"); + maskValuesInput.type = "checkbox"; + maskValuesInput.className = "cbi-input-checkbox"; + maskValuesInput.checked = maskValuesEnabled; + maskValuesInput.addEventListener("change", () => { + setMaskValuesEnabled(maskValuesInput.checked); + }); + footerChildren.unshift( + E("label", { class: "fkp-partial-modal__checkbox" }, [ + maskValuesInput, + E( + "span", + { class: "fkp-partial-modal__checkbox-text" }, + options.maskValuesLabel ?? _("Hide values") + ) + ]) + ); + } + const body = E("div", { class: "fkp-partial-modal__body" }, [ + E("div", {}, [ + contentEl, + E("div", { class: "fkp-partial-modal__footer" }, footerChildren) + ]) + ]); + if ((options?.getText || options?.startAtEnd) && typeof document !== "undefined") { + observer = new MutationObserver(() => { + if (!body.isConnected) { + destroyLiveRefresh(); + return; + } + scheduleInitialScrollToBottom(); + }); + observer.observe(document.body, { + childList: true, + subtree: true + }); + scheduleInitialScrollToBottom(); + } + if (options?.getText && typeof document !== "undefined") { + startRefreshTimer(); + requestRefresh(); + } + updateDisplayedTextFromRaw(); + return body; +} + +// src/partials/index.ts +var PartialStyles = ` +${styles2} +${styles3} +`; + +// src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts +function renderAvailableActions({ + restart, + start, + stop, + enable, + disable, + globalCheck, + viewLogs, + showSingBoxConfig +}) { + return E("div", { class: "fkp_diagnostic-page__right-bar__actions" }, [ + E("b", {}, _("Available actions")), + ...insertIf(restart.visible, [ + renderButton({ + classNames: ["cbi-button-apply"], + onClick: restart.onClick, + icon: renderRotateCcwIcon24, + text: _("Restart Tachyon"), + loading: restart.loading, + disabled: restart.disabled + }) + ]), + ...insertIf(stop.visible, [ + renderButton({ + classNames: ["cbi-button-remove"], + onClick: stop.onClick, + icon: renderCircleStopIcon24, + text: _("Stop Tachyon"), + loading: stop.loading, + disabled: stop.disabled + }) + ]), + ...insertIf(start.visible, [ + renderButton({ + classNames: ["cbi-button-save"], + onClick: start.onClick, + icon: renderCirclePlayIcon24, + text: _("Start Tachyon"), + loading: start.loading, + disabled: start.disabled + }) + ]), + ...insertIf(disable.visible, [ + renderButton({ + classNames: ["cbi-button-remove"], + onClick: disable.onClick, + icon: renderPauseIcon24, + text: _("Disable autostart"), + loading: disable.loading, + disabled: disable.disabled + }) + ]), + ...insertIf(enable.visible, [ + renderButton({ + classNames: ["cbi-button-save"], + onClick: enable.onClick, + icon: renderPlayIcon24, + text: _("Enable autostart"), + loading: enable.loading, + disabled: enable.disabled + }) + ]), + ...insertIf(globalCheck.visible, [ + renderButton({ + onClick: globalCheck.onClick, + icon: renderCircleCheckBigIcon24, + text: _("Get global check"), + loading: globalCheck.loading, + disabled: globalCheck.disabled + }) + ]), + ...insertIf(viewLogs.visible, [ + renderButton({ + onClick: viewLogs.onClick, + icon: renderSquareChartGanttIcon24, + text: _("View logs"), + loading: viewLogs.loading, + disabled: viewLogs.disabled + }) + ]), + ...insertIf(showSingBoxConfig.visible, [ + renderButton({ + onClick: showSingBoxConfig.onClick, + icon: renderCogIcon24, + text: _("Show sing-box config"), + loading: showSingBoxConfig.loading, + disabled: showSingBoxConfig.disabled + }) + ]) + ]); +} + +// src/tachyon/tabs/diagnostic/partials/renderCheckSection.ts +function renderCheckSummary(items) { + if (!items.length) { + return E("div", {}, ""); + } + const renderedItems = items.map((item) => { + function getIcon() { + const iconWrap = E("span", { + class: "fkp_diagnostic_alert__summary__item__icon" + }); + if (item.state === "success") { + iconWrap.appendChild(renderCheckIcon24()); + } + if (item.state === "warning") { + iconWrap.appendChild(renderTriangleAlertIcon24()); + } + if (item.state === "error") { + iconWrap.appendChild(renderXIcon24()); + } + return iconWrap; + } + return E( + "div", + { + class: `fkp_diagnostic_alert__summary__item fkp_diagnostic_alert__summary__item--${item.state}` + }, + [getIcon(), E("b", {}, item.key), E("div", {}, item.value)] + ); + }); + return E("div", { class: "fkp_diagnostic_alert__summary" }, renderedItems); +} +function renderLoadingState3(props) { + const iconWrap = E("span", { class: "fkp_diagnostic_alert__icon" }); + iconWrap.appendChild(renderLoaderCircleIcon24()); + return E( + "div", + { class: "fkp_diagnostic_alert fkp_diagnostic_alert--loading" }, + [ + iconWrap, + E("div", { class: "fkp_diagnostic_alert__content" }, [ + E("b", { class: "fkp_diagnostic_alert__title" }, props.title), + E( + "div", + { class: "fkp_diagnostic_alert__description" }, + props.description + ) + ]), + E("div", {}, ""), + renderCheckSummary(props.items) + ] + ); +} +function renderWarningState(props) { + const iconWrap = E("span", { class: "fkp_diagnostic_alert__icon" }); + iconWrap.appendChild(renderCircleAlertIcon24()); + return E( + "div", + { class: "fkp_diagnostic_alert fkp_diagnostic_alert--warning" }, + [ + iconWrap, + E("div", { class: "fkp_diagnostic_alert__content" }, [ + E("b", { class: "fkp_diagnostic_alert__title" }, props.title), + E( + "div", + { class: "fkp_diagnostic_alert__description" }, + props.description + ) + ]), + E("div", {}, ""), + renderCheckSummary(props.items) + ] + ); +} +function renderErrorState(props) { + const iconWrap = E("span", { class: "fkp_diagnostic_alert__icon" }); + iconWrap.appendChild(renderCircleXIcon24()); + return E( + "div", + { class: "fkp_diagnostic_alert fkp_diagnostic_alert--error" }, + [ + iconWrap, + E("div", { class: "fkp_diagnostic_alert__content" }, [ + E("b", { class: "fkp_diagnostic_alert__title" }, props.title), + E( + "div", + { class: "fkp_diagnostic_alert__description" }, + props.description + ) + ]), + E("div", {}, ""), + renderCheckSummary(props.items) + ] + ); +} +function renderSuccessState(props) { + const iconWrap = E("span", { class: "fkp_diagnostic_alert__icon" }); + iconWrap.appendChild(renderCircleCheckIcon24()); + return E( + "div", + { class: "fkp_diagnostic_alert fkp_diagnostic_alert--success" }, + [ + iconWrap, + E("div", { class: "fkp_diagnostic_alert__content" }, [ + E("b", { class: "fkp_diagnostic_alert__title" }, props.title), + E( + "div", + { class: "fkp_diagnostic_alert__description" }, + props.description + ) + ]), + E("div", {}, ""), + renderCheckSummary(props.items) + ] + ); +} +function renderSkippedState(props) { + const iconWrap = E("span", { class: "fkp_diagnostic_alert__icon" }); + iconWrap.appendChild(renderCircleSlashIcon24()); + return E( + "div", + { class: "fkp_diagnostic_alert fkp_diagnostic_alert--skipped" }, + [ + iconWrap, + E("div", { class: "fkp_diagnostic_alert__content" }, [ + E("b", { class: "fkp_diagnostic_alert__title" }, props.title), + E( + "div", + { class: "fkp_diagnostic_alert__description" }, + props.description + ) + ]), + E("div", {}, ""), + renderCheckSummary(props.items) + ] + ); +} +function renderCheckSection(props) { + if (props.state === "loading") { + return renderLoadingState3(props); + } + if (props.state === "warning") { + return renderWarningState(props); + } + if (props.state === "error") { + return renderErrorState(props); + } + if (props.state === "success") { + return renderSuccessState(props); + } + if (props.state === "skipped") { + return renderSkippedState(props); + } + return E("div", {}, _("Not implement yet")); +} + +// src/tachyon/tabs/diagnostic/partials/renderRunAction.ts +function renderRunAction({ + loading: loading2, + disabled, + click +}) { + return E("div", { class: "fkp_diagnostic-page__run_check_wrapper" }, [ + renderButton({ + text: _("Run Diagnostic"), + onClick: click, + icon: renderSearchIcon24, + loading: loading2, + disabled, + classNames: ["cbi-button-apply"] + }) + ]); +} + +// src/tachyon/tabs/diagnostic/partials/renderSystemInfo.ts +function renderSystemInfo({ items }) { + return E("div", { class: "fkp_diagnostic-page__right-bar__system-info" }, [ + E( + "b", + { class: "fkp_diagnostic-page__right-bar__system-info__title" }, + _("System information") + ), + ...items.map((item) => { + const tagClass = [ + "fkp_diagnostic-page__right-bar__system-info__row__tag", + ...insertIf(item.tag?.kind === "neutral", [ + "fkp_diagnostic-page__right-bar__system-info__row__tag--neutral" + ]), + ...insertIf(item.tag?.kind === "warning", [ + "fkp_diagnostic-page__right-bar__system-info__row__tag--warning" + ]), + ...insertIf(item.tag?.kind === "success", [ + "fkp_diagnostic-page__right-bar__system-info__row__tag--success" + ]) + ].filter(Boolean).join(" "); + return E( + "div", + { class: "fkp_diagnostic-page__right-bar__system-info__row" }, + [ + E("b", {}, item.key), + E("div", {}, [ + E("span", {}, item.value), + E("span", { class: tagClass }, item?.tag?.label) + ]) + ] + ); + }) + ]); +} + +// src/helpers/normalizeCompiledVersion.ts +function normalizeCompiledVersion(version) { + if (version.includes("COMPILED")) { + return "dev"; + } + return version; +} + +// src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts +function renderWikiDisclaimer(kind) { + const iconWrap = E("span", { + class: "fkp_diagnostic-page__right-bar__wiki__icon" + }); + iconWrap.appendChild(renderBookOpenTextIcon24()); + const className = [ + "fkp_diagnostic-page__right-bar__wiki", + ...insertIf(kind === "error", [ + "fkp_diagnostic-page__right-bar__wiki--error" + ]), + ...insertIf(kind === "warning", [ + "fkp_diagnostic-page__right-bar__wiki--warning" + ]) + ].join(" "); + return E("div", { class: className }, [ + E("div", { class: "fkp_diagnostic-page__right-bar__wiki__content" }, [ + iconWrap, + E("div", { class: "fkp_diagnostic-page__right-bar__wiki__texts" }, [ + E("b", {}, _("Troubleshooting")), + E("div", {}, _("Do not panic, everything can be fixed, just...")) + ]) + ]), + renderButton({ + classNames: ["cbi-button-save"], + text: _("Open Project Page"), + onClick: () => window.open( + "https://github.com/ushan0v/tachyon#readme", + "_blank", + "noopener,noreferrer" + ) + }) + ]); +} + +// src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts +function getSubscriptionLatencyState(latencyValues) { + const hasAvailableLatency = latencyValues.some((item) => Boolean(item)); + const hasUnavailableLatency = latencyValues.some((item) => !item); + if (!hasAvailableLatency) { + return "error"; + } + if (hasUnavailableLatency) { + return "warning"; + } + return "success"; +} +async function runSectionsCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const sections = await getDashboardSections({ + includeSubscriptionCopyState: false + }); + if (!sections.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("Rule outbounds checks failed"); + } + const items = []; + for (const section of sections.data) { + async function getLatency() { + if (section.withTagSelect) { + const selectedOutbound2 = section.outbounds.find((item) => item.selected) ?? section.outbounds.find( + (item) => item.type?.toLowerCase() === "urltest" + ) ?? section.outbounds[0]; + const isSubscription = section.proxyConfigType === "subscription"; + if (selectedOutbound2?.code) { + const latencyProxy2 = await TachyonShellMethods.getClashApiProxyLatency( + selectedOutbound2.code, + section.latencyTestTimeout + ); + const proxySuccess = latencyProxy2.success && !latencyProxy2.data.message; + if (proxySuccess) { + return { + state: "success", + latency: `[${selectedOutbound2.displayName ?? ""}] ${latencyProxy2.data.delay}ms` + }; + } + return { + state: "error", + latency: `[${selectedOutbound2.displayName ?? ""}] ${_("Not responding")}` + }; + } + const latencyGroup = await TachyonShellMethods.getClashApiGroupLatency( + section.code + ); + const success2 = latencyGroup.success && !latencyGroup.data.message; + if (success2) { + const latencyValues = Object.values(latencyGroup.data); + const sectionState = isSubscription ? getSubscriptionLatencyState(latencyValues) : "success"; + const selectedProxyDelay = latencyGroup.data?.[selectedOutbound2?.code ?? ""]; + if (selectedProxyDelay) { + return { + state: sectionState, + latency: `[${selectedOutbound2?.displayName ?? ""}] ${selectedProxyDelay}ms` + }; + } + return { + state: "error", + latency: `[${selectedOutbound2?.displayName ?? ""}] ${_("Not responding")}` + }; + } + return { + state: "error", + latency: _("Not responding") + }; + } + const selectedOutbound = section.outbounds[0]; + const latencyProxy = await TachyonShellMethods.getClashApiProxyLatency( + section.code, + section.latencyTestTimeout + ); + const success = latencyProxy.success && !latencyProxy.data.message; + if (success) { + return { + state: "success", + latency: `${latencyProxy.data.delay} ms` + }; + } + if (section.action === "vpn" && selectedOutbound?.runtimeAvailable) { + return { + state: "warning", + latency: `[${selectedOutbound.displayName || section.code}] ${_("Connectivity probe failed")}` + }; + } + return { + state: "error", + latency: _("Not responding") + }; + } + const { latency, state: state2 } = await getLatency(); + items.push({ + state: state2, + key: section.displayName, + value: latency + }); + } + const allGood = items.every((item) => item.state === "success"); + const atLeastOneGood = items.some((item) => item.state !== "error"); + const { state, description } = getMeta({ atLeastOneGood, allGood }); + updateCheckStore({ + order, + code, + title, + description, + state, + items + }); + if (!atLeastOneGood) { + throw new Error("Rule outbounds checks failed"); + } +} + +// src/tachyon/tabs/diagnostic/serviceTransition.ts +function isServiceTransitionStatus(status) { + return ["starting", "stopping", "restarting", "reloading"].includes(status); +} +function getServiceTransition(status) { + return { + starting: status === "starting", + stopping: status === "stopping", + restarting: status === "restarting" || status === "reloading" + }; +} +function hasLocalMutatingServiceActionLoading(actions) { + return actions.restart.loading || actions.start.loading || actions.stop.loading || actions.enable.loading || actions.disable.loading; +} +function shouldSkipServicesInfoAutoRefresh({ + force, + localMutatingActionLoading +}) { + return !force && localMutatingActionLoading; +} +function shouldResetDiagnosticsChecks({ + resetChecks, + diagnosticsRunLoading +}) { + return resetChecks && !diagnosticsRunLoading; +} +function shouldDisableDiagnosticRunAction({ + providerInfoLoaded, + servicesInfoLoading, + tachyonRunning, + mutatingServiceActionLoading +}) { + return !providerInfoLoaded || servicesInfoLoading || !tachyonRunning || mutatingServiceActionLoading; +} +function hasComponentActionLoading(actions) { + return Object.values(actions).some((action) => action.loading); +} +function getAvailableActionsDisabledState({ + servicesInfoLoading, + mutatingServiceActionLoading, + componentActionLoading +}) { + return { + serviceControlsDisabled: servicesInfoLoading || mutatingServiceActionLoading || componentActionLoading, + utilityActionsDisabled: mutatingServiceActionLoading || componentActionLoading, + viewLogsDisabled: false + }; +} +function shouldShowRestartAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading +}) { + return restartLoading || tachyonRunning && !startLoading && !stopLoading; +} +function shouldShowStartAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading +}) { + return startLoading || !restartLoading && !tachyonRunning && !stopLoading; +} +function shouldShowStopAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading +}) { + return stopLoading || restartLoading || tachyonRunning && !startLoading; +} + +// src/tachyon/tabs/diagnostic/diagnosticRunPersistence.ts +var DIAGNOSTIC_RUN_STORAGE_KEY = "tachyon:diagnostic-run:v1"; +var DIAGNOSTIC_RUN_TTL_MS = 30 * 60 * 1e3; +var CHECK_STATES = ["loading", "warning", "success", "error", "skipped"]; +var CHECK_ITEM_STATES = ["error", "warning", "success"]; +function getSessionStorage3() { + if (typeof window === "undefined") { + return null; + } + try { + return window.sessionStorage; + } catch { + return null; + } +} +function isRecord(value) { + return Boolean(value) && typeof value === "object"; +} +function isOptionalBoolean(value) { + return value === void 0 || typeof value === "boolean"; +} +function isDiagnosticsProviderOptions(value) { + if (!isRecord(value)) { + return false; + } + return isOptionalBoolean(value.includeZapret) && isOptionalBoolean(value.includeZapret2) && isOptionalBoolean(value.includeByedpi) && isOptionalBoolean(value.includeInbounds); +} +function isDiagnosticCheckItem(value) { + return isRecord(value) && CHECK_ITEM_STATES.includes(String(value.state)) && typeof value.key === "string" && typeof value.value === "string"; +} +function isDiagnosticCheck(value) { + return isRecord(value) && Number.isFinite(value.order) && Object.values(DIAGNOSTICS_CHECKS).includes( + value.code + ) && typeof value.title === "string" && typeof value.description === "string" && CHECK_STATES.includes(String(value.state)) && Array.isArray(value.items) && value.items.every(isDiagnosticCheckItem); +} +function isPersistedDiagnosticRun(value) { + if (!isRecord(value)) { + return false; + } + return typeof value.nextRunnerIndex === "number" && Number.isInteger(value.nextRunnerIndex) && value.nextRunnerIndex >= 0 && isDiagnosticsProviderOptions(value.providerOptions) && Array.isArray(value.diagnosticsChecks) && value.diagnosticsChecks.every(isDiagnosticCheck) && Number.isFinite(value.updatedAt); +} +function isExpired(run, now = Date.now()) { + return now - run.updatedAt > DIAGNOSTIC_RUN_TTL_MS; +} +function readPersistedDiagnosticRun(storage = getSessionStorage3()) { + if (!storage) { + return null; + } + try { + const parsed = JSON.parse( + storage.getItem(DIAGNOSTIC_RUN_STORAGE_KEY) || "null" + ); + if (!isPersistedDiagnosticRun(parsed) || isExpired(parsed)) { + storage.removeItem(DIAGNOSTIC_RUN_STORAGE_KEY); + return null; + } + return parsed; + } catch { + storage.removeItem(DIAGNOSTIC_RUN_STORAGE_KEY); + return null; + } +} +function savePersistedDiagnosticRun(run, storage = getSessionStorage3()) { + if (!storage) { + return; + } + try { + storage.setItem( + DIAGNOSTIC_RUN_STORAGE_KEY, + JSON.stringify({ + ...run, + updatedAt: Date.now() + }) + ); + } catch { + } +} +function clearPersistedDiagnosticRun(storage = getSessionStorage3()) { + if (!storage) { + return; + } + try { + storage.removeItem(DIAGNOSTIC_RUN_STORAGE_KEY); + } catch { + } +} + +// src/tachyon/tabs/diagnostic/helpers/maskDiagnostics.ts +var MASKED_VALUE = "MASKED"; +var SING_BOX_MASKED_KEYS = /* @__PURE__ */ new Set([ + "auth_key", + "control_url", + "exit_node", + "hostname", + "listen", + "listen_port", + "username", + "uuid", + "server", + "server_name", + "secret", + "password", + "private_key", + "public_key", + "short_id", + "fingerprint", + "server_port", + "server_ports", + "advertise_routes", + "domain", + "domain_suffix", + "domain_keyword", + "domain_regex", + "ip_cidr", + "source_ip_cidr" +]); +var TACHYON_MASK_AFTER_TOKEN = [ + "option proxy_string", + "option hwid", + "option subscription_url", + "list subscription_urls", + "list urltest_proxy_links", + "list selector_proxy_links", + "list server_users", + "option server_uuid", + "option server_username", + "option server_password", + "option mtproto_secret", + "option hysteria2_obfs_password", + "option reality_private_key", + "option reality_public_key", + "option reality_short_id", + "list reality_short_id", + "option yacd_secret_key" +]; +var TACHYON_MASK_AFTER_TOKEN_SPACE = [ + "option outbound_json", + "list domain", + "list domain_suffix", + "list domain_keyword", + "list domain_regex", + "list ip_cidr", + "list source_ip_cidr", + "list fully_routed_ips", + "option dns_server", + "option bootstrap_dns_server", + "list dns_server", + "list bootstrap_dns_server", + "option listen", + "option listen_port", + "option public_host", + "option mtproto_faketls", + "option mtproto_domain_fronting_ip", + "option tls_server_name", + "option reality_handshake_server", + "option reality_handshake_server_port", + "option transport_host", + "list transport_hosts", + "option tailscale_auth_key", + "option tailscale_control_url", + "option tailscale_hostname", + "list tailscale_advertise_routes", + "option tailscale_ephemeral", + "option tailscale_exit_node", + "option tailscale_exit_node_allow_lan_access", + "option mixed_proxy_username", + "option mixed_proxy_password", + "option ipaddr", + "option netmask", + "option gateway", + "option username", + "option password" +]; +function isRecord2(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} +function isSpaceChar(value) { + return value === " " || value === " " || value === "\r" || value === "\n"; +} +function maskAfterToken(line, token) { + const position = line.indexOf(token); + return position < 0 ? line : `${line.slice(0, position)}${token} '${MASKED_VALUE}'`; +} +function maskAfterTokenSpace(line, token) { + const position = line.indexOf(token); + if (position < 0) { + return line; + } + const spacePosition = position + token.length; + if (spacePosition >= line.length || !isSpaceChar(line.slice(spacePosition, spacePosition + 1))) { + return line; + } + return `${line.slice(0, spacePosition + 1)}'${MASKED_VALUE}'`; +} +function maskOptionPath(line, token) { + const position = line.indexOf(token); + if (position < 0) { + return line; + } + const slashOffset = line.slice(position + token.length).indexOf("/"); + if (slashOffset < 0) { + return line; + } + const slash = slashOffset + position + token.length; + const quoteOffset = line.slice(slash + 1).indexOf("'"); + if (quoteOffset < 0) { + return line; + } + const quote = quoteOffset + slash + 1; + return `${line.slice(0, slash)}/MASKED'${line.slice(quote + 1)}`; +} +function maskGlobalCheckLine(line) { + let maskedLine = line; + for (const token of TACHYON_MASK_AFTER_TOKEN) { + maskedLine = maskAfterToken(maskedLine, token); + } + for (const token of TACHYON_MASK_AFTER_TOKEN_SPACE) { + maskedLine = maskAfterTokenSpace(maskedLine, token); + } + maskedLine = maskOptionPath(maskedLine, "option dns_server '"); + maskedLine = maskOptionPath(maskedLine, "list dns_server '"); + return maskedLine; +} +function maskMultilineContinuation(line) { + const leadingSpace = line.match(/^\s*/)?.[0] ?? ""; + const hasClosingQuote = line.includes("'"); + return `${leadingSpace}${MASKED_VALUE}${hasClosingQuote ? "'" : ""}`; +} +function maskSingBoxConfigValue(value) { + if (Array.isArray(value)) { + return value.map((item) => maskSingBoxConfigValue(item)); + } + if (isRecord2(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + SING_BOX_MASKED_KEYS.has(key) ? MASKED_VALUE : maskSingBoxConfigValue(item) + ]) + ); + } + return value; +} +function stringifySingBoxConfig(value) { + return typeof value === "string" ? value : JSON.stringify(value, null, 2); +} +function formatMaskedSingBoxConfig(value) { + if (typeof value === "string") { + try { + return JSON.stringify(maskSingBoxConfigValue(JSON.parse(value)), null, 2); + } catch (_error) { + return value; + } + } + return JSON.stringify(maskSingBoxConfigValue(value), null, 2); +} +function maskGlobalCheckText(text = "") { + let inMaskedMultiline = false; + return `${text}`.split("\n").map((line) => { + if (inMaskedMultiline) { + if (line.includes("'")) { + inMaskedMultiline = false; + } + return maskMultilineContinuation(line); + } + const maskedLine = maskGlobalCheckLine(line); + if (line.includes("option outbound_json")) { + const firstQuote = line.indexOf("'"); + if (firstQuote >= 0 && line.slice(firstQuote + 1).indexOf("'") < 0) { + inMaskedMultiline = true; + } + } + return maskedLine; + }).join("\n"); +} + +// src/tachyon/tabs/diagnostic/initController.ts +var SERVICE_STATUS_REFRESH_INTERVAL_MS = 2e3; +var SERVICE_ACTION_STATUS_TIMEOUT_MS = 45e3; +var latestProviderInfoRequestId = 0; +var diagnosticLifecycleRegistered = false; +var diagnosticControllerInitialized = false; +var diagnosticMounted = false; +var diagnosticMountId = 0; +var diagnosticCompletedWhileHidden = false; +var servicesInfoStateUnsubscribe = null; +var servicesInfoRefreshPromise = null; +var followedServiceActionJobs = /* @__PURE__ */ new Set(); +var handledServiceActionJobs = /* @__PURE__ */ new Set(); +function sleep2(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +function getDiagnosticsProviderOptions(systemInfo = store.get().diagnosticsSystemInfo) { + return { + includeZapret: Boolean(systemInfo.zapret_installed), + includeZapret2: Boolean(systemInfo.zapret2_installed), + includeByedpi: Boolean(systemInfo.byedpi_installed), + includeInbounds: systemInfo.server_inbounds_enabled_count > 0 + }; +} +function getNotRunningDiagnosticsChecks() { + return getDiagnosticsChecks( + _("Not running"), + getDiagnosticsProviderOptions() + ); +} +function resetDiagnosticsChecks() { + store.set({ + diagnosticsChecks: getNotRunningDiagnosticsChecks() + }); +} +function setDiagnosticActionLoading(action, loading2, local = false) { + if (local || !loading2) { + setLocalServiceAction(action, loading2 && local); + } + const diagnosticsActions = store.get().diagnosticsActions; + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + [action]: { loading: loading2 } + } + }); +} +function isDiagnosticMountActive(mountId = diagnosticMountId) { + return diagnosticMounted && diagnosticMountId === mountId; +} +function isLocalMutatingServiceActionLoading() { + const actions = store.get().diagnosticsActions; + return hasLocalMutatingServiceActionLoading(actions); +} +function isMutatingServiceActionLoading() { + return isLocalMutatingServiceActionLoading() || isServiceTransitionStatus(store.get().servicesInfoWidget.data.tachyonStatus); +} +function getTachyonStatusText(running, enabled) { + if (running) { + return enabled ? "running & enabled" : "running but disabled"; + } + return enabled ? "stopped but enabled" : "stopped & disabled"; +} +function setDisplayedTachyonRunning(running) { + const servicesInfoWidget = store.get().servicesInfoWidget; + const enabled = Boolean(servicesInfoWidget.data.tachyonEnabled); + store.set({ + servicesInfoWidget: { + ...servicesInfoWidget, + loading: false, + data: { + ...servicesInfoWidget.data, + tachyonRunning: running ? 1 : 0, + tachyonStatus: getTachyonStatusText(running, enabled) + } + } + }); +} +async function refreshDiagnosticServicesInfo({ + force = false, + mountId = diagnosticMountId, + allowInactive = false +} = {}) { + if (!allowInactive && !isDiagnosticMountActive(mountId)) { + return; + } + if (shouldSkipServicesInfoAutoRefresh({ + force, + localMutatingActionLoading: isLocalMutatingServiceActionLoading() + })) { + return; + } + if (servicesInfoRefreshPromise) { + return servicesInfoRefreshPromise; + } + const promise = fetchServicesInfo().then((uiState) => { + followServiceActionsFromUiState(uiState); + }).catch((error) => { + logger.error( + "[DIAGNOSTIC]", + "refreshDiagnosticServicesInfo failed", + error + ); + }).finally(() => { + if (servicesInfoRefreshPromise === promise) { + servicesInfoRefreshPromise = null; + } + }); + servicesInfoRefreshPromise = promise; + return promise; +} +async function waitForTachyonRunningState(expectedRunning) { + const startedAt = Date.now(); + while (Date.now() - startedAt < SERVICE_ACTION_STATUS_TIMEOUT_MS) { + await refreshDiagnosticServicesInfo({ force: true, allowInactive: true }); + const tachyonRunning = Boolean( + store.get().servicesInfoWidget.data.tachyonRunning + ); + if (tachyonRunning === expectedRunning) { + return true; + } + await sleep2(SERVICE_STATUS_REFRESH_INTERVAL_MS); + } + return false; +} +function startServiceActionStateWatcher() { + if (servicesInfoStateUnsubscribe) { + return; + } + servicesInfoStateUnsubscribe = subscribeRuntimeUiState((uiState) => { + if (diagnosticMounted) { + followServiceActionsFromUiState(uiState); + } + }); +} +function stopServiceActionStateWatcher() { + if (!servicesInfoStateUnsubscribe) { + return; + } + servicesInfoStateUnsubscribe(); + servicesInfoStateUnsubscribe = null; +} +function isVisibleServiceRuntimeAction(action) { + return action === "restart" || action === "start" || action === "stop"; +} +function setServiceActionStateLoading(state, loading2) { + if (!isVisibleServiceRuntimeAction(state.action)) { + return; + } + setDiagnosticActionLoading(state.action, loading2); +} +async function followServiceActionState(state) { + const jobId = state.job_id; + if (!jobId || followedServiceActionJobs.has(jobId)) { + return; + } + if (!state.running && handledServiceActionJobs.has(jobId)) { + return; + } + followedServiceActionJobs.add(jobId); + if (state.running) { + setServiceActionStateLoading(state, true); + } + try { + if (state.running) { + await TachyonShellMethods.waitServiceActionJob(jobId); + } + } catch (error) { + logger.error("[DIAGNOSTIC]", "followServiceActionState failed", error); + } finally { + handledServiceActionJobs.add(jobId); + setServiceActionStateLoading(state, false); + await refreshDiagnosticServicesInfo({ force: true, allowInactive: true }); + void TachyonShellMethods.uiActionAck("service", jobId); + followedServiceActionJobs.delete(jobId); + resetDiagnosticsChecks(); + } +} +function followServiceActionsFromUiState(uiState) { + if (!uiState) { + return; + } + for (const action of uiState.actions.service || []) { + if (action.job_id) { + void followServiceActionState(action); + } + } +} +async function fetchSystemInfo() { + const systemInfo = await ensureSystemInfo(); + if (store.get().diagnosticsRunAction.loading) { + return; + } + store.set({ + diagnosticsChecks: getDiagnosticsChecks( + _("Not running"), + getDiagnosticsProviderOptions(systemInfo) + ) + }); +} +async function fetchDiagnosticsProviderInfo({ + resetChecks = true +} = {}) { + const requestId = ++latestProviderInfoRequestId; + try { + const uiState = await refreshRuntimeUiState({ force: true }); + if (requestId !== latestProviderInfoRequestId) { + return; + } + if (uiState) { + const currentSystemInfo2 = store.get().diagnosticsSystemInfo; + const nextSystemInfo2 = normalizeSingBoxVariantFields({ + ...currentSystemInfo2, + providerInfoLoaded: true, + sing_box_extended: uiState.capabilities.sing_box_extended, + sing_box_tiny: uiState.capabilities.sing_box_tiny, + sing_box_compressed: uiState.capabilities.sing_box_compressed, + sing_box_tailscale: uiState.capabilities.sing_box_tailscale, + zapret_installed: uiState.capabilities.zapret_installed, + zapret2_installed: uiState.capabilities.zapret2_installed, + byedpi_installed: uiState.capabilities.byedpi_installed, + server_inbounds_enabled_count: uiState.capabilities.server_inbounds_enabled_count + }); + if (!nextSystemInfo2.zapret_installed) { + nextSystemInfo2.zapret_version = "not installed"; + } + if (!nextSystemInfo2.zapret2_installed) { + nextSystemInfo2.zapret2_version = "not installed"; + } + if (!nextSystemInfo2.byedpi_installed) { + nextSystemInfo2.byedpi_version = "not installed"; + } + const nextState2 = { + diagnosticsSystemInfo: nextSystemInfo2 + }; + if (shouldResetDiagnosticsChecks({ + resetChecks, + diagnosticsRunLoading: store.get().diagnosticsRunAction.loading + })) { + nextState2.diagnosticsChecks = getDiagnosticsChecks( + _("Not running"), + getDiagnosticsProviderOptions(nextSystemInfo2) + ); + } + store.set(nextState2); + return; + } + const [zapretRuntime, zapret2Runtime, byedpiRuntime, inboundsConfig] = await Promise.all([ + TachyonShellMethods.checkZapretRuntime(), + TachyonShellMethods.checkZapret2Runtime(), + TachyonShellMethods.checkByedpiRuntime(), + TachyonShellMethods.checkInboundsConfig() + ]); + if (requestId !== latestProviderInfoRequestId) { + return; + } + const currentSystemInfo = store.get().diagnosticsSystemInfo; + const nextSystemInfo = { + ...currentSystemInfo, + providerInfoLoaded: true, + zapret_installed: zapretRuntime.success ? zapretRuntime.data.zapret_installed : currentSystemInfo.zapret_installed, + zapret2_installed: zapret2Runtime.success ? zapret2Runtime.data.zapret2_installed : currentSystemInfo.zapret2_installed, + byedpi_installed: byedpiRuntime.success ? byedpiRuntime.data.byedpi_installed : currentSystemInfo.byedpi_installed, + server_inbounds_enabled_count: inboundsConfig.success ? inboundsConfig.data.enabled_count : -1 + }; + if (!zapretRuntime.success) { + logger.error("[DIAGNOSTIC]", "fetchZapretRuntime failed", zapretRuntime); + } + if (!zapret2Runtime.success) { + logger.error( + "[DIAGNOSTIC]", + "fetchZapret2Runtime failed", + zapret2Runtime + ); + } + if (!byedpiRuntime.success) { + logger.error("[DIAGNOSTIC]", "fetchByedpiRuntime failed", byedpiRuntime); + } + if (!inboundsConfig.success) { + logger.error( + "[DIAGNOSTIC]", + "fetchInboundsConfig failed", + inboundsConfig + ); + } + if (!nextSystemInfo.zapret_installed) { + nextSystemInfo.zapret_version = "not installed"; + } + if (!nextSystemInfo.zapret2_installed) { + nextSystemInfo.zapret2_version = "not installed"; + } + if (!nextSystemInfo.byedpi_installed) { + nextSystemInfo.byedpi_version = "not installed"; + } + const nextState = { + diagnosticsSystemInfo: nextSystemInfo + }; + if (shouldResetDiagnosticsChecks({ + resetChecks, + diagnosticsRunLoading: store.get().diagnosticsRunAction.loading + })) { + nextState.diagnosticsChecks = getDiagnosticsChecks( + _("Not running"), + getDiagnosticsProviderOptions(nextSystemInfo) + ); + } + store.set(nextState); + } catch (error) { + logger.error("[DIAGNOSTIC]", "fetchDiagnosticsProviderInfo failed", error); + if (requestId === latestProviderInfoRequestId) { + const currentSystemInfo = store.get().diagnosticsSystemInfo; + store.set({ + diagnosticsSystemInfo: { + ...currentSystemInfo, + providerInfoLoaded: true, + server_inbounds_enabled_count: -1 + } + }); + } + } +} +function renderDiagnosticsChecks() { + logger.debug("[DIAGNOSTIC]", "renderDiagnosticsChecks"); + const diagnosticsChecks = [...store.get().diagnosticsChecks].sort( + (a, b) => a.order - b.order + ); + const container = document.getElementById("fkp_diagnostic-page-checks"); + const renderedDiagnosticsChecks = diagnosticsChecks.map( + (check) => renderCheckSection(check) + ); + return preserveScrollForPage(() => { + container.replaceChildren(...renderedDiagnosticsChecks); + }); +} +function renderDiagnosticRunActionWidget() { + logger.debug("[DIAGNOSTIC]", "renderDiagnosticRunActionWidget"); + const { loading: loading2 } = store.get().diagnosticsRunAction; + const providerInfoLoaded = store.get().diagnosticsSystemInfo.providerInfoLoaded; + const servicesInfoWidget = store.get().servicesInfoWidget; + const tachyonRunning = Boolean(servicesInfoWidget.data.tachyonRunning); + const container = document.getElementById("fkp_diagnostic-page-run-check"); + const renderedAction = renderRunAction({ + loading: loading2, + disabled: shouldDisableDiagnosticRunAction({ + providerInfoLoaded, + servicesInfoLoading: servicesInfoWidget.loading, + tachyonRunning, + mutatingServiceActionLoading: isMutatingServiceActionLoading() + }), + click: () => runChecks() + }); + return preserveScrollForPage(() => { + container.replaceChildren(renderedAction); + }); +} +async function handleServiceRuntimeAction({ + action, + expectedRunning, + optimisticRunning +}) { + setDiagnosticActionLoading(action, true, true); + let jobId = ""; + let ownsJobFollow = false; + let delegatedToWatcher = false; + if (optimisticRunning !== void 0) { + setDisplayedTachyonRunning(optimisticRunning); + } + try { + const startResponse = await TachyonShellMethods.serviceActionStart(action); + if (!startResponse.success) { + throw new Error(startResponse.error); + } + jobId = startResponse.data.job_id; + if (followedServiceActionJobs.has(jobId)) { + delegatedToWatcher = true; + return; + } + followedServiceActionJobs.add(jobId); + ownsJobFollow = true; + const result = await TachyonShellMethods.waitServiceActionJob(jobId); + if (!result.success) { + throw new Error(result.error); + } + if (result.data.success === false) { + throw new Error(result.data.message || _("Service action failed")); + } + await waitForTachyonRunningState(expectedRunning); + } catch (e) { + logger.error("[DIAGNOSTIC]", `handleServiceRuntimeAction(${action})`, e); + } finally { + if (!delegatedToWatcher) { + if (ownsJobFollow) { + followedServiceActionJobs.delete(jobId); + } + setDiagnosticActionLoading(action, false); + await refreshDiagnosticServicesInfo({ force: true, allowInactive: true }); + if (jobId) { + handledServiceActionJobs.add(jobId); + void TachyonShellMethods.uiActionAck("service", jobId); + } + resetDiagnosticsChecks(); + } + } +} +async function handleRestart() { + await handleServiceRuntimeAction({ + action: "restart", + expectedRunning: true, + optimisticRunning: false + }); +} +async function handleStart() { + await handleServiceRuntimeAction({ + action: "start", + expectedRunning: true + }); +} +async function handleStop() { + await handleServiceRuntimeAction({ + action: "stop", + expectedRunning: false + }); +} +async function handleEnable() { + setDiagnosticActionLoading("enable", true); + try { + await TachyonShellMethods.enable(); + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleEnable - e", e); + } finally { + await refreshDiagnosticServicesInfo({ + force: true, + allowInactive: true + }); + setDiagnosticActionLoading("enable", false); + } +} +async function handleDisable() { + setDiagnosticActionLoading("disable", true); + try { + await TachyonShellMethods.disable(); + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleDisable - e", e); + } finally { + await refreshDiagnosticServicesInfo({ + force: true, + allowInactive: true + }); + setDiagnosticActionLoading("disable", false); + } +} +async function handleShowGlobalCheck() { + setDiagnosticActionLoading("globalCheck", true); + try { + const globalCheck = await TachyonShellMethods.globalCheck(false); + if (globalCheck.success) { + const rawGlobalCheckText = globalCheck.data ?? ""; + const maskedGlobalCheckText = maskGlobalCheckText(rawGlobalCheckText); + ui.showModal( + _("Global check"), + renderModal(rawGlobalCheckText, "global_check", { + maskText: () => maskedGlobalCheckText, + initialAutoRefresh: false, + showMaskValuesToggle: true + }) + ); + } else { + logger.error("[DIAGNOSTIC]", "handleShowGlobalCheck - e", globalCheck); + } + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleShowGlobalCheck - e", e); + } finally { + setDiagnosticActionLoading("globalCheck", false); + } +} +async function handleViewLogs() { + setDiagnosticActionLoading("viewLogs", true); + try { + const viewLogs = await TachyonShellMethods.checkLogs(); + if (viewLogs.success) { + const getLatestLogs = async () => { + const latestLogs = await TachyonShellMethods.checkLogs(); + if (!latestLogs.success) { + throw latestLogs; + } + return latestLogs.data ?? ""; + }; + ui.showModal( + _("View logs"), + renderModal(viewLogs.data, "view_logs", { + getText: getLatestLogs, + refreshMs: 250, + initialAutoRefresh: true, + showAutoRefreshToggle: true, + startAtEnd: true + }) + ); + } else { + logger.error("[DIAGNOSTIC]", "handleViewLogs - e", viewLogs); + } + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleViewLogs - e", e); + } finally { + setDiagnosticActionLoading("viewLogs", false); + } +} +async function handleShowSingBoxConfig() { + setDiagnosticActionLoading("showSingBoxConfig", true); + try { + const showSingBoxConfig = await TachyonShellMethods.showSingBoxConfig(false); + if (showSingBoxConfig.success) { + const rawSingBoxConfigText = stringifySingBoxConfig( + showSingBoxConfig.data + ); + const maskedSingBoxConfigText = formatMaskedSingBoxConfig( + showSingBoxConfig.data + ); + ui.showModal( + _("Show sing-box config"), + renderModal(rawSingBoxConfigText, "show_sing_box_config", { + maskText: () => maskedSingBoxConfigText, + initialAutoRefresh: false, + showMaskValuesToggle: true + }) + ); + } else { + logger.error( + "[DIAGNOSTIC]", + "handleShowSingBoxConfig - e", + showSingBoxConfig + ); + } + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleShowSingBoxConfig - e", e); + } finally { + setDiagnosticActionLoading("showSingBoxConfig", false); + } +} +function renderWikiDisclaimerWidget() { + const diagnosticsChecks = store.get().diagnosticsChecks; + function getWikiKind() { + const allResults = diagnosticsChecks.map((check) => check.state); + if (allResults.includes("error")) { + return "error"; + } + if (allResults.includes("warning")) { + return "warning"; + } + return "default"; + } + const container = document.getElementById("fkp_diagnostic-page-wiki"); + return preserveScrollForPage(() => { + container.replaceChildren(renderWikiDisclaimer(getWikiKind())); + }); +} +function renderDiagnosticAvailableActionsWidget() { + const diagnosticsActions = store.get().diagnosticsActions; + const updatesActions = store.get().updatesActions; + const servicesInfoWidget = store.get().servicesInfoWidget; + logger.debug("[DIAGNOSTIC]", "renderDiagnosticAvailableActionsWidget"); + const tachyonEnabled = Boolean(servicesInfoWidget.data.tachyonEnabled); + const tachyonRunning = Boolean(servicesInfoWidget.data.tachyonRunning); + const serviceTransition = getServiceTransition( + servicesInfoWidget.data.tachyonStatus + ); + const restartLoading = diagnosticsActions.restart.loading || serviceTransition.restarting; + const startLoading = diagnosticsActions.start.loading || serviceTransition.starting; + const stopLoading = diagnosticsActions.stop.loading || serviceTransition.stopping; + const atLeastOneMutatingActionLoading = restartLoading || startLoading || stopLoading || diagnosticsActions.enable.loading || diagnosticsActions.disable.loading; + const componentActionLoading = hasComponentActionLoading(updatesActions); + const { serviceControlsDisabled, utilityActionsDisabled, viewLogsDisabled } = getAvailableActionsDisabledState({ + servicesInfoLoading: servicesInfoWidget.loading, + mutatingServiceActionLoading: atLeastOneMutatingActionLoading, + componentActionLoading + }); + const startVisible = shouldShowStartAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading + }); + const stopVisible = shouldShowStopAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading + }); + const container = document.getElementById("fkp_diagnostic-page-actions"); + const renderedActions = renderAvailableActions({ + restart: { + loading: restartLoading, + visible: shouldShowRestartAction({ + tachyonRunning, + restartLoading, + startLoading, + stopLoading + }), + onClick: handleRestart, + disabled: serviceControlsDisabled + }, + start: { + loading: startLoading, + visible: startVisible, + onClick: handleStart, + disabled: serviceControlsDisabled + }, + stop: { + loading: stopLoading, + visible: stopVisible, + onClick: handleStop, + disabled: serviceControlsDisabled + }, + enable: { + loading: diagnosticsActions.enable.loading, + visible: !tachyonEnabled, + onClick: handleEnable, + disabled: serviceControlsDisabled + }, + disable: { + loading: diagnosticsActions.disable.loading, + visible: tachyonEnabled, + onClick: handleDisable, + disabled: serviceControlsDisabled + }, + globalCheck: { + loading: diagnosticsActions.globalCheck.loading, + visible: true, + onClick: handleShowGlobalCheck, + disabled: utilityActionsDisabled + }, + viewLogs: { + loading: diagnosticsActions.viewLogs.loading, + visible: true, + onClick: handleViewLogs, + disabled: viewLogsDisabled + }, + showSingBoxConfig: { + loading: diagnosticsActions.showSingBoxConfig.loading, + visible: true, + onClick: handleShowSingBoxConfig, + disabled: utilityActionsDisabled + } + }); + return preserveScrollForPage(() => { + container.replaceChildren(renderedActions); + }); +} +function renderDiagnosticSystemInfoWidget() { + logger.debug("[DIAGNOSTIC]", "renderDiagnosticSystemInfoWidget"); + const diagnosticsSystemInfo = store.get().diagnosticsSystemInfo; + const container = document.getElementById("fkp_diagnostic-page-system-info"); + const items = [ + { + key: "Tachyon", + value: normalizeCompiledVersion(diagnosticsSystemInfo.tachyon_version) + }, + { + key: "Luci App", + value: normalizeCompiledVersion(TACHYON_LUCI_APP_VERSION) + }, + { + key: "Sing-box", + value: formatSingBoxVersion(diagnosticsSystemInfo) + } + ]; + if (diagnosticsSystemInfo.zapret_installed) { + items.push({ + key: "Zapret", + value: diagnosticsSystemInfo.zapret_version + }); + } + if (diagnosticsSystemInfo.zapret2_installed) { + items.push({ + key: "Zapret2", + value: diagnosticsSystemInfo.zapret2_version + }); + } + if (diagnosticsSystemInfo.byedpi_installed) { + items.push({ + key: "ByeDPI", + value: diagnosticsSystemInfo.byedpi_version + }); + } + items.push( + { + key: "OS", + value: diagnosticsSystemInfo.openwrt_version + }, + { + key: "Device", + value: diagnosticsSystemInfo.device_model + } + ); + const renderedSystemInfo = renderSystemInfo({ + items + }); + return preserveScrollForPage(() => { + container.replaceChildren(renderedSystemInfo); + }); +} +async function onStoreUpdate2(_next, _prev, diff) { + if (diff.diagnosticsChecks) { + renderDiagnosticsChecks(); + renderWikiDisclaimerWidget(); + } + if (diff.diagnosticsRunAction) { + renderDiagnosticRunActionWidget(); + } + if (diff.diagnosticsActions || diff.servicesInfoWidget || diff.updatesActions) { + renderDiagnosticAvailableActionsWidget(); + } + if (diff.diagnosticsActions || diff.servicesInfoWidget) { + renderDiagnosticRunActionWidget(); + } + if (diff.diagnosticsSystemInfo) { + renderDiagnosticSystemInfoWidget(); + renderDiagnosticRunActionWidget(); + } +} +function persistDiagnosticRunProgress({ + providerOptions, + nextRunnerIndex +}) { + savePersistedDiagnosticRun({ + providerOptions, + nextRunnerIndex, + diagnosticsChecks: store.get().diagnosticsChecks + }); +} +function setDiagnosticCheckLoading(code) { + const meta = DIAGNOSTICS_CHECKS_MAP[code]; + const diagnosticsChecks = store.get().diagnosticsChecks; + const other = diagnosticsChecks.filter((item) => item.code !== code); + store.set({ + diagnosticsChecks: [ + ...other, + { + order: meta.order, + code: meta.code, + title: meta.title, + description: _("Checking, please wait"), + state: "loading", + items: [] + } + ] + }); +} +function getDiagnosticRunners(providerOptions) { + return [ + { code: "DNS" /* DNS */, run: runDnsCheck }, + { code: "SINGBOX" /* SINGBOX */, run: runSingBoxCheck }, + ...providerOptions.includeInbounds ? [{ code: "INBOUNDS" /* INBOUNDS */, run: runInboundsCheck }] : [], + { code: "NFT" /* NFT */, run: runNftCheck }, + ...providerOptions.includeZapret ? [{ code: "ZAPRET" /* ZAPRET */, run: runZapretCheck }] : [], + ...providerOptions.includeZapret2 ? [{ code: "ZAPRET2" /* ZAPRET2 */, run: runZapret2Check }] : [], + ...providerOptions.includeByedpi ? [{ code: "BYEDPI" /* BYEDPI */, run: runByedpiCheck }] : [], + { code: "OUTBOUNDS" /* OUTBOUNDS */, run: runSectionsCheck }, + { code: "FAKEIP" /* FAKEIP */, run: runFakeIPCheck } + ]; +} +async function runChecks({ resume } = {}) { + if (store.get().diagnosticsRunAction.loading && !resume) { + return; + } + let providerOptions = resume?.providerOptions ?? getDiagnosticsProviderOptions(); + let nextRunnerIndex = resume?.nextRunnerIndex ?? 0; + store.set({ + diagnosticsRunAction: { loading: true }, + diagnosticsChecks: resume?.diagnosticsChecks ?? getLoadingDiagnosticsChecks(providerOptions).diagnosticsChecks + }); + persistDiagnosticRunProgress({ + providerOptions, + nextRunnerIndex + }); + try { + if (!resume) { + await fetchDiagnosticsProviderInfo({ resetChecks: false }); + providerOptions = getDiagnosticsProviderOptions(); + nextRunnerIndex = 0; + store.set({ + diagnosticsChecks: getLoadingDiagnosticsChecks(providerOptions).diagnosticsChecks + }); + persistDiagnosticRunProgress({ + providerOptions, + nextRunnerIndex + }); + } + const runners = getDiagnosticRunners(providerOptions); + for (let index = nextRunnerIndex; index < runners.length; index += 1) { + const runner = runners[index]; + setDiagnosticCheckLoading(runner.code); + persistDiagnosticRunProgress({ + providerOptions, + nextRunnerIndex: index + }); + try { + await runner.run(); + } catch (e) { + logger.error( + "[DIAGNOSTIC]", + `runChecks - ${runner.run.name} failed`, + e + ); + } + persistDiagnosticRunProgress({ + providerOptions, + nextRunnerIndex: index + 1 + }); + } + } catch (e) { + logger.error("[DIAGNOSTIC]", "runChecks - e", e); + } finally { + clearPersistedDiagnosticRun(); + store.set({ diagnosticsRunAction: { loading: false } }); + if (!diagnosticMounted) { + diagnosticCompletedWhileHidden = true; + } + } +} +async function loadInitialDiagnosticData() { + const diagnosticStatus = document.getElementById("diagnostic-status"); + if (diagnosticStatus?.isConnected && diagnosticStatus.offsetParent !== null) { + if (store.get().diagnosticsRunAction.loading) { + return; + } + await fetchSystemInfo(); + await fetchDiagnosticsProviderInfo(); + } +} +function restorePersistedDiagnosticRun() { + const persistedRun = readPersistedDiagnosticRun(); + if (!persistedRun) { + return false; + } + store.set({ + diagnosticsRunAction: { loading: true }, + diagnosticsChecks: persistedRun.diagnosticsChecks + }); + void runChecks({ resume: persistedRun }); + return true; +} +async function onPageMount2() { + const preserveHiddenResult = diagnosticCompletedWhileHidden; + onPageUnmount2({ + preserveCompletedResult: preserveHiddenResult, + preservePersistedRun: true + }); + diagnosticMounted = true; + diagnosticMountId += 1; + const mountId = diagnosticMountId; + const hasRuntimeSnapshot = Boolean(getCachedRuntimeUiState()); + if (!hasRuntimeSnapshot) { + const uiState = await refreshRuntimeUiState({ force: true }); + if (!diagnosticMounted || mountId !== diagnosticMountId) { + return; + } + if (!uiState) { + void refreshDiagnosticServicesInfo({ force: true }); + } + } + const restoredPersistedRun = !preserveHiddenResult && restorePersistedDiagnosticRun(); + if (preserveHiddenResult) { + diagnosticCompletedWhileHidden = false; + } else if (!restoredPersistedRun && !store.get().diagnosticsRunAction.loading) { + store.reset(["diagnosticsRunAction"]); + resetDiagnosticsChecks(); + } + store.subscribe(onStoreUpdate2); + startServiceActionStateWatcher(); + renderDiagnosticsChecks(); + renderDiagnosticRunActionWidget(); + renderDiagnosticAvailableActionsWidget(); + renderDiagnosticSystemInfoWidget(); + renderWikiDisclaimerWidget(); + if (hasRuntimeSnapshot) { + void refreshRuntimeUiState({ force: true }); + } + if (!preserveHiddenResult && !restoredPersistedRun) { + void loadInitialDiagnosticData(); + } +} +function onPageUnmount2({ + preserveCompletedResult = false, + preservePersistedRun = false +} = {}) { + diagnosticMounted = false; + diagnosticMountId += 1; + stopServiceActionStateWatcher(); + servicesInfoRefreshPromise = null; + store.unsubscribe(onStoreUpdate2); + if (!preserveCompletedResult && !store.get().diagnosticsRunAction.loading) { + if (!preservePersistedRun) { + clearPersistedDiagnosticRun(); + } + store.reset(["diagnosticsRunAction"]); + resetDiagnosticsChecks(); + diagnosticCompletedWhileHidden = false; + } +} +function registerLifecycleListeners2() { + if (diagnosticLifecycleRegistered) { + return; + } + diagnosticLifecycleRegistered = true; + store.subscribe((next, prev, diff) => { + if (diff.tabService && next.tabService.current !== prev.tabService.current) { + logger.debug( + "[DIAGNOSTIC]", + "active tab diff event, active tab:", + diff.tabService.current + ); + const isDIAGNOSTICVisible = next.tabService.current === "diagnostic"; + if (isDIAGNOSTICVisible) { + logger.debug( + "[DIAGNOSTIC]", + "registerLifecycleListeners", + "onPageMount" + ); + return onPageMount2(); + } + if (!isDIAGNOSTICVisible) { + logger.debug( + "[DIAGNOSTIC]", + "registerLifecycleListeners", + "onPageUnmount" + ); + return onPageUnmount2(); + } + } + }); +} +async function initController2() { + if (diagnosticControllerInitialized) { + return; + } + diagnosticControllerInitialized = true; + onMount("diagnostic-status").then(() => { + logger.debug("[DIAGNOSTIC]", "initController", "onMount"); + registerLifecycleListeners2(); + if (store.get().tabService.current === "diagnostic" || isActiveLuciTab("diagnostic")) { + onPageMount2(); + } + }); +} + +// src/tachyon/tabs/diagnostic/styles.ts +var styles4 = ` + +#cbi-${TACHYON_UCI_PACKAGE}-diagnostic-_mount_node > div { + width: 100%; +} + +#cbi-${TACHYON_UCI_PACKAGE}-diagnostic > h3 { + display: none; +} + +.fkp_diagnostic-page { + display: grid; + grid-template-columns: 2fr 1fr; + grid-column-gap: 10px; + align-items: start; +} + +@media (max-width: 800px) { + .fkp_diagnostic-page { + grid-template-columns: 1fr; + } +} + +.fkp_diagnostic-page__right-bar { + display: grid; + grid-template-columns: 1fr; + grid-row-gap: 10px; +} + +.fkp_diagnostic-page__right-bar__wiki { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + + display: grid; + grid-template-columns: auto; + grid-row-gap: 10px; +} + +.fkp_diagnostic-page__right-bar__wiki--warning { + border: 2px var(--warn-color-medium, orange) solid; +} +.fkp_diagnostic-page__right-bar__wiki--error { + border: 2px var(--error-color-medium, red) solid; +} + +.fkp_diagnostic-page__right-bar__wiki__content { + display: grid; + grid-template-columns: 1fr 5fr; + grid-column-gap: 10px; +} + +.fkp_diagnostic-page__right-bar__wiki__texts {} + +.fkp_diagnostic-page__right-bar__actions { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + + display: grid; + grid-template-columns: auto; + grid-row-gap: 10px; + +} + +.fkp_diagnostic-page__right-bar__actions > .fkp-partial-button { + width: 100%; + min-width: 0; + margin-left: 0; +} + +.fkp_diagnostic-page__right-bar__system-info { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + + display: grid; + grid-template-columns: auto; + grid-row-gap: 10px; +} + +.fkp_diagnostic-page__right-bar__system-info__title { + +} + +.fkp_diagnostic-page__right-bar__system-info__row { + display: grid; + grid-template-columns: auto 1fr; + grid-column-gap: 5px; +} + +.fkp_diagnostic-page__right-bar__system-info__row__tag { + padding: 2px 4px; + border: 1px transparent solid; + border-radius: 4px; + margin-left: 5px; +} + +.fkp_diagnostic-page__right-bar__system-info__row__tag--neutral { + border: 1px var(--background-color-high, gray) solid; + color: var(--text-color-medium, gray); +} + +.fkp_diagnostic-page__right-bar__system-info__row__tag--warning { + border: 1px var(--warn-color-medium, orange) solid; + color: var(--warn-color-medium, orange); +} + +.fkp_diagnostic-page__right-bar__system-info__row__tag--success { + border: 1px var(--success-color-medium, green) solid; + color: var(--success-color-medium, green); +} + +.fkp_diagnostic-page__left-bar { + display: grid; + grid-template-columns: 1fr; + grid-row-gap: 10px; +} + +.fkp_diagnostic-page__run_check_wrapper {} + +.fkp_diagnostic-page__run_check_wrapper button { + width: 100%; +} + +.fkp_diagnostic-page__checks { + display: grid; + grid-template-columns: 1fr; + grid-row-gap: 10px; +} + +.fkp_diagnostic_alert { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + + display: grid; + grid-template-columns: 24px 1fr; + grid-column-gap: 10px; + align-items: center; + padding: 10px; +} + +.fkp_diagnostic_alert--loading { + border: 2px var(--primary-color-high, dodgerblue) solid; +} + +.fkp_diagnostic_alert--warning { + border: 2px var(--warn-color-medium, orange) solid; + color: var(--warn-color-medium, orange); +} + +.fkp_diagnostic_alert--error { + border: 2px var(--error-color-medium, red) solid; + color: var(--error-color-medium, red); +} + +.fkp_diagnostic_alert--success { + border: 2px var(--success-color-medium, green) solid; + color: var(--success-color-medium, green); +} + +.fkp_diagnostic_alert--skipped {} + +.fkp_diagnostic_alert__icon {} + +.fkp_diagnostic_alert__content {} + +.fkp_diagnostic_alert__title { + display: block; +} + +.fkp_diagnostic_alert__description {} + +.fkp_diagnostic_alert__summary { + margin-top: 10px; +} + +.fkp_diagnostic_alert__summary__item { + display: grid; + grid-template-columns: 16px auto 1fr; + grid-column-gap: 10px; +} + +.fkp_diagnostic_alert__summary__item--error { + color: var(--error-color-medium, red); +} + +.fkp_diagnostic_alert__summary__item--warning { + color: var(--warn-color-medium, orange); +} + +.fkp_diagnostic_alert__summary__item--success { + color: var(--success-color-medium, green); +} + +.fkp_diagnostic_alert__summary__item__icon { + width: 16px; + height: 16px; +} +`; + +// src/tachyon/tabs/diagnostic/index.ts +var DiagnosticTab = { + render: render2, + initController: initController2, + styles: styles4 +}; + +// src/tachyon/tabs/monitoring/render.ts +function render3() { + return E( + "div", + { + id: "monitoring-status", + class: "fkp_monitoring-page" + }, + [ + E("div", { class: "fkp_monitoring-page__panel" }, [ + E("div", { class: "fkp_monitoring-page__controls" }, [ + E("div", { class: "fkp_monitoring-page__tabs" }, [ + E( + "button", + { + id: "monitoring-tab-active", + class: "btn cbi-button fkp_monitoring-page__tab fkp_monitoring-page__tab--active", + type: "button" + }, + `${_("Active")} 0` + ), + E( + "button", + { + id: "monitoring-tab-closed", + class: "btn cbi-button fkp_monitoring-page__tab", + type: "button" + }, + `${_("Closed")} 0` + ) + ]), + E("div", { class: "fkp_monitoring-page__filters" }, [ + E( + "select", + { + id: "monitoring-device-filter", + class: "cbi-input-select fkp_monitoring-page__device-filter" + }, + [E("option", { value: "all" }, _("All"))] + ), + E("label", { class: "fkp_monitoring-page__search" }, [ + E("span", { class: "fkp_monitoring-page__search-icon" }, []), + E("input", { + id: "monitoring-search", + class: "cbi-input-text fkp_monitoring-page__search-input", + type: "search", + placeholder: _("Search"), + autocomplete: "off" + }) + ]) + ]), + E("div", { class: "fkp_monitoring-page__actions" }, [ + E( + "button", + { + id: "monitoring-close-all", + class: "btn cbi-button fkp_monitoring-page__icon-button", + title: _("Close all connections"), + "aria-label": _("Close all connections"), + type: "button", + disabled: true + }, + [] + ), + E( + "button", + { + id: "monitoring-pause-toggle", + class: "btn cbi-button fkp_monitoring-page__icon-button", + title: _("Pause updates"), + "aria-label": _("Pause updates"), + type: "button" + }, + [] + ) + ]) + ]), + E( + "div", + { id: "monitoring-connections", class: "fkp_monitoring-page__body" }, + [ + E( + "div", + { + class: "fkp_monitoring-page__state fkp_monitoring-page__state--loading" + }, + _("Loading connections") + ) + ] + ) + ]) + ] + ); +} + +// src/tachyon/tabs/monitoring/initController.ts +function normalizeConnectionsPayload(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return value; +} +var RENDER_INTERVAL_MS = 500; +var CONNECTIONS_RPC_POLL_INTERVAL_MS = 1500; +var CLOSED_CONNECTION_LIMIT = 300; +var ALL_FILTER_VALUE = "all"; +var dependencies = {}; +var monitoringMounted = false; +var monitoringMountId = 0; +var monitoringLifecycleRegistered = false; +var monitoringControllerInitialized = false; +var serviceStateUnsubscribe = null; +var renderTimer = null; +var connectionsPollTimer = null; +var connectionsSocketUrl = ""; +var connectionsUpdatesId = 0; +var renderSkippedForSelection = false; +var pendingConnectionsPayload = null; +var pollingConnections = false; +var activeTab = "active"; +var selectedDeviceFilter = ALL_FILTER_VALUE; +var searchQuery = ""; +var localDeviceChoices = {}; +var routeDisplayNames = {}; +var routeSections = []; +var serverDisplayNames = {}; +var lastDeviceFilterSignature = ""; +var loading = true; +var failed = false; +var closingAll = false; +var monitoringPaused = false; +var monitoringPausedAt = null; +var serviceAvailability = "loading"; +var activeConnections = /* @__PURE__ */ new Map(); +var closedConnections = /* @__PURE__ */ new Map(); +var closingConnectionIds = /* @__PURE__ */ new Set(); +function normalizeString(value) { + return value == null ? "" : String(value).trim(); +} +function getListValues2(value) { + if (!value) { + return []; + } + if (Array.isArray(value)) { + return value.map((item) => normalizeString(item)).filter(Boolean); + } + return normalizeString(value).split(/\s+/).map((item) => item.trim()).filter(Boolean); +} +function getUrlTestIds2(section) { + const values = getListValues2(section.urltests); + return values.length ? values : section.urltest_enabled === "1" ? ["urltest"] : []; +} +function getUrlTestTag2(sectionName, id) { + return getOutboundTagBySection( + id === "urltest" ? `${sectionName}-urltest` : `${sectionName}-urltest-${id}` + ); +} +function formatEndpoint(address, port) { + const normalizedAddress = normalizeString(address); + const normalizedPort = normalizeString(port); + if (!normalizedAddress) { + return "-"; + } + if (!normalizedPort) { + return normalizedAddress; + } + if (normalizedPort === "443") { + return normalizedAddress; + } + if (normalizedAddress.includes(":") && !normalizedAddress.startsWith("[")) { + return `[${normalizedAddress}]:${normalizedPort}`; + } + return `${normalizedAddress}:${normalizedPort}`; +} +function getDisplayName2(section) { + return normalizeString(section.label) || section[".name"]; +} +function buildRouteDisplayNames(sections) { + const map = { + "bypass-out": "Bypass", + "direct-out": "direct" + }; + const serverMap = {}; + const routeSectionItems = []; + const urltestsBySection = /* @__PURE__ */ new Map(); + sections.filter((section) => section[".type"] === "urltest").forEach((section) => { + const owner = normalizeString(section.section); + const id = normalizeString(section.id) || section[".name"]; + if (!owner || !id) { + return; + } + urltestsBySection.set(owner, [ + ...urltestsBySection.get(owner) || [], + id + ]); + }); + sections.filter((section) => section[".type"] === "section").filter((section) => section.enabled !== "0").forEach((section) => { + const sectionName = section[".name"]; + const displayName = getDisplayName2(section); + if (!sectionName || !displayName) { + return; + } + routeSectionItems.push({ sectionName, displayName }); + map[getOutboundTagBySection(sectionName)] = displayName; + const urltestIds = urltestsBySection.get(sectionName) || getUrlTestIds2(section); + urltestIds.forEach((id) => { + map[getUrlTestTag2(sectionName, id)] = displayName; + }); + }); + sections.filter((section) => section[".type"] === "server").filter((section) => section.enabled !== "0").forEach((section) => { + const sectionName = section[".name"]; + const displayName = getDisplayName2(section); + if (!sectionName || !displayName) { + return; + } + serverMap[`server-${sectionName}-in`] = displayName; + }); + routeDisplayNames = map; + serverDisplayNames = serverMap; + routeSections = routeSectionItems.sort( + (a, b) => b.sectionName.length - a.sectionName.length + ); +} +function getRouteDisplayNameByTag(tag) { + if (!tag) { + return ""; + } + if (routeDisplayNames[tag]) { + return routeDisplayNames[tag]; + } + const manualSection = routeSections.find(({ sectionName }) => { + if (!tag.startsWith(`${sectionName}-`) || !tag.endsWith("-out")) { + return false; + } + const middle = tag.slice(sectionName.length + 1, -4); + return /^\d+(?:-\d+)?$/.test(middle); + }); + return manualSection?.displayName || ""; +} +function getRouteTagFromRule(rule) { + const match = normalizeString(rule).match(/=>\s*route\(([^)]+)\)/); + return normalizeString(match?.[1]).replace(/^['"]|['"]$/g, ""); +} +function parseStartedAt(connection) { + const startedAt = Date.parse(connection.start || ""); + return Number.isFinite(startedAt) ? startedAt : connection.lastSeenAt; +} +function formatDuration(ms) { + const totalSeconds = Math.max(0, Math.floor(ms / 1e3)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor(totalSeconds % 3600 / 60); + const seconds = totalSeconds % 60; + const pad = (value) => String(value).padStart(2, "0"); + if (hours > 0) { + return `${hours}:${pad(minutes)}:${pad(seconds)}`; + } + return `${minutes}:${pad(seconds)}`; +} +function formatConnectionDuration(connection) { + const startedAt = parseStartedAt(connection); + const finishedAt = connection.closedAt || monitoringPausedAt || Date.now(); + return formatDuration(finishedAt - startedAt); +} +function formatBytes2(value) { + return prettyBytes(Number.isFinite(value) ? Number(value) : 0); +} +function getConnectionSourceIp(connection) { + return normalizeString(connection.metadata?.sourceIP); +} +function getConnectionInboundTag(connection) { + const metadataType = normalizeString(connection.metadata?.type); + const metadataTypeParts = metadataType.split("/"); + const metadataTag = normalizeString( + metadataTypeParts.length > 1 ? metadataTypeParts[metadataTypeParts.length - 1] : metadataType + ); + if (metadataTag) { + return metadataTag; + } + const ruleInbound = normalizeString(connection.rule).match( + /(?:^|\s)inbound=([^\s]+)/ + ); + return normalizeString(ruleInbound?.[1]); +} +function getServerDisplayNameByInboundTag(tag) { + return normalizeString(serverDisplayNames[tag]); +} +function getDeviceName(ip) { + return normalizeString(localDeviceChoices[ip]); +} +function getServerSourceNameByIp(ip) { + if (!ip) { + return ""; + } + const connections = [ + ...Array.from(activeConnections.values()), + ...Array.from(closedConnections.values()) + ]; + for (const connection of connections) { + if (getConnectionSourceIp(connection) !== ip) { + continue; + } + const serverName = getServerDisplayNameByInboundTag( + getConnectionInboundTag(connection) + ); + if (serverName) { + return serverName; + } + } + return ""; +} +function getDeviceFilterLabel(ip) { + const serverName = getServerSourceNameByIp(ip); + if (serverName) { + return serverName; + } + const deviceName = getDeviceName(ip); + return deviceName || ip; +} +function getSourceCellParts(connection) { + const ip = getConnectionSourceIp(connection); + const inboundTag = getConnectionInboundTag(connection); + const serverName = getServerDisplayNameByInboundTag(inboundTag); + if (serverName) { + return { + primary: serverName, + ip: "", + copyValue: serverName, + searchValue: [serverName, ip, inboundTag].filter(Boolean).join(" ") + }; + } + const deviceName = getDeviceName(ip); + if (deviceName) { + return { + primary: deviceName, + ip: "", + copyValue: deviceName, + searchValue: `${deviceName} ${ip}` + }; + } + return { + primary: ip || "-", + ip: "", + copyValue: ip || "-", + searchValue: ip + }; +} +function getTargetCellParts(connection) { + const metadata = connection.metadata || {}; + const host = normalizeString(metadata.host); + const destinationIp = normalizeString(metadata.destinationIP); + const port = metadata.destinationPort; + const primaryTarget = host || destinationIp; + const primary = primaryTarget ? formatEndpoint(primaryTarget, port) : "-"; + return { + primary, + searchValue: [primary, host, destinationIp].filter(Boolean).join(" ") + }; +} +function getRoute(connection) { + const chains = Array.isArray(connection.chains) ? connection.chains : []; + const routeTag = [...chains].reverse().find(getRouteDisplayNameByTag); + const fallbackRouteTag = getRouteTagFromRule(connection.rule); + const route = getRouteDisplayNameByTag(routeTag || "") || getRouteDisplayNameByTag(fallbackRouteTag) || normalizeString(routeTag) || normalizeString(fallbackRouteTag); + return route || "-"; +} +function getNetwork(connection) { + return normalizeString(connection.metadata?.network).toLowerCase() || "-"; +} +function sortConnections(connections, tab) { + return [...connections].sort((a, b) => { + if (tab === "closed") { + return (b.closedAt || 0) - (a.closedAt || 0); + } + return parseStartedAt(b) - parseStartedAt(a); + }); +} +function getConnectionsForActiveTab() { + const source = activeTab === "active" ? Array.from(activeConnections.values()) : Array.from(closedConnections.values()); + return sortConnections(source, activeTab); +} +function normalizeSearchValue(value) { + return value.toLowerCase().replace(/\s+/g, " ").trim(); +} +function getSearchValues(connection) { + const target = getTargetCellParts(connection); + const source = getSourceCellParts(connection); + return [ + connection.id, + target.primary, + getNetwork(connection), + getRoute(connection), + formatConnectionDuration(connection), + formatBytes2(connection.download), + formatBytes2(connection.upload), + source.primary, + source.copyValue, + source.searchValue + ].filter(Boolean); +} +function getVisibleConnections() { + const normalizedSearch = normalizeSearchValue(searchQuery); + return getConnectionsForActiveTab().filter((connection) => { + const sourceIp = getConnectionSourceIp(connection); + if (selectedDeviceFilter !== ALL_FILTER_VALUE && sourceIp !== selectedDeviceFilter) { + return false; + } + if (!normalizedSearch) { + return true; + } + return getSearchValues(connection).some( + (value) => normalizeSearchValue(value).includes(normalizedSearch) + ); + }); +} +function moveConnectionToClosed(connection, now) { + closedConnections.set(connection.id, { + ...connection, + closedAt: now, + lastSeenAt: now + }); +} +function trimClosedConnections() { + const sorted = sortConnections( + Array.from(closedConnections.values()), + "closed" + ); + sorted.slice(CLOSED_CONNECTION_LIMIT).forEach((connection) => { + closedConnections.delete(connection.id); + }); +} +function applyConnectionsPayload(payload) { + if (monitoringPaused) { + pendingConnectionsPayload = payload; + return; + } + const mountId = monitoringMountId; + const now = Date.now(); + const incomingIds = /* @__PURE__ */ new Set(); + const rawConnections = Array.isArray(payload.connections) ? payload.connections : []; + rawConnections.forEach((rawConnection) => { + const id = normalizeString(rawConnection.id); + if (!id) { + return; + } + incomingIds.add(id); + closedConnections.delete(id); + activeConnections.set(id, { + ...rawConnection, + id, + lastSeenAt: now + }); + }); + Array.from(activeConnections.entries()).forEach(([id, connection]) => { + if (!incomingIds.has(id)) { + activeConnections.delete(id); + moveConnectionToClosed(connection, now); + } + }); + trimClosedConnections(); + loading = false; + failed = false; + if (monitoringMounted && mountId === monitoringMountId) { + renderControls(); + renderConnections(); + } +} +function setTab(tab) { + if (activeTab === tab) { + return; + } + activeTab = tab; + renderControls(); + renderConnections(); +} +function getKnownSourceIps() { + const ips = /* @__PURE__ */ new Set(); + activeConnections.forEach((connection) => { + const ip = getConnectionSourceIp(connection); + if (ip) { + ips.add(ip); + } + }); + closedConnections.forEach((connection) => { + const ip = getConnectionSourceIp(connection); + if (ip) { + ips.add(ip); + } + }); + return Array.from(ips).sort((a, b) => { + const byLabel = getDeviceFilterLabel(a).localeCompare( + getDeviceFilterLabel(b) + ); + return byLabel || a.localeCompare(b); + }); +} +function renderDeviceFilterOptions() { + const select = document.getElementById( + "monitoring-device-filter" + ); + if (!select) { + return; + } + const sourceIps = getKnownSourceIps(); + if (selectedDeviceFilter !== ALL_FILTER_VALUE && !sourceIps.includes(selectedDeviceFilter)) { + selectedDeviceFilter = ALL_FILTER_VALUE; + } + const signature = [ + selectedDeviceFilter, + ...sourceIps.map((ip) => `${ip}:${getDeviceFilterLabel(ip)}`) + ].join("|"); + if (signature === lastDeviceFilterSignature) { + select.value = selectedDeviceFilter; + return; + } + lastDeviceFilterSignature = signature; + const options = [ + E("option", { value: ALL_FILTER_VALUE }, _("All")), + ...sourceIps.map( + (ip) => E("option", { value: ip }, getDeviceFilterLabel(ip)) + ) + ]; + select.replaceChildren(...options); + select.value = selectedDeviceFilter; +} +function setButtonActive(button, active) { + if (!button) { + return; + } + button.classList.toggle("fkp_monitoring-page__tab--active", active); +} +function renderTabButtonContent(label, count) { + return [ + E("span", { class: "fkp_monitoring-page__tab-label" }, label), + E("span", { class: "fkp_monitoring-page__tab-badge" }, String(count)) + ]; +} +function renderControls() { + const activeButton = document.getElementById( + "monitoring-tab-active" + ); + const closedButton = document.getElementById( + "monitoring-tab-closed" + ); + const closeAllButton = document.getElementById( + "monitoring-close-all" + ); + const pauseToggleButton = document.getElementById( + "monitoring-pause-toggle" + ); + if (activeButton) { + activeButton.replaceChildren( + ...renderTabButtonContent(_("Active"), activeConnections.size) + ); + activeButton.disabled = serviceAvailability === "stopped"; + } + if (closedButton) { + closedButton.replaceChildren( + ...renderTabButtonContent(_("Closed"), closedConnections.size) + ); + closedButton.disabled = serviceAvailability === "stopped"; + } + setButtonActive(activeButton, activeTab === "active"); + setButtonActive(closedButton, activeTab === "closed"); + if (closeAllButton) { + closeAllButton.replaceChildren(renderXIcon24()); + closeAllButton.disabled = serviceAvailability === "stopped" || activeConnections.size === 0 || closingAll; + } + if (pauseToggleButton) { + const title = monitoringPaused ? _("Resume updates") : _("Pause updates"); + pauseToggleButton.replaceChildren( + monitoringPaused ? renderPlayIcon24() : renderPauseIcon24() + ); + pauseToggleButton.title = title; + pauseToggleButton.setAttribute("aria-label", title); + pauseToggleButton.disabled = serviceAvailability === "stopped"; + pauseToggleButton.classList.toggle( + "fkp_monitoring-page__icon-button--active", + monitoringPaused + ); + } + const searchIcon = document.querySelector( + ".fkp_monitoring-page__search-icon" + ); + if (searchIcon && searchIcon.childNodes.length === 0) { + searchIcon.replaceChildren(renderSearchIcon24()); + } + renderDeviceFilterOptions(); + const select = document.getElementById( + "monitoring-device-filter" + ); + const searchInput = document.getElementById( + "monitoring-search" + ); + if (select) { + select.disabled = serviceAvailability === "stopped"; + } + if (searchInput) { + searchInput.disabled = serviceAvailability === "stopped"; + } +} +function renderValue(value, className = "") { + const text = value || "-"; + const element = E( + "span", + { + class: ["fkp_monitoring-page__value", className].filter(Boolean).join(" "), + title: text + }, + text + ); + element.setAttribute("data-copy-value", text); + return element; +} +function renderSourceValue(source) { + const fullText = source.copyValue || source.primary || "-"; + if (!source.ip) { + const element2 = E( + "span", + { + class: "fkp_monitoring-page__value fkp_monitoring-page__source-value fkp_monitoring-page__source-value--ip-only", + title: fullText + }, + source.primary || "-" + ); + element2.setAttribute("data-copy-value", fullText); + return element2; + } + const element = E( + "span", + { + class: "fkp_monitoring-page__value fkp_monitoring-page__source-value", + title: fullText + }, + [ + E("span", { class: "fkp_monitoring-page__source-name" }, source.primary), + E("span", { class: "fkp_monitoring-page__source-ip" }, source.ip) + ] + ); + element.setAttribute("data-copy-value", fullText); + return element; +} +function renderTableCell(label, children) { + const cell = E("td", {}, children); + cell.setAttribute("data-label", label); + return cell; +} +function renderConnectionRow(connection) { + const target = getTargetCellParts(connection); + const source = getSourceCellParts(connection); + const isClosing = closingConnectionIds.has(connection.id); + const closeButton = activeTab === "active" ? E( + "button", + { + class: "btn cbi-button fkp_monitoring-page__row-action", + title: _("Close connection"), + "aria-label": _("Close connection"), + type: "button", + value: connection.id, + ...isClosing ? { disabled: true } : {} + }, + [renderXIcon24()] + ) : E("span", {}, "-"); + return E( + "tr", + { + class: isClosing ? "fkp_monitoring-page__row--closing" : "" + }, + [ + renderTableCell(_("Host"), [renderValue(target.primary)]), + renderTableCell(_("Type"), [ + renderValue(getNetwork(connection), "fkp_monitoring-page__network") + ]), + renderTableCell(_("Route"), [ + renderValue(getRoute(connection), "fkp_monitoring-page__route") + ]), + renderTableCell(_("Time"), [ + renderValue(formatConnectionDuration(connection)) + ]), + renderTableCell(_("Downloaded"), [ + renderValue(formatBytes2(connection.download)) + ]), + renderTableCell(_("Uploaded"), [ + renderValue(formatBytes2(connection.upload)) + ]), + renderTableCell(_("Source"), [renderSourceValue(source)]), + renderTableCell(_("Close"), [closeButton]) + ] + ); +} +function renderStateRow(text, className = "") { + return E("tr", { class: "fkp_monitoring-page__state-row" }, [ + E( + "td", + { + class: "fkp_monitoring-page__state-cell", + colSpan: 8 + }, + [ + E( + "div", + { + class: ["fkp_monitoring-page__state", className].filter(Boolean).join(" ") + }, + text + ) + ] + ) + ]); +} +function renderConnectionsTable(connections, state) { + const rows = state ? [renderStateRow(state.text, state.className)] : connections.map(renderConnectionRow); + return E("div", { class: "fkp_monitoring-page__table-wrap" }, [ + E( + "table", + { class: "table cbi-section-table fkp_monitoring-page__table" }, + [ + E("thead", {}, [ + E("tr", {}, [ + E("th", {}, _("Host")), + E("th", {}, _("Type")), + E("th", {}, _("Route")), + E("th", {}, _("Time")), + E("th", {}, `\u2193 ${_("Downloaded")}`), + E("th", {}, `\u2191 ${_("Uploaded")}`), + E("th", {}, _("Source")), + E("th", {}, _("Close")) + ]) + ]), + E("tbody", {}, rows) + ] + ) + ]); +} +function isNodeInsideMonitoring(node) { + const root = document.getElementById("monitoring-status"); + return Boolean(root && node && root.contains(node)); +} +function isTextSelectionInsideMonitoring() { + const selection = window.getSelection?.(); + if (!selection || selection.isCollapsed) { + return false; + } + return isNodeInsideMonitoring(selection.anchorNode) || isNodeInsideMonitoring(selection.focusNode); +} +function renderConnections(options = {}) { + const container = document.getElementById("monitoring-connections"); + if (!container) { + return; + } + if (!options.force && isTextSelectionInsideMonitoring()) { + renderSkippedForSelection = true; + return; + } + renderSkippedForSelection = false; + const previousScrollLeft = container.scrollLeft; + if (serviceAvailability === "stopped") { + container.replaceChildren( + renderConnectionsTable([], { + text: _( + "Tachyon service is stopped. Start the service to display connections." + ) + }) + ); + return; + } + if (loading) { + container.replaceChildren( + renderConnectionsTable([], { + text: _("Loading connections"), + className: "fkp_monitoring-page__state--loading" + }) + ); + return; + } + if (failed) { + container.replaceChildren( + renderConnectionsTable([], { + text: _("Connections are unavailable"), + className: "fkp_monitoring-page__state--error" + }) + ); + return; + } + const visibleConnections = getVisibleConnections(); + if (visibleConnections.length === 0) { + container.replaceChildren( + renderConnectionsTable([], { + text: activeTab === "active" ? _("No active connections") : _("No closed connections") + }) + ); + return; + } + container.replaceChildren(renderConnectionsTable(visibleConnections)); + container.scrollLeft = previousScrollLeft; +} +function flushRenderAfterSelection() { + if (!renderSkippedForSelection || isTextSelectionInsideMonitoring()) { + return; + } + renderConnections({ force: true }); +} +function setMonitoringPaused(paused) { + if (monitoringPaused === paused) { + return; + } + monitoringPaused = paused; + monitoringPausedAt = paused ? Date.now() : null; + renderSkippedForSelection = false; + renderControls(); + if (!paused) { + const payload = pendingConnectionsPayload; + pendingConnectionsPayload = null; + if (payload) { + applyConnectionsPayload(payload); + return; + } + if (connectionsPollTimer) { + void pollConnectionsSnapshot(); + return; + } + } + renderConnections(); +} +function isElementOverflowing(element) { + return element.scrollWidth > element.clientWidth + 1; +} +function getMonitoringValueOverflowElements(element) { + return [ + element, + ...Array.from(element.querySelectorAll("*")) + ].filter(isElementOverflowing); +} +function getElementCopyText(element, fallback) { + return element.getAttribute("data-copy-value") || element.textContent || fallback; +} +function compactMonitoringText(value) { + return value.replace(/\u2026/g, "").trim().replace(/\s+/g, ""); +} +function getMonitoringValueTextElements(element) { + const children = Array.from(element.children).filter( + (child) => child instanceof HTMLElement + ); + if (children.length === 0) { + return [element]; + } + const textElements = children.flatMap(getMonitoringValueTextElements).filter((child) => compactMonitoringText(getElementCopyText(child, ""))); + return textElements.length > 0 ? textElements : [element]; +} +function estimateVisibleMonitoringTextLength(element, fallbackText) { + const text = compactMonitoringText(getElementCopyText(element, fallbackText)); + if (!text) { + return 0; + } + if (!isElementOverflowing(element)) { + return text.length; + } + return Math.floor( + element.clientWidth / Math.max(element.scrollWidth, 1) * text.length + ); +} +function getEstimatedVisibleMonitoringTextLength(element, fallbackText) { + const textElements = getMonitoringValueTextElements(element); + if (textElements.length === 1 && textElements[0] === element) { + return estimateVisibleMonitoringTextLength(element, fallbackText); + } + return textElements.reduce( + (total, textElement) => total + estimateVisibleMonitoringTextLength(textElement, fallbackText), + 0 + ); +} +function isCompactTextSubsequence(needle, haystack) { + let haystackIndex = 0; + for (let needleIndex = 0; needleIndex < needle.length; needleIndex += 1) { + haystackIndex = haystack.indexOf(needle[needleIndex], haystackIndex); + if (haystackIndex === -1) { + return false; + } + haystackIndex += 1; + } + return true; +} +function getSelectionValueElements(selection) { + const root = document.getElementById("monitoring-status"); + if (!root) { + return []; + } + return Array.from( + root.querySelectorAll( + ".fkp_monitoring-page__value[data-copy-value]" + ) + ).filter((element) => { + for (let index = 0; index < selection.rangeCount; index += 1) { + try { + if (selection.getRangeAt(index).intersectsNode(element)) { + return true; + } + } catch (_error) { + return false; + } + } + return false; + }); +} +function shouldCopyFullMonitoringValue(element, selectedText, fullText) { + const normalizedSelectedText = selectedText.replace(/\u2026/g, "").trim(); + const normalizedFullText = fullText.trim(); + const compactSelectedText = compactMonitoringText(selectedText); + const compactFullText = compactMonitoringText(fullText); + const overflowElements = getMonitoringValueOverflowElements(element); + const hasCompositeText = getMonitoringValueTextElements(element).length > 1; + if (!normalizedSelectedText || !normalizedFullText) { + return false; + } + if (normalizedSelectedText === normalizedFullText) { + return true; + } + if (overflowElements.length === 0) { + return false; + } + if (hasCompositeText) { + const selectedPrefix = compactSelectedText.slice( + 0, + Math.min(4, compactSelectedText.length) + ); + if (!compactFullText.startsWith(selectedPrefix) || !isCompactTextSubsequence(compactSelectedText, compactFullText)) { + return false; + } + } else if (!compactFullText.startsWith(compactSelectedText)) { + return false; + } + const estimatedVisibleChars = getEstimatedVisibleMonitoringTextLength( + element, + normalizedFullText + ); + return compactSelectedText.length >= Math.max(4, estimatedVisibleChars - 2); +} +function handleMonitoringValueCopy(event) { + const selection = window.getSelection?.(); + if (!selection || selection.isCollapsed) { + return; + } + const valueElements = getSelectionValueElements(selection); + if (valueElements.length !== 1) { + return; + } + const valueElement = valueElements[0]; + const fullText = valueElement.getAttribute("data-copy-value") || valueElement.textContent || ""; + const selectedText = selection.toString(); + if (!shouldCopyFullMonitoringValue(valueElement, selectedText, fullText)) { + return; + } + event.clipboardData?.setData("text/plain", fullText); + event.preventDefault(); +} +async function closeConnection(connectionId) { + if (!connectionId || closingConnectionIds.has(connectionId)) { + return; + } + closingConnectionIds.add(connectionId); + renderConnections(); + try { + const response = await TachyonShellMethods.closeClashApiConnection(connectionId); + if (!response.success) { + showToast(_("Failed to close connection"), "error"); + return; + } + const now = Date.now(); + const connection = activeConnections.get(connectionId); + if (connection) { + activeConnections.delete(connectionId); + moveConnectionToClosed(connection, now); + trimClosedConnections(); + pendingConnectionsPayload = null; + renderControls(); + } + } catch (error) { + logger.error("[MONITORING]", "closeConnection: failed", error); + showToast(_("Failed to close connection"), "error"); + } finally { + closingConnectionIds.delete(connectionId); + renderConnections(); + } +} +async function closeAllConnections() { + if (activeConnections.size === 0 || closingAll) { + return; + } + closingAll = true; + renderControls(); + try { + const response = await TachyonShellMethods.closeAllClashApiConnections(); + if (!response.success) { + showToast(_("Failed to close connections"), "error"); + return; + } + const now = Date.now(); + activeConnections.forEach((connection) => { + moveConnectionToClosed(connection, now); + }); + activeConnections.clear(); + pendingConnectionsPayload = null; + trimClosedConnections(); + } catch (error) { + logger.error("[MONITORING]", "closeAllConnections: failed", error); + showToast(_("Failed to close connections"), "error"); + } finally { + closingAll = false; + renderControls(); + renderConnections(); + } +} +function bindControls() { + const activeButton = document.getElementById("monitoring-tab-active"); + const closedButton = document.getElementById("monitoring-tab-closed"); + const select = document.getElementById( + "monitoring-device-filter" + ); + const searchInput = document.getElementById( + "monitoring-search" + ); + const closeAllButton = document.getElementById("monitoring-close-all"); + const pauseToggleButton = document.getElementById("monitoring-pause-toggle"); + const connectionsContainer = document.getElementById( + "monitoring-connections" + ); + if (activeButton) { + activeButton.onclick = () => setTab("active"); + } + if (closedButton) { + closedButton.onclick = () => setTab("closed"); + } + if (closeAllButton) { + closeAllButton.onclick = () => { + void closeAllConnections(); + }; + } + if (pauseToggleButton) { + pauseToggleButton.onclick = () => { + setMonitoringPaused(!monitoringPaused); + pauseToggleButton.blur(); + }; + } + if (select) { + select.onchange = () => { + selectedDeviceFilter = select.value || ALL_FILTER_VALUE; + renderConnections(); + }; + } + if (searchInput) { + searchInput.oninput = () => { + searchQuery = searchInput.value; + renderConnections(); + }; + } + if (connectionsContainer) { + connectionsContainer.onclick = (event) => { + const target = event.target; + const button = target?.closest( + ".fkp_monitoring-page__row-action" + ); + if (button?.value) { + void closeConnection(button.value); + } + }; + } +} +async function loadLocalDevices() { + try { + localDeviceChoices = await dependencies.loadLocalDeviceChoices?.() || {}; + } catch (error) { + logger.warn("[MONITORING]", "loadLocalDevices: failed", error); + localDeviceChoices = {}; + } finally { + renderControls(); + renderConnections(); + } +} +async function loadRouteDisplayNames() { + try { + buildRouteDisplayNames(await CustomTachyonMethods.getConfigSections()); + } catch (error) { + logger.warn("[MONITORING]", "loadRouteDisplayNames: failed", error); + buildRouteDisplayNames([]); + } finally { + renderControls(); + renderConnections(); + } +} +async function pollConnectionsSnapshot() { + if (pollingConnections || !monitoringMounted || monitoringPaused || serviceAvailability !== "running") { + return; + } + const mountId = monitoringMountId; + pollingConnections = true; + try { + const response = await TachyonShellMethods.getClashApiConnections(); + if (!monitoringMounted || mountId !== monitoringMountId || serviceAvailability !== "running") { + return; + } + if (!response.success) { + failed = true; + loading = false; + renderConnections(); + return; + } + applyConnectionsPayload(normalizeConnectionsPayload(response.data)); + } catch (error) { + if (!monitoringMounted || mountId !== monitoringMountId || serviceAvailability !== "running") { + return; + } + logger.error("[MONITORING]", "connections polling failed", error); + failed = true; + loading = false; + renderConnections(); + } finally { + pollingConnections = false; + } +} +function startConnectionsPolling() { + if (connectionsPollTimer) { + return; + } + void pollConnectionsSnapshot(); + connectionsPollTimer = setInterval(() => { + void pollConnectionsSnapshot(); + }, CONNECTIONS_RPC_POLL_INTERVAL_MS); +} +async function connectToConnectionsSocket(updatesId) { + const mountId = monitoringMountId; + const clashApiSecret = await getClashApiSecret2(); + if (!monitoringMounted || mountId !== monitoringMountId || updatesId !== connectionsUpdatesId || serviceAvailability !== "running") { + return; + } + connectionsSocketUrl = `${getClashWsUrl()}/connections?token=${clashApiSecret}`; + socket.subscribe( + connectionsSocketUrl, + (msg) => { + if (updatesId !== connectionsUpdatesId || serviceAvailability !== "running") { + return; + } + try { + applyConnectionsPayload(JSON.parse(msg)); + } catch (error) { + logger.error("[MONITORING]", "connections socket parse failed", error); + } + }, + (_err) => { + if (!monitoringMounted || mountId !== monitoringMountId || updatesId !== connectionsUpdatesId || serviceAvailability !== "running") { + return; + } + failed = true; + loading = false; + renderConnections(); + } + ); +} +function startConnectionsUpdates() { + if (serviceAvailability !== "running") { + return; + } + if (canUseDirectClashApi()) { + const updatesId = ++connectionsUpdatesId; + void connectToConnectionsSocket(updatesId); + return; + } + startConnectionsPolling(); +} +function stopConnectionsUpdates() { + connectionsUpdatesId += 1; + if (connectionsPollTimer) { + clearInterval(connectionsPollTimer); + connectionsPollTimer = null; + } + if (connectionsSocketUrl) { + socket.disconnect(connectionsSocketUrl); + connectionsSocketUrl = ""; + } +} +function setServiceAvailability(next) { + if (serviceAvailability === next) { + return; + } + serviceAvailability = next; + if (next === "running") { + loading = true; + failed = false; + startConnectionsUpdates(); + } else { + stopConnectionsUpdates(); + pendingConnectionsPayload = null; + if (next === "stopped") { + loading = false; + failed = false; + activeConnections.clear(); + closedConnections.clear(); + closingConnectionIds.clear(); + } else if (next === "unavailable") { + loading = false; + failed = true; + } + } + renderControls(); + renderConnections(); +} +function watchServiceState() { + serviceStateUnsubscribe?.(); + serviceStateUnsubscribe = subscribeRuntimeUiState((uiState) => { + if (!monitoringMounted) { + return; + } + setServiceAvailability( + getServiceAvailability({ + loading: false, + failed: false, + running: uiState.service.tachyon.running + }) + ); + }); +} +function resetMonitoringState() { + activeTab = "active"; + selectedDeviceFilter = ALL_FILTER_VALUE; + searchQuery = ""; + lastDeviceFilterSignature = ""; + loading = true; + failed = false; + closingAll = false; + monitoringPaused = false; + monitoringPausedAt = null; + serviceAvailability = "loading"; + pendingConnectionsPayload = null; + activeConnections.clear(); + closedConnections.clear(); + closingConnectionIds.clear(); + const searchInput = document.getElementById( + "monitoring-search" + ); + if (searchInput) { + searchInput.value = ""; + } +} +async function onPageMount3() { + onPageUnmount3(); + monitoringMounted = true; + monitoringMountId += 1; + const mountId = monitoringMountId; + resetMonitoringState(); + bindControls(); + renderControls(); + renderConnections(); + watchServiceState(); + void loadLocalDevices(); + void loadRouteDisplayNames(); + if (getCachedRuntimeUiState()) { + void refreshRuntimeUiState({ force: true }); + } else { + const uiState = await refreshRuntimeUiState({ force: true }); + if (!monitoringMounted || mountId !== monitoringMountId) { + return; + } + if (!uiState && serviceAvailability === "loading") { + setServiceAvailability("unavailable"); + } + } + document.addEventListener("selectionchange", flushRenderAfterSelection); + document.addEventListener("copy", handleMonitoringValueCopy); + renderTimer = setInterval(() => { + if (monitoringPaused) { + return; + } + renderConnections(); + }, RENDER_INTERVAL_MS); +} +function onPageUnmount3() { + monitoringMounted = false; + monitoringMountId += 1; + if (renderTimer) { + clearInterval(renderTimer); + renderTimer = null; + } + stopConnectionsUpdates(); + serviceStateUnsubscribe?.(); + serviceStateUnsubscribe = null; + document.removeEventListener("selectionchange", flushRenderAfterSelection); + document.removeEventListener("copy", handleMonitoringValueCopy); +} +function registerLifecycleListeners3() { + if (monitoringLifecycleRegistered) { + return; + } + monitoringLifecycleRegistered = true; + store.subscribe( + (next, prev, diff) => { + if (diff.tabService && next.tabService.current !== prev.tabService.current) { + const isMonitoringVisible = next.tabService.current === "monitoring"; + if (isMonitoringVisible) { + return onPageMount3(); + } + if (!isMonitoringVisible) { + return onPageUnmount3(); + } + } + } + ); +} +async function initController3(controllerDependencies = {}) { + dependencies = { + ...dependencies, + ...controllerDependencies + }; + if (monitoringControllerInitialized) { + return; + } + monitoringControllerInitialized = true; + onMount("monitoring-status").then(() => { + registerLifecycleListeners3(); + if (store.get().tabService.current === "monitoring") { + onPageMount3(); + } + }); +} + +// src/tachyon/tabs/monitoring/styles.ts +var styles5 = ` +#cbi-${TACHYON_UCI_PACKAGE}-monitoring-_mount_node { + margin: 16px 0 22px; + padding: 0; +} + +#cbi-${TACHYON_UCI_PACKAGE}-monitoring-_mount_node > .cbi-value-title { + display: none; +} + +#cbi-${TACHYON_UCI_PACKAGE}-monitoring-_mount_node > .cbi-value-field { + margin-left: 0; + width: 100%; +} + +#cbi-${TACHYON_UCI_PACKAGE}-monitoring-_mount_node > div { + width: 100%; +} + +#cbi-${TACHYON_UCI_PACKAGE}-monitoring > h3 { + display: none; +} + +.fkp_monitoring-page { + --fkp-monitoring-control-height: 34px; + --fkp-monitoring-row-action-size: 24px; + --fkp-monitoring-divider-color: rgba(127, 127, 127, 0.22); + --fkp-monitoring-soft-bg: rgba(127, 127, 127, 0.08); + --fkp-monitoring-soft-bg-hover: rgba(127, 127, 127, 0.14); + --fkp-monitoring-danger-color: var(--error-color-medium, #d32f2f); + --fkp-monitoring-success-color: var(--success-color-medium, #2e7d32); + --fkp-monitoring-paused-color: var(--primary-color-high, #1976d2); + + width: 100%; + min-width: 0; +} + +.fkp_monitoring-page__panel { + margin-top: 0; + border: 0; + border-radius: 0; + padding: 0; + background: transparent; + box-sizing: border-box; + width: 100%; + min-width: 0; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__icon-button { + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; + padding: 0; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; + margin: 0; + border: 1px solid var(--fkp-monitoring-divider-color) !important; + border-radius: 6px; + background: var(--fkp-monitoring-soft-bg) !important; + color: var(--text-color-medium) !important; + box-shadow: none; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__icon-button:hover:not(:disabled) { + background: var(--fkp-monitoring-soft-bg-hover) !important; + color: var(--text-color-high) !important; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__icon-button--active { + background: rgba(25, 118, 210, 0.16) !important; + color: var(--primary-color-high, #1976d2) !important; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__icon-button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.fkp_monitoring-page #monitoring-close-all.btn.fkp_monitoring-page__icon-button { + order: 2; + border-color: rgba(217, 83, 79, 0.4) !important; + background: transparent !important; + color: #d9534f !important; +} + +.fkp_monitoring-page #monitoring-close-all.btn.fkp_monitoring-page__icon-button:hover:not(:disabled) { + border-color: rgba(217, 83, 79, 0.6) !important; + background: transparent !important; + color: #d9534f !important; +} + +.fkp_monitoring-page #monitoring-pause-toggle.btn.fkp_monitoring-page__icon-button, +.fkp_monitoring-page #monitoring-pause-toggle.btn.fkp_monitoring-page__icon-button--active { + order: 1; + border-color: rgba(128, 128, 128, 0.3) !important; + background: transparent !important; + color: var(--text-color-medium, #888) !important; +} + +.fkp_monitoring-page #monitoring-pause-toggle.btn.fkp_monitoring-page__icon-button:hover:not(:disabled), +.fkp_monitoring-page #monitoring-pause-toggle.btn.fkp_monitoring-page__icon-button--active:hover:not(:disabled) { + border-color: rgba(128, 128, 128, 0.6) !important; + background: transparent !important; + color: var(--text-color-high, #eee) !important; +} + +.fkp_monitoring-page__icon-button svg, +.fkp_monitoring-page__row-action svg { + width: 16px; + height: 16px; + display: block; + flex: 0 0 auto; +} + +.fkp_monitoring-page__controls { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; + width: 100%; + min-width: 0; +} + +.fkp_monitoring-page__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + +.fkp_monitoring-page__tabs { + display: inline-flex; + align-items: center; + gap: 2px; + width: max-content; + padding: 2px; + border: 1px solid var(--fkp-monitoring-divider-color); + border-radius: 6px; + background: var(--fkp-monitoring-soft-bg); + box-sizing: border-box; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__tab { + height: calc(var(--fkp-monitoring-control-height) - 6px); + min-height: calc(var(--fkp-monitoring-control-height) - 6px); + margin: 0; + padding: 0 12px; + border: 0 !important; + border-radius: 4px; + background: transparent !important; + color: var(--text-color-medium) !important; + box-shadow: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 600; + line-height: 1; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__tab:hover { + background: var(--fkp-monitoring-soft-bg-hover) !important; + color: var(--text-color-high) !important; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__tab--active { + background: rgba(25, 118, 210, 0.16) !important; + color: var(--primary-color-high, #1976d2) !important; + font-weight: 700; +} + +.fkp_monitoring-page__tab-label { + display: inline-block; +} + +.fkp_monitoring-page__tab-badge { + min-width: 18px; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: rgba(127, 127, 127, 0.22); + color: var(--text-color-medium); + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + font-size: 12px; + font-weight: 700; + line-height: 1; +} + +.fkp_monitoring-page__tab--active .fkp_monitoring-page__tab-badge { + background: rgba(25, 118, 210, 0.22); + color: var(--primary-color-high, #1976d2); +} + +.fkp_monitoring-page__filters { + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 12px; + min-width: 0; +} + +.fkp_monitoring-page__device-filter { + width: min(220px, 100%); + min-width: 0; + height: var(--fkp-monitoring-control-height) !important; + min-height: var(--fkp-monitoring-control-height) !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + margin: 0 !important; + box-sizing: border-box; + line-height: calc(var(--fkp-monitoring-control-height) - 2px) !important; +} + +.fkp_monitoring-page__search { + position: relative; + display: flex; + align-items: center; + width: min(320px, 100%); + min-width: 0; + height: var(--fkp-monitoring-control-height); + margin: 0; +} + +.fkp_monitoring-page__search-icon { + position: absolute; + left: 8px; + width: 16px; + height: 16px; + color: var(--text-color-medium); + pointer-events: none; +} + +.fkp_monitoring-page__search-icon svg { + width: 16px; + height: 16px; + display: block; +} + +.fkp_monitoring-page__search-input { + width: 100%; + height: var(--fkp-monitoring-control-height) !important; + min-height: var(--fkp-monitoring-control-height) !important; + padding-left: 30px !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + margin: 0 !important; + box-sizing: border-box; + line-height: calc(var(--fkp-monitoring-control-height) - 2px) !important; +} + +.fkp_monitoring-page__body { + margin-top: 0; + width: 100%; + min-width: 0; +} + +.fkp_monitoring-page__table-wrap { + width: 100%; + overflow-x: auto; + margin-bottom: 0; +} + +.fkp_monitoring-page__table { + width: 100%; + min-width: 840px; + table-layout: fixed; + border-collapse: collapse; + border-spacing: 0; + margin-bottom: 0; +} + +.fkp_monitoring-page__table th, +.fkp_monitoring-page__table td { + padding: 8px 6px; + border-bottom: 1px solid var(--fkp-monitoring-divider-color); + box-sizing: border-box; + text-align: left; + vertical-align: middle; + overflow: hidden; + white-space: nowrap; +} + +.fkp_monitoring-page__table th { + color: var(--text-color-medium); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + white-space: nowrap; + border-bottom-color: rgba(127, 127, 127, 0.32); +} + +.fkp_monitoring-page__table th:nth-child(1) { + width: 28%; +} + +.fkp_monitoring-page__table th:nth-child(2) { + width: 6%; +} + +.fkp_monitoring-page__table th:nth-child(3) { + width: 16%; +} + +.fkp_monitoring-page__table th:nth-child(4) { + width: 8%; +} + +.fkp_monitoring-page__table th:nth-child(5) { + width: 9.5%; +} + +.fkp_monitoring-page__table th:nth-child(6) { + width: 8.5%; +} + +.fkp_monitoring-page__table th:nth-child(7) { + width: 16%; +} + +.fkp_monitoring-page__table th:nth-child(8) { + width: 8%; +} + +.fkp_monitoring-page__table tbody tr:last-child td { + border-bottom: 0; +} + +.fkp_monitoring-page__table td:last-child { + padding-top: 0; + padding-bottom: 0; +} + +.fkp_monitoring-page__table th:nth-child(4), +.fkp_monitoring-page__table td:nth-child(4), +.fkp_monitoring-page__table th:nth-child(5), +.fkp_monitoring-page__table td:nth-child(5), +.fkp_monitoring-page__table th:nth-child(6), +.fkp_monitoring-page__table td:nth-child(6) { + text-align: right; +} + +.fkp_monitoring-page__table th:nth-child(7), +.fkp_monitoring-page__table td:nth-child(7) { + text-align: left; +} + +.fkp_monitoring-page__table th:last-child, +.fkp_monitoring-page__table td:last-child { + text-align: center; +} + +.fkp_monitoring-page__value { + display: block; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + line-height: 1.3; + color: var(--text-color-high); + font-size: 13px; + user-select: text; +} + +.fkp_monitoring-page__source-value { + display: flex; + align-items: baseline; + justify-content: flex-start; + gap: 5px; +} + +.fkp_monitoring-page__source-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fkp_monitoring-page__source-ip { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-color-medium); + font-size: 12px; +} + +.fkp_monitoring-page__source-value--ip-only { + color: var(--text-color-high); +} + +.fkp_monitoring-page__cell-main { + color: var(--text-color-high); + font-weight: 600; + line-height: 1.25; +} + +.fkp_monitoring-page__cell-secondary { + margin-top: 2px; + color: var(--text-color-medium); + font-size: 12px; + line-height: 1.25; +} + +.fkp_monitoring-page__route { + display: inline-block; + width: auto; + padding: 2px 6px; + border-radius: 4px; + background: rgba(128, 128, 128, 0.15); + color: var(--text-color-high, #eee); + font-size: 11px; + font-weight: 500; +} + +.fkp_monitoring-page__network { + background: transparent; + border: 0; + padding: 0; + color: var(--text-color-medium, #bbb); + font-family: inherit; + font-size: 13px; + text-transform: lowercase; +} + +.fkp_monitoring-page__table td:nth-child(4) .fkp_monitoring-page__value, +.fkp_monitoring-page__table td:nth-child(5) .fkp_monitoring-page__value, +.fkp_monitoring-page__table td:nth-child(6) .fkp_monitoring-page__value { + color: var(--text-color-medium, #bbb); + font-family: inherit; + text-align: right; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__row-action { + width: var(--fkp-monitoring-row-action-size); + height: var(--fkp-monitoring-row-action-size); + min-width: var(--fkp-monitoring-row-action-size); + min-height: var(--fkp-monitoring-row-action-size); + padding: 0; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + margin: 0; + border: 0 !important; + border-radius: 999px; + background: transparent !important; + color: var(--fkp-monitoring-danger-color) !important; + box-shadow: none; + cursor: pointer; +} + +.fkp_monitoring-page__row-action svg { + width: 14px; + height: 14px; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__row-action:hover:not(:disabled) { + background: var(--fkp-monitoring-soft-bg-hover) !important; + color: var(--fkp-monitoring-danger-color) !important; +} + +.fkp_monitoring-page .btn.fkp_monitoring-page__row-action:disabled { + opacity: 0.45; + cursor: wait; +} + +.fkp_monitoring-page__row--closing { + opacity: 0.55; +} + +.fkp_monitoring-page__state { + min-height: 90px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-color-medium); + text-align: center; + box-sizing: border-box; +} + +.fkp_monitoring-page__state-cell { + padding: 0 !important; +} + +.fkp_monitoring-page__state--error { + color: var(--error-color-medium, #d32f2f); +} + +@media (max-width: 900px) { + .fkp_monitoring-page__controls { + align-items: center; + } + + .fkp_monitoring-page__tabs { + flex: 1 0 100%; + } + + .fkp_monitoring-page__filters { + flex: 1 1 0; + } + + .fkp_monitoring-page__device-filter, + .fkp_monitoring-page__search { + max-width: none; + } + + .fkp_monitoring-page__table { + min-width: 0; + } + + .fkp_monitoring-page__table thead { + display: none; + } + + .fkp_monitoring-page__table, + .fkp_monitoring-page__table tbody, + .fkp_monitoring-page__table tr, + .fkp_monitoring-page__table td { + display: block; + width: 100%; + } + + .fkp_monitoring-page__table tr { + border: 1px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 8px; + box-sizing: border-box; + margin-bottom: 8px; + } + + .fkp_monitoring-page__table td { + display: grid; + grid-template-columns: minmax(92px, 34%) minmax(0, 1fr); + gap: 8px; + border: 0; + border-bottom: 1px solid var(--fkp-monitoring-divider-color); + padding: 4px 0; + box-sizing: border-box; + text-align: left; + } + + .fkp_monitoring-page__table td::before { + content: attr(data-label); + color: var(--text-color-medium); + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .fkp_monitoring-page__table td:last-child { + grid-template-columns: minmax(92px, 34%) minmax(0, 1fr); + align-items: center; + border-bottom: 0; + min-height: var(--fkp-monitoring-row-action-size); + padding: 0; + } + + .fkp_monitoring-page__value { + text-align: right; + } + + .fkp_monitoring-page__source-value { + justify-content: flex-end; + } + + .fkp_monitoring-page__state-row td::before { + display: none; + } +} + +@media (max-width: 520px) { + .fkp_monitoring-page__controls, + .fkp_monitoring-page__filters { + align-items: stretch; + } + + .fkp_monitoring-page__tabs, + .fkp_monitoring-page__filters, + .fkp_monitoring-page__device-filter, + .fkp_monitoring-page__search { + width: 100%; + } + + .fkp_monitoring-page__controls, + .fkp_monitoring-page__filters { + flex-direction: column; + } + + .fkp_monitoring-page__actions { + align-self: flex-end; + } + + .fkp_monitoring-page__tabs { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + width: 100%; + } + + .fkp_monitoring-page__table td { + grid-template-columns: 1fr; + gap: 2px; + } + + .fkp_monitoring-page__value { + text-align: left; + } + + .fkp_monitoring-page__source-value { + justify-content: flex-start; + } +} +`; + +// src/tachyon/tabs/monitoring/index.ts +var MonitoringTab = { + render: render3, + initController: initController3, + styles: styles5 +}; + +// src/tachyon/tabs/updates/render.ts +function render4() { + return E("div", { id: "updates-status", class: "fkp_updates-page" }, [ + E("div", { + id: "fkp_updates-components", + class: "fkp_updates-page__components" + }) + ]); +} + +// src/tachyon/tabs/updates/componentActionCompletion.ts +function shouldApplyCompletedComponentActionResult(result, notify) { + return result.action !== "check_update" || notify; +} + +// src/tachyon/tabs/updates/checkResultLifecycle.ts +function shouldPreserveCompletedCheckResultOnNextMount({ + action, + mounted +}) { + return action === "check_update" && !mounted; +} +function shouldResetCheckResultsOnMount({ + anyActionLoading, + preserveCheckResultsOnNextMount: preserveCheckResultsOnNextMount2, + persistentCacheEnabled = false +}) { + return !persistentCacheEnabled && !anyActionLoading && !preserveCheckResultsOnNextMount2; +} +function shouldRefreshComponentStateBeforeRender(uiState) { + return Boolean( + uiState?.actions.component.some((state) => state.running === true) + ); +} +function shouldExposeCheckResults({ + mounted, + cacheResolved +}) { + return mounted && cacheResolved; +} + +// src/tachyon/tabs/updates/initController.ts +var updatesLifecycleRegistered = false; +var updatesControllerInitialized = false; +var updatesMounted = false; +var updatesMountId = 0; +var pageUnloading2 = false; +var preserveCheckResultsOnNextMount = false; +var componentUpdateCheckCacheResolved = false; +var componentUpdateCheckCacheSnapshot = null; +var componentUpdateCheckCachePromise = null; +var componentActionStateUnsubscribe = null; +var componentActionStateRefreshPromise = null; +var followedComponentJobs = /* @__PURE__ */ new Set(); +var handledComponentJobs = /* @__PURE__ */ new Set(); +if (typeof window !== "undefined") { + window.addEventListener("pagehide", () => { + pageUnloading2 = true; + }); + window.addEventListener("pageshow", () => { + pageUnloading2 = false; + }); +} +function isNotInstalled(version) { + return !version || version === "not installed"; +} +function shouldShowInstallAfterCheck(component) { + const status = getVisibleCheckResult(component)?.status; + return status === "outdated" || status === "dev"; +} +function getVisibleCheckResult(component) { + if (!shouldExposeCheckResults({ + mounted: updatesMounted, + cacheResolved: componentUpdateCheckCacheResolved + })) { + return null; + } + return store.get().updatesChecks[component]; +} +function getLatestVersion(component) { + const checkResult = getVisibleCheckResult(component); + if (!checkResult || !shouldShowInstallAfterCheck(component)) { + return void 0; + } + return checkResult.latest_version || void 0; +} +function getGitHubReleaseUrl(component) { + const checkResult = getVisibleCheckResult(component); + if (!checkResult || !shouldShowInstallAfterCheck(component) || !checkResult.release_url) { + return void 0; + } + return checkResult.release_url; +} +function isAnyActionLoading() { + return Object.values(store.get().updatesActions).some((item) => item.loading); +} +function isServiceRuntimeActionLoading() { + const state = store.get(); + return hasLocalMutatingServiceActionLoading(state.diagnosticsActions) || isServiceTransitionStatus(state.servicesInfoWidget.data.tachyonStatus); +} +function isSystemInfoLoading() { + const systemInfo = store.get().diagnosticsSystemInfo; + return systemInfo.loading || !systemInfo.loaded; +} +function setActionLoading(action, loading2, local = false) { + if (local || !loading2) { + setLocalComponentAction(action, loading2 && local); + } + const updatesActions = store.get().updatesActions; + store.set({ + updatesActions: { + ...updatesActions, + [action]: { loading: loading2 } + } + }); +} +function beginComponentAction(button) { + if (isAnyActionLoading()) { + return false; + } + setActionLoading(button.key, true, true); + return true; +} +function setCheckResult(component, status, latestVersion, releaseUrl = "") { + const updatesChecks = store.get().updatesChecks; + store.set({ + updatesChecks: { + ...updatesChecks, + [component]: { + status, + latest_version: latestVersion, + release_url: releaseUrl + } + } + }); +} +function resetCheckResult(component) { + setCheckResult(component, null, ""); +} +function applyCachedCheckResults(results) { + results.forEach((result) => { + const status = result.status || null; + if (status === "latest" || status === "outdated" || status === "dev") { + setCheckResult( + result.component, + status, + result.latest_version || "", + result.release_url || "" + ); + } + }); +} +function loadComponentUpdateCheckCache({ force = false } = {}) { + if (!force && componentUpdateCheckCacheSnapshot) { + return Promise.resolve(componentUpdateCheckCacheSnapshot); + } + if (componentUpdateCheckCachePromise) { + return componentUpdateCheckCachePromise; + } + const promise = TachyonShellMethods.componentUpdateCheckCache().then( + (response) => response.success ? response.data : { + enabled: false, + results: [] + } + ).then((cache) => { + componentUpdateCheckCacheSnapshot = cache; + return cache; + }).finally(() => { + if (componentUpdateCheckCachePromise === promise) { + componentUpdateCheckCachePromise = null; + } + }); + componentUpdateCheckCachePromise = promise; + return promise; +} +function getErrorMessage(error, fallback) { + return error instanceof Error && error.message ? error.message : fallback; +} +async function ackComponentActionJob(jobId) { + try { + const response = await TachyonShellMethods.uiActionAck("component", jobId); + if (!response.success) { + logger.debug("[UPDATES]", "component action ack failed", response.error); + } + } catch (error) { + logger.debug("[UPDATES]", "component action ack failed", error); + } +} +function getExpectedLatestVersionForAction(button) { + if (button.component !== "tachyon" || button.action !== "install") { + return void 0; + } + return store.get().updatesChecks[button.component].latest_version || void 0; +} +function getCheckToastMessage(status) { + if (status === "outdated") { + return _("Update is available"); + } + if (status === "dev") { + return _("Installed version is newer than release"); + } + return _("Latest version is installed"); +} +async function refreshSystemInfoAfterMutation() { + await ensureSystemInfo({ force: true, silent: true }); +} +function notifyActionProvidersAvailabilityChanged(systemInfo) { + if (typeof window === "undefined" || typeof CustomEvent === "undefined") { + return; + } + window.dispatchEvent( + new CustomEvent(TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT, { + detail: { + zapretInstalled: Boolean(systemInfo.zapret_installed), + zapret2Installed: Boolean(systemInfo.zapret2_installed), + byedpiInstalled: Boolean(systemInfo.byedpi_installed) + } + }) + ); +} +function reloadPageAfterTachyonUpdate() { + window.setTimeout(() => { + window.location.reload(); + }, 1200); +} +function patchSystemInfoAfterMutation(result) { + const systemInfo = store.get().diagnosticsSystemInfo; + const nextSystemInfo = { ...systemInfo, loading: false, loaded: true }; + const version = result.current_version || result.latest_version || _("unknown"); + if (result.component === "tachyon" && result.action === "install") { + nextSystemInfo.tachyon_version = version; + } + if (result.component === "sing_box") { + nextSystemInfo.sing_box_version = version; + if (result.action === "install_extended") { + nextSystemInfo.sing_box_extended = 1; + nextSystemInfo.sing_box_tiny = 0; + nextSystemInfo.sing_box_compressed = 0; + nextSystemInfo.sing_box_tailscale = 1; + } + if (result.action === "install_extended_compressed") { + nextSystemInfo.sing_box_extended = 1; + nextSystemInfo.sing_box_tiny = 0; + nextSystemInfo.sing_box_compressed = 1; + nextSystemInfo.sing_box_tailscale = 1; + } + if (result.action === "install_stable") { + nextSystemInfo.sing_box_extended = 0; + nextSystemInfo.sing_box_tiny = 0; + nextSystemInfo.sing_box_compressed = 0; + nextSystemInfo.sing_box_tailscale = 1; + } + if (result.action === "install_tiny") { + nextSystemInfo.sing_box_extended = 0; + nextSystemInfo.sing_box_tiny = 1; + nextSystemInfo.sing_box_compressed = 0; + nextSystemInfo.sing_box_tailscale = 0; + } + } + if (result.component === "zapret") { + nextSystemInfo.providerInfoLoaded = true; + if (result.action === "remove") { + nextSystemInfo.zapret_installed = 0; + nextSystemInfo.zapret_version = "not installed"; + } else { + nextSystemInfo.zapret_installed = 1; + nextSystemInfo.zapret_version = version; + } + } + if (result.component === "zapret2") { + nextSystemInfo.providerInfoLoaded = true; + if (result.action === "remove") { + nextSystemInfo.zapret2_installed = 0; + nextSystemInfo.zapret2_version = "not installed"; + } else { + nextSystemInfo.zapret2_installed = 1; + nextSystemInfo.zapret2_version = version; + } + } + if (result.component === "byedpi") { + nextSystemInfo.providerInfoLoaded = true; + if (result.action === "remove") { + nextSystemInfo.byedpi_installed = 0; + nextSystemInfo.byedpi_version = "not installed"; + } else { + nextSystemInfo.byedpi_installed = 1; + nextSystemInfo.byedpi_version = version; + } + } + const normalizedSystemInfo = normalizeSingBoxVariantFields(nextSystemInfo); + store.set({ + diagnosticsSystemInfo: normalizedSystemInfo + }); + if (result.component === "zapret" || result.component === "zapret2" || result.component === "byedpi") { + notifyActionProvidersAvailabilityChanged(normalizedSystemInfo); + } +} +async function applyCompletedComponentAction({ + key, + result, + notify +}) { + if (result.action === "check_update") { + setActionLoading(key, false); + if (!shouldApplyCompletedComponentActionResult(result, notify)) { + return; + } + if (shouldPreserveCompletedCheckResultOnNextMount({ + action: result.action, + mounted: updatesMounted + })) { + preserveCheckResultsOnNextMount = true; + } + const status = result.status || null; + if (status === "latest" || status === "outdated" || status === "dev") { + setCheckResult( + result.component, + status, + result.latest_version || "", + result.release_url || "" + ); + } + if (notify) { + showToast(getCheckToastMessage(status), "success"); + } + return; + } + if (result.action === "install" || result.action.startsWith("install_")) { + setCheckResult(result.component, "latest", result.latest_version || ""); + } else { + resetCheckResult(result.component); + } + patchSystemInfoAfterMutation(result); + setActionLoading(key, false); + if (result.component === "tachyon" && result.action === "install") { + if (notify && result.message) { + showToast(result.message, "success", 1200); + } + if (notify) { + reloadPageAfterTachyonUpdate(); + } + return; + } + if (notify && result.message) { + showToast(result.message, "success"); + } + void refreshSystemInfoAfterMutation(); +} +async function completeComponentActionJob(key, jobId, response) { + if (pageUnloading2) { + setActionLoading(key, false); + return; + } + const alreadyHandled = handledComponentJobs.has(jobId); + if (alreadyHandled) { + setActionLoading(key, false); + return; + } + const shouldNotify = shouldNotifyOwnedUiAction("component", jobId); + if (!response.success || response.data.success === false) { + const message = response.success ? response.data.message || _("Failed to execute") : response.error || _("Failed to execute"); + if (isTransientRpcError(message)) { + setActionLoading(key, false); + void refreshComponentActionState(); + return; + } + handledComponentJobs.add(jobId); + setActionLoading(key, false); + if (shouldNotify) { + showToast(message, "error"); + } + await ackComponentActionJob(jobId); + return; + } + handledComponentJobs.add(jobId); + await ackComponentActionJob(jobId); + await applyCompletedComponentAction({ + key, + result: response.data, + notify: shouldNotify + }); +} +async function followComponentActionState(state) { + const jobId = state.job_id; + const key = getComponentActionKey(state.component, state.action); + if (!jobId || !key || followedComponentJobs.has(jobId)) { + return; + } + if (!state.running && handledComponentJobs.has(jobId)) { + return; + } + followedComponentJobs.add(jobId); + if (shouldShowLoadingForRestoredAction(state)) { + setActionLoading(key, true); + } + try { + const response = state.running ? await TachyonShellMethods.waitComponentActionJob( + jobId, + state.component, + state.action, + state.latest_version || void 0 + ) : { + success: true, + data: state + }; + await completeComponentActionJob(key, jobId, response); + } catch (error) { + logger.error("[UPDATES]", "followComponentActionState failed", error); + if (!pageUnloading2) { + const message = getErrorMessage(error, _("Failed to execute")); + setActionLoading(key, false); + if (!isTransientRpcError(message)) { + showToast(message, "error"); + } + } + } finally { + followedComponentJobs.delete(jobId); + } +} +async function followAlreadyRunningComponentAction(button) { + const uiState = await refreshRuntimeUiState({ force: true }); + if (!uiState) { + return false; + } + const state = uiState.actions.component.find( + (item) => item.running && item.component === button.component && item.action === button.action + ); + if (!state) { + return false; + } + if (state.job_id) { + markUiActionOwned("component", state.job_id); + } + await followComponentActionState(state); + return true; +} +function isComponentActionAlreadyRunningError(message) { + return Boolean( + message && message.includes("Another component action is already running") + ); +} +function handleComponentUiState(uiState) { + for (const state of uiState.actions.component || []) { + void followComponentActionState(state); + } +} +async function refreshComponentActionState() { + if (componentActionStateRefreshPromise) { + return componentActionStateRefreshPromise; + } + componentActionStateRefreshPromise = (async () => { + if (!updatesMounted) { + return; + } + const state = await refreshRuntimeUiState({ force: true }); + if (!state) { + return; + } + handleComponentUiState(state); + })().finally(() => { + componentActionStateRefreshPromise = null; + }); + return componentActionStateRefreshPromise; +} +function startComponentActionStateWatcher() { + if (componentActionStateUnsubscribe) { + return; + } + componentActionStateUnsubscribe = subscribeRuntimeUiState((uiState) => { + if (updatesMounted) { + handleComponentUiState(uiState); + } + }); +} +function stopComponentActionStateWatcher() { + if (!componentActionStateUnsubscribe) { + return; + } + componentActionStateUnsubscribe(); + componentActionStateUnsubscribe = null; +} +async function handleComponentAction(button) { + if (!beginComponentAction(button)) { + return; + } + let jobId = ""; + let ownsJobFollow = false; + try { + const startResponse = await TachyonShellMethods.componentActionStart( + button.component, + button.action + ); + if (!startResponse.success) { + if (isComponentActionAlreadyRunningError(startResponse.error)) { + setActionLoading(button.key, false); + if (!await followAlreadyRunningComponentAction(button)) { + await refreshComponentActionState(); + } + return; + } + if (isTransientRpcError(startResponse.error)) { + if (!await followAlreadyRunningComponentAction(button)) { + setActionLoading(button.key, false); + await refreshComponentActionState(); + } + return; + } + throw new Error(startResponse.error); + } + jobId = startResponse.data.job_id; + markUiActionOwned("component", jobId); + if (followedComponentJobs.has(jobId)) { + return; + } + followedComponentJobs.add(jobId); + ownsJobFollow = true; + const response = await TachyonShellMethods.waitComponentActionJob( + jobId, + button.component, + button.action, + getExpectedLatestVersionForAction(button) + ); + await completeComponentActionJob(button.key, jobId, response); + } catch (error) { + logger.error("[UPDATES]", "handleComponentAction failed", error); + if (!pageUnloading2) { + const message = getErrorMessage(error, _("Failed to execute")); + setActionLoading(button.key, false); + if (!isTransientRpcError(message)) { + showToast(message, "error"); + } + void refreshComponentActionState(); + } + } finally { + if (ownsJobFollow) { + followedComponentJobs.delete(jobId); + } + } +} +function getCheckAction(component, key) { + return { + key, + text: _("Check update"), + icon: renderSearchIcon24, + component, + action: "check_update" + }; +} +function getInstallAction(component, key, installed) { + return { + key, + text: installed ? _("Update") : _("Install"), + icon: installed ? renderRotateCcwIcon24 : renderDownloadIcon24, + component, + action: "install" + }; +} +function getInstalledUpdateActions(component, checkKey, installKey, installed = true) { + if (!installed) { + return []; + } + const actions = [getCheckAction(component, checkKey)]; + if (shouldShowInstallAfterCheck(component)) { + actions.push(getInstallAction(component, installKey, true)); + } + return actions; +} +function getOptionalComponentActions({ + component, + installed, + checkKey, + installKey, + removeKey +}) { + if (!installed) { + return [getInstallAction(component, installKey, false)]; + } + return [ + ...getInstalledUpdateActions(component, checkKey, installKey), + { + key: removeKey, + text: _("Remove"), + icon: renderXIcon24, + component, + action: "remove" + } + ]; +} +function getComponentCards() { + const systemInfo = normalizeSingBoxVariantFields( + store.get().diagnosticsSystemInfo + ); + const systemInfoLoading = isSystemInfoLoading(); + const zapretInstalled = Boolean(systemInfo.zapret_installed); + const zapret2Installed = Boolean(systemInfo.zapret2_installed); + const byedpiInstalled = Boolean(systemInfo.byedpi_installed); + const singBoxInstalled = !isNotInstalled(systemInfo.sing_box_version); + const singBoxStable = singBoxInstalled && !systemInfo.sing_box_extended && !systemInfo.sing_box_tiny; + const singBoxExtended = Boolean(systemInfo.sing_box_extended) && !systemInfo.sing_box_compressed; + const singBoxExtendedCompressed = Boolean(systemInfo.sing_box_extended) && Boolean(systemInfo.sing_box_compressed); + const singBoxTiny = Boolean(systemInfo.sing_box_tiny); + const tachyonActions = getInstalledUpdateActions( + "tachyon", + "tachyonCheck", + "tachyonInstall" + ); + const singBoxActions = getInstalledUpdateActions( + "sing_box", + "singBoxCheck", + "singBoxInstall", + singBoxInstalled + ); + if (!singBoxStable) { + singBoxActions.push({ + key: "singBoxInstallStable", + text: "Stable", + icon: renderDownloadIcon24, + component: "sing_box", + action: "install_stable" + }); + } + if (!singBoxTiny) { + singBoxActions.push({ + key: "singBoxInstallTiny", + text: "Tiny", + icon: renderDownloadIcon24, + component: "sing_box", + action: "install_tiny" + }); + } + if (!singBoxExtended) { + singBoxActions.push({ + key: "singBoxInstallExtended", + text: "Extended", + icon: renderDownloadIcon24, + component: "sing_box", + action: "install_extended" + }); + } + if (!singBoxExtendedCompressed) { + singBoxActions.push({ + key: "singBoxInstallExtendedCompressed", + text: "Extended compressed", + icon: renderDownloadIcon24, + component: "sing_box", + action: "install_extended_compressed" + }); + } + const zapretActions = getOptionalComponentActions({ + component: "zapret", + installed: zapretInstalled, + checkKey: "zapretCheck", + installKey: "zapretInstall", + removeKey: "zapretRemove" + }); + const zapret2Actions = getOptionalComponentActions({ + component: "zapret2", + installed: zapret2Installed, + checkKey: "zapret2Check", + installKey: "zapret2Install", + removeKey: "zapret2Remove" + }); + const byedpiActions = getOptionalComponentActions({ + component: "byedpi", + installed: byedpiInstalled, + checkKey: "byedpiCheck", + installKey: "byedpiInstall", + removeKey: "byedpiRemove" + }); + return [ + { + component: "tachyon", + column: 0, + title: "Tachyon", + version: systemInfoLoading ? _("Loading...") : normalizeCompiledVersion(systemInfo.tachyon_version), + latestVersion: getLatestVersion("tachyon"), + releaseUrl: getGitHubReleaseUrl("tachyon"), + actions: tachyonActions + }, + { + component: "sing_box", + column: 0, + title: "Sing-box", + version: systemInfoLoading ? _("Loading...") : formatSingBoxVersion(systemInfo), + latestVersion: getLatestVersion("sing_box"), + releaseUrl: getGitHubReleaseUrl("sing_box"), + actions: singBoxActions + }, + { + component: "zapret", + column: 1, + title: "Zapret", + version: systemInfoLoading ? _("Loading...") : zapretInstalled ? systemInfo.zapret_version : _("Not installed"), + latestVersion: getLatestVersion("zapret"), + releaseUrl: getGitHubReleaseUrl("zapret"), + actions: zapretActions + }, + { + component: "zapret2", + column: 1, + title: "Zapret2", + version: systemInfoLoading ? _("Loading...") : zapret2Installed ? systemInfo.zapret2_version : _("Not installed"), + latestVersion: getLatestVersion("zapret2"), + releaseUrl: getGitHubReleaseUrl("zapret2"), + actions: zapret2Actions + }, + { + component: "byedpi", + column: 1, + title: "ByeDPI", + version: systemInfoLoading ? _("Loading...") : byedpiInstalled ? systemInfo.byedpi_version : _("Not installed"), + latestVersion: getLatestVersion("byedpi"), + releaseUrl: getGitHubReleaseUrl("byedpi"), + actions: byedpiActions + } + ]; +} +function renderComponentCard(card) { + const updatesActions = store.get().updatesActions; + const anyActionLoading = isAnyActionLoading(); + const serviceRuntimeActionLoading = isServiceRuntimeActionLoading(); + const systemInfoLoading = isSystemInfoLoading(); + const headerChildren = [ + E("b", { class: "fkp_updates-page__component__title" }, card.title), + E( + "span", + { class: "fkp_updates-page__component__header-version" }, + card.version + ) + ]; + const header = E( + "div", + { class: "fkp_updates-page__component__header" }, + headerChildren + ); + const detailsChildren = []; + const checkResult = getVisibleCheckResult(card.component); + if (checkResult && checkResult.status) { + let labelText = ""; + const latestValueNodes = []; + if (checkResult.status === "outdated") { + labelText = _("Update is available:"); + const versionToShow = checkResult.latest_version || card.latestVersion || card.version; + if (checkResult.release_url) { + latestValueNodes.push( + E( + "a", + { + class: "fkp_updates-page__component__release-version-link", + href: checkResult.release_url, + target: "_blank", + rel: "noopener noreferrer" + }, + versionToShow || _("Open") + ) + ); + } else if (versionToShow) { + latestValueNodes.push(document.createTextNode(versionToShow)); + } + } else if (checkResult.status === "latest") { + labelText = _("Latest version is installed"); + } else if (checkResult.status === "dev") { + labelText = `${_("Installed version is newer than release")}. ${_("Latest version:")}`; + const versionToShow = checkResult.latest_version || card.latestVersion; + if (checkResult.release_url) { + latestValueNodes.push( + E( + "a", + { + class: "fkp_updates-page__component__release-version-link", + href: checkResult.release_url, + target: "_blank", + rel: "noopener noreferrer" + }, + versionToShow || _("Open") + ) + ); + } else if (versionToShow) { + latestValueNodes.push(document.createTextNode(versionToShow)); + } + } + if (labelText) { + const rowChildren = [ + E( + "span", + { class: "fkp_updates-page__component__info-label" }, + labelText + ) + ]; + if (latestValueNodes.length > 0) { + rowChildren.push( + E( + "span", + { + class: "fkp_updates-page__component__info-value fkp_updates-page__component__info-value--latest" + }, + latestValueNodes + ) + ); + } + detailsChildren.push( + E( + "div", + { class: "fkp_updates-page__component__info-row" }, + rowChildren + ) + ); + } + } + const detailsContainer = detailsChildren.length > 0 ? E( + "div", + { class: "fkp_updates-page__component__details" }, + detailsChildren + ) : null; + const primaryActions = []; + const dangerActions = []; + const variantActions = []; + card.actions.forEach((action) => { + if (action.action === "remove") { + dangerActions.push(action); + } else if (action.action.startsWith("install_")) { + variantActions.push(action); + } else { + primaryActions.push(action); + } + }); + const actionElements = []; + const primaryButtons = primaryActions.map((action) => { + const loading2 = updatesActions[action.key].loading; + const isUpdateOrInstall = action.action === "install"; + return renderButton({ + classNames: isUpdateOrInstall ? ["cbi-button-save"] : [], + text: action.text, + icon: action.icon, + loading: loading2, + disabled: systemInfoLoading || serviceRuntimeActionLoading || anyActionLoading && !loading2, + onClick: () => void handleComponentAction(action) + }); + }); + const dangerButtons = dangerActions.map((action) => { + const loading2 = updatesActions[action.key].loading; + return renderButton({ + classNames: ["cbi-button-remove"], + text: action.text, + icon: action.icon, + loading: loading2, + disabled: systemInfoLoading || serviceRuntimeActionLoading || anyActionLoading && !loading2, + onClick: () => void handleComponentAction(action) + }); + }); + if (primaryButtons.length > 0 || dangerButtons.length > 0) { + actionElements.push( + E("div", { class: "fkp_updates-page__component__actions-main" }, [ + ...primaryButtons, + ...dangerButtons + ]) + ); + } + if (variantActions.length > 0) { + const variantButtons = variantActions.map((action) => { + const loading2 = updatesActions[action.key].loading; + return renderButton({ + text: action.text, + icon: action.icon, + loading: loading2, + disabled: systemInfoLoading || serviceRuntimeActionLoading || anyActionLoading && !loading2, + onClick: () => void handleComponentAction(action) + }); + }); + actionElements.push( + E("div", { class: "fkp_updates-page__component__variants" }, [ + E( + "div", + { class: "fkp_updates-page__component__variants-title" }, + _("Install another build:") + ), + E( + "div", + { class: "fkp_updates-page__component__variants-buttons" }, + variantButtons + ) + ]) + ); + } + const actionsContainer = E( + "div", + { + class: [ + "fkp_updates-page__component__actions", + detailsContainer ? "fkp_updates-page__component__actions--with-details" : "" + ].filter(Boolean).join(" ") + }, + actionElements + ); + const cardChildren = [header]; + if (detailsContainer) { + cardChildren.push(detailsContainer); + } + cardChildren.push(actionsContainer); + return E("div", { class: "fkp_updates-page__component" }, cardChildren); +} +function renderUpdatesComponents() { + const container = document.getElementById("fkp_updates-components"); + if (!container) { + return; + } + const columns = [[], []]; + getComponentCards().forEach((card) => { + columns[card.column].push(renderComponentCard(card)); + }); + return preserveScrollForPage(() => { + container.replaceChildren( + E("div", { class: "fkp_updates-page__components-column" }, columns[0]), + E("div", { class: "fkp_updates-page__components-column" }, columns[1]) + ); + }); +} +function onStoreUpdate3(_next, _prev, diff) { + if (diff.diagnosticsSystemInfo || diff.updatesActions || diff.updatesChecks || diff.diagnosticsActions || diff.servicesInfoWidget) { + renderUpdatesComponents(); + } +} +function applyComponentUpdateCheckCache(componentUpdateCheckCache) { + componentUpdateCheckCacheResolved = true; + if (componentUpdateCheckCache.enabled) { + store.reset(["updatesChecks"]); + applyCachedCheckResults(componentUpdateCheckCache.results); + } + if (shouldResetCheckResultsOnMount({ + anyActionLoading: isAnyActionLoading(), + preserveCheckResultsOnNextMount, + persistentCacheEnabled: componentUpdateCheckCache.enabled + })) { + store.reset(["updatesChecks"]); + } +} +async function onPageMount4() { + onPageUnmount4(); + updatesMounted = true; + updatesMountId += 1; + const mountId = updatesMountId; + const cachedRuntimeState = getCachedRuntimeUiState(); + const hasRuntimeSnapshot = Boolean(cachedRuntimeState); + const needsFreshStateBeforeRender = shouldRefreshComponentStateBeforeRender(cachedRuntimeState); + const runtimeStateRefreshPromise = !hasRuntimeSnapshot || needsFreshStateBeforeRender ? refreshRuntimeUiState({ force: true }) : null; + const prefetchedComponentUpdateCheckCache = componentUpdateCheckCacheSnapshot; + if (prefetchedComponentUpdateCheckCache) { + applyComponentUpdateCheckCache(prefetchedComponentUpdateCheckCache); + } + renderUpdatesComponents(); + const componentUpdateCheckCache = await loadComponentUpdateCheckCache({ + force: Boolean(prefetchedComponentUpdateCheckCache) + }); + if (!updatesMounted || mountId !== updatesMountId) { + return; + } + applyComponentUpdateCheckCache(componentUpdateCheckCache); + preserveCheckResultsOnNextMount = false; + renderUpdatesComponents(); + if (runtimeStateRefreshPromise) { + await runtimeStateRefreshPromise; + if (!updatesMounted || mountId !== updatesMountId) { + return; + } + } + store.subscribe(onStoreUpdate3); + startComponentActionStateWatcher(); + renderUpdatesComponents(); + void ensureSystemInfo(); + if (hasRuntimeSnapshot) { + void refreshRuntimeUiState({ force: true }); + } +} +function onPageUnmount4() { + updatesMounted = false; + updatesMountId += 1; + stopComponentActionStateWatcher(); + store.unsubscribe(onStoreUpdate3); +} +function registerLifecycleListeners4() { + if (updatesLifecycleRegistered) { + return; + } + updatesLifecycleRegistered = true; + store.subscribe((next, prev, diff) => { + if (diff.tabService && next.tabService.current !== prev.tabService.current) { + const isUpdatesVisible = next.tabService.current === "updates"; + if (isUpdatesVisible) { + return onPageMount4(); + } + if (updatesMounted) { + return onPageUnmount4(); + } + } + }); +} +async function initController4() { + if (updatesControllerInitialized) { + return; + } + updatesControllerInitialized = true; + void loadComponentUpdateCheckCache(); + onMount("updates-status").then(() => { + logger.debug("[UPDATES]", "initController", "onMount"); + registerLifecycleListeners4(); + if (store.get().tabService.current === "updates" || isActiveLuciTab("updates")) { + onPageMount4(); + } + }); +} + +// src/tachyon/tabs/updates/styles.ts +var styles6 = ` +#cbi-${TACHYON_UCI_PACKAGE}-updates-_mount_node > div { + width: 100%; +} + +#cbi-${TACHYON_UCI_PACKAGE}-updates > h3 { + display: none; +} + +.fkp_updates-page { + width: 100%; +} + +.fkp_updates-page__components { + display: flex; + align-items: flex-start; + gap: 10px; + width: 100%; + flex-wrap: wrap; +} + +.fkp_updates-page__components-column { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 10px; + min-width: max-content; +} + +@media (max-width: 760px) { + .fkp_updates-page__components { + flex-direction: column; + } + + .fkp_updates-page__components-column { + width: 100%; + min-width: 0; + } +} + +.fkp_updates-page__component { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + min-width: max-content; +} + +.fkp_updates-page__component__header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + border-bottom: 1px var(--background-color-low, lightgray) solid; + padding-bottom: 8px; + margin-bottom: 2px; +} + +.fkp_updates-page__component__title { + color: var(--text-color-high); + font-size: 16px; + font-weight: bold; + line-height: 1.2; +} + +.fkp_updates-page__component__header-version { + color: var(--text-color-medium, #888); + font-size: 13px; + font-weight: normal; +} + +.fkp_updates-page__component__details { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fkp_updates-page__component__info-row { + display: flex; + justify-content: flex-start; + align-items: center; + min-height: 24px; + gap: 8px; + white-space: nowrap; +} + +.fkp_updates-page__component__info-label { + color: var(--text-color-medium, #888); + font-size: 12px; +} + +.fkp_updates-page__component__info-value { + color: var(--text-color-high, #000); + font-weight: 500; + font-size: 13px; + text-align: left; + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow-wrap: anywhere; +} + +.fkp_updates-page__component__info-value--latest { + flex-wrap: wrap; + justify-content: flex-start; +} + +.fkp_updates-page__component__release-version-link { + color: var(--link-color, #3498db) !important; + text-decoration: underline; + font-weight: bold; +} + +.fkp_updates-page__component__release-version-link:hover { + color: var(--link-color-dark, #2980b9) !important; +} + +.fkp_updates-page__component__actions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: auto; +} + +.fkp_updates-page__component__actions--with-details { + border-top: 1px var(--background-color-low, lightgray) solid; + padding-top: 10px; +} + +.fkp_updates-page__component__actions-main { + display: flex; + justify-content: flex-start; + align-items: center; + flex-wrap: nowrap; + gap: 6px; +} + +.fkp_updates-page__component__variants { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} + +.fkp_updates-page__component__variants-title { + font-size: 11px; + font-weight: bold; + color: var(--text-color-medium, gray); +} + +.fkp_updates-page__component__variants-buttons { + display: flex; + flex-wrap: nowrap; + gap: 6px; +} +`; + +// src/tachyon/tabs/updates/index.ts +var UpdatesTab = { + render: render4, + initController: initController4, + styles: styles6 +}; + +// src/styles.ts +var GlobalStyles = ` +${DashboardTab.styles} +${DiagnosticTab.styles} +${MonitoringTab.styles} +${UpdatesTab.styles} +${PartialStyles} + + +/* Hide extra H3 for settings tab */ +#cbi-${TACHYON_UCI_PACKAGE}-settings > h3 { + display: none; +} + +/* Hide extra H3 for rules tab */ +#cbi-${TACHYON_UCI_PACKAGE}-section > h3:nth-child(1) { + display: none; +} + +/* Vertical align for remove rule action button */ +#cbi-${TACHYON_UCI_PACKAGE}-section > .cbi-section-remove { + margin-bottom: -32px; +} + +#cbi-${TACHYON_UCI_PACKAGE}-section .cbi-section-actions > div { + display: inline-flex; + align-items: center; + gap: 4px; +} + +#cbi-${TACHYON_UCI_PACKAGE}-section .cbi-section-actions { + text-align: right; +} + +/* Rule reorder visuals */ +#cbi-${TACHYON_UCI_PACKAGE}-section { + position: relative; +} + +#cbi-${TACHYON_UCI_PACKAGE}-section .cbi-section-table-row { + position: relative; +} + +#cbi-${TACHYON_UCI_PACKAGE}-section .cbi-section-table-row.placeholder { + opacity: 1; +} + +#cbi-${TACHYON_UCI_PACKAGE}-section .cbi-section-table-row.placeholder em { + font-style: italic; +} + +#cbi-${TACHYON_UCI_PACKAGE}-section .cbi-section-table-row.drag-over-above::after, +#cbi-${TACHYON_UCI_PACKAGE}-section .cbi-section-table-row.drag-over-below::after { + content: ''; + position: absolute; + left: 10px; + right: 10px; + height: 2px; + border-radius: 2px; + background: var(--primary-color-high, #1976d2); + pointer-events: none; + z-index: 2; +} + +#cbi-${TACHYON_UCI_PACKAGE}-section .cbi-section-table-row.drag-over-above::after { + top: -1px; +} + +#cbi-${TACHYON_UCI_PACKAGE}-section .cbi-section-table-row.drag-over-below::after { + bottom: -1px; +} + +/* Centered class helper */ +.centered { + display: flex; + align-items: center; + justify-content: center; +} + +/* Rotate class helper */ +.rotate { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Skeleton styles*/ +.skeleton { + background-color: var(--background-color-low, #e0e0e0); + border-radius: 4px; + position: relative; + overflow: hidden; +} + +.skeleton::after { + content: ''; + position: absolute; + top: 0; + left: -150%; + width: 150%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent + ); + animation: skeleton-shimmer 1.6s infinite; +} + +@keyframes skeleton-shimmer { + 100% { + left: 150%; + } +} +/* Toast */ +.toast-container { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + z-index: 9999; + font-family: system-ui, sans-serif; +} + +.toast { + opacity: 0; + transform: translateY(10px); + transition: opacity 0.3s ease, transform 0.3s ease; + padding: 10px 16px; + border-radius: 6px; + color: #fff; + font-size: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + min-width: 220px; + max-width: 340px; + text-align: center; +} + +.toast-success { + background-color: #28a745; +} + +.toast-error { + background-color: #dc3545; +} + +.toast.visible { + opacity: 1; + transform: translateY(0); +} +`; + +// src/helpers/injectGlobalStyles.ts +var TACHYON_GLOBAL_STYLES_ID = "tachyon-global-styles"; +function injectGlobalStyles() { + if (document.getElementById(TACHYON_GLOBAL_STYLES_ID)) { + return; + } + document.head.insertAdjacentHTML( + "beforeend", + ` + + ` + ); +} + +// src/main.ts +if (typeof structuredClone !== "function") + globalThis.structuredClone = (obj) => JSON.parse(JSON.stringify(obj)); +return baseclass.extend({ + ALLOWED_WITH_RUSSIA_INSIDE, + BOOTSTRAP_DNS_SERVER_OPTIONS, + DEFAULT_LATENCY_TEST_URL, + DNS_SERVER_OPTIONS, + DOMAIN_LIST_OPTIONS, + DashboardTab, + DiagnosticTab, + TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT, + TACHYON_UCI_PACKAGE, + TachyonShellMethods, + LATENCY_TEST_URL_OPTIONS, + MonitoringTab, + REGIONAL_OPTIONS, + UpdatesTab, + applyUiStateToStore, + bulkValidate, + coreService, + getClashUIUrl, + getProxyUrlName, + injectGlobalStyles, + parseValueList, + showToast, + store, + validateDNS, + validateDomain, + validateIP, + validateOutboundJson, + validatePath, + validateProxyUrl, + validateSubnet, + validateUrl +}); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/monitoring.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/monitoring.js new file mode 100644 index 00000000..56db2012 --- /dev/null +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/monitoring.js @@ -0,0 +1,22 @@ +"use strict"; +"require baseclass"; +"require form"; +"require view.tachyon.local_devices as localDevices"; +"require view.tachyon.main as main"; + +function createMonitoringContent(section) { + const o = section.option(form.DummyValue, "_mount_node"); + o.rawhtml = true; + o.cfgvalue = () => { + main.MonitoringTab.initController({ + loadLocalDeviceChoices: localDevices.loadLocalDeviceChoices, + }); + return main.MonitoringTab.render(); + }; +} + +const EntryPoint = { + createMonitoringContent, +}; + +return baseclass.extend(EntryPoint); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js new file mode 100644 index 00000000..04866638 --- /dev/null +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js @@ -0,0 +1,7881 @@ +"use strict"; +"require form"; +"require baseclass"; +"require fs"; +"require network"; +"require ui"; +"require uci"; +"require view.tachyon.local_devices as localDevices"; +"require view.tachyon.main as main"; + +const UCI_PACKAGE = main.TACHYON_UCI_PACKAGE; +const ACTION_PROVIDERS_AVAILABILITY_EVENT = + main.TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT || + "tachyon:action-providers-availability"; +const RULE_SET_ITEM_SETTINGS_KEY = "rule_set_settings"; +const ROUTING_ACTIONS = [ + "connection", + "proxy", + "outbound", + "vpn", + "bypass", + "block", + "zapret", + "zapret2", + "byedpi", +]; +const CONNECTIONS_BLOCKED_INTERFACES = [ + "br-lan", + "eth0", + "eth1", + "wan", + "phy0-ap0", + "phy1-ap0", + "pppoe-wan", + "lan", +]; + +function valuesToText(values) { + if (!values) { + return ""; + } + + if (Array.isArray(values)) { + return values.filter(Boolean).join("\n"); + } + + return values ? `${values}` : ""; +} + +function normalizeOptionValues(value) { + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return value + .filter(Boolean) + .map((item) => `${item}`.trim()) + .filter(Boolean); + } + + return `${value}` + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function getUciSectionName(section) { + return section && section[".name"] ? section[".name"] : ""; +} + +function getUciSectionLabel(section) { + return (section && section.label) || getUciSectionName(section); +} + +function isOutboundDetourTargetSection(section, currentSectionId) { + const sectionName = getUciSectionName(section); + const action = (section && section.action) || ""; + + return ( + sectionName && + sectionName !== currentSectionId && + section.enabled !== "0" && + ["connection", "proxy", "outbound", "vpn"].includes(action) + ); +} + +function getOutboundDetourTargetSections(currentSectionId) { + return (uci.sections(UCI_PACKAGE, "section") || []).filter((section) => + isOutboundDetourTargetSection(section, currentSectionId), + ); +} + +function getDefaultOutboundDetourSection(currentSectionId) { + const targetSections = getOutboundDetourTargetSections(currentSectionId); + + return targetSections.length ? getUciSectionName(targetSections[0]) : ""; +} + +function refreshOutboundDetourSectionOptionValues(option, sectionId) { + option.keylist = []; + option.vallist = []; + + getOutboundDetourTargetSections(sectionId).forEach((targetSection) => { + option.value( + getUciSectionName(targetSection), + getUciSectionLabel(targetSection), + ); + }); +} + +function isDnsDetourTargetSection(section, currentSectionId) { + const sectionName = getUciSectionName(section); + const action = (section && section.action) || ""; + + if ( + !sectionName || + sectionName === currentSectionId || + section.enabled === "0" + ) { + return false; + } + + if (["connection", "proxy", "outbound", "vpn"].includes(action)) { + return true; + } + if (action === "zapret") { + return isZapretInstalledForUi(); + } + if (action === "zapret2") { + return isZapret2InstalledForUi(); + } + return action === "byedpi" && isByedpiInstalledForUi(); +} + +function refreshDnsDetourSectionOptionValues(option, sectionId) { + option.keylist = []; + option.vallist = []; + + (uci.sections(UCI_PACKAGE, "section") || []) + .filter((section) => isDnsDetourTargetSection(section, sectionId)) + .forEach((targetSection) => { + option.value( + getUciSectionName(targetSection), + getUciSectionLabel(targetSection), + ); + }); +} + +function dependsOnRoutingAction(option) { + ROUTING_ACTIONS.forEach((action) => option.depends("action", action)); + return option; +} + +const ZAPRET_LEGACY_DEFAULT_NFQWS_OPT = + "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --new --filter-tcp=443 --dpi-desync=multidisorder --dpi-desync-split-pos=1,sniext+1,host+1,midsld-2,midsld,midsld+2,endhost-1"; + +const ZAPRET_DEFAULT_NFQWS_OPT = + "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin"; + +const ZAPRET2_DEFAULT_NFQWS2_OPT = + "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=fake_default_quic:repeats=6"; + +const BYEDPI_DEFAULT_CMD_OPTS = "-o 2 --auto=t,r,a,s -d 2"; +const ANNOTATED_TEXTAREA_STYLE_ID = "fkp-annotated-textarea-styles"; +const CONNECTIONS_DYNLIST_STYLE_ID = "fkp-connections-dynlist-styles"; +const NFQWS_REMOTE_VALIDATION_DEBOUNCE_MS = 500; +const NFQWS_VALIDATION_COMMAND = "/usr/bin/tachyon"; +const nfqwsRemoteValidationCache = new Map(); +const nfqwsRemoteValidationInflight = new Map(); +const nfqws2RemoteValidationCache = new Map(); +const nfqws2RemoteValidationInflight = new Map(); +const byedpiRemoteValidationCache = new Map(); +const byedpiRemoteValidationInflight = new Map(); +const BYEDPI_LONG_VALUE_OPTIONS = new Set([ + "--max-conn", + "--conn-ip", + "--buf-size", + "--debug", + "--def-ttl", + "--auto", + "--auto-mode", + "--cache-ttl", + "--cache-dump", + "--timeout", + "--proto", + "--hosts", + "--ipset", + "--pf", + "--round", + "--split", + "--disorder", + "--oob", + "--disoob", + "--fake", + "--fake-sni", + "--ttl", + "--fake-offset", + "--fake-data", + "--fake-tls-mod", + "--oob-data", + "--mod-http", + "--tlsrec", + "--tlsminor", + "--udp-fake", +]); +const BYEDPI_LONG_FLAG_OPTIONS = new Set([ + "--md5sig", + "--tfo", + "--drop-sack", + "--no-domain", + "--no-udp", +]); +const BYEDPI_SHORT_VALUE_OPTIONS = new Set([ + "-c", + "-I", + "-b", + "-x", + "-g", + "-A", + "-L", + "-u", + "-y", + "-T", + "-K", + "-H", + "-j", + "-V", + "-R", + "-s", + "-d", + "-o", + "-q", + "-f", + "-n", + "-t", + "-O", + "-l", + "-Q", + "-e", + "-M", + "-r", + "-m", + "-a", +]); +const BYEDPI_SHORT_FLAG_OPTIONS = new Set(["-N", "-U", "-F", "-S", "-Y"]); +const NFQWS_OPTIONAL_ARG_OPTIONS = new Set([ + "--comment", + "--ctrack-disable", + "--debug", + "--dpi-desync-any-protocol", + "--dpi-desync-autottl", + "--dpi-desync-autottl6", + "--dpi-desync-skip-nosni", + "--dpi-desync-tcp-flags-set", + "--dpi-desync-tcp-flags-unset", + "--dup-autottl", + "--dup-autottl6", + "--dup-replace", + "--dup-tcp-flags-set", + "--dup-tcp-flags-unset", + "--ipcache-hostname", + "--orig-autottl", + "--orig-autottl6", + "--orig-tcp-flags-set", + "--orig-tcp-flags-unset", + "--synack-split", +]); +const NFQWS_NO_ARG_OPTIONS = new Set([ + "--bind-fix4", + "--bind-fix6", + "--daemon", + "--domcase", + "--dry-run", + "--hostcase", + "--hostnospace", + "--methodeol", + "--new", + "--skip", + "--version", +]); +const NFQWS_REQUIRED_ARG_OPTIONS = new Set([ + "--ctrack-timeouts", + "--dpi-desync", + "--dpi-desync-badack-increment", + "--dpi-desync-badseq-increment", + "--dpi-desync-cutoff", + "--dpi-desync-fake-dht", + "--dpi-desync-fake-discord", + "--dpi-desync-fake-http", + "--dpi-desync-fake-quic", + "--dpi-desync-fake-stun", + "--dpi-desync-fake-syndata", + "--dpi-desync-fake-tcp-mod", + "--dpi-desync-fake-tls", + "--dpi-desync-fake-tls-mod", + "--dpi-desync-fake-unknown", + "--dpi-desync-fake-unknown-udp", + "--dpi-desync-fake-wireguard", + "--dpi-desync-fakedsplit-mod", + "--dpi-desync-fakedsplit-pattern", + "--dpi-desync-fooling", + "--dpi-desync-fwmark", + "--dpi-desync-hostfakesplit-midhost", + "--dpi-desync-hostfakesplit-mod", + "--dpi-desync-ipfrag-pos-tcp", + "--dpi-desync-ipfrag-pos-udp", + "--dpi-desync-repeats", + "--dpi-desync-split-http-req", + "--dpi-desync-split-pos", + "--dpi-desync-split-seqovl", + "--dpi-desync-split-seqovl-pattern", + "--dpi-desync-split-tls", + "--dpi-desync-start", + "--dpi-desync-ts-increment", + "--dpi-desync-ttl", + "--dpi-desync-ttl6", + "--dpi-desync-udplen-increment", + "--dpi-desync-udplen-pattern", + "--dup", + "--dup-badack-increment", + "--dup-badseq-increment", + "--dup-cutoff", + "--dup-fooling", + "--dup-ip-id", + "--dup-start", + "--dup-ts-increment", + "--dup-ttl", + "--dup-ttl6", + "--filter-l3", + "--filter-l7", + "--filter-tcp", + "--filter-udp", + "--hostlist", + "--hostlist-auto", + "--hostlist-auto-debug", + "--hostlist-auto-fail-threshold", + "--hostlist-auto-fail-time", + "--hostlist-auto-retrans-threshold", + "--hostlist-domains", + "--hostlist-exclude", + "--hostlist-exclude-domains", + "--hostspell", + "--ip-id", + "--ipcache-lifetime", + "--ipset", + "--ipset-exclude", + "--ipset-exclude-ip", + "--ipset-ip", + "--orig-mod-cutoff", + "--orig-mod-start", + "--orig-ttl", + "--orig-ttl6", + "--pidfile", + "--qnum", + "--uid", + "--user", + "--wsize", + "--wssize", + "--wssize-cutoff", + "--wssize-forced-cutoff", +]); +const NFQWS2_OPTIONAL_ARG_OPTIONS = new Set([ + "--chdir", + "--comment", + "--ctrack-disable", + "--debug", + "--hostlist-auto-retrans-reset", + "--intercept", + "--ipcache-hostname", + "--new", + "--payload-disable", + "--reasm-disable", + "--server", + "--template", + "--writeable", +]); +const NFQWS2_NO_ARG_OPTIONS = new Set([ + "--bind-fix4", + "--bind-fix6", + "--daemon", + "--dry-run", + "--skip", + "--version", +]); +const NFQWS2_REQUIRED_ARG_OPTIONS = new Set([ + "--blob", + "--cookie", + "--ctrack-timeouts", + "--filter-l3", + "--filter-l7", + "--filter-tcp", + "--filter-udp", + "--fwmark", + "--fuzz", + "--hostlist", + "--hostlist-auto", + "--hostlist-auto-debug", + "--hostlist-auto-fail-threshold", + "--hostlist-auto-fail-time", + "--hostlist-auto-retrans-threshold", + "--hostlist-domains", + "--hostlist-exclude", + "--hostlist-exclude-domains", + "--import", + "--in-range", + "--ipcache-lifetime", + "--ipset", + "--ipset-exclude", + "--ipset-exclude-ip", + "--ipset-ip", + "--lua-gc", + "--lua-init", + "--lua-desync", + "--name", + "--out-range", + "--payload", + "--pidfile", + "--qnum", + "--uid", + "--user", +]); +const actionProvidersAvailabilityState = { + loaded: false, + zapretInstalled: false, + zapret2Installed: false, + byedpiInstalled: false, +}; +let actionProvidersAvailabilityPromise = null; +let actionProvidersAvailabilityLoader = null; +const outboundNameChoicesCache = new Map(); +const outboundNameChoicesInflight = new Map(); +const outboundNameSourceOptions = new Map(); +const sectionGroupSourceOptions = new Map(); +const dashboardFilterChoiceRefreshers = new Map(); +const SECTION_CACHE_DIR = "/var/run/tachyon/section-cache"; +const COUNTRY_CODES = + "AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW XK".split( + " ", + ); +const REGION_NAME_FALLBACKS = { + XK: "Kosovo", +}; +let regionDisplayNamesCache = {}; + +function updateActionProvidersAvailabilityState(nextState) { + if (!nextState) { + return; + } + + actionProvidersAvailabilityState.loaded = true; + + if (typeof nextState.zapretInstalled !== "undefined") { + actionProvidersAvailabilityState.zapretInstalled = Boolean( + nextState.zapretInstalled, + ); + } + + if (typeof nextState.zapret2Installed !== "undefined") { + actionProvidersAvailabilityState.zapret2Installed = Boolean( + nextState.zapret2Installed, + ); + } + + if (typeof nextState.byedpiInstalled !== "undefined") { + actionProvidersAvailabilityState.byedpiInstalled = Boolean( + nextState.byedpiInstalled, + ); + } + + actionProvidersAvailabilityPromise = null; +} + +function updateActionProvidersAvailabilityFromSystemInfo(systemInfo) { + if (!systemInfo || !systemInfo.providerInfoLoaded) { + return; + } + + updateActionProvidersAvailabilityState({ + zapretInstalled: Boolean(systemInfo.zapret_installed), + zapret2Installed: Boolean(systemInfo.zapret2_installed), + byedpiInstalled: Boolean(systemInfo.byedpi_installed), + }); +} + +function setActionProvidersAvailabilityLoader(loader) { + actionProvidersAvailabilityLoader = + typeof loader === "function" ? loader : null; +} + +if (typeof window !== "undefined") { + window.addEventListener(ACTION_PROVIDERS_AVAILABILITY_EVENT, (event) => { + updateActionProvidersAvailabilityState(event.detail); + }); +} + +if (main.store && typeof main.store.subscribe === "function") { + main.store.subscribe((next, _prev, diff) => { + if (!diff || diff.diagnosticsSystemInfo) { + updateActionProvidersAvailabilityFromSystemInfo( + next.diagnosticsSystemInfo, + ); + } + }); +} + +function getLuciLanguage() { + if (typeof L !== "undefined" && L.env && L.env.lang) { + return `${L.env.lang}`.replace("_", "-"); + } + + if (document.documentElement.lang) { + return document.documentElement.lang; + } + + return navigator.language || "en"; +} + +function getRegionDisplayName(code) { + const normalizedCode = `${code || ""}`.toUpperCase(); + const language = getLuciLanguage(); + const cacheKey = `${language}:${normalizedCode}`; + + if (regionDisplayNamesCache[cacheKey]) { + return regionDisplayNamesCache[cacheKey]; + } + + try { + if (typeof Intl !== "undefined" && Intl.DisplayNames) { + const displayNames = new Intl.DisplayNames([language, "en"], { + type: "region", + }); + const displayName = displayNames.of(normalizedCode); + if (displayName && displayName !== normalizedCode) { + regionDisplayNamesCache[cacheKey] = displayName; + return displayName; + } + } + } catch (_error) { + // Fall through to the static fallback. + } + + const fallback = REGION_NAME_FALLBACKS[normalizedCode] || normalizedCode; + regionDisplayNamesCache[cacheKey] = fallback; + return fallback; +} + +function getCountryFlagEmoji(code) { + const normalizedCode = `${code || ""}`.toUpperCase(); + + if (!/^[A-Z]{2}$/.test(normalizedCode)) { + return ""; + } + + return String.fromCodePoint( + ...normalizedCode + .split("") + .map((char) => 0x1f1e6 + char.charCodeAt(0) - 65), + ); +} + +function getCountryOptionLabel(code) { + return `${getCountryFlagEmoji(code)} ${getRegionDisplayName(code)}`; +} + +function validateCountryCode(_section_id, value) { + const values = Array.isArray(value) ? value : [value]; + const normalizedValues = values + .filter((item) => item && `${item}`.length) + .map((item) => `${item}`.toUpperCase()); + + if (!normalizedValues.length) { + return true; + } + + return normalizedValues.every((item) => COUNTRY_CODES.includes(item)) + ? true + : _("Unknown country"); +} + +function plainObject(value) { + return value && typeof value === "object" && !Array.isArray(value) + ? value + : {}; +} + +function safeCacheSectionName(section_id) { + return /^[A-Za-z0-9_-]+$/.test(`${section_id || ""}`); +} + +function filteredOutboundMetadataFromCache(cache) { + const metadata = plainObject(plainObject(cache).outboundMetadata); + const names = plainObject(metadata.names); + const countries = plainObject(metadata.countries); + const candidateTags = Array.isArray(cache.urltestCandidateTags) + ? cache.urltestCandidateTags + : []; + const groups = plainObject(cache.urltestGroups); + const result = { + names: {}, + countries: {}, + }; + + if (candidateTags.length > 0) { + candidateTags.forEach((tag) => { + tag = `${tag || ""}`; + if (!tag) { + return; + } + if (names[tag] != null) { + result.names[tag] = names[tag]; + } + if (countries[tag] != null) { + result.countries[tag] = countries[tag]; + } + }); + return result; + } + + Object.entries(names).forEach(([tag, name]) => { + if (!groups[tag]) { + result.names[tag] = name; + } + }); + Object.entries(countries).forEach(([tag, country]) => { + if (!groups[tag]) { + result.countries[tag] = country; + } + }); + + return result; +} + +function readOutboundMetadataFromSectionCache(section_id) { + if (!safeCacheSectionName(section_id)) { + return Promise.resolve({ names: {}, countries: {} }); + } + + return fs + .read(`${SECTION_CACHE_DIR}/${section_id}.json`) + .then((raw) => filteredOutboundMetadataFromCache(JSON.parse(raw || "{}"))) + .catch(() => ({ names: {}, countries: {} })); +} + +function loadOutboundNameChoices(section_id) { + if (outboundNameChoicesCache.has(section_id)) { + return Promise.resolve(outboundNameChoicesCache.get(section_id)); + } + + if (outboundNameChoicesInflight.has(section_id)) { + return outboundNameChoicesInflight.get(section_id); + } + + const task = readOutboundMetadataFromSectionCache(section_id) + .then((metadata) => { + const names = Object.values(plainObject(metadata.names)); + + const choices = names + .filter(Boolean) + .filter((name, index, values) => values.indexOf(name) === index) + .sort((a, b) => `${a}`.localeCompare(`${b}`)); + + outboundNameChoicesCache.set(section_id, choices); + + return choices; + }) + .catch(() => []) + .finally(() => { + outboundNameChoicesInflight.delete(section_id); + }); + + outboundNameChoicesInflight.set(section_id, task); + + return task; +} + +function normalizeDynamicListItems(value) { + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return value + .map((item) => `${item || ""}`.trim()) + .filter((item) => item.length); + } + + const normalized = `${value}`.trim(); + return normalized.length ? [normalized] : []; +} + +function uniqueDynamicListItems(value) { + const seen = new Set(); + const result = []; + + normalizeDynamicListItems(value).forEach((item) => { + if (!seen.has(item)) { + seen.add(item); + result.push(item); + } + }); + + return result; +} + +function readItemSettingsMap(section_id, settingsKey) { + const raw = uci.get(UCI_PACKAGE, section_id, settingsKey); + + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(`${raw}`); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : {}; + } catch (_error) { + return {}; + } +} + +function compactItemSettings(values) { + const result = {}; + + Object.entries(values || {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === "") { + return; + } + + if (Array.isArray(value)) { + const items = value + .map((item) => `${item || ""}`.trim()) + .filter((item) => item.length); + + if (items.length) { + result[key] = items; + } + return; + } + + result[key] = `${value}`; + }); + + return result; +} + +function writeItemSettingsMap(section_id, settingsKey, map) { + const result = {}; + + Object.entries(map || {}).forEach(([item, settings]) => { + const compact = compactItemSettings(settings); + if (item && Object.keys(compact).length) { + result[item] = compact; + } + }); + + if (Object.keys(result).length) { + uci.set(UCI_PACKAGE, section_id, settingsKey, JSON.stringify(result)); + } else { + uci.unset(UCI_PACKAGE, section_id, settingsKey); + } +} + +function cleanupListItemSettings(section_id, settingsKey, values) { + if (!settingsKey) { + return; + } + + const keep = new Set(normalizeDynamicListItems(values)); + const map = readItemSettingsMap(section_id, settingsKey); + let changed = false; + + Object.keys(map).forEach((item) => { + if (!keep.has(item)) { + delete map[item]; + changed = true; + } + }); + + if (changed) { + writeItemSettingsMap(section_id, settingsKey, map); + } +} + +function itemSettingsFlag(settings, key, defaultValue) { + const value = settings ? settings[key] : null; + + if (value === undefined || value === null || value === "") { + return Boolean(defaultValue); + } + + return value === true || `${value}` === "1"; +} + +function ensureConnectionsDynamicListStyles() { + if (document.getElementById(CONNECTIONS_DYNLIST_STYLE_ID)) { + return; + } + + document.head.appendChild( + E( + "style", + { id: CONNECTIONS_DYNLIST_STYLE_ID }, + ` +.fkp-connections-dynlist > .item { + --fkp-dynlist-action-width: 2em; + padding-right: calc(var(--fkp-dynlist-action-width) * 2); + position: relative; +} + +.fkp-connections-dynlist > .item > .fkp-dynlist-settings { + align-items: center; + border: 1px solid var(--border-color-high, currentColor); + border-right: 0; + border-radius: 0; + bottom: -1px; + color: inherit; + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: 0.9em; + justify-content: center; + line-height: 1; + min-height: 0; + padding: 0; + pointer-events: auto; + position: absolute; + right: calc(var(--fkp-dynlist-action-width) - 1px); + user-select: none; + text-decoration: none; + top: -1px; + width: var(--fkp-dynlist-action-width); + z-index: 1; +} + +.fkp-connections-dynlist > .item > .fkp-dynlist-settings:hover, +.fkp-connections-dynlist > .item > .fkp-dynlist-settings:focus { + --focus-color-rgb: 82, 168, 236; + outline: 0; + border-color: rgba(var(--focus-color-rgb), 0.8) !important; + box-shadow: inset 0 1px 3px hsla(var(--border-color-low-hsl), .01), 0 0 8px rgba(var(--focus-color-rgb), 0.6); + text-decoration: none; +} + +.fkp-connections-dynlist > .add-item > .cbi-dropdown { + width: 100%; +} + +.fkp-interface-dynlist-label { + align-items: center; + display: inline-flex; + gap: 0.25em; + max-width: 100%; + vertical-align: middle; +} + +.fkp-interface-dynlist-label > img { + flex: 0 0 auto; + height: 1.35em; + width: auto; +} + +.fkp-interface-dynlist-label > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fkp-button-add-dynlist > .add-item { + align-items: stretch; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + display: flex; + margin-top: 4px; + max-width: 100%; + overflow: visible; + padding: 0; + width: var(--fkp-button-add-width, 210px); +} + +.fkp-button-add-dynlist > .add-item > input[type="text"] { + display: none !important; +} + +.fkp-button-add-dynlist > .add-item > .cbi-button-add { + align-items: center !important; + background: linear-gradient(var(--background-color-high) 0%, var(--border-color-low) 100%) !important; + border: 1px solid var(--border-color-high) !important; + border-radius: 3px !important; + box-shadow: inset 0 1px 3px hsla(var(--border-color-low-hsl), .01) !important; + box-sizing: border-box !important; + color: var(--text-color-medium) !important; + cursor: pointer !important; + display: flex !important; + font-size: 13px !important; + height: 30px !important; + justify-content: flex-start !important; + margin-left: 0 !important; + max-height: 30px !important; + min-height: 30px !important; + max-width: 100% !important; + overflow: hidden !important; + padding: 0 4px !important; + text-overflow: ellipsis !important; + transition: border linear .2s, box-shadow linear .2s !important; + white-space: nowrap !important; + width: 100% !important; +} + +.fkp-button-add-dynlist > .add-item > .cbi-button-add:hover, +.fkp-button-add-dynlist > .add-item > .cbi-button-add:focus { + border-color: rgba(82, 168, 236, .8) !important; + box-shadow: inset 0 1px 3px hsla(var(--border-color-low-hsl), .01), 0 0 8px rgba(82, 168, 236, .6) !important; + outline: 0; +} + +body.modal-overlay-active > #modal_overlay > .modal.cbi-modal > .cbi-map.flash { + animation: none !important; +} + +`, + ), + ); +} + +function findDynamicListItemByValue(dl, value) { + const stringValue = `${value}`; + const items = dl.querySelectorAll(".item"); + + for (let i = 0; i < items.length; i += 1) { + const hidden = items[i].querySelector('input[type="hidden"]'); + if ( + hidden && + hidden.parentNode === items[i] && + hidden.value === stringValue + ) { + return items[i]; + } + } + + return null; +} + +function dynamicListItemCurrentValue(item, fallback) { + const hidden = item ? item.querySelector('input[type="hidden"]') : null; + return hidden && hidden.parentNode === item ? hidden.value : fallback; +} + +function dynamicListItemValues(dl) { + return Array.from(dl.querySelectorAll(".item")) + .map((item) => { + const hidden = item.querySelector('input[type="hidden"]'); + return hidden && hidden.parentNode === item ? hidden.value : null; + }) + .filter((value) => value != null); +} + +function updateDynamicListItemLabel(item, label) { + const labelNode = item ? item.querySelector(".fkp-dynlist-label") : null; + if (labelNode) { + labelNode.textContent = `${label || ""}`; + } +} + +function addDynamicListItem(widget, value, text) { + if (!widget || !widget.node) { + return null; + } + + const normalized = `${value || ""}`.trim(); + if (!normalized) { + return null; + } + + if ( + typeof widget.options.hasEquivalentValue === "function" && + widget.options.hasEquivalentValue(normalized, widget.node) + ) { + widget.dispatchCbiDynlistChange(widget.node, normalized); + return null; + } + + widget.addItem(widget.node, normalized, text || null, false); + widget.dispatchCbiDynlistChange(widget.node, normalized); + updateButtonAddDynamicListLayout(widget.node, widget.options.addButtonLabel); + + return findDynamicListItemByValue(widget.node, normalized); +} + +function updateButtonAddDynamicListLayout(dl, label) { + const addButton = dl ? dl.querySelector(".add-item > .cbi-button-add") : null; + if (!addButton) { + return; + } + + addButton.textContent = label || "+"; + addButton.setAttribute("role", "button"); + addButton.setAttribute("tabindex", "0"); + dl.style.setProperty( + "--fkp-button-add-width", + dl.querySelector(".item") ? "100%" : "210px", + ); +} + +function setDynamicListItemValue(item, value) { + const hidden = item ? item.querySelector('input[type="hidden"]') : null; + if (hidden && hidden.parentNode === item) { + hidden.value = `${value || ""}`; + } +} + +function parseOutboundJsonObject(value) { + try { + const parsed = JSON.parse(`${value || ""}`); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : null; + } catch (_error) { + return null; + } +} + +function outboundJsonDisplayTag(value) { + const parsed = parseOutboundJsonObject(value); + const tag = parsed && typeof parsed.tag === "string" ? parsed.tag.trim() : ""; + return tag || ""; +} + +function outboundJsonListItemLabel(value) { + return E( + "span", + { class: "fkp-dynlist-label" }, + outboundJsonDisplayTag(value) || _("JSON outbound"), + ); +} + +function cleanFormSectionData(sectionData) { + const result = {}; + + Object.entries(sectionData || {}).forEach(([key, value]) => { + if (!key || key.charAt(0) === ".") { + return; + } + + result[key] = Array.isArray(value) ? value.slice() : value; + }); + + return result; +} + +const SettingsUIDynamicList = ui.DynamicList.extend({ + render() { + ensureConnectionsDynamicListStyles(); + + const node = ui.DynamicList.prototype.render.apply(this, arguments); + node.classList.add("fkp-connections-dynlist"); + return node; + }, + + addItem(dl, value, text, flash) { + if ( + flash && + typeof this.options.hasEquivalentValue === "function" && + this.options.hasEquivalentValue(value, dl) + ) { + this.dispatchCbiDynlistChange(dl, value); + return; + } + + const itemText = + typeof this.options.itemLabel === "function" + ? this.options.itemLabel(value, text) + : text; + + ui.DynamicList.prototype.addItem.call(this, dl, value, itemText, flash); + + const item = findDynamicListItemByValue(dl, value); + const hasSettings = + typeof this.options.hasSettings === "function" + ? this.options.hasSettings(value) + : true; + + if (!item || item.querySelector(".fkp-dynlist-settings") || !hasSettings) { + return; + } + + item.appendChild( + E( + "span", + { + role: "button", + tabindex: this.options.disabled ? null : "0", + class: "fkp-dynlist-settings", + "aria-label": _("Settings"), + "aria-disabled": this.options.disabled ? "true" : null, + click: (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (this.options.disabled) { + return; + } + + if (typeof this.options.settingsHandler === "function") { + this.options.settingsHandler( + dynamicListItemCurrentValue(item, value), + item, + this, + {}, + ); + } + }, + keydown: (event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if ( + !this.options.disabled && + typeof this.options.settingsHandler === "function" + ) { + this.options.settingsHandler( + dynamicListItemCurrentValue(item, value), + item, + this, + {}, + ); + } + }, + }, + "\u2699", + ), + ); + }, + handleClick(event) { + if (event.target.closest(".fkp-dynlist-settings")) { + return; + } + + return ui.DynamicList.prototype.handleClick.apply(this, arguments); + }, +}); + +const ButtonAddSettingsUIDynamicList = SettingsUIDynamicList.extend({ + render() { + const node = SettingsUIDynamicList.prototype.render.apply(this, arguments); + node.classList.add("fkp-button-add-dynlist"); + + const input = node.querySelector('.add-item > input[type="text"]'); + if (input) { + input.setAttribute("aria-hidden", "true"); + input.setAttribute("tabindex", "-1"); + } + + updateButtonAddDynamicListLayout(node, this.options.addButtonLabel); + + node.addEventListener("cbi-dynlist-change", () => { + updateButtonAddDynamicListLayout(node, this.options.addButtonLabel); + }); + + return node; + }, + + addButtonItem() { + if (typeof this.options.settingsHandler === "function") { + this.options.settingsHandler("", null, this, { adding: true }); + } + }, + + handleClick(event) { + if ( + !this.options.disabled && + event.target.closest(".add-item > .cbi-button-add") + ) { + event.preventDefault(); + event.stopPropagation(); + this.addButtonItem(event.currentTarget); + return; + } + + return SettingsUIDynamicList.prototype.handleClick.apply(this, arguments); + }, + + handleKeydown(event) { + if ( + !this.options.disabled && + event.target.closest(".add-item > .cbi-button-add") && + (event.key === "Enter" || event.key === " ") + ) { + event.preventDefault(); + event.stopPropagation(); + this.addButtonItem(event.currentTarget); + return; + } + + return ui.DynamicList.prototype.handleKeydown.apply(this, arguments); + }, +}); + +const SettingsDynamicList = form.DynamicList.extend({ + childOwner(section_id) { + return typeof this.childOwnerId === "function" + ? `${this.childOwnerId(section_id) || ""}`.trim() + : section_id; + }, + + parentSection(section_id) { + return typeof this.parentSectionId === "function" + ? `${this.parentSectionId(section_id) || ""}`.trim() + : section_id; + }, + + load(section_id) { + if (this.childType) { + return getChildItemIds( + this.childOwner(section_id), + this.childType, + this.ownerOption, + ); + } + + return form.DynamicList.prototype.load.apply(this, arguments); + }, + + renderWidget(section_id, _option_index, cfgvalue) { + const value = cfgvalue != null ? cfgvalue : this.default; + const choices = this.transformChoices(); + const WidgetClass = this.buttonAdd + ? ButtonAddSettingsUIDynamicList + : SettingsUIDynamicList; + const widget = new WidgetClass(L.toArray(value), choices, { + id: this.cbid(section_id), + sort: this.keylist, + allowduplicates: this.allowduplicates, + optional: this.optional || this.rmempty, + datatype: this.datatype, + placeholder: this.placeholder, + validate: L.bind(this.validate, this, section_id), + disabled: this.readonly != null ? this.readonly : this.map.readonly, + addButtonLabel: + typeof this.addButtonLabel === "function" + ? this.addButtonLabel(section_id) + : this.addButtonLabel, + settingsHandler: (itemValue, _item, widget, context) => { + if (typeof this.renderItemSettingsModal === "function") { + const ownerId = this.childOwner(section_id); + this.renderItemSettingsModal( + ownerId, + `${itemValue}`, + this, + widget, + _item, + Object.assign({}, context || {}, { + parentSectionId: this.parentSection(section_id), + ownerId, + }), + ); + } + }, + itemLabel: (itemValue, text) => { + if (typeof this.renderListItemLabel === "function") { + return this.renderListItemLabel( + this.childOwner(section_id), + `${itemValue}`, + text, + ); + } + + return text; + }, + hasSettings: (itemValue) => { + if (typeof this.hasItemSettings === "function") { + return this.hasItemSettings( + this.childOwner(section_id), + `${itemValue}`, + ); + } + + if (this.childType) { + return isExistingChildItem( + this.childOwner(section_id), + `${itemValue}`, + this.childType, + this.ownerOption, + ); + } + + return true; + }, + hasEquivalentValue: (itemValue, dl) => { + const inputValueForItem = (value) => { + const ownerId = this.childOwner(section_id); + + if (typeof this.inputValueForItem === "function") { + return `${this.inputValueForItem(ownerId, `${value || ""}`) || ""}`.trim(); + } + + if (this.childType && this.childValueOption) { + return childItemInputValue( + ownerId, + `${value || ""}`, + this.childType, + this.childValueOption, + this.ownerOption, + ); + } + + return `${value || ""}`.trim(); + }; + const normalized = inputValueForItem(itemValue); + + return Boolean( + normalized && + dynamicListItemValues(dl).some( + (existingValue) => + inputValueForItem(existingValue) === normalized, + ), + ); + }, + }); + + const node = widget.render(); + if (typeof this.onListChange === "function") { + node.addEventListener("cbi-dynlist-change", () => { + this.onListChange(section_id); + }); + } + + return node; + }, + + parse(section_id) { + if ( + this.isActive(section_id) && + typeof this.validateItemsOnSave === "function" + ) { + const result = this.validateItemsOnSave( + this.childType ? this.childOwner(section_id) : section_id, + this.formvalue(section_id), + this, + section_id, + ); + if (result !== true) { + const title = this.stripTags(this.title).trim(); + return Promise.reject( + new TypeError( + `${_('Option "%s" contains an invalid input value.').format(title || this.option)} ${result}`, + ), + ); + } + } + + return form.DynamicList.prototype.parse.apply(this, arguments); + }, + + write(section_id, value) { + if (this.childType) { + const ownerId = this.childOwner(section_id); + const itemIds = materializeChildItems( + ownerId, + { + typeName: this.childType, + valueOption: this.childValueOption, + ownerOption: this.ownerOption, + createId: this.createId, + defaults: this.childDefaults, + stagedSettings: + typeof this.stagedChildSettings === "function" + ? (itemValue, itemId, created) => + this.stagedChildSettings(ownerId, itemValue, itemId, created) + : null, + }, + value, + ); + cleanupRemovedChildItems( + ownerId, + this.childType, + itemIds, + this.ownerOption, + ); + if (typeof this.afterMaterializeChildItems === "function") { + this.afterMaterializeChildItems(ownerId, itemIds); + } + uci.unset(UCI_PACKAGE, section_id, this.option); + cleanupListItemSettings(ownerId, this.settingsKey, itemIds); + if (typeof this.clearStagedChildSettings === "function") { + this.clearStagedChildSettings(ownerId); + } + return; + } + + const result = form.DynamicList.prototype.write.apply(this, arguments); + cleanupListItemSettings(section_id, this.settingsKey, value); + return result; + }, + + remove(section_id) { + if (this.childType) { + cleanupRemovedChildItems( + this.childOwner(section_id), + this.childType, + [], + this.ownerOption, + ); + uci.unset(UCI_PACKAGE, section_id, this.option); + return; + } + + if (this.settingsKey) { + uci.unset(UCI_PACKAGE, section_id, this.settingsKey); + } + + return form.DynamicList.prototype.remove.apply(this, arguments); + }, +}); + +const ButtonAddSettingsDynamicList = SettingsDynamicList.extend({ + buttonAdd: true, +}); + +function configureLiveDynamicListChoices(option, getChoices) { + option.renderWidget = function (section_id, _option_index, cfgvalue) { + const values = L.toArray(cfgvalue != null ? cfgvalue : this.default); + const choices = getChoices(section_id, values); + const labels = {}; + choices.forEach((choice) => { + labels[choice.value] = choice.label; + }); + refreshOptionChoices(this, choices); + let choiceSignature = JSON.stringify( + choices.map((choice) => [choice.value, choice.label]), + ); + // form.DynamicList passes null when there are no choices, which permanently + // renders a plain input. An empty object keeps the stock combobox alive so + // later live choices can be added without reopening the modal. + const widget = new ui.DynamicList(values, labels, { + id: this.cbid(section_id), + sort: this.keylist, + allowduplicates: this.allowduplicates, + optional: this.optional || this.rmempty, + datatype: this.datatype, + placeholder: this.placeholder, + validate: L.bind(this.validate, this, section_id), + disabled: this.readonly != null ? this.readonly : this.map.readonly, + }); + const node = widget.render(); + const refreshChoices = () => { + if (!node.isConnected) { + return false; + } + + const currentValues = widget.getValue(); + const currentChoices = getChoices(section_id, currentValues); + const currentLabels = {}; + currentChoices.forEach((choice) => { + currentLabels[choice.value] = choice.label; + }); + const currentSignature = JSON.stringify( + currentChoices.map((choice) => [choice.value, choice.label]), + ); + + if (currentSignature === choiceSignature) { + return; + } + choiceSignature = currentSignature; + + refreshOptionChoices(this, currentChoices); + widget.choices = currentLabels; + widget.clearChoices(); + widget.addChoices( + currentChoices.map((choice) => choice.value), + currentLabels, + ); + return true; + }; + const refreshBeforeOpening = (event) => { + if (event.target && event.target.closest(".add-item")) { + refreshChoices(); + } + }; + node.addEventListener("mousedown", refreshBeforeOpening, true); + node.addEventListener("focusin", refreshBeforeOpening, true); + + if (!dashboardFilterChoiceRefreshers.has(section_id)) { + dashboardFilterChoiceRefreshers.set(section_id, new Set()); + } + dashboardFilterChoiceRefreshers.get(section_id).add(refreshChoices); + + return node; + }; +} + +function countryChoices() { + return COUNTRY_CODES.map((code) => ({ + value: code, + label: getCountryOptionLabel(code), + })).sort((a, b) => a.label.localeCompare(b.label)); +} + +function currentOutboundNameChoices(section_id, values) { + if (!outboundNameChoicesCache.has(section_id)) { + loadOutboundNameChoices(section_id); + } + + const seen = new Set(); + const result = []; + const append = (name) => { + const value = `${name || ""}`.trim(); + if (!value || seen.has(value)) { + return; + } + + seen.add(value); + result.push({ value, label: value }); + }; + + (outboundNameChoicesCache.get(section_id) || []).forEach(append); + currentDraftOutboundNames(section_id).forEach(append); + normalizeDynamicListItems(values).forEach(append); + + return result.sort((a, b) => a.label.localeCompare(b.label)); +} + +function currentSourceOptionValues(section_id, optionName) { + const liveValues = currentLiveDynamicListValues(section_id, optionName); + if (liveValues != null) { + return liveValues; + } + + const option = outboundNameSourceOptions.get(optionName); + + if (option && typeof option.formvalue === "function") { + try { + const value = option.formvalue(section_id); + if (value != null) { + return normalizeDynamicListItems(value); + } + } catch (_error) { + // Fall back to the LuCI UCI cache when the section widget is not mounted. + } + } + + return getConfigListValues(section_id, optionName); +} + +function currentLiveDynamicListValues(section_id, optionName) { + if (typeof document === "undefined") { + return null; + } + + const widget = document.getElementById( + `cbid.${UCI_PACKAGE}.${section_id}.${optionName}`, + ); + if (!widget) { + return null; + } + + const values = Array.from( + widget.querySelectorAll('.item > input[type="hidden"]'), + ) + .map((input) => `${input.value || ""}`.trim()) + .filter(Boolean); + const pendingInput = widget.querySelector('.add-item > input[type="text"]'); + const pendingValue = `${(pendingInput && pendingInput.value) || ""}`.trim(); + + if ( + pendingValue && + !pendingInput.classList.contains("cbi-input-invalid") && + !values.includes(pendingValue) + ) { + values.push(pendingValue); + } + + return values; +} + +function currentDraftOutboundNames(section_id) { + const names = []; + + currentSourceOptionValues(section_id, "selector_proxy_links").forEach( + (value, index) => { + const name = main.getProxyUrlName(`${value || ""}`); + names.push(name || `${section_id}-${index + 1}-out`); + }, + ); + currentSourceOptionValues(section_id, "interfaces").forEach((itemId) => { + const normalized = childItemInputValue( + section_id, + itemId, + "section_interface", + "name", + ).trim(); + if (normalized) { + names.push(normalized); + } + }); + currentSourceOptionValues(section_id, "outbound_jsons").forEach((value) => { + const tag = outboundJsonDisplayTag(value); + if (tag) { + names.push(tag); + } + }); + + return names; +} + +function currentSectionGroupValues(section_id, typeName) { + const liveValues = currentLiveDynamicListValues(section_id, typeName); + if (liveValues != null) { + return liveValues; + } + + const option = sectionGroupSourceOptions.get(typeName); + if (option && typeof option.formvalue === "function") { + try { + const value = option.formvalue(section_id); + if (value != null) { + return normalizeDynamicListItems(value); + } + } catch (_error) { + // Fall back to the child sections when the widget is not mounted. + } + } + + return getChildItemIds(section_id, typeName); +} + +function sectionGroupDisplayName(section_id, typeName, value) { + const itemId = `${value || ""}`.trim(); + if (isExistingChildItem(section_id, itemId, typeName)) { + return `${uci.get(UCI_PACKAGE, itemId, "name") || itemId}`.trim(); + } + + const option = sectionGroupSourceOptions.get(typeName); + const pending = + option && option.pendingChildSettings + ? option.pendingChildSettings[section_id] + : null; + const settings = pending && pending[itemId]; + return `${(settings && settings.name) || itemId}`.trim(); +} + +function currentSectionGroupChoices(section_id, selectedValues = []) { + const seen = new Set(); + const result = []; + const append = (typeName, value) => { + value = `${value || ""}`.trim(); + if (!value) { + return; + } + + const name = sectionGroupDisplayName(section_id, typeName, value) || value; + if (seen.has(name)) { + return; + } + + seen.add(name); + result.push({ value: name, label: name }); + }; + + currentSectionGroupValues(section_id, "urltest").forEach((value) => + append("urltest", value), + ); + currentSectionGroupValues(section_id, "priority_group").forEach((value) => + append("priority_group", value), + ); + normalizeDynamicListItems(selectedValues).forEach((value) => { + value = `${value || ""}`.trim(); + if (value && !seen.has(value)) { + seen.add(value); + result.push({ value, label: value }); + } + }); + + return result.sort((left, right) => left.label.localeCompare(right.label)); +} + +function refreshDashboardFilterChoiceWidgets(section_id) { + const refreshers = dashboardFilterChoiceRefreshers.get(section_id); + if (!refreshers) { + return; + } + + refreshers.forEach((refresh) => { + if (refresh() === false) { + refreshers.delete(refresh); + } + }); + + if (refreshers.size === 0) { + dashboardFilterChoiceRefreshers.delete(section_id); + } +} + +function isDownloadThroughTargetSection(section, currentSectionId) { + const sectionName = getUciSectionName(section); + const action = (section && section.action) || ""; + + if ( + !sectionName || + sectionName === currentSectionId || + section.enabled === "0" + ) { + return false; + } + + if (["connection", "proxy", "outbound", "vpn"].includes(action)) { + return true; + } + + if (action === "zapret") { + return isZapretInstalledForUi(); + } + + if (action === "zapret2") { + return isZapret2InstalledForUi(); + } + + if (action === "byedpi") { + return isByedpiInstalledForUi(); + } + + return false; +} + +function subscriptionDownloadTargetChoices(section_id) { + return (uci.sections(UCI_PACKAGE, "section") || []) + .filter((section) => isDownloadThroughTargetSection(section, section_id)) + .map((section) => ({ + value: getUciSectionName(section), + label: getUciSectionLabel(section), + })); +} + +function dnsTypeChoices() { + return [ + { value: "doh", label: _("DNS over HTTPS (DoH)") }, + { value: "dot", label: _("DNS over TLS (DoT)") }, + { value: "udp", label: "UDP" }, + ]; +} + +function isConnectionNetworkInterfaceAllowed(deviceName, device) { + if (CONNECTIONS_BLOCKED_INTERFACES.includes(deviceName)) { + return false; + } + + if (!device) { + return true; + } + + const type = device.getType(); + const isWireless = + type === "wifi" || type === "wireless" || type.indexOf("wlan") >= 0; + + return !isWireless; +} + +function renderNetworkInterfaceChoice(device) { + const name = device.getName(); + const type = device.getType(); + + return E([ + E("img", { + title: device.getI18n(), + src: L.resource( + "icons/%s%s.svg".format(type, device.isUp() ? "" : "_disabled"), + ), + }), + E("span", { class: "hide-open" }, [name]), + E("span", { class: "hide-close" }, [device.getI18n()]), + ]); +} + +function renderNetworkInterfaceListItem(device, fallbackName) { + const name = device ? device.getName() : fallbackName; + const type = device ? device.getType() : "ethernet"; + const up = device ? device.isUp() : false; + + return E("span", { class: "fkp-interface-dynlist-label" }, [ + E("img", { + title: device ? device.getI18n() : _("Network Interface"), + src: L.resource("icons/%s%s.svg".format(type, up ? "" : "_disabled")), + }), + E("span", {}, [name]), + ]); +} + +function refreshNetworkInterfaceOptionValues(option) { + option.keylist = []; + option.vallist = []; + option.interfaceChoiceMap = {}; + option.interfaceDeviceMap = {}; + + (option.devices || []).forEach((device) => { + const name = device.getName(); + const type = device.getType(); + + if ( + name === "lo" || + type === "alias" || + !isConnectionNetworkInterfaceAllowed(name, device) + ) { + return; + } + + option.value(name, renderNetworkInterfaceChoice(device)); + option.interfaceChoiceMap[name] = true; + option.interfaceDeviceMap[name] = device; + }); +} + +const InterfaceSettingsDynamicList = SettingsDynamicList.extend({ + load(section_id) { + return network.getDevices().then( + L.bind(function (devices) { + this.devices = devices || []; + refreshNetworkInterfaceOptionValues(this); + + return this.super("load", section_id); + }, this), + ); + }, + + validate(section_id, value) { + value = childItemInputValue(section_id, value, "section_interface", "name"); + + if (!value || value.length === 0) { + return true; + } + + if (!this.interfaceChoiceMap || !this.interfaceChoiceMap[value]) { + return _("Select an existing network interface"); + } + + return true; + }, + + renderListItemLabel(section_id, value, text) { + value = childItemInputValue(section_id, value, "section_interface", "name"); + + return renderNetworkInterfaceListItem( + this.interfaceDeviceMap ? this.interfaceDeviceMap[value] : null, + value || text, + ); + }, +}); + +function urlTestFilterModeChoices() { + return [ + { value: "disabled", label: _("All servers") }, + { value: "exclude", label: _("All except selected") }, + { value: "include", label: _("Only selected") }, + { value: "mixed", label: _("Only selected except exclusions") }, + ]; +} + +function priorityLevelFilterModeChoices() { + return [ + { value: "disabled", label: _("All remaining servers") }, + { value: "include", label: _("Only selected") }, + { value: "exclude", label: _("All remaining except selected") }, + { value: "mixed", label: _("Only selected except exclusions") }, + ]; +} + +function serverCountryDetectionChoices() { + return [ + { value: "flag_emoji", label: _("By flag emoji from name") }, + { value: "country_is", label: _("Via country.is") }, + ]; +} + +function proxyProtocolChoices() { + return [ + ["vless", "VLESS"], + ["vmess", "VMess"], + ["trojan", "Trojan"], + ["shadowsocks", "Shadowsocks"], + ["socks", "SOCKS"], + ["http", "HTTP"], + ["hysteria2", "Hysteria2"], + ["direct", "Direct"], + ]; +} + +function proxyTransportChoices() { + return [ + ["tcp", "TCP"], + ["ws", "WebSocket"], + ["grpc", "gRPC"], + ["http", "HTTP"], + ["httpupgrade", "HTTPUpgrade"], + ["xhttp", "XHTTP"], + ]; +} + +function proxySecurityChoices() { + return [ + ["none", "None"], + ["tls", "TLS"], + ["reality", "Reality"], + ]; +} + +function addProxyParameterFilterOptions(itemSection, options) { + const prefix = options.prefix; + const dependencies = options.dependencies; + + let o = itemSection.option( + form.Flag, + `${prefix}_proxy_parameters`, + options.toggleLabel, + options.toggleDescription, + ); + dependencies.forEach((dependency) => o.depends(dependency)); + o.default = "0"; + o.rmempty = false; + + [ + [ + "protocols", + _("Protocol"), + options.protocolDescription, + proxyProtocolChoices(), + ], + [ + "transports", + _("Transport"), + options.transportDescription, + proxyTransportChoices(), + ], + [ + "securities", + _("Security"), + options.securityDescription, + proxySecurityChoices(), + ], + ].forEach(([suffix, label, description, choices]) => { + const list = itemSection.option( + form.DynamicList, + `${prefix}_${suffix}`, + label, + description, + ); + dependencies.forEach((dependency) => + list.depends( + Object.assign({}, dependency, { + [`${prefix}_proxy_parameters`]: "1", + }), + ), + ); + list.rmempty = true; + choices.forEach(([value, choiceLabel]) => list.value(value, choiceLabel)); + list.placeholder = _("-- Select --"); + }); +} + +function urlTestUrlChoices() { + return Array.isArray(main.LATENCY_TEST_URL_OPTIONS) + ? main.LATENCY_TEST_URL_OPTIONS + : [main.DEFAULT_LATENCY_TEST_URL || "https://www.gstatic.com/generate_204"]; +} + +function validateUrlTestTolerance(value) { + if (!value || `${value}`.length === 0) { + return _("Must be a number in the range of 0 - 10000"); + } + + const normalized = `${value}`; + const parsed = parseFloat(normalized); + if ( + /^[0-9]+$/.test(normalized) && + !isNaN(parsed) && + isFinite(parsed) && + parsed >= 0 && + parsed <= 10000 + ) { + return true; + } + + return _("Must be a number in the range of 0 - 10000"); +} + +function validateUrlTestUrl(value) { + const validation = main.validateUrl(`${value || ""}`.trim()); + return validation.valid ? true : validation.message; +} + +function optionMapValue(option, section_id, key) { + const value = + option && option.map && option.map.data + ? option.map.data.get(option.map.config, section_id, key) + : uci.get(UCI_PACKAGE, section_id, key); + + return value == null ? "" : value; +} + +function subscriptionUrlSettingsKeys() { + return [ + "subscription_update_enabled", + "subscription_update_interval", + "download_via_proxy_enabled", + "download_via_proxy_section", + "auto_user_agent", + "user_agent", + "auto_hwid", + "hwid", + "show_dashboard_metadata", + "prefix_nodes", + "node_prefix", + "include_urltest_groups", + "hide_urltest_group_outbounds", + "hide_detour_outbounds", + ]; +} + +function defaultSubscriptionUrlSettings() { + return { + subscription_update_enabled: "1", + subscription_update_interval: "1h", + download_via_proxy_enabled: "0", + download_via_proxy_section: "", + auto_user_agent: "1", + user_agent: "", + auto_hwid: "1", + hwid: "", + show_dashboard_metadata: "1", + prefix_nodes: "0", + node_prefix: "", + include_urltest_groups: "1", + hide_urltest_group_outbounds: "1", + hide_detour_outbounds: "1", + }; +} + +function interfaceSettingsKeys() { + return [ + "domain_resolver_enabled", + "domain_resolver_dns_type", + "domain_resolver_dns_server", + ]; +} + +function defaultInterfaceSettings() { + return { + domain_resolver_enabled: "0", + domain_resolver_dns_type: "udp", + domain_resolver_dns_server: "8.8.8.8", + }; +} + +function subscriptionUserAgentChoices() { + return [ + "sing-box", + "Happ", + "v2rayN", + "v2rayNG", + "v2RayTun", + "Incy", + "Hiddify", + "HiddifyNext", + "Clash", + "Clash.Meta", + "ClashMetaForAndroid", + "Mihomo", + "NekoBox", + "Karing", + "Husi", + ]; +} + +function urlTestSettingsKeys() { + return [ + "name", + "check_interval", + "tolerance", + "testing_url", + "idle_timeout", + "interrupt_exist_connections", + "pin_dashboard", + "filter_mode", + "detect_server_country", + "include_countries", + "include_outbounds", + "include_regex", + "include_proxy_parameters", + "include_protocols", + "include_transports", + "include_securities", + "exclude_countries", + "exclude_outbounds", + "exclude_regex", + "exclude_proxy_parameters", + "exclude_protocols", + "exclude_transports", + "exclude_securities", + ]; +} + +function defaultUrlTestSettings(name) { + return { + name: "", + check_interval: "3m", + tolerance: "50", + testing_url: "https://www.gstatic.com/generate_204", + idle_timeout: "30m", + interrupt_exist_connections: "1", + pin_dashboard: "1", + filter_mode: "disabled", + detect_server_country: "flag_emoji", + }; +} + +function urlTestChildDefaults() { + return { + check_interval: "3m", + tolerance: "50", + testing_url: "https://www.gstatic.com/generate_204", + idle_timeout: "30m", + interrupt_exist_connections: "1", + pin_dashboard: "1", + filter_mode: "disabled", + detect_server_country: "flag_emoji", + }; +} + +function priorityGroupSettingsKeys() { + return [ + "name", + "health_url", + "active_check_interval", + "check_timeout", + "recovery_check_interval", + "pick_fastest", + "switch_to_faster_same_priority", + "fastest_check_interval", + "interrupt_exist_connections", + "pin_dashboard", + ]; +} + +function defaultPriorityGroupSettings() { + return { + name: "", + health_url: "https://www.gstatic.com/generate_204", + active_check_interval: "5s", + check_timeout: "2s", + recovery_check_interval: "15s", + pick_fastest: "0", + switch_to_faster_same_priority: "0", + fastest_check_interval: "3m", + interrupt_exist_connections: "1", + pin_dashboard: "1", + }; +} + +function priorityGroupChildDefaults() { + return { + health_url: "https://www.gstatic.com/generate_204", + active_check_interval: "5s", + check_timeout: "2s", + recovery_check_interval: "15s", + pick_fastest: "0", + switch_to_faster_same_priority: "0", + fastest_check_interval: "3m", + interrupt_exist_connections: "1", + pin_dashboard: "1", + }; +} + +function priorityLevelSettingsKeys() { + return [ + "name", + "order", + "direct", + "filter_mode", + "detect_server_country", + "country", + "server_name", + "regex", + "include_proxy_parameters", + "include_protocols", + "include_transports", + "include_securities", + "exclude_countries", + "exclude_outbounds", + "exclude_regex", + "exclude_proxy_parameters", + "exclude_protocols", + "exclude_transports", + "exclude_securities", + ]; +} + +function defaultPriorityLevelSettings() { + return { + name: "", + order: "0", + direct: "0", + filter_mode: "include", + detect_server_country: "flag_emoji", + }; +} + +function randomPriorityGroupId() { + for (let i = 0; i < 100; i += 1) { + const value = Math.floor(Math.random() * 0xffffffff) + .toString(16) + .padStart(8, "0"); + const id = `pg_${value}`; + + if (!uci.get(UCI_PACKAGE, id)) { + return id; + } + } + + return `pg_${Date.now().toString(16)}`; +} + +function addSubscriptionUrlItemOptions(itemSection, options = {}) { + const parentSectionForItem = + typeof options.parentSectionId === "function" + ? options.parentSectionId + : parentSectionIdForItem; + + let o = itemSection.option( + form.Flag, + "subscription_update_enabled", + _("Subscription auto update"), + _("Update this subscription automatically"), + ); + o.default = "1"; + o.rmempty = false; + + o = itemSection.option( + form.Value, + "subscription_update_interval", + _("Subscription update interval"), + _("Use sing-box duration format like 1d, 12h or 30m"), + ); + o.depends("subscription_update_enabled", "1"); + o.placeholder = "1h"; + o.validate = function (itemId, value) { + return optionMapValue(this, itemId, "subscription_update_enabled") === "1" + ? validateRequiredSingBoxDuration(value) + : validateOptionalSingBoxDuration(value); + }; + + o = itemSection.option( + form.Flag, + "download_via_proxy_enabled", + _("Download subscription through a section"), + _("Download subscriptions via the selected section"), + ); + o.default = "0"; + o.rmempty = false; + + o = itemSection.option( + form.ListValue, + "download_via_proxy_section", + _("Download through"), + ); + o.depends("download_via_proxy_enabled", "1"); + o.load = function (itemId) { + const sectionId = parentSectionForItem(itemId); + refreshOptionChoices(this, subscriptionDownloadTargetChoices(sectionId)); + return optionMapValue(this, itemId, "download_via_proxy_section") || ""; + }; + o.validate = function (itemId, value) { + const sectionId = parentSectionForItem(itemId); + if (optionMapValue(this, itemId, "download_via_proxy_enabled") !== "1") { + return true; + } + if (!value) { + return _("Select a section for downloading this subscription"); + } + if (value === sectionId) { + return _("Current section cannot download its own subscription"); + } + return true; + }; + + o = itemSection.option( + form.Flag, + "auto_user_agent", + _("Automatic User-Agent selection"), + _( + "Try compatible User-Agent profiles automatically when downloading this subscription", + ), + ); + o.default = "1"; + o.rmempty = false; + + o = itemSection.option( + form.Value, + "user_agent", + _("User-Agent"), + _("Select a common client profile or enter a custom User-Agent"), + ); + o.depends("auto_user_agent", "0"); + o.placeholder = _("-- Select --"); + o.rmempty = false; + subscriptionUserAgentChoices().forEach((choice) => o.value(choice)); + o.load = function (itemId) { + return optionMapValue(this, itemId, "user_agent") || ""; + }; + o.validate = function (itemId, value) { + if (optionMapValue(this, itemId, "auto_user_agent") !== "0") { + return true; + } + return `${value || ""}`.trim() ? true : _("Select or enter a User-Agent"); + }; + + o = itemSection.option( + form.Flag, + "auto_hwid", + _("Auto-generate HWID"), + _("Generate HWID from router hardware information for this subscription"), + ); + o.default = "1"; + o.rmempty = false; + + o = itemSection.option( + form.Value, + "hwid", + _("HWID"), + _("Enter the HWID sent with subscription requests"), + ); + o.depends("auto_hwid", "0"); + o.rmempty = false; + o.validate = function (itemId, value) { + if (optionMapValue(this, itemId, "auto_hwid") !== "0") { + return true; + } + return `${value || ""}`.trim() ? true : _("Enter HWID"); + }; + + o = itemSection.option( + form.Flag, + "show_dashboard_metadata", + _("Show metadata on dashboard"), + _("Show subscription metadata for this source on the dashboard"), + ); + o.default = "1"; + o.rmempty = false; + + o = itemSection.option( + form.Flag, + "prefix_nodes", + _("Add prefix to nodes"), + _( + "Automatically add text to the name of each server from this subscription for convenient filtering.", + ), + ); + o.default = "0"; + o.rmempty = false; + + o = itemSection.option(form.Value, "node_prefix", _("Prefix text")); + o.depends("prefix_nodes", "1"); + o.rmempty = false; + + o = itemSection.option( + form.Flag, + "include_urltest_groups", + _("Import subscription URLTest groups"), + _("Import URLTest groups returned by this subscription provider"), + ); + o.default = "1"; + o.rmempty = false; + + o = itemSection.option( + form.Flag, + "hide_urltest_group_outbounds", + _("Hide URLTest group nodes"), + _( + "Hide individual nodes that are already included in imported subscription URLTest groups", + ), + ); + o.depends("include_urltest_groups", "1"); + o.default = "1"; + o.rmempty = false; + + o = itemSection.option( + form.Flag, + "hide_detour_outbounds", + _("Hide cascade connection nodes"), + _("Hide intermediate nodes used as detours by other subscription nodes"), + ); + o.default = "1"; + o.rmempty = false; +} + +function addInterfaceItemOptions(itemSection) { + let o = itemSection.option( + form.Flag, + "domain_resolver_enabled", + _("Domain Resolver"), + _("Enable built-in DNS resolver for domains handled by this section"), + ); + o.default = "0"; + o.rmempty = false; + + o = itemSection.option( + form.ListValue, + "domain_resolver_dns_type", + _("DNS protocol"), + _("DNS protocol used by the resolver"), + ); + o.depends("domain_resolver_enabled", "1"); + dnsTypeChoices().forEach((choice) => o.value(choice.value, choice.label)); + o.default = "udp"; + + o = itemSection.option( + form.Value, + "domain_resolver_dns_server", + _("DNS server"), + _("DNS server used by the resolver"), + ); + o.depends("domain_resolver_enabled", "1"); + o.default = "8.8.8.8"; + o.validate = function (itemId, value) { + if (optionMapValue(this, itemId, "domain_resolver_enabled") !== "1") { + return true; + } + const validation = main.validateDNS(value); + return validation.valid ? true : validation.message; + }; +} + +function addUrlTestItemOptions(itemSection, options = {}) { + const parentSectionForItem = + typeof options.parentSectionId === "function" + ? options.parentSectionId + : parentSectionIdForItem; + + let o = itemSection.option( + form.Value, + "name", + _("Display name"), + _("Name displayed on the dashboard"), + ); + o.rmempty = false; + o.load = function (itemId) { + return ( + optionMapValue(this, itemId, "name") || + optionMapValue(this, itemId, "display_name") || + "" + ); + }; + o.validate = function (_itemId, value) { + return `${value || ""}`.trim() ? true : _("Enter a display name"); + }; + + o = itemSection.option( + form.Value, + "check_interval", + _("Check interval"), + _("Use sing-box duration format like 1d, 12h or 30m"), + ); + o.default = "3m"; + o.rmempty = false; + o.validate = function (_itemId, value) { + return validateRequiredSingBoxDuration(value); + }; + + o = itemSection.option( + form.Value, + "tolerance", + _("Tolerance"), + _( + "Minimum latency difference in milliseconds that triggers switching to a faster server.", + ), + ); + o.default = "50"; + o.rmempty = false; + o.validate = function (_itemId, value) { + return validateUrlTestTolerance(value); + }; + + o = itemSection.option( + form.Value, + "testing_url", + _("Check URL"), + _("URL used to test server latency"), + ); + o.default = "https://www.gstatic.com/generate_204"; + o.rmempty = false; + urlTestUrlChoices().forEach((value) => o.value(value)); + o.validate = function (_itemId, value) { + return validateUrlTestUrl(value); + }; + + o = itemSection.option( + form.Value, + "idle_timeout", + _("Idle timeout"), + _( + "Stop checking when URLTest group is not used. Use sing-box duration format like 1d, 12h or 30m.", + ), + ); + o.default = "30m"; + o.rmempty = false; + o.validate = function (_itemId, value) { + return validateRequiredSingBoxDuration(value); + }; + + o = itemSection.option( + form.Flag, + "interrupt_exist_connections", + _("Interrupt connections"), + _( + "Interrupt connections when URLTest switches the selected server", + ), + ); + o.default = "1"; + o.rmempty = false; + + o = itemSection.option( + form.Flag, + "pin_dashboard", + _("Pin on dashboard"), + _("Keep URLTest before latency-sorted servers"), + ); + o.default = "1"; + o.rmempty = false; + + o = itemSection.option( + form.ListValue, + "filter_mode", + _("Server filtering"), + _("Allows limiting the list of servers for URLTest"), + ); + urlTestFilterModeChoices().forEach((choice) => + o.value(choice.value, choice.label), + ); + o.default = "disabled"; + + o = itemSection.option( + form.ListValue, + "detect_server_country", + _("Detect server country"), + ); + o.depends("filter_mode", "exclude"); + o.depends("filter_mode", "include"); + o.depends("filter_mode", "mixed"); + serverCountryDetectionChoices().forEach((choice) => + o.value(choice.value, choice.label), + ); + o.default = "flag_emoji"; + + const includeProxyParameterOptions = { + prefix: "include", + toggleLabel: _("Include by proxy parameters"), + toggleDescription: _( + "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters.", + ), + dependencies: [{ filter_mode: "include" }, { filter_mode: "mixed" }], + protocolDescription: _( + "Test only servers with one of the selected protocols.", + ), + transportDescription: _( + "Test only servers with one of the selected transports.", + ), + securityDescription: _( + "Test only servers with one of the selected security types.", + ), + }; + const excludeProxyParameterOptions = { + prefix: "exclude", + toggleLabel: _("Exclude by proxy parameters"), + toggleDescription: _( + "Additionally exclude servers by protocol, transport, and security. Exclude only servers matching the specified parameters.", + ), + dependencies: [{ filter_mode: "exclude" }, { filter_mode: "mixed" }], + protocolDescription: _( + "Do not test servers with one of the selected protocols.", + ), + transportDescription: _( + "Do not test servers with one of the selected transports.", + ), + securityDescription: _( + "Do not test servers with one of the selected security types.", + ), + }; + + [ + [ + "include_countries", + _("Include countries"), + _("Test servers only from the specified countries."), + countryChoices(), + validateCountryCode, + ["include", "mixed"], + ], + [ + "include_outbounds", + _("Include servers"), + _("Test only selected servers."), + null, + null, + ["include", "mixed"], + ], + [ + "include_regex", + _("Include by regular expression"), + _("Test servers whose names match the expression."), + null, + validateRegex, + ["include", "mixed"], + ], + [ + "exclude_countries", + _("Exclude countries"), + _("Do not test servers from these countries."), + countryChoices(), + validateCountryCode, + ["exclude", "mixed"], + ], + [ + "exclude_outbounds", + _("Exclude servers"), + _("Do not test specified servers."), + null, + null, + ["exclude", "mixed"], + ], + [ + "exclude_regex", + _("Exclude by regular expression"), + _("Do not test servers whose names match the expression."), + null, + validateRegex, + ["exclude", "mixed"], + ], + ].forEach(([key, label, description, choices, validator, modes]) => { + const list = itemSection.option(form.DynamicList, key, label, description); + modes.forEach((mode) => list.depends("filter_mode", mode)); + list.rmempty = true; + if (choices) { + choices.forEach((choice) => list.value(choice.value, choice.label)); + list.placeholder = _("-- Select --"); + } + if (key.endsWith("_outbounds")) { + list.load = function (itemId) { + const sectionId = parentSectionForItem(itemId); + const values = normalizeOptionValues(optionMapValue(this, itemId, key)); + + return loadOutboundNameChoices(sectionId).then(() => { + refreshOptionChoices( + this, + currentOutboundNameChoices(sectionId, values), + ); + return values; + }); + }; + list.placeholder = _("-- Select --"); + configureLiveDynamicListChoices(list, (itemId, values) => + currentOutboundNameChoices(parentSectionForItem(itemId), values), + ); + } + if (validator) { + list.validate = function (_itemId, value) { + return validator(null, value); + }; + } + if (key === "include_regex") { + addProxyParameterFilterOptions(itemSection, includeProxyParameterOptions); + } else if (key === "exclude_regex") { + addProxyParameterFilterOptions(itemSection, excludeProxyParameterOptions); + } + }); +} + +function priorityLevelSettingsForValidation(groupId, levelId, option) { + const store = childPendingSettingsStore(option, groupId); + + if (isExistingChildItem(groupId, levelId, "priority_level", "group")) { + return readChildSettings( + levelId, + priorityLevelSettingsKeys(), + defaultPriorityLevelSettings(), + ); + } + + return Object.assign({}, store[levelId] || {}); +} + +function validatePriorityLevelItemsBeforeSave(groupId, values, option) { + const normalizedValues = normalizeDynamicListItems(values); + + for (const value of normalizedValues) { + const levelId = `${value || ""}`; + const settings = priorityLevelSettingsForValidation( + groupId, + levelId, + option, + ); + + if (!`${settings.name || ""}`.trim()) { + return _("Enter a level name"); + } + } + + return true; +} + +function addPriorityLevelItemOptions(itemSection, options = {}) { + const parentSectionForItem = + typeof options.parentSectionId === "function" + ? options.parentSectionId + : parentSectionIdForItem; + + let o = itemSection.option( + form.Value, + "name", + _("Level name"), + _("Name shown in the priority level list"), + ); + o.rmempty = false; + o.validate = function (_itemId, value) { + return `${value || ""}`.trim() ? true : _("Enter a level name"); + }; + + o = itemSection.option( + form.Flag, + "direct", + _("Direct connection"), + _("Traffic for this level goes directly."), + ); + o.default = "0"; + o.rmempty = false; + + o = itemSection.option( + form.ListValue, + "filter_mode", + _("Server filtering"), + _( + "All remaining servers means every server not already assigned to a higher-priority level.", + ), + ); + priorityLevelFilterModeChoices().forEach((choice) => + o.value(choice.value, choice.label), + ); + o.default = "include"; + o.depends("direct", "0"); + + o = itemSection.option( + form.ListValue, + "detect_server_country", + _("Detect server country"), + ); + ["exclude", "include", "mixed"].forEach((mode) => + o.depends({ direct: "0", filter_mode: mode }), + ); + serverCountryDetectionChoices().forEach((choice) => + o.value(choice.value, choice.label), + ); + o.default = "flag_emoji"; + + const includeProxyParameterOptions = { + prefix: "include", + toggleLabel: _("Include by proxy parameters"), + toggleDescription: _( + "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters.", + ), + dependencies: [ + { direct: "0", filter_mode: "include" }, + { direct: "0", filter_mode: "mixed" }, + ], + protocolDescription: _("Only servers with one of the selected protocols."), + transportDescription: _( + "Only servers with one of the selected transports.", + ), + securityDescription: _( + "Only servers with one of the selected security types.", + ), + }; + const excludeProxyParameterOptions = { + prefix: "exclude", + toggleLabel: _("Exclude by proxy parameters"), + toggleDescription: _( + "Additionally exclude servers by protocol, transport, and security. Exclude only servers matching the specified parameters.", + ), + dependencies: [ + { direct: "0", filter_mode: "exclude" }, + { direct: "0", filter_mode: "mixed" }, + ], + protocolDescription: _( + "Exclude servers with one of the selected protocols from this level.", + ), + transportDescription: _( + "Exclude servers with one of the selected transports from this level.", + ), + securityDescription: _( + "Exclude servers with one of the selected security types from this level.", + ), + }; + + [ + [ + "country", + _("Include countries"), + _("Only from the specified countries."), + countryChoices(), + validateCountryCode, + ["include", "mixed"], + ], + [ + "server_name", + _("Include servers"), + _("Only the specified servers."), + null, + null, + ["include", "mixed"], + ], + [ + "regex", + _("Include by regular expression"), + _("Only servers whose names match the expression."), + null, + validateRegex, + ["include", "mixed"], + ], + [ + "exclude_countries", + _("Exclude countries"), + _("Remove servers from the specified countries from this level."), + countryChoices(), + validateCountryCode, + ["exclude", "mixed"], + ], + [ + "exclude_outbounds", + _("Exclude servers"), + _("Remove the specified servers from this level."), + null, + null, + ["exclude", "mixed"], + ], + [ + "exclude_regex", + _("Exclude by regular expression"), + _("Remove servers whose names match the expression from this level."), + null, + validateRegex, + ["exclude", "mixed"], + ], + ].forEach(([key, label, description, choices, validator, modes]) => { + const list = itemSection.option(form.DynamicList, key, label, description); + modes.forEach((mode) => list.depends({ direct: "0", filter_mode: mode })); + list.rmempty = true; + if (choices) { + choices.forEach((choice) => list.value(choice.value, choice.label)); + list.placeholder = _("-- Select --"); + } + if (key === "server_name" || key === "exclude_outbounds") { + list.load = function (itemId) { + const sectionId = parentSectionForItem(itemId); + const values = normalizeOptionValues(optionMapValue(this, itemId, key)); + + return loadOutboundNameChoices(sectionId).then(() => { + refreshOptionChoices( + this, + currentOutboundNameChoices(sectionId, values), + ); + return values; + }); + }; + list.placeholder = _("-- Select --"); + configureLiveDynamicListChoices(list, (itemId, values) => + currentOutboundNameChoices(parentSectionForItem(itemId), values), + ); + } + if (validator) { + list.validate = function (_itemId, value) { + return validator(null, value); + }; + } + if (key === "regex") { + addProxyParameterFilterOptions(itemSection, includeProxyParameterOptions); + } else if (key === "exclude_regex") { + addProxyParameterFilterOptions(itemSection, excludeProxyParameterOptions); + } + }); +} + +function addPriorityGroupItemOptions(itemSection, options = {}) { + const parentSectionForGroup = + typeof options.parentSectionId === "function" + ? options.parentSectionId + : parentSectionIdForItem; + const ownerId = + typeof options.ownerId === "function" ? options.ownerId : () => ""; + + let o = itemSection.option( + form.Value, + "name", + _("Display name"), + _("Name displayed on the dashboard"), + ); + o.rmempty = false; + o.validate = function (_itemId, value) { + return `${value || ""}`.trim() ? true : _("Enter a display name"); + }; + + o = itemSection.option( + form.Value, + "health_url", + _("Check URL"), + _("URL used to check whether a server is alive"), + ); + o.default = "https://www.gstatic.com/generate_204"; + o.rmempty = false; + urlTestUrlChoices().forEach((value) => o.value(value)); + o.validate = function (_itemId, value) { + return validateUrlTestUrl(value); + }; + + o = itemSection.option( + form.Value, + "active_check_interval", + _("Check interval"), + _("How often the currently selected server is checked"), + ); + o.default = "5s"; + o.rmempty = false; + o.validate = function (_itemId, value) { + return validateRequiredSingBoxDuration(value); + }; + + o = itemSection.option( + form.Value, + "check_timeout", + _("Unavailability timeout"), + _("Check timeout after which the server is considered dead"), + ); + o.default = "2s"; + o.rmempty = false; + o.validate = function (_itemId, value) { + return validateRequiredSingBoxDuration(value); + }; + + o = itemSection.option( + form.Value, + "recovery_check_interval", + _("Higher-level check interval"), + _( + "How often higher priority levels are checked while a lower level is active", + ), + ); + o.default = "15s"; + o.rmempty = false; + o.validate = function (_itemId, value) { + return validateRequiredSingBoxDuration(value); + }; + + o = itemSection.option( + form.Flag, + "pick_fastest", + _("Select the fastest node"), + _( + "When switching to another level, test every server and select the fastest instead of the first working one.", + ), + ); + o.default = "0"; + o.rmempty = false; + + o = itemSection.option( + form.Flag, + "switch_to_faster_same_priority", + _("Automatically select the fastest node in the current level"), + _( + "Periodically check the current level and switch to a faster server even when the current one works.", + ), + ); + o.default = "0"; + o.rmempty = false; + + o = itemSection.option( + form.Value, + "fastest_check_interval", + _("Faster server search interval"), + _("Use sing-box duration format like 1d, 12h or 30m"), + ); + o.depends("switch_to_faster_same_priority", "1"); + o.default = "3m"; + o.rmempty = false; + o.validate = function (itemId, value) { + return optionMapValue(this, itemId, "switch_to_faster_same_priority") === + "1" + ? validateRequiredSingBoxDuration(value) + : true; + }; + + o = itemSection.option( + form.Flag, + "interrupt_exist_connections", + _("Interrupt connections"), + _("Interrupt connections when priority failover switches server"), + ); + o.default = "1"; + o.rmempty = false; + + o = itemSection.option( + form.Flag, + "pin_dashboard", + _("Pin on dashboard"), + _("Keep Priority before latency-sorted servers"), + ); + o.default = "1"; + o.rmempty = false; + + o = itemSection.option( + ButtonAddSettingsDynamicList, + "priority_level", + _("Priority levels"), + _("Top level has the highest priority; lower levels are used as fallback"), + ); + o.rmempty = true; + o.modalonly = true; + o.addButtonLabel = _("+ Add level"); + o.childType = "priority_level"; + o.ownerOption = "group"; + o.childOwnerId = ownerId; + o.parentSectionId = parentSectionForGroup; + o.childValueOption = "name"; + o.childDefaults = defaultPriorityLevelSettings(); + o.renderItemSettingsModal = showPriorityLevelSettingsModal; + o.validateItemsOnSave = function (groupId, values) { + return validatePriorityLevelItemsBeforeSave(groupId, values, this); + }; + o.hasItemSettings = function (groupId, value) { + const normalized = `${value || ""}`.trim(); + + if (isExistingChildItem(groupId, normalized, "priority_level", "group")) { + return true; + } + + return normalized.length > 0; + }; + o.inputValueForItem = function (groupId, value) { + const inputValue = childItemInputValue( + groupId, + value, + "priority_level", + "name", + "group", + ); + const store = childPendingSettingsStore(this, groupId); + return store[inputValue] && store[inputValue].name + ? store[inputValue].name + : inputValue; + }; + o.stagedChildSettings = function (groupId, value) { + const id = childItemInputValue( + groupId, + value, + "priority_level", + "name", + "group", + ); + const store = childPendingSettingsStore(this, groupId); + return store[id] ? Object.assign({}, store[id]) : null; + }; + o.clearStagedChildSettings = function (groupId) { + if (this.pendingChildSettings) { + delete this.pendingChildSettings[groupId]; + } + }; + o.afterMaterializeChildItems = function (_groupId, itemIds) { + itemIds.forEach((itemId, index) => { + uci.set(UCI_PACKAGE, itemId, "order", `${index}`); + }); + }; + o.renderListItemLabel = function (groupId, itemId) { + return E( + "span", + { class: "fkp-dynlist-label" }, + this.inputValueForItem(groupId, itemId), + ); + }; +} + +function addDashboardGroupFilterOption( + optionSection, + key, + label, + description, + modes, +) { + const list = optionSection.option(form.DynamicList, key, label, description); + modes.forEach((mode) => { + list.depends({ + action: "connection", + dashboard_filter_mode: mode, + urltest: /.+/, + }); + list.depends({ + action: "connection", + dashboard_filter_mode: mode, + priority_group: /.+/, + }); + }); + list.rmempty = true; + list.placeholder = _("-- Select --"); + list.load = function (section_id) { + const values = getConfigListValues(section_id, key); + const choices = currentSectionGroupChoices(section_id, values); + refreshOptionChoices(this, choices); + return values; + }; + list.validate = function (section_id, value) { + const selected = new Set( + currentSectionGroupChoices(section_id).map((choice) => choice.value), + ); + return normalizeDynamicListItems(value).every((item) => selected.has(item)) + ? true + : _("Select an existing URLTest or Priority group"); + }; + configureLiveDynamicListChoices(list, currentSectionGroupChoices); +} + +function addDashboardServerFilterOptions(section) { + const optionSection = { + option: (optionType, ...args) => { + const option = section.taboption("settings", optionType, ...args); + option.modalonly = true; + return option; + }, + }; + let o = optionSection.option( + form.ListValue, + "dashboard_filter_mode", + _("Servers on dashboard"), + _("Filter the servers that will be displayed on the dashboard."), + ); + urlTestFilterModeChoices().forEach((choice) => + o.value(choice.value, choice.label), + ); + o.default = "disabled"; + o.depends("action", "connection"); + + o = optionSection.option( + form.ListValue, + "dashboard_detect_server_country", + _("Detect server country"), + ); + ["exclude", "include", "mixed"].forEach((mode) => + o.depends({ action: "connection", dashboard_filter_mode: mode }), + ); + serverCountryDetectionChoices().forEach((choice) => + o.value(choice.value, choice.label), + ); + o.default = "flag_emoji"; + + const includeProxyParameterOptions = { + prefix: "dashboard_include", + toggleLabel: _("Include by proxy parameters"), + toggleDescription: _( + "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters.", + ), + dependencies: [ + { action: "connection", dashboard_filter_mode: "include" }, + { action: "connection", dashboard_filter_mode: "mixed" }, + ], + protocolDescription: _( + "Show only servers with one of the selected protocols.", + ), + transportDescription: _( + "Show only servers with one of the selected transports.", + ), + securityDescription: _( + "Show only servers with one of the selected security types.", + ), + }; + const excludeProxyParameterOptions = { + prefix: "dashboard_exclude", + toggleLabel: _("Exclude by proxy parameters"), + toggleDescription: _( + "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters.", + ), + dependencies: [ + { action: "connection", dashboard_filter_mode: "exclude" }, + { action: "connection", dashboard_filter_mode: "mixed" }, + ], + protocolDescription: _("Hide servers with one of the selected protocols."), + transportDescription: _( + "Hide servers with one of the selected transports.", + ), + securityDescription: _( + "Hide servers with one of the selected security types.", + ), + }; + + [ + [ + "dashboard_include_countries", + _("Include countries"), + _("Show servers only from the specified countries."), + countryChoices(), + validateCountryCode, + ["include", "mixed"], + ], + [ + "dashboard_include_outbounds", + _("Include servers"), + _("Show only selected servers."), + null, + null, + ["include", "mixed"], + ], + [ + "dashboard_include_regex", + _("Include by regular expression"), + _("Show servers whose names match the expression."), + null, + validateRegex, + ["include", "mixed"], + ], + [ + "dashboard_exclude_countries", + _("Exclude countries"), + _("Hide servers from the specified countries."), + countryChoices(), + validateCountryCode, + ["exclude", "mixed"], + ], + [ + "dashboard_exclude_outbounds", + _("Exclude servers"), + _("Hide selected servers."), + null, + null, + ["exclude", "mixed"], + ], + [ + "dashboard_exclude_regex", + _("Exclude by regular expression"), + _("Hide servers whose names match the expression."), + null, + validateRegex, + ["exclude", "mixed"], + ], + ].forEach(([key, label, description, choices, validator, modes]) => { + const list = optionSection.option( + form.DynamicList, + key, + label, + description, + ); + modes.forEach((mode) => + list.depends({ action: "connection", dashboard_filter_mode: mode }), + ); + list.rmempty = true; + if (choices) { + choices.forEach((choice) => list.value(choice.value, choice.label)); + list.placeholder = _("-- Select --"); + } + if (key.endsWith("_outbounds")) { + list.load = function (section_id) { + const values = getConfigListValues(section_id, key); + loadOutboundNameChoices(section_id).then(() => { + refreshDashboardFilterChoiceWidgets(section_id); + }); + return values; + }; + list.placeholder = _("-- Select --"); + configureLiveDynamicListChoices(list, currentOutboundNameChoices); + } + if (validator) { + list.validate = function (_section_id, value) { + return validator(null, value); + }; + } + if (key === "dashboard_include_regex") { + addDashboardGroupFilterOption( + optionSection, + "dashboard_include_groups", + _("Add URLTest / Priority group"), + _( + "Show servers from selected groups without repeating their filtering criteria.", + ), + ["include", "mixed"], + ); + addProxyParameterFilterOptions( + optionSection, + includeProxyParameterOptions, + ); + } else if (key === "dashboard_exclude_regex") { + addDashboardGroupFilterOption( + optionSection, + "dashboard_exclude_groups", + _("Exclude URLTest / Priority group"), + _( + "Hide servers from selected groups without repeating their filtering criteria.", + ), + ["exclude", "mixed"], + ); + addProxyParameterFilterOptions( + optionSection, + excludeProxyParameterOptions, + ); + } + }); +} + +function settingValueEquals(left, right) { + const normalize = (value) => { + if (Array.isArray(value)) { + return JSON.stringify(value.map((item) => `${item || ""}`)); + } + + return value === undefined || value === null ? "" : `${value}`; + }; + + return normalize(left) === normalize(right); +} + +function readChildSettings(itemId, keys, defaults) { + const result = Object.assign({}, defaults || {}); + + keys.forEach((key) => { + const value = uci.get(UCI_PACKAGE, itemId, key); + if (value !== null && value !== undefined) { + result[key] = Array.isArray(value) ? value.slice() : value; + } + }); + + return result; +} + +function changedSettings(base, next, keys) { + const result = {}; + + keys.forEach((key) => { + if (!settingValueEquals(base ? base[key] : null, next ? next[key] : null)) { + result[key] = next ? next[key] : null; + } + }); + + return result; +} + +function hasChangedSettings(settings) { + return Object.keys(settings || {}).length > 0; +} + +function childPendingSettingsStore(option, section_id) { + if (!option.pendingChildSettings) { + option.pendingChildSettings = {}; + } + + if (!option.pendingChildSettings[section_id]) { + option.pendingChildSettings[section_id] = {}; + } + + return option.pendingChildSettings[section_id]; +} + +function pendingChildSettings(option, section_id, value, defaults) { + value = `${value || ""}`.trim(); + const store = childPendingSettingsStore(option, section_id); + + if (!store[value]) { + store[value] = Object.assign({}, defaults || {}); + } + + return store[value]; +} + +function renderStackedJsonSettingsModal(title, map, onSave) { + const modal = document.querySelector("#modal_overlay > .modal.cbi-modal"); + const activeMap = modal ? modal.querySelector(".cbi-map:not(.hidden)") : null; + const buttonRow = modal ? modal.querySelector("div.button-row") : null; + const heading = modal ? modal.querySelector("h4") : null; + + if (!modal || !activeMap || !buttonRow || !heading) { + return Promise.resolve(); + } + + return map.render().then((nodes) => { + const titleNode = E("span", title ? ` » ${title}` : ""); + const originalButtonClass = buttonRow.getAttribute("class") || ""; + const originalButtonNodes = Array.from(buttonRow.childNodes); + let closed = false; + let saveButton; + let validationSummary; + + const clearValidationSummary = () => { + if (validationSummary && validationSummary.parentNode) { + validationSummary.parentNode.removeChild(validationSummary); + } + validationSummary = null; + }; + + const showValidationSummary = (error) => { + clearValidationSummary(); + + const message = error?.message || ""; + validationSummary = E( + "div", + { + class: + "alert-message warning fkp-stacked-settings-validation-summary", + }, + [ + E("strong", {}, _("Cannot save settings")), + E("div", {}, _("Fix the highlighted fields and save again.")), + message ? E("small", {}, message) : "", + ], + ); + buttonRow.parentNode.insertBefore(validationSummary, buttonRow); + + const invalidInput = nodes.querySelector(".cbi-input-invalid"); + if (invalidInput) { + invalidInput.scrollIntoView({ block: "center", behavior: "smooth" }); + invalidInput.focus({ preventScroll: true }); + } + }; + + const restoreButtonRow = () => { + buttonRow.textContent = ""; + originalButtonNodes.forEach((node) => buttonRow.appendChild(node)); + buttonRow.setAttribute("class", originalButtonClass); + }; + + const close = () => { + if (closed) { + return; + } + + closed = true; + clearValidationSummary(); + + if (nodes.parentNode) { + nodes.parentNode.removeChild(nodes); + } + if (titleNode.parentNode) { + titleNode.parentNode.removeChild(titleNode); + } + + activeMap.classList.remove("hidden"); + restoreButtonRow(); + }; + + const save = () => { + if (saveButton) { + saveButton.disabled = true; + } + clearValidationSummary(); + + return map + .parse() + .then(() => { + onSave(cleanFormSectionData(map.data.get(map.config, "settings"))); + close(); + }) + .catch((error) => { + if (saveButton) { + saveButton.disabled = false; + } + showValidationSummary(error); + }); + }; + + buttonRow.textContent = ""; + buttonRow.append( + E( + "button", + { + class: "btn cbi-button", + click: close, + }, + _("Close"), + ), + " ", + (saveButton = E( + "button", + { + class: "btn cbi-button cbi-button-positive important", + click: save, + }, + _("Save"), + )), + ); + + heading.appendChild(titleNode); + activeMap.classList.add("hidden"); + activeMap.parentNode.insertBefore(nodes, activeMap.nextElementSibling); + }); +} + +function showChildItemSettingsModal(section_id, itemValue, option, settings) { + const value = `${itemValue || ""}`.trim(); + const existing = isExistingChildItem( + section_id, + value, + settings.typeName, + settings.ownerOption, + ); + const inputValue = childItemInputValue( + section_id, + value, + settings.typeName, + settings.valueOption, + settings.ownerOption, + ); + const defaults = + typeof settings.defaults === "function" + ? settings.defaults(inputValue) + : Object.assign({}, settings.defaults || {}); + const initialSettings = existing + ? readChildSettings(value, settings.keys, defaults) + : Object.assign( + {}, + pendingChildSettings(option, section_id, inputValue, defaults), + ); + const data = { + settings: Object.assign({}, initialSettings), + }; + const map = new form.JSONMap(data); + const itemSection = map.section(form.NamedSection, "settings"); + itemSection.anonymous = true; + itemSection.addremove = false; + settings.addOptions(itemSection, { + parentSectionId: () => section_id, + ownerId: () => value || section_id, + }); + + return renderStackedJsonSettingsModal( + settings.title(inputValue), + map, + (nextSettings) => { + if (existing) { + const diff = changedSettings( + initialSettings, + nextSettings, + settings.keys, + ); + if (hasChangedSettings(diff)) { + applyChildItemSettings(value, diff); + } + if (typeof settings.afterSave === "function") { + settings.afterSave(value, inputValue, nextSettings, existing); + } + return; + } + + const nextInputValue = + `${nextSettings[settings.valueOption] || inputValue || ""}`.trim(); + const store = childPendingSettingsStore(option, section_id); + if (nextInputValue !== inputValue) { + delete store[inputValue]; + } + store[nextInputValue] = nextSettings; + if (typeof settings.afterSave === "function") { + settings.afterSave(value, nextInputValue, nextSettings, existing); + } + }, + ); +} + +function ruleSetIncludesSubnets(section_id, value) { + const settings = readItemSettingsMap(section_id, RULE_SET_ITEM_SETTINGS_KEY); + const itemSettings = settings && settings[value]; + + if ( + itemSettings && + typeof itemSettings === "object" && + itemSettings.include_subnets != null + ) { + return itemSettingsFlag(itemSettings, "include_subnets", false); + } + + return getConfigListValues(section_id, "rule_set_with_subnets").includes( + value, + ); +} + +function showSubscriptionUrlSettingsModal(_section_id, itemValue, option) { + return showChildItemSettingsModal(_section_id, itemValue, option, { + typeName: "subscription_url", + valueOption: "url", + keys: subscriptionUrlSettingsKeys(), + defaults: defaultSubscriptionUrlSettings(), + addOptions: addSubscriptionUrlItemOptions, + title: () => _("Subscription URL settings"), + }); +} + +function showInterfaceSettingsModal(_section_id, itemValue, option) { + return showChildItemSettingsModal(_section_id, itemValue, option, { + typeName: "section_interface", + valueOption: "name", + keys: interfaceSettingsKeys(), + defaults: defaultInterfaceSettings(), + addOptions: addInterfaceItemOptions, + title: () => _("Network interface settings"), + }); +} + +function showUrlTestSettingsModal( + _section_id, + itemValue, + option, + widget, + itemNode, + context = {}, +) { + return showChildItemSettingsModal(_section_id, itemValue, option, { + typeName: "urltest", + valueOption: "name", + keys: urlTestSettingsKeys(), + defaults: defaultUrlTestSettings, + addOptions: addUrlTestItemOptions, + title: (name) => { + const normalized = `${name || ""}`.trim(); + return !normalized || /^urltest-[a-z0-9]+-\d+$/.test(normalized) + ? _("URLTest settings") + : `${_("URLTest settings")}: ${normalized}`; + }, + afterSave: (itemId, inputValue, settings, existing) => { + const displayName = `${settings.name || inputValue || ""}`.trim(); + + if (existing) { + uci.unset(UCI_PACKAGE, itemId, "id"); + uci.unset(UCI_PACKAGE, itemId, "display_name"); + updateDynamicListItemLabel(itemNode, displayName); + return; + } + + if (context.adding) { + addDynamicListItem(widget, displayName, displayName); + } else { + updateDynamicListItemLabel(itemNode, displayName); + } + }, + }); +} + +function showPriorityLevelSettingsModal( + groupId, + itemValue, + option, + widget, + itemNode, + context = {}, +) { + return showChildItemSettingsModal(groupId, itemValue, option, { + typeName: "priority_level", + ownerOption: "group", + valueOption: "name", + keys: priorityLevelSettingsKeys(), + defaults: defaultPriorityLevelSettings(), + addOptions: (itemSection) => + addPriorityLevelItemOptions(itemSection, { + parentSectionId: () => context.parentSectionId || "", + }), + title: (name) => { + const normalized = `${name || ""}`.trim(); + return normalized + ? `${_("Priority level settings")}: ${normalized}` + : _("Priority level settings"); + }, + afterSave: (itemId, inputValue, settings, existing) => { + const displayName = `${settings.name || inputValue || ""}`.trim(); + + if (existing) { + updateDynamicListItemLabel(itemNode, displayName); + return; + } + + if (context.adding) { + addDynamicListItem(widget, displayName, displayName); + } else { + updateDynamicListItemLabel(itemNode, displayName); + } + }, + }); +} + +function createPriorityGroupItem(section_id, groupId, settings) { + const created = uci.add(UCI_PACKAGE, "priority_group", groupId) || groupId; + + uci.set(UCI_PACKAGE, created, "section", section_id); + Object.entries(priorityGroupChildDefaults()).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + uci.set(UCI_PACKAGE, created, key, `${value}`); + } + }); + applyChildItemSettings(created, settings); + + return created; +} + +function showPriorityGroupSettingsModal( + section_id, + itemValue, + option, + widget, + itemNode, + context = {}, +) { + const groupId = context.adding + ? randomPriorityGroupId() + : `${itemValue || ""}`.trim(); + + if (!groupId) { + return null; + } + + return showChildItemSettingsModal(section_id, groupId, option, { + typeName: "priority_group", + valueOption: "name", + keys: priorityGroupSettingsKeys(), + defaults: defaultPriorityGroupSettings(), + addOptions: addPriorityGroupItemOptions, + title: (name) => { + if (context.adding) { + return _("Priority settings"); + } + + const normalized = `${name || ""}`.trim(); + return normalized + ? `${_("Priority settings")}: ${normalized}` + : _("Priority settings"); + }, + afterSave: (itemId, inputValue, settings, existing) => { + const displayName = `${settings.name || inputValue || ""}`.trim(); + + if (!existing) { + const created = createPriorityGroupItem(section_id, groupId, settings); + const store = childPendingSettingsStore(option, section_id); + delete store[groupId]; + delete store[inputValue]; + delete store[displayName]; + addDynamicListItem(widget, created, displayName); + return; + } + + updateDynamicListItemLabel(itemNode, displayName); + }, + }); +} + +function validateUrlTestItemsBeforeSave(section_id, values, option) { + const store = childPendingSettingsStore(option, section_id); + + for (const value of normalizeDynamicListItems(values)) { + const itemId = `${value || ""}`; + let name = ""; + + if (isExistingChildItem(section_id, itemId, "urltest")) { + name = + uci.get(UCI_PACKAGE, itemId, "name") || + uci.get(UCI_PACKAGE, itemId, "display_name") || + ""; + } else if (store[itemId]) { + name = store[itemId].name || ""; + } + + if (!`${name || ""}`.trim()) { + return _("Enter a display name"); + } + } + + return true; +} + +function validatePriorityGroupItemsBeforeSave(section_id, values) { + for (const value of normalizeDynamicListItems(values)) { + const groupId = `${value || ""}`.trim(); + + if (!isExistingChildItem(section_id, groupId, "priority_group")) { + return _("Open priority settings and enter a display name"); + } + + if (!`${uci.get(UCI_PACKAGE, groupId, "name") || ""}`.trim()) { + return _("Enter a display name"); + } + } + + return true; +} + +function showOutboundJsonSettingsModal( + _section_id, + itemValue, + _option, + widget, + itemNode, + context = {}, +) { + const data = { + settings: { + outbound_json: `${itemValue || ""}`, + }, + }; + const map = new form.JSONMap(data); + const itemSection = map.section(form.NamedSection, "settings"); + itemSection.anonymous = true; + itemSection.addremove = false; + + const jsonOption = itemSection.option( + form.TextValue, + "outbound_json", + _("JSON outbound"), + _("Enter a complete sing-box outbound object"), + ); + jsonOption.rows = 12; + jsonOption.wrap = "soft"; + jsonOption.textarea = true; + jsonOption.modalonly = true; + jsonOption.rmempty = false; + jsonOption.validate = function (_itemId, value) { + const usedTags = + widget && widget.node + ? Array.from(widget.node.querySelectorAll(".item")) + .filter((item) => item !== itemNode) + .map((item) => + outboundJsonDisplayTag(dynamicListItemCurrentValue(item, "")), + ) + .filter(Boolean) + : []; + const validation = main.validateOutboundJson(`${value || ""}`, usedTags); + return validation.valid ? true : validation.message; + }; + configureTextareaOption(jsonOption); + + return renderStackedJsonSettingsModal( + _("JSON outbound settings"), + map, + (settings) => { + const value = `${settings.outbound_json || ""}`.trim(); + if (context.adding) { + addDynamicListItem(widget, value); + return; + } + + setDynamicListItemValue(itemNode, value); + updateDynamicListItemLabel(itemNode, outboundJsonDisplayTag(value)); + if (widget && widget.node) { + widget.dispatchCbiDynlistChange(widget.node, value); + } + }, + ); +} + +function validateOutboundJsonItemsBeforeSave(_section_id, values) { + const items = Array.isArray(values) ? values : values ? [values] : []; + const tags = []; + + for (const value of items) { + const validation = main.validateOutboundJson(`${value || ""}`, tags); + if (!validation.valid) { + const tag = outboundJsonDisplayTag(value); + return tag ? `${tag}: ${validation.message}` : validation.message; + } + + const tag = outboundJsonDisplayTag(value); + tags.push(tag); + } + + return true; +} + +function showRuleSetSettingsModal(section_id, itemValue, option, widget) { + const data = { + settings: { + include_subnets: ruleSetIncludesSubnets(section_id, itemValue) + ? "1" + : "0", + }, + }; + const map = new form.JSONMap(data); + const section = map.section(form.NamedSection, "settings"); + section.anonymous = true; + section.addremove = false; + + const includeSubnets = section.option( + form.Flag, + "include_subnets", + _("Include IP addresses and subnets"), + _("Subnets from the list will be extracted and added to nftables"), + ); + includeSubnets.default = "0"; + includeSubnets.rmempty = false; + + return renderStackedJsonSettingsModal( + _("Rule set settings"), + map, + (settings) => { + const value = settings.include_subnets === "1"; + const refs = uniqueDynamicListItems( + widget && typeof widget.getValue === "function" + ? widget.getValue() + : getCustomRulesetReferences(section_id), + ); + const subnets = new Set( + getConfigListValues(section_id, "rule_set_with_subnets").filter((ref) => + refs.includes(ref), + ), + ); + + if (value) { + subnets.add(itemValue); + } else { + subnets.delete(itemValue); + } + + writeListOption( + section_id, + "rule_set", + refs.filter((ref) => !subnets.has(ref)), + ); + writeListOption(section_id, "rule_set_with_subnets", [...subnets]); + if (option && typeof option.getUIElement === "function") { + option.getUIElement(section_id).setValue(refs); + } + }, + ); +} + +function ensureActionProvidersAvailabilityLoaded() { + if (actionProvidersAvailabilityState.loaded) { + return Promise.resolve(actionProvidersAvailabilityState); + } + + if (actionProvidersAvailabilityPromise) { + return actionProvidersAvailabilityPromise; + } + + if (actionProvidersAvailabilityLoader) { + actionProvidersAvailabilityPromise = actionProvidersAvailabilityLoader() + .then((capabilities) => { + updateActionProvidersAvailabilityState({ + zapretInstalled: Boolean(capabilities?.zapretInstalled), + zapret2Installed: Boolean(capabilities?.zapret2Installed), + byedpiInstalled: Boolean(capabilities?.byedpiInstalled), + }); + return actionProvidersAvailabilityState; + }) + .catch(() => { + actionProvidersAvailabilityLoader = null; + actionProvidersAvailabilityPromise = null; + return ensureActionProvidersAvailabilityLoaded(); + }) + .finally(() => { + actionProvidersAvailabilityPromise = null; + }); + + return actionProvidersAvailabilityPromise; + } + + actionProvidersAvailabilityPromise = Promise.allSettled([ + main.TachyonShellMethods.checkZapretRuntime(), + main.TachyonShellMethods.checkZapret2Runtime(), + main.TachyonShellMethods.checkByedpiRuntime(), + ]) + .then(([zapretResult, zapret2Result, byedpiResult]) => { + const zapret = + zapretResult && zapretResult.status === "fulfilled" + ? zapretResult.value + : null; + const zapret2 = + zapret2Result && zapret2Result.status === "fulfilled" + ? zapret2Result.value + : null; + const byedpi = + byedpiResult && byedpiResult.status === "fulfilled" + ? byedpiResult.value + : null; + + actionProvidersAvailabilityState.loaded = true; + actionProvidersAvailabilityState.zapretInstalled = Boolean( + zapret && zapret.success && zapret.data && zapret.data.zapret_installed, + ); + actionProvidersAvailabilityState.zapret2Installed = Boolean( + zapret2 && + zapret2.success && + zapret2.data && + zapret2.data.zapret2_installed, + ); + actionProvidersAvailabilityState.byedpiInstalled = Boolean( + byedpi && byedpi.success && byedpi.data && byedpi.data.byedpi_installed, + ); + return actionProvidersAvailabilityState; + }) + .catch(() => { + actionProvidersAvailabilityState.loaded = true; + actionProvidersAvailabilityState.zapretInstalled = false; + actionProvidersAvailabilityState.zapret2Installed = false; + actionProvidersAvailabilityState.byedpiInstalled = false; + return actionProvidersAvailabilityState; + }) + .finally(() => { + actionProvidersAvailabilityPromise = null; + }); + + return actionProvidersAvailabilityPromise; +} + +function isZapretInstalledForUi() { + return actionProvidersAvailabilityState.zapretInstalled; +} + +function isZapret2InstalledForUi() { + return actionProvidersAvailabilityState.zapret2Installed; +} + +function isByedpiInstalledForUi() { + return actionProvidersAvailabilityState.byedpiInstalled; +} + +function getRuleConfiguredAction(section_id) { + const action = uci.get(UCI_PACKAGE, section_id, "action"); + return action ? `${action}` : null; +} + +function getRuleResolvedAction(section_id) { + return getRuleConfiguredAction(section_id) || "connection"; +} + +function getActionOptionLabel(action) { + switch (`${action}`) { + case "block": + return "Block"; + case "bypass": + return "Bypass"; + case "connection": + return "Connection"; + case "dns": + return "DNS"; + case "vpn": + return "VPN"; + case "zapret": + return "Zapret"; + case "zapret2": + return "Zapret2"; + case "byedpi": + return "ByeDPI"; + case "outbound": + return _("JSON outbound"); + case "proxy": + default: + return "Proxy"; + } +} + +function getRuleActionDisplayValue(section_id) { + const action = getRuleResolvedAction(section_id); + + if (action === "zapret") { + return "Zapret"; + } + + if (action === "zapret2") { + return "Zapret2"; + } + + if (action === "byedpi") { + return "ByeDPI"; + } + + return getActionOptionLabel(action); +} + +function getRuleActionDisplayMarkup(section_id) { + return getRuleActionDisplayValue(section_id); +} + +function populateActionOptionValues(option) { + delete option.keylist; + delete option.vallist; + + option.value("connection", getActionOptionLabel("connection")); + option.value("bypass", "Bypass"); + option.value("block", "Block"); + option.value("dns", "DNS"); + if (isZapretInstalledForUi()) { + option.value("zapret", getActionOptionLabel("zapret")); + } + if (isZapret2InstalledForUi()) { + option.value("zapret2", getActionOptionLabel("zapret2")); + } + if (isByedpiInstalledForUi()) { + option.value("byedpi", getActionOptionLabel("byedpi")); + } +} + +function getConfigListValues(section_id, key) { + return normalizeOptionValues(uci.get(UCI_PACKAGE, section_id, key)); +} + +function stringArraysEqual(left, right) { + left = normalizeDynamicListItems(left); + right = normalizeDynamicListItems(right); + + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function writeListOption(section_id, key, values) { + const normalized = normalizeOptionValues(values); + + if (stringArraysEqual(getConfigListValues(section_id, key), normalized)) { + return; + } + + if (normalized.length) { + uci.set(UCI_PACKAGE, section_id, key, normalized); + } else { + uci.unset(UCI_PACKAGE, section_id, key); + } +} + +function childOwnerOption(ownerOption) { + return ownerOption || "section"; +} + +function childItemOrder(item) { + const value = item ? item.order : null; + const parsed = Number.parseInt(value == null ? "0" : `${value}`, 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getChildItemIds(section_id, typeName, ownerOption) { + const ownerKey = childOwnerOption(ownerOption); + const items = uci + .sections(UCI_PACKAGE, typeName) + .filter((item) => item[ownerKey] === section_id); + + if (typeName === "priority_level") { + items.sort((a, b) => { + const orderDiff = childItemOrder(a) - childItemOrder(b); + if (orderDiff !== 0) { + return orderDiff; + } + + return `${a[".name"] || ""}`.localeCompare(`${b[".name"] || ""}`); + }); + } + + return items.map((item) => item[".name"]).filter(Boolean); +} + +function childItemValue(itemId, valueOption, fallback) { + if (!valueOption) { + return itemId; + } + + const value = uci.get(UCI_PACKAGE, itemId, valueOption); + return value == null || value === "" ? fallback || itemId : `${value}`; +} + +function childItemInputValue( + section_id, + value, + typeName, + valueOption, + ownerOption, +) { + const itemId = `${value || ""}`; + + if (isExistingChildItem(section_id, itemId, typeName, ownerOption)) { + return childItemValue(itemId, valueOption, itemId); + } + + return itemId; +} + +function isExistingChildItem(section_id, itemId, typeName, ownerOption) { + const ownerKey = childOwnerOption(ownerOption); + + return Boolean( + itemId && + uci.get(UCI_PACKAGE, itemId, ".type") === typeName && + uci.get(UCI_PACKAGE, itemId, ownerKey) === section_id, + ); +} + +function findChildItemForInput(section_id, options, inputValue) { + const rawValue = `${inputValue || ""}`.trim(); + + if ( + isExistingChildItem( + section_id, + rawValue, + options.typeName, + options.ownerOption, + ) + ) { + return rawValue; + } + + if (options.valueOption) { + return getChildItemIds( + section_id, + options.typeName, + options.ownerOption, + ).find( + (itemId) => + childItemValue(itemId, options.valueOption, itemId) === rawValue, + ); + } + + return null; +} + +function createChildItem(section_id, options, inputValue) { + const rawValue = `${inputValue || ""}`.trim(); + const existing = findChildItemForInput(section_id, options, rawValue); + + if (existing) { + return { + value: existing, + text: options.valueOption + ? childItemValue(existing, options.valueOption, existing) + : existing, + created: false, + }; + } + + const requestedId = + typeof options.createId === "function" + ? `${options.createId(rawValue, section_id) || ""}`.trim() + : ""; + const itemId = + (requestedId && uci.add(UCI_PACKAGE, options.typeName, requestedId)) || + requestedId || + uci.add(UCI_PACKAGE, options.typeName); + + uci.set( + UCI_PACKAGE, + itemId, + childOwnerOption(options.ownerOption), + section_id, + ); + + if (options.valueOption) { + uci.set(UCI_PACKAGE, itemId, options.valueOption, rawValue); + } + + if (options.defaults && typeof options.defaults === "object") { + Object.entries(options.defaults).forEach(([key, value]) => { + value = + typeof value === "function" + ? value(rawValue, section_id, itemId) + : value; + if (value !== undefined && value !== null && value !== "") { + uci.set(UCI_PACKAGE, itemId, key, `${value}`); + } + }); + } + + return { + value: itemId, + text: options.valueOption ? rawValue : itemId, + created: true, + }; +} + +function applyChildItemSettings(itemId, settings) { + Object.entries(settings || {}).forEach(([key, value]) => { + if (!key || key.charAt(0) === ".") { + return; + } + + if (value === undefined || value === null || value === "") { + uci.unset(UCI_PACKAGE, itemId, key); + } else if (Array.isArray(value)) { + uci.set( + UCI_PACKAGE, + itemId, + key, + value + .map((item) => `${item || ""}`.trim()) + .filter((item) => item.length), + ); + } else { + uci.set(UCI_PACKAGE, itemId, key, `${value}`); + } + }); +} + +function materializeChildItems(section_id, options, inputValue) { + const result = []; + const seen = new Set(); + + normalizeDynamicListItems(inputValue).forEach((value) => { + const createdItem = createChildItem(section_id, options, value); + const itemId = createdItem.value; + const stagedSettings = + createdItem.created && typeof options.stagedSettings === "function" + ? options.stagedSettings(value, itemId, createdItem.created) + : null; + + if (stagedSettings) { + applyChildItemSettings(itemId, stagedSettings); + } + + if (itemId && !seen.has(itemId)) { + seen.add(itemId); + result.push(itemId); + } + }); + + return result; +} + +function cleanupPriorityLevelsForGroup(groupId) { + getChildItemIds(groupId, "priority_level", "group").forEach((levelId) => { + uci.remove(UCI_PACKAGE, levelId); + }); +} + +function cleanupRemovedChildItems( + section_id, + typeName, + keepValues, + ownerOption, +) { + const keep = new Set(normalizeDynamicListItems(keepValues)); + + getChildItemIds(section_id, typeName, ownerOption).forEach((itemId) => { + if (!keep.has(itemId)) { + if (typeName === "priority_group") { + cleanupPriorityLevelsForGroup(itemId); + } + uci.remove(UCI_PACKAGE, itemId); + } + }); +} + +function parentSectionIdForItem(itemId) { + return uci.get(UCI_PACKAGE, itemId, "section") || ""; +} + +function refreshOptionChoices(option, choices) { + delete option.keylist; + delete option.vallist; + + (choices || []).forEach((choice) => { + if (typeof choice === "object") { + option.value(choice.value, choice.label); + } else { + option.value(choice); + } + }); +} + +function validateRegex(_section_id, value) { + if (!value || !value.length) { + return true; + } + + try { + new RegExp(value); + return true; + } catch (_error) { + return _("Invalid regular expression"); + } +} + +function validateKeyword(_section_id, value) { + if (!value || !value.length) { + return true; + } + + if (/[,\s]/.test(value)) { + return _("Keyword must not contain spaces or commas"); + } + + return true; +} + +function isSingBoxDuration(value) { + return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value); +} + +function writeOptionalDurationOption(section_id, key, value) { + const normalized = value ? `${value}`.trim() : ""; + const disabledKey = `${key}_disabled`; + + if (normalized.length) { + uci.set(UCI_PACKAGE, section_id, key, normalized); + uci.unset(UCI_PACKAGE, section_id, disabledKey); + } else { + uci.unset(UCI_PACKAGE, section_id, key); + uci.set(UCI_PACKAGE, section_id, disabledKey, "1"); + } +} + +function validateOptionalSingBoxDuration(value) { + const normalized = value ? `${value}`.trim() : ""; + + if (!normalized.length) { + return true; + } + + if (isSingBoxDuration(normalized)) { + return true; + } + + return _("Use sing-box duration format like 1d, 12h or 30m"); +} + +function validateRequiredSingBoxDuration(value) { + const normalized = value ? `${value}`.trim() : ""; + + if (!normalized.length) { + return _("Use sing-box duration format like 1d, 12h or 30m"); + } + + if (isSingBoxDuration(normalized)) { + return true; + } + + return _("Use sing-box duration format like 1d, 12h or 30m"); +} + +function parseSubscriptionUrlEntry(value) { + const normalized = value ? `${value}`.trim() : ""; + + if (!normalized.length) { + return { valid: true, url: "" }; + } + + if (normalized.includes("|")) { + return { + valid: false, + message: _("Configure User-Agent in the item settings"), + }; + } + + return { valid: true, url: normalized }; +} + +function validateSubscriptionUrlEntry(_section_id, value) { + if (!value || value.length === 0) { + return true; + } + + const parsed = parseSubscriptionUrlEntry(value); + if (!parsed.valid) { + return parsed.message; + } + + const validation = main.validateUrl(parsed.url); + return validation.valid ? true : validation.message; +} + +function getDuplicateTextListErrors(values, normalizeValue, duplicateMessage) { + const seen = new Set(); + const duplicates = []; + + values.forEach((item) => { + const normalized = normalizeValue ? normalizeValue(item) : item; + + if (seen.has(normalized)) { + if (!duplicates.includes(item)) { + duplicates.push(item); + } + return; + } + + seen.add(normalized); + }); + + return duplicates.map((item) => `${item}: ${duplicateMessage}`); +} + +function getValidationHeaderText() { + return _("Validation errors:"); +} + +function getDuplicateValueText() { + return _("Duplicate value"); +} + +function escapeHtml(value) { + return `${value}` + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function ensureAnnotatedTextareaStyles() { + if ( + typeof document === "undefined" || + !document.head || + document.getElementById(ANNOTATED_TEXTAREA_STYLE_ID) + ) { + return; + } + + document.head.insertAdjacentHTML( + "beforeend", + ``, + ); +} + +function applyTextareaInputAttributes(textarea) { + textarea.setAttribute("spellcheck", "false"); + textarea.setAttribute("autocomplete", "off"); + textarea.setAttribute("autocorrect", "off"); + textarea.setAttribute("autocapitalize", "off"); + textarea.setAttribute("data-gramm", "false"); + textarea.setAttribute("data-gramm_editor", "false"); + textarea.setAttribute("data-enable-grammarly", "false"); + textarea.style.resize = "vertical"; + textarea.style.maxWidth = "100%"; + + const getRowsMinHeight = () => { + const rows = Number.parseInt(textarea.getAttribute("rows") || "0", 10); + if (!rows || typeof window === "undefined") { + return 0; + } + + const style = window.getComputedStyle(textarea); + const fontSize = Number.parseFloat(style.fontSize) || 16; + const lineHeight = + Number.parseFloat(style.lineHeight) || Math.ceil(fontSize * 1.2); + const verticalPadding = + (Number.parseFloat(style.paddingTop) || 0) + + (Number.parseFloat(style.paddingBottom) || 0); + const verticalBorder = + (Number.parseFloat(style.borderTopWidth) || 0) + + (Number.parseFloat(style.borderBottomWidth) || 0); + + return Math.ceil(rows * lineHeight + verticalPadding + verticalBorder); + }; + + const applyMinHeight = () => { + const storedMinHeight = Number.parseFloat( + textarea.getAttribute("data-fkp-default-min-height") || "0", + ); + const nextMinHeight = Math.max( + storedMinHeight, + textarea.offsetHeight, + getRowsMinHeight(), + ); + + if (nextMinHeight > 0) { + if (storedMinHeight <= 0) { + textarea.setAttribute( + "data-fkp-default-min-height", + `${nextMinHeight}`, + ); + } + + textarea.style.minHeight = `${nextMinHeight}px`; + return true; + } + + return false; + }; + + if ( + !applyMinHeight() && + typeof window !== "undefined" && + typeof window.requestAnimationFrame === "function" + ) { + window.requestAnimationFrame(() => { + if (!applyMinHeight()) { + window.setTimeout(applyMinHeight, 0); + } + }); + } + + textarea.addEventListener("focus", applyMinHeight); + textarea.addEventListener("pointerdown", applyMinHeight); +} + +function syncAnnotatedTextareaOverlay(textarea, wrapper, overlay) { + if ( + typeof window === "undefined" || + !textarea || + !wrapper || + !overlay || + typeof window.getComputedStyle !== "function" + ) { + return; + } + + const style = window.getComputedStyle(textarea); + + wrapper.style.backgroundColor = style.backgroundColor; + wrapper.style.borderRadius = style.borderRadius; + + overlay.style.font = style.font; + overlay.style.lineHeight = style.lineHeight; + overlay.style.letterSpacing = style.letterSpacing; + overlay.style.paddingTop = style.paddingTop; + overlay.style.paddingRight = style.paddingRight; + overlay.style.paddingBottom = style.paddingBottom; + overlay.style.paddingLeft = style.paddingLeft; + overlay.style.borderTopWidth = style.borderTopWidth; + overlay.style.borderRightWidth = style.borderRightWidth; + overlay.style.borderBottomWidth = style.borderBottomWidth; + overlay.style.borderLeftWidth = style.borderLeftWidth; + overlay.style.borderStyle = "solid"; + overlay.style.borderColor = "transparent"; + overlay.style.textAlign = style.textAlign; + overlay.style.direction = style.direction; + overlay.style.tabSize = style.tabSize; + overlay.style.textIndent = style.textIndent; + overlay.style.textTransform = style.textTransform; + overlay.style.boxSizing = style.boxSizing; + overlay.style.scrollPaddingTop = style.scrollPaddingTop; + + overlay.scrollTop = textarea.scrollTop; + overlay.scrollLeft = textarea.scrollLeft; +} + +function createAnnotationKey(annotation) { + return `${annotation.start}:${annotation.end}`; +} + +function addAnnotationIssue(annotationMap, annotation, message) { + const key = createAnnotationKey(annotation); + const existing = annotationMap.get(key); + if (existing) { + if (!existing.messages.includes(message)) { + existing.messages.push(message); + } + return; + } + + annotationMap.set(key, { + start: annotation.start, + end: annotation.end, + messages: [message], + }); +} + +function finalizeAnnotations(annotationMap) { + return Array.from(annotationMap.values()) + .map((annotation) => ({ + start: annotation.start, + end: annotation.end, + message: annotation.messages.join("; "), + })) + .sort((left, right) => left.start - right.start || left.end - right.end); +} + +function renderAnnotatedTextareaOverlay(value, annotations) { + const text = value ? `${value}` : ""; + const normalizedAnnotations = Array.isArray(annotations) ? annotations : []; + + if (!text.length) { + return "​"; + } + + if (!normalizedAnnotations.length) { + return `${escapeHtml(text)}${text.endsWith("\n") ? "\n " : ""}`; + } + + let cursor = 0; + let html = ""; + + normalizedAnnotations.forEach((annotation) => { + if ( + annotation.start < cursor || + annotation.start >= annotation.end || + annotation.start < 0 + ) { + return; + } + + html += escapeHtml(text.slice(cursor, annotation.start)); + html += `${escapeHtml( + text.slice(annotation.start, annotation.end), + )}`; + cursor = annotation.end; + }); + + html += escapeHtml(text.slice(cursor)); + + if (text.endsWith("\n")) { + html += "\n "; + } + + return html; +} + +function attachAnnotatedTextarea(textarea, analyzer) { + if (!textarea || typeof analyzer !== "function") { + return; + } + + ensureAnnotatedTextareaStyles(); + + if (textarea.__tachyonAnnotatedTextareaController) { + textarea.__tachyonAnnotatedTextareaController.analyzer = analyzer; + textarea.__tachyonAnnotatedTextareaController.update(); + return; + } + + const wrapper = textarea.parentNode; + if (!wrapper) { + return; + } + + wrapper.classList.add("fkp-annotated-textarea"); + + const overlay = document.createElement("div"); + overlay.className = "fkp-annotated-textarea__overlay"; + overlay.setAttribute("aria-hidden", "true"); + wrapper.insertBefore(overlay, textarea.nextSibling); + + const controller = { + analyzer, + textarea, + wrapper, + overlay, + update() { + const analysis = this.analyzer(this.textarea.value); + this.overlay.innerHTML = renderAnnotatedTextareaOverlay( + this.textarea.value, + analysis.annotations, + ); + syncAnnotatedTextareaOverlay(this.textarea, this.wrapper, this.overlay); + }, + }; + + textarea.__tachyonAnnotatedTextareaController = controller; + + const updateAnnotatedTextarea = () => controller.update(); + textarea.addEventListener("input", updateAnnotatedTextarea); + textarea.addEventListener("change", updateAnnotatedTextarea); + textarea.addEventListener("scroll", updateAnnotatedTextarea, { + passive: true, + }); + textarea.addEventListener("keyup", updateAnnotatedTextarea); + + if (typeof ResizeObserver === "function") { + const resizeObserver = new ResizeObserver(() => controller.update()); + resizeObserver.observe(textarea); + controller.resizeObserver = resizeObserver; + } + + controller.update(); +} + +function refreshAnnotatedTextareaValidation(option, section_id, textarea) { + if (option && typeof option.triggerValidation === "function") { + option.triggerValidation(section_id); + } + + if ( + textarea && + textarea.__tachyonAnnotatedTextareaController && + typeof textarea.__tachyonAnnotatedTextareaController.update === "function" + ) { + textarea.__tachyonAnnotatedTextareaController.update(); + } +} + +function attachNfqwsRemoteValidation(option, section_id, textarea) { + if (!textarea || textarea.__tachyonNfqwsRemoteValidationAttached) { + return; + } + + textarea.__tachyonNfqwsRemoteValidationAttached = true; + textarea.__tachyonNfqwsRemoteValidationRequestId = 0; + textarea.__tachyonNfqwsRemoteValidationTimer = null; + + const runValidation = () => { + const value = textarea.value; + const localAnalysis = buildNfqwsLocalAnalysis(value); + if (!localAnalysis.valid) { + refreshAnnotatedTextareaValidation(option, section_id, textarea); + return; + } + + const requestId = + (textarea.__tachyonNfqwsRemoteValidationRequestId || 0) + 1; + textarea.__tachyonNfqwsRemoteValidationRequestId = requestId; + + validateNfqwsStrategyRemotely(value).then(() => { + if (textarea.__tachyonNfqwsRemoteValidationRequestId !== requestId) { + return; + } + + refreshAnnotatedTextareaValidation(option, section_id, textarea); + }); + }; + + const scheduleValidation = (delay = NFQWS_REMOTE_VALIDATION_DEBOUNCE_MS) => { + if (textarea.__tachyonNfqwsRemoteValidationTimer) { + window.clearTimeout(textarea.__tachyonNfqwsRemoteValidationTimer); + } + + textarea.__tachyonNfqwsRemoteValidationTimer = window.setTimeout(() => { + textarea.__tachyonNfqwsRemoteValidationTimer = null; + runValidation(); + }, delay); + }; + + textarea.addEventListener("input", () => scheduleValidation()); + textarea.addEventListener("change", () => scheduleValidation(0)); + textarea.addEventListener("blur", () => scheduleValidation(0)); + + scheduleValidation(0); +} + +function attachNfqws2RemoteValidation(option, section_id, textarea) { + if (!textarea || textarea.__tachyonNfqws2RemoteValidationAttached) { + return; + } + + textarea.__tachyonNfqws2RemoteValidationAttached = true; + textarea.__tachyonNfqws2RemoteValidationRequestId = 0; + textarea.__tachyonNfqws2RemoteValidationTimer = null; + + const runValidation = () => { + const value = textarea.value; + const localAnalysis = buildNfqws2LocalAnalysis(value); + if (!localAnalysis.valid) { + refreshAnnotatedTextareaValidation(option, section_id, textarea); + return; + } + + const requestId = + (textarea.__tachyonNfqws2RemoteValidationRequestId || 0) + 1; + textarea.__tachyonNfqws2RemoteValidationRequestId = requestId; + + validateNfqws2StrategyRemotely(value).then(() => { + if (textarea.__tachyonNfqws2RemoteValidationRequestId !== requestId) { + return; + } + + refreshAnnotatedTextareaValidation(option, section_id, textarea); + }); + }; + + const scheduleValidation = (delay = NFQWS_REMOTE_VALIDATION_DEBOUNCE_MS) => { + if (textarea.__tachyonNfqws2RemoteValidationTimer) { + window.clearTimeout(textarea.__tachyonNfqws2RemoteValidationTimer); + } + + textarea.__tachyonNfqws2RemoteValidationTimer = window.setTimeout(() => { + textarea.__tachyonNfqws2RemoteValidationTimer = null; + runValidation(); + }, delay); + }; + + textarea.addEventListener("input", () => scheduleValidation()); + textarea.addEventListener("change", () => scheduleValidation(0)); + textarea.addEventListener("blur", () => scheduleValidation(0)); + + scheduleValidation(0); +} + +function parseCommentAwareListTokens(value) { + const text = value ? `${value}` : ""; + const tokens = []; + const lines = text.split(/\r\n|\r|\n/); + const newlines = text.match(/\r\n|\r|\n/g) || []; + let offset = 0; + + lines.forEach((line, index) => { + const hashIndex = line.indexOf("#"); + const slashIndex = line.indexOf("//"); + let commentIndex = -1; + + if (hashIndex >= 0 && slashIndex >= 0) { + commentIndex = Math.min(hashIndex, slashIndex); + } else if (hashIndex >= 0) { + commentIndex = hashIndex; + } else if (slashIndex >= 0) { + commentIndex = slashIndex; + } + + const source = commentIndex >= 0 ? line.slice(0, commentIndex) : line; + const matcher = /[^,\s]+/g; + let match; + + while ((match = matcher.exec(source)) !== null) { + tokens.push({ + value: match[0], + start: offset + match.index, + end: offset + match.index + match[0].length, + }); + } + + offset += line.length + (newlines[index] ? newlines[index].length : 0); + }); + + return tokens; +} + +function analyzeTextListValue(value, validateItem, emptyMessage, options = {}) { + const text = value ? `${value}` : ""; + if (!text.length) { + return { valid: true, message: "", annotations: [] }; + } + + const tokens = parseCommentAwareListTokens(text); + if (!tokens.length) { + return { valid: false, message: emptyMessage, annotations: [] }; + } + + const duplicateMessage = options.duplicateMessage || getDuplicateValueText(); + const annotationMap = new Map(); + const errors = []; + const seen = new Set(); + + tokens.forEach((token) => { + if (typeof validateItem === "function") { + const validation = validateItem(token.value); + if (!validation.valid) { + errors.push(`${token.value}: ${validation.message}`); + addAnnotationIssue(annotationMap, token, validation.message); + } + } + + const normalized = options.normalizeDuplicateValue + ? options.normalizeDuplicateValue(token.value) + : token.value; + + if (!normalized) { + return; + } + + if (seen.has(normalized)) { + errors.push(`${token.value}: ${duplicateMessage}`); + addAnnotationIssue(annotationMap, token, duplicateMessage); + return; + } + + seen.add(normalized); + }); + + if (!errors.length) { + return { valid: true, message: "", annotations: [] }; + } + + return { + valid: false, + message: [getValidationHeaderText(), ...errors].join("\n"), + annotations: finalizeAnnotations(annotationMap), + }; +} + +function analyzeDomainSuffixText(value) { + const validateDomainCondition = (domain) => { + const normalized = `${domain || ""}`.trim(); + if (normalized.includes("/")) { + return { valid: false, message: _("Invalid domain address") }; + } + + return main.validateDomain(normalized, true); + }; + + return analyzeTextListValue( + value, + (item) => { + const colonIndex = item.indexOf(":"); + const prefix = colonIndex > 0 ? item.slice(0, colonIndex) : ""; + const body = colonIndex > 0 ? item.slice(colonIndex + 1) : item; + + if (!prefix) { + return validateDomainCondition(body); + } + + if (!["full", "keyword", "regex"].includes(prefix)) { + return { + valid: false, + message: _("Allowed domain prefixes are full:, keyword:, and regex:"), + }; + } + + if (!body.length) { + return { valid: false, message: _("Value cannot be empty") }; + } + + if (prefix === "full") { + return validateDomainCondition(body); + } + + if (prefix === "keyword") { + const validation = validateKeyword(null, body); + return validation === true + ? { valid: true, message: _("Valid") } + : { valid: false, message: validation }; + } + + if (/[,\s]/.test(body)) { + return { + valid: false, + message: _("Regular expression must not contain spaces or commas"), + }; + } + + const validation = validateRegex(null, body); + return validation === true + ? { valid: true, message: _("Valid") } + : { valid: false, message: validation }; + }, + _("At least one valid domain must be specified."), + { + normalizeDuplicateValue: (item) => `${item}`.toLowerCase(), + }, + ); +} + +function domainValuesWithPrefix(section_id, key, prefix) { + return getConfigListValues(section_id, key).map((value) => + prefix ? `${prefix}:${value}` : value, + ); +} + +function domainTextValuesWithPrefix(section_id, key, prefix) { + const legacyText = uci.get(UCI_PACKAGE, section_id, `${key}_text`); + if (!legacyText) { + return []; + } + + return main + .parseValueList(legacyText) + .map((value) => (prefix ? `${prefix}:${value}` : value)); +} + +function uniqueDomainTextValues(values) { + const seen = new Set(); + + return values.filter((value) => { + const key = `${value}`.toLowerCase(); + if (!key || seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +} + +function appendUniqueDomainTextValues(textValue, values) { + const originalText = typeof textValue === "string" ? textValue : ""; + const seen = new Set( + main.parseValueList(originalText).map((value) => `${value}`.toLowerCase()), + ); + const additions = uniqueDomainTextValues(values).filter((value) => { + const key = `${value}`.toLowerCase(); + if (!key || seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + + if (!additions.length) { + return originalText; + } + + const base = originalText.replace(/\s+$/, ""); + return [base, ...additions].filter(Boolean).join("\n"); +} + +function loadCombinedDomainText(section_id) { + const textValue = + uci.get(UCI_PACKAGE, section_id, "domain") || + uci.get(UCI_PACKAGE, section_id, "domain_suffix_text"); + const values = [ + ...domainValuesWithPrefix(section_id, "domain_suffix", ""), + ...domainValuesWithPrefix(section_id, "domain_keyword", "keyword"), + ...domainValuesWithPrefix(section_id, "domain_regex", "regex"), + ...domainTextValuesWithPrefix(section_id, "domain_suffix", ""), + ...domainTextValuesWithPrefix(section_id, "domain", "full"), + ...domainTextValuesWithPrefix(section_id, "domain_keyword", "keyword"), + ...domainTextValuesWithPrefix(section_id, "domain_regex", "regex"), + ]; + + return appendUniqueDomainTextValues(textValue, values); +} + +function analyzeIpCidrText(value) { + return analyzeTextListValue( + value, + (item) => main.validateSubnet(item), + _("At least one valid IP or subnet must be specified."), + { + normalizeDuplicateValue: (item) => `${item}`.trim(), + }, + ); +} + +function validatePortCondition(_section_id, value) { + const normalized = value ? `${value}`.trim() : ""; + + if (!normalized.length) { + return true; + } + + const match = normalized.match(/^(\d+)(?:-(\d+))?$/); + if (!match) { + return _("Invalid port or range. Use 80 or 1000-2000"); + } + + const start = Number.parseInt(match[1], 10); + const end = match[2] ? Number.parseInt(match[2], 10) : start; + + if (start < 1 || start > 65535 || end < 1 || end > 65535) { + return _("Port must be between 1 and 65535"); + } + + if (start > end) { + return _("Port range start must be less than or equal to end"); + } + + return true; +} + +function getNfqwsOptionArgumentMode(option) { + if (NFQWS_REQUIRED_ARG_OPTIONS.has(option)) { + return "required"; + } + + if (NFQWS_OPTIONAL_ARG_OPTIONS.has(option)) { + return "optional"; + } + + if (NFQWS_NO_ARG_OPTIONS.has(option)) { + return "none"; + } + + return "unknown"; +} + +function getNfqws2OptionArgumentMode(option) { + if (NFQWS2_REQUIRED_ARG_OPTIONS.has(option)) { + return "required"; + } + + if (NFQWS2_OPTIONAL_ARG_OPTIONS.has(option)) { + return "optional"; + } + + if (NFQWS2_NO_ARG_OPTIONS.has(option)) { + return "none"; + } + + return "unknown"; +} + +function normalizeNfqwsStrategyWhitespace(value) { + return value ? `${value}`.replace(/\s+/g, " ").trim() : ""; +} + +function parseNfqwsRuntimeTokens(value) { + const text = value ? `${value}` : ""; + const tokens = []; + const matcher = /\S+/g; + let match; + + while ((match = matcher.exec(text)) !== null) { + tokens.push({ + value: match[0], + start: match.index, + end: match.index + match[0].length, + }); + } + + return tokens; +} +function normalizeNfqwsStrategyValue(value) { + const normalized = normalizeNfqwsStrategyWhitespace(value); + if (!normalized.length) { + return ""; + } + + return normalized === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT + ? ZAPRET_DEFAULT_NFQWS_OPT + : normalized; +} + +function getCachedNfqwsRemoteValidation(value) { + const normalized = normalizeNfqwsStrategyValue(value); + return normalized.length + ? nfqwsRemoteValidationCache.get(normalized) || null + : null; +} + +function cacheNfqwsRemoteValidation(value, result) { + const normalized = normalizeNfqwsStrategyValue(value); + if (!normalized.length) { + return result; + } + + const cached = { + valid: result && result.valid === true, + message: result && result.message ? `${result.message}` : "", + needle: result && result.needle ? `${result.needle}` : "", + needles: + result && Array.isArray(result.needles) + ? result.needles.filter(Boolean).map((item) => `${item}`) + : result && result.needle + ? [`${result.needle}`] + : [], + }; + + nfqwsRemoteValidationCache.set(normalized, cached); + return cached; +} + +function buildNfqwsRemoteValidationFallback(error) { + const message = + error && error.message + ? `${error.message}` + : _("Unable to validate the NFQWS strategy through the backend parser."); + + return { + valid: false, + message: _("Backend validation failed: %s").format(message), + needle: "", + needles: [], + }; +} + +function validateNfqwsStrategyRemotely(value) { + const normalized = normalizeNfqwsStrategyValue(value); + + if (!normalized.length) { + return Promise.resolve({ + valid: true, + message: "", + needle: "", + needles: [], + }); + } + + if (nfqwsRemoteValidationCache.has(normalized)) { + return Promise.resolve(nfqwsRemoteValidationCache.get(normalized)); + } + + if (nfqwsRemoteValidationInflight.has(normalized)) { + return nfqwsRemoteValidationInflight.get(normalized); + } + + const validationTask = fs + .exec(NFQWS_VALIDATION_COMMAND, [ + "validate_nfqws_strategy_json", + normalized, + ]) + .then((result) => { + const payload = JSON.parse( + (result && result.stdout ? result.stdout : "{}").trim() || "{}", + ); + return cacheNfqwsRemoteValidation(normalized, { + valid: payload.valid === true, + message: payload.message || "", + needle: payload.needle || "", + needles: Array.isArray(payload.needles) + ? payload.needles.filter(Boolean) + : payload.needle + ? [payload.needle] + : [], + }); + }) + .catch((error) => + cacheNfqwsRemoteValidation( + normalized, + buildNfqwsRemoteValidationFallback(error), + ), + ) + .finally(() => { + nfqwsRemoteValidationInflight.delete(normalized); + }); + + nfqwsRemoteValidationInflight.set(normalized, validationTask); + return validationTask; +} + +function getNfqwsForbiddenTokenInfo(token, index) { + const configFileMessage = _( + "External nfqws config files bypass Tachyon queue management and explicit validation.", + ); + const hostSelectionMessage = _( + "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE.", + ); + const ipSelectionMessage = _( + "Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE.", + ); + const placeholderMessage = _( + "Zapret hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS strategies.", + ); + const queueMessage = _( + "The NFQUEUE number is assigned by Tachyon for each rule and must not be overridden here.", + ); + const fwmarkMessage = _( + "The desync fwmark is managed by Tachyon for loop prevention and must not be overridden here.", + ); + const daemonMessage = _( + "Tachyon manages the nfqws process lifecycle itself, so daemon mode is not allowed here.", + ); + const dryRunMessage = _( + "This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here.", + ); + const versionMessage = _( + "This field must start a working nfqws strategy; --version exits immediately and is not allowed here.", + ); + + if (index === 0 && (token.startsWith("@") || token.startsWith("$"))) { + return { + reason: configFileMessage, + captureNextValue: false, + }; + } + + if (token === "" || token === "") { + return { + reason: placeholderMessage, + captureNextValue: false, + }; + } + + if ( + token === "--hostlist" || + token.startsWith("--hostlist=") || + token === "--hostlist-domains" || + token.startsWith("--hostlist-domains=") || + token === "--hostlist-exclude" || + token.startsWith("--hostlist-exclude=") || + token === "--hostlist-exclude-domains" || + token.startsWith("--hostlist-exclude-domains=") || + token === "--hostlist-auto" || + token.startsWith("--hostlist-auto=") || + token === "--hostlist-auto-fail-threshold" || + token.startsWith("--hostlist-auto-fail-threshold=") || + token === "--hostlist-auto-fail-time" || + token.startsWith("--hostlist-auto-fail-time=") || + token === "--hostlist-auto-retrans-threshold" || + token.startsWith("--hostlist-auto-retrans-threshold=") || + token === "--hostlist-auto-debug" || + token.startsWith("--hostlist-auto-debug=") + ) { + return { + reason: hostSelectionMessage, + captureNextValue: !token.includes("="), + }; + } + + if ( + token === "--ipset" || + token.startsWith("--ipset=") || + token === "--ipset-ip" || + token.startsWith("--ipset-ip=") || + token === "--ipset-exclude" || + token.startsWith("--ipset-exclude=") || + token === "--ipset-exclude-ip" || + token.startsWith("--ipset-exclude-ip=") + ) { + return { + reason: ipSelectionMessage, + captureNextValue: !token.includes("="), + }; + } + + if (token === "--qnum" || token.startsWith("--qnum=")) { + return { + reason: queueMessage, + captureNextValue: !token.includes("="), + }; + } + + if ( + token === "--dpi-desync-fwmark" || + token.startsWith("--dpi-desync-fwmark=") + ) { + return { + reason: fwmarkMessage, + captureNextValue: !token.includes("="), + }; + } + + if (token === "--daemon") { + return { + reason: daemonMessage, + captureNextValue: false, + }; + } + + if (token === "--dry-run") { + return { + reason: dryRunMessage, + captureNextValue: false, + }; + } + + if (token === "--version") { + return { + reason: versionMessage, + captureNextValue: false, + }; + } + + return null; +} + +function buildNfqwsLocalAnalysis(value) { + const text = value ? `${value}` : ""; + if (!text.trim().length) { + return { + valid: false, + message: _("NFQWS strategy cannot be empty"), + annotations: [], + }; + } + + if (text.trim() === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT) { + return { valid: true, message: "", annotations: [] }; + } + + const tokens = parseNfqwsRuntimeTokens(text); + const annotationMap = new Map(); + const errors = []; + + for (let index = 0; index < tokens.length; ) { + const token = tokens[index]; + const bareToken = token.value.includes("=") + ? token.value.slice(0, token.value.indexOf("=")) + : token.value; + const nextToken = tokens[index + 1] || null; + + const forbidden = getNfqwsForbiddenTokenInfo(token.value, index); + if (forbidden) { + addAnnotationIssue(annotationMap, token, forbidden.reason); + + let displayToken = token.value; + + if ( + forbidden.captureNextValue && + nextToken && + !nextToken.value.startsWith("--") + ) { + addAnnotationIssue(annotationMap, nextToken, forbidden.reason); + displayToken = `${displayToken} ${nextToken.value}`; + index += 2; + } else { + index += 1; + } + + errors.push(`${displayToken}: ${forbidden.reason}`); + continue; + } + + if (!token.value.startsWith("--")) { + const reason = _( + "Unexpected standalone token. Use explicit flags such as --name or --name=value.", + ); + addAnnotationIssue(annotationMap, token, reason); + errors.push(`${token.value}: ${reason}`); + index += 1; + continue; + } + + const mode = getNfqwsOptionArgumentMode(bareToken); + if (mode === "unknown") { + const reason = _("Unknown NFQWS flag."); + addAnnotationIssue(annotationMap, token, reason); + errors.push(`${token.value}: ${reason}`); + index += 1; + continue; + } + + if (mode === "none") { + if (token.value.includes("=")) { + const reason = _("This flag does not accept a value."); + addAnnotationIssue(annotationMap, token, reason); + errors.push(`${token.value}: ${reason}`); + } + + index += 1; + continue; + } + + if (mode === "optional") { + if ( + nextToken && + !token.value.includes("=") && + !nextToken.value.startsWith("--") + ) { + const reason = _( + "Optional values must be attached with '=' here; a separate token would be ignored by nfqws.", + ); + addAnnotationIssue(annotationMap, token, reason); + addAnnotationIssue(annotationMap, nextToken, reason); + errors.push(`${token.value} ${nextToken.value}: ${reason}`); + index += 2; + } else { + index += 1; + } + + continue; + } + + if (!token.value.includes("=")) { + if (!nextToken || nextToken.value.startsWith("--")) { + const reason = _("This option requires a value."); + addAnnotationIssue(annotationMap, token, reason); + errors.push(`${token.value}: ${reason}`); + index += 1; + continue; + } + + index += 2; + continue; + } + + index += 1; + } + + if (!errors.length) { + return { valid: true, message: "", annotations: [] }; + } + + return { + valid: false, + message: [getValidationHeaderText(), ...errors].join("\n"), + annotations: finalizeAnnotations(annotationMap), + }; +} + +function addNfqwsRemoteValidationNeedleAnnotations( + annotationMap, + tokens, + remoteValidation, + needle, +) { + if (!needle.length) { + return; + } + + let matched = false; + + tokens.forEach((token) => { + const tokenValue = token.value || ""; + const optionMatch = + needle.startsWith("--") && + (tokenValue === needle || tokenValue.startsWith(`${needle}=`)); + const valueMatch = + tokenValue === needle || + tokenValue.endsWith(`=${needle}`) || + (!needle.startsWith("--") && tokenValue.includes(`=${needle},`)) || + (!needle.startsWith("--") && tokenValue.endsWith(`=${needle}`)); + + if (optionMatch || valueMatch) { + addAnnotationIssue(annotationMap, token, remoteValidation.message); + matched = true; + } + }); + + if (matched) { + return; + } + + if (needle.startsWith("--")) { + tokens + .filter((token) => token.value && token.value.startsWith(needle)) + .forEach((token) => + addAnnotationIssue(annotationMap, token, remoteValidation.message), + ); + } +} + +function addNfqwsRemoteValidationAnnotations( + annotationMap, + tokens, + remoteValidation, +) { + const needles = + remoteValidation && + Array.isArray(remoteValidation.needles) && + remoteValidation.needles.length + ? remoteValidation.needles.map((needle) => `${needle}`) + : remoteValidation && remoteValidation.needle + ? [`${remoteValidation.needle}`] + : []; + + needles.forEach((needle) => + addNfqwsRemoteValidationNeedleAnnotations( + annotationMap, + tokens, + remoteValidation, + needle, + ), + ); +} + +function analyzeNfqwsStrategy(value) { + const localAnalysis = buildNfqwsLocalAnalysis(value); + if (!localAnalysis.valid) { + return localAnalysis; + } + + const remoteValidation = getCachedNfqwsRemoteValidation(value); + if (!remoteValidation || remoteValidation.valid) { + return localAnalysis; + } + + const text = value ? `${value}` : ""; + const tokens = parseNfqwsRuntimeTokens(text); + const annotationMap = new Map(); + + localAnalysis.annotations.forEach((annotation) => + addAnnotationIssue(annotationMap, annotation, annotation.message), + ); + addNfqwsRemoteValidationAnnotations(annotationMap, tokens, remoteValidation); + + return { + valid: false, + message: [getValidationHeaderText(), remoteValidation.message].join("\n"), + annotations: finalizeAnnotations(annotationMap), + }; +} + +function normalizeNfqws2StrategyValue(value) { + const normalized = normalizeNfqwsStrategyWhitespace(value); + return normalized.length ? normalized : ZAPRET2_DEFAULT_NFQWS2_OPT; +} + +function getCachedNfqws2RemoteValidation(value) { + const normalized = normalizeNfqws2StrategyValue(value); + return normalized.length + ? nfqws2RemoteValidationCache.get(normalized) || null + : null; +} + +function cacheNfqws2RemoteValidation(value, result) { + const normalized = normalizeNfqws2StrategyValue(value); + if (!normalized.length) { + return result; + } + + const cached = { + valid: result && result.valid === true, + message: result && result.message ? `${result.message}` : "", + needle: result && result.needle ? `${result.needle}` : "", + needles: + result && Array.isArray(result.needles) + ? result.needles.filter(Boolean).map((item) => `${item}`) + : result && result.needle + ? [`${result.needle}`] + : [], + }; + + nfqws2RemoteValidationCache.set(normalized, cached); + return cached; +} + +function buildNfqws2RemoteValidationFallback(error) { + const message = + error && error.message + ? `${error.message}` + : _("Unable to validate the NFQWS2 strategy through the backend parser."); + + return { + valid: false, + message: _("Backend validation failed: %s").format(message), + needle: "", + needles: [], + }; +} + +function validateNfqws2StrategyRemotely(value) { + const normalized = normalizeNfqws2StrategyValue(value); + + if (!normalized.length) { + return Promise.resolve({ + valid: true, + message: "", + needle: "", + needles: [], + }); + } + + if (nfqws2RemoteValidationCache.has(normalized)) { + return Promise.resolve(nfqws2RemoteValidationCache.get(normalized)); + } + + if (nfqws2RemoteValidationInflight.has(normalized)) { + return nfqws2RemoteValidationInflight.get(normalized); + } + + const validationTask = fs + .exec(NFQWS_VALIDATION_COMMAND, [ + "validate_nfqws2_strategy_json", + normalized, + ]) + .then((result) => { + const payload = JSON.parse( + (result && result.stdout ? result.stdout : "{}").trim() || "{}", + ); + return cacheNfqws2RemoteValidation(normalized, { + valid: payload.valid === true, + message: payload.message || "", + needle: payload.needle || "", + needles: Array.isArray(payload.needles) + ? payload.needles.filter(Boolean) + : payload.needle + ? [payload.needle] + : [], + }); + }) + .catch((error) => + cacheNfqws2RemoteValidation( + normalized, + buildNfqws2RemoteValidationFallback(error), + ), + ) + .finally(() => { + nfqws2RemoteValidationInflight.delete(normalized); + }); + + nfqws2RemoteValidationInflight.set(normalized, validationTask); + return validationTask; +} + +function getNfqws2ForbiddenTokenInfo(token, index, nextToken) { + const configFileMessage = _( + "External nfqws2 config files bypass Tachyon queue management and explicit validation.", + ); + const hostSelectionMessage = _( + "Resource selection by hostname inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE.", + ); + const ipSelectionMessage = _( + "Resource selection by IP or CIDR inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE.", + ); + const placeholderMessage = _( + "Zapret2 hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS2 strategies.", + ); + const queueMessage = _( + "The NFQUEUE number is assigned by Tachyon for each rule and must not be overridden here.", + ); + const fwmarkMessage = _( + "The desync fwmark is managed by Tachyon for loop prevention and must not be overridden here.", + ); + const fuzzMessage = _( + "Fuzzing is not supported here because Tachyon needs deterministic runtime validation.", + ); + const interceptMessage = _( + "Disabling interception is incompatible with action=zapret2 because Tachyon sends matched traffic through NFQUEUE.", + ); + const daemonMessage = _( + "Tachyon manages the nfqws2 process lifecycle itself, so daemon mode is not allowed here.", + ); + const dryRunMessage = _( + "This field must start a working nfqws2 strategy; --dry-run exits immediately and is not allowed here.", + ); + const versionMessage = _( + "This field must start a working nfqws2 strategy; --version exits immediately and is not allowed here.", + ); + + if (index === 0 && (token.startsWith("@") || token.startsWith("$"))) { + return { + reason: configFileMessage, + captureNextValue: false, + }; + } + + if (token === "" || token === "") { + return { + reason: placeholderMessage, + captureNextValue: false, + }; + } + + if ( + token === "--hostlist" || + token.startsWith("--hostlist=") || + token === "--hostlist-domains" || + token.startsWith("--hostlist-domains=") || + token === "--hostlist-exclude" || + token.startsWith("--hostlist-exclude=") || + token === "--hostlist-exclude-domains" || + token.startsWith("--hostlist-exclude-domains=") || + token === "--hostlist-auto" || + token.startsWith("--hostlist-auto=") || + token === "--hostlist-auto-fail-threshold" || + token.startsWith("--hostlist-auto-fail-threshold=") || + token === "--hostlist-auto-fail-time" || + token.startsWith("--hostlist-auto-fail-time=") || + token === "--hostlist-auto-retrans-threshold" || + token.startsWith("--hostlist-auto-retrans-threshold=") || + token === "--hostlist-auto-debug" || + token.startsWith("--hostlist-auto-debug=") || + token === "--hostlist-auto-retrans-reset" || + token.startsWith("--hostlist-auto-retrans-reset=") + ) { + return { + reason: hostSelectionMessage, + captureNextValue: !token.includes("="), + }; + } + + if ( + token === "--ipset" || + token.startsWith("--ipset=") || + token === "--ipset-ip" || + token.startsWith("--ipset-ip=") || + token === "--ipset-exclude" || + token.startsWith("--ipset-exclude=") || + token === "--ipset-exclude-ip" || + token.startsWith("--ipset-exclude-ip=") + ) { + return { + reason: ipSelectionMessage, + captureNextValue: !token.includes("="), + }; + } + + if (token === "--qnum" || token.startsWith("--qnum=")) { + return { + reason: queueMessage, + captureNextValue: !token.includes("="), + }; + } + + if ( + token === "--fwmark" || + token.startsWith("--fwmark=") || + token === "--dpi-desync-fwmark" || + token.startsWith("--dpi-desync-fwmark=") + ) { + return { + reason: fwmarkMessage, + captureNextValue: !token.includes("="), + }; + } + + if (token === "--fuzz" || token.startsWith("--fuzz=")) { + return { + reason: fuzzMessage, + captureNextValue: !token.includes("="), + }; + } + + if ( + token === "--intercept=0" || + token === "--intercept=false" || + token === "--intercept=no" || + (token === "--intercept" && nextToken && nextToken.value === "0") + ) { + return { + reason: interceptMessage, + captureNextValue: token === "--intercept", + }; + } + + if (token === "--daemon") { + return { + reason: daemonMessage, + captureNextValue: false, + }; + } + + if (token === "--dry-run") { + return { + reason: dryRunMessage, + captureNextValue: false, + }; + } + + if (token === "--version") { + return { + reason: versionMessage, + captureNextValue: false, + }; + } + + return null; +} + +function buildNfqws2LocalAnalysis(value) { + const text = value ? `${value}` : ""; + if (!text.trim().length) { + return { + valid: false, + message: _("NFQWS2 strategy cannot be empty"), + annotations: [], + }; + } + + const tokens = parseNfqwsRuntimeTokens(text); + const annotationMap = new Map(); + const errors = []; + + for (let index = 0; index < tokens.length; ) { + const token = tokens[index]; + const bareToken = token.value.includes("=") + ? token.value.slice(0, token.value.indexOf("=")) + : token.value; + const nextToken = tokens[index + 1] || null; + + const forbidden = getNfqws2ForbiddenTokenInfo( + token.value, + index, + nextToken, + ); + if (forbidden) { + addAnnotationIssue(annotationMap, token, forbidden.reason); + + let displayToken = token.value; + + if ( + forbidden.captureNextValue && + nextToken && + !nextToken.value.startsWith("--") + ) { + addAnnotationIssue(annotationMap, nextToken, forbidden.reason); + displayToken = `${displayToken} ${nextToken.value}`; + index += 2; + } else { + index += 1; + } + + errors.push(`${displayToken}: ${forbidden.reason}`); + continue; + } + + if (!token.value.startsWith("--")) { + const reason = _( + "Unexpected standalone token. Use explicit flags such as --name or --name=value.", + ); + addAnnotationIssue(annotationMap, token, reason); + errors.push(`${token.value}: ${reason}`); + index += 1; + continue; + } + + const mode = getNfqws2OptionArgumentMode(bareToken); + if (mode === "unknown") { + const reason = _("Unknown NFQWS2 flag."); + addAnnotationIssue(annotationMap, token, reason); + errors.push(`${token.value}: ${reason}`); + index += 1; + continue; + } + + if (mode === "none") { + if (token.value.includes("=")) { + const reason = _("This flag does not accept a value."); + addAnnotationIssue(annotationMap, token, reason); + errors.push(`${token.value}: ${reason}`); + } + + index += 1; + continue; + } + + if (mode === "optional") { + if ( + nextToken && + !token.value.includes("=") && + !nextToken.value.startsWith("--") + ) { + const reason = _( + "Optional values must be attached with '=' here; a separate token would be ignored by nfqws2.", + ); + addAnnotationIssue(annotationMap, token, reason); + addAnnotationIssue(annotationMap, nextToken, reason); + errors.push(`${token.value} ${nextToken.value}: ${reason}`); + index += 2; + } else { + index += 1; + } + + continue; + } + + if (!token.value.includes("=")) { + if (!nextToken || nextToken.value.startsWith("--")) { + const reason = _("This option requires a value."); + addAnnotationIssue(annotationMap, token, reason); + errors.push(`${token.value}: ${reason}`); + index += 1; + continue; + } + + index += 2; + continue; + } + + index += 1; + } + + if (!errors.length) { + return { valid: true, message: "", annotations: [] }; + } + + return { + valid: false, + message: [getValidationHeaderText(), ...errors].join("\n"), + annotations: finalizeAnnotations(annotationMap), + }; +} + +function analyzeNfqws2Strategy(value) { + const localAnalysis = buildNfqws2LocalAnalysis(value); + if (!localAnalysis.valid) { + return localAnalysis; + } + + const remoteValidation = getCachedNfqws2RemoteValidation(value); + if (!remoteValidation || remoteValidation.valid) { + return localAnalysis; + } + + const text = value ? `${value}` : ""; + const tokens = parseNfqwsRuntimeTokens(text); + const annotationMap = new Map(); + + localAnalysis.annotations.forEach((annotation) => + addAnnotationIssue(annotationMap, annotation, annotation.message), + ); + addNfqwsRemoteValidationAnnotations(annotationMap, tokens, remoteValidation); + + return { + valid: false, + message: [getValidationHeaderText(), remoteValidation.message].join("\n"), + annotations: finalizeAnnotations(annotationMap), + }; +} + +function normalizeByedpiStrategyWhitespace(value) { + return value ? `${value}`.replace(/\s+/g, " ").trim() : ""; +} + +function normalizeByedpiStrategyValue(value) { + const normalized = normalizeByedpiStrategyWhitespace(value); + return normalized.length ? normalized : BYEDPI_DEFAULT_CMD_OPTS; +} + +function getCachedByedpiRemoteValidation(value) { + const normalized = normalizeByedpiStrategyValue(value); + return normalized.length + ? byedpiRemoteValidationCache.get(normalized) || null + : null; +} + +function cacheByedpiRemoteValidation(value, result) { + const normalized = normalizeByedpiStrategyValue(value); + if (!normalized.length) { + return result; + } + + const cached = { + valid: result && result.valid === true, + message: result && result.message ? `${result.message}` : "", + needle: result && result.needle ? `${result.needle}` : "", + needles: + result && Array.isArray(result.needles) + ? result.needles.filter(Boolean).map((item) => `${item}`) + : result && result.needle + ? [`${result.needle}`] + : [], + }; + + byedpiRemoteValidationCache.set(normalized, cached); + return cached; +} + +function buildByedpiRemoteValidationFallback(error) { + const message = + error && error.message + ? `${error.message}` + : _("Unable to validate the ByeDPI strategy through the backend parser."); + + return { + valid: false, + message: _("Backend validation failed: %s").format(message), + needle: "", + needles: [], + }; +} + +function validateByedpiStrategyRemotely(value) { + const normalized = normalizeByedpiStrategyValue(value); + + if (!normalized.length) { + return Promise.resolve({ + valid: true, + message: "", + needle: "", + needles: [], + }); + } + + if (byedpiRemoteValidationCache.has(normalized)) { + return Promise.resolve(byedpiRemoteValidationCache.get(normalized)); + } + + if (byedpiRemoteValidationInflight.has(normalized)) { + return byedpiRemoteValidationInflight.get(normalized); + } + + const validationTask = fs + .exec(NFQWS_VALIDATION_COMMAND, [ + "validate_byedpi_strategy_json", + normalized, + ]) + .then((result) => { + const payload = JSON.parse( + (result && result.stdout ? result.stdout : "{}").trim() || "{}", + ); + return cacheByedpiRemoteValidation(normalized, { + valid: payload.valid === true, + message: payload.message || "", + needle: payload.needle || "", + needles: Array.isArray(payload.needles) + ? payload.needles.filter(Boolean) + : payload.needle + ? [payload.needle] + : [], + }); + }) + .catch((error) => + cacheByedpiRemoteValidation( + normalized, + buildByedpiRemoteValidationFallback(error), + ), + ) + .finally(() => { + byedpiRemoteValidationInflight.delete(normalized); + }); + + byedpiRemoteValidationInflight.set(normalized, validationTask); + return validationTask; +} + +function getByedpiShortOptionName(token) { + return token.length > 2 ? token.slice(0, 2) : token; +} + +function byedpiTokenLooksLikeOption(token) { + return /^--.+/.test(token) || /^-[A-Za-z].*/.test(token); +} + +function getByedpiControlledTokenInfo(token) { + const listenMessage = _( + "ByeDPI listen address and port are assigned by Tachyon and must not be set in the strategy.", + ); + const transparentMessage = _( + "Transparent proxy mode is incompatible with action=byedpi because Tachyon connects to ciadpi through SOCKS.", + ); + const daemonMessage = _( + "Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here.", + ); + const pidfileMessage = _( + "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here.", + ); + const exitMessage = _( + "This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here.", + ); + + if ( + token === "--ip" || + token.startsWith("--ip=") || + token === "-i" || + /^-i.+/.test(token) || + token === "--port" || + token.startsWith("--port=") || + token === "-p" || + /^-p.+/.test(token) + ) { + return { + reason: listenMessage, + captureNextValue: + token === "--ip" || + token === "-i" || + token === "--port" || + token === "-p", + }; + } + + if (token === "--transparent" || token === "-E" || /^-E.+/.test(token)) { + return { + reason: transparentMessage, + captureNextValue: false, + }; + } + + if (token === "--daemon" || token === "-D" || /^-D.+/.test(token)) { + return { + reason: daemonMessage, + captureNextValue: false, + }; + } + + if ( + token === "--pidfile" || + token.startsWith("--pidfile=") || + token === "-w" || + /^-w.+/.test(token) + ) { + return { + reason: pidfileMessage, + captureNextValue: token === "--pidfile" || token === "-w", + }; + } + + if ( + token === "--help" || + token === "-h" || + /^-h.+/.test(token) || + token === "--version" || + token === "-v" || + /^-v.+/.test(token) + ) { + return { + reason: exitMessage, + captureNextValue: false, + }; + } + + return null; +} + +function validateByedpiStrategyToken(token, nextToken) { + const controlled = getByedpiControlledTokenInfo(token); + if (controlled) { + return { + valid: false, + reason: controlled.reason, + captureNextValue: controlled.captureNextValue, + }; + } + + if (/^--[^=]+=/.test(token)) { + const base = token.split("=", 1)[0]; + const value = token.slice(base.length + 1); + + if (BYEDPI_LONG_VALUE_OPTIONS.has(base)) { + return value.length + ? { valid: true, consumeNext: false } + : { + valid: false, + reason: _("ByeDPI option requires a value: %s").format(base), + captureNextValue: false, + }; + } + + if (BYEDPI_LONG_FLAG_OPTIONS.has(base)) { + return { + valid: false, + reason: _("ByeDPI option does not accept a value: %s").format(base), + captureNextValue: false, + }; + } + + return { + valid: false, + reason: _("Unknown ByeDPI option: %s").format(base), + captureNextValue: false, + }; + } + + if (/^--.+/.test(token)) { + if (BYEDPI_LONG_VALUE_OPTIONS.has(token)) { + return nextToken && !byedpiTokenLooksLikeOption(nextToken) + ? { valid: true, consumeNext: true } + : { + valid: false, + reason: _("ByeDPI option requires a value: %s").format(token), + captureNextValue: false, + }; + } + + if (BYEDPI_LONG_FLAG_OPTIONS.has(token)) { + return { valid: true, consumeNext: false }; + } + + return { + valid: false, + reason: _("Unknown ByeDPI option: %s").format(token), + captureNextValue: false, + }; + } + + if (/^-./.test(token)) { + if (token === "-") { + return { + valid: false, + reason: _("Unexpected ByeDPI strategy argument: %s").format(token), + captureNextValue: false, + }; + } + + const short = getByedpiShortOptionName(token); + const compactValue = token.slice(short.length); + + if (BYEDPI_SHORT_VALUE_OPTIONS.has(short)) { + if (token === short) { + return nextToken && !byedpiTokenLooksLikeOption(nextToken) + ? { valid: true, consumeNext: true } + : { + valid: false, + reason: _("ByeDPI option requires a value: %s").format(short), + captureNextValue: false, + }; + } + + return compactValue.length + ? { valid: true, consumeNext: false } + : { + valid: false, + reason: _("ByeDPI option requires a value: %s").format(short), + captureNextValue: false, + }; + } + + if (BYEDPI_SHORT_FLAG_OPTIONS.has(short)) { + return token === short + ? { valid: true, consumeNext: false } + : { + valid: false, + reason: _( + "ByeDPI option does not accept a compact value: %s", + ).format(short), + captureNextValue: false, + }; + } + + return { + valid: false, + reason: _("Unknown ByeDPI option: %s").format(short), + captureNextValue: false, + }; + } + + return { + valid: false, + reason: _("Unexpected ByeDPI strategy argument: %s").format(token), + captureNextValue: false, + }; +} + +function buildByedpiLocalAnalysis(value) { + const text = value ? `${value}` : ""; + if (!text.trim().length) { + return { + valid: false, + message: _("ByeDPI strategy cannot be empty"), + annotations: [], + }; + } + + const tokens = parseNfqwsRuntimeTokens(text); + const annotationMap = new Map(); + const errors = []; + + for (let index = 0; index < tokens.length; ) { + const token = tokens[index]; + const nextToken = tokens[index + 1] || null; + const tokenValidation = validateByedpiStrategyToken( + token.value, + nextToken ? nextToken.value : null, + ); + + if (tokenValidation.valid) { + index += tokenValidation.consumeNext ? 2 : 1; + continue; + } + + addAnnotationIssue(annotationMap, token, tokenValidation.reason); + let displayToken = token.value; + + if ( + tokenValidation.captureNextValue && + nextToken && + !nextToken.value.startsWith("-") + ) { + addAnnotationIssue(annotationMap, nextToken, tokenValidation.reason); + displayToken = `${displayToken} ${nextToken.value}`; + index += 2; + } else { + index += 1; + } + + errors.push(`${displayToken}: ${tokenValidation.reason}`); + } + + if (!errors.length) { + return { valid: true, message: "", annotations: [] }; + } + + return { + valid: false, + message: [getValidationHeaderText(), ...errors].join("\n"), + annotations: finalizeAnnotations(annotationMap), + }; +} + +function analyzeByedpiStrategy(value) { + const localAnalysis = buildByedpiLocalAnalysis(value); + if (!localAnalysis.valid) { + return localAnalysis; + } + + const remoteValidation = getCachedByedpiRemoteValidation(value); + if (!remoteValidation || remoteValidation.valid) { + return localAnalysis; + } + + const text = value ? `${value}` : ""; + const tokens = parseNfqwsRuntimeTokens(text); + const annotationMap = new Map(); + + localAnalysis.annotations.forEach((annotation) => + addAnnotationIssue(annotationMap, annotation, annotation.message), + ); + addNfqwsRemoteValidationAnnotations(annotationMap, tokens, remoteValidation); + + return { + valid: false, + message: [getValidationHeaderText(), remoteValidation.message].join("\n"), + annotations: finalizeAnnotations(annotationMap), + }; +} + +function configureTextareaOption(option, analyzer, remoteValidationAttacher) { + const originalRenderWidget = option.renderWidget; + + option.renderWidget = function (section_id, option_index, cfgvalue) { + const node = originalRenderWidget.call( + this, + section_id, + option_index, + cfgvalue, + ); + const textarea = + node && typeof node.querySelector === "function" + ? node.querySelector("textarea") + : node; + + if (textarea) { + applyTextareaInputAttributes(textarea); + if (typeof analyzer === "function") { + attachAnnotatedTextarea(textarea, analyzer); + } + if (typeof remoteValidationAttacher === "function") { + remoteValidationAttacher(this, section_id, textarea); + } + } + + return node; + }; +} + +function getOptionTextarea(option, section_id) { + const field = + typeof option.map.findElement === "function" + ? option.map.findElement("data-field", option.cbid(section_id)) + : null; + + if (field && typeof field.querySelector === "function") { + return field.querySelector("textarea"); + } + + const elem = + typeof option.getUIElement === "function" + ? option.getUIElement(section_id) + : null; + const node = elem && elem.node ? elem.node : null; + + if (node && node.nodeName === "TEXTAREA") { + return node; + } + + return node && typeof node.querySelector === "function" + ? node.querySelector("textarea") + : null; +} + +function rejectStrategyValidation(option, section_id, message) { + const title = option.stripTags(option.title).trim(); + const error = message || option.getValidationError(section_id) || ""; + + return Promise.reject( + new TypeError( + `${_('Option "%s" contains an invalid input value.').format(title || option.option)} ${error}`, + ), + ); +} + +function parseStrategyWithRemoteValidation(section_id, config) { + const active = this.isActive(section_id); + + if (active) { + if (typeof this.triggerValidation === "function") { + this.triggerValidation(section_id); + } + + if (!this.isValid(section_id)) { + return rejectStrategyValidation( + this, + section_id, + this.getValidationError(section_id), + ); + } + + const cval = this.cfgvalue(section_id); + const fval = this.formvalue(section_id); + const cvalString = cval == null ? "" : `${cval}`; + const fvalString = fval == null ? "" : `${fval}`; + const shouldWrite = this.forcewrite || cvalString !== fvalString; + + if (!shouldWrite) { + return Promise.resolve(); + } + + return config.remoteValidate(fvalString).then((result) => { + const textarea = getOptionTextarea(this, section_id); + + if (textarea) { + refreshAnnotatedTextareaValidation(this, section_id, textarea); + } + + if (typeof this.triggerValidation === "function") { + this.triggerValidation(section_id); + } + + if (!result || result.valid !== true) { + return rejectStrategyValidation( + this, + section_id, + result && result.message ? result.message : config.invalidMessage, + ); + } + + return Promise.resolve(this.write(section_id, fvalString)); + }); + } + + if (!this.retain) { + return Promise.resolve(this.remove(section_id)); + } + + return Promise.resolve(); +} + +function parseNfqwsStrategyOnSave(section_id) { + return parseStrategyWithRemoteValidation.call(this, section_id, { + remoteValidate: validateNfqwsStrategyRemotely, + invalidMessage: _( + "Unable to validate the NFQWS strategy through the backend parser.", + ), + }); +} + +function parseNfqws2StrategyOnSave(section_id) { + return parseStrategyWithRemoteValidation.call(this, section_id, { + remoteValidate: validateNfqws2StrategyRemotely, + invalidMessage: _( + "Unable to validate the NFQWS2 strategy through the backend parser.", + ), + }); +} + +function addDynamicConditionField(section, config) { + const o = section.taboption( + "conditions", + form.DynamicList, + config.key, + config.label, + config.description, + ); + + o.modalonly = true; + if (config.placeholder) { + o.placeholder = config.placeholder; + } + if (config.dynamicValidate) { + o.validate = config.dynamicValidate; + } + + o.load = function (section_id) { + const values = getConfigListValues(section_id, config.key); + if (values.length) { + return values; + } + + const legacyText = uci.get(UCI_PACKAGE, section_id, `${config.key}_text`); + return legacyText ? main.parseValueList(legacyText) : []; + }; + + o.write = function (section_id, value) { + writeListOption(section_id, config.key, value); + uci.unset(UCI_PACKAGE, section_id, `${config.key}_text`); + uci.unset(UCI_PACKAGE, section_id, `${config.key}_text_mode`); + }; + + return o; +} + +function addLocalDeviceSubnetDynamicField(section, config) { + const o = section.taboption( + "conditions", + form.DynamicList, + config.key, + config.label, + config.description, + ); + + o.modalonly = true; + o.placeholder = _("Device or IP"); + o.validate = function (_section_id, value) { + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateSubnet(value); + return validation.valid ? true : validation.message; + }; + o.load = function (section_id) { + const values = getConfigListValues(section_id, config.key); + if (values.length) { + return values; + } + + const legacyText = uci.get(UCI_PACKAGE, section_id, `${config.key}_text`); + return legacyText ? main.parseValueList(legacyText) : []; + }; + o.write = function (section_id, value) { + writeListOption(section_id, config.key, value); + uci.unset(UCI_PACKAGE, section_id, `${config.key}_text`); + uci.unset(UCI_PACKAGE, section_id, `${config.key}_text_mode`); + }; + o.renderWidget = function (section_id, _option_index, cfgvalue) { + return localDevices.createLocalDeviceDynamicListWidget( + this, + section_id, + cfgvalue, + ); + }; + + return o; +} + +function addTextConditionField(section, config) { + const optionName = config.optionName || `${config.key}_text`; + const legacyTextOptionName = + config.legacyTextOptionName || `${config.key}_text`; + const o = section.taboption( + "conditions", + form.TextValue, + optionName, + config.label, + config.description, + ); + + o.rows = 8; + o.wrap = "soft"; + o.textarea = true; + o.modalonly = true; + if (config.textAnalyze) { + o.validate = function (_section_id, value) { + const analysis = config.textAnalyze(value); + return analysis.valid ? true : analysis.message; + }; + } else if (config.textValidate) { + o.validate = config.textValidate; + } + configureTextareaOption(o, config.textAnalyze); + + o.load = function (section_id) { + if (typeof config.loadText === "function") { + return config.loadText(section_id); + } + + const textValue = + uci.get(UCI_PACKAGE, section_id, optionName) || + uci.get(UCI_PACKAGE, section_id, legacyTextOptionName); + if (textValue) { + return valuesToText(textValue); + } + + return valuesToText(uci.get(UCI_PACKAGE, section_id, config.key)); + }; + + o.write = function (section_id, value) { + const normalized = value ? `${value}`.trim() : ""; + + if (normalized.length) { + uci.set(UCI_PACKAGE, section_id, optionName, normalized); + } else { + uci.unset(UCI_PACKAGE, section_id, optionName); + } + + if (config.key !== optionName) { + uci.unset(UCI_PACKAGE, section_id, config.key); + } + if (legacyTextOptionName !== optionName) { + uci.unset(UCI_PACKAGE, section_id, legacyTextOptionName); + } + uci.unset(UCI_PACKAGE, section_id, `${config.key}_text_mode`); + + if (typeof config.afterWrite === "function") { + config.afterWrite(section_id); + } + }; + + return o; +} + +function loadRulesetValues(option) { + delete option.keylist; + delete option.vallist; + + Object.entries(main.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => { + option.value(key, _(label)); + }); +} + +function isBuiltinRulesetValue(value) { + return Object.prototype.hasOwnProperty.call(main.DOMAIN_LIST_OPTIONS, value); +} + +function normalizeReferenceForExtensionCheck(value) { + return `${value || ""}`.split(/[?#]/, 1)[0].toLowerCase(); +} + +function hasAllowedReferenceExtension(value, extensions) { + const normalized = normalizeReferenceForExtensionCheck(value); + return extensions.some((extension) => normalized.endsWith(extension)); +} + +function validateFileReference(value, extensions, errorMessage, options = {}) { + if (!value || value.length === 0) { + return true; + } + + if (value.startsWith("http://") || value.startsWith("https://")) { + const validation = main.validateUrl(value); + if ( + validation.valid && + (options.allowRemoteWithoutExtension || + hasAllowedReferenceExtension(value, extensions)) + ) { + return true; + } + + return errorMessage; + } + + if (value.startsWith("/")) { + const validation = main.validatePath(value); + if (validation.valid && hasAllowedReferenceExtension(value, extensions)) { + return true; + } + + return errorMessage; + } + + return errorMessage; +} + +function validateCustomRulesetReference(value) { + return validateFileReference( + value, + [".srs", ".json"], + _("Rule set must be an HTTP(S) URL or a local .srs / .json path"), + { allowRemoteWithoutExtension: true }, + ); +} + +function validatePlainListReference(value) { + return validateFileReference( + value, + [".lst"], + _("List must be an HTTP(S) URL or a local .lst path"), + { allowRemoteWithoutExtension: true }, + ); +} + +function getRulesetReferences(section_id) { + return getConfigListValues(section_id, "rule_set"); +} + +function getBuiltInRulesetReferences(section_id) { + const values = getConfigListValues(section_id, "community_lists").filter( + (value) => isBuiltinRulesetValue(value), + ); + + return values.filter( + (value, index, values) => + isBuiltinRulesetValue(value) && values.indexOf(value) === index, + ); +} + +function getCustomRulesetReferences(section_id) { + return uniqueDynamicListItems([ + ...getRulesetReferences(section_id).filter( + (value) => !isBuiltinRulesetValue(value), + ), + ...getConfigListValues(section_id, "rule_set_with_subnets"), + ]); +} + +function writeBuiltInRulesetReferences(section_id, values) { + const refs = normalizeDynamicListItems(values).filter((value) => + isBuiltinRulesetValue(value), + ); + writeListOption(section_id, "community_lists", refs); +} + +function writeCustomRulesetReferences(section_id, values) { + const refs = uniqueDynamicListItems(values); + if (getRuleResolvedAction(section_id) === "dns") { + writeDnsRulesetReferences(section_id, refs); + return; + } + const subnetRefs = getConfigListValues( + section_id, + "rule_set_with_subnets", + ).filter((value) => refs.includes(value)); + const subnetRefSet = new Set(subnetRefs); + + writeListOption( + section_id, + "rule_set", + refs.filter((value) => !subnetRefSet.has(value)), + ); + writeListOption(section_id, "rule_set_with_subnets", subnetRefs); + uci.unset(UCI_PACKAGE, section_id, RULE_SET_ITEM_SETTINGS_KEY); +} + +function writeDnsRulesetReferences(section_id, values) { + writeListOption(section_id, "rule_set", uniqueDynamicListItems(values)); + uci.unset(UCI_PACKAGE, section_id, "rule_set_with_subnets"); + uci.unset(UCI_PACKAGE, section_id, RULE_SET_ITEM_SETTINGS_KEY); +} + +function createSectionContent(section) { + let o; + + section.tab("settings", _("Settings")); + section.tab("conditions", _("Conditions")); + + o = section.taboption("settings", form.Flag, "enabled", _("Enable")); + o.default = "1"; + o.rmempty = false; + o.editable = true; + o.width = "6rem"; + + o = section.taboption( + "settings", + form.DummyValue, + "_action_display", + _("Action"), + ); + o.modalonly = false; + o.rawhtml = true; + o.cfgvalue = function (section_id) { + return getRuleActionDisplayMarkup(section_id); + }; + o.textvalue = function (section_id) { + return getRuleActionDisplayValue(section_id); + }; + o.width = "7rem"; + + o = section.taboption( + "settings", + form.Value, + "label", + _("Section name"), + _("Visible name of this section"), + ); + o.rmempty = false; + o.modalonly = true; + o.load = function (section_id) { + return uci.get(UCI_PACKAGE, section_id, "label") || section_id; + }; + + o = section.taboption( + "settings", + form.ListValue, + "action", + _("Action"), + _("What Tachyon should do when this section matches"), + ); + populateActionOptionValues(o); + o.default = "connection"; + o.rmempty = false; + o.modalonly = true; + o.cfgvalue = function (section_id) { + return getRuleConfiguredAction(section_id); + }; + o.load = function (section_id) { + return ensureActionProvidersAvailabilityLoaded().then(() => { + populateActionOptionValues(this); + return this.cfgvalue(section_id); + }); + }; + + o = section.taboption( + "settings", + form.ListValue, + "dns_type", + _("DNS protocol"), + _("DNS protocol used by the resolver"), + ); + o.depends("action", "dns"); + dnsTypeChoices().forEach((choice) => o.value(choice.value, choice.label)); + o.default = "udp"; + o.rmempty = false; + o.modalonly = true; + + o = section.taboption( + "settings", + form.Value, + "dns_server", + _("DNS server"), + _("DNS server used by the resolver"), + ); + o.depends("action", "dns"); + o.rmempty = false; + o.modalonly = true; + o.validate = function (_section_id, value) { + const normalized = `${value || ""}`.trim(); + if (!normalized) { + return _("DNS server address cannot be empty"); + } + const validation = main.validateDNS(normalized); + return validation.valid ? true : _("Enter a valid DNS server address"); + }; + + o = section.taboption( + "settings", + form.Flag, + "dns_detour_enabled", + _("DNS through section"), + _("Route requests to this DNS server through another section."), + ); + o.depends("action", "dns"); + o.default = "0"; + o.rmempty = false; + o.modalonly = true; + + o = section.taboption( + "settings", + form.ListValue, + "dns_detour_section", + _("DNS requests through section"), + ); + o.depends({ action: "dns", dns_detour_enabled: "1" }); + o.rmempty = false; + o.modalonly = true; + o.load = function (section_id) { + refreshDnsDetourSectionOptionValues(this, section_id); + return uci.get(UCI_PACKAGE, section_id, "dns_detour_section") || ""; + }; + o.validate = function (_section_id, value) { + return value ? true : _("Select a section"); + }; + + o = section.taboption( + "settings", + form.TextValue, + "nfqws_opt", + _("NFQWS Strategy"), + ); + o.depends("action", "zapret"); + o.rows = 6; + o.wrap = "soft"; + o.textarea = true; + o.modalonly = true; + o.load = function (section_id) { + const value = uci.get(UCI_PACKAGE, section_id, "nfqws_opt"); + if (!value || value === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT) { + return ZAPRET_DEFAULT_NFQWS_OPT; + } + + return value; + }; + o.write = function (section_id, value) { + const normalized = normalizeNfqwsStrategyValue(value); + const nextValue = + !normalized.length || normalized === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT + ? ZAPRET_DEFAULT_NFQWS_OPT + : normalized; + + return validateNfqwsStrategyRemotely(nextValue).then((result) => { + if (!result || result.valid !== true) { + throw new TypeError( + result && result.message + ? result.message + : _( + "Unable to validate the NFQWS strategy through the backend parser.", + ), + ); + } + + uci.set(UCI_PACKAGE, section_id, "nfqws_opt", nextValue); + }); + }; + o.validate = function (_section_id, value) { + const analysis = analyzeNfqwsStrategy(value); + return analysis.valid ? true : analysis.message; + }; + o.parse = parseNfqwsStrategyOnSave; + configureTextareaOption(o, analyzeNfqwsStrategy, attachNfqwsRemoteValidation); + + o = section.taboption( + "settings", + form.TextValue, + "nfqws2_opt", + _("NFQWS2 Strategy"), + ); + o.depends("action", "zapret2"); + o.rows = 6; + o.wrap = "soft"; + o.textarea = true; + o.modalonly = true; + o.load = function (section_id) { + return ( + uci.get(UCI_PACKAGE, section_id, "nfqws2_opt") || + ZAPRET2_DEFAULT_NFQWS2_OPT + ); + }; + o.write = function (section_id, value) { + const normalized = normalizeNfqws2StrategyValue(value); + + return validateNfqws2StrategyRemotely(normalized).then((result) => { + if (!result || result.valid !== true) { + throw new TypeError( + result && result.message + ? result.message + : _("Invalid NFQWS2 strategy"), + ); + } + + uci.set(UCI_PACKAGE, section_id, "nfqws2_opt", normalized); + }); + }; + o.validate = function (_section_id, value) { + const analysis = analyzeNfqws2Strategy(value); + return analysis.valid ? true : analysis.message; + }; + o.parse = parseNfqws2StrategyOnSave; + configureTextareaOption( + o, + analyzeNfqws2Strategy, + attachNfqws2RemoteValidation, + ); + + o = section.taboption( + "settings", + form.TextValue, + "byedpi_cmd_opts", + _("ByeDPI Strategy"), + ); + o.depends("action", "byedpi"); + o.rows = 6; + o.wrap = "soft"; + o.textarea = true; + o.modalonly = true; + o.load = function (section_id) { + return ( + uci.get(UCI_PACKAGE, section_id, "byedpi_cmd_opts") || + BYEDPI_DEFAULT_CMD_OPTS + ); + }; + o.write = function (section_id, value) { + const normalized = normalizeByedpiStrategyValue(value); + + return validateByedpiStrategyRemotely(normalized).then((result) => { + if (!result || result.valid !== true) { + throw new TypeError( + result && result.message + ? result.message + : _("Invalid ByeDPI strategy"), + ); + } + + uci.set(UCI_PACKAGE, section_id, "byedpi_cmd_opts", normalized); + }); + }; + o.validate = function (_section_id, value) { + const analysis = analyzeByedpiStrategy(value); + return analysis.valid ? true : analysis.message; + }; + configureTextareaOption(o, analyzeByedpiStrategy); + + o = section.taboption( + "settings", + form.DynamicList, + "selector_proxy_links", + _("Connection URL"), + _( + "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links", + ), + ); + o.depends("action", "connection"); + o.rmempty = true; + o.modalonly = true; + o.validate = function (_section_id, value) { + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateProxyUrl(value); + return validation.valid ? true : validation.message; + }; + o.onchange = function (_event, section_id) { + refreshDashboardFilterChoiceWidgets(section_id); + }; + outboundNameSourceOptions.set("selector_proxy_links", o); + + o = section.taboption( + "settings", + SettingsDynamicList, + "subscription_url", + _("Subscription URL"), + _("Enter the subscription URL"), + ); + o.depends("action", "connection"); + o.rmempty = true; + o.modalonly = true; + o.childType = "subscription_url"; + o.childValueOption = "url"; + o.childDefaults = defaultSubscriptionUrlSettings(); + o.renderItemSettingsModal = showSubscriptionUrlSettingsModal; + o.hasItemSettings = function (section_id, value) { + const normalized = `${value || ""}`.trim(); + if (isExistingChildItem(section_id, normalized, "subscription_url")) { + return true; + } + + return this.validate(section_id, normalized) === true; + }; + o.stagedChildSettings = function (section_id, value) { + const inputValue = childItemInputValue( + section_id, + value, + "subscription_url", + "url", + ); + const store = childPendingSettingsStore(this, section_id); + return store[inputValue] ? Object.assign({}, store[inputValue]) : null; + }; + o.clearStagedChildSettings = function (section_id) { + if (this.pendingChildSettings) { + delete this.pendingChildSettings[section_id]; + } + }; + o.renderListItemLabel = function (section_id, itemId) { + return childItemInputValue(section_id, itemId, "subscription_url", "url"); + }; + o.validate = validateSubscriptionUrlEntry; + o.validate = function (section_id, value) { + return validateSubscriptionUrlEntry( + section_id, + childItemInputValue(section_id, value, "subscription_url", "url"), + ); + }; + + o = section.taboption( + "settings", + InterfaceSettingsDynamicList, + "interfaces", + _("Network Interface"), + _("Select network interface for VPN connection"), + ); + o.depends("action", "connection"); + o.rmempty = true; + o.modalonly = true; + o.placeholder = _("Select a network interface"); + o.childType = "section_interface"; + o.childValueOption = "name"; + o.childDefaults = defaultInterfaceSettings(); + o.renderItemSettingsModal = showInterfaceSettingsModal; + o.hasItemSettings = function (section_id, value) { + const normalized = `${value || ""}`.trim(); + if (isExistingChildItem(section_id, normalized, "section_interface")) { + return true; + } + + return this.validate(section_id, normalized) === true; + }; + o.stagedChildSettings = function (section_id, value) { + const inputValue = childItemInputValue( + section_id, + value, + "section_interface", + "name", + ); + const store = childPendingSettingsStore(this, section_id); + return store[inputValue] ? Object.assign({}, store[inputValue]) : null; + }; + o.clearStagedChildSettings = function (section_id) { + if (this.pendingChildSettings) { + delete this.pendingChildSettings[section_id]; + } + }; + o.onListChange = refreshDashboardFilterChoiceWidgets; + outboundNameSourceOptions.set("interfaces", o); + + o = section.taboption( + "settings", + ButtonAddSettingsDynamicList, + "outbound_jsons", + _("JSON outbound"), + _("Custom outbound configurations in JSON format"), + ); + o.depends("action", "connection"); + o.rmempty = true; + o.modalonly = true; + o.addButtonLabel = _("+ Add JSON outbound"); + o.renderItemSettingsModal = showOutboundJsonSettingsModal; + o.renderListItemLabel = function (_section_id, value) { + return outboundJsonListItemLabel(value); + }; + o.validateItemsOnSave = validateOutboundJsonItemsBeforeSave; + o.validate = function (_section_id, value) { + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateOutboundJson(value); + return validation.valid ? true : validation.message; + }; + o.onListChange = refreshDashboardFilterChoiceWidgets; + outboundNameSourceOptions.set("outbound_jsons", o); + + o = section.taboption( + "settings", + ButtonAddSettingsDynamicList, + "urltest", + _("URLTest"), + _("Server group for automatic lowest-latency selection"), + ); + o.depends("action", "connection"); + o.rmempty = true; + o.modalonly = true; + o.addButtonLabel = _("+ Add URLTest"); + o.childType = "urltest"; + o.childValueOption = "name"; + o.childDefaults = urlTestChildDefaults(); + o.renderItemSettingsModal = showUrlTestSettingsModal; + o.validateItemsOnSave = function (section_id, values) { + return validateUrlTestItemsBeforeSave(section_id, values, this); + }; + o.hasItemSettings = function (section_id, value) { + const normalized = `${value || ""}`.trim(); + + if (isExistingChildItem(section_id, normalized, "urltest")) { + return true; + } + + return normalized.length > 0; + }; + o.inputValueForItem = function (section_id, value) { + const inputValue = childItemInputValue( + section_id, + value, + "urltest", + "name", + ); + const store = childPendingSettingsStore(this, section_id); + return store[inputValue] && store[inputValue].name + ? store[inputValue].name + : inputValue; + }; + o.stagedChildSettings = function (section_id, value) { + const id = childItemInputValue(section_id, value, "urltest", "name"); + const store = childPendingSettingsStore(this, section_id); + return store[id] ? Object.assign({}, store[id]) : null; + }; + o.clearStagedChildSettings = function (section_id) { + if (this.pendingChildSettings) { + delete this.pendingChildSettings[section_id]; + } + }; + o.renderListItemLabel = function (section_id, itemId) { + return E( + "span", + { class: "fkp-dynlist-label" }, + this.inputValueForItem(section_id, itemId), + ); + }; + o.onListChange = refreshDashboardFilterChoiceWidgets; + sectionGroupSourceOptions.set("urltest", o); + + o = section.taboption( + "settings", + ButtonAddSettingsDynamicList, + "priority_group", + _("Priority"), + _("Server group for priority failover"), + ); + o.depends("action", "connection"); + o.rmempty = true; + o.modalonly = true; + o.addButtonLabel = _("+ Add priority"); + o.childType = "priority_group"; + o.childValueOption = "name"; + o.childDefaults = priorityGroupChildDefaults(); + o.createId = () => randomPriorityGroupId(); + o.renderItemSettingsModal = showPriorityGroupSettingsModal; + o.validateItemsOnSave = validatePriorityGroupItemsBeforeSave; + o.hasItemSettings = function (section_id, value) { + const normalized = `${value || ""}`.trim(); + + if (isExistingChildItem(section_id, normalized, "priority_group")) { + return true; + } + + return normalized.length > 0; + }; + o.inputValueForItem = function (section_id, value) { + return childItemInputValue(section_id, value, "priority_group", "name"); + }; + o.renderListItemLabel = function (section_id, itemId) { + return E( + "span", + { class: "fkp-dynlist-label" }, + this.inputValueForItem(section_id, itemId), + ); + }; + o.onListChange = refreshDashboardFilterChoiceWidgets; + sectionGroupSourceOptions.set("priority_group", o); + + o = section.taboption( + "settings", + form.Flag, + "outbound_detour_enabled", + _("Cascade connection"), + _( + "Use another section as an intermediate hop to connect to servers in this section. Does not apply to network interfaces or JSON outbounds.", + ), + ); + o.default = "0"; + o.rmempty = false; + o.depends("action", "connection"); + o.modalonly = true; + o.write = function (section_id, value) { + if (value === "1") { + const currentValue = + uci.get(UCI_PACKAGE, section_id, "outbound_detour_section") || ""; + const targetSections = getOutboundDetourTargetSections(section_id); + const currentIsValid = targetSections.some( + (targetSection) => getUciSectionName(targetSection) === currentValue, + ); + const selectedValue = currentIsValid + ? currentValue + : getDefaultOutboundDetourSection(section_id); + + if (selectedValue) { + uci.set( + UCI_PACKAGE, + section_id, + "outbound_detour_section", + selectedValue, + ); + } + } + + return form.Flag.prototype.write.apply(this, arguments); + }; + + o = section.taboption( + "settings", + form.ListValue, + "outbound_detour_section", + _("Connect through"), + _("Select a transit section"), + ); + o.rmempty = false; + o.depends({ action: "connection", outbound_detour_enabled: "1" }); + o.modalonly = true; + o.load = function (section_id) { + refreshOutboundDetourSectionOptionValues(this, section_id); + return Promise.resolve( + uci.get(UCI_PACKAGE, section_id, "outbound_detour_section") || "", + ); + }; + o.validate = function (section_id, value) { + if (!value) { + return _("Select an intermediate section"); + } + if (value === section_id) { + return _("Current section cannot be used as its own transit section"); + } + return true; + }; + + o = section.taboption( + "settings", + form.Flag, + "sort_by_latency", + _("Sort by latency"), + _("Sorts servers in this section by lowest latency in the dashboard."), + ); + o.default = "0"; + o.rmempty = false; + o.depends("action", "connection"); + o.modalonly = true; + + o = section.taboption( + "settings", + form.Flag, + "mixed_proxy_enabled", + _("Enable Mixed Proxy"), + _("Expose this section as a local HTTP+SOCKS proxy"), + ); + o.default = "0"; + o.rmempty = false; + o.depends("action", "connection"); + o.depends("action", "byedpi"); + o.depends("action", "zapret"); + o.depends("action", "zapret2"); + o.modalonly = true; + + o = section.taboption( + "settings", + form.Value, + "mixed_proxy_port", + _("Mixed Proxy Port"), + _("Port for the local mixed proxy of this section"), + ); + o.rmempty = false; + o.depends({ action: "connection", mixed_proxy_enabled: "1" }); + o.depends({ action: "byedpi", mixed_proxy_enabled: "1" }); + o.depends({ action: "zapret", mixed_proxy_enabled: "1" }); + o.depends({ action: "zapret2", mixed_proxy_enabled: "1" }); + o.modalonly = true; + o.validate = function (_section_id, value) { + if (!value || value.length === 0) { + return _("Port cannot be empty"); + } + + const parsed = parseInt(value, 10); + if (!isNaN(parsed) && parsed >= 1 && parsed <= 65535) { + return true; + } + + return _("Invalid port number. Must be between 1 and 65535"); + }; + + o = section.taboption( + "settings", + form.Flag, + "mixed_proxy_auth_enabled", + _("Enable Mixed Proxy Authentication"), + _("Require a username and password for the local mixed proxy"), + ); + o.default = "0"; + o.rmempty = false; + o.depends({ action: "connection", mixed_proxy_enabled: "1" }); + o.depends({ action: "byedpi", mixed_proxy_enabled: "1" }); + o.depends({ action: "zapret", mixed_proxy_enabled: "1" }); + o.depends({ action: "zapret2", mixed_proxy_enabled: "1" }); + o.modalonly = true; + + o = section.taboption( + "settings", + form.Value, + "mixed_proxy_username", + _("Mixed Proxy Username"), + ); + o.rmempty = false; + o.depends({ + action: "connection", + mixed_proxy_enabled: "1", + mixed_proxy_auth_enabled: "1", + }); + o.depends({ + action: "byedpi", + mixed_proxy_enabled: "1", + mixed_proxy_auth_enabled: "1", + }); + o.depends({ + action: "zapret", + mixed_proxy_enabled: "1", + mixed_proxy_auth_enabled: "1", + }); + o.depends({ + action: "zapret2", + mixed_proxy_enabled: "1", + mixed_proxy_auth_enabled: "1", + }); + o.modalonly = true; + o.validate = function (_section_id, value) { + if (!value || value.length === 0) { + return _("Username cannot be empty"); + } + + return true; + }; + + o = section.taboption( + "settings", + form.Value, + "mixed_proxy_password", + _("Mixed Proxy Password"), + ); + o.rmempty = false; + o.depends({ + action: "connection", + mixed_proxy_enabled: "1", + mixed_proxy_auth_enabled: "1", + }); + o.depends({ + action: "byedpi", + mixed_proxy_enabled: "1", + mixed_proxy_auth_enabled: "1", + }); + o.depends({ + action: "zapret", + mixed_proxy_enabled: "1", + mixed_proxy_auth_enabled: "1", + }); + o.depends({ + action: "zapret2", + mixed_proxy_enabled: "1", + mixed_proxy_auth_enabled: "1", + }); + o.modalonly = true; + o.validate = function (_section_id, value) { + if (!value || value.length === 0) { + return _("Password cannot be empty"); + } + + return true; + }; + + o = section.taboption( + "settings", + form.Flag, + "resolve_real_ip_for_routing", + _("Resolve real IP for routing"), + _( + "Resolve domain names before routing so sing-box can use real destination IPs.", + ), + ); + o.default = "0"; + o.rmempty = false; + o.depends("action", "connection"); + o.modalonly = true; + o.cfgvalue = function (section_id) { + const value = uci.get( + UCI_PACKAGE, + section_id, + "resolve_real_ip_for_routing", + ); + if (value !== null && value !== undefined && value !== "") { + return value; + } + + return getRuleResolvedAction(section_id) === "byedpi" ? "1" : "0"; + }; + + addTextConditionField(section, { + key: "domain_suffix", + optionName: "domain", + legacyTextOptionName: "domain_suffix_text", + label: _("Domains"), + description: _( + "The rule applies to the domain and all its subdomains. Use full:, keyword:, or regex: prefixes for exact match, keyword match, or regular expression.", + ), + textAnalyze: analyzeDomainSuffixText, + loadText: loadCombinedDomainText, + afterWrite: function (section_id) { + [ + "domain_suffix", + "domain_suffix_text", + "domain_suffix_text_mode", + "domain_keyword", + "domain_regex", + "domain_text", + "domain_keyword_text", + "domain_regex_text", + "domain_text_mode", + "domain_keyword_text_mode", + "domain_regex_text_mode", + ].forEach((key) => { + uci.unset(UCI_PACKAGE, section_id, key); + }); + }, + }); + + const ipConditionOption = addTextConditionField(section, { + key: "ip_cidr", + optionName: "ip_cidr", + legacyTextOptionName: "ip_cidr_text", + label: _("IPs"), + description: _("Match destination IPs or subnets"), + textAnalyze: analyzeIpCidrText, + }); + dependsOnRoutingAction(ipConditionOption); + + const builtInRulesetOption = section.taboption( + "conditions", + form.DynamicList, + "community_lists", + _("Built-in rule sets"), + _("Select a predefined domain list"), + ); + builtInRulesetOption.modalonly = true; + builtInRulesetOption.placeholder = _("Service list"); + builtInRulesetOption.load = function (section_id) { + loadRulesetValues(this); + return getBuiltInRulesetReferences(section_id); + }; + builtInRulesetOption.write = function (section_id, values) { + writeBuiltInRulesetReferences(section_id, values); + }; + builtInRulesetOption.remove = function (section_id) { + uci.unset(UCI_PACKAGE, section_id, "community_lists"); + }; + let isProcessingBuiltIns = false; + builtInRulesetOption.onchange = function (_ev, section_id, value) { + if (isProcessingBuiltIns) { + return; + } + + isProcessingBuiltIns = true; + + try { + const values = Array.isArray(value) + ? value.filter(Boolean) + : value + ? [value] + : []; + let newValues = [...values]; + const notifications = []; + + const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter((opt) => + newValues.includes(opt), + ); + + if (selectedRegionalOptions.length > 1) { + const lastSelected = + selectedRegionalOptions[selectedRegionalOptions.length - 1]; + const removedRegions = selectedRegionalOptions.slice(0, -1); + newValues = newValues.filter( + (v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v), + ); + notifications.push( + E("p", {}, [ + E("strong", {}, _("Regional options cannot be used together")), + E("br"), + _( + "Warning: %s cannot be used together with %s. Previous selections have been removed.", + ).format(removedRegions.join(", "), lastSelected), + ]), + ); + } + + if (newValues.includes("russia_inside")) { + const removedServices = newValues.filter( + (v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), + ); + if (removedServices.length > 0) { + newValues = newValues.filter((v) => + main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), + ); + notifications.push( + E("p", { class: "alert-message warning" }, [ + E("strong", {}, _("Russia inside restrictions")), + E("br"), + _( + "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.", + ).format( + main.ALLOWED_WITH_RUSSIA_INSIDE.map( + (key) => main.DOMAIN_LIST_OPTIONS[key], + ) + .filter((label) => label !== "Russia inside") + .join(", "), + removedServices.join(", "), + ), + ]), + ); + } + } + + if ( + JSON.stringify(newValues.slice().sort()) !== + JSON.stringify(values.slice().sort()) + ) { + this.getUIElement(section_id).setValue(newValues); + } + + notifications.forEach((notification) => + ui.addNotification(null, notification), + ); + } finally { + isProcessingBuiltIns = false; + } + }; + + const ruleSetOption = section.taboption( + "conditions", + SettingsDynamicList, + "rule_set", + _("Rule sets"), + _( + "Add URLs or local paths to .srs / .json lists. Only domain rules are supported.", + ), + ); + ruleSetOption.modalonly = true; + // Both widgets map to rule_set, so neither inactive view may erase shared storage. + ruleSetOption.retain = true; + dependsOnRoutingAction(ruleSetOption); + ruleSetOption.renderItemSettingsModal = showRuleSetSettingsModal; + ruleSetOption.load = function (section_id) { + return getCustomRulesetReferences(section_id); + }; + ruleSetOption.write = function (section_id, value) { + writeCustomRulesetReferences(section_id, value); + }; + ruleSetOption.remove = function (section_id) { + uci.unset(UCI_PACKAGE, section_id, "rule_set"); + uci.unset(UCI_PACKAGE, section_id, "rule_set_with_subnets"); + uci.unset(UCI_PACKAGE, section_id, RULE_SET_ITEM_SETTINGS_KEY); + }; + ruleSetOption.validate = function (section_id, value) { + return validateCustomRulesetReference(value); + }; + + const dnsRuleSetOption = section.taboption( + "conditions", + form.DynamicList, + "_dns_rule_set", + _("Rule sets"), + _( + "Add URLs or local paths to .srs / .json lists. Only domain rules are supported.", + ), + ); + dnsRuleSetOption.depends("action", "dns"); + dnsRuleSetOption.modalonly = true; + dnsRuleSetOption.retain = true; + dnsRuleSetOption.load = function (section_id) { + return getCustomRulesetReferences(section_id); + }; + dnsRuleSetOption.write = function (section_id, value) { + writeDnsRulesetReferences(section_id, value); + }; + dnsRuleSetOption.remove = function (section_id) { + writeDnsRulesetReferences(section_id, []); + }; + dnsRuleSetOption.validate = function (_section_id, value) { + return validateCustomRulesetReference(value); + }; + + const domainIpListsOption = section.taboption( + "conditions", + form.DynamicList, + "domain_ip_lists", + _("Domain lists"), + _( + "Add URLs or local paths to .lst lists. Only domain rules are supported.", + ), + ); + domainIpListsOption.modalonly = true; + domainIpListsOption.load = function (section_id) { + return getConfigListValues(section_id, "domain_ip_lists"); + }; + domainIpListsOption.validate = function (_section_id, value) { + return validatePlainListReference(value); + }; + + const sourceIpOption = addLocalDeviceSubnetDynamicField(section, { + key: "source_ip_cidr", + label: _("Device filter"), + description: _( + "Apply section rules only to the specified local IP addresses", + ), + }); + dependsOnRoutingAction(sourceIpOption); + + const fullyRoutedOption = addLocalDeviceSubnetDynamicField(section, { + key: "fully_routed_ips", + label: _("Forced device routing"), + description: _( + "All traffic from these IP addresses will be routed through the section unconditionally, ignoring all other conditions.", + ), + }); + dependsOnRoutingAction(fullyRoutedOption); + + const portsOption = addDynamicConditionField(section, { + key: "ports", + label: _("Ports"), + description: _("Match destination ports. Use a single port or a range"), + dynamicValidate: validatePortCondition, + }); + dependsOnRoutingAction(portsOption); + + addDashboardServerFilterOptions(section); +} + +function loadSectionTableOptions(sectionRef) { + const sectionIds = sectionRef.cfgsections(); + const tasks = []; + + for (let i = 0; i < sectionIds.length; i += 1) { + const sectionId = sectionIds[i]; + + for (let j = 0; j < sectionRef.children.length; j += 1) { + const option = sectionRef.children[j]; + + if (option.disable || option.modalonly) { + continue; + } + + tasks.push( + Promise.resolve(option.load.call(option, sectionId)).then((value) => { + option.cfgvalue(sectionId, value); + }), + ); + } + } + + return Promise.all(tasks); +} + +function configureSectionSection(sectionRef, options = {}) { + setActionProvidersAvailabilityLoader(options.loadActionProvidersAvailability); + + sectionRef.load = function () { + // The table renders only non-modal fields; the cloned Add/Edit modal loads + // action/provider details when the user opens it. + return loadSectionTableOptions(this); + }; +} + +const EntryPoint = { + configureSectionSection, + createSectionContent, + setActionProvidersAvailabilityLoader, +}; + +return baseclass.extend(EntryPoint); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js new file mode 100644 index 00000000..017551ed --- /dev/null +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js @@ -0,0 +1,3141 @@ +"use strict"; +"require form"; +"require baseclass"; +"require dom"; +"require fs"; +"require rpc"; +"require uci"; +"require ui"; +"require view.tachyon.main as main"; + +const UCI_PACKAGE = main.TACHYON_UCI_PACKAGE; +const ROUTING_SECTION_ACTIONS = [ + "connection", + "proxy", + "outbound", + "vpn", + "byedpi", + "zapret", + "zapret2", +]; +const callNetworkInterfaceDump = rpc.declare({ + object: "network.interface", + method: "dump", + expect: { interface: [] }, +}); +let defaultPublicHostPromise = null; +let defaultPublicHostCache = null; + +function asList(value) { + if (!value) { + return []; + } + + return Array.isArray(value) ? value.filter(Boolean) : [`${value}`]; +} + +const QR_EC_CODEWORDS_PER_BLOCK_LOW = [ + -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, + 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + 30, 30, +]; +const QR_NUM_ERROR_CORRECTION_BLOCKS_LOW = [ + -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, + 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, +]; + +function qrGetBit(value, index) { + return ((value >>> index) & 1) !== 0; +} + +function qrAppendBits(buffer, value, length) { + for (let i = length - 1; i >= 0; i -= 1) { + buffer.push(qrGetBit(value, i)); + } +} + +function qrRawDataModules(version) { + let result = (16 * version + 128) * version + 64; + + if (version >= 2) { + const numAlign = Math.floor(version / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (version >= 7) { + result -= 36; + } + } + + return result; +} + +function qrRawCodewords(version) { + return Math.floor(qrRawDataModules(version) / 8); +} + +function qrDataCodewords(version) { + return ( + qrRawCodewords(version) - + QR_EC_CODEWORDS_PER_BLOCK_LOW[version] * + QR_NUM_ERROR_CORRECTION_BLOCKS_LOW[version] + ); +} + +function qrEncodeText(text) { + return Array.from(new TextEncoder().encode(text)); +} + +function qrBuildDataCodewords(bytes, version) { + const capacity = qrDataCodewords(version); + const bits = []; + const countBits = version <= 9 ? 8 : 16; + + qrAppendBits(bits, 4, 4); + qrAppendBits(bits, bytes.length, countBits); + bytes.forEach((value) => qrAppendBits(bits, value, 8)); + qrAppendBits(bits, 0, Math.min(4, capacity * 8 - bits.length)); + + while (bits.length % 8 !== 0) { + bits.push(false); + } + + const result = []; + for (let i = 0; i < bits.length; i += 8) { + let value = 0; + for (let j = 0; j < 8; j += 1) { + value = (value << 1) | (bits[i + j] ? 1 : 0); + } + result.push(value); + } + + for (let pad = 0xec; result.length < capacity; pad ^= 0xfd) { + result.push(pad); + } + + return result; +} + +function qrMultiply(x, y) { + let z = 0; + + for (let i = 7; i >= 0; i -= 1) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + + return z; +} + +function qrReedSolomonDivisor(degree) { + const result = Array(degree).fill(0); + result[degree - 1] = 1; + + let root = 1; + for (let i = 0; i < degree; i += 1) { + for (let j = 0; j < degree; j += 1) { + result[j] = qrMultiply(result[j], root); + if (j + 1 < degree) { + result[j] ^= result[j + 1]; + } + } + root = qrMultiply(root, 2); + } + + return result; +} + +function qrReedSolomonRemainder(data, divisor) { + const result = Array(divisor.length).fill(0); + + data.forEach((value) => { + const factor = value ^ result.shift(); + result.push(0); + for (let i = 0; i < result.length; i += 1) { + result[i] ^= qrMultiply(divisor[i], factor); + } + }); + + return result; +} + +function qrAddErrorCorrection(data, version) { + const numBlocks = QR_NUM_ERROR_CORRECTION_BLOCKS_LOW[version]; + const blockEccLen = QR_EC_CODEWORDS_PER_BLOCK_LOW[version]; + const rawCodewords = qrRawCodewords(version); + const numShortBlocks = numBlocks - (rawCodewords % numBlocks); + const shortBlockDataLen = Math.floor(rawCodewords / numBlocks) - blockEccLen; + const divisor = qrReedSolomonDivisor(blockEccLen); + const blocks = []; + + for (let i = 0, offset = 0; i < numBlocks; i += 1) { + const dataLen = shortBlockDataLen + (i < numShortBlocks ? 0 : 1); + const blockData = data.slice(offset, offset + dataLen); + offset += dataLen; + blocks.push({ + data: blockData, + ecc: qrReedSolomonRemainder(blockData, divisor), + }); + } + + const result = []; + const maxDataLen = Math.max(...blocks.map((block) => block.data.length)); + + for (let i = 0; i < maxDataLen; i += 1) { + blocks.forEach((block) => { + if (i < block.data.length) { + result.push(block.data[i]); + } + }); + } + + for (let i = 0; i < blockEccLen; i += 1) { + blocks.forEach((block) => result.push(block.ecc[i])); + } + + return result; +} + +function qrAlignmentPatternPositions(version) { + if (version === 1) { + return []; + } + + const size = version * 4 + 17; + const numAlign = Math.floor(version / 7) + 2; + const step = + version === 32 ? 26 : Math.ceil((version * 4 + 4) / (numAlign * 2 - 2)) * 2; + const result = [6]; + + for (let pos = size - 7; result.length < numAlign; pos -= step) { + result.splice(1, 0, pos); + } + + return result; +} + +function qrMakeMatrix(version, dataCodewords) { + const size = version * 4 + 17; + const modules = Array.from({ length: size }, () => Array(size).fill(false)); + const isFunction = Array.from({ length: size }, () => + Array(size).fill(false), + ); + + function setFunction(x, y, dark) { + modules[y][x] = dark; + isFunction[y][x] = true; + } + + function drawFinder(cx, cy) { + for (let dy = -4; dy <= 4; dy += 1) { + for (let dx = -4; dx <= 4; dx += 1) { + const x = cx + dx; + const y = cy + dy; + if (x < 0 || x >= size || y < 0 || y >= size) { + continue; + } + const dist = Math.max(Math.abs(dx), Math.abs(dy)); + setFunction(x, y, dist !== 2 && dist !== 4); + } + } + } + + function drawAlignment(cx, cy) { + for (let dy = -2; dy <= 2; dy += 1) { + for (let dx = -2; dx <= 2; dx += 1) { + setFunction( + cx + dx, + cy + dy, + Math.max(Math.abs(dx), Math.abs(dy)) !== 1, + ); + } + } + } + + drawFinder(3, 3); + drawFinder(size - 4, 3); + drawFinder(3, size - 4); + + for (let i = 8; i < size - 8; i += 1) { + setFunction(6, i, i % 2 === 0); + setFunction(i, 6, i % 2 === 0); + } + + const alignPositions = qrAlignmentPatternPositions(version); + alignPositions.forEach((x) => { + alignPositions.forEach((y) => { + const overlapsFinder = + (x === 6 && y === 6) || + (x === 6 && y === size - 7) || + (x === size - 7 && y === 6); + if (!overlapsFinder) { + drawAlignment(x, y); + } + }); + }); + + if (version >= 7) { + let rem = version; + for (let i = 0; i < 12; i += 1) { + rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + } + const bits = (version << 12) | rem; + for (let i = 0; i < 18; i += 1) { + const bit = qrGetBit(bits, i); + const a = size - 11 + (i % 3); + const b = Math.floor(i / 3); + setFunction(a, b, bit); + setFunction(b, a, bit); + } + } + + function drawFormatBits(mask) { + const formatData = (1 << 3) | mask; + let rem = formatData; + + for (let i = 0; i < 10; i += 1) { + rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + } + + const formatBits = ((formatData << 10) | rem) ^ 0x5412; + + for (let i = 0; i <= 5; i += 1) { + setFunction(8, i, qrGetBit(formatBits, i)); + } + setFunction(8, 7, qrGetBit(formatBits, 6)); + setFunction(8, 8, qrGetBit(formatBits, 7)); + setFunction(7, 8, qrGetBit(formatBits, 8)); + for (let i = 9; i < 15; i += 1) { + setFunction(14 - i, 8, qrGetBit(formatBits, i)); + } + for (let i = 0; i < 8; i += 1) { + setFunction(size - 1 - i, 8, qrGetBit(formatBits, i)); + } + for (let i = 8; i < 15; i += 1) { + setFunction(8, size - 15 + i, qrGetBit(formatBits, i)); + } + setFunction(8, size - 8, true); + } + + drawFormatBits(0); + + const codewords = qrAddErrorCorrection(dataCodewords, version); + let bitIndex = 0; + for (let right = size - 1; right >= 1; right -= 2) { + if (right === 6) { + right = 5; + } + for (let vert = 0; vert < size; vert += 1) { + for (let j = 0; j < 2; j += 1) { + const x = right - j; + const upward = ((right + 1) & 2) === 0; + const y = upward ? size - 1 - vert : vert; + if (!isFunction[y][x] && bitIndex < codewords.length * 8) { + modules[y][x] = qrGetBit( + codewords[Math.floor(bitIndex / 8)], + 7 - (bitIndex % 8), + ); + bitIndex += 1; + } + } + } + } + + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + if (!isFunction[y][x] && (x + y) % 2 === 0) { + modules[y][x] = !modules[y][x]; + } + } + } + + drawFormatBits(0); + + return modules; +} + +function qrSvgDataUri(text) { + const bytes = qrEncodeText(text); + let version = 1; + + for (; version <= 40; version += 1) { + const countBits = version <= 9 ? 8 : 16; + if (4 + countBits + bytes.length * 8 <= qrDataCodewords(version) * 8) { + break; + } + } + + if (version > 40) { + return ""; + } + + const matrix = qrMakeMatrix(version, qrBuildDataCodewords(bytes, version)); + const border = 4; + const size = matrix.length; + const viewSize = size + border * 2; + const path = []; + + matrix.forEach((row, y) => { + row.forEach((dark, x) => { + if (dark) { + path.push(`M${x + border},${y + border}h1v1h-1z`); + } + }); + }); + + const svg = ``; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +const SERVER_STYLE_ID = "tachyon-server-styles"; +const DEFAULT_SERVER_PROTOCOL = "tailscale"; +const FALLBACK_SERVER_PROTOCOL = "vless"; +const STREAM_PROTOCOLS = ["vless", "vmess", "trojan"]; +const PORT_PROTOCOLS = [ + "shadowsocks", + "socks", + "vmess", + "vless", + "trojan", + "hysteria2", +]; +const EXTENDED_PORT_PROTOCOLS = ["mtproto"]; +const PASSWORD_PROTOCOLS = ["shadowsocks", "socks", "trojan", "hysteria2"]; + +const BASE_PROTOCOL_LABELS = { + vless: "VLESS", + shadowsocks: "Shadowsocks", + socks: "SOCKS", + vmess: "VMess", + trojan: "Trojan", + hysteria2: "Hysteria2", +}; +const EXTENDED_PROTOCOL_LABELS = { + mtproto: "MTProto", +}; +const CUSTOM_PROTOCOL_LABELS = { + json_inbound: "JSON Inbound", +}; +const TAILSCALE_PROTOCOL_LABELS = { + tailscale: "Tailscale", +}; +const PROTOCOL_LABELS = { + ...BASE_PROTOCOL_LABELS, + ...EXTENDED_PROTOCOL_LABELS, + ...CUSTOM_PROTOCOL_LABELS, + ...TAILSCALE_PROTOCOL_LABELS, +}; + +const SECURITY_BY_PROTOCOL = { + vless: ["reality", "tls", "none"], + vmess: ["tls", "none"], + trojan: ["tls", "none"], + hysteria2: ["tls"], + shadowsocks: ["none"], + socks: ["none"], + mtproto: ["none"], + tailscale: ["none"], + json_inbound: ["none"], +}; + +function getSecurityLabel(security) { + if (security === "reality") { + return "Reality"; + } + if (security === "tls") { + return "TLS"; + } + return _("None"); +} + +function injectServerStyles() { + if (document.getElementById(SERVER_STYLE_ID)) { + return; + } + + document.head.appendChild( + E( + "style", + { id: SERVER_STYLE_ID }, + ` +.fkp-server-icon-button{box-sizing:border-box;vertical-align:middle} +.fkp-server-icon-button__glyph{display:inline-block;line-height:1;transform:scale(1.55);transform-origin:center} +.fkp-server-icon-button--done{outline:2px solid var(--success-color,#37a969);outline-offset:1px} +.fkp-server-validation-summary{margin:0 0 12px} +#cbi-${UCI_PACKAGE}-server > h3:nth-child(1){display:none} +#cbi-${UCI_PACKAGE}-server > .cbi-section-remove{margin-bottom:-32px} +#cbi-${UCI_PACKAGE}-server .cbi-section-actions > div{display:inline-flex;align-items:center;gap:4px} +#cbi-${UCI_PACKAGE}-server .cbi-section-actions{text-align:right} + +.fkp-server-info-modal{display:flex;flex-direction:column;gap:14px;width:560px;max-width:100%;box-sizing:border-box} +.fkp-server-info-modal__container{display:flex;gap:24px;align-items:flex-start;width:100%;box-sizing:border-box} +.fkp-server-info-modal__qr-col{display:flex;flex-direction:column;align-items:center;gap:12px;flex-shrink:0;width:180px} +.fkp-server-info-modal__qr-image{width:180px;height:180px;display:block;image-rendering:pixelated} +.fkp-server-info-modal__details-col{display:flex;flex-direction:column;gap:16px;flex-grow:1;min-width:0;box-sizing:border-box} +.fkp-server-info-modal__details-grid{display:flex;flex-direction:column;gap:10px;background:rgba(128,128,128,0.08);padding:14px;border:1px solid rgba(128,128,128,0.15);box-sizing:border-box} +.fkp-server-info-modal__detail-item{display:flex;justify-content:space-between;align-items:center;gap:16px} +.fkp-server-info-modal__detail-label{font-size:11px;opacity:0.65;text-transform:uppercase;letter-spacing:0.05em;font-weight:700} +.fkp-server-info-modal__detail-value{font-size:13px;font-weight:500} +.fkp-server-info-modal__detail-value--title{font-weight:600} +.fkp-server-info-modal__detail-value--mono{font-family:monospace;font-size:12px;background:rgba(128,128,128,0.12);padding:2px 6px;border-radius:4px;word-break:break-all} +.fkp-server-info-modal__badge{display:inline-flex;align-items:center;padding:2px 8px;font-size:11px;font-weight:600;border-radius:4px;border:1px solid var(--border-color,rgba(128,128,128,0.25));background:rgba(128,128,128,0.08);line-height:1.2} +.fkp-server-info-modal__link-section{display:flex;flex-direction:column;gap:8px;box-sizing:border-box} +.fkp-server-info-modal__link{width:100% !important;max-width:100% !important;font-family:monospace;font-size:11px;padding:8px 10px;resize:none;min-height:54px;box-sizing:border-box;margin:0 !important;line-height:1.4;word-break:break-all;float:none !important;position:static !important;border-radius:0 !important;background-color:transparent !important} +.fkp-server-info-modal__copy-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;align-self:flex-start;padding:3px 10px !important;font-size:11px !important;height:auto !important;min-height:24px !important;float:none !important;position:static !important;margin:0 !important} + +@media (max-width:640px){ +.fkp-server-info-modal{width:100%} +.fkp-server-info-modal__container{flex-direction:column;align-items:center;gap:20px} +.fkp-server-info-modal__qr-col{width:100%;max-width:180px} +.fkp-server-info-modal__details-col{width:100%} +} +`, + ), + ); +} + +function getFirstListValue(sectionId, option) { + return asList(uci.get(UCI_PACKAGE, sectionId, option))[0] || ""; +} + +function getProtocol(sectionId) { + return uci.get(UCI_PACKAGE, sectionId, "protocol") || DEFAULT_SERVER_PROTOCOL; +} + +function getProtocolLabel(protocol) { + return PROTOCOL_LABELS[protocol] || protocol || ""; +} + +function resetOptionValues(option) { + option.keylist = []; + option.vallist = []; +} + +function addOptionValue(option, value, label) { + if (option.keylist && option.keylist.indexOf(value) >= 0) { + return; + } + + option.value(value, label); +} + +function normalizeServerCapabilities(capabilities) { + return { + singBoxExtended: Boolean(capabilities?.singBoxExtended), + singBoxTailscale: capabilities?.singBoxTailscale !== false, + }; +} + +function getDefaultProtocolForCapabilities(capabilities) { + return normalizeServerCapabilities(capabilities).singBoxTailscale + ? DEFAULT_SERVER_PROTOCOL + : FALLBACK_SERVER_PROTOCOL; +} + +function populateProtocolValues(option, capabilities) { + const normalized = normalizeServerCapabilities(capabilities); + + resetOptionValues(option); + + if (normalized.singBoxTailscale) { + Object.entries(TAILSCALE_PROTOCOL_LABELS).forEach(([value, label]) => { + addOptionValue(option, value, label); + }); + } + + Object.entries(BASE_PROTOCOL_LABELS).forEach(([value, label]) => { + addOptionValue(option, value, label); + }); + + if (normalized.singBoxExtended) { + Object.entries(EXTENDED_PROTOCOL_LABELS).forEach(([value, label]) => { + addOptionValue(option, value, label); + }); + } + + Object.entries(CUSTOM_PROTOCOL_LABELS).forEach(([value, label]) => { + addOptionValue(option, value, label); + }); +} + +function populateTransportValues(option, singBoxExtended) { + resetOptionValues(option); + + addOptionValue(option, "tcp", "TCP"); + addOptionValue(option, "ws", "WebSocket"); + addOptionValue(option, "grpc", "gRPC"); + addOptionValue(option, "http", "HTTP"); + addOptionValue(option, "httpupgrade", "HTTPUpgrade"); + + if (singBoxExtended) { + addOptionValue(option, "xhttp", "XHTTP"); + } +} + +function applyServerCapabilities(sectionRef, capabilities) { + const options = sectionRef.serverCapabilityOptions; + const normalized = normalizeServerCapabilities(capabilities); + + sectionRef.serverCapabilities = normalized; + + if (!options) { + return; + } + + if (options.protocol) { + populateProtocolValues(options.protocol, normalized); + options.protocol.default = getDefaultProtocolForCapabilities(normalized); + } + + if (options.transport) { + populateTransportValues(options.transport, normalized.singBoxExtended); + } +} + +function getServerName(sectionId) { + return uci.get(UCI_PACKAGE, sectionId, "label") || sectionId; +} + +function getSocksUsername(sectionId) { + return ( + uci.get(UCI_PACKAGE, sectionId, "server_username") || + getServerName(sectionId) || + sectionId + ); +} + +function isSocksAuthenticationEnabled(sectionId) { + const value = `${ + uci.get(UCI_PACKAGE, sectionId, "socks_auth_enabled") ?? "1" + }`.toLowerCase(); + + return !["0", "false", "no", "off"].includes(value); +} + +function getDefaultSecurity(protocol) { + if (protocol === "vless") { + return "reality"; + } + if (protocol === "trojan" || protocol === "hysteria2") { + return "tls"; + } + return "none"; +} + +function normalizeVlessFlow(flow) { + return flow === "xtls-rprx-vision" ? flow : ""; +} + +function getEffectiveSecurity(sectionId) { + const protocol = getProtocol(sectionId); + const security = uci.get(UCI_PACKAGE, sectionId, "security"); + + if ( + protocol === "shadowsocks" || + protocol === "socks" || + protocol === "mtproto" || + protocol === "tailscale" || + protocol === "json_inbound" + ) { + return "none"; + } + if (protocol === "hysteria2") { + return "tls"; + } + if (security === "reality" && protocol !== "vless") { + return getDefaultSecurity(protocol); + } + + return security || getDefaultSecurity(protocol); +} + +function normalizeHost(host) { + return `${host || ""}`.trim().replace(/^\[|\]$/g, ""); +} + +function getBrowserHost() { + return normalizeHost(window.location.hostname || ""); +} + +function isIpv4(value) { + return /^(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])){3}$/.test( + value, + ); +} + +function isIpv6(value) { + const normalized = normalizeHost(value); + if (!normalized.includes(":")) { + return false; + } + + try { + new URL(`http://[${normalized}]/`); + return true; + } catch (_e) { + return false; + } +} + +function formatHostForUri(host) { + const normalized = normalizeHost(host); + return isIpv6(normalized) ? `[${normalized}]` : normalized; +} + +function isHostname(value) { + const hostname = normalizeHost(value); + if (hostname.length < 1 || hostname.length > 253 || hostname.endsWith(".")) { + return false; + } + + return hostname + .split(".") + .every((part) => + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(part), + ); +} + +function isHost(value) { + const host = normalizeHost(value); + return isIpv4(host) || isIpv6(host) || isHostname(host); +} + +function isPrivateOrReservedIpv4(value) { + if (!isIpv4(value)) { + return true; + } + + const octets = value.split(".").map((part) => parseInt(part, 10)); + const [a, b] = octets; + + return ( + a === 0 || + a === 10 || + a === 127 || + a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 192 && b === 0) || + (a === 192 && b === 2) || + (a === 198 && (b === 18 || b === 19 || b === 51)) || + (a === 203 && b === 0) + ); +} + +function isPrivateOrReservedIp(value) { + const normalized = normalizeHost(value).toLowerCase(); + if (isIpv4(normalized)) { + return isPrivateOrReservedIpv4(normalized); + } + if (!isIpv6(normalized)) { + return true; + } + + return ( + normalized === "::" || + normalized === "::1" || + normalized.startsWith("fe80") || + normalized.startsWith("fc") || + normalized.startsWith("fd") || + normalized.startsWith("ff") || + normalized.startsWith("2001:db8") + ); +} + +function getInterfaceIpAddresses(networkInterface) { + const ipv4Values = + networkInterface && + typeof networkInterface === "object" && + Array.isArray(networkInterface["ipv4-address"]) + ? networkInterface["ipv4-address"] + : []; + const ipv6Values = + networkInterface && + typeof networkInterface === "object" && + Array.isArray(networkInterface["ipv6-address"]) + ? networkInterface["ipv6-address"] + : []; + + return [...ipv4Values, ...ipv6Values] + .map((address) => + normalizeHost( + address && typeof address === "object" ? address.address : address, + ), + ) + .filter((address) => isIpv4(address) || isIpv6(address)); +} + +function chooseDefaultPublicHost(networkInterfaces) { + const interfaces = Array.isArray(networkInterfaces) ? networkInterfaces : []; + const upInterfaces = interfaces.filter( + (networkInterface) => networkInterface && networkInterface.up !== false, + ); + const publicIp = upInterfaces + .flatMap(getInterfaceIpAddresses) + .find((ip) => !isPrivateOrReservedIp(ip)); + + if (publicIp) { + return publicIp; + } + + const lanInterface = + upInterfaces.find((networkInterface) => { + const name = `${networkInterface.interface || ""}`; + const device = `${networkInterface.device || networkInterface.l3_device || ""}`; + return name === "lan" || device === "br-lan"; + }) || {}; + const lanIp = getInterfaceIpAddresses(lanInterface).find( + (ip) => ip !== "::1" && !ip.startsWith("127."), + ); + + if (lanIp) { + return lanIp; + } + + return ( + upInterfaces + .flatMap(getInterfaceIpAddresses) + .find((ip) => ip !== "::1" && !ip.startsWith("127.")) || getBrowserHost() + ); +} + +function loadDefaultPublicHost() { + if (defaultPublicHostCache != null) { + return Promise.resolve(defaultPublicHostCache); + } + + if (defaultPublicHostPromise) { + return defaultPublicHostPromise; + } + + defaultPublicHostPromise = callNetworkInterfaceDump() + .then((networkInterfaces) => { + defaultPublicHostCache = chooseDefaultPublicHost(networkInterfaces); + return defaultPublicHostCache; + }) + .catch(() => getBrowserHost()) + .finally(() => { + defaultPublicHostPromise = null; + }); + + return defaultPublicHostPromise; +} + +function preloadServerModalData() { + return loadDefaultPublicHost().catch(() => null); +} + +function applyDefaultPublicHost(sectionId, host) { + const normalizedHost = normalizeHost(host); + const currentHost = normalizeHost( + uci.get(UCI_PACKAGE, sectionId, "public_host"), + ); + const browserHost = getBrowserHost(); + + if ( + normalizedHost && + (!currentHost || currentHost === browserHost || currentHost === "127.0.0.1") + ) { + uci.set(UCI_PACKAGE, sectionId, "public_host", normalizedHost); + } +} + +function randomBytes(length) { + const bytes = new Uint8Array(length); + + if (window.crypto && window.crypto.getRandomValues) { + window.crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < length; i += 1) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + + return bytes; +} + +function randomHex(byteLength) { + return Array.from(randomBytes(byteLength)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function stringToHex(value) { + return Array.from(unescape(encodeURIComponent(`${value || ""}`))) + .map((char) => char.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""); +} + +function hexToString(value) { + const hex = `${value || ""}`; + if (!/^(?:[0-9a-fA-F]{2})+$/.test(hex)) { + return ""; + } + + const bytes = hex.match(/../g).map((byte) => parseInt(byte, 16)); + try { + return decodeURIComponent( + escape(bytes.map((byte) => String.fromCharCode(byte)).join("")), + ); + } catch (_error) { + return ""; + } +} + +function generateUuid() { + if (window.crypto && window.crypto.randomUUID) { + return window.crypto.randomUUID(); + } + + const bytes = randomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function base64Encode(value) { + return btoa(unescape(encodeURIComponent(value))); +} + +function base64UrlEncode(value) { + return base64Encode(value) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function generatePassword() { + return base64UrlEncode( + Array.from(randomBytes(18)) + .map((byte) => String.fromCharCode(byte)) + .join(""), + ); +} + +function generateMtprotoSecret() { + const secret = randomHex(16); + return secret === "00000000000000000000000000000000" + ? "11111111111111111111111111111111" + : secret; +} + +function randomPort() { + const bytes = randomBytes(2); + const value = (bytes[0] << 8) | bytes[1]; + return `${20000 + (value % 30000)}`; +} + +function serverSafeName(sectionId) { + return `${sectionId || "server"}`.replace(/[^A-Za-z0-9_.-]/g, "_"); +} + +function defaultTlsCertificatePath(sectionId) { + return `/etc/tachyon/server-certs/${serverSafeName(sectionId)}.crt`; +} + +function defaultTlsKeyPath(sectionId) { + return `/etc/tachyon/server-certs/${serverSafeName(sectionId)}.key`; +} + +function defaultTailscaleHostname(sectionId) { + return `tachyon-${serverSafeName(sectionId)}`; +} + +function setDefault(sectionId, key, value) { + const current = uci.get(UCI_PACKAGE, sectionId, key); + if (current == null || current === "") { + uci.set(UCI_PACKAGE, sectionId, key, value); + } +} + +function setValue(sectionId, key, value) { + uci.set(UCI_PACKAGE, sectionId, key, value); +} + +function cloneUciState(value) { + return value == null ? null : JSON.parse(JSON.stringify(value)); +} + +function isEmptyObject(value) { + return value && Object.keys(value).length === 0; +} + +function setUciSectionState(bucketName, sectionId, value) { + const state = uci.state; + + if (!state) { + return; + } + + state[bucketName] ??= {}; + const bucket = state[bucketName]; + + if (value == null) { + delete bucket[UCI_PACKAGE]?.[sectionId]; + if (isEmptyObject(bucket[UCI_PACKAGE])) { + delete bucket[UCI_PACKAGE]; + } + return; + } + + bucket[UCI_PACKAGE] ??= {}; + bucket[UCI_PACKAGE][sectionId] = cloneUciState(value); +} + +function captureServerEditState(sectionId) { + const state = uci.state; + + if (!state || state.creates?.[UCI_PACKAGE]?.[sectionId]) { + return null; + } + + return { + sectionId, + changes: cloneUciState(state.changes?.[UCI_PACKAGE]?.[sectionId]), + deletes: cloneUciState(state.deletes?.[UCI_PACKAGE]?.[sectionId]), + }; +} + +function restoreServerEditState(snapshot) { + if (!snapshot) { + return; + } + + setUciSectionState("changes", snapshot.sectionId, snapshot.changes); + setUciSectionState("deletes", snapshot.sectionId, snapshot.deletes); +} + +function ensureProtocolDefaults(sectionId, protocol, forceProtocolDefaults) { + setValue(sectionId, "protocol", protocol); + setDefault(sectionId, "enabled", "1"); + setDefault(sectionId, "label", sectionId); + setDefault(sectionId, "routing_mode", "rules"); + + if (protocol === "json_inbound") { + setValue(sectionId, "security", getDefaultSecurity(protocol)); + return; + } + + setDefault(sectionId, "listen", "0.0.0.0"); + setDefault(sectionId, "listen_port", randomPort()); + setDefault(sectionId, "public_host", getBrowserHost()); + setDefault( + sectionId, + "tls_certificate_path", + defaultTlsCertificatePath(sectionId), + ); + setDefault(sectionId, "tls_key_path", defaultTlsKeyPath(sectionId)); + + if (forceProtocolDefaults) { + setValue(sectionId, "security", getDefaultSecurity(protocol)); + setValue(sectionId, "transport", "tcp"); + } else { + setDefault(sectionId, "security", getDefaultSecurity(protocol)); + setDefault(sectionId, "transport", "tcp"); + } + + if (protocol === "vless" || protocol === "vmess") { + setDefault(sectionId, "server_uuid", generateUuid()); + } + + if (PASSWORD_PROTOCOLS.includes(protocol)) { + setDefault(sectionId, "server_password", generatePassword()); + } + + if (protocol === "socks") { + setDefault(sectionId, "socks_auth_enabled", "1"); + setDefault(sectionId, "server_username", getServerName(sectionId)); + } + + if (protocol === "vless") { + setDefault(sectionId, "vless_flow", "none"); + setDefault(sectionId, "tls_server_name", "www.microsoft.com"); + setDefault(sectionId, "client_fingerprint", "chrome"); + setDefault(sectionId, "reality_handshake_server", "www.microsoft.com"); + setDefault(sectionId, "reality_handshake_server_port", "443"); + setDefault(sectionId, "reality_short_id", randomHex(4)); + setDefault(sectionId, "reality_max_time_difference", "1m"); + } + + if (protocol === "vmess") { + setDefault(sectionId, "vmess_alter_id", "0"); + } + + if (protocol === "shadowsocks") { + setDefault(sectionId, "shadowsocks_method", "aes-128-gcm"); + } + + if (protocol === "tailscale") { + setDefault( + sectionId, + "tailscale_control_url", + "https://controlplane.tailscale.com", + ); + setDefault( + sectionId, + "tailscale_hostname", + defaultTailscaleHostname(sectionId), + ); + setDefault(sectionId, "tailscale_advertise_exit_node", "1"); + } + + if (protocol === "hysteria2") { + setDefault(sectionId, "hysteria2_obfs_password", generatePassword()); + } + + if (protocol === "mtproto") { + setDefault(sectionId, "mtproto_secret", generateMtprotoSecret()); + setDefault(sectionId, "mtproto_faketls", "google.com"); + setDefault(sectionId, "mtproto_padding", "1"); + setDefault(sectionId, "mtproto_domain_fronting_port", "443"); + setDefault(sectionId, "mtproto_prefer_ip", "prefer-ipv4"); + setDefault(sectionId, "mtproto_tolerate_time_skewness", "3s"); + setDefault(sectionId, "mtproto_idle_timeout", "5m"); + setDefault(sectionId, "mtproto_handshake_timeout", "10s"); + } +} + +function parseLegacyServerUser(sectionId, protocol) { + const entry = asList(uci.get(UCI_PACKAGE, sectionId, "server_users"))[0]; + if (!entry) { + return null; + } + + const parts = `${entry}`.split("|"); + return { + name: parts.length > 1 ? parts[0].trim() : "client", + credential: (parts.length > 1 ? parts[1] : parts[0] || "").trim(), + extra: (parts[2] || "").trim(), + protocol, + }; +} + +function normalizeMtprotoBaseSecret(value) { + const secret = `${value || ""}`.trim().toLowerCase(); + + if (/^[0-9a-f]{32}$/.test(secret)) { + return secret; + } + if (/^ee[0-9a-f]{32}(?:[0-9a-f]{2})+$/.test(secret)) { + return secret.slice(2, 34); + } + + return ""; +} + +function getMtprotoFaketlsFromSecret(value) { + const secret = `${value || ""}`.trim().toLowerCase(); + + if (!/^ee[0-9a-f]{32}(?:[0-9a-f]{2})+$/.test(secret)) { + return ""; + } + + return hexToString(secret.slice(34)); +} + +function getMtprotoFaketls(sectionId) { + return ( + uci.get(UCI_PACKAGE, sectionId, "mtproto_faketls") || + getMtprotoFaketlsFromSecret( + uci.get(UCI_PACKAGE, sectionId, "mtproto_secret"), + ) || + "google.com" + ); +} + +function isMtprotoPaddingEnabled(sectionId) { + return uci.get(UCI_PACKAGE, sectionId, "mtproto_padding") !== "0"; +} + +function buildMtprotoFullSecret(baseSecret, faketls, padding) { + if (!padding) { + return baseSecret; + } + + return `ee${baseSecret}${stringToHex(faketls || "google.com")}`; +} + +function getServerIdentity(sectionId) { + const protocol = getProtocol(sectionId); + const legacy = parseLegacyServerUser(sectionId, protocol); + const name = getServerName(sectionId); + const uuid = + uci.get(UCI_PACKAGE, sectionId, "server_uuid") || legacy?.credential || ""; + const password = + uci.get(UCI_PACKAGE, sectionId, "server_password") || + (PASSWORD_PROTOCOLS.includes(protocol) ? legacy?.credential : "") || + ""; + const mtprotoSecret = + normalizeMtprotoBaseSecret( + uci.get(UCI_PACKAGE, sectionId, "mtproto_secret"), + ) || + normalizeMtprotoBaseSecret( + protocol === "mtproto" ? legacy?.credential : "", + ) || + ""; + + return { + name, + username: getSocksUsername(sectionId), + uuid, + password, + mtprotoSecret, + mtprotoFaketls: getMtprotoFaketls(sectionId), + mtprotoPadding: isMtprotoPaddingEnabled(sectionId), + flow: normalizeVlessFlow( + uci.get(UCI_PACKAGE, sectionId, "vless_flow") || + (protocol === "vless" ? legacy?.extra : "") || + "", + ), + }; +} + +function encodeQuery(params) { + return Object.entries(params) + .filter(([, value]) => value != null && value !== "") + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(`${value}`)}`, + ) + .join("&"); +} + +function getPublicHost(sectionId) { + return ( + uci.get(UCI_PACKAGE, sectionId, "public_host") || + window.location.hostname || + "" + ); +} + +function getTransportParams(sectionId, params) { + const transport = uci.get(UCI_PACKAGE, sectionId, "transport") || "tcp"; + params.type = transport === "raw" ? "tcp" : transport; + + if (transport === "ws" || transport === "httpupgrade") { + params.path = uci.get(UCI_PACKAGE, sectionId, "transport_path") || ""; + params.host = uci.get(UCI_PACKAGE, sectionId, "transport_host") || ""; + } else if (transport === "xhttp") { + params.path = uci.get(UCI_PACKAGE, sectionId, "transport_path") || "/"; + params.host = uci.get(UCI_PACKAGE, sectionId, "transport_host") || ""; + params.mode = + uci.get(UCI_PACKAGE, sectionId, "transport_xhttp_mode") || "auto"; + } else if (transport === "grpc") { + params.serviceName = + uci.get(UCI_PACKAGE, sectionId, "transport_service_name") || ""; + } else if (transport === "http") { + params.path = uci.get(UCI_PACKAGE, sectionId, "transport_path") || ""; + params.host = asList( + uci.get(UCI_PACKAGE, sectionId, "transport_hosts"), + ).join(","); + } + + return transport; +} + +function buildVlessTrojanLink(sectionId, identity) { + const protocol = getProtocol(sectionId); + const host = getPublicHost(sectionId); + const uriHost = formatHostForUri(host); + const port = uci.get(UCI_PACKAGE, sectionId, "listen_port") || ""; + const security = getEffectiveSecurity(sectionId); + const tlsServerName = + uci.get(UCI_PACKAGE, sectionId, "tls_server_name") || + uci.get(UCI_PACKAGE, sectionId, "reality_handshake_server") || + ""; + const params = { security }; + const credential = protocol === "vless" ? identity.uuid : identity.password; + + getTransportParams(sectionId, params); + + if (protocol === "vless") { + params.encryption = "none"; + } + + if (security === "tls" || security === "reality") { + params.sni = tlsServerName; + params.alpn = asList(uci.get(UCI_PACKAGE, sectionId, "tls_alpn")).join(","); + } + + if (security === "reality") { + params.pbk = uci.get(UCI_PACKAGE, sectionId, "reality_public_key") || ""; + params.sid = getFirstListValue(sectionId, "reality_short_id"); + params.fp = + uci.get(UCI_PACKAGE, sectionId, "client_fingerprint") || "chrome"; + } + + if (protocol === "vless" && identity.flow) { + params.flow = identity.flow; + } + + const query = encodeQuery(params); + return `${protocol}://${encodeURIComponent(credential)}@${uriHost}:${port}${query ? `?${query}` : ""}#${encodeURIComponent(identity.name || sectionId)}`; +} + +function buildVmessLink(sectionId, identity) { + const host = getPublicHost(sectionId); + const port = uci.get(UCI_PACKAGE, sectionId, "listen_port") || ""; + const security = getEffectiveSecurity(sectionId); + const transportParams = {}; + const transport = getTransportParams(sectionId, transportParams); + if ( + security === "reality" && + (!uci.get(UCI_PACKAGE, sectionId, "reality_public_key") || + !getFirstListValue(sectionId, "reality_short_id")) + ) { + return ""; + } + + const payload = { + v: "2", + ps: identity.name || sectionId, + add: host, + port, + id: identity.uuid, + aid: uci.get(UCI_PACKAGE, sectionId, "vmess_alter_id") || "0", + scy: "auto", + net: transport === "tcp" ? "tcp" : transport, + type: "none", + host: transportParams.host || "", + path: transportParams.path || transportParams.serviceName || "", + tls: security === "tls" ? "tls" : "", + sni: uci.get(UCI_PACKAGE, sectionId, "tls_server_name") || "", + alpn: asList(uci.get(UCI_PACKAGE, sectionId, "tls_alpn")).join(","), + }; + + return `vmess://${base64Encode(JSON.stringify(payload))}`; +} + +function buildShadowsocksLink(sectionId, identity) { + const method = + uci.get(UCI_PACKAGE, sectionId, "shadowsocks_method") || "aes-128-gcm"; + const host = getPublicHost(sectionId); + const uriHost = formatHostForUri(host); + const port = uci.get(UCI_PACKAGE, sectionId, "listen_port") || ""; + const userInfo = base64UrlEncode(`${method}:${identity.password}`); + + return `ss://${userInfo}@${uriHost}:${port}#${encodeURIComponent(identity.name || sectionId)}`; +} + +function buildSocksLink(sectionId, identity) { + const host = getPublicHost(sectionId); + const uriHost = formatHostForUri(host); + const port = uci.get(UCI_PACKAGE, sectionId, "listen_port") || ""; + const username = identity.username || identity.name || sectionId; + const userInfo = isSocksAuthenticationEnabled(sectionId) + ? `${encodeURIComponent(username)}:${encodeURIComponent(identity.password)}@` + : ""; + + return `socks5://${userInfo}${uriHost}:${port}#${encodeURIComponent(identity.name || sectionId)}`; +} + +function normalizeSha256(value) { + const normalized = `${value || ""}`.trim().replace(/:/g, "").toLowerCase(); + return /^[0-9a-f]{64}$/.test(normalized) ? normalized : ""; +} + +function shouldUseGeneratedTlsCertificatePin(sectionId) { + const certificatePath = + uci.get(UCI_PACKAGE, sectionId, "tls_certificate_path") || + defaultTlsCertificatePath(sectionId); + + return `${certificatePath}` === defaultTlsCertificatePath(sectionId); +} + +function loadGeneratedTlsCertificatePin(sectionId) { + if (!shouldUseGeneratedTlsCertificatePin(sectionId)) { + return Promise.resolve(""); + } + + return fs + .exec("/usr/bin/tachyon", ["get_tls_certificate_sha256", sectionId]) + .then((response) => { + if ((response.code ?? 0) !== 0 || !response.stdout) { + throw new Error(response.stderr || response.stdout || ""); + } + + const data = JSON.parse(response.stdout); + if (!data.success) { + throw new Error(data.message || ""); + } + + return normalizeSha256(data.sha256); + }) + .catch((error) => { + console.warn( + error?.message || "Failed to read generated TLS certificate pin", + error, + ); + return ""; + }); +} + +function buildHysteria2Link(sectionId, identity, options = {}) { + const host = getPublicHost(sectionId); + const uriHost = formatHostForUri(host); + const port = uci.get(UCI_PACKAGE, sectionId, "listen_port") || ""; + const certificatePin = normalizeSha256(options.tlsCertificateSha256); + const obfsType = uci.get(UCI_PACKAGE, sectionId, "hysteria2_obfs_type") || ""; + const params = { + sni: uci.get(UCI_PACKAGE, sectionId, "tls_server_name") || "", + pcs: certificatePin, + }; + + if (obfsType === "salamander") { + params.obfs = obfsType; + params["obfs-password"] = + uci.get(UCI_PACKAGE, sectionId, "hysteria2_obfs_password") || ""; + } + + const query = encodeQuery(params); + + return `hysteria2://${encodeURIComponent(identity.password)}@${uriHost}:${port}${query ? `?${query}` : ""}#${encodeURIComponent(identity.name || sectionId)}`; +} + +function buildMtprotoLink(sectionId, identity) { + const host = getPublicHost(sectionId); + const port = uci.get(UCI_PACKAGE, sectionId, "listen_port") || ""; + const secret = buildMtprotoFullSecret( + identity.mtprotoSecret, + identity.mtprotoFaketls, + identity.mtprotoPadding, + ); + const query = encodeQuery({ + server: host, + port, + secret, + }); + + return `https://t.me/proxy?${query}`; +} + +function buildClientLink(sectionId, options = {}) { + const protocol = getProtocol(sectionId); + const identity = getServerIdentity(sectionId); + + if (protocol === "tailscale" || protocol === "json_inbound") { + return ""; + } + + if ( + ((protocol === "vless" || protocol === "vmess") && !identity.uuid) || + (PASSWORD_PROTOCOLS.includes(protocol) && !identity.password) || + (protocol === "mtproto" && !identity.mtprotoSecret) + ) { + return ""; + } + + if ( + protocol === "vless" && + getEffectiveSecurity(sectionId) === "reality" && + (!uci.get(UCI_PACKAGE, sectionId, "reality_public_key") || + !getFirstListValue(sectionId, "reality_short_id")) + ) { + return ""; + } + + switch (protocol) { + case "shadowsocks": + return buildShadowsocksLink(sectionId, identity); + case "socks": + return buildSocksLink(sectionId, identity); + case "vmess": + return buildVmessLink(sectionId, identity); + case "trojan": + return buildVlessTrojanLink(sectionId, identity); + case "hysteria2": + return buildHysteria2Link(sectionId, identity, options); + case "mtproto": + return buildMtprotoLink(sectionId, identity); + case "vless": + default: + return buildVlessTrojanLink(sectionId, identity); + } +} + +function buildClientLinkContext(sectionId) { + const certificatePinExpected = + getProtocol(sectionId) === "hysteria2" && + shouldUseGeneratedTlsCertificatePin(sectionId); + + if (!certificatePinExpected) { + return Promise.resolve({ + link: buildClientLink(sectionId), + certificatePinExpected, + tlsCertificateSha256: "", + }); + } + + return loadGeneratedTlsCertificatePin(sectionId).then( + (tlsCertificateSha256) => ({ + link: buildClientLink(sectionId, { tlsCertificateSha256 }), + certificatePinExpected, + tlsCertificateSha256, + }), + ); +} + +function svgEl(tag, attrs = {}, children = []) { + const element = document.createElementNS("http://www.w3.org/2000/svg", tag); + Object.entries(attrs).forEach(([key, value]) => { + if (value != null) { + element.setAttribute(key, `${value}`); + } + }); + (Array.isArray(children) ? children : [children]) + .filter(Boolean) + .forEach((child) => element.appendChild(child)); + return element; +} + +function iconSvg(name) { + const attrs = { + viewBox: "0 0 24 24", + width: "16", + height: "16", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + focusable: "false", + }; + + if (name === "copy") { + return svgEl("svg", attrs, [ + svgEl("rect", { + x: "9", + y: "9", + width: "13", + height: "13", + rx: "2", + ry: "2", + }), + svgEl("path", { + d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1", + }), + ]); + } + + if (name === "check") { + return svgEl("svg", attrs, [ + svgEl("polyline", { points: "20 6 9 17 4 12" }), + ]); + } + + if (name === "info") { + return svgEl("svg", attrs, [ + svgEl("circle", { cx: "12", cy: "12", r: "10" }), + svgEl("path", { d: "M12 16v-4" }), + svgEl("path", { d: "M12 8h.01" }), + ]); + } + + return svgEl("svg", attrs, [ + svgEl("rect", { x: "3", y: "3", width: "5", height: "5" }), + svgEl("rect", { x: "16", y: "3", width: "5", height: "5" }), + svgEl("rect", { x: "3", y: "16", width: "5", height: "5" }), + svgEl("path", { + d: "M16 16h1v1h-1zM20 16h1v1h-1zM16 20h1v1h-1zM20 20h1v1h-1zM18 18h1v1h-1z", + }), + ]); +} + +function copyText(text) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + + try { + document.execCommand("copy"); + main.showToast(_("Copied"), "success"); + } catch (error) { + main.showToast(_("Failed to copy!"), "error"); + console.warn("Failed to copy server client link", error); + } + + document.body.removeChild(textarea); +} + +function renderInfoClientLink(sectionId, linkContext = {}) { + const link = linkContext.link || buildClientLink(sectionId); + + if (!link) { + return E("em", {}, _("Client link is not available yet")); + } + + const qr = qrSvgDataUri(link); + const protocol = getProtocol(sectionId); + const security = getEffectiveSecurity(sectionId); + const host = getPublicHost(sectionId); + const port = uci.get(UCI_PACKAGE, sectionId, "listen_port") || ""; + const name = getServerName(sectionId); + + const securitySuffix = + security && security !== "none" ? ` + ${getSecurityLabel(security)}` : ""; + const protocolText = `${getProtocolLabel(protocol)}${securitySuffix}`; + const certificatePinWarning = + protocol === "hysteria2" && + linkContext.certificatePinExpected && + !linkContext.tlsCertificateSha256 + ? E( + "div", + { class: "alert-message warning" }, + _( + "Certificate pin is unavailable. Apply or restart the server to generate the default certificate, then open this dialog again.", + ), + ) + : ""; + + return E("div", { class: "fkp-server-info-modal__container" }, [ + // Left column: QR code + E("div", { class: "fkp-server-info-modal__qr-col" }, [ + qr + ? E("img", { + class: "fkp-server-info-modal__qr-image", + src: qr, + alt: _("Client link QR code"), + }) + : E("em", {}, _("QR code is too large")), + ]), + + // Right column: Details and Copy Box + E("div", { class: "fkp-server-info-modal__details-col" }, [ + E("div", { class: "fkp-server-info-modal__details-grid" }, [ + E("div", { class: "fkp-server-info-modal__detail-item" }, [ + E( + "span", + { class: "fkp-server-info-modal__detail-label" }, + _("Server name"), + ), + E( + "span", + { + class: + "fkp-server-info-modal__detail-value fkp-server-info-modal__detail-value--title", + }, + name, + ), + ]), + E("div", { class: "fkp-server-info-modal__detail-item" }, [ + E( + "span", + { class: "fkp-server-info-modal__detail-label" }, + _("Type"), + ), + E( + "span", + { + class: + "fkp-server-info-modal__detail-value fkp-server-info-modal__badge", + }, + protocolText, + ), + ]), + E("div", { class: "fkp-server-info-modal__detail-item" }, [ + E( + "span", + { class: "fkp-server-info-modal__detail-label" }, + _("Address"), + ), + E( + "span", + { + class: + "fkp-server-info-modal__detail-value fkp-server-info-modal__detail-value--mono", + }, + `${host}:${port}`, + ), + ]), + ]), + + E("div", { class: "fkp-server-info-modal__link-section" }, [ + E( + "span", + { class: "fkp-server-info-modal__detail-label" }, + _("Client link"), + ), + certificatePinWarning, + E( + "textarea", + { + class: "cbi-input-textarea fkp-server-info-modal__link", + readonly: "readonly", + rows: 3, + click: (ev) => ev.currentTarget.select(), + }, + link, + ), + E( + "button", + { + class: + "btn cbi-button cbi-button-neutral fkp-server-info-modal__copy-btn", + type: "button", + click: (ev) => { + ev.preventDefault(); + copyText(link); + }, + }, + [iconSvg("copy"), " ", E("span", {}, _("Copy"))], + ), + ]), + ]), + ]); +} + +function showServerInfoModal(sectionId) { + const modalContent = E("div", { class: "fkp-server-info-modal" }, [ + E("em", {}, _("Loading client link...")), + ]); + + ui.showModal( + _("Server information"), + [ + modalContent, + E("div", { class: "button-row" }, [ + E( + "button", + { + class: "btn cbi-button cbi-button-neutral", + type: "button", + click: () => ui.hideModal(), + }, + _("Close"), + ), + ]), + ], + "cbi-modal", + ); + + buildClientLinkContext(sectionId) + .then((linkContext) => { + modalContent.innerHTML = ""; + modalContent.appendChild(renderInfoClientLink(sectionId, linkContext)); + }) + .catch((error) => { + console.warn(error?.message || "Failed to build client link", error); + modalContent.innerHTML = ""; + modalContent.appendChild(renderInfoClientLink(sectionId)); + }); +} + +function getServerEditButtonText() { + const label = _("Edit rule action"); + + return label === "Edit rule action" ? "Edit" : label; +} + +function renderServerRowActions(sectionRef, sectionId) { + const editLabel = getServerEditButtonText(); + const deleteLabel = _("Delete"); + const infoLabel = _("Information"); + const tdEl = E( + "td", + { class: "td cbi-section-table-cell nowrap cbi-section-actions" }, + E("div"), + ); + const actionsEl = tdEl.lastElementChild; + const actionButtons = []; + + if (!["tailscale", "json_inbound"].includes(getProtocol(sectionId))) { + actionButtons.push( + E( + "button", + { + title: infoLabel, + "aria-label": infoLabel, + class: "cbi-button center fkp-server-icon-button", + style: "display:inline-block;", + click: (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + showServerInfoModal(sectionId); + }, + }, + E("span", { class: "fkp-server-icon-button__glyph" }, "\u24D8"), + ), + ); + } + + dom.append(actionsEl, [ + ...actionButtons, + E( + "button", + { + title: editLabel, + class: "btn cbi-button cbi-button-edit", + click: ui.createHandlerFn( + sectionRef, + "renderMoreOptionsModal", + sectionId, + ), + }, + editLabel, + ), + ]); + + if (sectionRef.addremove) { + dom.append( + actionsEl, + E( + "button", + { + title: deleteLabel, + class: "btn cbi-button cbi-button-remove", + click: ui.createHandlerFn(sectionRef, "handleRemove", sectionId), + disabled: sectionRef.map.readonly || null, + }, + deleteLabel, + ), + ); + } + + return tdEl; +} + +function validatePort(_sectionId, value) { + const port = parseInt(value, 10); + if (!/^[0-9]+$/.test(value) || port < 1 || port > 65535) { + return _("Port must be between 1 and 65535"); + } + return true; +} + +function isEmptyValue(value) { + return ( + value == null || + value === "" || + (Array.isArray(value) && + value.filter((item) => item != null && item !== "").length === 0) + ); +} + +function validateRequired(_sectionId, value) { + return isEmptyValue(value) ? _("This field is required") : true; +} + +function validateRequiredText(_sectionId, value) { + if (isEmptyValue(value)) { + return _("This field is required"); + } + + return /[\u0000-\u001F\u007F]/.test(`${value}`) + ? _("Value must not contain control characters") + : true; +} + +function isRoutingSectionAction(action) { + return ROUTING_SECTION_ACTIONS.indexOf(`${action || ""}`) !== -1; +} + +function loadRoutingSectionChoices(option) { + const sections = option.map?.data?.state?.values?.[UCI_PACKAGE] ?? {}; + + option.keylist = []; + option.vallist = []; + + for (const sectionName in sections) { + const section = sections[sectionName]; + + if ( + section[".type"] === "section" && + section.enabled !== "0" && + isRoutingSectionAction(section.action) + ) { + option.keylist.push(sectionName); + option.vallist.push(section.label || sectionName); + } + } + + return Promise.resolve(); +} + +function loadServerTableOptions(sectionRef) { + const sectionIds = sectionRef.cfgsections(); + const tasks = []; + + for (let i = 0; i < sectionIds.length; i += 1) { + const sectionId = sectionIds[i]; + + for (let j = 0; j < sectionRef.children.length; j += 1) { + const option = sectionRef.children[j]; + + if (option.disable || option.modalonly) { + continue; + } + + tasks.push( + Promise.resolve(option.load.call(option, sectionId)).then((value) => { + option.cfgvalue(sectionId, value); + }), + ); + } + } + + return Promise.all(tasks); +} + +function validateHost(_sectionId, value) { + return isHost(value) ? true : _("Use a domain name or IP address"); +} + +function validateOptionalHost(_sectionId, value) { + return isEmptyValue(value) || isHost(value) + ? true + : _("Use a domain name or IP address"); +} + +function validateListenAddress(_sectionId, value) { + const address = normalizeHost(value); + return address === "0.0.0.0" || + address === "::" || + isIpv4(address) || + isIpv6(address) + ? true + : _("Use an IP listen address"); +} + +function validateOptionalIp(_sectionId, value) { + const address = normalizeHost(value); + return isEmptyValue(value) || isIpv4(address) || isIpv6(address) + ? true + : _("Use an IP address"); +} + +function validateFilePath(_sectionId, value) { + const path = `${value || ""}`; + + if (isEmptyValue(path)) { + return _("This field is required"); + } + + const validation = main.validatePath(path); + return validation.valid && path !== "/" && !path.endsWith("/") + ? true + : _("Specify a file path"); +} + +function validateTlsCertificatePath(sectionId, value) { + const result = validateFilePath(sectionId, value); + if (result !== true) { + return result; + } + + return `${value}` === + `${uci.get(UCI_PACKAGE, sectionId, "tls_key_path") || ""}` + ? _("Specify different files") + : true; +} + +function validateTlsKeyPath(sectionId, value) { + const result = validateFilePath(sectionId, value); + if (result !== true) { + return result; + } + + return `${value}` === + `${uci.get(UCI_PACKAGE, sectionId, "tls_certificate_path") || ""}` + ? _("Specify different files") + : true; +} + +function validateHttpUrl(_sectionId, value) { + try { + const url = new URL(`${value || ""}`); + return (url.protocol === "http:" || url.protocol === "https:") && + isHost(url.hostname) + ? true + : _("Use an HTTP or HTTPS URL"); + } catch (_err) { + return _("Use an HTTP or HTTPS URL"); + } +} + +function validateInboundJson(_sectionId, value) { + if (isEmptyValue(value)) { + return _("Inbound JSON cannot be empty"); + } + + try { + const parsed = JSON.parse(`${value}`); + return parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + typeof parsed.type === "string" && + parsed.type + ? true + : _("Inbound JSON must be an object with a type field"); + } catch (_err) { + return _("Invalid JSON format"); + } +} + +function validateDurationValue(value) { + return /^([0-9]+(ns|us|ms|s|m|h))+$/.test(`${value || ""}`); +} + +function validateRequiredDuration(_sectionId, value) { + return validateDurationValue(value) + ? true + : _("Use a duration like 30s, 5m, or 1h30m"); +} + +function validateOptionalNonNegativeInteger(_sectionId, value) { + return isEmptyValue(value) || /^[0-9]+$/.test(`${value}`) + ? true + : _("Use a non-negative integer"); +} + +function validateTransportPath(_sectionId, value) { + return isEmptyValue(value) || `${value}`.startsWith("/") + ? true + : _("Transport path must start with /"); +} + +function validateOptionalHostList(_sectionId, value) { + const values = Array.isArray(value) ? value : [value]; + const invalid = values + .filter((item) => item != null && item !== "") + .some((item) => !isHost(item)); + + return invalid ? _("Use a domain name or IP address") : true; +} + +function validateGrpcServiceName(_sectionId, value) { + return isEmptyValue(value) || /^[A-Za-z0-9_.-]+$/.test(`${value}`) + ? true + : _("Use letters, digits, dots, underscores, or hyphens"); +} + +function isIpv6Literal(value) { + return isIpv6(value); +} + +function normalizeCidrOrIp(value) { + const normalized = `${value || ""}`.trim(); + if (!normalized.includes("/") && isIpv4(normalized)) { + return `${normalized}/32`; + } + if (!normalized.includes("/") && isIpv6Literal(normalized)) { + return `${normalized}/128`; + } + return normalized; +} + +function validateOptionalCidr(value) { + if (isEmptyValue(value)) { + return true; + } + + const values = Array.isArray(value) ? value : [value]; + const invalid = values + .filter((item) => item != null && item !== "") + .some((item) => { + const [address, prefix, extra] = `${item}`.split("/"); + if (extra != null || prefix == null || !/^[0-9]+$/.test(prefix)) { + return true; + } + const prefixNumber = parseInt(prefix, 10); + if (isIpv4(address)) { + return prefixNumber < 0 || prefixNumber > 32; + } + if (isIpv6Literal(address)) { + return prefixNumber < 0 || prefixNumber > 128; + } + return true; + }); + + return invalid + ? _("Use CIDR prefixes like 192.168.1.0/24 or 2001:db8::/32") + : true; +} + +function validateOptionalCidrOrIp(value) { + if (isEmptyValue(value)) { + return true; + } + const values = Array.isArray(value) ? value : [value]; + const invalid = values + .filter((item) => item != null && item !== "") + .some((item) => validateOptionalCidr(normalizeCidrOrIp(item)) !== true); + + return invalid + ? _( + "Use CIDR prefixes or IP addresses like 192.168.1.0/24, 192.168.1.10, or 2001:db8::1", + ) + : true; +} + +function validateShortId(_sectionId, value) { + if (isEmptyValue(value)) { + return _("This field is required"); + } + + const values = Array.isArray(value) ? value : [value]; + const invalid = values + .filter((item) => item != null && item !== "") + .some((item) => !/^[0-9a-fA-F]{1,8}$/.test(`${item}`)); + + if (invalid) { + return _("Reality short ID must contain 1-8 hex digits"); + } + return true; +} + +function validateMtprotoSecret(_sectionId, value) { + const secret = normalizeMtprotoBaseSecret(value); + + if (!secret) { + return _("Use 32 hex characters"); + } + if (secret === "00000000000000000000000000000000") { + return _("Use a non-zero secret"); + } + + return true; +} + +function validateSecurity(sectionId, value) { + const protocol = getProtocol(sectionId); + const selectedSecurity = value || getDefaultSecurity(protocol); + const supportedSecurity = SECURITY_BY_PROTOCOL[protocol] || ["none"]; + + return supportedSecurity.includes(selectedSecurity) + ? true + : _("Unsupported security mode for this protocol"); +} + +function getWidget(sectionId, option) { + return document.getElementById( + `widget.cbid.${UCI_PACKAGE}.${sectionId}.${option}`, + ); +} + +function getControlWidget(sectionId, option) { + const widget = getWidget(sectionId, option); + + if (!widget) { + return null; + } + + if (/^(INPUT|SELECT|TEXTAREA)$/.test(widget.tagName)) { + return widget; + } + + return typeof widget.querySelector === "function" + ? widget.querySelector("input:not([type='hidden']), select, textarea") + : null; +} + +function getSelectWidget(sectionId, option) { + const widget = getControlWidget(sectionId, option); + + return widget && widget.tagName === "SELECT" ? widget : null; +} + +function setWidgetValue(sectionId, option, value) { + const widget = getControlWidget(sectionId, option); + + if (!widget) { + uci.set(UCI_PACKAGE, sectionId, option, value); + return; + } + + widget.value = value; + uci.set(UCI_PACKAGE, sectionId, option, value); + widget.dispatchEvent(new Event("input", { bubbles: true })); + widget.dispatchEvent(new Event("change", { bubbles: true })); +} + +function renderSecurityChoices(select, protocol, selectedSecurity) { + select.replaceChildren( + ...(SECURITY_BY_PROTOCOL[protocol] || ["none"]).map((security) => + E( + "option", + { + value: security, + selected: security === selectedSecurity ? "" : null, + }, + getSecurityLabel(security), + ), + ), + ); +} + +function syncSecurityChoices(sectionId) { + const protocol = getProtocol(sectionId); + const select = getSelectWidget(sectionId, "security"); + const selectedSecurity = + uci.get(UCI_PACKAGE, sectionId, "security") || getDefaultSecurity(protocol); + const nextSecurity = + validateSecurity(sectionId, selectedSecurity) === true + ? selectedSecurity + : getDefaultSecurity(protocol); + + if (select) { + renderSecurityChoices(select, protocol, nextSecurity); + if ( + Array.from(select.options).some((option) => option.value === nextSecurity) + ) { + select.value = nextSecurity; + } + } + + if (selectedSecurity !== nextSecurity) { + uci.set(UCI_PACKAGE, sectionId, "security", nextSecurity); + } + + if (select) { + select.dispatchEvent(new Event("input", { bubbles: true })); + select.dispatchEvent(new Event("change", { bubbles: true })); + } +} + +function getModalNode() { + return document.querySelector( + "body.modal-overlay-active > #modal_overlay > .modal.cbi-modal", + ); +} + +function clearServerValidationMessage() { + const modal = getModalNode(); + + if (modal) { + modal + .querySelectorAll(".fkp-server-validation-summary") + .forEach((node) => node.remove()); + } +} + +function showServerValidationMessage(error) { + const modal = getModalNode(); + + if (!modal) { + return; + } + + clearServerValidationMessage(); + + const message = error?.message || ""; + const summary = E( + "div", + { + class: "alert-message warning fkp-server-validation-summary", + }, + [ + E("strong", {}, _("Cannot save server")), + E("div", {}, _("Fix the highlighted fields and save again.")), + message ? E("small", {}, message) : "", + ], + ); + const buttonRow = modal.querySelector(".button-row"); + modal.insertBefore(summary, buttonRow || modal.firstChild); + + const invalidInput = modal.querySelector(".cbi-input-invalid"); + if (invalidInput) { + invalidInput.scrollIntoView({ block: "center", behavior: "smooth" }); + invalidInput.focus({ preventScroll: true }); + } +} + +function hasRealityKeypair(sectionId) { + return ( + !!uci.get(UCI_PACKAGE, sectionId, "reality_private_key") && + !!uci.get(UCI_PACKAGE, sectionId, "reality_public_key") + ); +} + +function generateRealityKeypair(sectionId) { + return fs + .exec("/usr/bin/tachyon", ["generate_reality_keypair"]) + .then((response) => { + if ((response.code ?? 0) !== 0 || !response.stdout) { + throw new Error(response.stderr || response.stdout || ""); + } + + const data = JSON.parse(response.stdout); + if (!data.success || !data.private_key || !data.public_key) { + throw new Error(data.message || ""); + } + + setWidgetValue(sectionId, "reality_private_key", data.private_key); + setWidgetValue(sectionId, "reality_public_key", data.public_key); + }) + .catch((error) => { + console.warn( + error?.message || "Failed to generate Reality key pair", + error, + ); + }); +} + +function generateRealityKeypairIfMissing(sectionId) { + return hasRealityKeypair(sectionId) + ? Promise.resolve() + : generateRealityKeypair(sectionId); +} + +function shouldGenerateRealityKeypair(sectionId) { + return ( + getProtocol(sectionId) === "vless" && + getEffectiveSecurity(sectionId) === "reality" + ); +} + +function prepareServerModal(sectionId) { + return shouldGenerateRealityKeypair(sectionId) + ? generateRealityKeypairIfMissing(sectionId) + : Promise.resolve(); +} + +function addStreamDepends(option) { + STREAM_PROTOCOLS.forEach((protocol) => option.depends("protocol", protocol)); +} + +function addPortProtocolDepends(option, singBoxExtended) { + PORT_PROTOCOLS.forEach((protocol) => option.depends("protocol", protocol)); + if (singBoxExtended) { + EXTENDED_PORT_PROTOCOLS.forEach((protocol) => + option.depends("protocol", protocol), + ); + } +} + +function addTlsDepends(option) { + option.depends({ protocol: "vless", security: "tls" }); + option.depends({ protocol: "vmess", security: "tls" }); + option.depends({ protocol: "trojan", security: "tls" }); + option.depends("protocol", "hysteria2"); +} + +function addRealityDepends(option) { + option.depends({ protocol: "vless", security: "reality" }); +} + +function addTailscaleDepends(option, singBoxTailscale) { + option.depends( + "protocol", + singBoxTailscale ? "tailscale" : "__unsupported_tailscale__", + ); +} + +function addMtprotoDepends(option) { + option.depends("protocol", "mtproto"); +} + +function configureServerTextareaOption(option) { + const originalRenderWidget = option.renderWidget; + + option.renderWidget = function (sectionId, optionIndex, cfgvalue) { + const node = originalRenderWidget.call( + this, + sectionId, + optionIndex, + cfgvalue, + ); + const textarea = + node && typeof node.querySelector === "function" + ? node.querySelector("textarea") + : node; + + if (textarea) { + textarea.setAttribute("spellcheck", "false"); + textarea.setAttribute("autocomplete", "off"); + textarea.setAttribute("autocorrect", "off"); + textarea.setAttribute("autocapitalize", "off"); + textarea.setAttribute("data-gramm", "false"); + textarea.setAttribute("data-gramm_editor", "false"); + textarea.setAttribute("data-enable-grammarly", "false"); + textarea.style.resize = "vertical"; + textarea.style.maxWidth = "100%"; + } + + return node; + }; +} + +function configureServerSection(sectionRef, options = {}) { + sectionRef.sortable = false; + sectionRef.nodescriptions = true; + sectionRef.load = function () { + // The main page table renders only non-modal fields; full server defaults + // are loaded by LuCI's cloned modal map when the user opens Add/Edit. + return loadServerTableOptions(this); + }; + + sectionRef.renderMoreOptionsModal = function (sectionId, ev) { + this.serverEditSnapshots ??= {}; + this.serverEditSnapshots[sectionId] = captureServerEditState(sectionId); + + const capabilitiesTask = + typeof options.loadCapabilities === "function" + ? options.loadCapabilities() + : Promise.resolve(); + + return Promise.resolve(capabilitiesTask) + .then(() => prepareServerModal(sectionId)) + .then(() => + form.GridSection.prototype.renderMoreOptionsModal.call( + this, + sectionId, + ev, + ), + ); + }; + + sectionRef.handleAdd = function (_ev, name) { + const configName = this.uciconfig ?? this.map.config; + const sectionId = this.map.data.add(configName, this.sectiontype, name); + const mapNode = this.getPreviousModalMap(); + const prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map; + + prevMap.addedSection = sectionId; + + const capabilitiesTask = + typeof options.loadCapabilities === "function" + ? options.loadCapabilities() + : Promise.resolve(this.serverCapabilities); + + return Promise.resolve(capabilitiesTask).then((capabilities) => { + if (capabilities) { + this.serverCapabilities = normalizeServerCapabilities(capabilities); + } + + ensureProtocolDefaults( + sectionId, + getDefaultProtocolForCapabilities(this.serverCapabilities), + true, + ); + + loadDefaultPublicHost() + .then((host) => applyDefaultPublicHost(sectionId, host)) + .catch(() => null); + + return this.renderMoreOptionsModal(sectionId); + }); + }; + + sectionRef.handleModalCancel = function (modalMap, ev, isSaving) { + const sectionId = modalMap?.section; + const snapshot = sectionId ? this.serverEditSnapshots?.[sectionId] : null; + + if (sectionId && this.serverEditSnapshots) { + delete this.serverEditSnapshots[sectionId]; + } + + if (!isSaving) { + restoreServerEditState(snapshot); + } + + return form.GridSection.prototype.handleModalCancel.call( + this, + modalMap, + ev, + isSaving, + ); + }; + + sectionRef.handleModalSave = function (modalMap, ev) { + clearServerValidationMessage(); + + const mapNode = this.getActiveModalMap(); + const activeMap = dom.findClassInstance(mapNode); + + return activeMap + .parse() + .then(() => + form.GridSection.prototype.handleModalSave.call(this, modalMap, ev), + ) + .catch((error) => showServerValidationMessage(error)); + }; + + sectionRef.renderRowActions = function (sectionId) { + return renderServerRowActions(this, sectionId); + }; +} + +function createServerContent(section, options = {}) { + injectServerStyles(); + const capabilities = normalizeServerCapabilities(options); + const singBoxExtended = capabilities.singBoxExtended; + const singBoxTailscale = capabilities.singBoxTailscale; + section.serverCapabilityOptions = {}; + section.serverCapabilities = capabilities; + + let o = section.option(form.Flag, "enabled", _("Enable")); + o.default = "1"; + o.rmempty = false; + o.editable = true; + o.width = "6rem"; + + o = section.option(form.DummyValue, "_protocol_display", _("Protocol")); + o.rawhtml = true; + o.modalonly = false; + o.cfgvalue = function (sectionId) { + return getProtocolLabel(getProtocol(sectionId)); + }; + o.textvalue = function (sectionId) { + return getProtocolLabel(getProtocol(sectionId)); + }; + o.width = "7rem"; + + o = section.option(form.Value, "label", _("Server name")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequiredText; + o.onchange = function (_ev, sectionId, value) { + const previousLabel = getServerName(sectionId); + const currentUsername = + uci.get(UCI_PACKAGE, sectionId, "server_username") || ""; + + uci.set(UCI_PACKAGE, sectionId, "label", value); + + if ( + getProtocol(sectionId) === "socks" && + (!currentUsername || + currentUsername === previousLabel || + currentUsername === sectionId) + ) { + const nextUsername = value || sectionId; + uci.set(UCI_PACKAGE, sectionId, "server_username", nextUsername); + setWidgetValue(sectionId, "server_username", nextUsername); + } + }; + + o = section.option(form.ListValue, "protocol", _("Protocol")); + section.serverCapabilityOptions.protocol = o; + populateProtocolValues(o, capabilities); + o.default = getDefaultProtocolForCapabilities(capabilities); + o.rmempty = false; + o.modalonly = true; + o.validate = validateRequired; + o.onchange = function (_ev, sectionId, value) { + ensureProtocolDefaults(sectionId, value, true); + syncSecurityChoices(sectionId); + if (value === "vless" && getEffectiveSecurity(sectionId) === "reality") { + generateRealityKeypairIfMissing(sectionId); + } + }; + + o = section.option(form.Value, "listen", _("Listen address")); + o.default = "0.0.0.0"; + o.rmempty = false; + o.modalonly = true; + o.validate = validateListenAddress; + addPortProtocolDepends(o, true); + + o = section.option(form.Value, "listen_port", _("Listen port")); + o.default = "443"; + o.rmempty = false; + o.modalonly = true; + o.validate = validatePort; + addPortProtocolDepends(o, true); + + o = section.option(form.Value, "public_host", _("Public host")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateHost; + addPortProtocolDepends(o, true); + o.load = function (sectionId) { + return loadDefaultPublicHost().then((host) => { + applyDefaultPublicHost(sectionId, host); + return uci.get(UCI_PACKAGE, sectionId, "public_host") || host || ""; + }); + }; + + o = section.option(form.ListValue, "routing_mode", _("Routing mode")); + o.value("rules", _("Rules")); + o.value("direct", _("Direct")); + o.value("section", _("Selected section")); + o.default = "rules"; + o.rmempty = false; + o.modalonly = true; + o.validate = validateRequired; + + o = section.option(form.ListValue, "routing_section", _("Routing section")); + o.depends("routing_mode", "section"); + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequired; + o.cfgvalue = function (sectionId) { + return uci.get(UCI_PACKAGE, sectionId, "routing_section"); + }; + o.load = function () { + return loadRoutingSectionChoices(this); + }; + + o = section.option( + form.TextValue, + "inbound_json", + _("Inbound JSON"), + _("Enter a complete sing-box inbound object"), + ); + o.depends("protocol", "json_inbound"); + o.rows = 10; + o.wrap = "soft"; + o.textarea = true; + o.modalonly = true; + o.rmempty = false; + o.validate = validateInboundJson; + configureServerTextareaOption(o); + + o = section.option(form.ListValue, "security", _("Security")); + o.value("reality", "Reality"); + o.value("tls", "TLS"); + o.value("none", _("None")); + o.default = "reality"; + o.rmempty = false; + o.modalonly = true; + addStreamDepends(o); + o.depends("protocol", "hysteria2"); + o.validate = validateSecurity; + o.onchange = function (_ev, sectionId, value) { + if (value === "reality") { + generateRealityKeypairIfMissing(sectionId); + } + }; + { + const originalRenderWidget = o.renderWidget; + o.renderWidget = function (sectionId, optionIndex, cfgvalue) { + const node = originalRenderWidget.call( + this, + sectionId, + optionIndex, + cfgvalue, + ); + window.setTimeout(() => syncSecurityChoices(sectionId), 0); + return node; + }; + } + + o = section.option(form.Value, "tls_server_name", _("Server name / SNI")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateHost; + o.load = function (sectionId) { + const current = normalizeHost( + uci.get(UCI_PACKAGE, sectionId, "tls_server_name"), + ); + if (current) { + return current; + } + + const fallback = "www.microsoft.com"; + uci.set(UCI_PACKAGE, sectionId, "tls_server_name", fallback); + return fallback; + }; + addTlsDepends(o); + addRealityDepends(o); + + o = section.option(form.MultiValue, "tls_alpn", _("ALPN")); + o.value("h3", "h3"); + o.value("h2", "h2"); + o.value("http/1.1", "http/1.1"); + o.create = false; + o.rmempty = true; + o.modalonly = true; + addTlsDepends(o); + addRealityDepends(o); + + o = section.option(form.Value, "tls_certificate_path", _("Certificate path")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateTlsCertificatePath; + o.load = function (sectionId) { + const current = uci.get(UCI_PACKAGE, sectionId, "tls_certificate_path"); + if (current) { + return current; + } + const value = defaultTlsCertificatePath(sectionId); + uci.set(UCI_PACKAGE, sectionId, "tls_certificate_path", value); + return value; + }; + addTlsDepends(o); + + o = section.option(form.Value, "tls_key_path", _("Key path")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateTlsKeyPath; + o.load = function (sectionId) { + const current = uci.get(UCI_PACKAGE, sectionId, "tls_key_path"); + if (current) { + return current; + } + const value = defaultTlsKeyPath(sectionId); + uci.set(UCI_PACKAGE, sectionId, "tls_key_path", value); + return value; + }; + addTlsDepends(o); + + o = section.option( + form.Value, + "reality_handshake_server", + _("Reality fake site"), + ); + o.default = "www.microsoft.com"; + o.modalonly = true; + o.rmempty = false; + o.validate = validateHost; + addRealityDepends(o); + + o = section.option( + form.Value, + "reality_handshake_server_port", + _("Reality fake site port"), + ); + o.default = "443"; + o.modalonly = true; + o.rmempty = false; + o.validate = validatePort; + addRealityDepends(o); + + o = section.option( + form.Value, + "reality_private_key", + _("Reality private key"), + ); + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequired; + addRealityDepends(o); + + o = section.option(form.Value, "reality_public_key", _("Reality public key")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequired; + addRealityDepends(o); + + o = section.option(form.Value, "reality_short_id", _("Reality short ID")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateShortId; + o.load = function (sectionId) { + const current = getFirstListValue(sectionId, "reality_short_id"); + if (current) { + uci.set(UCI_PACKAGE, sectionId, "reality_short_id", current); + return current; + } + const value = randomHex(4); + uci.set(UCI_PACKAGE, sectionId, "reality_short_id", value); + return value; + }; + addRealityDepends(o); + + o = section.option( + form.Value, + "reality_max_time_difference", + _("Reality max time difference"), + ); + o.default = "1m"; + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequiredDuration; + addRealityDepends(o); + + o = section.option( + form.ListValue, + "client_fingerprint", + _("Client fingerprint"), + ); + ["chrome", "firefox", "edge", "safari", "ios", "android", "random"].forEach( + (value) => o.value(value, value), + ); + o.default = "chrome"; + o.rmempty = false; + o.modalonly = true; + o.validate = validateRequired; + addRealityDepends(o); + + o = section.option(form.ListValue, "transport", _("Transport")); + section.serverCapabilityOptions.transport = o; + populateTransportValues(o, singBoxExtended); + o.default = "tcp"; + o.rmempty = false; + o.modalonly = true; + addStreamDepends(o); + + o = section.option(form.Value, "transport_path", _("Transport path")); + o.modalonly = true; + o.validate = validateTransportPath; + o.depends({ protocol: "vless", transport: "ws" }); + o.depends({ protocol: "vmess", transport: "ws" }); + o.depends({ protocol: "trojan", transport: "ws" }); + o.depends({ protocol: "vless", transport: "http" }); + o.depends({ protocol: "vmess", transport: "http" }); + o.depends({ protocol: "trojan", transport: "http" }); + o.depends({ protocol: "vless", transport: "httpupgrade" }); + o.depends({ protocol: "vmess", transport: "httpupgrade" }); + o.depends({ protocol: "trojan", transport: "httpupgrade" }); + o.depends({ protocol: "vless", transport: "xhttp" }); + o.depends({ protocol: "vmess", transport: "xhttp" }); + o.depends({ protocol: "trojan", transport: "xhttp" }); + + o = section.option(form.Value, "transport_host", _("Transport host")); + o.modalonly = true; + o.validate = validateOptionalHost; + o.depends({ protocol: "vless", transport: "ws" }); + o.depends({ protocol: "vmess", transport: "ws" }); + o.depends({ protocol: "trojan", transport: "ws" }); + o.depends({ protocol: "vless", transport: "httpupgrade" }); + o.depends({ protocol: "vmess", transport: "httpupgrade" }); + o.depends({ protocol: "trojan", transport: "httpupgrade" }); + o.depends({ protocol: "vless", transport: "xhttp" }); + o.depends({ protocol: "vmess", transport: "xhttp" }); + o.depends({ protocol: "trojan", transport: "xhttp" }); + + o = section.option(form.ListValue, "transport_xhttp_mode", _("XHTTP mode")); + o.value("auto", "auto"); + o.value("packet-up", "packet-up"); + o.value("stream-up", "stream-up"); + o.value("stream-one", "stream-one"); + o.default = "auto"; + o.rmempty = false; + o.modalonly = true; + o.depends({ protocol: "vless", transport: "xhttp" }); + o.depends({ protocol: "vmess", transport: "xhttp" }); + o.depends({ protocol: "trojan", transport: "xhttp" }); + + o = section.option(form.DynamicList, "transport_hosts", _("HTTP hosts")); + o.modalonly = true; + o.validate = validateOptionalHostList; + o.depends({ protocol: "vless", transport: "http" }); + o.depends({ protocol: "vmess", transport: "http" }); + o.depends({ protocol: "trojan", transport: "http" }); + + o = section.option(form.Value, "transport_service_name", _("gRPC service")); + o.modalonly = true; + o.validate = validateGrpcServiceName; + o.depends({ protocol: "vless", transport: "grpc" }); + o.depends({ protocol: "vmess", transport: "grpc" }); + o.depends({ protocol: "trojan", transport: "grpc" }); + + o = section.option(form.ListValue, "shadowsocks_method", _("Method")); + o.value("aes-128-gcm", "aes-128-gcm"); + o.value("aes-256-gcm", "aes-256-gcm"); + o.value("chacha20-ietf-poly1305", "chacha20-ietf-poly1305"); + o.default = "aes-128-gcm"; + o.rmempty = false; + o.modalonly = true; + o.validate = validateRequired; + o.depends("protocol", "shadowsocks"); + + o = section.option( + form.Flag, + "socks_auth_enabled", + _("Enable authentication"), + ); + o.default = "1"; + o.rmempty = false; + o.modalonly = true; + o.depends("protocol", "socks"); + + o = section.option(form.Value, "server_username", _("Username")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequiredText; + o.load = function (sectionId) { + const current = uci.get(UCI_PACKAGE, sectionId, "server_username"); + if (current) { + return current; + } + + const value = getServerName(sectionId); + uci.set(UCI_PACKAGE, sectionId, "server_username", value); + return value; + }; + o.depends({ protocol: "socks", socks_auth_enabled: "1" }); + + o = section.option(form.Value, "server_password", _("Password")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequiredText; + o.load = function (sectionId) { + const current = uci.get(UCI_PACKAGE, sectionId, "server_password"); + if (current) { + return current; + } + + const value = generatePassword(); + uci.set(UCI_PACKAGE, sectionId, "server_password", value); + return value; + }; + o.depends({ protocol: "socks", socks_auth_enabled: "1" }); + + o = section.option(form.Value, "vmess_alter_id", _("Alter ID")); + o.default = "0"; + o.rmempty = false; + o.modalonly = true; + o.depends("protocol", "vmess"); + o.validate = function (_sectionId, value) { + if (value == null || value === "") { + return _("This field is required"); + } + return /^[0-9]+$/.test(value) ? true : _("Use a non-negative integer"); + }; + + o = section.option(form.ListValue, "vless_flow", _("VLESS flow")); + o.value("none", _("None")); + o.value("xtls-rprx-vision", "xtls-rprx-vision"); + o.default = "none"; + o.rmempty = false; + o.modalonly = true; + o.validate = validateRequired; + o.depends("protocol", "vless"); + + o = section.option(form.ListValue, "hysteria2_obfs_type", _("Obfuscation")); + o.value("", _("None")); + o.value("salamander", "salamander"); + o.default = ""; + o.modalonly = true; + o.depends("protocol", "hysteria2"); + o.onchange = function (_ev, sectionId, value) { + if (value === "salamander") { + setDefault(sectionId, "hysteria2_obfs_password", generatePassword()); + const widget = getControlWidget(sectionId, "hysteria2_obfs_password"); + if (widget && !widget.value) { + setWidgetValue( + sectionId, + "hysteria2_obfs_password", + uci.get(UCI_PACKAGE, sectionId, "hysteria2_obfs_password"), + ); + } + } + }; + + o = section.option( + form.Value, + "hysteria2_obfs_password", + _("Obfuscation password"), + ); + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequired; + o.load = function (sectionId) { + const current = uci.get(UCI_PACKAGE, sectionId, "hysteria2_obfs_password"); + if (current) { + return current; + } + const value = generatePassword(); + uci.set(UCI_PACKAGE, sectionId, "hysteria2_obfs_password", value); + return value; + }; + o.depends({ protocol: "hysteria2", hysteria2_obfs_type: "salamander" }); + + o = section.option(form.Value, "mtproto_secret", _("Secret")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateMtprotoSecret; + o.load = function (sectionId) { + const current = uci.get(UCI_PACKAGE, sectionId, "mtproto_secret"); + const baseSecret = normalizeMtprotoBaseSecret(current); + const faketls = getMtprotoFaketlsFromSecret(current); + if (baseSecret) { + uci.set(UCI_PACKAGE, sectionId, "mtproto_secret", baseSecret); + if (faketls && !uci.get(UCI_PACKAGE, sectionId, "mtproto_faketls")) { + uci.set(UCI_PACKAGE, sectionId, "mtproto_faketls", faketls); + } + return baseSecret; + } + if (current) { + return current; + } + const value = generateMtprotoSecret(); + uci.set(UCI_PACKAGE, sectionId, "mtproto_secret", value); + return value; + }; + addMtprotoDepends(o); + + o = section.option(form.Value, "mtproto_faketls", _("FakeTLS host")); + o.default = "google.com"; + o.modalonly = true; + o.rmempty = false; + o.validate = validateHost; + o.load = function (sectionId) { + const current = uci.get(UCI_PACKAGE, sectionId, "mtproto_faketls"); + if (current) { + return current; + } + const faketls = + getMtprotoFaketlsFromSecret( + uci.get(UCI_PACKAGE, sectionId, "mtproto_secret"), + ) || "google.com"; + uci.set(UCI_PACKAGE, sectionId, "mtproto_faketls", faketls); + return faketls; + }; + addMtprotoDepends(o); + + o = section.option(form.Flag, "mtproto_padding", _("Padding")); + o.default = "1"; + o.modalonly = true; + o.rmempty = false; + addMtprotoDepends(o); + + o = section.option(form.Value, "mtproto_concurrency", _("Concurrency")); + o.modalonly = true; + o.rmempty = true; + o.validate = validateOptionalNonNegativeInteger; + addMtprotoDepends(o); + + o = section.option( + form.Value, + "mtproto_domain_fronting_port", + _("Fronting port"), + ); + o.default = "443"; + o.modalonly = true; + o.rmempty = false; + o.validate = validatePort; + addMtprotoDepends(o); + + o = section.option( + form.Value, + "mtproto_domain_fronting_ip", + _("Fronting IP"), + ); + o.modalonly = true; + o.rmempty = true; + o.validate = validateOptionalIp; + addMtprotoDepends(o); + + o = section.option( + form.Flag, + "mtproto_domain_fronting_proxy_protocol", + _("Fronting proxy protocol"), + ); + o.default = "0"; + o.modalonly = true; + o.rmempty = true; + addMtprotoDepends(o); + + o = section.option(form.ListValue, "mtproto_prefer_ip", _("Preferred IP")); + o.value("prefer-ipv4", "prefer-ipv4"); + o.value("prefer-ipv6", "prefer-ipv6"); + o.value("only-ipv4", "only-ipv4"); + o.value("only-ipv6", "only-ipv6"); + o.default = "prefer-ipv4"; + o.rmempty = false; + o.modalonly = true; + o.validate = validateRequired; + addMtprotoDepends(o); + + o = section.option(form.Flag, "mtproto_auto_update", _("Auto update")); + o.default = "0"; + o.modalonly = true; + o.rmempty = true; + addMtprotoDepends(o); + + o = section.option( + form.Flag, + "mtproto_allow_fallback_on_unknown_dc", + _("Fallback on unknown DC"), + ); + o.default = "0"; + o.modalonly = true; + o.rmempty = true; + addMtprotoDepends(o); + + o = section.option( + form.Value, + "mtproto_tolerate_time_skewness", + _("Time skew tolerance"), + ); + o.default = "3s"; + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequiredDuration; + addMtprotoDepends(o); + + o = section.option(form.Value, "mtproto_idle_timeout", _("Idle timeout")); + o.default = "5m"; + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequiredDuration; + addMtprotoDepends(o); + + o = section.option( + form.Value, + "mtproto_handshake_timeout", + _("Handshake timeout"), + ); + o.default = "10s"; + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequiredDuration; + addMtprotoDepends(o); + + o = section.option(form.Value, "tailscale_auth_key", _("Tailscale auth key")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequired; + addTailscaleDepends(o, singBoxTailscale); + + o = section.option( + form.Value, + "tailscale_control_url", + _("Tailscale control URL"), + ); + o.default = "https://controlplane.tailscale.com"; + o.modalonly = true; + o.rmempty = false; + o.validate = validateHttpUrl; + addTailscaleDepends(o, singBoxTailscale); + + o = section.option(form.Value, "tailscale_hostname", _("Tailscale hostname")); + o.modalonly = true; + o.rmempty = false; + o.validate = validateRequiredText; + o.load = function (sectionId) { + const current = uci.get(UCI_PACKAGE, sectionId, "tailscale_hostname"); + if (current) { + return current; + } + const value = defaultTailscaleHostname(sectionId); + uci.set(UCI_PACKAGE, sectionId, "tailscale_hostname", value); + return value; + }; + addTailscaleDepends(o, singBoxTailscale); + + o = section.option( + form.Flag, + "tailscale_advertise_exit_node", + _("Advertise exit node"), + ); + o.default = "1"; + o.modalonly = true; + o.rmempty = false; + addTailscaleDepends(o, singBoxTailscale); + + o = section.option( + form.DynamicList, + "tailscale_advertise_routes", + _("Advertise routes"), + ); + o.modalonly = true; + o.rmempty = true; + o.validate = (_sectionId, value) => validateOptionalCidrOrIp(value); + addTailscaleDepends(o, singBoxTailscale); + + o = section.option(form.Flag, "tailscale_accept_routes", _("Accept routes")); + o.default = "0"; + o.modalonly = true; + o.rmempty = true; + addTailscaleDepends(o, singBoxTailscale); +} + +return baseclass.extend({ + applyServerCapabilities, + configureServerSection, + createServerContent, + preloadServerModalData, +}); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js new file mode 100644 index 00000000..b7ad46f1 --- /dev/null +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js @@ -0,0 +1,688 @@ +"use strict"; +"require form"; +"require uci"; +"require baseclass"; +"require tools.widgets as widgets"; +"require view.tachyon.main as main"; + +const UCI_PACKAGE = main.TACHYON_UCI_PACKAGE; + +function isSingBoxDuration(value) { + return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value); +} + +function latencyTestUrlChoices() { + return Array.isArray(main.LATENCY_TEST_URL_OPTIONS) + ? main.LATENCY_TEST_URL_OPTIONS + : [main.DEFAULT_LATENCY_TEST_URL || "https://www.gstatic.com/generate_204"]; +} + +function validateLatencyTestUrl(value) { + const validation = main.validateUrl(`${value || ""}`.trim()); + return validation.valid ? true : validation.message; +} + +function isDownloadSectionAction(action, capabilities) { + switch (action) { + case "connection": + case "proxy": + case "outbound": + case "vpn": + return true; + case "zapret": + return !capabilities?.loaded || Boolean(capabilities.zapretInstalled); + case "zapret2": + return !capabilities?.loaded || Boolean(capabilities.zapret2Installed); + case "byedpi": + return !capabilities?.loaded || Boolean(capabilities.byedpiInstalled); + default: + return false; + } +} + +function refreshDownloadSectionChoices(option, capabilities) { + const sections = option.map?.data?.state?.values?.[UCI_PACKAGE] ?? {}; + + option.keylist = []; + option.vallist = []; + + for (const secName in sections) { + const sec = sections[secName]; + if ( + sec[".type"] === "section" && + sec.enabled !== "0" && + isDownloadSectionAction(sec.action, capabilities) + ) { + option.value(secName, sec.label || secName); + } + } +} + +function configureDownloadSectionOption(option, sectionOption, capabilities) { + option.default = ""; + option.rmempty = false; + option.cfgvalue = function (section_id) { + return uci.get(UCI_PACKAGE, section_id, sectionOption) || ""; + }; + option.load = function (section_id) { + refreshDownloadSectionChoices(this, capabilities); + return this.cfgvalue(section_id); + }; + option.write = function (section_id, value) { + const normalized = value ? `${value}`.trim() : ""; + + if (normalized) { + uci.set(UCI_PACKAGE, section_id, sectionOption, normalized); + } else { + uci.unset(UCI_PACKAGE, section_id, sectionOption); + } + }; + option.remove = function (section_id) { + uci.unset(UCI_PACKAGE, section_id, sectionOption); + }; + option.validate = function (_section_id, value) { + return value ? true : _("Select a section"); + }; +} + +function configureDownloadViaProxyFlag(option, sectionOption) { + option.default = "0"; + option.rmempty = false; + option.write = function (section_id, value) { + const enabled = value === "1" || value === true; + uci.set(UCI_PACKAGE, section_id, this.option, enabled ? "1" : "0"); + if (!enabled) { + uci.unset(UCI_PACKAGE, section_id, sectionOption); + } + }; +} + +function optionListValues(option, section_id) { + const formValue = option.formvalue(section_id); + const value = formValue != null ? formValue : option.cfgvalue(section_id); + return L.toArray(value) + .map((item) => `${item || ""}`.trim()) + .filter(Boolean); +} + +function configureDnsList(option, choices, defaultValue) { + Object.entries(choices).forEach(([key, label]) => { + option.value(key, _(label)); + }); + option.default = [defaultValue]; + option.rmempty = false; + option.validate = function (_section_id, value) { + const normalized = `${value || ""}`.trim(); + if (!normalized) { + return optionListValues(option, _section_id).length > 0 + ? true + : _("Add at least one DNS server"); + } + const validation = main.validateDNS(normalized); + return validation.valid ? true : validation.message; + }; +} + +function configureDnsFailoverVisibility(option, dnsOption, bootstrapOption) { + option.depends("dns_server", "__tachyon_multiple_dns__"); + option.depends("bootstrap_dns_server", "__tachyon_multiple_dns__"); + option.retain = true; + option.checkDepends = function (section_id) { + return ( + optionListValues(dnsOption, section_id).length > 1 || + optionListValues(bootstrapOption, section_id).length > 1 + ); + }; +} + +function configureDnsDuration( + option, + defaultValue, + dnsOption, + bootstrapOption, +) { + option.default = defaultValue; + option.rmempty = false; + option.validate = function (_section_id, value) { + const normalized = `${value || ""}`.trim(); + if (!normalized || !isSingBoxDuration(normalized)) { + return _("Use sing-box duration format like 10s, 1m or 2m30s"); + } + return true; + }; + configureDnsFailoverVisibility(option, dnsOption, bootstrapOption); +} + +function createSettingsContent(section, capabilities) { + let o = section.option( + form.ListValue, + "dns_type", + _("DNS Protocol Type"), + _("Select DNS protocol to use"), + ); + o.value("doh", _("DNS over HTTPS (DoH)")); + o.value("dot", _("DNS over TLS (DoT)")); + o.value("udp", _("UDP (Unprotected DNS)")); + o.default = "udp"; + o.rmempty = false; + + const dnsOption = section.option( + form.DynamicList, + "dns_server", + _("DNS Servers"), + _( + "Main DNS server. If multiple servers are selected, a timeout switches to a backup.", + ), + ); + configureDnsList(dnsOption, main.DNS_SERVER_OPTIONS, "77.88.8.8"); + + const bootstrapOption = section.option( + form.DynamicList, + "bootstrap_dns_server", + _("Bootstrap DNS Servers"), + _( + "DNS server used to obtain IP addresses for upstream DNS and proxies. If multiple servers are selected, a timeout switches to a backup.", + ), + ); + configureDnsList( + bootstrapOption, + main.BOOTSTRAP_DNS_SERVER_OPTIONS, + "77.88.8.8", + ); + + o = section.option( + form.Value, + "dns_check_interval", + _("DNS Check Interval"), + _("How often to check the active DNS servers."), + ); + configureDnsDuration(o, "10s", dnsOption, bootstrapOption); + + o = section.option( + form.Value, + "dns_recovery_check_interval", + _("Higher-priority DNS Check"), + _("How often to check whether a higher-priority DNS server has recovered."), + ); + configureDnsDuration(o, "60s", dnsOption, bootstrapOption); + + o = section.option( + form.Value, + "dns_check_timeout", + _("DNS Unavailability Timeout"), + _( + "Maximum time to wait for example.com to resolve during a DNS health check.", + ), + ); + configureDnsDuration(o, "2s", dnsOption, bootstrapOption); + + o = section.option( + form.Value, + "dns_rewrite_ttl", + _("DNS Rewrite TTL"), + _("Time in seconds for DNS record caching (default: 60)"), + ); + o.default = "60"; + o.rmempty = false; + o.validate = function (section_id, value) { + if (!value) { + return _("TTL value cannot be empty"); + } + + const ttl = parseInt(value); + if (isNaN(ttl) || ttl < 0) { + return _("TTL must be a positive number"); + } + + return true; + }; + + o = section.option(form.ListValue, "dns_strategy", _("DNS Strategy")); + o.value("prefer_ipv4", _("Prefer IPv4")); + o.value("ipv4_only", _("IPv4 only")); + o.value("prefer_ipv6", _("Prefer IPv6")); + o.value("ipv6_only", _("IPv6 only")); + o.default = "prefer_ipv4"; + o.rmempty = false; + + o = section.option( + form.Flag, + "dns_detour_enabled", + _("DNS through proxy"), + _("Route main DNS requests through the selected section."), + ); + configureDownloadViaProxyFlag(o, "dns_detour_section"); + + o = section.option( + form.ListValue, + "dns_detour_section", + _("DNS requests through section"), + ); + o.depends("dns_detour_enabled", "1"); + configureDownloadSectionOption(o, "dns_detour_section", capabilities); + + o = section.option( + widgets.DeviceSelect, + "source_network_interfaces", + _("Source Network Interface"), + _("Select the network interface from which the traffic will originate"), + ); + o.default = "br-lan"; + o.noaliases = true; + o.nobridges = false; + o.noinactive = false; + o.multiple = true; + o.filter = function (section_id, value) { + // Block specific interface names from being selectable + const blocked = ["wan", "phy0-ap0", "phy1-ap0", "pppoe-wan"]; + if (blocked.includes(value)) { + return false; + } + + // Try to find the device object by its name + const device = this.devices.find((dev) => dev.getName() === value); + + // If no device is found, allow the value + if (!device) { + return true; + } + + // Check the type of the device + const type = device.getType(); + + // Consider any Wi-Fi / wireless / wlan device as invalid + const isWireless = + type === "wifi" || type === "wireless" || type.includes("wlan"); + + // Allow only non-wireless devices + return !isWireless; + }; + + o = section.option( + form.Flag, + "enable_output_network_interface", + _("Enable Output Network Interface"), + _("You can select Output Network Interface, by default autodetect"), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + widgets.DeviceSelect, + "output_network_interface", + _("Output Network Interface"), + _("Select the network interface to which the traffic will originate"), + ); + o.noaliases = true; + o.multiple = false; + o.depends("enable_output_network_interface", "1"); + o.filter = function (section_id, value) { + // Blocked interface names that should never be selectable + const blockedInterfaces = ["br-lan"]; + + // Reject immediately if the value matches any blocked interface + if (blockedInterfaces.includes(value)) { + return false; + } + + // Reject lan* + if (value.startsWith("lan")) { + return false; + } + + // Reject tun*, wg*, vpn*, awg*, oc* + if ( + value.startsWith("tun") || + value.startsWith("wg") || + value.startsWith("vpn") || + value.startsWith("awg") || + value.startsWith("oc") + ) { + return false; + } + + // Try to find the device object with the given name + const device = this.devices.find((dev) => dev.getName() === value); + + // If no device is found, allow the value + if (!device) { + return true; + } + + // Get the device type (e.g., "wifi", "ethernet", etc.) + const type = device.getType(); + + // Reject wireless-related devices + const isWireless = + type === "wifi" || type === "wireless" || type.includes("wlan"); + + return !isWireless; + }; + + o = section.option( + form.Flag, + "enable_badwan_interface_monitoring", + _("Interface Monitoring"), + _("Interface monitoring for Bad WAN"), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + widgets.NetworkSelect, + "badwan_monitored_interfaces", + _("Monitored Interfaces"), + _("Select the WAN interfaces to be monitored"), + ); + o.depends("enable_badwan_interface_monitoring", "1"); + o.multiple = true; + o.filter = function (section_id, value) { + // Reject if the value is in the blocked list ['lan', 'loopback'] + if (["lan", "loopback"].includes(value)) { + return false; + } + + // Reject if the value starts with '@' (means it's an alias/reference) + if (value.startsWith("@")) { + return false; + } + + // Otherwise allow it + return true; + }; + + o = section.option( + form.Value, + "badwan_reload_delay", + _("Interface Monitoring Delay"), + _("Delay in milliseconds before reloading Tachyon after interface UP"), + ); + o.depends("enable_badwan_interface_monitoring", "1"); + o.default = "2000"; + o.rmempty = false; + o.validate = function (section_id, value) { + if (!value) { + return _("Delay value cannot be empty"); + } + return true; + }; + + o = section.option( + form.Flag, + "enable_yacd", + _("Enable YACD"), + `${main.getClashUIUrl()}`, + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.Flag, + "enable_yacd_wan_access", + _("Enable YACD WAN Access"), + _( + "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall.", + ), + ); + o.depends("enable_yacd", "1"); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.Value, + "yacd_secret_key", + _("YACD Secret Key"), + _( + "Secret key for authenticating remote access to YACD when WAN access is enabled.", + ), + ); + o.depends("enable_yacd_wan_access", "1"); + o.rmempty = false; + + o = section.option( + form.Flag, + "disable_quic", + _("Disable QUIC"), + _( + "Disable the QUIC protocol to improve compatibility or fix issues with video streaming", + ), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.Flag, + "list_update_enabled", + _("Enable list updates"), + _("Enable automatic updates for remote lists and rule sets"), + ); + o.default = "1"; + o.rmempty = false; + + o = section.option( + form.Value, + "update_interval", + _("List Update Frequency"), + _("Use sing-box duration format like 1d, 12h or 30m"), + ); + o.depends("list_update_enabled", "1"); + o.placeholder = "1d"; + o.default = "1d"; + o.rmempty = false; + o.cfgvalue = function (section_id) { + return uci.get(UCI_PACKAGE, section_id, "update_interval") || "1d"; + }; + o.write = function (section_id, value) { + const normalized = value ? `${value}`.trim() : ""; + + if (normalized.length) { + uci.set(UCI_PACKAGE, section_id, "update_interval", normalized); + } else { + uci.set(UCI_PACKAGE, section_id, "update_interval", "1d"); + } + }; + o.validate = function (_section_id, value) { + const normalized = value ? `${value}`.trim() : ""; + + if (!normalized.length) { + return _("Use sing-box duration format like 1d, 12h or 30m"); + } + + if (isSingBoxDuration(normalized)) { + return true; + } + + return _("Use sing-box duration format like 1d, 12h or 30m"); + }; + + o = section.option( + form.Flag, + "component_update_check_enabled", + _("Automatic component update checks"), + _("Automatically check installed components for new versions"), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.Value, + "component_update_check_interval", + _("Component update check interval"), + _("Use sing-box duration format like 1d, 12h or 30m"), + ); + o.depends("component_update_check_enabled", "1"); + o.placeholder = "1d"; + o.default = "1d"; + o.rmempty = false; + o.cfgvalue = function (section_id) { + return ( + uci.get(UCI_PACKAGE, section_id, "component_update_check_interval") || + "1d" + ); + }; + o.write = function (section_id, value) { + const normalized = value ? `${value}`.trim() : ""; + uci.set( + UCI_PACKAGE, + section_id, + "component_update_check_interval", + normalized.length ? normalized : "1d", + ); + }; + o.validate = function (_section_id, value) { + const normalized = value ? `${value}`.trim() : ""; + + if (normalized.length && isSingBoxDuration(normalized)) { + return true; + } + + return _("Use sing-box duration format like 1d, 12h or 30m"); + }; + + o = section.option( + form.Value, + "latency_test_url", + _("Latency test URL"), + _( + "Default address for checking server availability and latency. URLTest uses its own address.", + ), + ); + latencyTestUrlChoices().forEach((value) => o.value(value)); + o.default = + main.DEFAULT_LATENCY_TEST_URL || "https://www.gstatic.com/generate_204"; + o.rmempty = false; + o.validate = function (_section_id, value) { + return validateLatencyTestUrl(value); + }; + + o = section.option( + form.Flag, + "download_lists_via_proxy", + _("Download lists through a section"), + _("Download remote lists and rule sets via the selected section"), + ); + configureDownloadViaProxyFlag(o, "download_lists_via_proxy_section"); + + o = section.option( + form.ListValue, + "download_lists_via_proxy_section", + _("Download lists through"), + ); + o.depends("download_lists_via_proxy", "1"); + configureDownloadSectionOption( + o, + "download_lists_via_proxy_section", + capabilities, + ); + + o = section.option( + form.Flag, + "download_components_via_proxy", + _("Download components through a section"), + _("Download component packages via the selected section"), + ); + configureDownloadViaProxyFlag(o, "download_components_via_proxy_section"); + + o = section.option( + form.ListValue, + "download_components_via_proxy_section", + _("Download components through"), + ); + o.depends("download_components_via_proxy", "1"); + configureDownloadSectionOption( + o, + "download_components_via_proxy_section", + capabilities, + ); + + o = section.option( + form.Flag, + "dont_touch_dhcp", + _("Dont Touch My DHCP!"), + _("Tachyon will not modify your DHCP configuration"), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.ListValue, + "config_path", + _("Config File Path"), + _( + "Select path for sing-box config file. Change this ONLY if you know what you are doing", + ), + ); + o.value("/etc/sing-box/config.json", "Flash (/etc/sing-box/config.json)"); + o.value("/tmp/sing-box/config.json", "RAM (/tmp/sing-box/config.json)"); + o.default = "/etc/sing-box/config.json"; + o.rmempty = false; + + o = section.option( + form.Value, + "cache_path", + _("Cache File Path"), + _( + "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing", + ), + ); + o.value("/tmp/sing-box/cache.db", "RAM (/tmp/sing-box/cache.db)"); + o.value( + "/usr/share/sing-box/cache.db", + "Flash (/usr/share/sing-box/cache.db)", + ); + o.default = "/tmp/sing-box/cache.db"; + o.rmempty = false; + o.validate = function (section_id, value) { + if (!value) { + return _("Cache file path cannot be empty"); + } + + if (!value.startsWith("/")) { + return _("Path must be absolute (start with /)"); + } + + if (!value.endsWith("cache.db")) { + return _("Path must end with cache.db"); + } + + const parts = value.split("/").filter(Boolean); + if (parts.length < 2) { + return _("Path must contain at least one directory (like /tmp/cache.db)"); + } + + return true; + }; + + o = section.option( + form.ListValue, + "log_level", + _("Log Level"), + _("Select the log level for sing-box"), + ); + o.value("trace", "Trace"); + o.value("debug", "Debug"); + o.value("info", "Info"); + o.value("warn", "Warn"); + o.value("error", "Error"); + o.value("fatal", "Fatal"); + o.value("panic", "Panic"); + o.default = "warn"; + o.rmempty = false; + + o = section.option( + form.Flag, + "exclude_ntp", + _("Exclude NTP"), + _( + "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN", + ), + ); + o.default = "0"; + o.rmempty = false; +} + +const EntryPoint = { + createSettingsContent, +}; + +return baseclass.extend(EntryPoint); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js new file mode 100644 index 00000000..b880dc2f --- /dev/null +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js @@ -0,0 +1,493 @@ +"use strict"; +"require view"; +"require form"; +"require baseclass"; +"require uci"; +"require ui"; +"require view.tachyon.main as main"; + +// Global settings +"require view.tachyon.settings as settings"; + +// Sections +"require view.tachyon.section as section"; + +// Server +"require view.tachyon.server as server"; + +// Dashboard +"require view.tachyon.dashboard as dashboard"; + +// Monitoring +"require view.tachyon.monitoring as monitoring"; + +// Diagnostic +"require view.tachyon.diagnostic as diagnostic"; + +// Updates +"require view.tachyon.updates as updates"; + +const UCI_PACKAGE = main.TACHYON_UCI_PACKAGE; + +function renderSectionAdd(sectionRef, extra_class) { + const el = form.GridSection.prototype.renderSectionAdd.apply(sectionRef, [ + extra_class, + ]); + const nameEl = el.querySelector(".cbi-section-create-name"); + + ui.addValidator( + nameEl, + "uciname", + true, + (value) => { + const button = el.querySelector(".cbi-section-create > .cbi-button-add"); + const uciconfig = sectionRef.uciconfig || sectionRef.map.config; + + if (!value) { + button.disabled = true; + return true; + } + + if (uci.get(uciconfig, value)) { + button.disabled = true; + return _("Expecting: %s").format(_("unique UCI identifier")); + } + + button.disabled = null; + return true; + }, + "blur", + "keyup", + ); + + return el; +} + +function getRuleEditButtonText() { + const label = _("Edit rule action"); + + return label === "Edit rule action" ? "Edit" : label; +} + +function configureGridSection(sectionRef, type, title, addTitle) { + sectionRef.anonymous = false; + sectionRef.addremove = true; + sectionRef.sortable = true; + sectionRef.rowcolors = true; + sectionRef.nodescriptions = true; + sectionRef.modaltitle = function (section_id) { + const label = uci.get(UCI_PACKAGE, section_id, "label"); + return section_id ? `${title}: ${label || section_id}` : addTitle; + }; + sectionRef.sectiontitle = function (section_id) { + return uci.get(UCI_PACKAGE, section_id, "label") || section_id; + }; + sectionRef.renderSectionAdd = function (extra_class) { + return renderSectionAdd(sectionRef, extra_class); + }; + + if (type === "section") { + sectionRef.renderRowActions = function (section_id) { + return form.TableSection.prototype.renderRowActions.call( + this, + section_id, + getRuleEditButtonText(), + ); + }; + } +} + +const EntryPoint = { + async render() { + main.injectGlobalStyles(); + const uiCapabilities = { + loaded: false, + singBoxExtended: false, + singBoxTiny: false, + singBoxTailscale: true, + zapretInstalled: false, + zapret2Installed: false, + byedpiInstalled: false, + serverInboundsEnabledCount: -1, + }; + let uiCapabilitiesPromise = null; + let serverSectionRef = null; + + const applyUiCapabilities = function () { + if (serverSectionRef) { + server.applyServerCapabilities(serverSectionRef, uiCapabilities); + } + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(main.TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT, { + detail: { + zapretInstalled: uiCapabilities.zapretInstalled, + zapret2Installed: uiCapabilities.zapret2Installed, + byedpiInstalled: uiCapabilities.byedpiInstalled, + }, + }), + ); + } + + if (main.store && typeof main.store.set === "function") { + const currentSystemInfo = main.store.get().diagnosticsSystemInfo; + main.store.set({ + diagnosticsSystemInfo: { + ...currentSystemInfo, + providerInfoLoaded: true, + sing_box_extended: uiCapabilities.singBoxExtended ? 1 : 0, + sing_box_tiny: uiCapabilities.singBoxTiny ? 1 : 0, + sing_box_tailscale: uiCapabilities.singBoxTailscale ? 1 : 0, + zapret_installed: uiCapabilities.zapretInstalled ? 1 : 0, + zapret2_installed: uiCapabilities.zapret2Installed ? 1 : 0, + byedpi_installed: uiCapabilities.byedpiInstalled ? 1 : 0, + server_inbounds_enabled_count: + uiCapabilities.serverInboundsEnabledCount, + zapret_version: uiCapabilities.zapretInstalled + ? currentSystemInfo.zapret_version + : "not installed", + zapret2_version: uiCapabilities.zapret2Installed + ? currentSystemInfo.zapret2_version + : "not installed", + byedpi_version: uiCapabilities.byedpiInstalled + ? currentSystemInfo.byedpi_version + : "not installed", + }, + }); + } + }; + + const updateUiCapabilities = function (data) { + uiCapabilities.loaded = true; + uiCapabilities.singBoxExtended = Boolean( + Number(data?.sing_box_extended) === 1, + ); + uiCapabilities.singBoxTiny = Boolean(Number(data?.sing_box_tiny) === 1); + uiCapabilities.singBoxTailscale = + typeof data?.sing_box_tailscale === "undefined" + ? true + : Boolean(Number(data.sing_box_tailscale) === 1); + uiCapabilities.zapretInstalled = Boolean( + Number(data?.zapret_installed) === 1, + ); + uiCapabilities.zapret2Installed = Boolean( + Number(data?.zapret2_installed) === 1, + ); + uiCapabilities.byedpiInstalled = Boolean( + Number(data?.byedpi_installed) === 1, + ); + const serverInboundsEnabledCount = + typeof data?.server_inbounds_enabled_count !== "undefined" + ? Number(data.server_inbounds_enabled_count) + : -1; + uiCapabilities.serverInboundsEnabledCount = Number.isFinite( + serverInboundsEnabledCount, + ) + ? serverInboundsEnabledCount + : -1; + + applyUiCapabilities(); + + return uiCapabilities; + }; + + const applyUiState = function (data) { + const result = updateUiCapabilities(data?.capabilities || data || {}); + + if (typeof main.applyUiStateToStore === "function" && data?.service) { + main.applyUiStateToStore(data); + } else if ( + main.store && + typeof main.store.set === "function" && + data?.service + ) { + main.store.set({ + servicesInfoWidget: { + loading: false, + failed: false, + data: { + singbox: Number(data.service.sing_box?.running) || 0, + tachyonRunning: Number(data.service.tachyon?.running) || 0, + tachyonEnabled: Number(data.service.tachyon?.enabled) || 0, + tachyonStatus: data.service.tachyon?.status || "", + }, + }, + }); + } + + return result; + }; + + const loadFallbackUiCapabilities = function () { + return Promise.allSettled([ + main.TachyonShellMethods.getServerCapabilities(), + main.TachyonShellMethods.checkZapretRuntime(), + main.TachyonShellMethods.checkZapret2Runtime(), + main.TachyonShellMethods.checkByedpiRuntime(), + main.TachyonShellMethods.checkInboundsConfig(), + ]).then( + ([ + serverCapabilitiesResult, + zapretRuntimeResult, + zapret2RuntimeResult, + byedpiRuntimeResult, + inboundsConfigResult, + ]) => { + const serverCapabilities = + serverCapabilitiesResult.status === "fulfilled" + ? serverCapabilitiesResult.value + : null; + const zapretRuntime = + zapretRuntimeResult.status === "fulfilled" + ? zapretRuntimeResult.value + : null; + const zapret2Runtime = + zapret2RuntimeResult.status === "fulfilled" + ? zapret2RuntimeResult.value + : null; + const byedpiRuntime = + byedpiRuntimeResult.status === "fulfilled" + ? byedpiRuntimeResult.value + : null; + const inboundsConfig = + inboundsConfigResult.status === "fulfilled" + ? inboundsConfigResult.value + : null; + + return updateUiCapabilities({ + sing_box_extended: + serverCapabilities?.success && + Number(serverCapabilities.data?.sing_box_extended) === 1 + ? 1 + : 0, + sing_box_tiny: + serverCapabilities?.success && + Number(serverCapabilities.data?.sing_box_tiny) === 1 + ? 1 + : 0, + sing_box_tailscale: + !serverCapabilities?.success || + typeof serverCapabilities.data?.sing_box_tailscale === + "undefined" || + Number(serverCapabilities.data?.sing_box_tailscale) === 1 + ? 1 + : 0, + zapret_installed: + zapretRuntime?.success && + Number(zapretRuntime.data?.zapret_installed) === 1 + ? 1 + : 0, + zapret2_installed: + zapret2Runtime?.success && + Number(zapret2Runtime.data?.zapret2_installed) === 1 + ? 1 + : 0, + byedpi_installed: + byedpiRuntime?.success && + Number(byedpiRuntime.data?.byedpi_installed) === 1 + ? 1 + : 0, + server_inbounds_enabled_count: + inboundsConfig?.success && + typeof inboundsConfig.data?.enabled_count !== "undefined" + ? inboundsConfig.data.enabled_count + : -1, + }); + }, + ); + }; + + const loadUiCapabilities = function () { + if (uiCapabilities.loaded) { + return Promise.resolve(uiCapabilities); + } + + if (uiCapabilitiesPromise) { + return uiCapabilitiesPromise; + } + + uiCapabilitiesPromise = main.TachyonShellMethods.getUiCapabilities() + .then((response) => { + if (!response?.success) { + throw new Error("UI capabilities request failed"); + } + + return updateUiCapabilities(response.data); + }) + .catch((error) => { + console.warn("Failed to load Tachyon UI capabilities", error); + return main.TachyonShellMethods.getUiState() + .then((response) => { + if (!response?.success) { + throw new Error("UI state request failed"); + } + + return applyUiState(response.data); + }) + .catch((fallbackError) => { + console.warn("Failed to load Tachyon UI state", fallbackError); + return loadFallbackUiCapabilities(); + }); + }) + .finally(() => { + uiCapabilitiesPromise = null; + }); + + return uiCapabilitiesPromise; + }; + const tachyonMap = new form.Map( + UCI_PACKAGE, + _("Tachyon Settings"), + _("Configuration for Tachyon service"), + ); + tachyonMap.tabbed = true; + const originalHandleSaveApply = tachyonMap.handleSaveApply; + tachyonMap.handleSaveApply = function (ev, mode) { + const refreshUiState = function () { + main.TachyonShellMethods.getUiState() + .then((response) => { + if ( + response?.success && + typeof main.applyUiStateToStore === "function" + ) { + main.applyUiStateToStore(response.data); + } + }) + .catch(() => null); + }; + + if (main.store && typeof main.store.set === "function") { + const servicesInfoWidget = main.store.get().servicesInfoWidget; + main.store.set({ + servicesInfoWidget: { + ...servicesInfoWidget, + data: { + ...servicesInfoWidget.data, + tachyonStatus: "reloading", + }, + }, + }); + } + + return Promise.resolve(originalHandleSaveApply.call(this, ev, mode)) + .then((result) => { + window.setTimeout(refreshUiState, 250); + + return result; + }) + .catch((error) => { + refreshUiState(); + + throw error; + }); + }; + + const rulesSection = tachyonMap.section( + form.GridSection, + "section", + _("Sections"), + _("Drag rows to change priority. The rule at the top is checked first."), + ); + configureGridSection( + rulesSection, + "section", + _("Section"), + _("Add a section"), + ); + section.configureSectionSection(rulesSection, { + loadActionProvidersAvailability: loadUiCapabilities, + }); + section.createSectionContent(rulesSection); + + const serverSection = tachyonMap.section( + form.GridSection, + "server", + _("Servers"), + _("Accept external proxy connections and route them with sing-box."), + ); + configureGridSection( + serverSection, + "server", + _("Server"), + _("Add a server inbound"), + ); + serverSectionRef = serverSection; + server.configureServerSection(serverSection, { + loadCapabilities: loadUiCapabilities, + }); + server.createServerContent(serverSection, uiCapabilities); + + const settingsSection = tachyonMap.section( + form.TypedSection, + "settings", + _("Settings"), + ); + settingsSection.anonymous = true; + settingsSection.addremove = false; + settingsSection.cfgsections = function () { + return ["settings"]; + }; + settings.createSettingsContent(settingsSection, uiCapabilities); + + const diagnosticSection = tachyonMap.section( + form.TypedSection, + "diagnostic", + _("Diagnostics"), + ); + diagnosticSection.anonymous = true; + diagnosticSection.addremove = false; + diagnosticSection.cfgsections = function () { + return ["diagnostic"]; + }; + diagnostic.createDiagnosticContent(diagnosticSection); + + const dashboardSection = tachyonMap.section( + form.TypedSection, + "dashboard", + _("Dashboard"), + ); + dashboardSection.anonymous = true; + dashboardSection.addremove = false; + dashboardSection.cfgsections = function () { + return ["dashboard"]; + }; + dashboard.createDashboardContent(dashboardSection); + + const monitoringSection = tachyonMap.section( + form.TypedSection, + "monitoring", + _("Monitoring"), + ); + monitoringSection.anonymous = true; + monitoringSection.addremove = false; + monitoringSection.cfgsections = function () { + return ["monitoring"]; + }; + monitoring.createMonitoringContent(monitoringSection); + + const updatesSection = tachyonMap.section( + form.TypedSection, + "updates", + _("Components"), + ); + updatesSection.anonymous = true; + updatesSection.addremove = false; + updatesSection.cfgsections = function () { + return ["updates"]; + }; + updates.createUpdatesContent(updatesSection); + + await loadUiCapabilities().catch(() => null); + + const rendered = await tachyonMap.render(); + main.coreService({ + waitForLogWatcherStart: loadUiCapabilities, + logWatcherStartDelayMs: 5000, + }); + + return rendered; + }, +}; + +return view.extend(EntryPoint); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/updates.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/updates.js new file mode 100644 index 00000000..7c1a1256 --- /dev/null +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/updates.js @@ -0,0 +1,22 @@ +"use strict"; +"require baseclass"; +"require form"; +"require ui"; +"require uci"; +"require fs"; +"require view.tachyon.main as main"; + +function createUpdatesContent(section) { + const o = section.option(form.DummyValue, "_mount_node"); + o.rawhtml = true; + o.cfgvalue = () => { + main.UpdatesTab.initController(); + return main.UpdatesTab.render(); + }; +} + +const EntryPoint = { + createUpdatesContent, +}; + +return baseclass.extend(EntryPoint); diff --git a/luci-app-tachyon/po/ru/tachyon.po b/luci-app-tachyon/po/ru/tachyon.po new file mode 100644 index 00000000..f583f559 --- /dev/null +++ b/luci-app-tachyon/po/ru/tachyon.po @@ -0,0 +1,2310 @@ +# RU translations for TACHYON package. +# Copyright (C) 2026 THE TACHYON COPYRIGHT HOLDER +# This file is distributed under the same license as the TACHYON package. +# ushan0v, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: TACHYON\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-13 01:05+0300\n" +"PO-Revision-Date: 2026-07-13 01:05+0300\n" +"Last-Translator: ushan0v\n" +"Language-Team: none\n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +msgid "-- Select --" +msgstr "-- Выберите --" + +msgid "+ Add JSON outbound" +msgstr "+ Добавить JSON outbound" + +msgid "+ Add level" +msgstr "+ Добавить уровень" + +msgid "+ Add priority" +msgstr "+ Добавить приоритет" + +msgid "+ Add URLTest" +msgstr "+ Добавить URLTest" + +msgid "✔ Running" +msgstr "✔ Работает" + +msgid "✘ Stopped" +msgstr "✘ Остановлен" + +msgid "Accept external proxy connections and route them with sing-box." +msgstr "Принимает внешние прокси-подключения и маршрутизирует их через sing-box." + +msgid "Accept routes" +msgstr "Принимать маршруты" + +msgid "Action" +msgstr "Действие" + +msgid "Active" +msgstr "Активные" + +msgid "Active Bootstrap DNS" +msgstr "Активный Bootstrap DNS" + +msgid "Active Connections" +msgstr "Активные соединения" + +msgid "Active Main DNS" +msgstr "Активный основной DNS" + +msgid "Add a section" +msgstr "Добавить секцию" + +msgid "Add a server inbound" +msgstr "Добавить серверный inbound" + +msgid "Add at least one DNS server" +msgstr "Добавьте хотя бы один DNS-сервер" + +msgid "Add prefix to nodes" +msgstr "Добавлять префикс к узлам" + +msgid "Add URLs or local paths to .lst lists. Only domain rules are supported." +msgstr "Добавьте URL или локальные пути к спискам .lst. Поддерживаются только доменные правила." + +msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported." +msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только доменные правила." + +msgid "Add URLTest / Priority group" +msgstr "Добавить URLTest / Приоритет группу" + +msgid "Additional marking rules found" +msgstr "Найдены дополнительные правила маркировки" + +msgid "Additionally exclude servers by protocol, transport, and security. Exclude only servers matching the specified parameters." +msgstr "Дополнительно исключать серверы по протоколу, транспорту и защите. Исключать только серверы, совпавшие по указанным параметрам." + +msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters." +msgstr "Дополнительно отбирать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам." + +msgid "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters." +msgstr "Дополнительно скрывать серверы по протоколу, транспорту и защите. Скрывать серверы, совпавшие по указанным параметрам." + +msgid "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters." +msgstr "Дополнительно показывать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам." + +msgid "Address" +msgstr "Адрес" + +msgid "Advertise exit node" +msgstr "Анонсировать exit node" + +msgid "Advertise routes" +msgstr "Анонсировать маршруты" + +msgid "All" +msgstr "Все" + +msgid "All except selected" +msgstr "Все, кроме выбранных" + +msgid "All remaining except selected" +msgstr "Все оставшиеся, кроме выбранных" + +msgid "All remaining servers" +msgstr "Все оставшиеся серверы" + +msgid "All remaining servers means every server not already assigned to a higher-priority level." +msgstr "Добавляет все серверы, которые не попали в уровни с более высоким приоритетом." + +msgid "All servers" +msgstr "Все серверы" + +msgid "All traffic from these IP addresses will be routed through the section unconditionally, ignoring all other conditions." +msgstr "Весь трафик с этих IP-адресов будет направлен через секцию безусловно, игнорируя остальные условия." + +msgid "Allowed domain prefixes are full:, keyword:, and regex:" +msgstr "Разрешённые префиксы доменов: full:, keyword: и regex:" + +msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall." +msgstr "Обеспечивает доступ к YACD из WAN. Убедитесь, что в брандмауэре открыт соответствующий порт." + +msgid "Allows limiting the list of servers for URLTest" +msgstr "Позволяет ограничить список серверов для URLTest" + +msgid "ALPN" +msgstr "ALPN" + +msgid "Alter ID" +msgstr "Alter ID" + +msgid "Apply section rules only to the specified local IP addresses" +msgstr "Применять правила секции только к указанным локальным IP-адресам" + +msgid "At least one valid domain must be specified." +msgstr "Нужно указать хотя бы один корректный домен." + +msgid "At least one valid IP or subnet must be specified." +msgstr "Нужно указать хотя бы один корректный IP-адрес или подсеть." + +msgid "Auto refresh" +msgstr "Автообновление" + +msgid "Auto update" +msgstr "Автообновление" + +msgid "Auto-generate HWID" +msgstr "Авто-генерация HWID" + +msgid "Automatic component update checks" +msgstr "Автоматическая проверка обновлений компонентов" + +msgid "Automatic User-Agent selection" +msgstr "Авто-подбор User-Agent" + +msgid "Automatically add text to the name of each server from this subscription for convenient filtering." +msgstr "Автоматически добавлять текст к имени каждого сервера из этой подписки для удобной фильтрации." + +msgid "Automatically check installed components for new versions" +msgstr "Автоматически проверять наличие новых версий установленных компонентов" + +msgid "Automatically select the fastest node in the current level" +msgstr "Автовыбор самого быстрого узла уровня" + +msgid "Available actions" +msgstr "Доступные действия" + +msgid "Backend validation failed: %s" +msgstr "Backend-проверка завершилась ошибкой: %s" + +msgid "Bootstrap DNS" +msgstr "Bootstrap DNS" + +msgid "Bootstrap DNS Servers" +msgstr "Bootstrap DNS-серверы" + +msgid "Browser FakeIP check could not be completed" +msgstr "Не удалось выполнить проверку FakeIP в браузере" + +msgid "Browser is not using FakeIP" +msgstr "Браузер не использует FakeIP" + +msgid "Browser is using FakeIP correctly" +msgstr "Браузер использует FakeIP" + +msgid "Built-in rule sets" +msgstr "Встроенные наборы правил" + +msgid "By flag emoji from name" +msgstr "По эмодзи флага из названия" + +msgid "ByeDPI listen address and port are assigned by Tachyon and must not be set in the strategy." +msgstr "Адрес и порт прослушивания ByeDPI назначаются Tachyon и не должны задаваться в стратегии." + +msgid "ByeDPI option does not accept a compact value: %s" +msgstr "Опция ByeDPI не принимает компактное значение: %s" + +msgid "ByeDPI option does not accept a value: %s" +msgstr "Опция ByeDPI не принимает значение: %s" + +msgid "ByeDPI option requires a value: %s" +msgstr "Для опции ByeDPI требуется значение: %s" + +msgid "ByeDPI package is installed" +msgstr "Пакет ByeDPI установлен" + +msgid "ByeDPI package is not installed" +msgstr "Пакет ByeDPI не установлен" + +msgid "ByeDPI provider binary is available" +msgstr "Бинарный файл провайдера ByeDPI доступен" + +msgid "ByeDPI provider binary is not available" +msgstr "Бинарный файл провайдера ByeDPI недоступен" + +msgid "ByeDPI sing-box outbound is configured" +msgstr "Outbound ByeDPI в sing-box настроен" + +msgid "ByeDPI sing-box outbound is not configured" +msgstr "Outbound ByeDPI в sing-box не настроен" + +msgid "ByeDPI Strategy" +msgstr "Стратегия ByeDPI" + +msgid "ByeDPI strategy cannot be empty" +msgstr "Стратегия ByeDPI не может быть пустой" + +msgid "Cache File Path" +msgstr "Путь к файлу кэша" + +msgid "Cache file path cannot be empty" +msgstr "Путь к файлу кэша не может быть пустым" + +msgid "Cannot receive checks result" +msgstr "Не удалось получить результаты проверки" + +msgid "Cannot save server" +msgstr "Не удалось сохранить сервер" + +msgid "Cannot save settings" +msgstr "Не удалось сохранить настройки" + +msgid "Cascade connection" +msgstr "Каскадное подключение" + +msgid "Certificate path" +msgstr "Путь к сертификату" + +msgid "Certificate pin is unavailable. Apply or restart the server to generate the default certificate, then open this dialog again." +msgstr "Пин сертификата недоступен. Примените или перезапустите сервер, чтобы сгенерировать дефолтный сертификат, затем откройте это окно снова." + +msgid "Check interval" +msgstr "Интервал проверки" + +msgid "Check timeout after which the server is considered dead" +msgstr "Таймаут проверки, после которого сервер считается мёртвым" + +msgid "Check update" +msgstr "Проверить обновление" + +msgid "Check URL" +msgstr "URL проверки" + +msgid "Checking, please wait" +msgstr "Проверяем, пожалуйста подождите" + +msgid "checks" +msgstr "проверки" + +msgid "Checks failed" +msgstr "Проверки не выполнены" + +msgid "Checks passed" +msgstr "Проверки пройдены" + +msgid "Checks passed with manual DHCP" +msgstr "Проверки пройдены с ручным DHCP" + +msgid "CIDR must be between 0 and 32 for IPv4 or 0 and 128 for IPv6" +msgstr "CIDR должен быть от 0 до 32 для IPv4 или от 0 до 128 для IPv6" + +msgid "Client fingerprint" +msgstr "Отпечаток клиента" + +msgid "Client link" +msgstr "Ссылка клиента" + +msgid "Client link is not available yet" +msgstr "Ссылка клиента пока недоступна" + +msgid "Client link QR code" +msgstr "QR-код клиентской ссылки" + +msgid "Close" +msgstr "Закрыть" + +msgid "Close all connections" +msgstr "Закрыть все подключения" + +msgid "Close connection" +msgstr "Закрыть подключение" + +msgid "Closed" +msgstr "Закрытые" + +msgid "Component update available" +msgstr "Доступно обновление компонента" + +msgid "Component update check interval" +msgstr "Период проверки обновлений" + +msgid "Components" +msgstr "Компоненты" + +msgid "compressed" +msgstr "compressed" + +msgid "Concurrency" +msgstr "Параллелизм" + +msgid "Conditions" +msgstr "Условия" + +msgid "Config File Path" +msgstr "Путь к файлу конфигурации" + +msgid "Configuration for Tachyon service" +msgstr "Настройки сервиса Tachyon" + +msgid "Configure User-Agent in the item settings" +msgstr "Настройте User-Agent в настройках элемента" + +msgid "Connect through" +msgstr "Подключаться через" + +msgid "Connection URL" +msgstr "URL подключения" + +msgid "Connections are unavailable" +msgstr "Подключения недоступны" + +msgid "Connectivity probe failed" +msgstr "Проверка соединения не прошла" + +msgid "Copied" +msgstr "Скопировано" + +msgid "Copy" +msgstr "Копировать" + +msgid "Copy proxy link" +msgstr "Скопировать ссылку прокси" + +msgid "Could not compare FakeIP and control public IPs" +msgstr "Не удалось сравнить публичные IP FakeIP и контрольной проверки" + +msgid "Current section cannot be used as its own transit section" +msgstr "Текущую секцию нельзя использовать как транзит для самой себя" + +msgid "Current section cannot download its own subscription" +msgstr "Текущую секцию нельзя выбрать для скачивания её же подписки" + +msgid "Currently unavailable" +msgstr "Временно недоступно" + +msgid "Custom outbound configurations in JSON format" +msgstr "Пользовательские конфигурации outbound в формате JSON" + +msgid "Dashboard" +msgstr "Дашборд" + +msgid "Dashboard currently unavailable" +msgstr "Дашборд сейчас недоступен" + +msgid "Default address for checking server availability and latency. URLTest uses its own address." +msgstr "Адрес по умолчанию для проверки доступности и задержки серверов. Для URLTest используется собственный адрес." + +msgid "Delay in milliseconds before reloading Tachyon after interface UP" +msgstr "Задержка в миллисекундах перед перезапуском Tachyon после поднятия интерфейса" + +msgid "Delay value cannot be empty" +msgstr "Значение задержки не может быть пустым" + +msgid "Delete" +msgstr "Удалить" + +msgid "Detect server country" +msgstr "Определять страну сервера" + +msgid "detour must be a non-empty string" +msgstr "detour должен быть непустой строкой" + +msgid "Device filter" +msgstr "Фильтр по устройствам" + +msgid "Device or IP" +msgstr "Устройство или IP" + +msgid "DHCP has DNS server" +msgstr "DHCP содержит DNS сервер" + +msgid "DHCP is managed manually" +msgstr "DHCP управляется вручную" + +msgid "Diagnostics" +msgstr "Диагностика" + +msgid "Direct" +msgstr "Напрямую" + +msgid "Direct connection" +msgstr "Прямое подключение" + +msgid "Disable autostart" +msgstr "Отключить автостарт" + +msgid "Disable QUIC" +msgstr "Отключить QUIC" + +msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming" +msgstr "Отключить QUIC протокол для улучшения совместимости или исправления видео стриминга" + +msgid "Disabling interception is incompatible with action=zapret2 because Tachyon sends matched traffic through NFQUEUE." +msgstr "Отключение перехвата несовместимо с action=zapret2, потому что Tachyon отправляет совпавший трафик через NFQUEUE." + +msgid "Display name" +msgstr "Отображаемое имя" + +msgid "DNS Check Interval" +msgstr "Период проверки DNS" + +msgid "DNS on router" +msgstr "DNS на роутере" + +msgid "DNS over HTTPS (DoH)" +msgstr "DNS через HTTPS (DoH)" + +msgid "DNS over TLS (DoT)" +msgstr "DNS через TLS (DoT)" + +msgid "DNS protocol" +msgstr "Протокол DNS" + +msgid "DNS Protocol Type" +msgstr "Тип протокола DNS" + +msgid "DNS protocol used by the resolver" +msgstr "DNS-протокол, который будет использовать резолвер" + +msgid "DNS requests through section" +msgstr "DNS-запросы через секцию" + +msgid "DNS Rewrite TTL" +msgstr "Перезапись TTL для DNS" + +msgid "DNS server" +msgstr "DNS-сервер" + +msgid "DNS server address cannot be empty" +msgstr "Адрес DNS-сервера не может быть пустым" + +msgid "DNS server used by the resolver" +msgstr "DNS-сервер, который будет использовать резолвер" + +msgid "DNS server used to obtain IP addresses for upstream DNS and proxies. If multiple servers are selected, a timeout switches to a backup." +msgstr "DNS-сервер для получения IP вышестоящих DNS и прокси. Если выбрать несколько, то при таймауте произойдёт переключение на резервный." + +msgid "DNS Servers" +msgstr "DNS-серверы" + +msgid "DNS Strategy" +msgstr "Стратегия DNS" + +msgid "DNS through proxy" +msgstr "DNS через прокси" + +msgid "DNS through section" +msgstr "DNS через секцию" + +msgid "DNS Unavailability Timeout" +msgstr "Таймаут недоступности DNS" + +msgid "Do not panic, everything can be fixed, just..." +msgstr "Не паникуйте, всё можно исправить, просто..." + +msgid "Do not test servers from these countries." +msgstr "Не тестировать серверы из этих стран." + +msgid "Do not test servers whose names match the expression." +msgstr "Не тестировать серверы, имена которых совпадают с выражением." + +msgid "Do not test servers with one of the selected protocols." +msgstr "Не тестировать серверы с одним из выбранных протоколов." + +msgid "Do not test servers with one of the selected security types." +msgstr "Не тестировать серверы с одним из выбранных типов защиты." + +msgid "Do not test servers with one of the selected transports." +msgstr "Не тестировать серверы с одним из выбранных транспортов." + +msgid "Do not test specified servers." +msgstr "Не тестировать указанные серверы." + +msgid "Does not resolve" +msgstr "Не резолвится" + +msgid "Domain lists" +msgstr "Списки доменов" + +msgid "Domain Resolver" +msgstr "Резолвер доменов" + +msgid "Domains" +msgstr "Домены" + +msgid "Dont Touch My DHCP!" +msgstr "Отключить управление DHCP" + +msgid "Downlink" +msgstr "Входящий" + +msgid "Download" +msgstr "Скачать" + +msgid "Download component packages via the selected section" +msgstr "Скачивать пакеты компонентов через выбранную секцию" + +msgid "Download components through" +msgstr "Скачивать компоненты через" + +msgid "Download components through a section" +msgstr "Скачивать компоненты через секцию" + +msgid "Download lists through" +msgstr "Скачивать списки через" + +msgid "Download lists through a section" +msgstr "Скачивать списки через секцию" + +msgid "Download remote lists and rule sets via the selected section" +msgstr "Скачивать удаленные списки и наборы правил через выбранную секцию" + +msgid "Download subscription through a section" +msgstr "Скачивать подписку через секцию" + +msgid "Download subscriptions via the selected section" +msgstr "Скачивать подписки через выбранную секцию" + +msgid "Download through" +msgstr "Скачивать через" + +msgid "Downloaded" +msgstr "Загружено" + +msgid "Drag rows to change priority. The rule at the top is checked first." +msgstr "Перетаскивайте строки, чтобы изменить приоритет. Правило сверху проверяется первым." + +msgid "Duplicate JSON outbound tag" +msgstr "Такой тег JSON outbound уже используется" + +msgid "Duplicate value" +msgstr "Дублирующееся значение" + +msgid "Edit rule action" +msgstr "Изменить" + +msgid "Enable" +msgstr "Включить" + +msgid "Enable authentication" +msgstr "Включить авторизацию" + +msgid "Enable automatic updates for remote lists and rule sets" +msgstr "Включить автоматическое обновление удалённых списков и наборов правил" + +msgid "Enable autostart" +msgstr "Включить автостарт" + +msgid "Enable built-in DNS resolver for domains handled by this section" +msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых этой секцией" + +msgid "Enable list updates" +msgstr "Включить обновление списков" + +msgid "Enable Mixed Proxy" +msgstr "Включить смешанный прокси" + +msgid "Enable Mixed Proxy Authentication" +msgstr "Включить авторизацию смешанного прокси" + +msgid "Enable Output Network Interface" +msgstr "Включить выходной сетевой интерфейс" + +msgid "Enable YACD" +msgstr "Включить YACD" + +msgid "Enable YACD WAN Access" +msgstr "Включить доступ YACD WAN" + +msgid "Enter a complete sing-box inbound object" +msgstr "Введите полный объект inbound sing-box" + +msgid "Enter a complete sing-box outbound object" +msgstr "Введите полный объект outbound sing-box" + +msgid "Enter a display name" +msgstr "Введите отображаемое имя" + +msgid "Enter a level name" +msgstr "Введите название уровня" + +msgid "Enter a valid DNS server address" +msgstr "Укажите корректный адрес DNS-сервера" + +msgid "Enter HWID" +msgstr "Укажите HWID" + +msgid "Enter the HWID sent with subscription requests" +msgstr "HWID, который будет отправляться при запросах подписки" + +msgid "Enter the subscription URL" +msgstr "Введите URL подписки" + +msgid "Exclude by proxy parameters" +msgstr "Исключить по параметрам прокси" + +msgid "Exclude by regular expression" +msgstr "Исключить по регулярному выражению" + +msgid "Exclude countries" +msgstr "Исключить страны" + +msgid "Exclude NTP" +msgstr "Исключить NTP" + +msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN" +msgstr "Исключите трафик протокола NTP из туннеля, чтобы предотвратить его маршрутизацию через прокси-сервер или VPN." + +msgid "Exclude servers" +msgstr "Исключить серверы" + +msgid "Exclude servers with one of the selected protocols from this level." +msgstr "Исключить из этого уровня серверы с одним из выбранных протоколов." + +msgid "Exclude servers with one of the selected security types from this level." +msgstr "Исключить из этого уровня серверы с одним из выбранных типов защиты." + +msgid "Exclude servers with one of the selected transports from this level." +msgstr "Исключить из этого уровня серверы с одним из выбранных транспортов." + +msgid "Exclude URLTest / Priority group" +msgstr "Исключить URLTest / Приоритет группу" + +msgid "Expecting: %s" +msgstr "Ожидается: %s" + +msgid "Expires" +msgstr "Истекает" + +msgid "Expose this section as a local HTTP+SOCKS proxy" +msgstr "Открыть эту секцию как локальный HTTP+SOCKS прокси" + +msgid "External nfqws config files bypass Tachyon queue management and explicit validation." +msgstr "Внешние конфиги nfqws обходят управление очередями и явную валидацию Tachyon." + +msgid "External nfqws2 config files bypass Tachyon queue management and explicit validation." +msgstr "Внешние конфиги nfqws2 обходят управление очередями и явную валидацию Tachyon." + +msgid "Failed to close connection" +msgstr "Не удалось закрыть подключение" + +msgid "Failed to close connections" +msgstr "Не удалось закрыть подключения" + +msgid "Failed to copy!" +msgstr "Не удалось скопировать!" + +msgid "Failed to execute" +msgstr "Не удалось выполнить" + +msgid "Failed to update subscriptions" +msgstr "Не удалось обновить подписки" + +msgid "FakeIP and control checks use different public IPs" +msgstr "FakeIP и контрольная проверка используют разные публичные IP" + +msgid "FakeIP and control checks use the same public IP" +msgstr "FakeIP и контрольная проверка используют один публичный IP" + +msgid "FakeIP works; public IP comparison is inconclusive" +msgstr "FakeIP работает; сравнение публичных IP не дает однозначного результата" + +msgid "FakeTLS host" +msgstr "FakeTLS-хост" + +msgid "Fallback on unknown DC" +msgstr "Fallback для неизвестного DC" + +msgid "Faster server search interval" +msgstr "Интервал поиска более быстрого сервера" + +msgid "Fastest" +msgstr "Самый быстрый" + +msgid "Filter the servers that will be displayed on the dashboard." +msgstr "Отфильтровать серверы, которые будут отображаться в дашборде." + +msgid "Firewall WAN port" +msgstr "Порт в firewall для WAN" + +msgid "Fix the highlighted fields and save again." +msgstr "Исправьте подсвеченные поля и сохраните снова." + +msgid "Forced device routing" +msgstr "Принудительная маршрутизация устройств" + +msgid "Tachyon Error" +msgstr "Ошибка Tachyon" + +msgid "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here." +msgstr "Tachyon сам управляет pid-файлами ciadpi, поэтому параметры pidfile здесь запрещены." + +msgid "Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here." +msgstr "Tachyon сам управляет жизненным циклом ciadpi, поэтому режим daemon здесь запрещён." + +msgid "Tachyon manages the nfqws process lifecycle itself, so daemon mode is not allowed here." +msgstr "Tachyon сам управляет жизненным циклом nfqws, поэтому режим daemon здесь запрещён." + +msgid "Tachyon manages the nfqws2 process lifecycle itself, so daemon mode is not allowed here." +msgstr "Tachyon сам управляет жизненным циклом nfqws2, поэтому режим daemon здесь запрещён." + +msgid "Tachyon service is stopped. Start the service to display connections." +msgstr "Служба Tachyon остановлена. Запустите сервис для отображения подключений." + +msgid "Tachyon service is stopped. Start the service to display the dashboard." +msgstr "Служба Tachyon остановлена. Запустите сервис для отображения дашборда." + +msgid "Tachyon Settings" +msgstr "Настройки Tachyon" + +msgid "Tachyon will not modify your DHCP configuration" +msgstr "Tachyon не будет изменять вашу конфигурацию DHCP" + +msgid "Tachyon-managed ciadpi runtime has restarted" +msgstr "ciadpi Tachyon перезапускался" + +msgid "Tachyon-managed ciadpi runtime is not ready" +msgstr "ciadpi Tachyon не готов" + +msgid "Tachyon-managed ciadpi runtime is not running" +msgstr "ciadpi Tachyon не запущен" + +msgid "Tachyon-managed ciadpi runtime is ready" +msgstr "ciadpi Tachyon готов" + +msgid "Tachyon-managed nfqws runtime is not ready" +msgstr "NFQWS Tachyon не готов" + +msgid "Tachyon-managed nfqws runtime is not running" +msgstr "NFQWS Tachyon не запущен" + +msgid "Tachyon-managed nfqws runtime is ready" +msgstr "NFQWS Tachyon готов" + +msgid "Tachyon-managed nfqws2 runtime is not ready" +msgstr "NFQWS2 Tachyon не готов" + +msgid "Tachyon-managed nfqws2 runtime is not running" +msgstr "NFQWS2 Tachyon не запущен" + +msgid "Tachyon-managed nfqws2 runtime is ready" +msgstr "NFQWS2 Tachyon готов" + +msgid "Fronting IP" +msgstr "IP фронтинга" + +msgid "Fronting port" +msgstr "Порт фронтинга" + +msgid "Fronting proxy protocol" +msgstr "Proxy protocol для фронтинга" + +msgid "Fuzzing is not supported here because Tachyon needs deterministic runtime validation." +msgstr "Fuzzing здесь не поддерживается, потому что Tachyon нужна предсказуемая runtime-валидация." + +msgid "Generate HWID from router hardware information for this subscription" +msgstr "Генерировать HWID из аппаратной информации роутера для этой подписки" + +msgid "Generated inbound" +msgstr "Созданный inbound" + +msgid "Get global check" +msgstr "Получить глобальную проверку" + +msgid "Global check" +msgstr "Глобальная проверка" + +msgid "gRPC service" +msgstr "gRPC-сервис" + +msgid "Handshake timeout" +msgstr "Таймаут рукопожатия" + +msgid "Hide cascade connection nodes" +msgstr "Скрывать узлы каскадных подключений" + +msgid "Hide individual nodes that are already included in imported subscription URLTest groups" +msgstr "Скрывать отдельные узлы, которые уже входят в импортированные URLTest группы подписки" + +msgid "Hide intermediate nodes used as detours by other subscription nodes" +msgstr "Скрывать промежуточные узлы, которые используются как detour для других узлов подписки" + +msgid "Hide selected servers." +msgstr "Скрывать выбранные серверы." + +msgid "Hide servers from selected groups without repeating their filtering criteria." +msgstr "Скрывать серверы из выбранных групп без повторения их параметров фильтрации." + +msgid "Hide servers from the specified countries." +msgstr "Скрывать серверы из указанных стран." + +msgid "Hide servers whose names match the expression." +msgstr "Скрывать серверы, имена которых совпадают с выражением." + +msgid "Hide servers with one of the selected protocols." +msgstr "Скрывать серверы с одним из выбранных протоколов." + +msgid "Hide servers with one of the selected security types." +msgstr "Скрывать серверы с одним из выбранных типов защиты." + +msgid "Hide servers with one of the selected transports." +msgstr "Скрывать серверы с одним из выбранных транспортов." + +msgid "Hide URLTest group nodes" +msgstr "Скрывать узлы URLTest групп" + +msgid "Hide values" +msgstr "Скрывать значения" + +msgid "Higher-level check interval" +msgstr "Интервал проверки высших уровней" + +msgid "Higher-priority DNS Check" +msgstr "Проверка верхних DNS" + +msgid "Host" +msgstr "Хост" + +msgid "How often higher priority levels are checked while a lower level is active" +msgstr "Как часто проверять верхние уровни приоритета, пока активен нижний уровень" + +msgid "How often the currently selected server is checked" +msgstr "Как часто проверяется текущий выбранный сервер" + +msgid "How often to check the active DNS servers." +msgstr "Как часто проверять активные DNS-серверы." + +msgid "How often to check whether a higher-priority DNS server has recovered." +msgstr "Как часто проверять, восстановился ли DNS-сервер с более высоким приоритетом." + +msgid "HTTP error" +msgstr "Ошибка HTTP" + +msgid "HTTP hosts" +msgstr "HTTP-хосты" + +msgid "HWID" +msgstr "HWID" + +msgid "Idle timeout" +msgstr "Таймаут простоя" + +msgid "Import subscription URLTest groups" +msgstr "Добавлять URLTest группы из подписки" + +msgid "Import URLTest groups returned by this subscription provider" +msgstr "Импортировать URLTest группы, если провайдер подписки возвращает их" + +msgid "Inbound JSON" +msgstr "Inbound JSON" + +msgid "Inbound JSON cannot be empty" +msgstr "Inbound JSON не может быть пустым" + +msgid "Inbound JSON must be an object with a type field" +msgstr "Inbound JSON должен быть объектом с полем type" + +msgid "Include by proxy parameters" +msgstr "Включить по параметрам прокси" + +msgid "Include by regular expression" +msgstr "Включить по регулярному выражению" + +msgid "Include countries" +msgstr "Включить страны" + +msgid "Include IP addresses and subnets" +msgstr "Учитывать IP-адреса и подсети" + +msgid "Include servers" +msgstr "Включить серверы" + +msgid "Information" +msgstr "Информация" + +msgid "Install" +msgstr "Установить" + +msgid "Install another build:" +msgstr "Установить другую сборку:" + +msgid "Installed version is newer than release" +msgstr "Установлена версия новее релиза" + +msgid "Interface Monitoring" +msgstr "Мониторинг интерфейса" + +msgid "Interface Monitoring Delay" +msgstr "Задержка при мониторинге интерфейсов" + +msgid "Interface monitoring for Bad WAN" +msgstr "Мониторинг интерфейса для Bad WAN" + +msgid "Interrupt connections" +msgstr "Прерывать соединения" + +msgid "Interrupt connections when priority failover switches server" +msgstr "Разрывать соединения при переключении сервера приоритетом" + +msgid "Interrupt connections when URLTest switches the selected server" +msgstr "Прерывать соединения, когда URLTest переключает выбранный сервер" + +msgid "Interval" +msgstr "Интервал" + +msgid "Invalid ByeDPI strategy" +msgstr "Некорректная стратегия ByeDPI" + +msgid "Invalid CIDR prefix" +msgstr "Неверный префикс CIDR" + +msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" +msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, dns.example.com или dns.example.com/nicedns для DoH" + +msgid "Invalid domain address" +msgstr "Неверный домен" + +msgid "Invalid format. Use an IP address or CIDR subnet" +msgstr "Неверный формат. Используйте IP-адрес или подсеть CIDR" + +msgid "Invalid HTTP proxy URL: invalid host and port" +msgstr "Неверный URL HTTP proxy: неверный хост и порт" + +msgid "Invalid HTTP proxy URL: invalid host format" +msgstr "Неверный URL HTTP proxy: неверный формат хоста" + +msgid "Invalid HTTP proxy URL: invalid port number" +msgstr "Неверный URL HTTP proxy: неверный номер порта" + +msgid "Invalid HTTP proxy URL: missing host and port" +msgstr "Неверный URL HTTP proxy: отсутствуют хост и порт" + +msgid "Invalid HTTP proxy URL: missing hostname or IP" +msgstr "Неверный URL HTTP proxy: отсутствует имя хоста или IP-адрес" + +msgid "Invalid HTTP proxy URL: missing port" +msgstr "Неверный URL HTTP proxy: отсутствует порт" + +msgid "Invalid HTTP proxy URL: missing username" +msgstr "Неверный URL HTTP proxy: отсутствует имя пользователя" + +msgid "Invalid HTTP proxy URL: must not contain spaces" +msgstr "Неверный URL HTTP proxy: не должен содержать пробелов" + +msgid "Invalid HTTP proxy URL: must start with http:// or https://" +msgstr "Неверный URL HTTP proxy: должен начинаться с http:// или https://" + +msgid "Invalid HTTP proxy URL: parsing failed" +msgstr "Неверный URL HTTP proxy: ошибка разбора" + +msgid "Invalid HTTP proxy URL: path, query, and fragment are not supported" +msgstr "Неверный URL HTTP proxy: путь, query и fragment не поддерживаются" + +msgid "Invalid HY2 URL: insecure must be 0 or 1" +msgstr "Неверный URL Hysteria2: параметр insecure должен быть 0 или 1" + +msgid "Invalid HY2 URL: invalid host & port" +msgstr "Некорректный HY2 URL: некорректные хост и порт" + +msgid "Invalid HY2 URL: invalid port number" +msgstr "Неверный URL Hysteria2: неверный номер порта" + +msgid "Invalid HY2 URL: missing credentials/server" +msgstr "Неверный URL Hysteria2: отсутствуют учетные данные/сервер" + +msgid "Invalid HY2 URL: missing host" +msgstr "Неверный URL Hysteria2: отсутствует хост" + +msgid "Invalid HY2 URL: missing host & port" +msgstr "Неверный URL Hysteria2: отсутствуют хост и порт" + +msgid "Invalid HY2 URL: missing password" +msgstr "Неверный URL Hysteria2: отсутствует пароль" + +msgid "Invalid HY2 URL: missing port" +msgstr "Неверный URL Hysteria2: отсутствует порт" + +msgid "Invalid HY2 URL: must not contain spaces" +msgstr "Неверный URL Hysteria2: не должен содержать пробелов" + +msgid "Invalid HY2 URL: must start with hysteria2:// or hy2://" +msgstr "Неверный URL Hysteria2: должен начинаться с hysteria2:// или hy2://" + +msgid "Invalid HY2 URL: obfs-password required when obfs is set" +msgstr "Неверный URL Hysteria2: требуется obfs-password, когда установлен obfs" + +msgid "Invalid HY2 URL: parsing failed" +msgstr "Неверный URL Hysteria2: ошибка разбора" + +msgid "Invalid HY2 URL: sni cannot be empty" +msgstr "Неверный URL Hysteria2: sni не может быть пустым" + +msgid "Invalid HY2 URL: unsupported obfs type" +msgstr "Неверный URL Hysteria2: неподдерживаемый тип obfs" + +msgid "Invalid IP address" +msgstr "Неверный IP-адрес" + +msgid "Invalid JSON format" +msgstr "Неверный формат JSON" + +msgid "Invalid NFQWS2 strategy" +msgstr "Некорректная стратегия NFQWS2" + +msgid "Invalid path format. Path must start with \"/\" and contain valid characters" +msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы" + +msgid "Invalid port number. Must be between 1 and 65535" +msgstr "Неверный номер порта. Допустимо от 1 до 65535" + +msgid "Invalid port or range. Use 80 or 1000-2000" +msgstr "Неверный порт или диапазон. Используйте 80 или 1000-2000" + +msgid "Invalid regular expression" +msgstr "Неверное регулярное выражение" + +msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password" +msgstr "Неверный URL Shadowsocks: декодированные данные должны содержать method:password" + +msgid "Invalid Shadowsocks URL: invalid server and port" +msgstr "Некорректный Shadowsocks URL: некорректные сервер и порт" + +msgid "Invalid Shadowsocks URL: missing credentials" +msgstr "Неверный URL Shadowsocks: отсутствуют учетные данные" + +msgid "Invalid Shadowsocks URL: missing method and password separator \":\"" +msgstr "Неверный URL Shadowsocks: отсутствует разделитель метода и пароля \":\"" + +msgid "Invalid Shadowsocks URL: missing port" +msgstr "Неверный URL Shadowsocks: отсутствует порт" + +msgid "Invalid Shadowsocks URL: missing server" +msgstr "Неверный URL Shadowsocks: отсутствует сервер" + +msgid "Invalid Shadowsocks URL: missing server address" +msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера" + +msgid "Invalid Shadowsocks URL: must not contain spaces" +msgstr "Неверный URL Shadowsocks: не должен содержать пробелов" + +msgid "Invalid Shadowsocks URL: must start with ss://" +msgstr "Неверный URL Shadowsocks: должен начинаться с ss://" + +msgid "Invalid Shadowsocks URL: parsing failed" +msgstr "Неверный URL Shadowsocks: ошибка разбора" + +msgid "Invalid SOCKS URL: invalid host and port" +msgstr "Некорректный SOCKS URL: некорректные хост и порт" + +msgid "Invalid SOCKS URL: invalid host format" +msgstr "Неверный URL SOCKS: неверный формат хоста" + +msgid "Invalid SOCKS URL: invalid port number" +msgstr "Неверный URL SOCKS: неверный номер порта" + +msgid "Invalid SOCKS URL: missing host and port" +msgstr "Неверный URL SOCKS: отсутствует хост и порт" + +msgid "Invalid SOCKS URL: missing hostname or IP" +msgstr "Неверный URL SOCKS: отсутствует имя хоста или IP-адрес" + +msgid "Invalid SOCKS URL: missing port" +msgstr "Неверный URL SOCKS: отсутствует порт" + +msgid "Invalid SOCKS URL: missing username" +msgstr "Неверный URL SOCKS: отсутствует имя пользователя" + +msgid "Invalid SOCKS URL: must not contain spaces" +msgstr "Неверный URL SOCKS: не должен содержать пробелов" + +msgid "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://" +msgstr "Неверный URL-адрес SOCKS: должен начинаться с socks4://, socks4a:// или socks5://" + +msgid "Invalid SOCKS URL: parsing failed" +msgstr "Неверный URL SOCKS: парсинг не удался" + +msgid "Invalid Trojan URL: must not contain spaces" +msgstr "Неверный URL Trojan: не должен содержать пробелов" + +msgid "Invalid Trojan URL: must start with trojan://" +msgstr "Неверный URL Trojan: должен начинаться с trojan://" + +msgid "Invalid Trojan URL: parsing failed" +msgstr "Неверный URL Trojan: ошибка разбора" + +msgid "Invalid URL format" +msgstr "Неверный формат URL" + +msgid "Invalid VLESS URL: parsing failed" +msgstr "Неверный URL VLESS: ошибка разбора" + +msgid "Invalid VMess URL: parsing failed" +msgstr "Некорректный VMess URL: ошибка разбора" + +msgid "IPs" +msgstr "IP-адреса" + +msgid "IPv4 only" +msgstr "Только IPv4" + +msgid "IPv6 only" +msgstr "Только IPv6" + +msgid "Issues detected" +msgstr "Обнаружены проблемы" + +msgid "JSON outbound" +msgstr "JSON outbound" + +msgid "JSON outbound cannot be empty" +msgstr "JSON outbound не может быть пустым" + +msgid "JSON outbound must be a JSON object" +msgstr "JSON outbound должен быть JSON-объектом" + +msgid "JSON outbound must contain a non-empty tag field" +msgstr "JSON outbound должен содержать непустое поле tag" + +msgid "JSON outbound must contain a non-empty type field" +msgstr "JSON outbound должен содержать непустое поле type" + +msgid "JSON outbound settings" +msgstr "Настройки JSON outbound" + +msgid "Keep Priority before latency-sorted servers" +msgstr "Показывать Priority перед серверами, отсортированными по задержке" + +msgid "Keep URLTest before latency-sorted servers" +msgstr "Держать URLTest перед серверами, отсортированными по задержке" + +msgid "Key path" +msgstr "Путь к ключу" + +msgid "Keyword must not contain spaces or commas" +msgstr "Ключевое слово не должно содержать пробелы или запятые" + +msgid "Latency test failed" +msgstr "Проверка задержки не удалась" + +msgid "Latency test URL" +msgstr "URL для проверки задержки" + +msgid "Latest version is installed" +msgstr "Установлена последняя версия" + +msgid "Latest version:" +msgstr "Последняя версия:" + +msgid "Level" +msgstr "Уровень" + +msgid "Level name" +msgstr "Название уровня" + +msgid "List must be an HTTP(S) URL or a local .lst path" +msgstr "Список должен быть HTTP(S) URL или локальным путём .lst" + +msgid "List Update Frequency" +msgstr "Частота обновления списков" + +msgid "Listen address" +msgstr "Адрес прослушивания" + +msgid "Listen port" +msgstr "Порт прослушивания" + +msgid "Listening port" +msgstr "Слушающий порт" + +msgid "Loading client link..." +msgstr "Загрузка клиентской ссылки..." + +msgid "Loading connections" +msgstr "Загрузка подключений" + +msgid "Loading..." +msgstr "Загрузка..." + +msgid "Log Level" +msgstr "Уровень логов" + +msgid "Main DNS" +msgstr "Основной DNS" + +msgid "Main DNS server. If multiple servers are selected, a timeout switches to a backup." +msgstr "Основной DNS-сервер. Если выбрать несколько, то при таймауте произойдёт переключение на резервный." + +msgid "Match destination IPs or subnets" +msgstr "Совпадение по IP-адресам или подсетям назначения" + +msgid "Match destination ports. Use a single port or a range" +msgstr "Совпадение по портам назначения. Используйте одиночный порт или диапазон" + +msgid "Maximum time to wait for example.com to resolve during a DNS health check." +msgstr "Максимальное время ожидания разрешения example.com при проверке доступности DNS." + +msgid "Memory Usage" +msgstr "Использование памяти" + +msgid "Method" +msgstr "Метод" + +msgid "Minimum latency difference in milliseconds that triggers switching to a faster server." +msgstr "Минимальная разница задержки в миллисекундах, при которой произойдет переключение на более быстрый сервер." + +msgid "Mixed Proxy Password" +msgstr "Пароль смешанного прокси" + +msgid "Mixed Proxy Port" +msgstr "Порт смешанного прокси" + +msgid "Mixed Proxy Username" +msgstr "Логин смешанного прокси" + +msgid "Monitored Interfaces" +msgstr "Наблюдаемые интерфейсы" + +msgid "Monitoring" +msgstr "Мониторинг" + +msgid "Must be a number in the range of 0 - 10000" +msgstr "Должно быть числом в диапазоне 0 - 10000" + +msgid "Name displayed on the dashboard" +msgstr "Имя, которое отображается в дашборде" + +msgid "Name shown in the priority level list" +msgstr "Имя, которое отображается в списке уровней приоритета" + +msgid "Network Interface" +msgstr "Сетевой интерфейс" + +msgid "Network interface settings" +msgstr "Настройки сетевого интерфейса" + +msgid "New version %s is available for %s" +msgstr "Доступна новая версия %s для %s" + +msgid "NFQUEUE range is available" +msgstr "Диапазон NFQUEUE свободен" + +msgid "NFQUEUE range overlaps with another rule" +msgstr "Диапазон NFQUEUE пересекается с другим правилом" + +msgid "NFQWS Strategy" +msgstr "Стратегия NFQWS" + +msgid "NFQWS strategy cannot be empty" +msgstr "Стратегия NFQWS не может быть пустой" + +msgid "NFQWS2 Strategy" +msgstr "Стратегия NFQWS2" + +msgid "NFQWS2 strategy cannot be empty" +msgstr "Стратегия NFQWS2 не может быть пустой" + +msgid "No" +msgstr "Нет" + +msgid "No active connections" +msgstr "Нет активных подключений" + +msgid "No closed connections" +msgstr "Нет закрытых подключений" + +msgid "No conflict detected" +msgstr "Конфликт не обнаружен" + +msgid "No enabled server inbounds configured" +msgstr "Включённые серверные inbounds не настроены" + +msgid "No other marking rules found" +msgstr "Другие правила маркировки не найдены" + +msgid "No public firewall port required" +msgstr "Публичный порт в firewall не требуется" + +msgid "No rules use ByeDPI" +msgstr "Нет правил, использующих ByeDPI" + +msgid "No rules use Zapret" +msgstr "Нет правил, использующих Zapret" + +msgid "No rules use Zapret2" +msgstr "Нет правил, использующих Zapret2" + +msgid "Node list is empty" +msgstr "Список узлов пуст" + +msgid "Nodes" +msgstr "Узлы" + +msgid "None" +msgstr "Нет" + +msgid "Not configured" +msgstr "Не настроено" + +msgid "Not detected" +msgstr "Не обнаружено" + +msgid "Not implement yet" +msgstr "Ещё не реализовано" + +msgid "Not installed" +msgstr "Не установлено" + +msgid "Not public" +msgstr "Не публичный" + +msgid "Not required for this listen address" +msgstr "Не требуется для этого адреса прослушивания" + +msgid "Not responding" +msgstr "Не отвечает" + +msgid "Not running" +msgstr "Не запущено" + +msgid "Obfuscation" +msgstr "Обфускация" + +msgid "Obfuscation password" +msgstr "Пароль обфускации" + +msgid "Only from the specified countries." +msgstr "Только из указанных стран." + +msgid "Only selected" +msgstr "Только выбранные" + +msgid "Only selected except exclusions" +msgstr "Только выбранные, кроме исключений" + +msgid "Only servers whose names match the expression." +msgstr "Только серверы, имена которых соответствуют выражению." + +msgid "Only servers with one of the selected protocols." +msgstr "Только серверы с одним из выбранных протоколов." + +msgid "Only servers with one of the selected security types." +msgstr "Только серверы с одним из выбранных типов защиты." + +msgid "Only servers with one of the selected transports." +msgstr "Только серверы с одним из выбранных транспортов." + +msgid "Only the specified servers." +msgstr "Только указанные серверы." + +msgid "Open" +msgstr "" + +msgid "Open priority settings and enter a display name" +msgstr "Откройте настройки приоритета и укажите отображаемое имя" + +msgid "Open Project Page" +msgstr "Открыть страницу проекта" + +msgid "Operation timed out" +msgstr "Время ожидания истекло" + +msgid "Option \"%s\" contains an invalid input value." +msgstr "Параметр \"%s\" содержит некорректное значение." + +msgid "Optional values must be attached with '=' here; a separate token would be ignored by nfqws." +msgstr "Необязательное значение здесь нужно передавать только через '='; отдельный токен nfqws проигнорирует." + +msgid "Optional values must be attached with '=' here; a separate token would be ignored by nfqws2." +msgstr "Необязательные значения здесь должны задаваться через '='; отдельный токен будет проигнорирован nfqws2." + +msgid "outbounds must be a non-empty array of strings" +msgstr "outbounds должен быть непустым массивом строк" + +msgid "Output Network Interface" +msgstr "Выходной сетевой интерфейс" + +msgid "Padding" +msgstr "Padding" + +msgid "Password" +msgstr "Пароль" + +msgid "Password cannot be empty" +msgstr "Пароль не может быть пустым" + +msgid "Path cannot be empty" +msgstr "Путь не может быть пустым" + +msgid "Path must be absolute (start with /)" +msgstr "Путь должен быть абсолютным (начинаться с /)" + +msgid "Path must contain at least one directory (like /tmp/cache.db)" +msgstr "Путь должен содержать хотя бы одну директорию (например /tmp/cache.db)" + +msgid "Path must end with cache.db" +msgstr "Путь должен заканчиваться на cache.db" + +msgid "Pause updates" +msgstr "Приостановить обновление" + +msgid "Pending" +msgstr "Ожидает запуска" + +msgid "Periodically check the current level and switch to a faster server even when the current one works." +msgstr "Периодически проверять текущий уровень и переключаться на более быстрый сервер, даже если текущий работает." + +msgid "Pin on dashboard" +msgstr "Закрепить в дашборде" + +msgid "Port cannot be empty" +msgstr "Порт не может быть пустым" + +msgid "Port conflict" +msgstr "Конфликт порта" + +msgid "Port for the local mixed proxy of this section" +msgstr "Порт локального смешанного прокси для этой секции" + +msgid "Port must be between 1 and 65535" +msgstr "Порт должен быть от 1 до 65535" + +msgid "Port range start must be less than or equal to end" +msgstr "Начало диапазона портов должно быть меньше или равно концу" + +msgid "Ports" +msgstr "Порты" + +msgid "Prefer IPv4" +msgstr "Предпочитать IPv4" + +msgid "Prefer IPv6" +msgstr "Предпочитать IPv6" + +msgid "Preferred IP" +msgstr "Предпочитаемый IP" + +msgid "Prefix text" +msgstr "Текст префикса" + +msgid "Priority" +msgstr "Приоритет" + +msgid "Priority details" +msgstr "Детали Priority" + +msgid "Priority details are unavailable" +msgstr "Детали Priority недоступны" + +msgid "Priority level settings" +msgstr "Настройки уровня приоритета" + +msgid "Priority levels" +msgstr "Уровни приоритета" + +msgid "Priority settings" +msgstr "Настройки приоритета" + +msgid "Protocol" +msgstr "Протокол" + +msgid "Proxy link is unavailable" +msgstr "Ссылка прокси недоступна" + +msgid "Public host" +msgstr "Публичный хост" + +msgid "QR code is too large" +msgstr "QR-код слишком большой" + +msgid "Reality fake site" +msgstr "Fake-сайт Reality" + +msgid "Reality fake site port" +msgstr "Порт fake-сайта Reality" + +msgid "Reality max time difference" +msgstr "Максимальная разница времени Reality" + +msgid "Reality private key" +msgstr "Приватный ключ Reality" + +msgid "Reality public key" +msgstr "Публичный ключ Reality" + +msgid "Reality short ID" +msgstr "Reality short ID" + +msgid "Reality short ID must contain 1-8 hex digits" +msgstr "Reality short ID должен содержать 1-8 hex-символов" + +msgid "Refill" +msgstr "Сброс" + +msgid "Regional options cannot be used together" +msgstr "Нельзя использовать несколько региональных опций одновременно" + +msgid "Regular expression must not contain spaces or commas" +msgstr "Регулярное выражение не должно содержать пробелы или запятые" + +msgid "Remove" +msgstr "Удалить" + +msgid "Remove servers from the specified countries from this level." +msgstr "Исключить из уровня серверы из указанных стран." + +msgid "Remove servers whose names match the expression from this level." +msgstr "Исключить из уровня серверы, имена которых соответствуют выражению." + +msgid "Remove the specified servers from this level." +msgstr "Исключить из уровня указанные серверы." + +msgid "Require a username and password for the local mixed proxy" +msgstr "Требовать логин и пароль для локального смешанного прокси" + +msgid "Resolve domain names before routing so sing-box can use real destination IPs." +msgstr "Разрешать доменные имена перед маршрутизацией, чтобы sing-box использовал реальные IP-адреса назначения." + +msgid "Resolve real IP for routing" +msgstr "Разрешать реальный IP для маршрутизации" + +msgid "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE." +msgstr "Выбор ресурсов по hostname внутри nfqws здесь не поддерживается; ресурсы выбирает sing-box до NFQUEUE." + +msgid "Resource selection by hostname inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE." +msgstr "Выбор ресурсов по hostname внутри nfqws2 здесь не поддерживается; sing-box выбирает ресурсы до NFQUEUE." + +msgid "Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE." +msgstr "Выбор ресурсов по IP или CIDR внутри nfqws здесь не поддерживается; ресурсы выбирает sing-box до NFQUEUE." + +msgid "Resource selection by IP or CIDR inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE." +msgstr "Выбор ресурсов по IP или CIDR внутри nfqws2 здесь не поддерживается; sing-box выбирает ресурсы до NFQUEUE." + +msgid "Restart Tachyon" +msgstr "Перезапустить Tachyon" + +msgid "Resume updates" +msgstr "Продолжить обновление" + +msgid "Route" +msgstr "Маршрут" + +msgid "Route main DNS requests through the selected section." +msgstr "Маршрутизировать основные DNS-запросы через выбранную секцию." + +msgid "Route requests to this DNS server through another section." +msgstr "Направлять запросы к этому DNS-серверу через другую секцию." + +msgid "Routing mode" +msgstr "Режим маршрутизации" + +msgid "Routing rules" +msgstr "Правила маршрутизации" + +msgid "Routing section" +msgstr "Секция маршрутизации" + +msgid "Rule set must be an HTTP(S) URL or a local .srs / .json path" +msgstr "Набор правил должен быть HTTP(S) URL или локальным путём .srs / .json" + +msgid "Rule set settings" +msgstr "Настройки набора правил" + +msgid "Rule sets" +msgstr "Наборы правил" + +msgid "Rules" +msgstr "Правила" + +msgid "Rules mangle counters" +msgstr "Счётчики правил mangle" + +msgid "Rules mangle exist" +msgstr "Правила mangle существуют" + +msgid "Rules mangle output counters" +msgstr "Счётчики правил mangle output" + +msgid "Rules mangle output exist" +msgstr "Правила mangle output существуют" + +msgid "Rules proxy counters" +msgstr "Счётчики правил proxy" + +msgid "Rules proxy exist" +msgstr "Правила прокси существуют" + +msgid "Run Diagnostic" +msgstr "Запустить диагностику" + +msgid "Russia inside restrictions" +msgstr "Ограничения Russia inside" + +msgid "Save" +msgstr "Сохранить" + +msgid "Search" +msgstr "Поиск" + +msgid "Secret" +msgstr "Секрет" + +msgid "Secret key for authenticating remote access to YACD when WAN access is enabled." +msgstr "Секретный ключ для аутентификации удаленного доступа к YACD при включенном доступе через WAN." + +msgid "Section" +msgstr "Секция" + +msgid "Section name" +msgstr "Имя секции" + +msgid "Sections" +msgstr "Секции" + +msgid "Security" +msgstr "Защита" + +msgid "Select a common client profile or enter a custom User-Agent" +msgstr "Выберите распространённый профиль клиента или введите свой User-Agent" + +msgid "Select a network interface" +msgstr "Выберите сетевой интерфейс" + +msgid "Select a predefined domain list" +msgstr "Выберите готовый список доменов" + +msgid "Select a section" +msgstr "Выберите секцию" + +msgid "Select a section for downloading this subscription" +msgstr "Выберите секцию для скачивания этой подписки" + +msgid "Select a transit section" +msgstr "Выберите транзитную секцию" + +msgid "Select an existing network interface" +msgstr "Выберите существующий сетевой интерфейс" + +msgid "Select an existing URLTest or Priority group" +msgstr "Выберите существующую URLTest или Приоритет группу" + +msgid "Select an intermediate section" +msgstr "Выберите промежуточную секцию" + +msgid "Select DNS protocol to use" +msgstr "Выберите протокол DNS" + +msgid "Select network interface for VPN connection" +msgstr "Выберите сетевой интерфейс для VPN-подключения" + +msgid "Select or enter a User-Agent" +msgstr "Выберите или введите User-Agent" + +msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing" +msgstr "Выберите или введите путь к файлу кеша sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете" + +msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing" +msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете" + +msgid "Select the fastest node" +msgstr "Выбирать самый быстрый узел" + +msgid "Select the log level for sing-box" +msgstr "Выберите уровень логов для sing-box" + +msgid "Select the network interface from which the traffic will originate" +msgstr "Выберите сетевой интерфейс, с которого будет исходить трафик" + +msgid "Select the network interface to which the traffic will originate" +msgstr "Выберите сетевой интерфейс, на который будет поступать трафик." + +msgid "Select the WAN interfaces to be monitored" +msgstr "Выберите WAN интерфейсы для мониторинга" + +msgid "Selected" +msgstr "Выбран" + +msgid "Selected section" +msgstr "Выбранная секция" + +msgid "Selector and URLTest outbounds must contain a non-empty outbounds array" +msgstr "Selector и URLTest outbound должны содержать непустой массив outbounds" + +msgid "Server" +msgstr "Сервер" + +msgid "Server filtering" +msgstr "Фильтрация серверов" + +msgid "Server group for automatic lowest-latency selection" +msgstr "Группа серверов для автоматического выбора по наименьшей задержке" + +msgid "Server group for priority failover" +msgstr "Группа автоматически выбирает рабочий узел на основе установленного приоритета." + +msgid "Server information" +msgstr "Информация о сервере" + +msgid "Server name" +msgstr "Имя сервера" + +msgid "Server name / SNI" +msgstr "Имя сервера / SNI" + +msgid "Server outbound must contain a non-empty server field" +msgstr "Серверный outbound должен содержать непустое поле server" + +msgid "Server outbound must contain a numeric server_port from 1 to 65535" +msgstr "Серверный outbound должен содержать числовой server_port от 1 до 65535" + +msgid "server_port must be a number from 1 to 65535" +msgstr "server_port должен быть числом от 1 до 65535" + +msgid "Servers" +msgstr "Серверы" + +msgid "Servers on dashboard" +msgstr "Серверы в дашборде" + +msgid "Service action failed" +msgstr "Действие сервиса не выполнено" + +msgid "Service list" +msgstr "Список сервисов" + +msgid "Services info" +msgstr "Информация о сервисах" + +msgid "Settings" +msgstr "Настройки" + +msgid "Show metadata on dashboard" +msgstr "Отображать метаданные в дашборде" + +msgid "Show only selected servers." +msgstr "Показывать только выбранные серверы." + +msgid "Show only servers with one of the selected protocols." +msgstr "Показывать только серверы с одним из выбранных протоколов." + +msgid "Show only servers with one of the selected security types." +msgstr "Показывать только серверы с одним из выбранных типов защиты." + +msgid "Show only servers with one of the selected transports." +msgstr "Показывать только серверы с одним из выбранных транспортов." + +msgid "Show servers from selected groups without repeating their filtering criteria." +msgstr "Показывать серверы из выбранных групп без повторения их параметров фильтрации." + +msgid "Show servers only from the specified countries." +msgstr "Показывать серверы только из указанных стран." + +msgid "Show servers whose names match the expression." +msgstr "Показывать серверы, имена которых совпадают с выражением." + +msgid "Show sing-box config" +msgstr "Показать sing-box конфигурацию" + +msgid "Show subscription metadata for this source on the dashboard" +msgstr "Отображать метаданные этой подписки в дашборде" + +msgid "Sing-box autostart disabled" +msgstr "Автостарт sing-box отключен" + +msgid "Sing-box FakeIP DNS does not work" +msgstr "FakeIP DNS sing-box не работает" + +msgid "Sing-box FakeIP DNS works" +msgstr "FakeIP DNS sing-box работает" + +msgid "Sing-box installed" +msgstr "Sing-box установлен" + +msgid "Sing-box listening ports" +msgstr "Sing-box слушает порты" + +msgid "Sing-box process running" +msgstr "Процесс sing-box запущен" + +msgid "Sing-box service exist" +msgstr "Сервис sing-box существует" + +msgid "Sing-box version is compatible (newer than 1.12.4)" +msgstr "Версия Sing-box совместима (новее 1.12.4)" + +msgid "Sort by latency" +msgstr "Сортировать по задержке" + +msgid "Sorts servers in this section by lowest latency in the dashboard." +msgstr "Сортирует серверы этой секции по наименьшей задержке в дашборде" + +msgid "Source" +msgstr "Источник" + +msgid "Source Network Interface" +msgstr "Сетевой интерфейс источника" + +msgid "Specify a file path" +msgstr "Укажите путь к файлу" + +msgid "Specify different files" +msgstr "Укажите разные файлы" + +msgid "Standalone ByeDPI autostart is enabled" +msgstr "Автозапуск отдельной службы ByeDPI включен" + +msgid "Standalone ByeDPI is active together with Tachyon ByeDPI rules" +msgstr "Отдельная служба ByeDPI активна вместе с правилами ByeDPI в Tachyon" + +msgid "Standalone ByeDPI service is active" +msgstr "Отдельная служба ByeDPI активна" + +msgid "Standalone ByeDPI service is inactive" +msgstr "Отдельная служба ByeDPI неактивна" + +msgid "Standalone Zapret is active together with Tachyon Zapret rules" +msgstr "Отдельная служба Zapret активна вместе с правилами Zapret в Tachyon" + +msgid "Standalone Zapret service is active" +msgstr "Отдельная служба Zapret активна" + +msgid "Standalone Zapret service is inactive" +msgstr "Отдельная служба Zapret неактивна" + +msgid "Standalone Zapret2 autostart is enabled" +msgstr "Автозапуск отдельной службы Zapret2 включён" + +msgid "Standalone Zapret2 is active together with Tachyon Zapret2 rules" +msgstr "Отдельная служба Zapret2 активна вместе с правилами Zapret2 в Tachyon" + +msgid "Standalone Zapret2 service is active" +msgstr "Отдельная служба Zapret2 активна" + +msgid "Standalone Zapret2 service is inactive" +msgstr "Отдельная служба Zapret2 неактивна" + +msgid "Start Tachyon" +msgstr "Запустить Tachyon" + +msgid "Stop checking when URLTest group is not used. Use sing-box duration format like 1d, 12h or 30m." +msgstr "Останавливать проверку если URLTest группа не используется. Используйте формат длительности sing-box, например 1d, 12h или 30m." + +msgid "Stop Tachyon" +msgstr "Остановить Tachyon" + +msgid "Subnets from the list will be extracted and added to nftables" +msgstr "IP-адреса и подсети из списка будут извлечены и добавлены в nftables" + +msgid "Subscription auto update" +msgstr "Автообновление подписи" + +msgid "Subscription info:" +msgstr "Информация о подписке:" + +msgid "Subscription update failed" +msgstr "Обновление подписки завершилось ошибкой" + +msgid "Subscription update interval" +msgstr "Интервал обновления подписки" + +msgid "Subscription URL" +msgstr "URL подписки" + +msgid "Subscription URL cannot be empty" +msgstr "URL подписки не может быть пустым" + +msgid "Subscription URL settings" +msgstr "Настройки URL подписки" + +msgid "Subscriptions updated" +msgstr "Подписки обновлены" + +msgid "System info" +msgstr "Системная информация" + +msgid "System information" +msgstr "Системная информация" + +msgid "Table exist" +msgstr "Таблица существует" + +msgid "Tailscale auth key" +msgstr "Ключ авторизации Tailscale" + +msgid "Tailscale control URL" +msgstr "URL control plane Tailscale" + +msgid "Tailscale endpoint" +msgstr "Tailscale endpoint" + +msgid "Tailscale hostname" +msgstr "Hostname Tailscale" + +msgid "Test latency" +msgstr "Тестирование задержки" + +msgid "Test only selected servers." +msgstr "Тестировать только выбранные серверы." + +msgid "Test only servers with one of the selected protocols." +msgstr "Тестировать только серверы с одним из выбранных протоколов." + +msgid "Test only servers with one of the selected security types." +msgstr "Тестировать только серверы с одним из выбранных типов защиты." + +msgid "Test only servers with one of the selected transports." +msgstr "Тестировать только серверы с одним из выбранных транспортов." + +msgid "Test servers only from the specified countries." +msgstr "Тестировать серверы только из указанных стран." + +msgid "Test servers whose names match the expression." +msgstr "Тестировать серверы, имена которых совпадают с выражением." + +msgid "Testing URL" +msgstr "URL проверки" + +msgid "The desync fwmark is managed by Tachyon for loop prevention and must not be overridden here." +msgstr "Desync fwmark управляется Tachyon для защиты от циклов и не должен переопределяться здесь." + +msgid "The NFQUEUE number is assigned by Tachyon for each rule and must not be overridden here." +msgstr "Номер NFQUEUE назначается Tachyon для каждого правила и не должен переопределяться здесь." + +msgid "The rule applies to the domain and all its subdomains. Use full:, keyword:, or regex: prefixes for exact match, keyword match, or regular expression." +msgstr "Правило применяется к домену и всем его поддоменам. Для полного совпадения, совпадению по слову или регулярному выражению используйте префиксы full:, keyword: или regex:." + +msgid "There are rules using ByeDPI" +msgstr "Есть правила, использующие ByeDPI" + +msgid "There are rules using Zapret" +msgstr "Есть правила использующие Zapret" + +msgid "There are rules using Zapret2" +msgstr "Есть правила, использующие Zapret2" + +msgid "This field is required" +msgstr "Заполните это поле" + +msgid "This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here." +msgstr "Это поле должно запускать рабочую стратегию ciadpi; параметры help/version сразу завершают процесс и здесь запрещены." + +msgid "This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here." +msgstr "Это поле должно запускать рабочую стратегию nfqws; --dry-run сразу завершает процесс и здесь запрещён." + +msgid "This field must start a working nfqws strategy; --version exits immediately and is not allowed here." +msgstr "Это поле должно запускать рабочую стратегию nfqws; --version сразу завершает процесс и здесь запрещён." + +msgid "This field must start a working nfqws2 strategy; --dry-run exits immediately and is not allowed here." +msgstr "Это поле должно запускать рабочую стратегию nfqws2; --dry-run сразу завершает работу и здесь запрещён." + +msgid "This field must start a working nfqws2 strategy; --version exits immediately and is not allowed here." +msgstr "Это поле должно запускать рабочую стратегию nfqws2; --version сразу завершает работу и здесь запрещён." + +msgid "This flag does not accept a value." +msgstr "Этот флаг не принимает значение." + +msgid "This option requires a value." +msgstr "Этой опции требуется значение." + +msgid "Time" +msgstr "Время" + +msgid "Time in seconds for DNS record caching (default: 60)" +msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)" + +msgid "Time skew tolerance" +msgstr "Допуск расхождения времени" + +msgid "tiny" +msgstr "tiny" + +msgid "Tolerance" +msgstr "Допуск" + +msgid "Top level has the highest priority; lower levels are used as fallback" +msgstr "Верхний уровень имеет самый высокий приоритет; нижние уровни используются как резерв" + +msgid "Traffic" +msgstr "Трафик" + +msgid "Traffic for this level goes directly." +msgstr "Трафик этого уровня пойдёт напрямую." + +msgid "Traffic Total" +msgstr "Всего трафика" + +msgid "Transparent proxy mode is incompatible with action=byedpi because Tachyon connects to ciadpi through SOCKS." +msgstr "Режим прозрачного прокси несовместим с action=byedpi, потому что Tachyon подключается к ciadpi через SOCKS." + +msgid "Transport" +msgstr "Транспорт" + +msgid "Transport host" +msgstr "Хост транспорта" + +msgid "Transport path" +msgstr "Путь транспорта" + +msgid "Transport path must start with /" +msgstr "Путь транспорта должен начинаться с /" + +msgid "Troubleshooting" +msgstr "Устранение неполадок" + +msgid "Try compatible User-Agent profiles automatically when downloading this subscription" +msgstr "Автоматически пробовать совместимые профили User-Agent при загрузке этой подписки" + +msgid "TTL must be a positive number" +msgstr "TTL должно быть положительным числом" + +msgid "TTL value cannot be empty" +msgstr "Значение TTL не может быть пустым" + +msgid "Type" +msgstr "Тип" + +msgid "UDP (Unprotected DNS)" +msgstr "UDP (Незащищённый DNS)" + +msgid "Unable to validate the ByeDPI strategy through the backend parser." +msgstr "Не удалось проверить стратегию ByeDPI через backend-парсер." + +msgid "Unable to validate the NFQWS strategy through the backend parser." +msgstr "Не удалось проверить стратегию NFQWS через backend-парсер." + +msgid "Unable to validate the NFQWS2 strategy through the backend parser." +msgstr "Не удалось проверить стратегию NFQWS2 через backend-парсер." + +msgid "Unavailability timeout" +msgstr "Таймаут недоступности" + +msgid "Unexpected ByeDPI strategy argument: %s" +msgstr "Неожиданный аргумент стратегии ByeDPI: %s" + +msgid "Unexpected Tachyon-managed ciadpi runtime is running" +msgstr "Обнаружен лишний ciadpi Tachyon" + +msgid "Unexpected Tachyon-managed nfqws runtime is running" +msgstr "Обнаружен лишний NFQWS Tachyon" + +msgid "Unexpected Tachyon-managed nfqws2 runtime is running" +msgstr "Обнаружен лишний NFQWS2 Tachyon" + +msgid "Unexpected standalone token. Use explicit flags such as --name or --name=value." +msgstr "Неожиданный отдельный токен. Используйте явные флаги вида --имя или --имя=значение." + +msgid "unique UCI identifier" +msgstr "уникальный идентификатор UCI" + +msgid "unknown" +msgstr "неизвестно" + +msgid "Unknown ByeDPI option: %s" +msgstr "Неизвестная опция ByeDPI: %s" + +msgid "Unknown country" +msgstr "Неизвестная страна" + +msgid "Unknown error" +msgstr "Неизвестная ошибка" + +msgid "Unknown NFQWS flag." +msgstr "Неизвестный флаг NFQWS." + +msgid "Unknown NFQWS2 flag." +msgstr "Неизвестный флаг NFQWS2." + +msgid "Unspecified IP address is not allowed" +msgstr "Неуказанный IP-адрес не разрешён" + +msgid "Unsupported security mode for this protocol" +msgstr "Этот протокол не поддерживает выбранный режим защиты" + +msgid "Update" +msgstr "Обновить" + +msgid "Update is available" +msgstr "Доступно обновление" + +msgid "Update is available:" +msgstr "Доступна новая версия:" + +msgid "Update subscriptions" +msgstr "Обновить подписки" + +msgid "Update this subscription automatically" +msgstr "Автоматически обновлять эту подписку" + +msgid "Uplink" +msgstr "Исходящий" + +msgid "Uploaded" +msgstr "Отдано" + +msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://" +msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2:// или hy2://" + +msgid "URL must use one of the following protocols:" +msgstr "URL должен использовать один из следующих протоколов:" + +msgid "URL used to check whether a server is alive" +msgstr "URL, который используется для проверки, что сервер жив" + +msgid "URL used to test server latency" +msgstr "URL который будет использоваться для тестирования задержки серверов" + +msgid "URLTest" +msgstr "URLTest" + +msgid "URLTest details" +msgstr "Детали URLTest" + +msgid "URLTest details are unavailable" +msgstr "Детали URLTest недоступны" + +msgid "URLTest settings" +msgstr "Настройки URLTest" + +msgid "Use 32 hex characters" +msgstr "Укажите 32 hex-символа" + +msgid "Use a domain name or IP address" +msgstr "Используйте доменное имя или IP-адрес" + +msgid "Use a duration like 30s, 5m, or 1h30m" +msgstr "Укажите длительность в формате 30s, 5m или 1h30m" + +msgid "Use a non-negative integer" +msgstr "Используйте неотрицательное целое число" + +msgid "Use a non-zero secret" +msgstr "Укажите ненулевой секрет" + +msgid "Use an HTTP or HTTPS URL" +msgstr "Укажите HTTP или HTTPS URL" + +msgid "Use an IP address" +msgstr "Используйте IP-адрес" + +msgid "Use an IP listen address" +msgstr "Используйте IP-адрес для прослушивания" + +msgid "Use another section as an intermediate hop to connect to servers in this section. Does not apply to network interfaces or JSON outbounds." +msgstr "Использовать другую секцию как промежуточный узел подключения к серверам этой секции. Не применяется к сетевым интерфейсам и JSON outbound." + +msgid "Use CIDR prefixes like 192.168.1.0/24 or 2001:db8::/32" +msgstr "Используйте CIDR-префиксы, например 192.168.1.0/24 или 2001:db8::/32" + +msgid "Use CIDR prefixes or IP addresses like 192.168.1.0/24, 192.168.1.10, or 2001:db8::1" +msgstr "Используйте CIDR-префиксы или IP-адреса, например 192.168.1.0/24, 192.168.1.10 или 2001:db8::1" + +msgid "Use letters, digits, dots, underscores, or hyphens" +msgstr "Используйте буквы, цифры, точки, подчёркивания или дефисы" + +msgid "Use sing-box duration format like 10s, 1m or 2m30s" +msgstr "Используйте формат длительности sing-box, например 10s, 1m или 2m30s" + +msgid "Use sing-box duration format like 1d, 12h or 30m" +msgstr "Используйте формат длительности sing-box, например 1d, 12h или 30m" + +msgid "Used by" +msgstr "Используется" + +msgid "User-Agent" +msgstr "User-Agent" + +msgid "Username" +msgstr "Имя пользователя" + +msgid "Username cannot be empty" +msgstr "Логин не может быть пустым" + +msgid "Valid" +msgstr "Валидно" + +msgid "Validation errors:" +msgstr "Ошибки валидации:" + +msgid "Value cannot be empty" +msgstr "Значение не может быть пустым" + +msgid "Value must not contain control characters" +msgstr "Значение не должно содержать управляющие символы" + +msgid "Via country.is" +msgstr "Через сервис country.is" + +msgid "View logs" +msgstr "Посмотреть логи" + +msgid "Visible name of this section" +msgstr "Видимое имя этой секции" + +msgid "VLESS flow" +msgstr "VLESS flow" + +msgid "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links" +msgstr "ссылки vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2://" + +msgid "WAN" +msgstr "WAN" + +msgid "WAN public IP" +msgstr "Публичный WAN IP" + +msgid "Warning: %s cannot be used together with %s. Previous selections have been removed." +msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены." + +msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection." +msgstr "Предупреждение: Russia inside может быть использован только с %s. %s уже есть в Russia inside и будет удален из выбранных." + +msgid "What Tachyon should do when this section matches" +msgstr "Что Tachyon должен делать при совпадении этой секции" + +msgid "When switching to another level, test every server and select the fastest instead of the first working one." +msgstr "При переходе на другой уровень тестировать все сервера и выбирать самый быстрый, а не первый работающий." + +msgid "XHTTP mode" +msgstr "Режим XHTTP" + +msgid "YACD Secret Key" +msgstr "Секретный ключ YACD" + +msgid "Yes" +msgstr "Да" + +msgid "You can select Output Network Interface, by default autodetect" +msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически." + +msgid "Zapret hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS strategies." +msgstr "Шаблоны hostlist zapret здесь не поддерживаются, потому что Tachyon не разворачивает их для per-rule стратегий NFQWS." + +msgid "Zapret package is installed" +msgstr "Пакет Zapret установлен" + +msgid "Zapret package is not installed" +msgstr "Пакет Zapret не установлен" + +msgid "Zapret provider binary is available" +msgstr "Бинарник провайдера Zapret доступен" + +msgid "Zapret provider binary is not available" +msgstr "Бинарник провайдера Zapret недоступен" + +msgid "Zapret sing-box outbound is configured" +msgstr "sing-box outbound для Zapret настроен" + +msgid "Zapret sing-box outbound is not configured" +msgstr "sing-box outbound для Zapret не настроен" + +msgid "Zapret2 hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS2 strategies." +msgstr "Шаблоны hostlist Zapret2 здесь не поддерживаются, потому что Tachyon не разворачивает их для per-rule стратегий NFQWS2." + +msgid "Zapret2 package is installed" +msgstr "Пакет Zapret2 установлен" + +msgid "Zapret2 package is not installed" +msgstr "Пакет Zapret2 не установлен" + +msgid "Zapret2 provider binary is available" +msgstr "Бинарник провайдера Zapret2 доступен" + +msgid "Zapret2 provider binary is not available" +msgstr "Бинарник провайдера Zapret2 недоступен" + +msgid "Zapret2 sing-box outbound is configured" +msgstr "sing-box outbound для Zapret2 настроен" + +msgid "Zapret2 sing-box outbound is not configured" +msgstr "sing-box outbound для Zapret2 не настроен" diff --git a/luci-app-tachyon/po/templates/tachyon.pot b/luci-app-tachyon/po/templates/tachyon.pot new file mode 100644 index 00000000..14e8bfec --- /dev/null +++ b/luci-app-tachyon/po/templates/tachyon.pot @@ -0,0 +1,3354 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the TACHYON package. +# ushan0v , 2026. +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TACHYON\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-13 22:05+0300\n" +"PO-Revision-Date: 2026-07-13 22:05+0300\n" +"Last-Translator: ushan0v \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1961 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2308 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2648 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2663 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2861 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2876 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3112 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3264 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3274 +msgid "-- Select --" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7372 +msgid "+ Add JSON outbound" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3028 +msgid "+ Add level" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7458 +msgid "+ Add priority" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7399 +msgid "+ Add URLTest" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1675 +#: src/tachyon/tabs/dashboard/initController.ts:1686 +msgid "✔ Running" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1676 +#: src/tachyon/tabs/dashboard/initController.ts:1687 +msgid "✘ Stopped" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:408 +msgid "Accept external proxy connections and route them with sing-box." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3133 +msgid "Accept routes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7013 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7042 +msgid "Action" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:667 +#: src/tachyon/tabs/monitoring/render.ts:20 +msgid "Active" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:56 +msgid "Active Bootstrap DNS" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1633 +msgid "Active Connections" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:67 +msgid "Active Main DNS" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:397 +msgid "Add a section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:414 +msgid "Add a server inbound" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:119 +msgid "Add at least one DNS server" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2357 +msgid "Add prefix to nodes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7915 +msgid "Add URLs or local paths to .lst lists. Only domain rules are supported." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7861 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7890 +msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3286 +msgid "Add URLTest / Priority group" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:106 +msgid "Additional marking rules found" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2578 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2788 +msgid "Additionally exclude servers by protocol, transport, and security. Exclude only servers matching the specified parameters." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2561 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2770 +msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3186 +msgid "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3166 +msgid "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1653 +msgid "Address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3116 +msgid "Advertise exit node" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3126 +msgid "Advertise routes" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:626 +#: src/tachyon/tabs/monitoring/render.ts:39 +msgid "All" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1858 +msgid "All except selected" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1868 +msgid "All remaining except selected" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1866 +msgid "All remaining servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2744 +msgid "All remaining servers means every server not already assigned to a higher-priority level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1857 +msgid "All servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7939 +msgid "All traffic from these IP addresses will be routed through the section unconditionally, ignoring all other conditions." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5131 +msgid "Allowed domain prefixes are full:, keyword:, and regex:" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:423 +msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2538 +msgid "Allows limiting the list of servers for URLTest" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2645 +msgid "ALPN" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2879 +msgid "Alter ID" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7930 +msgid "Apply section rules only to the specified local IP addresses" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5162 +msgid "At least one valid domain must be specified." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5244 +msgid "At least one valid IP or subnet must be specified." +msgstr "" + +#: src/partials/modal/renderModal.ts:286 +msgid "Auto refresh" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3036 +msgid "Auto update" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2324 +msgid "Auto-generate HWID" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:501 +msgid "Automatic component update checks" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2293 +msgid "Automatic User-Agent selection" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2358 +msgid "Automatically add text to the name of each server from this subscription for convenient filtering." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:502 +msgid "Automatically check installed components for new versions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2978 +#: src/tachyon/tabs/dashboard/initController.ts:1155 +msgid "Automatically select the fastest node in the current level" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:43 +msgid "Available actions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5377 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5822 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6234 +msgid "Backend validation failed: %s" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:57 +msgid "Bootstrap DNS" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:182 +msgid "Bootstrap DNS Servers" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:51 +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:79 +msgid "Browser FakeIP check could not be completed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:82 +msgid "Browser is not using FakeIP" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:81 +msgid "Browser is using FakeIP correctly" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7760 +msgid "Built-in rule sets" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1875 +msgid "By flag emoji from name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6303 +msgid "ByeDPI listen address and port are assigned by Tachyon and must not be set in the strategy." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6480 +msgid "ByeDPI option does not accept a compact value: %s" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6409 +msgid "ByeDPI option does not accept a value: %s" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6401 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6427 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6461 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6470 +msgid "ByeDPI option requires a value: %s" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:71 +msgid "ByeDPI package is installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:72 +msgid "ByeDPI package is not installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:64 +msgid "ByeDPI provider binary is available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:65 +msgid "ByeDPI provider binary is not available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:107 +msgid "ByeDPI sing-box outbound is configured" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:108 +msgid "ByeDPI sing-box outbound is not configured" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7214 +msgid "ByeDPI Strategy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6506 +msgid "ByeDPI strategy cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:623 +msgid "Cache File Path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:637 +msgid "Cache file path cannot be empty" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:26 +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:27 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:172 +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:28 +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:48 +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:25 +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:26 +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:26 +msgid "Cannot receive checks result" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2241 +msgid "Cannot save server" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3414 +msgid "Cannot save settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7491 +msgid "Cascade connection" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2655 +msgid "Certificate path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1598 +msgid "Certificate pin is unavailable. Apply or restart the server to generate the default certificate, then open this dialog again." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2464 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2929 +#: src/tachyon/tabs/dashboard/initController.ts:1142 +msgid "Check interval" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2942 +msgid "Check timeout after which the server is considered dead" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:740 +msgid "Check update" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2490 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2916 +#: src/tachyon/tabs/dashboard/initController.ts:1140 +msgid "Check URL" +msgstr "" + +#: src/tachyon/tabs/diagnostic/initController.ts:1022 +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:14 +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:15 +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:15 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:160 +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:13 +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:34 +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:13 +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:14 +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:14 +msgid "Checking, please wait" +msgstr "" + +#: src/tachyon/tabs/diagnostic/helpers/getCheckTitle.ts:2 +msgid "checks" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts:10 +#: src/tachyon/tabs/diagnostic/helpers/getMeta.ts:26 +msgid "Checks failed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts:23 +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:43 +#: src/tachyon/tabs/diagnostic/helpers/getMeta.ts:13 +msgid "Checks passed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts:27 +msgid "Checks passed with manual DHCP" +msgstr "" + +#: src/validators/validateSubnet.ts:35 +msgid "CIDR must be between 0 and 32 for IPv4 or 0 and 128 for IPv6" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2753 +msgid "Client fingerprint" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1670 +msgid "Client link" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1578 +msgid "Client link is not available yet" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1611 +msgid "Client link QR code" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3481 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1718 +#: src/partials/modal/renderModal.ts:263 +#: src/tachyon/tabs/dashboard/initController.ts:1027 +#: src/tachyon/tabs/dashboard/initController.ts:1283 +#: src/tachyon/tabs/monitoring/initController.ts:832 +#: src/tachyon/tabs/monitoring/initController.ts:882 +msgid "Close" +msgstr "" + +#: src/tachyon/tabs/monitoring/render.ts:58 +#: src/tachyon/tabs/monitoring/render.ts:59 +msgid "Close all connections" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:799 +#: src/tachyon/tabs/monitoring/initController.ts:800 +msgid "Close connection" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:674 +#: src/tachyon/tabs/monitoring/render.ts:29 +msgid "Closed" +msgstr "" + +#: src/tachyon/services/core.service.ts:43 +msgid "Component update available" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:510 +msgid "Component update check interval" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:473 +msgid "Components" +msgstr "" + +#: src/tachyon/helpers/singBoxVariant.ts:51 +msgid "compressed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2988 +msgid "Concurrency" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7001 +msgid "Conditions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:610 +msgid "Config File Path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:343 +msgid "Configuration for Tachyon service" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4473 +msgid "Configure User-Agent in the item settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7529 +msgid "Connect through" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7252 +msgid "Connection URL" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:946 +msgid "Connections are unavailable" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:145 +msgid "Connectivity probe failed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1565 +#: src/helpers/copyToClipboard.ts:10 +msgid "Copied" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1694 +#: src/partials/modal/renderModal.ts:258 +msgid "Copy" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:997 +#: src/tachyon/tabs/dashboard/initController.ts:1253 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:366 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:367 +msgid "Copy proxy link" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:89 +msgid "Could not compare FakeIP and control public IPs" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7546 +msgid "Current section cannot be used as its own transit section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2285 +msgid "Current section cannot download its own subscription" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderWidget.ts:22 +msgid "Currently unavailable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7367 +msgid "Custom outbound configurations in JSON format" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:449 +msgid "Dashboard" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:47 +msgid "Dashboard currently unavailable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:546 +msgid "Default address for checking server availability and latency. URLTest uses its own address." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:398 +msgid "Delay in milliseconds before reloading Tachyon after interface UP" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:405 +msgid "Delay value cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1745 +msgid "Delete" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2548 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2757 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3153 +msgid "Detect server country" +msgstr "" + +#: src/validators/validateOutboundJson.ts:115 +msgid "detour must be a non-empty string" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7929 +msgid "Device filter" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6769 +msgid "Device or IP" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts:37 +msgid "DHCP has DNS server" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getDnsCheckPresentation.ts:36 +msgid "DHCP is managed manually" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:437 +msgid "Diagnostics" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2563 +msgid "Direct" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2734 +msgid "Direct connection" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:79 +msgid "Disable autostart" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:445 +msgid "Disable QUIC" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:446 +msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5904 +msgid "Disabling interception is incompatible with action=zapret2 because Tachyon sends matched traffic through NFQUEUE." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2446 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2905 +msgid "Display name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:196 +msgid "DNS Check Interval" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:75 +msgid "DNS on router" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1743 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:163 +msgid "DNS over HTTPS (DoH)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1744 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:164 +msgid "DNS over TLS (DoT)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2413 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7063 +msgid "DNS protocol" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:160 +msgid "DNS Protocol Type" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2414 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7064 +msgid "DNS protocol used by the resolver" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7107 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:259 +msgid "DNS requests through section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:222 +msgid "DNS Rewrite TTL" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2423 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7076 +msgid "DNS server" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7085 +#: src/validators/validateDns.ts:8 +msgid "DNS server address cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2424 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7077 +msgid "DNS server used by the resolver" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:183 +msgid "DNS server used to obtain IP addresses for upstream DNS and proxies. If multiple servers are selected, a timeout switches to a backup." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:172 +msgid "DNS Servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:240 +msgid "DNS Strategy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:251 +msgid "DNS through proxy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7095 +msgid "DNS through section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:212 +msgid "DNS Unavailability Timeout" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts:26 +msgid "Do not panic, everything can be fixed, just..." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2621 +msgid "Do not test servers from these countries." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2637 +msgid "Do not test servers whose names match the expression." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2582 +msgid "Do not test servers with one of the selected protocols." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2588 +msgid "Do not test servers with one of the selected security types." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2585 +msgid "Do not test servers with one of the selected transports." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2629 +msgid "Do not test specified servers." +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:54 +msgid "Does not resolve" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7914 +msgid "Domain lists" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2404 +msgid "Domain Resolver" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7721 +msgid "Domains" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:601 +msgid "Dont Touch My DHCP!" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1559 +#: src/tachyon/tabs/dashboard/initController.ts:1597 +msgid "Downlink" +msgstr "" + +#: src/partials/modal/renderModal.ts:253 +msgid "Download" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:582 +msgid "Download component packages via the selected section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:589 +msgid "Download components through" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:581 +msgid "Download components through a section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:569 +msgid "Download lists through" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:561 +msgid "Download lists through a section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:562 +msgid "Download remote lists and rule sets via the selected section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2259 +msgid "Download subscription through a section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2260 +msgid "Download subscriptions via the selected section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2268 +msgid "Download through" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:825 +#: src/tachyon/tabs/monitoring/initController.ts:879 +msgid "Downloaded" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:391 +msgid "Drag rows to change priority. The rule at the top is checked first." +msgstr "" + +#: src/validators/validateOutboundJson.ts:75 +msgid "Duplicate JSON outbound tag" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4568 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4583 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4603 +msgid "Duplicate value" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:68 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1738 +msgid "Edit rule action" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7003 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2480 +msgid "Enable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2840 +msgid "Enable authentication" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:457 +msgid "Enable automatic updates for remote lists and rule sets" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:89 +msgid "Enable autostart" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2405 +msgid "Enable built-in DNS resolver for domains handled by this section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:456 +msgid "Enable list updates" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7567 +msgid "Enable Mixed Proxy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7608 +msgid "Enable Mixed Proxy Authentication" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:304 +msgid "Enable Output Network Interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:413 +msgid "Enable YACD" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:422 +msgid "Enable YACD WAN Access" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2586 +msgid "Enter a complete sing-box inbound object" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3815 +msgid "Enter a complete sing-box outbound object" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2458 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2910 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3770 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3786 +msgid "Enter a display name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2707 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2728 +msgid "Enter a level name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7088 +msgid "Enter a valid DNS server address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2342 +msgid "Enter HWID" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2334 +msgid "Enter the HWID sent with subscription requests" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7278 +msgid "Enter the subscription URL" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2577 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2787 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3185 +msgid "Exclude by proxy parameters" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2636 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2849 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3245 +msgid "Exclude by regular expression" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2620 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2833 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3229 +msgid "Exclude countries" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:675 +msgid "Exclude NTP" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:676 +msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2628 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2841 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3237 +msgid "Exclude servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2795 +msgid "Exclude servers with one of the selected protocols from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2801 +msgid "Exclude servers with one of the selected security types from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2798 +msgid "Exclude servers with one of the selected transports from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3300 +msgid "Exclude URLTest / Priority group" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:54 +msgid "Expecting: %s" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:134 +msgid "Expires" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7568 +msgid "Expose this section as a local HTTP+SOCKS proxy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5438 +msgid "External nfqws config files bypass Tachyon queue management and explicit validation." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5883 +msgid "External nfqws2 config files bypass Tachyon queue management and explicit validation." +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:1213 +#: src/tachyon/tabs/monitoring/initController.ts:1228 +msgid "Failed to close connection" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:1247 +#: src/tachyon/tabs/monitoring/initController.ts:1260 +msgid "Failed to close connections" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1567 +#: src/helpers/copyToClipboard.ts:12 +msgid "Failed to copy!" +msgstr "" + +#: src/tachyon/methods/shell/index.ts:170 +#: src/tachyon/methods/shell/index.ts:177 +#: src/tachyon/tabs/updates/initController.ts:508 +#: src/tachyon/tabs/updates/initController.ts:509 +#: src/tachyon/tabs/updates/initController.ts:569 +#: src/tachyon/tabs/updates/initController.ts:719 +msgid "Failed to execute" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:275 +#: src/tachyon/tabs/dashboard/initController.ts:276 +#: src/tachyon/tabs/dashboard/initController.ts:295 +#: src/tachyon/tabs/dashboard/initController.ts:341 +#: src/tachyon/tabs/dashboard/initController.ts:345 +#: src/tachyon/tabs/dashboard/initController.ts:1341 +#: src/tachyon/tabs/dashboard/initController.ts:1345 +msgid "Failed to update subscriptions" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:91 +msgid "FakeIP and control checks use different public IPs" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:92 +msgid "FakeIP and control checks use the same public IP" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:46 +msgid "FakeIP works; public IP comparison is inconclusive" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2963 +msgid "FakeTLS host" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3045 +msgid "Fallback on unknown DC" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2989 +#: src/tachyon/tabs/dashboard/initController.ts:1161 +msgid "Faster server search interval" +msgstr "" + +#: src/tachyon/methods/custom/getDashboardSections.ts:641 +msgid "Fastest" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3142 +msgid "Filter the servers that will be displayed on the dashboard." +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:136 +msgid "Firewall WAN port" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3415 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2242 +msgid "Fix the highlighted fields and save again." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7938 +msgid "Forced device routing" +msgstr "" + +#: src/tachyon/services/core.service.ts:52 +msgid "Tachyon Error" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6312 +msgid "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6309 +msgid "Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5456 +msgid "Tachyon manages the nfqws process lifecycle itself, so daemon mode is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5907 +msgid "Tachyon manages the nfqws2 process lifecycle itself, so daemon mode is not allowed here." +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:925 +msgid "Tachyon service is stopped. Start the service to display connections." +msgstr "" + +#: src/tachyon/tabs/dashboard/render.ts:17 +msgid "Tachyon service is stopped. Start the service to display the dashboard." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:342 +msgid "Tachyon Settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:602 +msgid "Tachyon will not modify your DHCP configuration" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:91 +msgid "Tachyon-managed ciadpi runtime has restarted" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:94 +msgid "Tachyon-managed ciadpi runtime is not ready" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:97 +msgid "Tachyon-managed ciadpi runtime is not running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:93 +msgid "Tachyon-managed ciadpi runtime is ready" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:87 +msgid "Tachyon-managed nfqws runtime is not ready" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:90 +msgid "Tachyon-managed nfqws runtime is not running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:86 +msgid "Tachyon-managed nfqws runtime is ready" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:90 +msgid "Tachyon-managed nfqws2 runtime is not ready" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:93 +msgid "Tachyon-managed nfqws2 runtime is not running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:89 +msgid "Tachyon-managed nfqws2 runtime is ready" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3008 +msgid "Fronting IP" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2997 +msgid "Fronting port" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3018 +msgid "Fronting proxy protocol" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5901 +msgid "Fuzzing is not supported here because Tachyon needs deterministic runtime validation." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2325 +msgid "Generate HWID from router hardware information for this subscription" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:89 +msgid "Generated inbound" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:98 +msgid "Get global check" +msgstr "" + +#: src/tachyon/tabs/diagnostic/initController.ts:682 +msgid "Global check" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2820 +msgid "gRPC service" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3073 +msgid "Handshake timeout" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2393 +msgid "Hide cascade connection nodes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2382 +msgid "Hide individual nodes that are already included in imported subscription URLTest groups" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2394 +msgid "Hide intermediate nodes used as detours by other subscription nodes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3238 +msgid "Hide selected servers." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3301 +msgid "Hide servers from selected groups without repeating their filtering criteria." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3230 +msgid "Hide servers from the specified countries." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3246 +msgid "Hide servers whose names match the expression." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3193 +msgid "Hide servers with one of the selected protocols." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3197 +msgid "Hide servers with one of the selected security types." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3194 +msgid "Hide servers with one of the selected transports." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2381 +msgid "Hide URLTest group nodes" +msgstr "" + +#: src/partials/modal/renderModal.ts:310 +msgid "Hide values" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2953 +#: src/tachyon/tabs/dashboard/initController.ts:1147 +msgid "Higher-level check interval" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:204 +msgid "Higher-priority DNS Check" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:815 +#: src/tachyon/tabs/monitoring/initController.ts:875 +msgid "Host" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2954 +msgid "How often higher priority levels are checked while a lower level is active" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2930 +msgid "How often the currently selected server is checked" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:197 +msgid "How often to check the active DNS servers." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:205 +msgid "How often to check whether a higher-priority DNS server has recovered." +msgstr "" + +#: src/tachyon/api.ts:27 +msgid "HTTP error" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2813 +msgid "HTTP hosts" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2333 +msgid "HWID" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2503 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3063 +#: src/tachyon/tabs/dashboard/initController.ts:916 +msgid "Idle timeout" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2372 +msgid "Import subscription URLTest groups" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2373 +msgid "Import URLTest groups returned by this subscription provider" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2585 +msgid "Inbound JSON" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1971 +msgid "Inbound JSON cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1982 +msgid "Inbound JSON must be an object with a type field" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2560 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2769 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3165 +msgid "Include by proxy parameters" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2612 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2825 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3221 +msgid "Include by regular expression" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2596 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2809 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3205 +msgid "Include countries" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3890 +msgid "Include IP addresses and subnets" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2604 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2817 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3213 +msgid "Include servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1746 +msgid "Information" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:754 +msgid "Install" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:1159 +msgid "Install another build:" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:291 +#: src/tachyon/tabs/updates/initController.ts:1017 +msgid "Installed version is newer than release" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:365 +msgid "Interface Monitoring" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:397 +msgid "Interface Monitoring Delay" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:366 +msgid "Interface monitoring for Bad WAN" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2517 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3005 +#: src/tachyon/tabs/dashboard/initController.ts:918 +#: src/tachyon/tabs/dashboard/initController.ts:1167 +msgid "Interrupt connections" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3006 +msgid "Interrupt connections when priority failover switches server" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2518 +msgid "Interrupt connections when URLTest switches the selected server" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:914 +msgid "Interval" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7235 +msgid "Invalid ByeDPI strategy" +msgstr "" + +#: src/validators/validateSubnet.ts:25 +msgid "Invalid CIDR prefix" +msgstr "" + +#: src/validators/validateDns.ts:30 +msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5111 +#: src/validators/validateDomain.ts:67 +#: src/validators/validateDomain.ts:73 +msgid "Invalid domain address" +msgstr "" + +#: src/validators/validateSubnet.ts:10 +msgid "Invalid format. Use an IP address or CIDR subnet" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:57 +msgid "Invalid HTTP proxy URL: invalid host and port" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:87 +msgid "Invalid HTTP proxy URL: invalid host format" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:80 +msgid "Invalid HTTP proxy URL: invalid port number" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:49 +msgid "Invalid HTTP proxy URL: missing host and port" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:65 +msgid "Invalid HTTP proxy URL: missing hostname or IP" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:72 +msgid "Invalid HTTP proxy URL: missing port" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:41 +msgid "Invalid HTTP proxy URL: missing username" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:18 +msgid "Invalid HTTP proxy URL: must not contain spaces" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:9 +msgid "Invalid HTTP proxy URL: must start with http:// or https://" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:93 +msgid "Invalid HTTP proxy URL: parsing failed" +msgstr "" + +#: src/validators/validateHttpProxyUrl.ts:26 +msgid "Invalid HTTP proxy URL: path, query, and fragment are not supported" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:115 +msgid "Invalid HY2 URL: insecure must be 0 or 1" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:51 +msgid "Invalid HY2 URL: invalid host & port" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:101 +#: src/validators/validateHysteriaUrl.ts:157 +msgid "Invalid HY2 URL: invalid port number" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:33 +msgid "Invalid HY2 URL: missing credentials/server" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:57 +msgid "Invalid HY2 URL: missing host" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:44 +msgid "Invalid HY2 URL: missing host & port" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:39 +msgid "Invalid HY2 URL: missing password" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:61 +msgid "Invalid HY2 URL: missing port" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:20 +msgid "Invalid HY2 URL: must not contain spaces" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:13 +msgid "Invalid HY2 URL: must start with hysteria2:// or hy2://" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:138 +msgid "Invalid HY2 URL: obfs-password required when obfs is set" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:164 +msgid "Invalid HY2 URL: parsing failed" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:147 +msgid "Invalid HY2 URL: sni cannot be empty" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:127 +msgid "Invalid HY2 URL: unsupported obfs type" +msgstr "" + +#: src/validators/validateIp.ts:28 +#: src/validators/validateIp.ts:36 +#: src/validators/validateIp.ts:44 +msgid "Invalid IP address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1984 +#: src/validators/validateOutboundJson.ts:58 +msgid "Invalid JSON format" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7192 +msgid "Invalid NFQWS2 strategy" +msgstr "" + +#: src/validators/validatePath.ts:22 +msgid "Invalid path format. Path must start with \"/\" and contain valid characters" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7601 +#: src/validators/validateShadowsocksUrl.ts:93 +msgid "Invalid port number. Must be between 1 and 65535" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5260 +msgid "Invalid port or range. Use 80 or 1000-2000" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4384 +msgid "Invalid regular expression" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:38 +msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:67 +msgid "Invalid Shadowsocks URL: invalid server and port" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:28 +msgid "Invalid Shadowsocks URL: missing credentials" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:47 +msgid "Invalid Shadowsocks URL: missing method and password separator \":\"" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:84 +msgid "Invalid Shadowsocks URL: missing port" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:75 +msgid "Invalid Shadowsocks URL: missing server" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:59 +msgid "Invalid Shadowsocks URL: missing server address" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:17 +msgid "Invalid Shadowsocks URL: must not contain spaces" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:9 +msgid "Invalid Shadowsocks URL: must start with ss://" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:99 +msgid "Invalid Shadowsocks URL: parsing failed" +msgstr "" + +#: src/validators/validateSocksUrl.ts:49 +msgid "Invalid SOCKS URL: invalid host and port" +msgstr "" + +#: src/validators/validateSocksUrl.ts:76 +msgid "Invalid SOCKS URL: invalid host format" +msgstr "" + +#: src/validators/validateSocksUrl.ts:69 +msgid "Invalid SOCKS URL: invalid port number" +msgstr "" + +#: src/validators/validateSocksUrl.ts:41 +msgid "Invalid SOCKS URL: missing host and port" +msgstr "" + +#: src/validators/validateSocksUrl.ts:57 +msgid "Invalid SOCKS URL: missing hostname or IP" +msgstr "" + +#: src/validators/validateSocksUrl.ts:62 +msgid "Invalid SOCKS URL: missing port" +msgstr "" + +#: src/validators/validateSocksUrl.ts:33 +msgid "Invalid SOCKS URL: missing username" +msgstr "" + +#: src/validators/validateSocksUrl.ts:18 +msgid "Invalid SOCKS URL: must not contain spaces" +msgstr "" + +#: src/validators/validateSocksUrl.ts:9 +msgid "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://" +msgstr "" + +#: src/validators/validateSocksUrl.ts:80 +msgid "Invalid SOCKS URL: parsing failed" +msgstr "" + +#: src/validators/validateTrojanUrl.ts:16 +msgid "Invalid Trojan URL: must not contain spaces" +msgstr "" + +#: src/validators/validateTrojanUrl.ts:9 +msgid "Invalid Trojan URL: must start with trojan://" +msgstr "" + +#: src/validators/validateTrojanUrl.ts:63 +msgid "Invalid Trojan URL: parsing failed" +msgstr "" + +#: src/validators/validateUrl.ts:9 +#: src/validators/validateUrl.ts:37 +#: src/validators/validateUrl.ts:40 +msgid "Invalid URL format" +msgstr "" + +#: src/validators/validateVlessUrl.ts:118 +msgid "Invalid VLESS URL: parsing failed" +msgstr "" + +#: src/validators/validateVmessUrl.ts:68 +msgid "Invalid VMess URL: parsing failed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7750 +msgid "IPs" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:242 +msgid "IPv4 only" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:244 +msgid "IPv6 only" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/getCheckItemsMeta.ts:17 +#: src/tachyon/tabs/diagnostic/helpers/getMeta.ts:20 +msgid "Issues detected" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1036 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3814 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4050 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7366 +msgid "JSON outbound" +msgstr "" + +#: src/validators/validateOutboundJson.ts:50 +msgid "JSON outbound cannot be empty" +msgstr "" + +#: src/validators/validateOutboundJson.ts:62 +msgid "JSON outbound must be a JSON object" +msgstr "" + +#: src/validators/validateOutboundJson.ts:70 +msgid "JSON outbound must contain a non-empty tag field" +msgstr "" + +#: src/validators/validateOutboundJson.ts:66 +msgid "JSON outbound must contain a non-empty type field" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3838 +msgid "JSON outbound settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3015 +msgid "Keep Priority before latency-sorted servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2529 +msgid "Keep URLTest before latency-sorted servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2670 +msgid "Key path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4394 +msgid "Keyword must not contain spaces or commas" +msgstr "" + +#: src/tachyon/methods/shell/index.ts:462 +#: src/tachyon/methods/shell/index.ts:483 +msgid "Latency test failed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:545 +msgid "Latency test URL" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:294 +#: src/tachyon/tabs/updates/initController.ts:1015 +msgid "Latest version is installed" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:1017 +msgid "Latest version:" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1100 +msgid "Level" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2723 +msgid "Level name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6934 +msgid "List must be an HTTP(S) URL or a local .lst path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:465 +msgid "List Update Frequency" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2535 +msgid "Listen address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2542 +msgid "Listen port" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:125 +msgid "Listening port" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1703 +msgid "Loading client link..." +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:936 +#: src/tachyon/tabs/monitoring/render.ts:88 +msgid "Loading connections" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:905 +#: src/tachyon/tabs/updates/initController.ts:916 +#: src/tachyon/tabs/updates/initController.ts:927 +#: src/tachyon/tabs/updates/initController.ts:940 +#: src/tachyon/tabs/updates/initController.ts:953 +msgid "Loading..." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:659 +msgid "Log Level" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runDnsCheck.ts:67 +msgid "Main DNS" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:173 +msgid "Main DNS server. If multiple servers are selected, a timeout switches to a backup." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7751 +msgid "Match destination IPs or subnets" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7948 +msgid "Match destination ports. Use a single port or a range" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:213 +msgid "Maximum time to wait for example.com to resolve during a DNS health check." +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1637 +msgid "Memory Usage" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2827 +msgid "Method" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2477 +msgid "Minimum latency difference in milliseconds that triggers switching to a faster server." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7659 +msgid "Mixed Proxy Password" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7582 +msgid "Mixed Proxy Port" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7623 +msgid "Mixed Proxy Username" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:374 +msgid "Monitored Interfaces" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:461 +msgid "Monitoring" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1973 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1988 +msgid "Must be a number in the range of 0 - 10000" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2447 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2906 +msgid "Name displayed on the dashboard" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2724 +msgid "Name shown in the priority level list" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1788 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7325 +msgid "Network Interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3605 +msgid "Network interface settings" +msgstr "" + +#: src/tachyon/services/core.service.ts:38 +msgid "New version %s is available for %s" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:100 +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:97 +msgid "NFQUEUE range is available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:99 +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:96 +msgid "NFQUEUE range overlaps with another rule" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7124 +msgid "NFQWS Strategy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5568 +msgid "NFQWS strategy cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7171 +msgid "NFQWS2 Strategy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6042 +msgid "NFQWS2 strategy cannot be empty" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:762 +#: src/tachyon/tabs/dashboard/initController.ts:766 +#: src/tachyon/tabs/dashboard/initController.ts:842 +#: src/tachyon/tabs/dashboard/initController.ts:1055 +msgid "No" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:960 +msgid "No active connections" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:961 +msgid "No closed connections" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:32 +msgid "No conflict detected" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:187 +msgid "No enabled server inbounds configured" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:105 +msgid "No other marking rules found" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:99 +msgid "No public firewall port required" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:79 +msgid "No rules use ByeDPI" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:79 +msgid "No rules use Zapret" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:82 +msgid "No rules use Zapret2" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1012 +#: src/tachyon/tabs/dashboard/initController.ts:1268 +msgid "Node list is empty" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:942 +#: src/tachyon/tabs/dashboard/initController.ts:1191 +msgid "Nodes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:446 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2600 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2892 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2901 +msgid "None" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:46 +msgid "Not configured" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:70 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:199 +msgid "Not detected" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderCheckSection.ts:189 +msgid "Not implement yet" +msgstr "" + +#: src/tachyon/helpers/singBoxVariant.ts:29 +#: src/tachyon/helpers/singBoxVariant.ts:37 +#: src/tachyon/tabs/updates/initController.ts:930 +#: src/tachyon/tabs/updates/initController.ts:943 +#: src/tachyon/tabs/updates/initController.ts:956 +msgid "Not installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:62 +msgid "Not public" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:139 +msgid "Not required for this listen address" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:90 +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:117 +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:123 +#: src/tachyon/tabs/diagnostic/checks/runSectionsCheck.ts:151 +msgid "Not responding" +msgstr "" + +#: src/tachyon/tabs/diagnostic/diagnostic.store.ts:125 +#: src/tachyon/tabs/diagnostic/initController.ts:116 +#: src/tachyon/tabs/diagnostic/initController.ts:346 +#: src/tachyon/tabs/diagnostic/initController.ts:403 +#: src/tachyon/tabs/diagnostic/initController.ts:489 +msgid "Not running" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2900 +msgid "Obfuscation" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2923 +msgid "Obfuscation password" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2810 +msgid "Only from the specified countries." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1859 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1867 +msgid "Only selected" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1860 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1869 +msgid "Only selected except exclusions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2826 +msgid "Only servers whose names match the expression." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2777 +msgid "Only servers with one of the selected protocols." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2781 +msgid "Only servers with one of the selected security types." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2778 +msgid "Only servers with one of the selected transports." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2818 +msgid "Only the specified servers." +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:1008 +#: src/tachyon/tabs/updates/initController.ts:1030 +msgid "Open" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3782 +msgid "Open priority settings and enter a display name" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts:31 +msgid "Open Project Page" +msgstr "" + +#: src/helpers/withTimeout.ts:7 +#: src/tachyon/methods/shell/index.ts:432 +#: src/tachyon/methods/shell/index.ts:520 +msgid "Operation timed out" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1353 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4502 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6644 +msgid "Option \"%s\" contains an invalid input value." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5646 +msgid "Optional values must be attached with '=' here; a separate token would be ignored by nfqws." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6120 +msgid "Optional values must be attached with '=' here; a separate token would be ignored by nfqws2." +msgstr "" + +#: src/validators/validateOutboundJson.ts:111 +msgid "outbounds must be a non-empty array of strings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:313 +msgid "Output Network Interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2982 +msgid "Padding" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2863 +msgid "Password" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7685 +msgid "Password cannot be empty" +msgstr "" + +#: src/validators/validatePath.ts:7 +msgid "Path cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:641 +msgid "Path must be absolute (start with /)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:650 +msgid "Path must contain at least one directory (like /tmp/cache.db)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:645 +msgid "Path must end with cache.db" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:691 +#: src/tachyon/tabs/monitoring/render.ts:70 +#: src/tachyon/tabs/monitoring/render.ts:71 +msgid "Pause updates" +msgstr "" + +#: src/tachyon/tabs/diagnostic/diagnostic.store.ts:63 +msgid "Pending" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2979 +msgid "Periodically check the current level and switch to a faster server even when the current one works." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2528 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3014 +msgid "Pin on dashboard" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7593 +msgid "Port cannot be empty" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:19 +msgid "Port conflict" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7583 +msgid "Port for the local mixed proxy of this section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5267 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1814 +msgid "Port must be between 1 and 65535" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5271 +msgid "Port range start must be less than or equal to end" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7947 +msgid "Ports" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:241 +msgid "Prefer IPv4" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:243 +msgid "Prefer IPv6" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3025 +msgid "Preferred IP" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2365 +msgid "Prefix text" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7452 +msgid "Priority" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1298 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:404 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:405 +msgid "Priority details" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1128 +msgid "Priority details are unavailable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3669 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3670 +msgid "Priority level settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3023 +msgid "Priority levels" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3727 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3732 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3733 +msgid "Priority settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1929 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2486 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2520 +msgid "Protocol" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:757 +msgid "Proxy link is unavailable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2549 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:40 +msgid "Public host" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1613 +msgid "QR code is too large" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2688 +msgid "Reality fake site" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2699 +msgid "Reality fake site port" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2742 +msgid "Reality max time difference" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2710 +msgid "Reality private key" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2717 +msgid "Reality public key" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2723 +msgid "Reality short ID" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2095 +msgid "Reality short ID must contain 1-8 hex digits" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:135 +msgid "Refill" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7805 +msgid "Regional options cannot be used together" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5153 +msgid "Regular expression must not contain spaces or commas" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:799 +msgid "Remove" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2834 +msgid "Remove servers from the specified countries from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2850 +msgid "Remove servers whose names match the expression from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2842 +msgid "Remove the specified servers from this level." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7609 +msgid "Require a username and password for the local mixed proxy" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7696 +msgid "Resolve domain names before routing so sing-box can use real destination IPs." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7695 +msgid "Resolve real IP for routing" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5441 +msgid "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5886 +msgid "Resource selection by hostname inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5444 +msgid "Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5889 +msgid "Resource selection by IP or CIDR inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE." +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:49 +msgid "Restart Tachyon" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:691 +msgid "Resume updates" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:819 +#: src/tachyon/tabs/monitoring/initController.ts:877 +msgid "Route" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:252 +msgid "Route main DNS requests through the selected section." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7096 +msgid "Route requests to this DNS server through another section." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2561 +msgid "Routing mode" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:103 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:114 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:144 +msgid "Routing rules" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2570 +msgid "Routing section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6925 +msgid "Rule set must be an HTTP(S) URL or a local .srs / .json path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3897 +msgid "Rule set settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7860 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7889 +msgid "Rule sets" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2562 +msgid "Rules" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:79 +msgid "Rules mangle counters" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:74 +msgid "Rules mangle exist" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:89 +msgid "Rules mangle output counters" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:84 +msgid "Rules mangle output exist" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:99 +msgid "Rules proxy counters" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:94 +msgid "Rules proxy exist" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderRunAction.ts:17 +msgid "Run Diagnostic" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7824 +msgid "Russia inside restrictions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3490 +msgid "Save" +msgstr "" + +#: src/tachyon/tabs/monitoring/render.ts:47 +msgid "Search" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2939 +msgid "Secret" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:435 +msgid "Secret key for authenticating remote access to YACD when WAN access is enabled." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:396 +msgid "Section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7029 +msgid "Section name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:390 +msgid "Sections" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1941 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2597 +msgid "Security" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2305 +msgid "Select a common client profile or enter a custom User-Agent" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7331 +msgid "Select a network interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7761 +msgid "Select a predefined domain list" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7117 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:84 +msgid "Select a section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2282 +msgid "Select a section for downloading this subscription" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7530 +msgid "Select a transit section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1839 +msgid "Select an existing network interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3125 +msgid "Select an existing URLTest or Priority group" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7543 +msgid "Select an intermediate section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:161 +msgid "Select DNS protocol to use" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7326 +msgid "Select network interface for VPN connection" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2318 +msgid "Select or enter a User-Agent" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:624 +msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:611 +msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2967 +#: src/tachyon/tabs/dashboard/initController.ts:1151 +msgid "Select the fastest node" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:660 +msgid "Select the log level for sing-box" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:268 +msgid "Select the network interface from which the traffic will originate" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:314 +msgid "Select the network interface to which the traffic will originate" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:375 +msgid "Select the WAN interfaces to be monitored" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:910 +#: src/tachyon/tabs/dashboard/initController.ts:1137 +msgid "Selected" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2564 +msgid "Selected section" +msgstr "" + +#: src/validators/validateOutboundJson.ts:85 +msgid "Selector and URLTest outbounds must contain a non-empty outbounds array" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:413 +msgid "Server" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2537 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2743 +msgid "Server filtering" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7394 +msgid "Server group for automatic lowest-latency selection" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7453 +msgid "Server group for priority failover" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1707 +msgid "Server information" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1623 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2497 +msgid "Server name" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2626 +msgid "Server name / SNI" +msgstr "" + +#: src/validators/validateOutboundJson.ts:94 +msgid "Server outbound must contain a non-empty server field" +msgstr "" + +#: src/validators/validateOutboundJson.ts:100 +msgid "Server outbound must contain a numeric server_port from 1 to 65535" +msgstr "" + +#: src/validators/validateOutboundJson.ts:107 +msgid "server_port must be a number from 1 to 65535" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:407 +msgid "Servers" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3141 +msgid "Servers on dashboard" +msgstr "" + +#: src/tachyon/methods/shell/index.ts:383 +#: src/tachyon/methods/shell/index.ts:404 +#: src/tachyon/tabs/diagnostic/initController.ts:594 +msgid "Service action failed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7764 +msgid "Service list" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1670 +msgid "Services info" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:425 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1097 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7000 +msgid "Settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2348 +msgid "Show metadata on dashboard" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3214 +msgid "Show only selected servers." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3173 +msgid "Show only servers with one of the selected protocols." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3179 +msgid "Show only servers with one of the selected security types." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3176 +msgid "Show only servers with one of the selected transports." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3287 +msgid "Show servers from selected groups without repeating their filtering criteria." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3206 +msgid "Show servers only from the specified countries." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3222 +msgid "Show servers whose names match the expression." +msgstr "" + +#: src/tachyon/tabs/diagnostic/initController.ts:751 +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:116 +msgid "Show sing-box config" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2349 +msgid "Show subscription metadata for this source on the dashboard" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:77 +msgid "Sing-box autostart disabled" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:69 +msgid "Sing-box FakeIP DNS does not work" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runFakeIPCheck.ts:68 +msgid "Sing-box FakeIP DNS works" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:62 +msgid "Sing-box installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:87 +msgid "Sing-box listening ports" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:82 +msgid "Sing-box process running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:72 +msgid "Sing-box service exist" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runSingBoxCheck.ts:67 +msgid "Sing-box version is compatible (newer than 1.12.4)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7555 +msgid "Sort by latency" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7556 +msgid "Sorts servers in this section by lowest latency in the dashboard." +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:831 +#: src/tachyon/tabs/monitoring/initController.ts:881 +msgid "Source" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:267 +msgid "Source Network Interface" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1930 +msgid "Specify a file path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1941 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1953 +msgid "Specify different files" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:122 +msgid "Standalone ByeDPI autostart is enabled" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:119 +msgid "Standalone ByeDPI is active together with Tachyon ByeDPI rules" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:120 +msgid "Standalone ByeDPI service is active" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:123 +msgid "Standalone ByeDPI service is inactive" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:111 +msgid "Standalone Zapret is active together with Tachyon Zapret rules" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:112 +msgid "Standalone Zapret service is active" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:113 +msgid "Standalone Zapret service is inactive" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:121 +msgid "Standalone Zapret2 autostart is enabled" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:118 +msgid "Standalone Zapret2 is active together with Tachyon Zapret2 rules" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:119 +msgid "Standalone Zapret2 service is active" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:122 +msgid "Standalone Zapret2 service is inactive" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:69 +msgid "Start Tachyon" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2504 +msgid "Stop checking when URLTest group is not used. Use sing-box duration format like 1d, 12h or 30m." +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:59 +msgid "Stop Tachyon" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3891 +msgid "Subnets from the list will be extracted and added to nftables" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2236 +msgid "Subscription auto update" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:149 +msgid "Subscription info:" +msgstr "" + +#: src/tachyon/methods/shell/index.ts:720 +#: src/tachyon/methods/shell/index.ts:740 +msgid "Subscription update failed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2245 +msgid "Subscription update interval" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7277 +msgid "Subscription URL" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4513 +msgid "Subscription URL cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3594 +msgid "Subscription URL settings" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:301 +msgid "Subscriptions updated" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1630 +msgid "System info" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderSystemInfo.ts:21 +msgid "System information" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runNftCheck.ts:69 +msgid "Table exist" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3081 +msgid "Tailscale auth key" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3090 +msgid "Tailscale control URL" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:98 +msgid "Tailscale endpoint" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3098 +msgid "Tailscale hostname" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:242 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:250 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:491 +msgid "Test latency" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2605 +msgid "Test only selected servers." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2565 +msgid "Test only servers with one of the selected protocols." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2571 +msgid "Test only servers with one of the selected security types." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2568 +msgid "Test only servers with one of the selected transports." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2597 +msgid "Test servers only from the specified countries." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2613 +msgid "Test servers whose names match the expression." +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:913 +msgid "Testing URL" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5453 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5898 +msgid "The desync fwmark is managed by Tachyon for loop prevention and must not be overridden here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5450 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5895 +msgid "The NFQUEUE number is assigned by Tachyon for each rule and must not be overridden here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7722 +msgid "The rule applies to the domain and all its subdomains. Use full:, keyword:, or regex: prefixes for exact match, keyword match, or regular expression." +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:78 +msgid "There are rules using ByeDPI" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:78 +msgid "There are rules using Zapret" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:81 +msgid "There are rules using Zapret2" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1829 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1834 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1924 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2086 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2886 +msgid "This field is required" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6315 +msgid "This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5459 +msgid "This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5462 +msgid "This field must start a working nfqws strategy; --version exits immediately and is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5910 +msgid "This field must start a working nfqws2 strategy; --dry-run exits immediately and is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5913 +msgid "This field must start a working nfqws2 strategy; --version exits immediately and is not allowed here." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5631 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6105 +msgid "This flag does not accept a value." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5662 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6136 +msgid "This option requires a value." +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:822 +#: src/tachyon/tabs/monitoring/initController.ts:878 +msgid "Time" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:223 +msgid "Time in seconds for DNS record caching (default: 60)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:3055 +msgid "Time skew tolerance" +msgstr "" + +#: src/tachyon/helpers/singBoxVariant.ts:53 +msgid "tiny" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2476 +#: src/tachyon/tabs/dashboard/initController.ts:915 +msgid "Tolerance" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3024 +msgid "Top level has the highest priority; lower levels are used as fallback" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1556 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:130 +msgid "Traffic" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2735 +msgid "Traffic for this level goes directly." +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1590 +msgid "Traffic Total" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6306 +msgid "Transparent proxy mode is incompatible with action=byedpi because Tachyon connects to ciadpi through SOCKS." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1935 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2764 +msgid "Transport" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2788 +msgid "Transport host" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2772 +msgid "Transport path" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2007 +msgid "Transport path must start with /" +msgstr "" + +#: src/tachyon/tabs/diagnostic/partials/renderWikiDisclaimer.ts:25 +msgid "Troubleshooting" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2294 +msgid "Try compatible User-Agent profiles automatically when downloading this subscription" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:234 +msgid "TTL must be a positive number" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:229 +msgid "TTL value cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1638 +#: src/tachyon/tabs/monitoring/initController.ts:816 +#: src/tachyon/tabs/monitoring/initController.ts:876 +msgid "Type" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:165 +msgid "UDP (Unprotected DNS)" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6230 +msgid "Unable to validate the ByeDPI strategy through the backend parser." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5373 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6708 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7151 +msgid "Unable to validate the NFQWS strategy through the backend parser." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5818 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6717 +msgid "Unable to validate the NFQWS2 strategy through the backend parser." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2941 +#: src/tachyon/tabs/dashboard/initController.ts:1145 +msgid "Unavailability timeout" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6447 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6496 +msgid "Unexpected ByeDPI strategy argument: %s" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runByedpiCheck.ts:96 +msgid "Unexpected Tachyon-managed ciadpi runtime is running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:89 +msgid "Unexpected Tachyon-managed nfqws runtime is running" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:92 +msgid "Unexpected Tachyon-managed nfqws2 runtime is running" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5611 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6085 +msgid "Unexpected standalone token. Use explicit flags such as --name or --name=value." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js:54 +msgid "unique UCI identifier" +msgstr "" + +#: src/tachyon/helpers/singBoxVariant.ts:28 +#: src/tachyon/services/systemInfo.service.ts:10 +#: src/tachyon/services/systemInfo.service.ts:11 +#: src/tachyon/services/systemInfo.service.ts:12 +#: src/tachyon/services/systemInfo.service.ts:13 +#: src/tachyon/services/systemInfo.service.ts:18 +#: src/tachyon/services/systemInfo.service.ts:20 +#: src/tachyon/services/systemInfo.service.ts:22 +#: src/tachyon/services/systemInfo.service.ts:25 +#: src/tachyon/services/systemInfo.service.ts:26 +#: src/tachyon/tabs/updates/initController.ts:329 +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:25 +msgid "unknown" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6416 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6438 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6489 +msgid "Unknown ByeDPI option: %s" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:578 +msgid "Unknown country" +msgstr "" + +#: src/tachyon/api.ts:40 +msgid "Unknown error" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5622 +msgid "Unknown NFQWS flag." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:6096 +msgid "Unknown NFQWS2 flag." +msgstr "" + +#: src/validators/validateSubnet.ts:17 +msgid "Unspecified IP address is not allowed" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2120 +msgid "Unsupported security mode for this protocol" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:754 +msgid "Update" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:287 +msgid "Update is available" +msgstr "" + +#: src/tachyon/tabs/updates/initController.ts:994 +msgid "Update is available:" +msgstr "" + +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:219 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:232 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:233 +msgid "Update subscriptions" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2237 +msgid "Update this subscription automatically" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1558 +#: src/tachyon/tabs/dashboard/initController.ts:1593 +msgid "Uplink" +msgstr "" + +#: src/tachyon/tabs/monitoring/initController.ts:828 +#: src/tachyon/tabs/monitoring/initController.ts:880 +msgid "Uploaded" +msgstr "" + +#: src/validators/validateProxyUrl.ts:47 +msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://" +msgstr "" + +#: src/validators/validateUrl.ts:18 +msgid "URL must use one of the following protocols:" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2917 +msgid "URL used to check whether a server is alive" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2491 +msgid "URL used to test server latency" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7393 +msgid "URLTest" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:1042 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:385 +#: src/tachyon/tabs/dashboard/partials/renderSections.ts:386 +msgid "URLTest details" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:901 +msgid "URLTest details are unavailable" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3626 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:3627 +msgid "URLTest settings" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2104 +msgid "Use 32 hex characters" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1894 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1900 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2016 +msgid "Use a domain name or IP address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1995 +msgid "Use a duration like 30s, 5m, or 1h30m" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2001 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2888 +msgid "Use a non-negative integer" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2107 +msgid "Use a non-zero secret" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1963 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1965 +msgid "Use an HTTP or HTTPS URL" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1917 +msgid "Use an IP address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1910 +msgid "Use an IP listen address" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7492 +msgid "Use another section as an intermediate hop to connect to servers in this section. Does not apply to network interfaces or JSON outbounds." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2064 +msgid "Use CIDR prefixes like 192.168.1.0/24 or 2001:db8::/32" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2078 +msgid "Use CIDR prefixes or IP addresses like 192.168.1.0/24, 192.168.1.10, or 2001:db8::1" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2022 +msgid "Use letters, digits, dots, underscores, or hyphens" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:149 +msgid "Use sing-box duration format like 10s, 1m or 2m30s" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2246 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2465 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2990 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4446 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4453 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4460 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:466 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:488 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:495 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:511 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:539 +msgid "Use sing-box duration format like 1d, 12h or 30m" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:25 +msgid "Used by" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2304 +msgid "User-Agent" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2847 +msgid "Username" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7649 +msgid "Username cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5146 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5159 +#: src/validators/validateDns.ts:21 +#: src/validators/validateDns.ts:25 +#: src/validators/validateDomain.ts:57 +#: src/validators/validateDomain.ts:76 +#: src/validators/validateHttpProxyUrl.ts:97 +#: src/validators/validateHysteriaUrl.ts:162 +#: src/validators/validateIp.ts:25 +#: src/validators/validateIp.ts:33 +#: src/validators/validateIp.ts:41 +#: src/validators/validateOutboundJson.ts:118 +#: src/validators/validatePath.ts:16 +#: src/validators/validateShadowsocksUrl.ts:103 +#: src/validators/validateSocksUrl.ts:83 +#: src/validators/validateSubnet.ts:42 +#: src/validators/validateTrojanUrl.ts:66 +#: src/validators/validateUrl.ts:34 +#: src/validators/validateVlessUrl.ts:116 +#: src/validators/validateVmessUrl.ts:66 +msgid "Valid" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4575 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4595 +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:4599 +msgid "Validation errors:" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5136 +msgid "Value cannot be empty" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:1838 +msgid "Value must not contain control characters" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:1876 +msgid "Via country.is" +msgstr "" + +#: src/tachyon/tabs/diagnostic/initController.ts:717 +#: src/tachyon/tabs/diagnostic/partials/renderAvailableActions.ts:107 +msgid "View logs" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7030 +msgid "Visible name of this section" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2891 +msgid "VLESS flow" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7253 +msgid "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:70 +msgid "WAN" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runInboundsCheck.ts:198 +msgid "WAN public IP" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7807 +msgid "Warning: %s cannot be used together with %s. Previous selections have been removed." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7826 +msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:7043 +msgid "What Tachyon should do when this section matches" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:2968 +msgid "When switching to another level, test every server and select the fastest instead of the first working one." +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js:2801 +msgid "XHTTP mode" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:434 +msgid "YACD Secret Key" +msgstr "" + +#: src/tachyon/tabs/dashboard/initController.ts:762 +msgid "Yes" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:305 +msgid "You can select Output Network Interface, by default autodetect" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5447 +msgid "Zapret hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS strategies." +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:71 +msgid "Zapret package is installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:72 +msgid "Zapret package is not installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:60 +msgid "Zapret provider binary is available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:61 +msgid "Zapret provider binary is not available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:103 +msgid "Zapret sing-box outbound is configured" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapretCheck.ts:104 +msgid "Zapret sing-box outbound is not configured" +msgstr "" + +#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js:5892 +msgid "Zapret2 hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS2 strategies." +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:74 +msgid "Zapret2 package is installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:75 +msgid "Zapret2 package is not installed" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:63 +msgid "Zapret2 provider binary is available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:64 +msgid "Zapret2 provider binary is not available" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:106 +msgid "Zapret2 sing-box outbound is configured" +msgstr "" + +#: src/tachyon/tabs/diagnostic/checks/runZapret2Check.ts:107 +msgid "Zapret2 sing-box outbound is not configured" +msgstr "" diff --git a/luci-app-tachyon/root/etc/uci-defaults/50_luci-tachyon b/luci-app-tachyon/root/etc/uci-defaults/50_luci-tachyon new file mode 100644 index 00000000..6dd810a0 --- /dev/null +++ b/luci-app-tachyon/root/etc/uci-defaults/50_luci-tachyon @@ -0,0 +1,4 @@ +#!/bin/sh + +/usr/bin/tachyon luci_postinst >/dev/null 2>&1 || true +exit 0 diff --git a/luci-app-tachyon/root/usr/share/luci/menu.d/luci-app-tachyon.json b/luci-app-tachyon/root/usr/share/luci/menu.d/luci-app-tachyon.json new file mode 100644 index 00000000..0f7dee61 --- /dev/null +++ b/luci-app-tachyon/root/usr/share/luci/menu.d/luci-app-tachyon.json @@ -0,0 +1,14 @@ +{ + "admin/services/tachyon": { + "title": "Tachyon", + "order": 42, + "action": { + "type": "view", + "path": "tachyon/tachyon" + }, + "depends": { + "acl": [ "luci-app-tachyon" ], + "uci": { "tachyon": true } + } + } +} diff --git a/luci-app-tachyon/root/usr/share/rpcd/acl.d/luci-app-tachyon.json b/luci-app-tachyon/root/usr/share/rpcd/acl.d/luci-app-tachyon.json new file mode 100644 index 00000000..784fc1c9 --- /dev/null +++ b/luci-app-tachyon/root/usr/share/rpcd/acl.d/luci-app-tachyon.json @@ -0,0 +1,61 @@ +{ + "luci-app-tachyon": { + "description": "Grant UCI and RPC access to LuCI app Tachyon", + "read": { + "file": { + "/etc/init.d/tachyon": [ + "exec" + ], + "/var/run/tachyon/section-cache/*": [ + "read" + ], + "/var/run/tachyon/component-actions/*": [ + "read" + ], + "/var/run/tachyon/ui-state/*": [ + "read" + ], + "/var/run/tachyon/ui-state/service-actions/*": [ + "read" + ], + "/var/run/tachyon/ui-state/latency-actions/*": [ + "read" + ], + "/usr/bin/tachyon": [ + "exec" + ] + }, + "ubus": { + "luci-rpc": [ + "getDHCPLeases", + "getHostHints" + ], + "network.interface": [ + "dump" + ], + "service": [ + "list" + ] + }, + "uci": [ + "tachyon" + ] + }, + "write": { + "file": { + "/var/run/tachyon/ui-state/*": [ + "write" + ], + "/var/run/tachyon/ui-state/service-actions/*": [ + "write" + ], + "/var/run/tachyon/ui-state/latency-actions/*": [ + "write" + ] + }, + "uci": [ + "tachyon" + ] + } + } +} diff --git a/tachyon/Makefile b/tachyon/Makefile new file mode 100644 index 00000000..801ffe45 --- /dev/null +++ b/tachyon/Makefile @@ -0,0 +1,79 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=tachyon + +TACHYON_SOURCE_VERSION:=$(or $(TACHYON_PACKAGE_VERSION),$(TACHYON_VERSION)) +TACHYON_HAS_RELEASE_VERSION:=$(if $(filter dev,$(TACHYON_SOURCE_VERSION)),,$(TACHYON_SOURCE_VERSION)) +TACHYON_COMPILED_VERSION:=$(if $(TACHYON_HAS_RELEASE_VERSION),$(TACHYON_SOURCE_VERSION),$(or $(TACHYON_SOURCE_VERSION),dev)) +TACHYON_RELEASE_VERSION_VALID:=$(if $(TACHYON_HAS_RELEASE_VERSION),$(shell printf '%s\n' '$(TACHYON_SOURCE_VERSION)' | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$$' && printf '1'),1) + +ifeq ($(TACHYON_RELEASE_VERSION_VALID),) +$(error TACHYON_PACKAGE_VERSION and TACHYON_VERSION must use x.y.z format) +endif + +PKG_VERSION:=$(or $(TACHYON_HAS_RELEASE_VERSION),0.0.0) +PKG_RELEASE:= + +PKG_MAINTAINER:=ushan0v +PKG_LICENSE:=GPL-2.0-or-later + +include $(INCLUDE_DIR)/package.mk + +define Package/tachyon + SECTION:=net + CATEGORY:=Network + DEPENDS:=+ca-bundle +kmod-inet-diag +kmod-netlink-diag +kmod-tun +curl +ucode +ucode-mod-fs +ucode-mod-uci +kmod-nft-tproxy +coreutils-base64 +bind-dig +nftables +kmod-nft-nat +ip-full + CONFLICTS:=https-dns-proxy nextdns luci-app-passwall luci-app-passwall2 + TITLE:=Tachyon backend service + URL:=https://github.com/ushan0v/tachyon + PKGARCH:=all +endef + +define Package/tachyon/description + Rule-based Tachyon backend with hybrid sing-box + zapret orchestration +endef + +define Build/Configure +endef + +define Build/Compile +endef + +define Package/tachyon/prerm +#!/usr/bin/ucode + +if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "") + system("/usr/bin/tachyon package_prerm " + (ARGV[0] || "") + " >/dev/null 2>&1"); + +exit(0); +endef + +define Package/tachyon/postinst +#!/bin/sh + +[ -n "$${IPKG_INSTROOT}" ] && exit 0 +TACHYON_LIB=/usr/lib/tachyon ucode -L /usr/lib/tachyon /usr/lib/tachyon/config/migration.uc migrate || exit $$? +/usr/bin/tachyon package_postinst +endef + +define Package/tachyon/conffiles +/etc/config/tachyon +endef + +define Package/tachyon/install + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/etc/init.d/tachyon $(1)/etc/init.d/tachyon + + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/etc/config/tachyon $(1)/etc/config/tachyon + + $(INSTALL_DIR) $(1)/usr/bin + $(INSTALL_BIN) ./files/usr/bin/tachyon $(1)/usr/bin/tachyon + + $(INSTALL_DIR) $(1)/usr/lib/tachyon + $(CP) ./files/usr/lib/. $(1)/usr/lib/tachyon/ + + sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(TACHYON_COMPILED_VERSION)/g' $(1)/usr/lib/tachyon/core/constants.uc +endef + +$(eval $(call BuildPackage,tachyon)) diff --git a/tachyon/files/etc/config/tachyon b/tachyon/files/etc/config/tachyon new file mode 100644 index 00000000..e7e92851 --- /dev/null +++ b/tachyon/files/etc/config/tachyon @@ -0,0 +1,165 @@ +config settings 'settings' + option config_version '1.0.2' + list applied_migrations 'interface_sections' + list applied_migrations 'enable_component_checks' + option dns_type 'udp' + list dns_server '77.88.8.8' + list bootstrap_dns_server '77.88.8.8' + option dns_check_interval '10s' + option dns_recovery_check_interval '60s' + option dns_check_timeout '2s' + option dns_rewrite_ttl '60' + option dns_strategy 'prefer_ipv4' + option dns_detour_enabled '0' + #option dns_detour_section 'main' + list source_network_interfaces 'br-lan' + option enable_output_network_interface '0' + #option output_network_interface 'wan' + option enable_badwan_interface_monitoring '0' + #list badwan_monitored_interfaces 'wan' + #option badwan_reload_delay '2000' + option enable_yacd '0' + option disable_quic '0' + option list_update_enabled '1' + option update_interval '1d' + option component_update_check_enabled '1' + option component_update_check_interval '1d' + option latency_test_url 'https://www.gstatic.com/generate_204' + option download_lists_via_proxy '0' + option download_components_via_proxy '0' + #option download_lists_via_proxy_section 'main' + #option download_components_via_proxy_section 'main' + option dont_touch_dhcp '0' + option config_path '/etc/sing-box/config.json' + option cache_path '/tmp/sing-box/cache.db' + option log_level 'warn' + option exclude_ntp '0' + option shutdown_correctly '0' + +#config section 'subscription_example' +# option label 'Subscription' +# option enabled '1' +# option action 'connection' +# list selector_proxy_links 'vless://example' +# list community_lists 'russia_inside' +# list rule_set 'https://example.com/rules.srs' +# list rule_set_with_subnets 'https://example.com/rules-with-subnets.json' +# option dashboard_filter_mode 'disabled' +# option dashboard_detect_server_country 'flag_emoji' +# #list dashboard_include_outbounds 'Example server name' +# #list dashboard_include_groups 'Fastest' +# #list dashboard_exclude_outbounds 'Backup server name' +# #list dashboard_exclude_groups 'Backup' +# option outbound_detour_enabled '0' +# #option outbound_detour_section 'main' +# option sort_by_latency '0' +# #list fully_routed_ips '192.168.1.2' + +#config section_interface +# option section 'subscription_example' +# option name 'awg0' +# option domain_resolver_enabled '0' +# option domain_resolver_dns_type 'udp' +# option domain_resolver_dns_server '8.8.8.8' + +#config subscription_url +# option section 'subscription_example' +# option url 'https://example.com/api/sub' +# option auto_user_agent '0' +# option user_agent 'CustomAgent/1.0' +# option auto_hwid '1' +# option subscription_update_enabled '1' +# option subscription_update_interval '1h' +# option download_via_proxy_enabled '0' +# option show_dashboard_metadata '1' +# option prefix_nodes '0' +# #option node_prefix 'Provider' +# option include_urltest_groups '1' +# option hide_urltest_group_outbounds '1' +# option hide_detour_outbounds '1' + +#config urltest +# option section 'subscription_example' +# option name 'Fastest' +# option check_interval '3m' +# option tolerance '50' +# option testing_url 'https://www.gstatic.com/generate_204' +# option idle_timeout '30m' +# option interrupt_exist_connections '1' +# option pin_dashboard '1' +# option filter_mode 'disabled' +# option detect_server_country 'flag_emoji' +# list exclude_countries 'US' +# list exclude_outbounds 'Example server name' +# list exclude_regex '.*backup.*' +# list include_countries 'NL' +# list include_outbounds 'Example server name' +# list include_regex '.*primary.*' + +#config section 'json_outbound_example' +# option label 'JSON outbound' +# option enabled '1' +# option action 'connection' +# list outbound_jsons '{"type":"socks","tag":"Local SOCKS","server":"127.0.0.1","server_port":1080,"version":"5"}' +# list community_lists 'russia_inside' + +#config server 'server_json_inbound_example' +# option label 'JSON inbound' +# option enabled '1' +# option protocol 'json_inbound' +# option routing_mode 'rules' +# option inbound_json '{"type":"mixed","listen":"0.0.0.0","listen_port":1080}' + +#config server 'server_vless_reality_example' +# option label 'VLESS Reality server' +# option enabled '1' +# option protocol 'vless' +# option listen '0.0.0.0' +# option listen_port '443' +# option public_host 'vpn.example.com' +# option routing_mode 'rules' +# option security 'reality' +# option server_uuid '00000000-0000-0000-0000-000000000000' +# option vless_flow 'none' +# option tls_server_name 'www.microsoft.com' +# option client_fingerprint 'chrome' +# option reality_handshake_server 'www.microsoft.com' +# option reality_handshake_server_port '443' +# option reality_private_key 'PRIVATE_KEY_FROM_sing-box_generate_reality-keypair' +# option reality_public_key 'PUBLIC_KEY_FROM_sing-box_generate_reality-keypair' +# option reality_short_id '5a' +# option reality_max_time_difference '1m' +# option transport 'tcp' +# # sing-box-extended only: +# #option transport 'xhttp' +# #option transport_path '/xhttp' +# #option transport_xhttp_mode 'auto' + +#config server 'server_mtproto_example' +# option label 'MTProto proxy' +# option enabled '1' +# option protocol 'mtproto' +# option listen '0.0.0.0' +# option listen_port '443' +# option public_host 'vpn.example.com' +# option routing_mode 'rules' +# option mtproto_secret '11111111111111111111111111111111' +# option mtproto_faketls 'google.com' +# option mtproto_padding '1' +# option mtproto_domain_fronting_port '443' +# option mtproto_prefer_ip 'prefer-ipv4' +# option mtproto_tolerate_time_skewness '3s' +# option mtproto_idle_timeout '5m' +# option mtproto_handshake_timeout '10s' + +#config server 'server_tailscale_example' +# option label 'Tailscale endpoint' +# option enabled '1' +# option protocol 'tailscale' +# option routing_mode 'rules' +# option tailscale_control_url 'https://controlplane.tailscale.com' +# option tailscale_hostname 'tachyon-server-tailscale-example' +# option tailscale_auth_key 'tskey-auth-...' +# option tailscale_accept_routes '0' +# option tailscale_advertise_exit_node '1' +# #list tailscale_advertise_routes '192.168.1.0/24' diff --git a/tachyon/files/etc/init.d/tachyon b/tachyon/files/etc/init.d/tachyon new file mode 100644 index 00000000..6ac9ac8b --- /dev/null +++ b/tachyon/files/etc/init.d/tachyon @@ -0,0 +1,93 @@ +#!/bin/sh /etc/rc.common +# shellcheck disable=SC2034 + +START=99 +USE_PROCD=1 +EXTRA_COMMANDS="retry_start_on_wan_up" +EXTRA_HELP=" retry_start_on_wan_up Retry failed cold start after WAN comes up\n" + +script=$(readlink "$initscript") +NAME="$(basename ${script:-$initscript})" +TACHYON_LIB="/usr/lib/tachyon" +TACHYON_INITD_UC="$TACHYON_LIB/service/initd.uc" + +initd_ucode() { + [ -r "$TACHYON_INITD_UC" ] || return 1 + TACHYON_LIB="$TACHYON_LIB" \ + TACHYON_SERVICE_NAME="$NAME" \ + ucode -L "$TACHYON_LIB" "$TACHYON_INITD_UC" "$@" +} + +sync_service_triggers() { + _procd_call sync_service_triggers_payload +} + +sync_service_triggers_payload() { + json_init + json_add_string name "$NAME" + service_triggers + _procd_ubus_call set >/dev/null 2>&1 || true +} + +start_service() { + initd_ucode start-service "$1" "$$" + TACHYON_LAST_START_STATUS="$?" + return "$TACHYON_LAST_START_STATUS" +} + +service_started() { + return "${TACHYON_LAST_START_STATUS:-0}" +} + +stop_service() { + initd_ucode stop-service "$$" +} + +reload_service() { + local reason="$1" + local result status + + trap 'initd_ucode reload-release >/dev/null 2>&1 || true' INT TERM + result="$(initd_ucode reload-service "$reason" "$$")" + status="$?" + trap - INT TERM + + [ "$result" = "sync" ] && sync_service_triggers + return "$status" +} + +status_service() { + initd_ucode status-service +} + +retry_start_on_wan_up() { + initd_ucode retry-start-on-wan-up "$$" +} + +service_triggers() { + local plan kind event target init action reason + + plan="$(initd_ucode trigger-plan)" || return 0 + + procd_open_trigger + while IFS="$(printf '\t')" read -r kind event target init action reason; do + case "$kind" in + delay) + PROCD_RELOAD_DELAY="$event" + ;; + config) + procd_add_config_trigger "$event" "$target" "$init" "$action" "$reason" + ;; + interface) + if [ -n "$reason" ]; then + procd_add_interface_trigger "$event" "$target" "$init" "$action" "$reason" + else + procd_add_interface_trigger "$event" "$target" "$init" "$action" + fi + ;; + esac + done << EOF +$plan +EOF + procd_close_trigger +} diff --git a/tachyon/files/usr/bin/tachyon b/tachyon/files/usr/bin/tachyon new file mode 100644 index 00000000..98a028a8 --- /dev/null +++ b/tachyon/files/usr/bin/tachyon @@ -0,0 +1,233 @@ +#!/usr/bin/ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const BIN_PATH = getenv("TACHYON_BIN") || "/usr/bin/tachyon"; +const SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || "/etc/init.d/tachyon"; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; +} + +function module_path(path) { + return LIB_DIR + "/" + as_string(path); +} + +function file_readable(path) { + return fs.stat(as_string(path)) != null; +} + +function restore_dnsmasq_failsafe() { + let module = module_path("dns/apply.uc"); + if (!file_readable(module)) + return; + system(command_from_args([ "ucode", "-L", LIB_DIR, module, "failsafe-restore" ]) + " >/dev/null 2>&1"); +} + +function restore_dnsmasq_failsafe_on_loader_failure(command) { + command = as_string(command); + if (command == "start" || command == "stop" || command == "restart" || + command == "reload" || command == "uninstall" || + command == "dnsmasq_restore" || command == "restore_dnsmasq") + restore_dnsmasq_failsafe(); +} + +function show_help() { + print("Usage: ", BIN_PATH, " COMMAND\n\n"); + print("Available commands:\n"); + print(" start Start Tachyon service\n"); + print(" stop Stop Tachyon service\n"); + print(" reload Reload Tachyon configuration\n"); + print(" restart Restart Tachyon service\n"); + print(" enable Enable Tachyon autostart\n"); + print(" disable Disable Tachyon autostart\n"); + print(" uninstall Remove Tachyon files installed outside opkg/apk\n"); + print(" main Run main Tachyon process\n"); + print(" list_update Update remote lists\n"); + print(" list_update_if_due Update remote lists only when the configured interval has elapsed\n"); + print(" subscription_update [rule] [source_index]\n"); + print(" Update all subscription proxies, one rule, or one source in a rule\n"); + print(" subscription_update_async [rule] [source_index]\n"); + print(" Start forced subscription update in the background\n"); + print(" subscription_update_status [job_id]\n"); + print(" Get background subscription update status\n"); + print(" subscription_update_if_due\n"); + print(" Update subscription proxies only when per-rule intervals have elapsed\n"); + print(" check_proxy Check proxy connectivity\n"); + print(" check_nft Check NFT rules\n"); + print(" check_nft_rules Check NFT rules status\n"); + print(" check_sing_box Check sing-box installation and status\n"); + print(" check_inbounds_config Check whether enabled server inbounds are configured\n"); + print(" check_inbounds Check server inbounds from the Servers tab\n"); + print(" check_logs Show Tachyon logs from system journal\n"); + print(" check_sing_box_logs Show sing-box logs\n"); + print(" check_fakeip Test sing-box FakeIP DNS\n"); + print(" check_zapret_runtime Check external zapret provider state\n"); + print(" check_zapret2_runtime Check external zapret2 provider state\n"); + print(" check_byedpi_runtime Check external ByeDPI provider state\n"); + print(" neutralize_zapret_defaults\n"); + print(" Compatibility no-op; Tachyon does not manage standalone zapret\n"); + print(" clash_api Clash API interface for managing proxies and groups\n"); + print(" show_config Display current Tachyon configuration\n"); + print(" show_version Show Tachyon version\n"); + print(" show_sing_box_config Show sing-box configuration\n"); + print(" show_sing_box_version Show sing-box version\n"); + print(" get_status Get Tachyon service status\n"); + print(" get_outbound_metadata Get generated outbound display names and countries\n"); + print(" get_subscription_metadata\n"); + print(" Get UI-only metadata for a subscription rule\n"); + print(" get_sing_box_status Get sing-box service status\n"); + print(" get_zapret_status Get zapret provider and Tachyon-managed nfqws status\n"); + print(" get_zapret2_status Get zapret2 provider and Tachyon-managed nfqws2 status\n"); + print(" get_byedpi_status Get ByeDPI provider and Tachyon-managed ciadpi status\n"); + print(" get_system_info Get system information in JSON format\n"); + print(" get_server_capabilities Get server UI capabilities in JSON format\n"); + print(" get_ui_capabilities Get lightweight UI capabilities in JSON format\n"); + print(" get_ui_state Get lightweight UI service/action state in JSON format\n"); + print(" service_action_async Start a service action in the background\n"); + print(" service_action_status Get background service action status\n"); + print(" latency_test_async Start a dashboard latency test in the background\n"); + print(" latency_test_status Get background latency test status\n"); + print(" ui_action_ack Acknowledge a finished UI action state\n"); + print(" generate_reality_keypair\n"); + print(" Generate a sing-box Reality key pair in JSON format\n"); + print(" get_tls_certificate_sha256 [server]\n"); + print(" Get a server TLS certificate SHA-256 fingerprint in JSON format\n"); + print(" component_action Manage Tachyon, sing-box, zapret, zapret2, and ByeDPI components\n"); + print(" component_action_async Start a component action in the background\n"); + print(" component_action_status Get background component action status\n"); + print(" component_updates_if_due Check installed components for updates when due\n"); + print(" component_update_check_cache Get cached component update check results\n"); + print(" check_dns_available Check DNS server availability\n"); + print(" global_check Run global system check\n"); + print(" validate_nfqws_strategy_json\n"); + print(" Validate an NFQWS strategy and print JSON result\n"); + print(" validate_nfqws2_strategy_json\n"); + print(" Validate an NFQWS2 strategy and print JSON result\n"); + print(" validate_byedpi_strategy_json\n"); + print(" Validate a ByeDPI ciadpi strategy and print JSON result\n"); +} + +function command_spec(command) { + let commands = { + start: [ "service/lifecycle.uc", "start", 0 ], + stop: [ "service/lifecycle.uc", "stop", 0 ], + reload: [ "service/lifecycle.uc", "reload", 1 ], + dns_failover_apply: [ "service/lifecycle.uc", "dns-failover-apply", 1 ], + restart: [ "service/lifecycle.uc", "restart", 0 ], + enable: [ "service/lifecycle.uc", "enable", 0 ], + disable: [ "service/lifecycle.uc", "disable", 0 ], + uninstall: [ "service/lifecycle.uc", "uninstall", 0 ], + dnsmasq_restore: [ "service/lifecycle.uc", "dnsmasq-restore", 0 ], + restore_dnsmasq: [ "service/lifecycle.uc", "dnsmasq-restore", 0 ], + main: [ "service/lifecycle.uc", "main", 0 ], + list_update: [ "components/updates.uc", "list-update", 0 ], + list_update_if_due: [ "components/updates.uc", "list-update-if-due", 0 ], + subscription_update: [ "components/updates.uc", "subscription-update", 2 ], + subscription_update_async: [ "components/updates.uc", "subscription-update-async", 2 ], + subscription_update_status: [ "components/updates.uc", "subscription-update-status", 1 ], + subscription_update_if_due: [ "components/updates.uc", "subscription-update-if-due", 0 ], + check_proxy: [ "diagnostics/runtime.uc", "check-proxy", 0 ], + check_nft: [ "diagnostics/runtime.uc", "check-nft", 0 ], + check_nft_rules: [ "diagnostics/runtime.uc", "check-nft-rules", 0 ], + check_sing_box: [ "diagnostics/runtime.uc", "check-sing-box", 0 ], + check_inbounds_config: [ "diagnostics/runtime.uc", "check-inbounds-config", 0 ], + check_inbounds: [ "diagnostics/runtime.uc", "check-inbounds", 0 ], + check_logs: [ "diagnostics/runtime.uc", "check-logs", 0 ], + check_sing_box_logs: [ "diagnostics/runtime.uc", "check-sing-box-logs", 0 ], + check_fakeip: [ "diagnostics/runtime.uc", "check-fakeip", 0 ], + check_zapret_runtime: [ "diagnostics/runtime.uc", "check-zapret-runtime", 0 ], + check_zapret2_runtime: [ "diagnostics/runtime.uc", "check-zapret2-runtime", 0 ], + check_byedpi_runtime: [ "diagnostics/runtime.uc", "check-byedpi-runtime", 0 ], + neutralize_zapret_defaults: [ "diagnostics/runtime.uc", "neutralize-zapret-defaults", 0 ], + clash_api: [ "diagnostics/runtime.uc", "clash-api", 4 ], + show_config: [ "diagnostics/runtime.uc", "show-config", 1 ], + show_version: [ "diagnostics/runtime.uc", "show-version", 0 ], + show_sing_box_config: [ "diagnostics/runtime.uc", "show-sing-box-config", 1 ], + show_sing_box_version: [ "diagnostics/runtime.uc", "show-sing-box-version", 0 ], + get_status: [ "diagnostics/runtime.uc", "get-status", 0 ], + get_outbound_metadata: [ "diagnostics/runtime.uc", "get-outbound-metadata", 1 ], + get_subscription_metadata: [ "diagnostics/runtime.uc", "get-subscription-metadata", 1 ], + get_sing_box_status: [ "diagnostics/runtime.uc", "get-sing-box-status", 0 ], + get_zapret_status: [ "diagnostics/runtime.uc", "get-zapret-status", 0 ], + get_zapret2_status: [ "diagnostics/runtime.uc", "get-zapret2-status", 0 ], + get_byedpi_status: [ "diagnostics/runtime.uc", "get-byedpi-status", 0 ], + get_system_info: [ "diagnostics/runtime.uc", "get-system-info", 0 ], + get_server_capabilities: [ "diagnostics/runtime.uc", "get-server-capabilities", 0 ], + get_ui_capabilities: [ "service/ui.uc", "get-ui-capabilities", 0 ], + get_ui_state: [ "service/ui.uc", "get-ui-state", 0 ], + service_action_async: [ "service/ui.uc", "service-action-async", 1 ], + service_action_status: [ "service/ui.uc", "service-action-status", 1 ], + latency_test_async: [ "service/ui.uc", "latency-test-async", 4 ], + latency_test_status: [ "service/ui.uc", "latency-test-status", 1 ], + ui_action_ack: [ "service/ui.uc", "action-ack", 2 ], + generate_reality_keypair: [ "server/service.uc", "generate-reality-keypair", 0 ], + get_tls_certificate_sha256: [ "server/service.uc", "tls-certificate-sha256", 1 ], + component_action: [ "components/action.uc", "component-action", 2 ], + component_action_async: [ "components/updates.uc", "component-action-async", 2 ], + component_action_status: [ "components/updates.uc", "component-action-status", 1 ], + component_updates_if_due: [ "components/updates.uc", "component-updates-if-due", 0 ], + component_update_check_cache: [ "components/updates.uc", "component-update-check-cache", 0 ], + check_dns_available: [ "diagnostics/runtime.uc", "check-dns-available", 0 ], + global_check: [ "diagnostics/runtime.uc", "global-check", 2 ], + validate_nfqws_strategy_json: [ "diagnostics/runtime.uc", "validate-nfqws-strategy-json", 1 ], + validate_nfqws2_strategy_json: [ "diagnostics/runtime.uc", "validate-nfqws2-strategy-json", 1 ], + validate_byedpi_strategy_json: [ "providers/byedpi/validator.uc", "validate-json", 1 ], + package_prerm: [ "service/package.uc", "prerm", 1 ], + package_postinst: [ "service/package.uc", "postinst", 0 ], + luci_postinst: [ "service/package.uc", "luci-postinst", 0 ] + }; + + return commands[as_string(command)]; +} + +function run_spec(command, spec, user_args) { + let module = module_path(spec[0]); + if (!file_readable(module)) { + warn("Error: required file '", module, "' is missing or not readable\n"); + restore_dnsmasq_failsafe_on_loader_failure(command); + return 1; + } + + let args = [ "ucode", "-L", LIB_DIR, module, spec[1] ]; + let count = int(spec[2] || 0); + for (let i = 0; i < count; i++) + push(args, user_args[i] || ""); + + return normalize_status(system(command_from_args(args))); +} + +function main() { + let command = ARGV[0] || ""; + let spec = command_spec(command); + if (spec == null) { + show_help(); + return 1; + } + + let user_args = []; + for (let i = 1; i < length(ARGV); i++) + push(user_args, ARGV[i]); + + return run_spec(command, spec, user_args); +} + +exit(main()); diff --git a/tachyon/files/usr/lib/components/action.uc b/tachyon/files/usr/lib/components/action.uc new file mode 100644 index 00000000..c2977a86 --- /dev/null +++ b/tachyon/files/usr/lib/components/action.uc @@ -0,0 +1,1895 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let constants = require("core.constants"); +let uci_core = require("core.uci"); + +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const BIN_PATH = getenv("TACHYON_BIN") || constants.TACHYON_BIN || "/usr/bin/tachyon"; +const SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || constants.TACHYON_SERVICE_INIT || "/etc/init.d/tachyon"; +const TACHYON_VERSION = getenv("TACHYON_VERSION") || constants.TACHYON_VERSION || ""; +const TACHYON_RELEASE_REPO = getenv("TACHYON_RELEASE_REPO") || constants.TACHYON_RELEASE_REPO || "ushan0v/tachyon"; +const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const SYSTEM_INFO_CACHE_FILE = getenv("TACHYON_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json"; +const COMPONENT_LOCK_DIR = getenv("UPDATES_LOCK_DIR") || RUNTIME_STATE_DIR + "/component-action.lock"; +const TMP_STALE_TTL_MINUTES = getenv("UPDATES_TMP_STALE_TTL_MINUTES") || "30"; +const TMP_FILE_STALE_TTL_MINUTES = getenv("UPDATES_TMP_FILE_STALE_TTL_MINUTES") || "10"; +const SB_MANAGED_SERVICE_MARKER = getenv("SB_MANAGED_SERVICE_MARKER") || constants.SB_MANAGED_SERVICE_MARKER || "Tachyon managed sing-box service for binary variants"; + +let tmp_dir = ""; +let lock_held = false; +let tachyon_was_running = false; +let tachyon_stopped_for_sing_box_change = false; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_env(assignments) { + let parts = []; + for (let name, value in assignments) + push(parts, name + "=" + shell_quote(value)); + return join(" ", parts); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success(command) { + return command_status("(" + command + ") >/dev/null 2>&1") == 0; +} + +function command_success_from_args(args) { + return command_success(command_from_args(args)); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_exists(name) { + return command_success_from_args([ "command", "-v", name ]); +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function write_file(path, value) { + return fs.writefile(as_string(path), as_string(value)) != null; +} + +function read_file(path) { + let data = fs.readfile(as_string(path)); + return data == null ? "" : as_string(data); +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function ensure_dir(path) { + return command_success_from_args([ "mkdir", "-p", as_string(path) ]); +} + +function file_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function file_nonempty(path) { + let stat = fs.stat(as_string(path)); + return stat != null && int(stat.size || 0) > 0; +} + +function path_basename(path) { + let parts = split(as_string(path), "/"); + return length(parts) > 0 ? as_string(parts[length(parts) - 1]) : ""; +} + +function now_seconds() { + return int(clock()[0]); +} + +function owner_pid() { + let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ])); + return match(pid, /^[0-9]+$/) != null ? pid : "0"; +} + +function pid_running(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]); +} + +function updates_log(message, level) { + log_message("Updates: " + as_string(message), level || "info"); +} + +function module_command(args) { + let command_args = [ "ucode", "-L", LIB_DIR ]; + for (let arg in args) + push(command_args, arg); + return command_from_args(command_args); +} + +function module_output(args) { + return command_output(module_command(args)); +} + +function module_success(args) { + return command_success(module_command(args)); +} + +function helper_output(mode, args) { + let command_args = [ LIB_DIR + "/components/updater.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + return module_output(command_args); +} + +function helper_success(mode, args) { + let command_args = [ LIB_DIR + "/components/updater.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + return module_success(command_args); +} + +function cleanup_stale_tmp_files() { + command_success_from_args([ "find", "/tmp", "-maxdepth", "1", "-type", "d", "-name", "tachyon-updates.*", "-mmin", "+" + as_string(TMP_STALE_TTL_MINUTES), "-exec", "rm", "-rf", "{}", "+" ]); + command_success_from_args([ "find", "/tmp", "-maxdepth", "1", "-type", "f", "(", "-name", "tachyon-updates-command.*", "-o", "-name", "tachyon-updates-http.*", ")", "-mmin", "+" + as_string(TMP_FILE_STALE_TTL_MINUTES), "-delete" ]); +} + +function init_tmp_dir() { + if (tmp_dir != "") + return true; + + cleanup_stale_tmp_files(); + tmp_dir = trim(command_output_from_args([ "mktemp", "-d", "/tmp/tachyon-updates.XXXXXX" ])); + if (tmp_dir == "") { + tmp_dir = "/tmp/tachyon-updates." + owner_pid(); + if (!ensure_dir(tmp_dir)) { + tmp_dir = ""; + return false; + } + } + return true; +} + +function make_tmp_file(prefix) { + init_tmp_dir(); + let base = tmp_dir != "" ? tmp_dir + "/" + as_string(prefix) + ".XXXXXX" : "/tmp/tachyon-updates-" + as_string(prefix) + ".XXXXXX"; + let path = trim(command_output_from_args([ "mktemp", base ])); + if (path == "") { + path = (tmp_dir != "" ? tmp_dir : "/tmp") + "/" + as_string(prefix) + "." + owner_pid() + "." + now_seconds(); + if (!write_file(path, "")) + return ""; + } + return path; +} + +function helper_output_input(input, mode, args) { + let input_path = make_tmp_file("helper-input"); + if (input_path == "") + return ""; + write_file(input_path, as_string(input)); + + let command_args = [ LIB_DIR + "/components/updater.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + let output = command_output(command_from_args([ "cat", input_path ]) + " | " + module_command(command_args)); + remove_file(input_path); + return output; +} + +function helper_success_input(input, mode, args) { + let input_path = make_tmp_file("helper-input"); + if (input_path == "") + return false; + write_file(input_path, as_string(input)); + + let command_args = [ LIB_DIR + "/components/updater.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + let ok = command_success(command_from_args([ "cat", input_path ]) + " | " + module_command(command_args)); + remove_file(input_path); + return ok; +} + +function cleanup_tmp_dir() { + if (tmp_dir != "") { + command_success_from_args([ "rm", "-rf", tmp_dir ]); + tmp_dir = ""; + } + cleanup_stale_tmp_files(); +} + +function acquire_component_lock() { + ensure_dir(RUNTIME_STATE_DIR); + if (command_success_from_args([ "mkdir", COMPONENT_LOCK_DIR ])) { + write_file(COMPONENT_LOCK_DIR + "/pid", owner_pid() + "\n"); + lock_held = true; + return true; + } + + let current_owner = trim(read_file(COMPONENT_LOCK_DIR + "/pid")); + if (current_owner != "" && pid_running(current_owner)) + return false; + + remove_file(COMPONENT_LOCK_DIR + "/pid"); + command_success_from_args([ "rmdir", COMPONENT_LOCK_DIR ]); + if (!command_success_from_args([ "mkdir", COMPONENT_LOCK_DIR ])) + return false; + + write_file(COMPONENT_LOCK_DIR + "/pid", owner_pid() + "\n"); + lock_held = true; + return true; +} + +function release_component_lock() { + if (!lock_held) + return; + remove_file(COMPONENT_LOCK_DIR + "/pid"); + command_success_from_args([ "rmdir", COMPONENT_LOCK_DIR ]); + lock_held = false; +} + +function cleanup_action() { + cleanup_tmp_dir(); + release_component_lock(); +} + +function updates_response(success, component, action, message, current_version, latest_version, changed, status, release_url) { + write_json({ + success: !!success, + kind: "component", + component: as_string(component), + action: as_string(action), + message: as_string(message), + current_version: as_string(current_version), + latest_version: as_string(latest_version), + changed: int(changed || 0), + status: as_string(status), + release_url: as_string(release_url) + }); +} + +function restart_tachyon_after_failed_sing_box_change() { + if (!tachyon_stopped_for_sing_box_change || !tachyon_was_running || !file_exists(SERVICE_INIT)) + return; + updates_log("Restarting Tachyon after failed sing-box component change"); + if (!command_success_from_args([ SERVICE_INIT, "start" ])) + command_success_from_args([ SERVICE_INIT, "restart" ]); +} + +function action_success(component, action, message, current_version, latest_version, changed, status, release_url) { + updates_response(true, component, action, message, current_version, latest_version, changed || 0, status || "", release_url || ""); + cleanup_action(); + exit(0); +} + +function action_fail(component, action, message, current_version, latest_version, status, release_url) { + updates_log(message, "error"); + restart_tachyon_after_failed_sing_box_change(); + updates_response(false, component, action, message, current_version || "", latest_version || "", 0, status || "", release_url || ""); + cleanup_action(); + exit(1); +} + +function run_logged(description, command) { + init_tmp_dir(); + let output_file = make_tmp_file("command"); + if (output_file == "") + output_file = "/tmp/tachyon-updates-command." + owner_pid(); + + updates_log(description); + let status = command_status(as_string(command) + " >" + shell_quote(output_file) + " 2>&1"); + for (let line in split(read_file(output_file), "\n")) + if (trim(as_string(line)) != "") + updates_log(line); + remove_file(output_file); + if (status != 0) + updates_log(description + " failed with exit code " + status, "warn"); + return status == 0; +} + +function is_apk() { + return command_exists("apk"); +} + +function pkg_is_installed(package_name) { + package_name = as_string(package_name); + if (is_apk()) + return command_success_from_args([ "apk", "info", "-e", package_name ]); + return module_success([ LIB_DIR + "/core/packages.uc", "opkg-installed", package_name ]); +} + +function installed_package_version(package_name) { + package_name = as_string(package_name); + if (is_apk()) { + if (!pkg_is_installed(package_name)) + return ""; + return trim(module_output([ LIB_DIR + "/core/packages.uc", "apk-version", package_name ])); + } + return trim(module_output([ LIB_DIR + "/core/packages.uc", "opkg-version", package_name ])); +} + +function opkg_package_version_from_list(package_name, output) { + return trim(helper_output_input(output, "updates-opkg-package-version", [ package_name ])); +} + +function available_package_version(package_name) { + package_name = as_string(package_name); + if (is_apk()) + return trim(module_output([ LIB_DIR + "/core/packages.uc", "apk-available-version", package_name ])); + return opkg_package_version_from_list(package_name, command_output_from_args([ "opkg", "list", package_name ])); +} + +function pkg_list_update_command() { + return is_apk() ? "apk update ") + return 1; + if (apk_result == "<") + return -1; + if (apk_result == "=") + return 0; + } + + if (command_exists("opkg")) { + if (command_success_from_args([ "opkg", "compare-versions", lhs, ">", rhs ])) + return 1; + if (command_success_from_args([ "opkg", "compare-versions", lhs, "<", rhs ])) + return -1; + if (command_success_from_args([ "opkg", "compare-versions", lhs, "=", rhs ])) + return 0; + } + + return module_success([ LIB_DIR + "/core/helpers.uc", "version-at-least", lhs, rhs ]) ? 1 : -1; +} + +function status_from_compare(compare_result) { + if (compare_result == -1) + return "outdated"; + if (compare_result == 0) + return "latest"; + if (compare_result == 1) + return "dev"; + return ""; +} + +function check_success_compared(component, current_version, latest_version, compare_current_version, compare_latest_version, release_url) { + let compare_result = compare_versions(compare_current_version, compare_latest_version); + if (compare_result == null) + action_fail(component, "check_update", "Failed to compare versions", current_version, latest_version); + + let status = status_from_compare(compare_result); + if (status == "") + action_fail(component, "check_update", "Failed to compare versions", current_version, latest_version); + + let result_row = trim(helper_output("updates-check-result-row", [ component, current_version, latest_version, status ])); + if (result_row == "") + action_fail(component, "check_update", "Failed to compare versions", current_version, latest_version); + + let fields = split(result_row, "\t"); + let message = as_string(fields[0] || ""); + let log_line = length(fields) > 1 ? as_string(fields[1]) : message; + updates_log(log_line); + action_success(component, "check_update", message, current_version, latest_version, 0, status, release_url || ""); +} + +function check_success(component, current_version, latest_version, release_url) { + check_success_compared(component, current_version, latest_version, current_version, latest_version, release_url || ""); +} + +function read_openwrt_release_value(key) { + return trim(helper_output("openwrt-release-value", [ "/etc/openwrt_release", key ])); +} + +function service_proxy_address() { + if (!file_exists(LIB_DIR + "/singbox/runtime.uc")) + return ""; + if (file_exists(LIB_DIR + "/service/state.uc") && + !module_success([ LIB_DIR + "/service/state.uc", "sing-box-service-running" ])) + return ""; + return trim(module_output([ LIB_DIR + "/singbox/runtime.uc", "service-proxy-address", "components" ])); +} + +function http_get_once(url, output_path, proxy_address, timeout) { + url = as_string(url); + output_path = as_string(output_path); + proxy_address = as_string(proxy_address); + timeout = as_string(timeout || "30"); + + if (command_exists("curl")) { + let args = [ "curl", "--connect-timeout", "5", "-m", timeout, "-fsSL" ]; + if (proxy_address != "") { + push(args, "-x"); + push(args, "http://" + proxy_address); + } + push(args, url); + push(args, "-o"); + push(args, output_path); + return command_success_from_args(args); + } + + if (command_exists("wget")) { + let command = command_from_args([ "wget", "-T", timeout, "-q", "-O", output_path, url ]); + if (proxy_address != "") + command = command_env({ http_proxy: "http://" + proxy_address, https_proxy: "http://" + proxy_address }) + " " + command; + return command_success(command); + } + + return false; +} + +function http_get(url) { + init_tmp_dir(); + let output_path = make_tmp_file("http"); + if (output_path == "") + return ""; + + let proxy_address = service_proxy_address(); + if (proxy_address != "") { + if (http_get_once(url, output_path, proxy_address, "30")) { + let data = read_file(output_path); + remove_file(output_path); + return data; + } + remove_file(output_path); + updates_log("HTTP request via service proxy failed for " + as_string(url) + "; retrying directly", "warn"); + } + + if (http_get_once(url, output_path, "", "30")) { + let data = read_file(output_path); + remove_file(output_path); + return data; + } + + remove_file(output_path); + return ""; +} + +function download_file_once(url, output_path) { + let proxy_address = service_proxy_address(); + if (proxy_address != "") { + if (http_get_once(url, output_path, proxy_address, "120")) + return true; + remove_file(output_path); + updates_log("Download via service proxy failed for " + as_string(url) + "; retrying directly", "warn"); + } + return http_get_once(url, output_path, "", "120"); +} + +function download_with_retry(url, output_path, label) { + for (let attempt = 1; attempt <= 3; attempt++) { + updates_log("Downloading " + as_string(label) + " (" + attempt + "/3)"); + if (download_file_once(url, output_path) && file_nonempty(output_path)) + return true; + remove_file(output_path); + updates_log("Retrying " + as_string(label), "warn"); + } + return false; +} + +function fetch_github_release_json(owner, repo) { + let response = http_get("https://api.github.com/repos/" + as_string(owner) + "/" + as_string(repo) + "/releases/latest"); + if (response == "" || !helper_success_input(response, "github-response-ok", [])) + return ""; + return response; +} + +function fetch_github_releases_json(owner, repo, per_page) { + let response = http_get("https://api.github.com/repos/" + as_string(owner) + "/" + as_string(repo) + "/releases?per_page=" + as_string(per_page || "30")); + if (response == "" || !helper_success_input(response, "github-response-ok", [])) + return ""; + return response; +} + +function latest_tachyon_release_json() { + let parts = split(TACHYON_RELEASE_REPO, "/"); + if (length(parts) != 2 || as_string(parts[0]) == "" || as_string(parts[1]) == "") + return ""; + return fetch_github_release_json(parts[0], parts[1]); +} + +function latest_tachyon_version() { + let response = latest_tachyon_release_json(); + if (response == "") + return ""; + return trim(helper_output_input(response, "object-get-default", [ "tag_name", "" ])); +} + +function fetch_tachyon_latest_release_metadata() { + let response = latest_tachyon_release_json(); + if (response == "") + return ""; + return trim(helper_output_input(response, "release-metadata-tsv", [])); +} + +function write_tachyon_latest_version_cache(value, timestamp) { + if (as_string(value) == "") + return; + write_file("/tmp/tachyon.latest-version.cache", as_string(value) + "\n" + as_string(timestamp) + "\n"); +} + +function retry_resolve(description, fn) { + for (let attempt = 1; attempt <= 3; attempt++) { + if (fn()) + return true; + updates_log(as_string(description) + " failed (" + attempt + "/3)", "warn"); + command_success_from_args([ "sleep", "2" ]); + } + return false; +} + +function ensure_package_tool(tool_name, package_name, component, action) { + if (command_exists(tool_name)) + return true; + if (!run_logged("Updating package lists before installing " + as_string(package_name), pkg_list_update_command())) + return false; + return run_logged("Installing bootstrap package " + as_string(package_name), pkg_install_name_command(package_name)); +} + +function clear_version_caches() { + remove_file("/tmp/tachyon.latest-version.cache"); + remove_file(SYSTEM_INFO_CACHE_FILE); + remove_file("/tmp/tachyon/system-info.json"); +} + +function managed_sing_box_service_installed() { + return file_exists("/etc/init.d/sing-box") && index(read_file("/etc/init.d/sing-box"), SB_MANAGED_SERVICE_MARKER) >= 0; +} + +function managed_sing_box_service_text() { + return "#!/bin/sh /etc/rc.common\n" + + "# " + SB_MANAGED_SERVICE_MARKER + "\n\n" + + "USE_PROCD=1\n" + + "START=99\n" + + "PROG=\"/usr/bin/sing-box\"\n\n" + + "start_service() {\n" + + " config_load \"sing-box\"\n" + + " local enabled config_file working_directory\n" + + " local log_stderr\n\n" + + " config_get_bool enabled \"main\" \"enabled\" \"0\"\n" + + " [ \"$enabled\" -eq \"1\" ] || return 0\n\n" + + " config_get config_file \"main\" \"conffile\" \"/etc/sing-box/config.json\"\n" + + " config_get working_directory \"main\" \"workdir\" \"/usr/share/sing-box\"\n" + + " config_get_bool log_stderr \"main\" \"log_stderr\" \"1\"\n\n" + + " procd_open_instance\n" + + " procd_set_param command \"$PROG\" run -c \"$config_file\" -D \"$working_directory\"\n" + + " procd_set_param file \"$config_file\"\n" + + " procd_set_param stderr \"$log_stderr\"\n" + + " procd_set_param limits core=\"unlimited\"\n" + + " procd_set_param limits nofile=\"1000000 1000000\"\n" + + " procd_set_param respawn\n" + + " procd_close_instance\n" + + "}\n\n" + + "service_triggers() {\n" + + " procd_add_reload_trigger \"sing-box\"\n" + + "}\n"; +} + +function install_managed_sing_box_service_script() { + let tmp = "/etc/init.d/sing-box.tachyon." + owner_pid(); + if (!write_file(tmp, managed_sing_box_service_text())) + return false; + if (!command_success_from_args([ "chmod", "0755", tmp ])) { + remove_file(tmp); + return false; + } + return fs.rename(tmp, "/etc/init.d/sing-box"); +} + +function remove_managed_sing_box_service_script() { + if (!managed_sing_box_service_installed()) + return true; + command_success_from_args([ "/etc/init.d/sing-box", "stop" ]); + command_success_from_args([ "/etc/init.d/sing-box", "disable" ]); + remove_file("/etc/init.d/sing-box"); + return true; +} + +function disable_sing_box_service_config() { + if (!uci_core.available()) + return true; + if (!uci_core.exists("sing-box.main") && !uci_core.set_section("sing-box.main", "sing-box")) + return false; + if (!uci_core.set("sing-box.main.enabled", "0")) + return false; + return uci_core.commit("sing-box"); +} + +function prepare_sing_box_service_disabled() { + disable_sing_box_service_config(); + if (file_exists("/etc/init.d/sing-box")) { + command_success_from_args([ "/etc/init.d/sing-box", "stop" ]); + command_success_from_args([ "/etc/init.d/sing-box", "disable" ]); + } +} + +function prepare_sing_box_package_service_install() { + prepare_sing_box_service_disabled(); + remove_managed_sing_box_service_script(); +} + +function tachyon_status_running_with_timeout() { + init_tmp_dir(); + let output_file = make_tmp_file("tachyon-status"); + if (output_file == "") + return false; + + let command = command_from_args([ BIN_PATH, "get_status" ]) + " >" + shell_quote(output_file) + " 2>/dev/null & pid=$!; " + + "( sleep 6; kill $pid 2>/dev/null || true ) & watcher=$!; " + + "wait $pid 2>/dev/null; rc=$?; kill $watcher 2>/dev/null || true; wait $watcher 2>/dev/null || true; exit $rc"; + let ok = command_status("sh -c " + shell_quote(command)) == 0 && + match(read_file(output_file), /"running"[ \t]*:[ \t]*1/) != null; + remove_file(output_file); + return ok; +} + +function capture_tachyon_running_state() { + tachyon_was_running = file_exists(BIN_PATH) && tachyon_status_running_with_timeout(); +} + +function restart_tachyon_after_successful_change() { + if (!file_exists(SERVICE_INIT)) + return; + if (!tachyon_was_running) { + updates_log("Tachyon was not running before component change; restart skipped"); + prepare_sing_box_service_disabled(); + return; + } + run_logged("Restarting Tachyon after successful component change", command_from_args([ SERVICE_INIT, "restart" ])); +} + +function stop_tachyon_before_sing_box_change() { + if (tachyon_stopped_for_sing_box_change) + return; + tachyon_stopped_for_sing_box_change = true; + + if (tachyon_was_running && file_exists(SERVICE_INIT)) + run_logged("Stopping Tachyon before sing-box package change", command_from_args([ SERVICE_INIT, "stop" ])); + + if (tachyon_was_running && file_exists(BIN_PATH)) + command_success_from_args([ BIN_PATH, "restore_dnsmasq" ]); + + prepare_sing_box_service_disabled(); +} + +function wait_tachyon_running_after_sing_box_change() { + if (!tachyon_was_running) + return true; + if (!file_exists(BIN_PATH)) + return false; + + let waited = 0; + while (waited < 60) { + if (tachyon_status_running_with_timeout()) { + command_success_from_args([ "sleep", "8" ]); + if (tachyon_status_running_with_timeout()) + return true; + } + command_success_from_args([ "sleep", "4" ]); + waited += 4; + } + return false; +} + +function opkg_arch_list() { + return trim(helper_output_input(command_output_from_args([ "opkg", "print-architecture" ]), "updates-opkg-arch-list", [])); +} + +function resolve_arch_candidates() { + let arch_list = ""; + if (is_apk()) { + if (file_exists("/etc/apk/arch")) + arch_list += " " + trim(helper_output("file-whitespace-list", [ "/etc/apk/arch" ])); + arch_list += " " + trim(command_output_from_args([ "apk", "--print-arch" ])); + } + else { + arch_list = opkg_arch_list(); + } + + let release_arch = read_openwrt_release_value("DISTRIB_ARCH"); + if (release_arch != "") + arch_list += " " + release_arch; + if (!helper_success("string-has-whitespace-field", [ arch_list ])) + arch_list = trim(command_output_from_args([ "uname", "-m" ])); + + let resolved = trim(helper_output("updates-arch-candidates", [ arch_list ])); + let fields = split(resolved, "\t"); + if (length(fields) < 2 || as_string(fields[0]) == "" || as_string(fields[1]) == "") + return null; + + updates_log("Detected package architecture candidates: " + fields[1]); + return { + target: as_string(fields[0]), + candidates: as_string(fields[1]) + }; +} + +function select_inner_package_path(bundle_file, component, arch, ext) { + return trim(helper_output_input(command_output_from_args([ "unzip", "-l", bundle_file ]), "updates-zip-inner-package-path", [ component, arch, ext ])); +} + +function select_archive_member_path(archive_file, member_name) { + return trim(helper_output_input(command_output_from_args([ "tar", "-tzf", archive_file ]), "updates-archive-member-path", [ member_name ])); +} + +function extract_arch_package_version(package_name, package_arch) { + return trim(helper_output("updates-arch-package-version", [ package_name, package_arch ])); +} + +function extract_zapret_bundle_version(bundle_name) { + return trim(helper_output("updates-zapret-bundle-version", [ bundle_name ])); +} + +function extract_zapret2_bundle_version(bundle_name) { + return trim(helper_output("updates-zapret2-bundle-version", [ bundle_name ])); +} + +function normalize_zapret_version(value) { + return trim(helper_output("updates-normalize-zapret-version", [ value ])); +} + +function normalize_sing_box_version(value) { + return trim(helper_output("updates-normalize-sing-box-version", [ value ])); +} + +function resolve_zapret_release(arch) { + let release_json = fetch_github_release_json("remittor", "zapret-openwrt"); + if (release_json == "") + return null; + let resolved = trim(helper_output_input(release_json, "release-select-arch-suffix-asset", [ "zip", arch.candidates ])); + let fields = split(resolved, "\t"); + if (length(fields) < 4) + return null; + let version = extract_zapret_bundle_version(fields[1]); + if (version == "") + version = trim(helper_output("string-remove-suffix", [ fields[1], ".zip" ])); + return { + arch: fields[0], + bundle_name: fields[1], + bundle_url: fields[2], + release_url: fields[3], + version + }; +} + +function resolve_zapret2_release(arch) { + let releases_json = fetch_github_releases_json("remittor", "zapret-openwrt", "30"); + if (releases_json == "") + return null; + let resolved = trim(helper_output_input(releases_json, "named-release-select-asset", [ "zapret2 ", "zapret2", "zip", arch.candidates ])); + let fields = split(resolved, "\t"); + if (length(fields) < 4) + return null; + let version = extract_zapret2_bundle_version(fields[1]); + if (version == "") + version = trim(helper_output("string-remove-suffix", [ fields[1], ".zip" ])); + return { + arch: fields[0], + bundle_name: fields[1], + bundle_url: fields[2], + release_url: fields[3], + version + }; +} + +function download_and_extract_zip_package(release, component) { + let bundle_file = tmp_dir + "/" + release.bundle_name; + if (!download_with_retry(release.bundle_url, bundle_file, release.bundle_name)) + return null; + + let inner_package_path = is_apk() ? + select_inner_package_path(bundle_file, component, "", "apk") : + select_inner_package_path(bundle_file, component, release.arch, "ipk"); + if (inner_package_path == "") + return null; + + let package_name = path_basename(inner_package_path); + let package_file = tmp_dir + "/" + package_name; + if (!command_success(command_from_args([ "unzip", "-p", bundle_file, inner_package_path ]) + " >" + shell_quote(package_file)) || + !file_nonempty(package_file)) + return null; + + let version = as_string(release.version || ""); + if (version == "") + version = component == "zapret2" ? extract_zapret2_bundle_version(release.bundle_name) : extract_zapret_bundle_version(release.bundle_name); + if (version == "") + version = extract_arch_package_version(package_name, release.arch); + + return { + name: package_name, + file: package_file, + version + }; +} + +function resolve_byedpi_release(arch) { + let asset_ext = is_apk() ? "apk" : "ipk"; + let release_series = trim(helper_output("openwrt-release-series", [ "/etc/openwrt_release" ])); + let releases_json = fetch_github_releases_json("DPITrickster", "ByeDPI-OpenWrt", "30"); + if (releases_json == "") + return null; + let resolved = trim(helper_output_input(releases_json, "byedpi-select-asset", [ release_series, asset_ext, arch.candidates ])); + let fields = split(resolved, "\t"); + if (length(fields) < 4) + return null; + return { + arch: fields[0], + package_name: fields[1], + package_url: fields[2], + release_url: fields[3], + version: extract_arch_package_version(fields[1], fields[0]) + }; +} + +function download_byedpi_package(release) { + let package_file = tmp_dir + "/" + release.package_name; + if (!download_with_retry(release.package_url, package_file, release.package_name) || !file_nonempty(package_file)) + return null; + let version = as_string(release.version || ""); + if (version == "") + version = extract_arch_package_version(release.package_name, release.arch); + return { + name: release.package_name, + file: package_file, + version + }; +} + +function disable_standalone_service(name) { + let init = "/etc/init.d/" + as_string(name); + if (!file_exists(init)) + return; + run_logged("Stopping standalone " + as_string(name) + " service", command_from_args([ init, "stop" ])); + run_logged("Disabling standalone " + as_string(name) + " autostart", command_from_args([ init, "disable" ])); +} + +function provider_installed(runtime_module) { + return module_success([ runtime_module, "installed" ]); +} + +function provider_package_version(runtime_module) { + return trim(module_output([ runtime_module, "package-version" ])); +} + +function install_zapret_like(component, action, runtime_module, resolve_fn, label) { + init_tmp_dir() || action_fail(component, action, "Failed to create temporary directory"); + let arch = resolve_arch_candidates(); + if (arch == null) + action_fail(component, action, "Failed to detect package architecture"); + let release = null; + retry_resolve("Resolving " + label + " package", function() { + release = resolve_fn(arch); + return release != null; + }); + if (release == null) + action_fail(component, action, "Failed to resolve " + label + " package for this router architecture"); + + let installed = provider_installed(runtime_module); + let current_version = provider_package_version(runtime_module); + if (action == "check_update") { + if (!installed) + action_fail(component, action, label + " is not installed", current_version, release.version, "", release.release_url || ""); + check_success_compared(component, current_version, release.version, normalize_zapret_version(current_version), normalize_zapret_version(release.version), release.release_url || ""); + } + + if (!ensure_package_tool("unzip", "unzip", component, action)) + action_fail(component, action, "Failed to install unzip"); + let pkg = download_and_extract_zip_package(release, component); + if (pkg == null) + action_fail(component, action, "Failed to download " + label + " package", current_version, release.version, "", release.release_url || ""); + + if (!run_logged("Installing " + label + " package " + pkg.name, pkg_install_files_command([ pkg.file ]))) + action_fail(component, action, "Failed to install " + label + " package", current_version, pkg.version, "", release.release_url || ""); + + disable_standalone_service(component); + restart_tachyon_after_successful_change(); + clear_version_caches(); + current_version = provider_package_version(runtime_module); + if (current_version == "") + current_version = "unknown"; + action_success(component, action, label + " package has been installed", current_version, pkg.version, 1, "latest", release.release_url || ""); +} + +function install_zapret(action) { + install_zapret_like("zapret", action, LIB_DIR + "/providers/zapret/runtime.uc", resolve_zapret_release, "zapret"); +} + +function install_zapret2(action) { + install_zapret_like("zapret2", action, LIB_DIR + "/providers/zapret2/runtime.uc", resolve_zapret2_release, "zapret2"); +} + +function install_byedpi(action) { + init_tmp_dir() || action_fail("byedpi", action, "Failed to create temporary directory"); + let arch = resolve_arch_candidates(); + if (arch == null) + action_fail("byedpi", action, "Failed to detect package architecture"); + let release = null; + retry_resolve("Resolving ByeDPI package", function() { + release = resolve_byedpi_release(arch); + return release != null; + }); + if (release == null) + action_fail("byedpi", action, "Failed to resolve ByeDPI package for this router architecture"); + + let runtime_module = LIB_DIR + "/providers/byedpi/runtime.uc"; + let installed = provider_installed(runtime_module); + let current_version = provider_package_version(runtime_module); + if (action == "check_update") { + if (!installed) + action_fail("byedpi", action, "ByeDPI is not installed", current_version, release.version); + check_success("byedpi", current_version, release.version, release.release_url || ""); + } + + let pkg = download_byedpi_package(release); + if (pkg == null) + action_fail("byedpi", action, "Failed to download ByeDPI package"); + if (!run_logged("Installing ByeDPI package " + pkg.name, pkg_install_files_command([ pkg.file ]))) + action_fail("byedpi", action, "Failed to install ByeDPI package", current_version, pkg.version); + + disable_standalone_service("byedpi"); + restart_tachyon_after_successful_change(); + clear_version_caches(); + current_version = provider_package_version(runtime_module); + if (current_version == "") + current_version = "unknown"; + action_success("byedpi", action, "ByeDPI package has been installed", current_version, pkg.version, 1, "latest", release.release_url || ""); +} + +function remove_optional_component(component, package_name, label, runtime_module) { + if (!pkg_is_installed(package_name)) { + if (provider_installed(runtime_module)) + action_fail(component, "remove", label + " exists outside the package manager and was not removed automatically"); + action_success(component, "remove", label + " is already removed", "", "", 0); + } + + let current_version = provider_package_version(runtime_module); + let command = is_apk() ? + command_from_args([ "apk", "del", package_name ]) + " = 0 ? version : ""; +} + +function move_file_portable(source_path, target_path) { + if (fs.rename(source_path, target_path)) + return true; + + let staged_path = as_string(target_path) + ".tachyon-move." + owner_pid(); + remove_file(staged_path); + if (!command_success_from_args([ "cp", "-p", source_path, staged_path ]) || + !fs.rename(staged_path, target_path)) { + remove_file(staged_path); + return false; + } + remove_file(source_path); + return true; +} + +function install_staged_file(source_path, target_path, mode) { + let staged_path = as_string(target_path) + ".tachyon-new." + owner_pid(); + remove_file(staged_path); + if (!command_success_from_args([ "cp", "-f", source_path, staged_path ]) || + !command_success_from_args([ "chmod", mode, staged_path ]) || + !fs.rename(staged_path, target_path)) { + remove_file(staged_path); + return false; + } + remove_file(source_path); + return true; +} + +function move_file_to_backup(target_path, backup_path) { + if (!file_exists(target_path)) + return true; + remove_file(backup_path); + return move_file_portable(target_path, backup_path); +} + +function restore_sing_box_backup(backup_binary) { + if (as_string(backup_binary) != "" && file_nonempty(backup_binary)) { + if (!move_file_portable(backup_binary, "/usr/bin/sing-box")) + return false; + return command_success_from_args([ "chmod", "0755", "/usr/bin/sing-box" ]); + } + remove_file("/usr/bin/sing-box"); + return true; +} + +function restore_file_backup(target_path, backup_path) { + if (as_string(backup_path) != "" && file_nonempty(backup_path)) + return move_file_portable(backup_path, target_path); + remove_file(target_path); + return true; +} + +function restore_sing_box_service_from_marker(marker) { + if (as_string(marker) == "extended-compressed" || + (!file_exists("/etc/init.d/sing-box") && file_nonempty("/usr/bin/sing-box"))) + return install_managed_sing_box_service_script(); + remove_managed_sing_box_service_script(); + return true; +} + +function resolve_sing_box_extended_arch_suffix() { + let host_arch = trim(command_output_from_args([ "uname", "-m" ])); + let distrib_arch = read_openwrt_release_value("DISTRIB_ARCH"); + return trim(helper_output("sing-box-extended-arch-suffix", [ host_arch, distrib_arch ])); +} + +function sing_box_extended_tag_is_stable(tag) { + tag = lc(as_string(tag)); + return tag != "" && index(tag, "alpha") < 0 && index(tag, "beta") < 0 && index(tag, "rc") < 0; +} + +function set_sing_box_extended_release_from_json(release_json, compressed) { + if (as_string(release_json) == "") + return null; + let tag = trim(helper_output_input(release_json, "object-get-default", [ "tag_name", "" ])); + if (!sing_box_extended_tag_is_stable(tag)) + return null; + + let asset_url = ""; + if (compressed) { + let arch_suffix = resolve_sing_box_extended_arch_suffix(); + if (arch_suffix == "") + return null; + asset_url = trim(helper_output_input(release_json, "sing-box-extended-asset-url", [ arch_suffix, "0", "1" ])); + } + else { + let distrib_arch = read_openwrt_release_value("DISTRIB_ARCH"); + if (distrib_arch == "") + return null; + let asset_ext = is_apk() ? "apk" : "ipk"; + asset_url = trim(helper_output_input(release_json, "sing-box-extended-package-asset-url", [ distrib_arch, asset_ext ])); + } + + if (asset_url == "") + return null; + + return { + tag, + release_url: trim(helper_output_input(release_json, "object-get-default", [ "html_url", "" ])), + asset_url, + asset_name: path_basename(asset_url) + }; +} + +function resolve_sing_box_extended_release(compressed) { + let release_json = fetch_github_release_json("shtorm-7", "sing-box-extended"); + let resolved = set_sing_box_extended_release_from_json(release_json, compressed); + if (resolved != null) + return resolved; + + let releases_json = fetch_github_releases_json("shtorm-7", "sing-box-extended", "30"); + if (releases_json == "") + return null; + let tag = trim(helper_output_input(releases_json, "sing-box-extended-release-tag", [])); + if (tag == "") + return null; + release_json = helper_output_input(releases_json, "release-by-tag", [ tag ]); + return set_sing_box_extended_release_from_json(release_json, compressed); +} + +function sing_box_runtime_output(mode, args) { + let command_args = [ LIB_DIR + "/singbox/runtime.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + return trim(module_output(command_args)); +} + +function sing_box_runtime_success(mode, args) { + let command_args = [ LIB_DIR + "/singbox/runtime.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + return module_success(command_args); +} + +function write_sing_box_variant_state(marker, version) { + if (!sing_box_runtime_success("write-variant-marker", [ marker ])) + updates_log("Failed to write sing-box variant marker", "warn"); + if (!sing_box_runtime_success("write-version-state", [ version ])) + updates_log("Failed to write sing-box version state", "warn"); +} + +function restore_sing_box_variant_state(previous_marker, previous_version_state) { + sing_box_runtime_success("restore-variant-marker", [ previous_marker ]); + sing_box_runtime_success("restore-version-state", [ previous_version_state ]); +} + +function restore_sing_box_extended_package_variant() { + init_tmp_dir(); + let release = resolve_sing_box_extended_release(false); + if (release == null) + return false; + let package_file = tmp_dir + "/" + release.asset_name; + if (!download_with_retry(release.asset_url, package_file, release.asset_name)) + return false; + prepare_sing_box_package_service_install(); + pkg_remove_sing_box_conflict("sing-box-tiny"); + pkg_remove_sing_box_conflict("sing-box"); + if (!pkg_install_files([ package_file ])) { + remove_file(package_file); + return false; + } + remove_file(package_file); + let new_version = validate_sing_box_extended_binary("/usr/bin/sing-box", "/usr/lib"); + if (new_version == "") + return false; + write_sing_box_variant_state("extended", new_version); + return true; +} + +function replace_sing_box_package_variant(target_package, conflict_package, target_version) { + prepare_sing_box_package_service_install(); + if ((as_string(conflict_package) == "" || !pkg_is_installed(conflict_package)) && + (target_package == "sing-box-extended" || !pkg_is_installed("sing-box-extended"))) + return pkg_install_name_downgrade(target_package, target_version); + + if (target_package != "sing-box-extended" && !pkg_remove_sing_box_conflict("sing-box-extended")) + return false; + if (as_string(conflict_package) != "" && !pkg_remove_sing_box_conflict(conflict_package)) + return false; + return pkg_install_name_downgrade(target_package, target_version); +} + +function restore_sing_box_package_variant(previous_variant) { + if (previous_variant == "tiny") + return replace_sing_box_package_variant("sing-box-tiny", "sing-box", available_package_version("sing-box-tiny")); + if (previous_variant == "stable") + return replace_sing_box_package_variant("sing-box", "sing-box-tiny", available_package_version("sing-box")); + if (previous_variant == "extended") + return restore_sing_box_extended_package_variant(); + if (previous_variant == "not-installed") { + pkg_remove_sing_box_conflict("sing-box-extended"); + pkg_remove_sing_box_conflict("sing-box-tiny"); + pkg_remove_sing_box_conflict("sing-box"); + remove_managed_sing_box_service_script(); + remove_file("/usr/bin/sing-box"); + return true; + } + return false; +} + +function sing_box_variant_is_package_managed(variant) { + return variant == "stable" || variant == "tiny" || variant == "extended"; +} + +function restore_sing_box_install_backup(previous_variant, backup_binary) { + if (sing_box_variant_is_package_managed(previous_variant)) { + if (restore_sing_box_package_variant(previous_variant)) + return true; + if (as_string(backup_binary) != "" && restore_sing_box_backup(backup_binary)) { + updates_log("Package rollback failed; restored the previous sing-box binary backup", "warn"); + return true; + } + return false; + } + + if (as_string(backup_binary) != "") + return restore_sing_box_backup(backup_binary); + return restore_sing_box_package_variant(previous_variant); +} + +function restore_sing_box_after_failed_extended_install(previous_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched) { + if (as_string(archive_file) != "") + remove_file(archive_file); + let restore_status = true; + if (cronet_touched) + restore_file_backup("/usr/lib/libcronet.so", backup_cronet); + if (!restore_sing_box_install_backup(previous_variant, backup_binary)) + restore_status = false; + restore_sing_box_variant_state(previous_marker, previous_version_state); + restore_sing_box_service_from_marker(previous_marker); + clear_version_caches(); + return restore_status; +} + +function restore_sing_box_after_failed_extended_package_install(previous_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched) { + if (as_string(package_file) != "") + remove_file(package_file); + pkg_remove_sing_box_conflict("sing-box-extended"); + let restore_status = restore_sing_box_install_backup(previous_variant, backup_binary); + if (cronet_touched) { + restore_file_backup("/usr/lib/libcronet.so", backup_cronet); + if (file_nonempty("/usr/lib/libcronet.so")) + command_success_from_args([ "chmod", "0644", "/usr/lib/libcronet.so" ]); + } + restore_sing_box_variant_state(previous_marker, previous_version_state); + restore_sing_box_service_from_marker(previous_marker); + clear_version_caches(); + return restore_status; +} + +function restore_sing_box_after_failed_package_install(target_package, previous_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, cronet_touched) { + pkg_remove_sing_box_conflict(target_package); + let restore_status = restore_sing_box_install_backup(previous_variant, backup_binary); + if (cronet_touched) { + if (!restore_file_backup("/usr/lib/libcronet.so", backup_cronet)) + restore_status = false; + if (file_nonempty("/usr/lib/libcronet.so") && !command_success_from_args([ "chmod", "0644", "/usr/lib/libcronet.so" ])) + restore_status = false; + } + restore_sing_box_variant_state(previous_marker, previous_version_state); + if (!restore_sing_box_service_from_marker(previous_marker)) + restore_status = false; + if (restore_status) { + remove_file(backup_binary); + remove_file(backup_cronet); + } + clear_version_caches(); + return restore_status; +} + +function fail_package_sing_box_install(action, tiny, reason, current_version, latest_version, + target_package, previous_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, cronet_touched) { + let restored = restore_sing_box_after_failed_package_install( + target_package, + previous_variant, + backup_binary, + backup_cronet, + previous_marker, + previous_version_state, + cronet_touched + ); + + let prefix = tiny ? "sing-box-tiny" : "Stable sing-box"; + if (restored) + action_fail("sing_box", action, prefix + " " + reason + "; previous sing-box variant was restored", current_version, latest_version); + action_fail("sing_box", action, prefix + " " + reason + " and previous sing-box variant could not be restored", current_version, latest_version); +} + +function install_sing_box_extended_package(action) { + init_tmp_dir() || action_fail("sing_box", action, "Failed to create temporary directory"); + let current_version = sing_box_runtime_output("version", []); + let current_variant = sing_box_runtime_output("variant", []); + let previous_marker = sing_box_runtime_output("read-variant-marker", []); + let previous_version_state = sing_box_runtime_output("read-version-state", []); + let release = resolve_sing_box_extended_release(false); + if (release == null) + action_fail("sing_box", action, "Failed to resolve sing-box-extended package release", current_version); + let latest_version = normalize_sing_box_version(release.tag); + + if (action == "check_update") { + if (!sing_box_runtime_success("is-extended", [ current_version ])) + action_fail("sing_box", action, "sing-box-extended is not installed", current_version, latest_version); + check_success("sing_box", normalize_sing_box_version(current_version), normalize_sing_box_version(latest_version), release.release_url); + } + + let package_file = tmp_dir + "/" + release.asset_name; + if (!download_with_retry(release.asset_url, package_file, release.asset_name)) + action_fail("sing_box", action, "Failed to download sing-box-extended package", current_version, latest_version); + + if (!run_logged("Updating package lists before sing-box-extended package installation", pkg_list_update_command())) + action_fail("sing_box", action, "Failed to update package lists", current_version, latest_version); + + stop_tachyon_before_sing_box_change(); + prepare_sing_box_package_service_install(); + + let backup_binary = ""; + let backup_cronet = ""; + let cronet_touched = false; + if (current_variant == "extended" || current_variant == "extended-compressed") { + if (file_exists("/usr/bin/sing-box")) { + backup_binary = "/usr/bin/sing-box.tachyon-backup." + owner_pid(); + if (!move_file_to_backup("/usr/bin/sing-box", backup_binary)) + action_fail("sing_box", action, "Failed to backup current sing-box binary", current_version, latest_version); + } + if (file_exists("/usr/lib/libcronet.so")) { + cronet_touched = true; + backup_cronet = "/usr/lib/libcronet.so.tachyon-backup." + owner_pid(); + if (!move_file_to_backup("/usr/lib/libcronet.so", backup_cronet)) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to backup current libcronet.so", current_version, latest_version); + } + } + } + + if (!run_logged_pkg_remove_sing_box_conflict("sing-box-tiny", "Removing sing-box-tiny before sing-box-extended package installation")) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to remove sing-box-tiny before sing-box-extended package installation", current_version, latest_version); + } + if (!run_logged_pkg_remove_sing_box_conflict("sing-box", "Removing sing-box before sing-box-extended package installation")) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to remove sing-box before sing-box-extended package installation", current_version, latest_version); + } + + if (backup_binary == "" && file_exists("/usr/bin/sing-box")) { + backup_binary = "/usr/bin/sing-box.tachyon-backup." + owner_pid(); + if (!move_file_to_backup("/usr/bin/sing-box", backup_binary)) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to backup existing sing-box binary", current_version, latest_version); + } + } + if (!cronet_touched && file_exists("/usr/lib/libcronet.so")) { + cronet_touched = true; + backup_cronet = "/usr/lib/libcronet.so.tachyon-backup." + owner_pid(); + if (!move_file_to_backup("/usr/lib/libcronet.so", backup_cronet)) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to backup current libcronet.so", current_version, latest_version); + } + } + + if (!run_logged("Installing sing-box-extended package " + release.asset_name, pkg_install_files_command([ package_file ]))) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to install sing-box-extended package", current_version, latest_version); + } + remove_file(package_file); + + let new_version = validate_sing_box_extended_binary("/usr/bin/sing-box", "/usr/lib"); + if (new_version == "") { + if (restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched)) + action_fail("sing_box", action, "Installed sing-box-extended package failed validation; previous sing-box variant was restored", current_version, latest_version); + action_fail("sing_box", action, "Installed sing-box-extended package failed validation and previous sing-box variant could not be restored", current_version, latest_version); + } + + write_sing_box_variant_state("extended", new_version); + restart_tachyon_after_successful_change(); + if (!wait_tachyon_running_after_sing_box_change()) { + updates_log("sing-box-extended package did not start cleanly; restoring previous sing-box variant", "error"); + if (file_exists(SERVICE_INIT)) + command_success_from_args([ SERVICE_INIT, "stop" ]); + if (restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched)) { + remove_file(backup_binary); + remove_file(backup_cronet); + action_fail("sing_box", action, "sing-box-extended package was installed but Tachyon did not start cleanly; previous sing-box variant was restored", current_version, latest_version); + } + action_fail("sing_box", action, "sing-box-extended package was installed but Tachyon did not start cleanly and previous sing-box variant could not be restored", current_version, latest_version); + } + + remove_file(backup_binary); + remove_file(backup_cronet); + clear_version_caches(); + updates_log("Installed sing-box-extended " + (new_version != "" ? new_version : "unknown") + " from package"); + action_success("sing_box", action, "sing-box-extended has been installed", new_version, latest_version, new_version == current_version ? 0 : 1, "latest", release.release_url); +} + +function install_sing_box_extended(action, compressed) { + if (!compressed) { + install_sing_box_extended_package(action); + return; + } + + init_tmp_dir() || action_fail("sing_box", action, "Failed to create temporary directory"); + let label = "sing-box-extended compressed"; + let current_version = sing_box_runtime_output("version", []); + let current_variant = sing_box_runtime_output("variant", []); + let previous_marker = sing_box_runtime_output("read-variant-marker", []); + let previous_version_state = sing_box_runtime_output("read-version-state", []); + let release = resolve_sing_box_extended_release(true); + if (release == null) + action_fail("sing_box", action, "Failed to resolve " + label + " release", current_version); + let latest_version = normalize_sing_box_version(release.tag); + + if (action == "check_update") { + if (!sing_box_runtime_success("is-extended", [ current_version ])) + action_fail("sing_box", action, "sing-box-extended is not installed", current_version, latest_version); + if (!sing_box_runtime_success("marker-is", [ "extended-compressed" ])) + action_fail("sing_box", action, "sing-box-extended compressed is not installed", current_version, latest_version); + check_success("sing_box", normalize_sing_box_version(current_version), normalize_sing_box_version(latest_version), release.release_url); + } + + let archive_file = tmp_dir + "/" + release.asset_name; + if (!download_with_retry(release.asset_url, archive_file, release.asset_name)) + action_fail("sing_box", action, "Failed to download " + label, current_version, latest_version); + + let binary_path = select_archive_member_path(archive_file, "sing-box"); + if (binary_path == "") { + remove_file(archive_file); + action_fail("sing_box", action, "sing-box binary was not found in the downloaded archive", current_version, latest_version); + } + let cronet_path = select_archive_member_path(archive_file, "libcronet.so"); + let extract_error = tmp_dir + "/sing-box-extract.err"; + let tmp_binary = tmp_dir + "/sing-box.compressed." + owner_pid(); + let tmp_cronet = ""; + if (!command_success(command_from_args([ "tar", "-xzf", archive_file, "-O", binary_path ]) + " >" + shell_quote(tmp_binary) + " 2>" + shell_quote(extract_error)) || + !file_nonempty(tmp_binary) || + !command_success_from_args([ "chmod", "0755", tmp_binary ])) { + for (let line in split(read_file(extract_error), "\n")) + if (trim(as_string(line)) != "") + updates_log(line); + remove_file(tmp_binary); + remove_file(archive_file); + action_fail("sing_box", action, "Failed to extract " + label, current_version, latest_version); + } + + if (cronet_path != "") { + tmp_cronet = tmp_dir + "/libcronet.so"; + if (!command_success(command_from_args([ "tar", "-xzf", archive_file, "-O", cronet_path ]) + " >" + shell_quote(tmp_cronet) + " 2>" + shell_quote(extract_error)) || + !file_nonempty(tmp_cronet) || + !command_success_from_args([ "chmod", "0644", tmp_cronet ])) { + for (let line in split(read_file(extract_error), "\n")) + if (trim(as_string(line)) != "") + updates_log(line); + remove_file(tmp_binary); + remove_file(tmp_cronet); + remove_file(archive_file); + action_fail("sing_box", action, "Failed to extract libcronet.so from sing-box-extended archive", current_version, latest_version); + } + } + + remove_file(archive_file); + stop_tachyon_before_sing_box_change(); + let new_version = validate_sing_box_extended_binary(tmp_binary, tmp_dir); + if (new_version == "") { + remove_file(tmp_binary); + remove_file(tmp_cronet); + action_fail("sing_box", action, "Downloaded " + label + " failed validation", current_version, latest_version); + } + + let backup_binary = ""; + let backup_cronet = ""; + let cronet_touched = false; + if (file_exists("/usr/bin/sing-box")) { + backup_binary = "/usr/bin/sing-box.tachyon-backup." + owner_pid(); + if (!move_file_to_backup("/usr/bin/sing-box", backup_binary)) { + remove_file(backup_binary); + remove_file(tmp_binary); + remove_file(tmp_cronet); + remove_file(archive_file); + action_fail("sing_box", action, "Failed to backup current sing-box binary", current_version, latest_version); + } + } + if (cronet_path != "") { + cronet_touched = true; + if (file_exists("/usr/lib/libcronet.so")) { + backup_cronet = "/usr/lib/libcronet.so.tachyon-backup." + owner_pid(); + if (!move_file_to_backup("/usr/lib/libcronet.so", backup_cronet)) { + restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched); + remove_file(tmp_binary); + remove_file(tmp_cronet); + action_fail("sing_box", action, "Failed to backup current libcronet.so", current_version, latest_version); + } + } + } + + for (let item in [ + [ "sing-box-extended", "Removing sing-box-extended package before " + label + " installation" ], + [ "sing-box-tiny", "Removing sing-box-tiny package before " + label + " installation" ], + [ "sing-box", "Removing sing-box package before " + label + " installation" ] + ]) { + if (!run_logged_pkg_remove_sing_box_conflict(item[0], item[1])) { + restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched); + remove_file(tmp_binary); + remove_file(tmp_cronet); + action_fail("sing_box", action, "Failed to remove " + item[0] + " before " + label + " installation", current_version, latest_version); + } + } + + remove_managed_sing_box_service_script(); + if (!install_managed_sing_box_service_script()) { + restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched); + remove_file(tmp_binary); + remove_file(tmp_cronet); + action_fail("sing_box", action, "Failed to install managed sing-box service for " + label, current_version, latest_version); + } + + remove_file("/usr/bin/sing-box"); + if (!install_staged_file(tmp_binary, "/usr/bin/sing-box", "0755")) { + remove_file("/usr/bin/sing-box"); + restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched); + action_fail("sing_box", action, "Failed to install " + label + " binary", current_version, latest_version); + } + if (tmp_cronet != "") { + remove_file("/usr/lib/libcronet.so"); + if (!install_staged_file(tmp_cronet, "/usr/lib/libcronet.so", "0644")) { + remove_file("/usr/lib/libcronet.so"); + restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched); + action_fail("sing_box", action, "Failed to install libcronet.so for " + label, current_version, latest_version); + } + } + remove_file(archive_file); + + new_version = validate_sing_box_extended_binary("/usr/bin/sing-box", "/usr/lib"); + if (new_version == "") { + if (restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched)) + action_fail("sing_box", action, "Installed " + label + " failed validation; previous sing-box variant was restored", current_version, latest_version); + action_fail("sing_box", action, "Installed " + label + " failed validation and previous sing-box variant could not be restored", current_version, latest_version); + } + + write_sing_box_variant_state("extended-compressed", new_version); + restart_tachyon_after_successful_change(); + if (!wait_tachyon_running_after_sing_box_change()) { + updates_log(label + " did not start cleanly; restoring previous sing-box binary", "error"); + if (file_exists(SERVICE_INIT)) + command_success_from_args([ SERVICE_INIT, "stop" ]); + if (restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched)) { + remove_file(backup_binary); + remove_file(backup_cronet); + action_fail("sing_box", action, label + " was installed but Tachyon did not start cleanly; previous sing-box variant was restored", current_version, latest_version); + } + action_fail("sing_box", action, label + " was installed but Tachyon did not start cleanly and previous sing-box variant could not be restored", current_version, latest_version); + } + + remove_file(backup_binary); + remove_file(backup_cronet); + clear_version_caches(); + updates_log("Installed " + label + " " + (new_version != "" ? new_version : "unknown")); + action_success("sing_box", action, label + " has been installed", new_version, latest_version, 1, "latest", release.release_url); +} + +function install_package_sing_box(action, tiny) { + let package_name = tiny ? "sing-box-tiny" : "sing-box"; + let conflict = tiny ? "sing-box" : "sing-box-tiny"; + let label = tiny ? "tiny sing-box" : "stable sing-box"; + let package_version = installed_package_version(package_name); + let binary_version = sing_box_runtime_output("version", []); + let current_version = package_version; + if (sing_box_runtime_success("is-extended", [ binary_version ])) + current_version = binary_version; + if (current_version == "") + current_version = binary_version; + let latest_version = available_package_version(package_name); + if (latest_version == "") + latest_version = installed_package_version(package_name); + + if (action == "check_update") { + if (latest_version == "") + action_fail("sing_box", action, "Failed to resolve " + (tiny ? "tiny" : "stable") + " sing-box package version", current_version); + if (tiny && !sing_box_runtime_success("is-tiny", [ binary_version ])) + action_fail("sing_box", action, "sing-box-tiny is not installed", current_version, latest_version); + check_success("sing_box", current_version, latest_version, ""); + } + + if (!run_logged("Updating package lists before " + package_name + " installation", pkg_list_update_command())) + action_fail("sing_box", action, "Failed to update package lists", current_version, latest_version); + latest_version = available_package_version(package_name); + if (latest_version == "") + latest_version = installed_package_version(package_name); + if (latest_version == "") + action_fail("sing_box", action, "Failed to resolve " + (tiny ? "tiny" : "stable") + " sing-box package version", current_version); + + let previous_variant = sing_box_runtime_output("variant", []); + let previous_marker = sing_box_runtime_output("read-variant-marker", []); + let previous_version_state = sing_box_runtime_output("read-version-state", []); + stop_tachyon_before_sing_box_change(); + + let backup_binary = ""; + let backup_cronet = ""; + let cronet_touched = false; + let backup_on_tmpfs = previous_variant == "extended-compressed"; + if (file_exists("/usr/bin/sing-box")) { + backup_binary = backup_on_tmpfs ? tmp_dir + "/sing-box.tachyon-backup" : + "/usr/bin/sing-box.tachyon-backup." + owner_pid(); + if (!move_file_to_backup("/usr/bin/sing-box", backup_binary)) + action_fail("sing_box", action, "Failed to backup current sing-box binary", current_version, latest_version); + } + if (file_exists("/usr/lib/libcronet.so")) { + cronet_touched = true; + backup_cronet = backup_on_tmpfs ? tmp_dir + "/libcronet.so.tachyon-backup" : + "/usr/lib/libcronet.so.tachyon-backup." + owner_pid(); + if (!move_file_to_backup("/usr/lib/libcronet.so", backup_cronet)) { + restore_sing_box_backup(backup_binary); + action_fail("sing_box", action, "Failed to backup current libcronet.so", current_version, latest_version); + } + } + + if (!run_logged("Installing " + label + " package", "sh -c " + shell_quote("exit 0")) || + !replace_sing_box_package_variant(package_name, conflict, latest_version)) + fail_package_sing_box_install(action, tiny, "package installation failed", current_version, latest_version, + package_name, previous_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, cronet_touched); + + let new_version = read_sing_box_binary_version("/usr/bin/sing-box", ""); + if (new_version == "") + fail_package_sing_box_install(action, tiny, "package was installed, but sing-box binary is not available", current_version, latest_version, + package_name, previous_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, cronet_touched); + if (sing_box_runtime_success("is-extended", [ new_version ])) + fail_package_sing_box_install(action, tiny, "package was installed, but the active binary is still sing-box-extended", new_version, latest_version, + package_name, previous_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, cronet_touched); + write_sing_box_variant_state(tiny ? "tiny" : "stable", new_version); + restart_tachyon_after_successful_change(); + if (!wait_tachyon_running_after_sing_box_change()) + fail_package_sing_box_install(action, tiny, "was installed, but Tachyon did not start cleanly", new_version, latest_version, + package_name, previous_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, cronet_touched); + remove_file(backup_binary); + remove_file(backup_cronet); + clear_version_caches(); + action_success("sing_box", action, label + " has been installed", new_version, latest_version, new_version == current_version ? 0 : 1, "latest"); +} + +function check_tachyon() { + let metadata = fetch_tachyon_latest_release_metadata(); + let fields = split(metadata, "\t"); + let latest_version = length(fields) > 0 && as_string(fields[0]) != "" ? as_string(fields[0]) : "unknown"; + let release_url = length(fields) > 1 ? as_string(fields[1]) : ""; + if (latest_version == "unknown") + action_fail("tachyon", "check_update", "Failed to check Tachyon updates", TACHYON_VERSION, latest_version); + + write_tachyon_latest_version_cache(latest_version, now_seconds()); + if (!helper_success("tachyon-release-version-valid", [ TACHYON_VERSION ])) { + updates_log("Tachyon current version is not a release version (" + TACHYON_VERSION + ")"); + action_success("tachyon", "check_update", "Installed version is newer than release", TACHYON_VERSION, latest_version, 0, "dev", release_url); + } + + let compare = trim(helper_output("tachyon-release-version-compare", [ TACHYON_VERSION, latest_version ])); + if (compare == "") + action_fail("tachyon", "check_update", "Failed to compare Tachyon versions", TACHYON_VERSION, latest_version); + let status = status_from_compare(int(compare)); + if (status == "") + action_fail("tachyon", "check_update", "Failed to compare Tachyon versions", TACHYON_VERSION, latest_version); + if (status == "latest") { + updates_log("Tachyon is already up to date (" + TACHYON_VERSION + ")"); + action_success("tachyon", "check_update", "Latest version is installed", TACHYON_VERSION, latest_version, 0, status, release_url); + } + if (status == "outdated") { + updates_log("Tachyon update found: " + TACHYON_VERSION + " -> " + latest_version); + action_success("tachyon", "check_update", "Update is available", TACHYON_VERSION, latest_version, 0, status, release_url); + } + updates_log("Tachyon installed version is newer than upstream release: " + TACHYON_VERSION + " -> " + latest_version); + action_success("tachyon", "check_update", "Installed version is newer than release", TACHYON_VERSION, latest_version, 0, status, release_url); +} + +function resolve_tachyon_release(latest_version) { + let release_json = latest_tachyon_release_json(); + if (release_json == "") + return null; + let asset_ext = is_apk() ? "apk" : "ipk"; + let i18n_required = pkg_is_installed("luci-i18n-tachyon-ru") ? "1" : "0"; + let plan = trim(helper_output_input(release_json, "tachyon-release-plan", [ latest_version, asset_ext, i18n_required ])); + let fields = split(plan, "\t"); + if (length(fields) < 7 || as_string(fields[1]) == "" || as_string(fields[2]) == "" || as_string(fields[3]) == "" || as_string(fields[4]) == "") + return null; + return { + release_url: fields[0], + backend_name: fields[1], + backend_url: fields[2], + app_name: fields[3], + app_url: fields[4], + i18n_name: fields[5], + i18n_url: fields[6] + }; +} + +function install_tachyon() { + let latest_version = latest_tachyon_version(); + if (latest_version == "") + latest_version = "unknown"; + if (latest_version == "unknown") + action_fail("tachyon", "install", "Failed to resolve Tachyon release", TACHYON_VERSION, latest_version); + + write_tachyon_latest_version_cache(latest_version, now_seconds()); + init_tmp_dir() || action_fail("tachyon", "install", "Failed to create temporary directory", TACHYON_VERSION, latest_version); + updates_log("Resolving Tachyon release " + latest_version + " packages"); + let release = resolve_tachyon_release(latest_version); + if (release == null) + action_fail("tachyon", "install", "Failed to resolve Tachyon release packages", TACHYON_VERSION, latest_version); + + let backend_file = tmp_dir + "/" + release.backend_name; + let app_file = tmp_dir + "/" + release.app_name; + let i18n_file = release.i18n_url != "" ? tmp_dir + "/" + release.i18n_name : ""; + if (!download_with_retry(release.backend_url, backend_file, release.backend_name) || + !download_with_retry(release.app_url, app_file, release.app_name) || + (release.i18n_url != "" && !download_with_retry(release.i18n_url, i18n_file, release.i18n_name))) + action_fail("tachyon", "install", "Failed to download Tachyon release packages", TACHYON_VERSION, latest_version); + + if (!run_logged("Installing LuCI app package " + release.app_name, pkg_install_files_command([ app_file ]))) + action_fail("tachyon", "install", "Failed to install LuCI app package", TACHYON_VERSION, latest_version); + if (i18n_file != "" && !run_logged("Installing LuCI Russian i18n package " + release.i18n_name, pkg_install_files_command([ i18n_file ]))) + action_fail("tachyon", "install", "Failed to install LuCI Russian i18n package", TACHYON_VERSION, latest_version); + if (!run_logged("Installing Tachyon package " + release.backend_name, pkg_install_files_command([ backend_file ]))) + action_fail("tachyon", "install", "Failed to install Tachyon package", TACHYON_VERSION, latest_version); + + remove_file("/var/luci-indexcache"); + command_success("rm -f /var/luci-indexcache* /tmp/luci-indexcache* 2>/dev/null"); + command_success("rm -rf /tmp/luci-modulecache/ 2>/dev/null"); + if (file_exists("/etc/init.d/rpcd") && !command_success_from_args([ "/etc/init.d/rpcd", "reload" ])) + command_success_from_args([ "/etc/init.d/rpcd", "restart" ]); + command_success_from_args([ "killall", "-HUP", "rpcd" ]); + + restart_tachyon_after_successful_change(); + clear_version_caches(); + let new_version = installed_package_version("tachyon"); + if (new_version == "") + new_version = latest_version; + updates_log("Tachyon updated to " + new_version); + action_success("tachyon", "install", "Tachyon has been installed", new_version, latest_version, 1, "latest", release.release_url); +} + +function dispatch_sing_box(action) { + if (action == "install_extended") { + install_sing_box_extended(action, false); + return; + } + if (action == "install_extended_compressed") { + install_sing_box_extended(action, true); + return; + } + if (action == "install_tiny") { + install_package_sing_box(action, true); + return; + } + if (action == "install_stable") { + install_package_sing_box(action, false); + return; + } + + let variant = sing_box_runtime_output("variant", []); + if (variant == "extended-compressed") + install_sing_box_extended(action, true); + else if (variant == "extended") + install_sing_box_extended(action, false); + else if (variant == "tiny") + install_package_sing_box(action, true); + else + install_package_sing_box(action, false); +} + +function normalize_component_name(component) { + component = as_string(component); + if (component == "sing-box" || component == "singbox") + return "sing_box"; + if (component == "tachyon") + return "tachyon"; + return component; +} + +function component_action(component, action) { + component = normalize_component_name(component); + action = as_string(action); + if (!acquire_component_lock()) + action_fail(component != "" ? component : "unknown", action != "" ? action : "unknown", "Another component action is already running"); + if (!init_tmp_dir()) + action_fail(component != "" ? component : "unknown", action != "" ? action : "unknown", "Failed to create temporary directory"); + capture_tachyon_running_state(); + + if (component == "tachyon" && action == "check_update") + check_tachyon(); + else if (component == "tachyon" && action == "install") + install_tachyon(); + else if (component == "sing_box" && (action == "check_update" || action == "install" || + action == "install_extended" || action == "install_extended_compressed" || + action == "install_tiny" || action == "install_stable")) + dispatch_sing_box(action); + else if (component == "zapret" && (action == "check_update" || action == "install")) + install_zapret(action); + else if (component == "zapret" && action == "remove") + remove_optional_component("zapret", "zapret", "zapret", LIB_DIR + "/providers/zapret/runtime.uc"); + else if (component == "zapret2" && (action == "check_update" || action == "install")) + install_zapret2(action); + else if (component == "zapret2" && action == "remove") + remove_optional_component("zapret2", "zapret2", "zapret2", LIB_DIR + "/providers/zapret2/runtime.uc"); + else if (component == "byedpi" && (action == "check_update" || action == "install")) + install_byedpi(action); + else if (component == "byedpi" && action == "remove") + remove_optional_component("byedpi", "byedpi", "ByeDPI", LIB_DIR + "/providers/byedpi/runtime.uc"); + else + action_fail(component != "" ? component : "unknown", action != "" ? action : "unknown", "Unknown component action"); +} + +let mode = ARGV[0] || ""; + +if (mode == "component-action") + component_action(ARGV[1], ARGV[2]); +else if (mode == "latest-tachyon-release-json") + print(latest_tachyon_release_json()); +else if (mode == "latest-tachyon-version") + print(latest_tachyon_version(), "\n"); +else if (mode == "tachyon-release-metadata") + print(fetch_tachyon_latest_release_metadata(), "\n"); +else { + warn("Usage: components/action.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/components/updater.uc b/tachyon/files/usr/lib/components/updater.uc new file mode 100644 index 00000000..14810318 --- /dev/null +++ b/tachyon/files/usr/lib/components/updater.uc @@ -0,0 +1,1261 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_stdin_json() { + let data = read_stdin(); + try { + return json(data); + } + catch (e) { + return null; + } +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function stdin_contains_ci(needle) { + needle = lc(as_string(needle)); + if (needle == "") + return false; + return index(lc(read_stdin()), needle) >= 0; +} + +function stdin_first_line_last_field() { + let data = read_stdin(); + let newline = index(data, "\n"); + let line = trim(newline >= 0 ? substr(data, 0, newline) : data); + let fields = split(line, /[ \t\r\n]+/); + + if (length(fields) > 0) + print(as_string(fields[length(fields) - 1]), "\n"); +} + +function file_first_line(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let newline = index(data, "\n"); + print(newline >= 0 ? substr(data, 0, newline) : data, "\n"); +} + +function json_file_field(path, key, fallback) { + let value = read_json_file(path); + if (type(value) == "object" && value[key] != null) + print(as_string(value[key]), "\n"); + else + print(as_string(fallback), "\n"); +} + +function object_get_default(key, fallback) { + let value = read_stdin_json(); + if (type(value) == "object" && value[key] != null) + print(as_string(value[key]), "\n"); + else + print(as_string(fallback), "\n"); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function str_contains(haystack, needle) { + return index(as_string(haystack), as_string(needle)) >= 0; +} + +function str_startswith(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function str_endswith(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix; +} + +function str_remove_suffix(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return str_endswith(value, suffix) ? substr(value, 0, length(value) - length(suffix)) : value; +} + +function string_remove_suffix(value, suffix) { + print(str_remove_suffix(value, suffix), "\n"); +} + +function path_basename(value) { + let parts = split(as_string(value), "/"); + return length(parts) > 0 ? as_string(parts[length(parts) - 1]) : ""; +} + +function arg_bool(value) { + return value === true || value == "true" || value == "1" || value == 1; +} + +function arg_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9-]/)) + return 0; + return int(value); +} + +function file_json_valid(path) { + return read_json_file(path) != null; +} + +function github_response_ok() { + let response = read_stdin_json(); + if (response == null) + return false; + + if (type(response) == "object") { + let message = as_string(response.message || ""); + if (match(message, /API rate limit/) || match(message, /rate limit exceeded/) || message == "Not Found") + return false; + } + + return true; +} + +function release_by_tag(tag) { + for (let release in array_or_empty(read_stdin_json())) { + if (type(release) != "object") + continue; + if (release.draft === true || release.prerelease === true) + continue; + if (as_string(release.tag_name || "") == tag) { + write_json(release); + return; + } + } +} + +function release_asset_name(prefix, ext) { + let release = object_or_empty(read_stdin_json()); + for (let asset in array_or_empty(release.assets)) { + if (type(asset) != "object") + continue; + let name = as_string(asset.name || ""); + if ((str_startswith(name, prefix + "_") || str_startswith(name, prefix + "-")) && + str_endswith(name, "." + ext)) { + print(name, "\n"); + return; + } + } +} + +function release_asset_url(name) { + let release = object_or_empty(read_stdin_json()); + for (let asset in array_or_empty(release.assets)) { + if (type(asset) == "object" && as_string(asset.name || "") == name) { + print(as_string(asset.browser_download_url || ""), "\n"); + return; + } + } +} + +function release_asset_name_by_suffix(suffix) { + let release = object_or_empty(read_stdin_json()); + for (let asset in array_or_empty(release.assets)) { + let name = type(asset) == "object" ? as_string(asset.name || "") : ""; + if (str_endswith(name, suffix)) { + print(name, "\n"); + return; + } + } +} + +function release_asset_url_by_suffix_from_release(release, suffix) { + suffix = as_string(suffix); + for (let asset in array_or_empty(release.assets)) { + if (type(asset) != "object") + continue; + let name = as_string(asset.name || ""); + if (str_endswith(name, suffix)) + return as_string(asset.browser_download_url || ""); + } + + return ""; +} + +function release_asset_url_by_suffix(suffix) { + let release = object_or_empty(read_stdin_json()); + let url = release_asset_url_by_suffix_from_release(release, suffix); + if (url != "") + print(url, "\n"); +} + +function release_asset_pair(release, expected_name) { + for (let asset in array_or_empty(release.assets)) { + if (type(asset) != "object") + continue; + + let name = as_string(asset.name || ""); + let url = as_string(asset.browser_download_url || ""); + if (name == as_string(expected_name) && url != "") + return { name, url }; + } + + return null; +} + +function tachyon_release_plan(latest_version, asset_ext, i18n_required) { + let release = object_or_empty(read_stdin_json()); + + if (as_string(release.tag_name || "") != as_string(latest_version)) + exit(1); + + if (match(as_string(latest_version), /^[0-9]+[.][0-9]+[.][0-9]+$/) == null) + exit(1); + + let backend = release_asset_pair(release, "tachyon_" + latest_version + "." + asset_ext); + let app = release_asset_pair(release, "luci-app-tachyon_" + latest_version + "." + asset_ext); + if (backend == null || app == null) + exit(1); + + let i18n = { name: "", url: "" }; + if (arg_bool(i18n_required)) { + i18n = release_asset_pair(release, "luci-i18n-tachyon-ru_" + latest_version + "." + asset_ext); + if (i18n == null) + exit(1); + } + + print(as_string(release.html_url || ""), "\t", + backend.name, "\t", backend.url, "\t", + app.name, "\t", app.url, "\t", + i18n.name, "\t", i18n.url, "\n"); +} + +function release_metadata_tsv() { + let release = object_or_empty(read_stdin_json()); + let tag = as_string(release.tag_name || ""); + + if (tag == "") + return; + + print(tag, "\t", as_string(release.html_url || ""), "\n"); +} + +function openwrt_release_value(path, key) { + let data = fs.readfile(path); + if (data == null) + return; + + let prefix = as_string(key) + "='"; + for (let line in split(as_string(data), "\n")) { + if (!str_startswith(line, prefix)) + continue; + + let rest = substr(line, length(prefix)); + let quote = rindex(rest, "'"); + if (quote >= 0) + print(substr(rest, 0, quote), "\n"); + return; + } +} + +function openwrt_release_series(path) { + let data = fs.readfile(path); + if (data == null) + return; + + let prefix = "DISTRIB_RELEASE='"; + for (let line in split(as_string(data), "\n")) { + if (!str_startswith(line, prefix)) + continue; + + let value = substr(line, length(prefix)); + let quote = rindex(value, "'"); + if (quote >= 0) + value = substr(value, 0, quote); + + let matched = match(value, /^([0-9]+\.[0-9]+)/); + if (matched != null) + print(matched[1], "\n"); + return; + } +} + +function updates_arch_package_version(package_name, package_arch) { + let version = str_remove_suffix(str_remove_suffix(package_name, ".ipk"), ".apk"); + let prefixes = ["zapret2_", "zapret2-", "zapret_", "zapret-", "byedpi_", "byedpi-"]; + + for (let prefix in prefixes) { + if (str_startswith(version, prefix)) { + version = substr(version, length(prefix)); + break; + } + } + + package_arch = as_string(package_arch); + if (package_arch != "") { + version = str_remove_suffix(version, "_" + package_arch); + version = str_remove_suffix(version, "-" + package_arch); + } + + print(version, "\n"); +} + +function updates_bundle_version(bundle_name, prefixes) { + let name = path_basename(bundle_name); + + for (let prefix in prefixes) { + if (!str_startswith(name, prefix)) + continue; + + let rest = substr(name, length(prefix)); + let separator = index(rest, "_"); + if (separator <= 0) + continue; + + let version = substr(rest, 0, separator); + if (str_startswith(version, "v")) + version = substr(version, 1); + + print(version, "\n"); + return; + } + + print("\n"); +} + +function updates_zapret_bundle_version(bundle_name) { + updates_bundle_version(bundle_name, ["zapret_v", "zapret_"]); +} + +function updates_zapret2_bundle_version(bundle_name) { + updates_bundle_version(bundle_name, ["zapret2_v", "zapret2_", "zapret_v", "zapret_"]); +} + +function first_version_token(value) { + value = as_string(value); + for (let i = 0; i < length(value); i++) { + let chr = substr(value, i, 1); + if (chr == " " || chr == "\t" || chr == "\r" || chr == "\n") + return substr(value, 0, i); + } + + return value; +} + +function strip_plus_metadata(value) { + value = as_string(value); + let plus = index(value, "+"); + return plus >= 0 ? substr(value, 0, plus) : value; +} + +function strip_revision_suffix(value) { + let matched = match(as_string(value), /^(.*)-r[0-9]+$/); + return matched ? matched[1] : as_string(value); +} + +function updates_normalize_sing_box_version(value) { + value = as_string(value); + if (str_startswith(value, "v")) + value = substr(value, 1); + + print(first_version_token(strip_plus_metadata(value)), "\n"); +} + +function updates_normalize_zapret_version(value) { + value = as_string(value); + if (str_startswith(value, "v")) + value = substr(value, 1); + + value = strip_revision_suffix(value); + value = strip_plus_metadata(value); + print(first_version_token(value), "\n"); +} + +function tachyon_normalized_release_version(value) { + return as_string(value); +} + +function tachyon_release_version_valid(value) { + value = tachyon_normalized_release_version(value); + return match(value, /^[0-9]+[.][0-9]+[.][0-9]+$/) != null; +} + +function tachyon_release_version_parts(value) { + let version = tachyon_normalized_release_version(value); + if (!tachyon_release_version_valid(version)) + return null; + + let parts = split(version, "."); + return [ int(parts[0]), int(parts[1]), int(parts[2]) ]; +} + +function tachyon_release_version_compare(lhs, rhs) { + let lhs_parts = tachyon_release_version_parts(lhs); + let rhs_parts = tachyon_release_version_parts(rhs); + if (lhs_parts == null || rhs_parts == null) + return false; + + for (let i = 0; i < length(lhs_parts); i++) { + if (lhs_parts[i] < rhs_parts[i]) { + print("-1\n"); + return true; + } + if (lhs_parts[i] > rhs_parts[i]) { + print("1\n"); + return true; + } + } + + print("0\n"); + return true; +} + +function fourth_whitespace_field(line) { + let fields = split(trim(as_string(line)), /[ \t]+/); + return length(fields) >= 4 ? as_string(fields[3]) : ""; +} + +function updates_zip_inner_package_path(component, arch, ext) { + component = as_string(component); + arch = as_string(arch); + ext = as_string(ext); + + let fallback = ""; + for (let line in split(read_stdin(), "\n")) { + let path = fourth_whitespace_field(line); + if (path == "") + continue; + + if (ext == "apk") { + if (str_startswith(path, "apk/" + component + "-") && str_endswith(path, ".apk")) { + print(path, "\n"); + return; + } + continue; + } + + if (ext == "ipk" && str_startswith(path, component + "_") && str_endswith(path, ".ipk")) { + if (arch != "" && str_endswith(path, "_" + arch + ".ipk")) { + print(path, "\n"); + return; + } + if (fallback == "") + fallback = path; + } + } + + if (fallback != "") + print(fallback, "\n"); +} + +function updates_archive_member_path(member_name) { + member_name = as_string(member_name); + + for (let line in split(read_stdin(), "\n")) { + let path = trim(as_string(line)); + if (path != "" && path_basename(path) == member_name) { + print(path, "\n"); + return; + } + } +} + +function updates_opkg_arch_list() { + let arches = []; + + for (let line in split(read_stdin(), "\n")) { + let fields = split(trim(as_string(line)), /[ \t]+/); + if (length(fields) < 2 || as_string(fields[0]) != "arch") + continue; + + let name = as_string(fields[1]); + if (name == "all" || name == "noarch") + continue; + + push(arches, { + name, + priority: int(length(fields) >= 3 ? as_string(fields[2]) : "") + }); + } + + for (let i = 1; i < length(arches); i++) { + let item = arches[i]; + let j = i - 1; + while (j >= 0 && (arches[j].priority < item.priority || + (arches[j].priority == item.priority && arches[j].name > item.name))) { + arches[j + 1] = arches[j]; + j--; + } + arches[j + 1] = item; + } + + for (let arch in arches) + print(arch.name, "\n"); +} + +function append_unique(values, value) { + value = as_string(value); + if (value == "" || value == "all" || value == "noarch") + return; + + for (let existing in values) + if (existing == value) + return; + + push(values, value); +} + +function whitespace_fields(value) { + value = as_string(value); + let result = []; + let start = -1; + + for (let i = 0; i < length(value); i++) { + let chr = substr(value, i, 1); + let whitespace = chr == " " || chr == "\t" || chr == "\r" || chr == "\n"; + + if (whitespace) { + if (start >= 0) { + push(result, substr(value, start, i - start)); + start = -1; + } + } + else if (start < 0) { + start = i; + } + } + + if (start >= 0) + push(result, substr(value, start)); + + return result; +} + +function string_has_whitespace_field(value) { + return length(whitespace_fields(value)) > 0; +} + +function file_whitespace_list(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + print(join(" ", whitespace_fields(data)), "\n"); +} + +function arch_candidate_variants(candidate) { + let result = []; + candidate = as_string(candidate); + if (candidate == "") + return result; + + append_unique(result, candidate); + + let plus = index(candidate, "+"); + if (plus >= 0) + append_unique(result, substr(candidate, 0, plus)); + + let suffixes = [ + "_musl", "_uclibc", "_glibc", + "-musl", "-uclibc", "-glibc", + ".musl", ".uclibc", ".glibc" + ]; + + for (let suffix in suffixes) + if (str_endswith(candidate, suffix)) + append_unique(result, substr(candidate, 0, length(candidate) - length(suffix))); + + return result; +} + +function arch_candidate_bases(arch) { + let result = []; + arch = as_string(arch); + push(result, arch); + + if (str_startswith(arch, "aarch64_")) + push(result, "aarch64_generic"); + else if (str_startswith(arch, "riscv64_")) + push(result, "riscv64_generic"); + else if (arch == "arm_cortex-a7_neon-vfpv4") { + push(result, "arm_cortex-a7_vfpv4"); + push(result, "arm_cortex-a7"); + } + else if (str_startswith(arch, "arm_cortex-a7_")) + push(result, "arm_cortex-a7"); + else if (str_startswith(arch, "arm_cortex-a9_")) + push(result, "arm_cortex-a9"); + else if (arch == "mipsel_24kc_24kf") + push(result, "mipsel_24kc"); + + return result; +} + +function updates_arch_candidates(arch_list) { + let target = ""; + let candidates = []; + + for (let arch in whitespace_fields(arch_list)) { + arch = as_string(arch); + if (arch == "" || arch == "all" || arch == "noarch") + continue; + + if (target == "") + target = arch; + for (let base in arch_candidate_bases(arch)) + for (let variant in arch_candidate_variants(base)) + append_unique(candidates, variant); + } + + if (target == "") + exit(1); + + print(target, "\t", join(" ", candidates), "\n"); +} + +function sing_box_extended_arch_suffix(host_arch, distrib_arch) { + host_arch = as_string(host_arch); + distrib_arch = as_string(distrib_arch); + + if (str_contains(distrib_arch, "mipsel") || str_contains(distrib_arch, "mipsle")) + host_arch = "mipsel"; + else if (str_contains(distrib_arch, "mips64el") || str_contains(distrib_arch, "mips64le")) + host_arch = "mips64el"; + + if (host_arch == "aarch64") + print("arm64\n"); + else if (str_startswith(host_arch, "armv7")) + print("armv7\n"); + else if (str_startswith(host_arch, "armv6")) + print("armv6\n"); + else if (host_arch == "x86_64") + print("amd64\n"); + else if (host_arch == "i386" || host_arch == "i686") + print("386\n"); + else if (host_arch == "mips") + print("mips-softfloat\n"); + else if (host_arch == "mipsel" || host_arch == "mipsle") + print("mipsle-softfloat\n"); + else if (host_arch == "mips64") + print("mips64\n"); + else if (host_arch == "mips64el" || host_arch == "mips64le") + print("mips64le\n"); + else if (host_arch == "riscv64") + print("riscv64\n"); + else if (host_arch == "s390x") + print("s390x\n"); + else + exit(1); +} + +function sing_box_extended_asset_url(arch_suffix, _unused, compressed) { + let release = object_or_empty(read_stdin_json()); + + if (as_string(compressed) != "1") + exit(1); + + arch_suffix = as_string(arch_suffix); + if (arch_suffix == "") + exit(1); + + let url = release_asset_url_by_suffix_from_release(release, "linux-" + arch_suffix + "-compressed.tar.gz"); + if (url != "") { + print(url, "\n"); + return; + } + + exit(1); +} + +function sing_box_extended_package_asset_url(distrib_arch, asset_ext) { + let release = object_or_empty(read_stdin_json()); + let suffix; + + distrib_arch = as_string(distrib_arch); + asset_ext = as_string(asset_ext); + if (distrib_arch == "" || asset_ext == "") + exit(1); + + suffix = "_openwrt_" + distrib_arch + "." + asset_ext; + for (let asset in array_or_empty(release.assets)) { + if (type(asset) != "object") + continue; + + let name = as_string(asset.name || ""); + let url = as_string(asset.browser_download_url || ""); + if (url != "" && str_startswith(name, "sing-box-extended_") && str_endswith(name, suffix)) { + print(url, "\n"); + return; + } + } + + exit(1); +} + +function updates_opkg_package_installed(package_name) { + package_name = as_string(package_name); + + for (let line in split(read_stdin(), "\n")) { + let fields = split(trim(as_string(line)), /[ \t]+/); + if (length(fields) >= 1 && as_string(fields[0]) == package_name) + exit(0); + } + + exit(1); +} + +function updates_opkg_package_version(package_name) { + package_name = as_string(package_name); + + for (let line in split(read_stdin(), "\n")) { + let fields = split(trim(as_string(line)), /[ \t]+/); + if (length(fields) >= 3 && as_string(fields[0]) == package_name && as_string(fields[1]) == "-") { + print(as_string(fields[2]), "\n"); + return; + } + } +} + +function updates_apk_manifest_package_version(package_name) { + package_name = as_string(package_name); + + for (let line in split(read_stdin(), "\n")) { + let fields = split(trim(as_string(line)), /[ \t]+/); + if (length(fields) >= 2 && as_string(fields[0]) == package_name) { + print(as_string(fields[1]), "\n"); + return; + } + } +} + +function updates_apk_info_package_version(package_name) { + package_name = as_string(package_name); + let prefix = package_name + "-"; + let first = split(read_stdin(), "\n")[0]; + first = as_string(first); + + if (!str_startswith(first, prefix)) + return; + + let version = substr(first, length(prefix)); + version = replace(version, /[ \t].*$/, ""); + if (version != "") + print(version, "\n"); +} + +function updates_apk_policy_version() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (match(line, /^ [^ \t][^ \t]*:/) == null) + continue; + + let fields = split(trim(line), /[ \t]+/); + if (length(fields) == 0) + return; + + print(str_remove_suffix(as_string(fields[0]), ":"), "\n"); + return; + } +} + +function byedpi_asset_matches(name, arch, ext) { + return (str_startswith(name, "byedpi_") || str_startswith(name, "byedpi-")) && + str_endswith(name, "." + ext) && + (str_contains(name, "_" + arch + "." + ext) || str_contains(name, "-" + arch + "." + ext)); +} + +function release_asset_matches_arch(name, prefix, arch, ext) { + return (str_startswith(name, prefix + "_") || str_startswith(name, prefix + "-")) && + str_endswith(name, "." + ext) && + (str_contains(name, "_" + arch + "." + ext) || str_contains(name, "-" + arch + "." + ext)); +} + +function named_release_select_asset(release_prefix, asset_prefix, asset_ext, arch_candidates) { + let releases = array_or_empty(read_stdin_json()); + + for (let release in releases) { + if (type(release) != "object") + continue; + if (release.draft === true) + continue; + + let release_name = as_string(release.name || ""); + if (!str_startswith(release_name, release_prefix)) + continue; + + for (let arch in split(as_string(arch_candidates), " ")) { + if (arch == "") + continue; + + for (let asset in array_or_empty(release.assets)) { + if (type(asset) != "object") + continue; + + let name = as_string(asset.name || ""); + let url = as_string(asset.browser_download_url || ""); + if (url != "" && release_asset_matches_arch(name, asset_prefix, arch, asset_ext)) { + print(arch, "\t", name, "\t", url, "\t", + as_string(release.html_url || ""), "\t", as_string(release.tag_name || ""), "\n"); + return; + } + } + } + } +} + +function release_select_arch_suffix_asset(asset_ext, arch_candidates) { + let release = object_or_empty(read_stdin_json()); + + for (let arch in split(as_string(arch_candidates), " ")) { + if (arch == "") + continue; + + let suffix = "_" + arch + "." + as_string(asset_ext); + for (let asset in array_or_empty(release.assets)) { + if (type(asset) != "object") + continue; + + let name = as_string(asset.name || ""); + let url = as_string(asset.browser_download_url || ""); + if (url != "" && str_endswith(name, suffix)) { + print(arch, "\t", name, "\t", url, "\t", + as_string(release.html_url || ""), "\t", as_string(release.tag_name || ""), "\n"); + return; + } + } + } +} + +function select_byedpi_asset_from_release(release, asset_ext, arch_candidates) { + for (let arch in split(as_string(arch_candidates), " ")) { + if (arch == "") + continue; + for (let asset in array_or_empty(release.assets)) { + if (type(asset) != "object") + continue; + let name = as_string(asset.name || ""); + let url = as_string(asset.browser_download_url || ""); + if (url != "" && byedpi_asset_matches(name, arch, asset_ext)) { + print(arch, "\t", name, "\t", url, "\t", as_string(release.html_url || ""), "\n"); + return true; + } + } + } + + return false; +} + +function byedpi_select_asset(series, asset_ext, arch_candidates) { + let releases = array_or_empty(read_stdin_json()); + + for (let pass = 0; pass < 2; pass++) { + if (pass == 0 && as_string(series) == "") + continue; + + for (let release in releases) { + if (type(release) != "object") + continue; + if (release.draft === true || release.prerelease === true) + continue; + if (pass == 0) { + let tag = as_string(release.tag_name || ""); + let name = as_string(release.name || ""); + if (!str_contains(tag, series) && !str_contains(name, series)) + continue; + } + if (select_byedpi_asset_from_release(release, asset_ext, arch_candidates)) + return; + } + } +} + +function sing_box_extended_release_tag() { + for (let release in array_or_empty(read_stdin_json())) { + if (type(release) != "object") + continue; + if (release.draft === true || release.prerelease === true) + continue; + let tag = as_string(release.tag_name || ""); + let lowered = lc(tag); + if (tag != "" && !str_contains(lowered, "alpha") && !str_contains(lowered, "beta") && !str_contains(lowered, "rc")) { + print(tag, "\n"); + return; + } + } +} + +function text_first_chars(value, max_chars) { + value = as_string(value); + max_chars = int(max_chars || "0", 10) || 0; + return max_chars > 0 && length(value) > max_chars ? substr(value, 0, max_chars) : value; +} + +function file_last_nonblank_line(path, fallback, max_chars) { + let data = fs.readfile(path); + let result = ""; + + if (data != null) { + for (let line in split(as_string(data), "\n")) + if (match(line, /^[[:space:]]*$/) == null) + result = line; + } + + if (result == "") + result = as_string(fallback); + + print(text_first_chars(result, max_chars), "\n"); +} + +function file_flat_snippet(path, max_chars) { + let data = fs.readfile(path); + if (data == null) + return; + + print(text_first_chars(replace(as_string(data), /\n/g, " "), max_chars), "\n"); +} + +function file_tail_json_object(path) { + let data = fs.readfile(path); + let result = ""; + + if (data != null) { + for (let line in split(as_string(data), "\n")) { + let start = index(line, "{"); + if (start >= 0) + result = substr(line, start); + } + } + + if (result != "") + print(result, "\n"); +} + +function job_running_is(path, expected) { + let value = read_json_file(path); + let running = type(value) == "object" && value.running === true; + return running == arg_bool(expected); +} + +function updates_json_response(success, component, action, message, current_version, latest_version, changed, status, release_url) { + write_json({ + success: arg_bool(success), + kind: "component", + component: as_string(component), + action: as_string(action), + message: as_string(message), + current_version: as_string(current_version), + latest_version: as_string(latest_version), + changed: arg_number(changed), + status: as_string(status), + release_url: as_string(release_url) + }); +} + +function updates_status_from_compare(compare_result) { + compare_result = as_string(compare_result); + + if (compare_result == "-1") + print("outdated\n"); + else if (compare_result == "0") + print("latest\n"); + else if (compare_result == "1") + print("dev\n"); + else + exit(1); +} + +function updates_check_result_row(component, current_version, latest_version, status) { + component = as_string(component); + current_version = as_string(current_version); + latest_version = as_string(latest_version); + status = as_string(status); + + if (status == "latest") { + print("Latest version is installed\t", component, " is up to date (", current_version, ")\n"); + return; + } + + if (status == "outdated") { + print("Update is available\t", component, " update is available: ", current_version, " -> ", latest_version, "\n"); + return; + } + + if (status == "dev") { + print("Installed version is newer than release\t", component, " installed version is newer than upstream release: ", current_version, " -> ", latest_version, "\n"); + return; + } + + exit(1); +} + +function updates_job_json_response(success, job_id, message) { + write_json({ + success: arg_bool(success), + job_id: as_string(job_id), + message: as_string(message) + }); +} + +function updates_job_state_path(job_dir, job_id) { + job_dir = as_string(job_dir); + job_id = as_string(job_id); + + if (job_id == "" || job_id == "." || job_id == ".." || match(job_id, /[^A-Za-z0-9._-]/) != null) + exit(1); + + print(job_dir, "/", job_id, ".json\n"); +} + +function updates_running_job_state(component, action, pid, started_at) { + pid = as_string(pid); + write_json({ + success: true, + running: true, + kind: "component", + component: as_string(component), + action: as_string(action), + message: "Component action is running", + pid: pid != "" ? pid : null, + started_at: arg_number(started_at), + updated_at: null, + current_version: "", + latest_version: "", + changed: 0, + status: "", + exit_code: null + }); +} + +function job_started_at_within_grace(value, now, grace_seconds) { + let started_at = arg_number(value); + now = arg_number(now); + grace_seconds = arg_number(grace_seconds); + + if (started_at <= 0 || now <= 0) + return false; + + return now - started_at < grace_seconds; +} + +function job_pid_valid(pid) { + pid = as_string(pid); + return pid != "" && match(pid, /^[0-9]+$/) != null; +} + +function updates_job_refresh_plan(path, now, grace_seconds) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) { + print("skip\n"); + return; + } + + let within_grace = job_started_at_within_grace(value.started_at, now, grace_seconds); + let pid = as_string(value.pid || ""); + if (!job_pid_valid(pid)) { + print(within_grace ? "skip\n" : "stale\n"); + return; + } + + print("pid\t", pid, "\t", within_grace ? "0" : "1", "\n"); +} + +function updates_set_running_job_pid(path, pid) { + let value = object_or_empty(read_json_file(path)); + if (value.running === true) + value.pid = as_string(pid); + write_json(value); +} + +function updates_mark_stale_job_state(path) { + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.success = false; + value.running = false; + value.message = "Component action job is stale or the worker process exited unexpectedly"; + value.changed = 0; + value.status = ""; + value.exit_code = null; + } + write_json(value); +} + +function updates_finish_job_state(path, exit_code, updated_at) { + let value = read_json_file(path); + if (value == null) + exit(1); + + value.running = false; + value.kind = "component"; + value.exit_code = arg_number(exit_code); + value.updated_at = arg_number(updated_at); + write_json(value); +} + +function updates_fallback_job_state(component, action, message, exit_code, updated_at) { + write_json({ + success: false, + running: false, + kind: "component", + component: as_string(component), + action: as_string(action), + message: as_string(message), + current_version: "", + latest_version: "", + changed: 0, + status: "", + exit_code: arg_number(exit_code), + updated_at: arg_number(updated_at) + }); +} + +let mode = ARGV[0] || ""; + +if (mode == "file-json-valid") + exit(file_json_valid(ARGV[1]) ? 0 : 1); +else if (mode == "file-first-line") + file_first_line(ARGV[1]); +else if (mode == "json-file-field") + json_file_field(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "object-get-default") + object_get_default(ARGV[1], ARGV[2]); +else if (mode == "string-remove-suffix") + string_remove_suffix(ARGV[1], ARGV[2]); +else if (mode == "github-response-ok") + exit(github_response_ok() ? 0 : 1); +else if (mode == "release-by-tag") + release_by_tag(ARGV[1]); +else if (mode == "release-asset-name") + release_asset_name(ARGV[1], ARGV[2]); +else if (mode == "release-asset-url") + release_asset_url(ARGV[1]); +else if (mode == "release-asset-name-by-suffix") + release_asset_name_by_suffix(ARGV[1]); +else if (mode == "release-asset-url-by-suffix") + release_asset_url_by_suffix(ARGV[1]); +else if (mode == "tachyon-release-plan") + tachyon_release_plan(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "release-metadata-tsv") + release_metadata_tsv(); +else if (mode == "openwrt-release-value") + openwrt_release_value(ARGV[1], ARGV[2]); +else if (mode == "openwrt-release-series") + openwrt_release_series(ARGV[1]); +else if (mode == "stdin-contains-ci") + exit(stdin_contains_ci(ARGV[1]) ? 0 : 1); +else if (mode == "stdin-first-line-last-field") + stdin_first_line_last_field(); +else if (mode == "updates-arch-package-version") + updates_arch_package_version(ARGV[1], ARGV[2]); +else if (mode == "updates-zapret-bundle-version") + updates_zapret_bundle_version(ARGV[1]); +else if (mode == "updates-zapret2-bundle-version") + updates_zapret2_bundle_version(ARGV[1]); +else if (mode == "updates-normalize-sing-box-version") + updates_normalize_sing_box_version(ARGV[1]); +else if (mode == "updates-normalize-zapret-version") + updates_normalize_zapret_version(ARGV[1]); +else if (mode == "tachyon-release-version-valid") + exit(tachyon_release_version_valid(ARGV[1]) ? 0 : 1); +else if (mode == "tachyon-release-version-compare") + exit(tachyon_release_version_compare(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "updates-zip-inner-package-path") + updates_zip_inner_package_path(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "updates-archive-member-path") + updates_archive_member_path(ARGV[1]); +else if (mode == "updates-opkg-arch-list") + updates_opkg_arch_list(); +else if (mode == "updates-arch-candidates") + updates_arch_candidates(ARGV[1]); +else if (mode == "string-has-whitespace-field") + exit(string_has_whitespace_field(ARGV[1]) ? 0 : 1); +else if (mode == "file-whitespace-list") + file_whitespace_list(ARGV[1]); +else if (mode == "sing-box-extended-arch-suffix") + sing_box_extended_arch_suffix(ARGV[1], ARGV[2]); +else if (mode == "sing-box-extended-asset-url") + sing_box_extended_asset_url(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "sing-box-extended-package-asset-url") + sing_box_extended_package_asset_url(ARGV[1], ARGV[2]); +else if (mode == "updates-opkg-package-installed") + updates_opkg_package_installed(ARGV[1]); +else if (mode == "updates-opkg-package-version") + updates_opkg_package_version(ARGV[1]); +else if (mode == "updates-apk-manifest-package-version") + updates_apk_manifest_package_version(ARGV[1]); +else if (mode == "updates-apk-info-package-version") + updates_apk_info_package_version(ARGV[1]); +else if (mode == "updates-apk-policy-version") + updates_apk_policy_version(); +else if (mode == "named-release-select-asset") + named_release_select_asset(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "release-select-arch-suffix-asset") + release_select_arch_suffix_asset(ARGV[1], ARGV[2]); +else if (mode == "byedpi-select-asset") + byedpi_select_asset(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "sing-box-extended-release-tag") + sing_box_extended_release_tag(); +else if (mode == "file-last-nonblank-line") + file_last_nonblank_line(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "file-flat-snippet") + file_flat_snippet(ARGV[1], ARGV[2]); +else if (mode == "file-tail-json-object") + file_tail_json_object(ARGV[1]); +else if (mode == "job-running-is") + exit(job_running_is(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "updates-json-response") + updates_json_response(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9]); +else if (mode == "updates-status-from-compare") + updates_status_from_compare(ARGV[1]); +else if (mode == "updates-check-result-row") + updates_check_result_row(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "updates-job-json-response") + updates_job_json_response(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "updates-job-state-path") + updates_job_state_path(ARGV[1], ARGV[2]); +else if (mode == "updates-running-job-state") + updates_running_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "updates-job-refresh-plan") + updates_job_refresh_plan(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "updates-set-running-job-pid") + updates_set_running_job_pid(ARGV[1], ARGV[2]); +else if (mode == "updates-mark-stale-job-state") + updates_mark_stale_job_state(ARGV[1]); +else if (mode == "updates-finish-job-state") + updates_finish_job_state(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "updates-fallback-job-state") + updates_fallback_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else { + warn("Usage: components/updater.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/components/updates.uc b/tachyon/files/usr/lib/components/updates.uc new file mode 100644 index 00000000..504c693e --- /dev/null +++ b/tachyon/files/usr/lib/components/updates.uc @@ -0,0 +1,2949 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); +let connections = require("config.connections"); +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const BIN_PATH = getenv("TACHYON_BIN") || "/usr/bin/tachyon"; +const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || "/tmp/sing-box"; +const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || TMP_SING_BOX_FOLDER + "/rulesets"; +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || TMP_SING_BOX_FOLDER + "/subscriptions"; +const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const LIST_UPDATE_STATE_FILE = getenv("TACHYON_LIST_UPDATE_STATE_FILE") || RUNTIME_STATE_DIR + "/list-update.timestamp"; +const LIST_UPDATE_PID_FILE = getenv("TACHYON_LIST_UPDATE_PID_FILE") || "/var/run/tachyon_list_update.pid"; +const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update"; +const SUBSCRIPTION_JOB_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_JOB_DIR") || "/var/run/tachyon/subscription-update-jobs"; +const SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR") || RUNTIME_STATE_DIR + "/subscription-update.lock"; +const SUBSCRIPTION_LINKS_DIR = getenv("TACHYON_SUBSCRIPTION_LINKS_DIR") || RUNTIME_STATE_DIR + "/subscription-links"; +const SUBSCRIPTION_METADATA_DIR = getenv("TACHYON_SUBSCRIPTION_METADATA_DIR") || RUNTIME_STATE_DIR + "/subscription-metadata"; +const OUTBOUND_METADATA_DIR = getenv("TACHYON_OUTBOUND_METADATA_DIR") || RUNTIME_STATE_DIR + "/outbound-metadata"; +const SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; +const RUNTIME_CACHE_FORMAT_FILE = getenv("TACHYON_RUNTIME_CACHE_FORMAT_FILE") || RUNTIME_STATE_DIR + "/cache-format"; +const RUNTIME_CACHE_FORMAT = getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8"; +const PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/tachyon/subscription-cache"; +const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; +const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT") || "7"; +const PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending"; +const RELOAD_STATE_FILE = getenv("TACHYON_RELOAD_STATE_FILE") || RUNTIME_STATE_DIR + "/reload-state"; +const RELOAD_STATE_FORMAT = getenv("TACHYON_RELOAD_STATE_FORMAT") || "1"; +const RULE_CONDITION_CACHE_DIR = getenv("TACHYON_RULE_CONDITION_CACHE_DIR") || RUNTIME_STATE_DIR + "/rule-condition-cache"; +const RELOAD_LOCK_DIR = getenv("TACHYON_RELOAD_LOCK_DIR") || "/var/run/tachyon.reload.lock"; +const SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || "/etc/init.d/tachyon"; +const PRIORITY_UC = getenv("TACHYON_PRIORITY_UC") || LIB_DIR + "/singbox/priority.uc"; +const DNS_FAILOVER_UC = getenv("TACHYON_DNS_FAILOVER_UC") || LIB_DIR + "/singbox/dns_failover.uc"; +const COMPONENT_JOB_DIR = getenv("UPDATES_JOB_DIR") || getenv("TACHYON_UI_COMPONENT_ACTION_DIR") || "/var/run/tachyon/component-actions"; +const COMPONENT_UPDATE_CHECK_CACHE_DIR = getenv("TACHYON_COMPONENT_UPDATE_CHECK_CACHE_DIR") || RUNTIME_STATE_DIR + "/component-update-checks"; +const COMPONENT_UPDATE_CHECK_STATE_FILE = getenv("TACHYON_COMPONENT_UPDATE_CHECK_STATE_FILE") || RUNTIME_STATE_DIR + "/component-update-check.timestamp"; +const COMPONENT_UPDATE_CHECK_LOCK_DIR = getenv("TACHYON_COMPONENT_UPDATE_CHECK_LOCK_DIR") || RUNTIME_STATE_DIR + "/component-update-check.lock"; +const COMPONENT_JOB_FINISHED_TTL_MINUTES = getenv("UPDATES_JOB_FINISHED_TTL_MINUTES") || "60"; +const COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES = getenv("UPDATES_JOB_ORPHAN_OUTPUT_TTL_MINUTES") || "60"; +const COMPONENT_JOB_STALE_GRACE_SECONDS = getenv("UPDATES_JOB_STALE_GRACE_SECONDS") || getenv("TACHYON_UI_ACTION_STALE_GRACE_SECONDS") || "15"; +const SUBSCRIPTION_JOB_FINISHED_TTL_MINUTES = getenv("TACHYON_SUBSCRIPTION_UPDATE_JOB_FINISHED_TTL_MINUTES") || "60"; +const SUBSCRIPTION_JOB_ORPHAN_OUTPUT_TTL_MINUTES = getenv("TACHYON_SUBSCRIPTION_UPDATE_JOB_ORPHAN_OUTPUT_TTL_MINUTES") || "60"; +const SUBSCRIPTION_JOB_STALE_GRACE_SECONDS = getenv("TACHYON_UI_ACTION_STALE_GRACE_SECONDS") || "15"; +const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || "TachyonTable"; +const NFT_COMMON_SET_NAME = getenv("NFT_COMMON_SET_NAME") || "tachyon_subnets"; +const NFT_COMMON6_SET_NAME = getenv("NFT_COMMON6_SET_NAME") || "tachyon_subnets6"; +const NFT_IP_PORT_SET_NAME = getenv("NFT_IP_PORT_SET_NAME") || "tachyon_ip_ports"; +const NFT_IP_PORT6_SET_NAME = getenv("NFT_IP_PORT6_SET_NAME") || "tachyon_ip6_ports"; +const NFT_DISCORD_SET_NAME = getenv("NFT_DISCORD_SET_NAME") || "tachyon_discord_subnets"; +const NFT_DISCORD6_SET_NAME = getenv("NFT_DISCORD6_SET_NAME") || "tachyon_discord_subnets6"; +const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || "tachyon_interfaces"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x00100000"; +const SB_SERVICE_MIXED_INBOUND_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1"; +const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534"; +const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || "/etc/tachyon/sing-box-variant"; +const GITHUB_RAW_URL = getenv("GITHUB_RAW_URL") || "https://raw.githubusercontent.com/itdoginfo/allow-domains/main"; +const BUILTIN_SUBNET_URLS = { + twitter: getenv("SUBNETS_TWITTER") || GITHUB_RAW_URL + "/Subnets/IPv4/twitter.lst", + meta: getenv("SUBNETS_META") || GITHUB_RAW_URL + "/Subnets/IPv4/meta.lst", + discord: getenv("SUBNETS_DISCORD") || GITHUB_RAW_URL + "/Subnets/IPv4/discord.lst", + roblox: getenv("SUBNETS_ROBLOX") || GITHUB_RAW_URL + "/Subnets/IPv4/roblox.lst", + telegram: getenv("SUBNETS_TELERAM") || GITHUB_RAW_URL + "/Subnets/IPv4/telegram.lst", + cloudflare: getenv("SUBNETS_CLOUDFLARE") || GITHUB_RAW_URL + "/Subnets/IPv4/cloudflare.lst", + hetzner: getenv("SUBNETS_HETZNER") || GITHUB_RAW_URL + "/Subnets/IPv4/hetzner.lst", + ovh: getenv("SUBNETS_OVH") || GITHUB_RAW_URL + "/Subnets/IPv4/ovh.lst", + digitalocean: getenv("SUBNETS_DIGITALOCEAN") || GITHUB_RAW_URL + "/Subnets/IPv4/digitalocean.lst", + cloudfront: getenv("SUBNETS_CLOUDFRONT") || GITHUB_RAW_URL + "/Subnets/IPv4/cloudfront.lst" +}; +let rule_config = null; +let routing_rulesets_module_value = null; +let singbox_rulesets_module_value = null; + +function routing_rulesets_module() { + if (routing_rulesets_module_value == null) + routing_rulesets_module_value = require("routing.rulesets"); + return routing_rulesets_module_value; +} + +function singbox_rulesets_module() { + if (singbox_rulesets_module_value == null) + singbox_rulesets_module_value = require("singbox.rulesets"); + return singbox_rulesets_module_value; +} + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let data = fs.readfile("/dev/stdin"); + return data == null ? "" : data; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function command_env(assignments) { + let parts = []; + + for (let name, value in assignments) + push(parts, name + "=" + shell_quote(value)); + + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function file_md5(path) { + path = as_string(path); + if (path == "" || fs.stat(path) == null) + return ""; + + let fields = split(trim(command_output(command_from_args([ "md5sum", path ]))), /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[0]) : ""; +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function now_seconds() { + return int(clock()[0]); +} + +function ensure_dir(path) { + return command_success_from_args([ "mkdir", "-p", path ]); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]); +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function json_text(value) { + return sprintf("%J", value) + "\n"; +} + +function write_file(path, value) { + return fs.writefile(as_string(path), as_string(value)) != null; +} + +function write_state_file(path, value) { + path = as_string(path); + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); + + if (!write_file(tmp_path, json_text(value))) { + fs.unlink(tmp_path); + return false; + } + if (!fs.rename(tmp_path, path)) { + fs.unlink(tmp_path); + return false; + } + return true; +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function file_first_line(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let newline = index(data, "\n"); + print(newline >= 0 ? substr(data, 0, newline) : data, "\n"); +} + +function file_first_line_value(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "true" || value == "1" || value == "yes" || value == "on"; +} + +function arg_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9-]/)) + return 0; + return int(value); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function text_first_chars(value, max_chars) { + value = as_string(value); + max_chars = int(max_chars || "0", 10) || 0; + return max_chars > 0 && length(value) > max_chars ? substr(value, 0, max_chars) : value; +} + +function file_last_nonblank_line_value(path, fallback, max_chars) { + let data = fs.readfile(path); + let result = ""; + + if (data != null) { + for (let line in split(as_string(data), "\n")) + if (match(line, /^[[:space:]]*$/) == null) + result = line; + } + + if (result == "") + result = as_string(fallback); + + return text_first_chars(result, max_chars); +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function bool_option(section, key, fallback) { + let value = object_or_empty(section)[key]; + return value == null ? !!fallback : arg_bool(value); +} + +function section_name(section) { + return as_string(object_or_empty(section)[".name"]); +} + +function file_exists_value(path) { + return fs.stat(as_string(path)) != null; +} + +function file_nonempty(path) { + let stat = fs.stat(as_string(path)); + return stat != null && int(stat.size || 0) > 0; +} + +function copy_file(source, target) { + let data = fs.readfile(as_string(source)); + if (data == null) + return false; + return write_file(target, data); +} + +function parent_dir(path) { + path = as_string(path); + let slash = rindex(path, "/"); + return slash >= 0 ? substr(path, 0, slash) : ""; +} + +function ensure_parent_dir(path) { + let dir = parent_dir(path); + return dir == "" || dir == "." || ensure_dir(dir); +} + +function temp_path() { + return trim(command_output_from_args([ "mktemp" ])); +} + +function remove_files(paths) { + for (let path in paths) + if (as_string(path) != "") + remove_file(path); +} + +function owner_pid() { + let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ])); + return match(pid, /^[0-9]+$/) != null ? pid : "0"; +} + +function runtime_pid_running(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function whitespace_items(value) { + let result = []; + if (type(value) == "array") { + for (let item in value) { + item = as_string(item); + if (item != "") + push(result, item); + } + return result; + } + + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function list_option_values(section, key) { + return whitespace_items(object_or_empty(section)[key]); +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, as_string(type_name)); +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function module_command(args) { + let command_args = [ "ucode", "-L", LIB_DIR ]; + for (let arg in args) + push(command_args, arg); + return command_from_args(command_args); +} + +function module_status(args) { + return command_status(module_command(args)); +} + +function module_success(args) { + return module_status(args) == 0; +} + +function module_output(args) { + return command_output(module_command(args)); +} + +function nft_module_success(args) { + let command_args = [ LIB_DIR + "/nft/apply.uc" ]; + for (let arg in args) + push(command_args, arg); + return module_success(command_args); +} + +function service_state_success(args) { + let command_args = [ LIB_DIR + "/service/state.uc" ]; + for (let arg in args) + push(command_args, arg); + return module_success(command_args); +} + +function acquire_runtime_lock(lock_dir, wait) { + return service_state_success([ + wait ? "acquire-runtime-dir-lock-wait" : "acquire-runtime-dir-lock", + lock_dir, + owner_pid(), + "300" + ]); +} + +function release_runtime_lock(lock_dir) { + service_state_success([ "release-runtime-dir-lock", lock_dir ]); +} + +function unsigned_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9]/) != null) + return null; + return int(value); +} + +function update_due_status(now_value, last_run_value, interval_value) { + let now = unsigned_number(now_value); + let interval = unsigned_number(interval_value); + + if (now == null || interval == null || interval <= 0) + return 2; + + let last_run = unsigned_number(last_run_value); + if (last_run == null) + last_run = 0; + + if (last_run > 0 && now - last_run < interval) + return 1; + + return 0; +} + +function update_is_due(now_value, last_run_value, interval_value) { + exit(update_due_status(now_value, last_run_value, interval_value)); +} + +function duration_to_seconds_value(value) { + let rest = as_string(value); + if (rest == "") + return null; + + let total = 0.0; + let multipliers = { + ns: 0.000000001, + us: 0.000001, + ms: 0.001, + s: 1, + m: 60, + h: 3600, + d: 86400 + }; + + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return null; + + let token = as_string(matched[0]); + let amount = matched[1] * 1; + let unit = matched[3]; + total = total + amount * multipliers[unit]; + rest = substr(rest, length(token)); + } + + if (total <= 0) + return null; + + return int(total + 0.5); +} + +function duration_to_seconds(value) { + let seconds = duration_to_seconds_value(value); + if (seconds == null) + exit(1); + + print(seconds, "\n"); +} + +function due_check_cron_schedule_text(value) { + let seconds = arg_number(value); + + if (seconds <= 60) + return "* * * * *"; + + if (seconds % 86400 == 0) + return "0 0 * * *"; + + if (seconds % 3600 == 0) { + let hours = seconds / 3600; + if (hours >= 1 && hours <= 23) + return hours == 1 ? "0 * * * *" : "0 */" + hours + " * * *"; + } + + if (seconds % 60 == 0) { + let minutes = seconds / 60; + if (minutes >= 1 && minutes <= 59) + return minutes == 1 ? "* * * * *" : "*/" + minutes + " * * * *"; + } + + return "* * * * *"; +} + +function due_check_cron_schedule(value) { + print(due_check_cron_schedule_text(value), "\n"); +} + +function update_cron_job(interval, command, bin, marker) { + let seconds = duration_to_seconds_value(interval); + if (seconds == null) + exit(1); + + print(due_check_cron_schedule_text(seconds), " ", as_string(bin), " ", as_string(command), " ", as_string(marker), "\n"); +} + +function subscription_update_cron_job(min_interval_seconds, bin, marker) { + let seconds = arg_number(min_interval_seconds); + if (seconds <= 0) + exit(1); + + print(due_check_cron_schedule_text(seconds), " ", as_string(bin), " subscription_update_if_due ", as_string(marker), "\n"); +} + +function subscription_update_interval_plan() { + let min_interval = 0; + + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (line == "") + continue; + + let separator = index(line, "\t"); + let section = separator >= 0 ? substr(line, 0, separator) : line; + let interval = separator >= 0 ? substr(line, separator + 1) : ""; + if (interval == "") + continue; + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + print("error\t", section, "\t", interval, "\n"); + continue; + } + + if (min_interval == 0 || seconds < min_interval) + min_interval = seconds; + } + + print("min\t", min_interval, "\n"); +} + +function list_has_remote_references(value) { + for (let item in split(as_string(value), /[ \t\r\n]+/)) { + if (match(item, /^https?:\/\//) != null) + return true; + } + + return false; +} + +function rule_has_list_update_source(section) { + section = object_or_empty(section); + if (rule_config == null) + rule_config = require("config.rule"); + + if (!bool_option(section, "enabled", true)) + return false; + if (option(section, "action", "") == "dns") + return list_has_remote_references(option(section, "domain_ip_lists", "")); + + return ( + rule_config.has_community_subnet_list(connections.community_lists_value(section)) || + option(section, "remote_domain_lists", "") != "" || + option(section, "remote_subnet_lists", "") != "" || + length(connections.rule_sets_with_subnets(section)) > 0 || + list_has_remote_references(option(section, "domain_ip_lists", "")) + ); +} + +function has_list_update_sources(sections) { + for (let section in sections) + if (rule_has_list_update_source(section)) + return true; + return false; +} + +function settings_update_interval(settings) { + settings = object_or_empty(settings); + + if (!bool_option(settings, "list_update_enabled", true)) + return ""; + + let value = option(settings, "update_interval", "1d"); + return value != "" ? value : "1d"; +} + +function settings_component_update_check_interval(settings) { + settings = object_or_empty(settings); + + if (!bool_option(settings, "component_update_check_enabled", false)) + return ""; + + let value = option(settings, "component_update_check_interval", "1d"); + return value != "" ? value : "1d"; +} + +function section_subscription_update_interval(section) { + let result = ""; + let result_seconds = 0; + + for (let entry in connections.subscription_urls(section)) { + if (!connections.subscription_update_enabled(section, entry)) + continue; + + let value = connections.subscription_update_interval(section, entry); + if (value == "") + value = "1h"; + let seconds = duration_to_seconds_value(value); + if (result == "" || (seconds != null && (result_seconds == 0 || seconds < result_seconds))) { + result = value; + result_seconds = seconds == null ? 0 : seconds; + } + } + + return result; +} + +function section_is_subscription_proxy(section) { + return bool_option(section, "enabled", true) && + connections.is_connections_action(option(section, "action", "")) && + length(connections.subscription_urls(section)) > 0; +} + +function line_contains_any_marker(line, markers) { + line = as_string(line); + for (let marker in markers) { + marker = as_string(marker); + if (marker != "" && index(line, marker) >= 0) + return true; + } + + return false; +} + +function filter_cron_markers_text(data, markers) { + data = as_string(data); + if (data == "") + return ""; + + let lines = split(data, "\n"); + let has_trailing_newline = substr(data, length(data) - 1) == "\n"; + let result = ""; + + for (let i = 0; i < length(lines); i++) { + let line = as_string(lines[i]); + if (i == length(lines) - 1 && has_trailing_newline && line == "") + continue; + if (line_contains_any_marker(line, markers)) + continue; + result += line + "\n"; + } + + return result; +} + +function filter_cron_markers(markers) { + print(filter_cron_markers_text(read_stdin(), markers)); +} + +function cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker, component_marker) { + let status = 0; + let rows = []; + + if (has_list_update_sources(sections)) { + let interval = settings_update_interval(settings); + if (interval == "") { + push(rows, "list-disabled"); + } + else { + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + push(rows, "list-error\t" + as_string(interval)); + status = 1; + } + else { + push(rows, "list\t" + due_check_cron_schedule_text(seconds) + " " + as_string(bin) + " list_update_if_due " + as_string(list_marker)); + } + } + } + + let min_interval = 0; + let subscription_source_count = 0; + for (let section in sections) { + section = object_or_empty(section); + if (!section_is_subscription_proxy(section)) + continue; + + let interval = section_subscription_update_interval(section); + if (interval == "") + continue; + + subscription_source_count++; + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + push(rows, "subscription-error\t" + section_name(section) + "\t" + as_string(interval)); + continue; + } + + if (min_interval == 0 || seconds < min_interval) + min_interval = seconds; + } + + if (subscription_source_count > 0) { + if (min_interval <= 0) + status = 1; + else + push(rows, "subscription\t" + due_check_cron_schedule_text(min_interval) + " " + as_string(bin) + " subscription_update_if_due " + as_string(subscription_marker)); + } + + let component_interval = settings_component_update_check_interval(settings); + if (component_interval != "") { + let component_seconds = duration_to_seconds_value(component_interval); + if (component_seconds == null) { + push(rows, "component-error\t" + as_string(component_interval)); + status = 1; + } + else { + push(rows, "component\t" + due_check_cron_schedule_text(component_seconds) + " " + as_string(bin) + " component_updates_if_due " + as_string(component_marker)); + } + } + + return { + status, + rows + }; +} + +function print_cron_refresh_plan(result) { + result = object_or_empty(result); + for (let row in result.rows) + print(row, "\n"); + exit(int(result.status || 0)); +} + +function cron_refresh_plan(settings, sections, bin, list_marker, subscription_marker, component_marker) { + print_cron_refresh_plan(cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker, component_marker)); +} + +function cron_refresh_apply_result(settings, sections, existing_crontab, bin, list_marker, subscription_marker, component_marker) { + let plan = cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker, component_marker); + let filtered_crontab = filter_cron_markers_text(existing_crontab, [ list_marker, subscription_marker, component_marker ]); + let cron_jobs = ""; + let logs = [ { level: "info", message: "The cron job removed" } ]; + let tab = "\t"; + + for (let row in plan.rows) { + let line = as_string(row); + let separator = index(line, tab); + let type = separator >= 0 ? substr(line, 0, separator) : line; + let rest = separator >= 0 ? substr(line, separator + 1) : ""; + + if (type == "list") { + cron_jobs += rest + "\n"; + push(logs, { level: "info", message: "The cron job has been created: " + rest }); + } + else if (type == "list-disabled") { + push(logs, { level: "info", message: "Remote list auto-update is disabled" }); + } + else if (type == "list-error") { + push(logs, { level: "error", message: "Invalid update_interval value: " + rest }); + } + else if (type == "subscription") { + cron_jobs += rest + "\n"; + push(logs, { level: "info", message: "The subscription cron job has been created: " + rest }); + } + else if (type == "subscription-error") { + let section_separator = index(rest, tab); + let section = section_separator >= 0 ? substr(rest, 0, section_separator) : rest; + let interval = section_separator >= 0 ? substr(rest, section_separator + 1) : ""; + push(logs, { level: "error", message: "Invalid subscription_update_interval value for rule '" + section + "': " + interval }); + } + else if (type == "component") { + cron_jobs += rest + "\n"; + push(logs, { level: "info", message: "The component update check cron job has been created: " + rest }); + } + else if (type == "component-error") { + push(logs, { level: "error", message: "Invalid component_update_check_interval value: " + rest }); + } + } + + return { + status: int(plan.status || 0), + crontab: filtered_crontab + (int(plan.status || 0) == 0 ? cron_jobs : ""), + logs + }; +} + +function write_crontab_text(text) { + let tmp = trim(command_output_from_args([ "mktemp" ])); + if (tmp == "") + return false; + + if (fs.writefile(tmp, as_string(text)) == null) { + fs.unlink(tmp); + return false; + } + + let ok = command_success_from_args([ "crontab", tmp ]); + fs.unlink(tmp); + return ok; +} + +function log_cron_apply_result(result) { + for (let item in array_or_empty(object_or_empty(result).logs)) + log_message(item.message, item.level); +} + +function remove_cron_jobs(list_marker, subscription_marker, component_marker) { + let crontab = command_output_from_args([ "crontab", "-l" ]); + let result = { + crontab: filter_cron_markers_text(crontab, [ list_marker, subscription_marker, component_marker ]), + logs: [ { level: "info", message: "The cron job removed" } ] + }; + + if (!write_crontab_text(result.crontab)) + exit(1); + + log_cron_apply_result(result); +} + +function refresh_cron_from_sources(settings, sections, bin, list_marker, subscription_marker, component_marker) { + let result = cron_refresh_apply_result( + settings, + sections, + command_output_from_args([ "crontab", "-l" ]), + bin, + list_marker, + subscription_marker, + component_marker + ); + + if (!write_crontab_text(result.crontab)) + exit(1); + + log_cron_apply_result(result); + exit(result.status); +} + +function list_update_due_status(settings, timestamp_path, now) { + let interval = settings_update_interval(settings); + if (interval == "") + exit(1); + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + print("error\t", interval, "\n"); + exit(2); + } + + exit(update_due_status(now, file_first_line_value(timestamp_path), seconds)); +} + +function subscription_update_section_due_status(section, timestamp_path, now) { + section = object_or_empty(section); + let interval = section_subscription_update_interval(section); + if (interval == "") + exit(1); + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + print("error\t", interval, "\n"); + exit(2); + } + + exit(update_due_status(now, file_first_line_value(timestamp_path), seconds)); +} + +function stdin_first_ipv4_line() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (match(line, /^[0-9]+\./) != null) { + print(line, "\n"); + return; + } + } +} + +function json_length(path) { + let value = read_json_file(path); + if (type(value) == "array" || type(value) == "object") + print(length(value), "\n"); + else + print("0\n"); +} + +function job_pid(path) { + let value = read_json_file(path); + if (type(value) == "object" && value.pid != null) + print(as_string(value.pid), "\n"); +} + +function subscription_job_state_path(job_dir, job_id) { + job_dir = as_string(job_dir); + job_id = as_string(job_id); + + if (job_id == "" || job_id == "." || job_id == ".." || match(job_id, /[^A-Za-z0-9._-]/) != null) + exit(1); + + print(job_dir, "/", job_id, ".json\n"); +} + +function subscription_job_json_response(success, job_id, message) { + write_json({ + success: arg_bool(success), + job_id: as_string(job_id), + message: as_string(message) + }); +} + +function subscription_running_job_state_value(section, source_index, started_at) { + return { + success: true, + running: true, + kind: "subscription", + message: "Subscription update is running", + section: as_string(section), + source_index: as_string(source_index), + pid: null, + started_at: arg_number(started_at), + updated_at: null, + exit_code: null + }; +} + +function subscription_running_job_state(section, source_index, started_at) { + write_json(subscription_running_job_state_value(section, source_index, started_at)); +} + +function job_started_at_within_grace(value, now, grace_seconds) { + let started_at = arg_number(value); + now = arg_number(now); + grace_seconds = arg_number(grace_seconds); + + if (started_at <= 0 || now <= 0) + return false; + + return now - started_at < grace_seconds; +} + +function job_pid_valid(pid) { + pid = as_string(pid); + return pid != "" && match(pid, /^[0-9]+$/) != null; +} + +function set_subscription_running_job_pid(path, pid) { + pid = as_string(pid); + if (!job_pid_valid(pid)) + return false; + + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.pid = pid; + return write_state_file(path, value); + } + + return false; +} + +function subscription_job_refresh_plan(path, now, grace_seconds) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) { + print("skip\n"); + return; + } + + let within_grace = job_started_at_within_grace(value.started_at, now, grace_seconds); + let pid = as_string(value.pid || ""); + if (!job_pid_valid(pid)) { + print(within_grace ? "skip\n" : "stale\n"); + return; + } + + print("pid\t", pid, "\t", within_grace ? "0" : "1", "\n"); +} + +function subscription_finished_job_state_value(success, message, exit_code, updated_at, section, source_index, started_at) { + return { + success: arg_bool(success), + running: false, + kind: "subscription", + message: as_string(message), + section: as_string(section), + source_index: as_string(source_index), + pid: null, + started_at: arg_number(started_at), + exit_code: arg_number(exit_code), + updated_at: arg_number(updated_at) + }; +} + +function subscription_finished_job_state(success, message, exit_code, updated_at, section, source_index, started_at) { + write_json(subscription_finished_job_state_value(success, message, exit_code, updated_at, section, source_index, started_at)); +} + +function subscription_stale_job_state_value(updated_at, section, source_index, started_at) { + return { + success: false, + running: false, + kind: "subscription", + message: "Subscription update worker exited unexpectedly", + section: as_string(section), + source_index: as_string(source_index), + pid: null, + started_at: arg_number(started_at), + exit_code: null, + updated_at: arg_number(updated_at) + }; +} + +function subscription_stale_job_state(updated_at, section, source_index, started_at) { + write_json(subscription_stale_job_state_value(updated_at, section, source_index, started_at)); +} + +function subscription_status_error(message) { + write_json({ + success: false, + running: false, + message: as_string(message), + exit_code: null + }); +} + +function subscription_status_error_exit(message) { + subscription_status_error(message); + exit(1); +} + +function valid_subscription_job_id(job_id) { + job_id = as_string(job_id); + return job_id != "" && job_id != "." && job_id != ".." && match(job_id, /[^A-Za-z0-9._-]/) == null; +} + +function subscription_job_state_path_value(job_dir, job_id) { + if (!valid_subscription_job_id(job_id)) + return ""; + return as_string(job_dir) + "/" + as_string(job_id) + ".json"; +} + +function subscription_job_id() { + let stamp = clock(); + return sprintf("%d-%d", stamp[0], stamp[1]); +} + +function ensure_subscription_runtime_dirs() { + let command = command_env({ + TACHYON_CONFIG_NAME: CONFIG_NAME, + TACHYON_LIB: LIB_DIR, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER, + TACHYON_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, + TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, + TACHYON_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, + TACHYON_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, + TACHYON_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, + TACHYON_SECTION_CACHE_DIR: SECTION_CACHE_DIR, + TACHYON_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, + TACHYON_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT + }) + " " + command_from_args([ + "ucode", + "-L", LIB_DIR, + LIB_DIR + "/subscription/cache.uc", + "ensure-runtime-dirs" + ]); + + return command_success(command) && ensure_dir(SUBSCRIPTION_JOB_DIR); +} + +function subscription_job_output_path(job_id) { + return SUBSCRIPTION_JOB_DIR + "/" + as_string(job_id) + ".out"; +} + +function subscription_job_output_path_from_state(path) { + path = as_string(path); + if (length(path) >= 5 && substr(path, length(path) - 5) == ".json") + return substr(path, 0, length(path) - 5) + ".out"; + return path + ".out"; +} + +function remove_subscription_job_state(path) { + remove_file(path); + remove_file(subscription_job_output_path_from_state(path)); +} + +function subscription_job_running_is(path, expected) { + let value = read_json_file(path); + let running = type(value) == "object" && value.running === true; + return running == arg_bool(expected); +} + +function subscription_cleanup_jobs() { + ensure_dir(SUBSCRIPTION_JOB_DIR); + + command_success_from_args([ + "find", + SUBSCRIPTION_JOB_DIR, + "-type", "f", + "-name", "*.out", + "-mmin", "+" + as_string(SUBSCRIPTION_JOB_ORPHAN_OUTPUT_TTL_MINUTES), + "-delete" + ]); + + let old = command_output_from_args([ + "find", + SUBSCRIPTION_JOB_DIR, + "-type", "f", + "-name", "*.json", + "-mmin", "+" + as_string(SUBSCRIPTION_JOB_FINISHED_TTL_MINUTES) + ]); + + for (let path in split(old, "\n")) { + path = trim(as_string(path)); + if (path != "" && subscription_job_running_is(path, false)) + remove_subscription_job_state(path); + } +} + +function pid_running(pid) { + pid = as_string(pid); + return job_pid_valid(pid) && command_success_from_args([ "kill", "-0", pid ]); +} + +function write_subscription_stale_job_state(path) { + let value = object_or_empty(read_json_file(path)); + return write_state_file(path, subscription_stale_job_state_value( + now_seconds(), + value.section || "", + value.source_index || "", + value.started_at || 0 + )); +} + +function refresh_subscription_running_job_state(path) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) + return; + + let now = now_seconds(); + let within_grace = job_started_at_within_grace(value.started_at, now, SUBSCRIPTION_JOB_STALE_GRACE_SECONDS); + let pid = as_string(value.pid || ""); + + if (!job_pid_valid(pid)) { + if (!within_grace) + write_subscription_stale_job_state(path); + return; + } + + if (pid_running(pid)) + return; + if (within_grace) + return; + + command_success_from_args([ "sleep", "1" ]); + value = read_json_file(path); + if (type(value) != "object" || value.running !== true) + return; + if (pid_running(pid)) + return; + + write_subscription_stale_job_state(path); +} + +function finish_subscription_job(path, exit_code, output_file) { + let value = object_or_empty(read_json_file(path)); + let success = arg_number(exit_code) == 0; + let message = file_last_nonblank_line_value( + output_file, + success ? "Subscription update completed" : "Subscription update failed", + 240 + ); + + let ok = write_state_file(path, subscription_finished_job_state_value( + success, + message, + exit_code, + now_seconds(), + value.section || "", + value.source_index || "", + value.started_at || 0 + )); + remove_file(output_file); + return ok; +} + +function subscription_worker_env() { + return { + TACHYON_CONFIG_NAME: CONFIG_NAME, + TACHYON_LIB: LIB_DIR, + TACHYON_BIN: BIN_PATH, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER, + TACHYON_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, + TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, + TACHYON_SUBSCRIPTION_UPDATE_JOB_DIR: SUBSCRIPTION_JOB_DIR, + TACHYON_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, + TACHYON_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, + TACHYON_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, + TACHYON_SECTION_CACHE_DIR: SECTION_CACHE_DIR, + TACHYON_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, + TACHYON_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT, + TACHYON_SUBSCRIPTION_UPDATE_JOB_FINISHED_TTL_MINUTES: SUBSCRIPTION_JOB_FINISHED_TTL_MINUTES, + TACHYON_SUBSCRIPTION_UPDATE_JOB_ORPHAN_OUTPUT_TTL_MINUTES: SUBSCRIPTION_JOB_ORPHAN_OUTPUT_TTL_MINUTES, + TACHYON_UI_ACTION_STALE_GRACE_SECONDS: SUBSCRIPTION_JOB_STALE_GRACE_SECONDS + }; +} + +function launch_subscription_worker(args) { + let command_args = [ "ucode", "-L", LIB_DIR, LIB_DIR + "/components/updates.uc" ]; + for (let arg in args) + push(command_args, arg); + + let command = command_env(subscription_worker_env()) + " " + + command_from_args(command_args) + + " >/dev/null 2>&1 1000>&- & echo $!"; + return trim(command_output("sh -c " + shell_quote(command))); +} + +function subscription_update_worker(state_file, output_file, section, source_index) { + let status = command_status(command_from_args([ + BIN_PATH, + "subscription_update", + as_string(section), + as_string(source_index) + ]) + " >" + shell_quote(output_file) + " 2>&1"); + + finish_subscription_job(state_file, status, output_file); +} + +function subscription_update_async(section, source_index) { + if (!ensure_subscription_runtime_dirs()) { + subscription_job_json_response(false, "", "Failed to create subscription update state directory"); + exit(1); + } + + subscription_cleanup_jobs(); + + let job_id = subscription_job_id(); + let state_file = subscription_job_state_path_value(SUBSCRIPTION_JOB_DIR, job_id); + if (state_file == "") { + subscription_job_json_response(false, "", "Failed to prepare subscription update job"); + exit(1); + } + + if (!write_state_file(state_file, subscription_running_job_state_value(section, source_index, now_seconds()))) { + subscription_job_json_response(false, "", "Failed to write subscription update state"); + exit(1); + } + + let output_file = subscription_job_output_path(job_id); + let pid = launch_subscription_worker([ + "subscription-update-worker", + state_file, + output_file, + as_string(section), + as_string(source_index) + ]); + + if (pid == "" || !set_subscription_running_job_pid(state_file, pid)) { + if (pid != "") + command_success_from_args([ "kill", pid ]); + subscription_job_json_response(false, "", "Failed to write subscription update worker pid"); + exit(1); + } + + subscription_job_json_response(true, job_id, "Subscription update started"); +} + +function subscription_update_status(job_id) { + ensure_dir(SUBSCRIPTION_JOB_DIR); + subscription_cleanup_jobs(); + + let state_file = subscription_job_state_path_value(SUBSCRIPTION_JOB_DIR, job_id); + if (state_file == "") + subscription_status_error_exit("Invalid subscription update job id"); + + if (fs.stat(state_file) == null) + subscription_status_error_exit("Subscription update job was not found"); + + refresh_subscription_running_job_state(state_file); + print(as_string(fs.readfile(state_file))); +} + +function component_job_json_response(success, job_id, message) { + write_json({ + success: arg_bool(success), + job_id: as_string(job_id), + message: as_string(message) + }); +} + +function component_action_status_error(message) { + write_json({ + success: false, + running: false, + kind: "component", + component: "unknown", + action: "status", + message: as_string(message), + current_version: "", + latest_version: "", + changed: 0, + status: "", + exit_code: null + }); +} + +function component_action_status_error_exit(message) { + component_action_status_error(message); + exit(1); +} + +function component_running_job_state_value(component, action, started_at) { + return { + success: true, + running: true, + kind: "component", + component: as_string(component), + action: as_string(action), + message: "Component action is running", + pid: null, + started_at: arg_number(started_at), + updated_at: null, + current_version: "", + latest_version: "", + changed: 0, + status: "", + exit_code: null + }; +} + +function normalize_component_name(component) { + component = as_string(component); + if (component == "sing-box" || component == "singbox") + return "sing_box"; + if (component == "tachyon") + return "tachyon"; + return component; +} + +function valid_component_name(component) { + component = normalize_component_name(component); + return component == "tachyon" || component == "sing_box" || component == "zapret" || + component == "zapret2" || component == "byedpi"; +} + +function component_update_check_cache_path(component) { + component = normalize_component_name(component); + if (!valid_component_name(component)) + return ""; + return COMPONENT_UPDATE_CHECK_CACHE_DIR + "/" + component + ".json"; +} + +function component_update_check_cache_enabled() { + return settings_component_update_check_interval(uci_settings()) != ""; +} + +function component_update_check_result_cacheable(value) { + value = object_or_empty(value); + let status = as_string(value.status || ""); + return value.success === true && as_string(value.action) == "check_update" && + valid_component_name(value.component) && + (status == "latest" || status == "outdated" || status == "dev"); +} + +function cache_component_update_check_result(value, notify_update) { + value = object_or_empty(value); + if (!component_update_check_result_cacheable(value)) + return false; + + let component = normalize_component_name(value.component); + let path = component_update_check_cache_path(component); + if (path == "" || !ensure_dir(COMPONENT_UPDATE_CHECK_CACHE_DIR)) + return false; + + let previous = object_or_empty(read_json_file(path)); + let cached = { + success: true, + component, + action: "check_update", + message: as_string(value.message), + current_version: as_string(value.current_version), + latest_version: as_string(value.latest_version), + release_url: as_string(value.release_url), + changed: 0, + status: as_string(value.status), + updated_at: now_seconds() + }; + if (!write_state_file(path, cached)) + return false; + + if (arg_bool(notify_update) && cached.status == "outdated" && + (as_string(previous.status) != "outdated" || + as_string(previous.latest_version) != cached.latest_version)) { + log_message("[component-update] " + component + " " + cached.latest_version, "info"); + } + + return true; +} + +function update_component_check_cache_from_action(value) { + value = object_or_empty(value); + let component = normalize_component_name(value.component); + let action = as_string(value.action); + let path = component_update_check_cache_path(component); + if (path == "") + return; + + if (action == "remove" && value.success === true) { + remove_file(path); + return; + } + + if (!component_update_check_cache_enabled()) + return; + + if (action == "check_update") { + cache_component_update_check_result(value, false); + return; + } + + if (value.success === true && (action == "install" || match(action, /^install_/) != null)) { + let latest_version = as_string(value.latest_version); + if (latest_version == "") + latest_version = as_string(value.current_version); + cache_component_update_check_result({ + success: true, + component, + action: "check_update", + message: "Latest version is installed", + current_version: as_string(value.current_version), + latest_version, + release_url: as_string(value.release_url), + status: "latest" + }, false); + } +} + +function clear_component_update_check_cache() { + for (let path in fs.glob(COMPONENT_UPDATE_CHECK_CACHE_DIR + "/*.json")) + remove_file(path); + remove_file(COMPONENT_UPDATE_CHECK_STATE_FILE); +} + +function component_update_check_cache() { + let enabled = component_update_check_cache_enabled(); + let results = []; + + if (enabled) { + for (let component in [ "tachyon", "sing_box", "zapret", "zapret2", "byedpi" ]) { + let value = read_json_file(component_update_check_cache_path(component)); + if (component_update_check_result_cacheable(value)) + push(results, value); + } + } + + write_json({ enabled, results }); +} + +function valid_component_job_id(job_id) { + job_id = as_string(job_id); + return job_id != "" && job_id != "." && job_id != ".." && match(job_id, /[^A-Za-z0-9._-]/) == null; +} + +function component_job_state_path_value(job_id) { + if (!valid_component_job_id(job_id)) + return ""; + return COMPONENT_JOB_DIR + "/" + as_string(job_id) + ".json"; +} + +function component_job_id() { + let stamp = clock(); + return sprintf("%d-%d", stamp[0], stamp[1]); +} + +function component_job_output_path(job_id) { + return COMPONENT_JOB_DIR + "/" + as_string(job_id) + ".out"; +} + +function component_job_output_path_from_state(path) { + path = as_string(path); + if (length(path) >= 5 && substr(path, length(path) - 5) == ".json") + return substr(path, 0, length(path) - 5) + ".out"; + return path + ".out"; +} + +function remove_component_job_state(path) { + let output_path = component_job_output_path_from_state(path); + remove_file(path); + remove_file(output_path); + remove_file(output_path + ".json"); +} + +function component_job_running_is(path, expected) { + let value = read_json_file(path); + let running = type(value) == "object" && value.running === true; + return running == arg_bool(expected); +} + +function ensure_component_runtime_dirs() { + return ensure_dir(COMPONENT_JOB_DIR); +} + +function write_component_stale_job_state(path) { + let value = object_or_empty(read_json_file(path)); + value.success = false; + value.running = false; + value.kind = "component"; + value.message = "Component action job is stale or the worker process exited unexpectedly"; + value.changed = 0; + value.status = ""; + value.exit_code = null; + value.updated_at = now_seconds(); + return write_state_file(path, value); +} + +function refresh_component_running_job_state(path) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) + return; + + let now = now_seconds(); + let within_grace = job_started_at_within_grace(value.started_at, now, COMPONENT_JOB_STALE_GRACE_SECONDS); + let pid = as_string(value.pid || ""); + + if (!job_pid_valid(pid)) { + if (!within_grace) + write_component_stale_job_state(path); + return; + } + + if (pid_running(pid) || within_grace) + return; + + command_success_from_args([ "sleep", "1" ]); + value = read_json_file(path); + if (type(value) != "object" || value.running !== true) + return; + if (pid_running(pid)) + return; + + write_component_stale_job_state(path); +} + +function path_basename_without_suffix(path, suffix) { + let parts = split(as_string(path), "/"); + let name = length(parts) > 0 ? as_string(parts[length(parts) - 1]) : ""; + suffix = as_string(suffix); + if (length(name) >= length(suffix) && substr(name, length(name) - length(suffix)) == suffix) + return substr(name, 0, length(name) - length(suffix)); + return name; +} + +function component_cleanup_jobs() { + ensure_dir(COMPONENT_JOB_DIR); + + for (let path in fs.glob(COMPONENT_JOB_DIR + "/*.json")) + refresh_component_running_job_state(path); + + for (let output_path in fs.glob(COMPONENT_JOB_DIR + "/*.out")) { + let state_file = component_job_state_path_value(path_basename_without_suffix(output_path, ".out")); + if (state_file == "" || fs.stat(state_file) == null || !component_job_running_is(state_file, true)) { + remove_file(output_path); + remove_file(output_path + ".json"); + } + } + + command_success_from_args([ + "find", + COMPONENT_JOB_DIR, + "-type", "f", + "-name", "*.out", + "-mmin", "+" + as_string(COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES), + "-delete" + ]); + command_success_from_args([ + "find", + COMPONENT_JOB_DIR, + "-type", "f", + "-name", "*.out.json", + "-mmin", "+" + as_string(COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES), + "-delete" + ]); + command_success_from_args([ + "find", + COMPONENT_JOB_DIR, + "-type", "f", + "-name", "*.json.*", + "-mmin", "+10", + "-delete" + ]); + + let old = command_output_from_args([ + "find", + COMPONENT_JOB_DIR, + "-type", "f", + "-name", "*.json", + "-mmin", "+" + as_string(COMPONENT_JOB_FINISHED_TTL_MINUTES) + ]); + + for (let path in split(old, "\n")) { + path = trim(as_string(path)); + if (path != "" && component_job_running_is(path, false)) + remove_component_job_state(path); + } +} + +function set_component_running_job_pid(path, pid) { + pid = as_string(pid); + if (!job_pid_valid(pid)) + return false; + + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.pid = pid; + return write_state_file(path, value); + } + + return false; +} + +function output_json_object(path) { + let data = as_string(fs.readfile(path) || ""); + + try { + let value = json(data); + if (type(value) == "object") + return value; + } + catch (e) { + } + + let result = null; + for (let line in split(data, "\n")) { + line = trim(as_string(line)); + let start = index(line, "{"); + if (start < 0) + continue; + try { + let value = json(substr(line, start)); + if (type(value) == "object") + result = value; + } + catch (e) { + } + } + + return result; +} + +function component_fallback_job_state(component, action, message, exit_code, updated_at) { + return { + success: false, + running: false, + kind: "component", + component: as_string(component), + action: as_string(action), + message: as_string(message), + current_version: "", + latest_version: "", + changed: 0, + status: "", + exit_code: arg_number(exit_code), + updated_at: arg_number(updated_at) + }; +} + +function finish_component_job(path, component, action, exit_code, output_file) { + let updated_at = now_seconds(); + let value = output_json_object(output_file); + let ok; + + if (type(value) == "object") { + value.running = false; + value.kind = "component"; + value.exit_code = arg_number(exit_code); + value.updated_at = updated_at; + update_component_check_cache_from_action(value); + ok = write_state_file(path, value); + } + else { + let raw_output = file_last_nonblank_line_value(output_file, "Failed to execute", 240); + ok = write_state_file(path, component_fallback_job_state(component, action, raw_output, exit_code, updated_at)); + } + + remove_file(output_file); + remove_file(output_file + ".json"); + return ok; +} + +function component_worker_env() { + return { + TACHYON_CONFIG_NAME: CONFIG_NAME, + TACHYON_LIB: LIB_DIR, + TACHYON_BIN: BIN_PATH, + TACHYON_SERVICE_INIT: SERVICE_INIT, + UPDATES_JOB_DIR: COMPONENT_JOB_DIR, + UPDATES_JOB_FINISHED_TTL_MINUTES: COMPONENT_JOB_FINISHED_TTL_MINUTES, + UPDATES_JOB_ORPHAN_OUTPUT_TTL_MINUTES: COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES, + UPDATES_JOB_STALE_GRACE_SECONDS: COMPONENT_JOB_STALE_GRACE_SECONDS, + TACHYON_UI_COMPONENT_ACTION_TRACKED: "1" + }; +} + +function launch_component_worker(args) { + let command_args = [ "ucode", "-L", LIB_DIR, LIB_DIR + "/components/updates.uc" ]; + for (let arg in args) + push(command_args, arg); + + let command = command_env(component_worker_env()) + " " + + command_from_args(command_args) + + " >/dev/null 2>&1 1000>&- & echo $!"; + return trim(command_output("sh -c " + shell_quote(command))); +} + +function component_action_worker(state_file, output_file, component, action) { + component = normalize_component_name(component); + let command = command_env(component_worker_env()) + " " + + command_from_args([ + "ucode", + "-L", LIB_DIR, + LIB_DIR + "/components/action.uc", + "component-action", + as_string(component), + as_string(action) + ]) + " >" + shell_quote(output_file) + " 2>&1"; + let status = command_status(command); + + finish_component_job(state_file, component, action, status, output_file); +} + +function component_action_async(component, action) { + component = normalize_component_name(component); + if (!ensure_component_runtime_dirs()) { + component_job_json_response(false, "", "Failed to create component action state directory"); + exit(1); + } + + component_cleanup_jobs(); + + let job_id = component_job_id(); + let state_file = component_job_state_path_value(job_id); + if (state_file == "") { + component_job_json_response(false, "", "Failed to prepare component action job"); + exit(1); + } + + if (!write_state_file(state_file, component_running_job_state_value(component, action, now_seconds()))) { + component_job_json_response(false, "", "Failed to write component action state"); + exit(1); + } + + let output_file = component_job_output_path(job_id); + let pid = launch_component_worker([ + "component-action-worker", + state_file, + output_file, + as_string(component), + as_string(action) + ]); + + if (pid == "" || !set_component_running_job_pid(state_file, pid)) { + if (pid != "") + command_success_from_args([ "kill", pid ]); + component_job_json_response(false, "", "Failed to write component action worker pid"); + exit(1); + } + + component_job_json_response(true, job_id, "Component action started"); +} + +function component_action_status(job_id) { + ensure_dir(COMPONENT_JOB_DIR); + component_cleanup_jobs(); + + let state_file = component_job_state_path_value(job_id); + if (state_file == "") + component_action_status_error_exit("Invalid component action job id"); + + if (fs.stat(state_file) == null) + component_action_status_error_exit("Component action job was not found"); + + refresh_component_running_job_state(state_file); + print(as_string(fs.readfile(state_file))); +} + +function automatic_component_check_names() { + let result = [ "tachyon" ]; + + if (fs.stat("/usr/bin/sing-box") != null) + push(result, "sing_box"); + if (module_success([ LIB_DIR + "/providers/zapret/runtime.uc", "installed" ])) + push(result, "zapret"); + if (module_success([ LIB_DIR + "/providers/zapret2/runtime.uc", "installed" ])) + push(result, "zapret2"); + if (module_success([ LIB_DIR + "/providers/byedpi/runtime.uc", "installed" ])) + push(result, "byedpi"); + + return result; +} + +function run_automatic_component_update_check(component) { + let output_file = temp_path(); + if (output_file == "") + return false; + + let command = command_env(component_worker_env()) + " " + + command_from_args([ + "ucode", + "-L", LIB_DIR, + LIB_DIR + "/components/action.uc", + "component-action", + as_string(component), + "check_update" + ]) + " >" + shell_quote(output_file) + " 2>&1"; + let status = command_status(command); + let value = output_json_object(output_file); + remove_file(output_file); + + if (status != 0 || !component_update_check_result_cacheable(value)) + return false; + + return cache_component_update_check_result(value, true); +} + +function component_updates_if_due() { + let interval = settings_component_update_check_interval(uci_settings()); + if (interval == "") + exit(0); + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + log_message("Invalid component_update_check_interval value: " + interval, "error"); + exit(1); + } + + let due = update_due_status( + now_seconds(), + file_first_line_value(COMPONENT_UPDATE_CHECK_STATE_FILE), + seconds + ); + if (due == 1) + exit(0); + if (due != 0) + exit(1); + + if (!acquire_runtime_lock(COMPONENT_UPDATE_CHECK_LOCK_DIR, false)) + exit(0); + + due = update_due_status( + now_seconds(), + file_first_line_value(COMPONENT_UPDATE_CHECK_STATE_FILE), + seconds + ); + if (due != 0) { + release_runtime_lock(COMPONENT_UPDATE_CHECK_LOCK_DIR); + exit(due == 1 ? 0 : 1); + } + + ensure_dir(COMPONENT_UPDATE_CHECK_CACHE_DIR); + for (let component in automatic_component_check_names()) + run_automatic_component_update_check(component); + + if (!write_file(COMPONENT_UPDATE_CHECK_STATE_FILE, as_string(now_seconds()) + "\n")) { + release_runtime_lock(COMPONENT_UPDATE_CHECK_LOCK_DIR); + log_message("Failed to write component update check timestamp", "error"); + exit(1); + } + release_runtime_lock(COMPONENT_UPDATE_CHECK_LOCK_DIR); +} + +function download_via_proxy_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy"; + if (purpose == "components") + return "download_components_via_proxy"; + return ""; +} + +function download_via_proxy_section_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy_section"; + if (purpose == "components") + return "download_components_via_proxy_section"; + return ""; +} + +function download_via_proxy_section(settings, purpose) { + let enabled_option = download_via_proxy_option_for_purpose(purpose); + if (enabled_option == "" || !bool_option(settings, enabled_option, false)) + return ""; + + let section_option = download_via_proxy_section_option_for_purpose(purpose); + let configured = section_option != "" ? option(settings, section_option, "") : ""; + if (configured != "") + return configured; + + return option(settings, "download_lists_via_proxy_section", ""); +} + +function service_proxy_port_for_purpose(purpose) { + return int(SB_SERVICE_MIXED_INBOUND_PORT) + (as_string(purpose || "lists") == "components" ? 1 : 0); +} + +function service_proxy_address(settings, purpose) { + return download_via_proxy_section(settings, purpose) != "" ? + SB_SERVICE_MIXED_INBOUND_ADDRESS + ":" + service_proxy_port_for_purpose(purpose) : ""; +} + +function download_to_file(url, filepath, proxy_address) { + let attempt = 1; + while (attempt <= 3) { + let command = command_from_args([ "wget", "-O", filepath, url ]); + if (as_string(proxy_address) != "") + command = "http_proxy=" + shell_quote("http://" + as_string(proxy_address)) + + " https_proxy=" + shell_quote("http://" + as_string(proxy_address)) + " " + command; + + if (command_success(command)) + return true; + + log_message("Attempt " + attempt + "/3 to download " + as_string(url) + " failed", "warn"); + command_success_from_args([ "sleep", "2" ]); + attempt++; + } + + return false; +} + +function convert_crlf_to_lf(path) { + let data = fs.readfile(as_string(path)); + if (data == null || index(data, "\r") < 0) + return; + + log_message("Converting CRLF line endings to LF in " + as_string(path), "debug"); + write_file(path, replace(data, /\r/g, "")); +} + +function ruleset_module_success(args) { + let command_args = [ LIB_DIR + "/routing/rulesets.uc" ]; + for (let arg in args) + push(command_args, arg); + return module_success(command_args); +} + +function domain_ip_list_ruleset_path(section) { + return TMP_RULESET_FOLDER + "/" + routing_rulesets_module().ruleset_tag(section_name(section), "lists", "") + ".json"; +} + +function remote_ruleset_path(section, kind) { + return TMP_RULESET_FOLDER + "/" + routing_rulesets_module().ruleset_tag(section_name(section), "remote", kind) + ".json"; +} + +function reset_domain_ip_list_ruleset(section) { + let path = domain_ip_list_ruleset_path(section); + ensure_dir(TMP_RULESET_FOLDER); + remove_file(path); + return ruleset_module_success([ "create-source", path ]); +} + +function ensure_ruleset_source(path) { + ensure_dir(TMP_RULESET_FOLDER); + if (file_exists_value(path)) + return true; + return ruleset_module_success([ "create-source", path ]); +} + +function cleanup_empty_ruleset(path) { + if (routing_rulesets_module().has_rules(path)) + return true; + remove_file(path); + return false; +} + +function add_plain_subnet_file_to_nft_for_section(section, filepath) { + if (!file_nonempty(filepath)) + return true; + + return nft_module_success([ + "nft-add-subnet-file-for-uci-section", + section_name(section), + filepath, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_IP_PORT_SET_NAME, + "5000", + NFT_COMMON6_SET_NAME, + NFT_IP_PORT6_SET_NAME + ]); +} + +function add_json_ruleset_subnets_to_nft_for_section(section, json_file, label) { + let unscoped_tmpfile = temp_path(); + let scoped_tmpfile = temp_path(); + if (unscoped_tmpfile == "" || scoped_tmpfile == "") { + remove_files([ unscoped_tmpfile, scoped_tmpfile ]); + return false; + } + + let ok = nft_module_success([ + "nft-add-json-ruleset-subnets-for-uci-section", + section_name(section), + json_file, + label, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_IP_PORT_SET_NAME, + unscoped_tmpfile, + scoped_tmpfile, + "5000", + NFT_COMMON6_SET_NAME, + NFT_IP_PORT6_SET_NAME + ]); + remove_files([ unscoped_tmpfile, scoped_tmpfile ]); + return ok; +} + +function import_domain_ip_list_file_into_rulesets(filepath, section) { + if (!file_exists_value(filepath)) + return true; + + let domains_tmpfile = temp_path(); + let subnets_tmpfile = temp_path(); + if (domains_tmpfile == "" || subnets_tmpfile == "") { + remove_files([ domains_tmpfile, subnets_tmpfile ]); + return false; + } + + let ruleset_filepath = domain_ip_list_ruleset_path(section); + let ok = nft_module_success([ "split-domain-subnet-file", filepath, domains_tmpfile, subnets_tmpfile ]); + let domains_only = option(section, "action", "") == "dns"; + if (ok) + ok = ruleset_module_success([ "import-plain-list", domains_tmpfile, ruleset_filepath, "domain_suffix", "domains", "5000" ]); + if (ok && !domains_only) + ok = ruleset_module_success([ "import-plain-list", subnets_tmpfile, ruleset_filepath, "ip_cidr", "subnets", "5000" ]); + if (ok && !domains_only) + ok = add_plain_subnet_file_to_nft_for_section(section, subnets_tmpfile); + + remove_files([ domains_tmpfile, subnets_tmpfile ]); + return ok; +} + +function import_domain_ip_list_reference_into_rulesets(reference, section, settings) { + reference = as_string(reference); + if (match(reference, /^https?:\/\//) == null) + return import_domain_ip_list_file_into_rulesets(reference, section); + + let tmpfile = temp_path(); + if (tmpfile == "") + return false; + + let ok = true; + if (download_to_file(reference, tmpfile, service_proxy_address(settings, "lists")) && file_nonempty(tmpfile)) { + convert_crlf_to_lf(tmpfile); + ok = import_domain_ip_list_file_into_rulesets(tmpfile, section); + } + else { + log_message("Failed to download remote domain/IP list " + reference + "; skipping it until the next successful update", "warn"); + } + + remove_file(tmpfile); + return ok; +} + +function rebuild_domain_ip_lists_from_rule(section, settings) { + if (!bool_option(section, "enabled", true)) + return true; + + let references = list_option_values(section, "domain_ip_lists"); + if (length(references) == 0) + return true; + + if (!reset_domain_ip_list_ruleset(section)) + return false; + + let ok = true; + for (let reference in references) + if (!import_domain_ip_list_reference_into_rulesets(reference, section, settings)) + ok = false; + + cleanup_empty_ruleset(domain_ip_list_ruleset_path(section)); + return ok; +} + +function import_builtin_subnets_from_rule(section, settings) { + if (!bool_option(section, "enabled", true)) + return true; + if (option(section, "action", "") == "dns") + return true; + + let ok = true; + for (let service in connections.community_lists(section)) { + if (!singbox_rulesets_module().is_community(service)) + continue; + + let url = BUILTIN_SUBNET_URLS[as_string(service)]; + if (url == null) + continue; + + let tmpfile = temp_path(); + if (tmpfile == "") { + ok = false; + continue; + } + + if (!download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(tmpfile)) { + log_message("Failed to download built-in " + as_string(service) + " subnet list; skipping it until the next successful update", "warn"); + remove_file(tmpfile); + continue; + } + + if (!nft_module_success([ + "nft-add-community-subnet-file-for-uci-section", + section_name(section), + service, + tmpfile, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_DISCORD_SET_NAME, + NFT_FAKEIP_MARK, + "5000", + NFT_COMMON6_SET_NAME, + NFT_IP_PORT6_SET_NAME, + NFT_DISCORD6_SET_NAME + ])) + ok = false; + + remove_file(tmpfile); + } + + return ok; +} + +function import_custom_ruleset_subnets_from_local(path, format, section, label) { + if (!file_exists_value(path)) { + log_message("Local rule set file " + as_string(path) + " not found", "error"); + return false; + } + + let json_tmpfile = temp_path(); + if (json_tmpfile == "") + return false; + + let ok = true; + if (as_string(format) == "binary") { + if (!command_success_from_args([ "sing-box", "rule-set", "decompile", path, "-o", json_tmpfile ])) { + log_message("Failed to decompile rule set " + as_string(path), "error"); + ok = false; + } + } + else if (!copy_file(path, json_tmpfile)) { + log_message("Failed to copy source rule set file " + as_string(path), "error"); + ok = false; + } + + if (ok && !add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, label)) + ok = false; + + remove_file(json_tmpfile); + return ok; +} + +function import_custom_ruleset_subnets_from_remote(url, format, section, label, settings) { + let remote_tmpfile = temp_path(); + let json_tmpfile = temp_path(); + if (remote_tmpfile == "" || json_tmpfile == "") { + remove_files([ remote_tmpfile, json_tmpfile ]); + return false; + } + + if (!download_to_file(url, remote_tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(remote_tmpfile)) { + log_message("Failed to download remote rule set " + as_string(url) + "; skipping it until the next successful update", "warn"); + remove_files([ remote_tmpfile, json_tmpfile ]); + return true; + } + + let ok = true; + if (as_string(format) == "binary") { + if (!command_success_from_args([ "sing-box", "rule-set", "decompile", remote_tmpfile, "-o", json_tmpfile ])) { + log_message("Failed to decompile rule set " + as_string(url), "error"); + ok = false; + } + } + else if (!copy_file(remote_tmpfile, json_tmpfile)) { + log_message("Failed to copy downloaded source rule set " + as_string(url), "error"); + ok = false; + } + + if (ok && !add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, label)) + ok = false; + + remove_files([ remote_tmpfile, json_tmpfile ]); + return ok; +} + +function import_rule_sets_with_subnets_from_rule(section, settings) { + if (!bool_option(section, "enabled", true)) + return true; + if (option(section, "action", "") == "dns") + return true; + + let references = connections.rule_sets_with_subnets(section); + if (length(references) == 0) + return true; + + log_message("Importing subnets from rule sets with subnets for '" + section_name(section) + "' section", "info"); + let ok = true; + + for (let reference in references) { + reference = as_string(reference); + log_message("Importing subnets from rule set reference for '" + section_name(section) + "': " + reference, "info"); + + let extension = singbox_rulesets_module().file_extension(reference); + if (match(reference, /^\/.*\.srs$/) != null) { + if (!import_custom_ruleset_subnets_from_local(reference, "binary", section, "Rule set " + reference)) + ok = false; + } + else if (match(reference, /^\/.*\.json$/) != null) { + if (!import_custom_ruleset_subnets_from_local(reference, "source", section, "Rule set " + reference)) + ok = false; + } + else if (match(reference, /^https?:\/\//) != null) { + let format = extension == "json" ? "source" : (extension == "srs" ? "binary" : singbox_rulesets_module().remote_format(reference)); + if (!import_custom_ruleset_subnets_from_remote(reference, format, section, "Rule set " + reference, settings)) + ok = false; + } + else { + log_message("Unsupported rule set reference for subnet import: " + reference, "error"); + ok = false; + } + } + + return ok; +} + +function import_domains_from_remote_plain_file(url, section, settings) { + let tmpfile = temp_path(); + if (tmpfile == "") + return false; + + if (!download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(tmpfile)) { + log_message("Failed to download remote domain list " + as_string(url) + "; skipping it until the next successful update", "warn"); + remove_file(tmpfile); + return true; + } + + convert_crlf_to_lf(tmpfile); + let ruleset_path = remote_ruleset_path(section, "domains"); + let ok = ensure_ruleset_source(ruleset_path) && + ruleset_module_success([ "import-plain-list", tmpfile, ruleset_path, "domain_suffix", "domains", "5000" ]); + remove_file(tmpfile); + return ok; +} + +function import_domains_from_remote_domain_lists(section, settings) { + if (!bool_option(section, "enabled", true)) + return true; + + let references = list_option_values(section, "remote_domain_lists"); + if (length(references) == 0) + return true; + + log_message("Importing domains from remote domain lists for '" + section_name(section) + "' section", "info"); + let ok = true; + for (let url in references) { + log_message("Importing domains from URL: " + as_string(url), "info"); + let extension = singbox_rulesets_module().file_extension(url); + log_message("Detected file extension: '" + extension + "'", "debug"); + if (extension == "json" || extension == "srs") { + log_message("No update needed - sing-box manages updates automatically.", "info"); + continue; + } + log_message("Import domains from a remote plain-text list", "info"); + if (!import_domains_from_remote_plain_file(url, section, settings)) + ok = false; + } + return ok; +} + +function import_subnets_from_remote_json_file(url, section, settings) { + let json_tmpfile = temp_path(); + if (json_tmpfile == "") + return false; + + if (!download_to_file(url, json_tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(json_tmpfile)) { + log_message("Failed to download remote JSON subnet list " + as_string(url) + "; skipping it until the next successful update", "warn"); + remove_file(json_tmpfile); + return true; + } + + let ok = add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, "Remote JSON rule set " + as_string(url)); + if (!ok) + log_message("Failed to add subnets from remote JSON list " + as_string(url) + " to nftables", "error"); + remove_file(json_tmpfile); + return ok; +} + +function import_subnets_from_remote_srs_file(url, section, settings) { + let binary_tmpfile = temp_path(); + let json_tmpfile = temp_path(); + if (binary_tmpfile == "" || json_tmpfile == "") { + remove_files([ binary_tmpfile, json_tmpfile ]); + return false; + } + + if (!download_to_file(url, binary_tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(binary_tmpfile)) { + log_message("Failed to download remote SRS subnet list " + as_string(url) + "; skipping it until the next successful update", "warn"); + remove_files([ binary_tmpfile, json_tmpfile ]); + return true; + } + + let ok = command_success_from_args([ "sing-box", "rule-set", "decompile", binary_tmpfile, "-o", json_tmpfile ]); + if (!ok) + log_message("Failed to decompile binary rule set file", "error"); + if (ok && !add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, "Remote SRS rule set " + as_string(url))) { + log_message("Failed to add subnets from remote SRS list " + as_string(url) + " to nftables", "error"); + ok = false; + } + + remove_files([ binary_tmpfile, json_tmpfile ]); + return ok; +} + +function import_subnets_from_remote_plain_file(url, section, settings) { + let tmpfile = temp_path(); + if (tmpfile == "") + return false; + + if (!download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(tmpfile)) { + log_message("Failed to download remote plain subnet list " + as_string(url) + "; skipping it until the next successful update", "warn"); + remove_file(tmpfile); + return true; + } + + convert_crlf_to_lf(tmpfile); + let ruleset_path = remote_ruleset_path(section, "subnets"); + let ok = ensure_ruleset_source(ruleset_path) && + ruleset_module_success([ "import-plain-list", tmpfile, ruleset_path, "ip_cidr", "subnets", "5000" ]); + if (ok) + ok = add_plain_subnet_file_to_nft_for_section(section, tmpfile); + remove_file(tmpfile); + return ok; +} + +function import_subnets_from_remote_subnet_lists(section, settings) { + if (!bool_option(section, "enabled", true)) + return true; + + let references = list_option_values(section, "remote_subnet_lists"); + if (length(references) == 0) + return true; + + log_message("Importing subnets from remote subnet lists for '" + section_name(section) + "' section", "info"); + let ok = true; + for (let url in references) { + log_message("Importing subnets from URL: " + as_string(url), "info"); + let extension = singbox_rulesets_module().file_extension(url); + log_message("Detected file extension: '" + extension + "'", "debug"); + if (extension == "json") { + log_message("Import subnets from a remote JSON list", "info"); + if (!import_subnets_from_remote_json_file(url, section, settings)) + ok = false; + } + else if (extension == "srs") { + log_message("Import subnets from a remote SRS list", "info"); + if (!import_subnets_from_remote_srs_file(url, section, settings)) + ok = false; + } + else { + log_message("Import subnets from a remote plain-text list", "info"); + if (!import_subnets_from_remote_plain_file(url, section, settings)) + ok = false; + } + } + return ok; +} + +function list_update_pid_begin() { + let existing_pid = trim(as_string(fs.readfile(LIST_UPDATE_PID_FILE) || "")); + let current_pid = owner_pid(); + if (existing_pid != "" && existing_pid != current_pid && runtime_pid_running(existing_pid)) { + log_message("Another lists update is already running, skipping", "info"); + return false; + } + + ensure_parent_dir(LIST_UPDATE_PID_FILE); + write_file(LIST_UPDATE_PID_FILE, current_pid + "\n"); + return true; +} + +function list_update_pid_end() { + remove_file(LIST_UPDATE_PID_FILE); +} + +function dns_probe_passed(proxy_address) { + if (as_string(proxy_address) != "") { + log_message("DNS check skipped because list downloads use service proxy", "info"); + return true; + } + + let attempt = 1; + while (attempt <= 10) { + let output = command_output_from_args([ "dig", "+short", "openwrt.org", "A", "+timeout=3", "+tries=1" ]); + for (let line in split(output, "\n")) { + if (match(as_string(line), /^[0-9]+\./) != null) { + log_message("DNS check passed", "info"); + return true; + } + } + + log_message("DNS is unavailable [" + attempt + "/10]", "info"); + command_success_from_args([ "sleep", "3" ]); + attempt++; + } + + log_message("DNS check failed after 10 attempts; skipping remote lists update until the next attempt", "info"); + return false; +} + +function github_probe(proxy_address) { + let attempt = 1; + let timeout = 5; + while (attempt <= 10) { + let args = [ "curl", "-s", "-m", "" + timeout ]; + if (as_string(proxy_address) != "") { + push(args, "-x"); + push(args, "http://" + as_string(proxy_address)); + } + push(args, "https://github.com"); + + if (command_success_from_args(args)) { + log_message(as_string(proxy_address) != "" ? "GitHub connection check passed (via proxy)" : "GitHub connection check passed", "info"); + return true; + } + + log_message("GitHub is unavailable [" + attempt + "/10] (max-timeout=" + timeout + ")", "info"); + if (timeout < 10) + timeout++; + command_success_from_args([ "sleep", "3" ]); + attempt++; + } + + log_message("GitHub connection check failed after 10 attempts; trying configured list downloads individually", "info"); + return false; +} + +function write_list_update_timestamp(timestamp) { + ensure_dir(RUNTIME_STATE_DIR); + write_file(LIST_UPDATE_STATE_FILE, as_string(timestamp) + "\n"); +} + +function list_update() { + log_message("Starting lists update", "info"); + if (!list_update_pid_begin()) + exit(0); + + let settings = uci_settings(); + let proxy_address = service_proxy_address(settings, "lists"); + if (!dns_probe_passed(proxy_address)) { + list_update_pid_end(); + exit(0); + } + github_probe(proxy_address); + + log_message("Downloading and processing lists", "info"); + let sections = uci_sections("section"); + let ok = true; + + for (let section in sections) + if (!rebuild_domain_ip_lists_from_rule(section, settings)) + ok = false; + for (let section in sections) + if (!import_builtin_subnets_from_rule(section, settings)) + ok = false; + for (let section in sections) + if (!import_domains_from_remote_domain_lists(section, settings)) + ok = false; + for (let section in sections) + if (!import_subnets_from_remote_subnet_lists(section, settings)) + ok = false; + for (let section in sections) + if (!import_rule_sets_with_subnets_from_rule(section, settings)) + ok = false; + + if (ok) { + write_list_update_timestamp(now_seconds()); + log_message("Lists update completed successfully", "info"); + } + else { + log_message("Lists update failed", "info"); + } + + list_update_pid_end(); + exit(ok ? 0 : 1); +} + +function list_update_if_due() { + let interval = settings_update_interval(uci_settings()); + if (interval == "") + exit(1); + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + log_message("Invalid update_interval value: " + interval, "error"); + exit(1); + } + + let status = update_due_status(now_seconds(), file_first_line_value(LIST_UPDATE_STATE_FILE), seconds); + if (status == 0) + list_update(); + if (status == 1) + exit(0); + + exit(1); +} + +function stop_list_update() { + let pid = trim(as_string(fs.readfile(LIST_UPDATE_PID_FILE) || "")); + if (pid != "" && runtime_pid_running(pid)) { + command_success_from_args([ "kill", pid ]); + log_message("Stopped list_update", "info"); + } + remove_file(LIST_UPDATE_PID_FILE); +} + +function subscription_cache_env() { + return { + TACHYON_CONFIG_NAME: CONFIG_NAME, + TACHYON_LIB: LIB_DIR, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER, + TACHYON_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, + TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, + TACHYON_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, + TACHYON_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, + TACHYON_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, + TACHYON_SECTION_CACHE_DIR: SECTION_CACHE_DIR, + TACHYON_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, + TACHYON_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT, + TACHYON_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE, + TACHYON_SERVICE_INIT: SERVICE_INIT, + SB_SERVICE_MIXED_INBOUND_ADDRESS, + SB_SERVICE_MIXED_INBOUND_PORT, + SB_VARIANT_STATE_FILE + }; +} + +function module_env_capture(env, args) { + let output_path = temp_path(); + if (output_path == "") + return { status: 1, output: "" }; + + let status = command_status(command_env(env) + " " + module_command(args) + " >" + shell_quote(output_path) + " 2>&1"); + let output = as_string(fs.readfile(output_path) || ""); + remove_file(output_path); + return { status, output }; +} + +function subscription_cache_capture(args) { + let command_args = [ LIB_DIR + "/subscription/cache.uc" ]; + for (let arg in args) + push(command_args, arg); + return module_env_capture(subscription_cache_env(), command_args); +} + +function subscription_cache_success(args) { + let result = subscription_cache_capture(args); + if (result.output != "") + for (let line in split(result.output, "\n")) + if (trim(as_string(line)) != "") + log_message("subscription cache: " + line, "debug"); + return result.status == 0; +} + +function log_file_lines_from_text(text, level, prefix) { + for (let line in split(as_string(text), "\n")) + if (trim(as_string(line)) != "") + log_message(as_string(prefix) + as_string(line), level); +} + +function singbox_runtime_success(args) { + let command_args = [ LIB_DIR + "/singbox/runtime.uc" ]; + for (let arg in args) + push(command_args, arg); + + let result = module_env_capture(subscription_cache_env(), command_args); + if (result.output != "") + log_file_lines_from_text(result.output, "debug", "sing-box runtime: "); + return result.status == 0; +} + +function write_current_reload_state_clean() { + return service_state_success([ + "write-current-reload-state-clean", + RELOAD_STATE_FILE, + RELOAD_STATE_FORMAT, + RULE_CONDITION_CACHE_DIR + ]); +} + +function mark_pending_reload(reason) { + service_state_success([ "mark-pending-reload", PENDING_RELOAD_FILE, reason ]); +} + +function run_pending_reload_if_requested() { + service_state_success([ "run-pending-reload-if-requested", PENDING_RELOAD_FILE, SERVICE_INIT ]); +} + +function subscription_update_common_locked(force, target_section, target_source_index) { + let result = subscription_cache_capture([ + "update-request", + force ? "1" : "0", + as_string(target_section), + as_string(target_source_index) + ]); + if (result.status != 0) { + log_file_lines_from_text(result.output, "error", "subscription update: "); + return false; + } + + let fields = split(trim(result.output), /[ \t\r\n]+/); + let updated = arg_number(fields[0] || "0"); + let failed = arg_number(fields[1] || "0"); + let unchanged = arg_number(fields[2] || "0"); + let superseded = arg_number(fields[3] || "0"); + + if (updated == 0) { + if (superseded > 0) { + log_message("Subscription update was superseded by newer configuration", "info"); + return true; + } + if (failed > 0) { + log_message("Subscription update finished with errors; keeping the last working cache", "info"); + return false; + } + if (unchanged > 0) + log_message("Subscription update completed: no changes detected", "info"); + else + log_message("No subscription rules are due for update", "info"); + return true; + } + + log_message("Reloading sing-box to apply updated subscriptions", "info"); + if (!module_success([ LIB_DIR + "/server/service.uc", "prepare-all-defaults" ])) + return false; + if (!module_success([ LIB_DIR + "/config/validator.uc", "validate-runtime" ])) { + log_message("Runtime config validation failed. Aborted.", "fatal"); + return false; + } + + if (!singbox_runtime_success([ "configure-service" ])) + return false; + let sing_box_config_path = option(uci_settings(), "config_path", ""); + let sing_box_config_hash_before = file_md5(sing_box_config_path); + let sing_box_pid_before = trim(module_output([ LIB_DIR + "/service/state.uc", "sing-box-service-runtime-pid" ])); + module_success([ DNS_FAILOVER_UC, "stop-runtime" ]); + if (!singbox_runtime_success([ "init-config", "0", "1", "1" ])) { + module_success([ DNS_FAILOVER_UC, "start-runtime" ]); + log_message("Failed to rebuild sing-box after subscription update", "error"); + return false; + } + module_success([ PRIORITY_UC, "stop-runtime" ]); + if (!service_state_success([ + "reload-sing-box-runtime", + sing_box_pid_before, + sing_box_config_hash_before, + file_md5(sing_box_config_path) + ])) { + module_success([ PRIORITY_UC, "start-runtime" ]); + module_success([ DNS_FAILOVER_UC, "start-runtime" ]); + return false; + } + if (!module_success([ PRIORITY_UC, "start-runtime" ])) { + log_message("Failed to restart Priority runtime after subscription update", "error"); + return false; + } + if (!module_success([ DNS_FAILOVER_UC, "start-runtime" ])) { + log_message("Failed to restart DNS failover runtime after subscription update", "error"); + return false; + } + if (!write_current_reload_state_clean()) + return false; + + if (failed > 0) + log_message("Subscription update applied for changed rules; failed rules kept their previous cache", "info"); + else + log_message("Subscription update completed", "info"); + return true; +} + +function subscription_update_common(force, target_section, target_source_index) { + if (!subscription_cache_success([ "ensure-runtime-dirs" ])) + exit(1); + + force = !!force; + if (!acquire_runtime_lock(SUBSCRIPTION_UPDATE_LOCK_DIR, force)) { + log_message("Subscription update is already running", "info"); + if (force) + mark_pending_reload("subscription_update_busy"); + return force ? 1 : 0; + } + + if (!acquire_runtime_lock(RELOAD_LOCK_DIR, force)) { + release_runtime_lock(SUBSCRIPTION_UPDATE_LOCK_DIR); + log_message("Tachyon reload is already running; skipping subscription update", "info"); + if (force) + mark_pending_reload("reload_busy"); + return force ? 1 : 0; + } + + let ok = subscription_update_common_locked(force, target_section, target_source_index); + release_runtime_lock(RELOAD_LOCK_DIR); + release_runtime_lock(SUBSCRIPTION_UPDATE_LOCK_DIR); + run_pending_reload_if_requested(); + return ok ? 0 : 1; +} + +function subscription_update_if_due() { + log_message("Starting due subscription update", "info"); + exit(subscription_update_common(false, "", "")); +} + +function subscription_update(target_section, target_source_index) { + if (as_string(target_section) != "") + log_message("Starting subscription update for rule '" + as_string(target_section) + "'", "info"); + else + log_message("Starting subscription update", "info"); + exit(subscription_update_common(true, target_section, target_source_index)); +} + +function fixture_section_list(data, type_name) { + type_name = as_string(type_name || "section"); + let value = object_or_empty(data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function fixture_cron_refresh_plan(path, bin, list_marker, subscription_marker, component_marker) { + let data = object_or_empty(read_json_file(path)); + connections.set_item_sections_from_data(data); + cron_refresh_plan( + object_or_empty(data.settings), + fixture_section_list(data), + bin, + list_marker, + subscription_marker, + component_marker + ); +} + +function fixture_cron_refresh_apply(path, existing_crontab_path, bin, list_marker, subscription_marker, component_marker) { + let data = object_or_empty(read_json_file(path)); + connections.set_item_sections_from_data(data); + let result = cron_refresh_apply_result( + object_or_empty(data.settings), + fixture_section_list(data), + fs.readfile(as_string(existing_crontab_path)) || "", + bin, + list_marker, + subscription_marker, + component_marker + ); + + write_json({ + crontab: result.crontab, + logs: result.logs + }); + exit(result.status); +} + +function fixture_section_by_name(data, target_name) { + target_name = as_string(target_name); + for (let section in fixture_section_list(data)) + if (section_name(section) == target_name) + return section; + return {}; +} + +function uci_cron_refresh_plan(bin, list_marker, subscription_marker, component_marker) { + cron_refresh_plan( + uci_settings(), + uci_sections("section"), + bin, + list_marker, + subscription_marker, + component_marker + ); +} + +function uci_refresh_cron(bin, list_marker, subscription_marker, component_marker) { + let settings = uci_settings(); + if (settings_component_update_check_interval(settings) == "") + clear_component_update_check_cache(); + refresh_cron_from_sources( + settings, + uci_sections("section"), + bin, + list_marker, + subscription_marker, + component_marker + ); +} + +function uci_list_update_due_status(timestamp_path, now) { + list_update_due_status(uci_settings(), timestamp_path, now); +} + +function uci_subscription_update_section_due_status(section_name, timestamp_path, now) { + subscription_update_section_due_status(object_or_empty(uci_core.get_all(CONFIG_NAME, section_name)), timestamp_path, now); +} + +function fixture_list_update_due_status(path, timestamp_path, now) { + let data = object_or_empty(read_json_file(path)); + connections.set_item_sections_from_data(data); + list_update_due_status(object_or_empty(data.settings), timestamp_path, now); +} + +function fixture_subscription_update_section_due_status(path, section_name_value, timestamp_path, now) { + let data = object_or_empty(read_json_file(path)); + connections.set_item_sections_from_data(data); + subscription_update_section_due_status(fixture_section_by_name(data, section_name_value), timestamp_path, now); +} + +let mode = ARGV[0] || ""; + +if (mode == "json-length") + json_length(ARGV[1]); +else if (mode == "update-is-due") + update_is_due(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "duration-to-seconds") + duration_to_seconds(ARGV[1]); +else if (mode == "due-check-cron-schedule") + due_check_cron_schedule(ARGV[1]); +else if (mode == "list-update-cron-job") + update_cron_job(ARGV[1], "list_update_if_due", ARGV[2], ARGV[3]); +else if (mode == "subscription-update-cron-job") + subscription_update_cron_job(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-update-interval-plan") + subscription_update_interval_plan(); +else if (mode == "cron-refresh-plan") + uci_cron_refresh_plan(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "cron-refresh-plan-fixture") + fixture_cron_refresh_plan(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "refresh-cron-from-uci") + uci_refresh_cron(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "refresh-cron-fixture") + fixture_cron_refresh_apply(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]); +else if (mode == "remove-cron-jobs") + remove_cron_jobs(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "list-update") + list_update(); +else if (mode == "list-update-if-due") + list_update_if_due(); +else if (mode == "stop-list-update") + stop_list_update(); +else if (mode == "list-update-due-status") + uci_list_update_due_status(ARGV[1], ARGV[2]); +else if (mode == "list-update-due-status-fixture") + fixture_list_update_due_status(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-update-section-due-status") + uci_subscription_update_section_due_status(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-update-section-due-status-fixture") + fixture_subscription_update_section_due_status(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "file-first-line") + file_first_line(ARGV[1]); +else if (mode == "stdin-first-ipv4-line") + stdin_first_ipv4_line(); +else if (mode == "filter-cron-markers") + filter_cron_markers([ARGV[1], ARGV[2]]); +else if (mode == "job-pid") + job_pid(ARGV[1]); +else if (mode == "subscription-job-state-path") + subscription_job_state_path(ARGV[1], ARGV[2]); +else if (mode == "subscription-job-json-response") + subscription_job_json_response(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-running-job-state") + subscription_running_job_state(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-job-refresh-plan") + subscription_job_refresh_plan(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-finished-job-state") + subscription_finished_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); +else if (mode == "subscription-stale-job-state") + subscription_stale_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "subscription-status-error") + subscription_status_error(ARGV[1]); +else if (mode == "subscription-cleanup-jobs") + subscription_cleanup_jobs(); +else if (mode == "subscription-update-worker") + subscription_update_worker(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "subscription-update") + subscription_update(ARGV[1], ARGV[2]); +else if (mode == "subscription-update-if-due") + subscription_update_if_due(); +else if (mode == "subscription-update-async") + subscription_update_async(ARGV[1], ARGV[2]); +else if (mode == "subscription-update-status") + subscription_update_status(ARGV[1]); +else if (mode == "component-action-worker") + component_action_worker(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "component-action-async") + component_action_async(ARGV[1], ARGV[2]); +else if (mode == "component-action-status") + component_action_status(ARGV[1]); +else if (mode == "component-updates-if-due") + component_updates_if_due(); +else if (mode == "component-update-check-cache") + component_update_check_cache(); +else { + warn("Usage: components/updates.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/config/connections.uc b/tachyon/files/usr/lib/config/connections.uc new file mode 100644 index 00000000..4ac4a99a --- /dev/null +++ b/tachyon/files/usr/lib/config/connections.uc @@ -0,0 +1,1166 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); +let uci_core = require("core.uci"); + +let as_string = common.as_string; +let object_or_empty = common.object_or_empty; + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const ITEM_TYPES = [ + "subscription_url", + "section_interface", + "urltest", + "priority_group", + "priority_level" +]; + +let item_sections = null; + +function raw_option(section, key) { + return object_or_empty(section)[key]; +} + +function option(section, key, fallback) { + return common.option(section, key, fallback); +} + +function bool_value(value, fallback) { + if (value == null || value == "") + return !!fallback; + + value = as_string(value); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function bool_option(section, key, fallback) { + return bool_value(raw_option(section, key), fallback); +} + +function list_value(section, key) { + let value = raw_option(section, key); + if (value == null) + return []; + if (type(value) == "array") + return value; + + value = as_string(value); + return value == "" ? [] : [ value ]; +} + +function whitespace_list_value(section, key) { + let value = raw_option(section, key); + if (type(value) == "array") + return value; + return common.list_option(section, key); +} + +function section_name(section) { + return option(section, ".name", ""); +} + +function empty_item_sections() { + let result = {}; + for (let type_name in ITEM_TYPES) { + result[type_name] = { + by_name: {}, + list: [] + }; + } + return result; +} + +function add_item_section(index, type_name, section) { + section = object_or_empty(section); + let name = section_name(section); + if (name == "") + return; + + if (index[type_name] == null) + index[type_name] = { by_name: {}, list: [] }; + index[type_name].by_name[name] = section; + push(index[type_name].list, section); +} + +function item_index_from_cursor(cursor, config_name) { + let index = empty_item_sections(); + if (cursor == null) + return index; + + config_name = as_string(config_name || CONFIG_NAME); + for (let type_name in ITEM_TYPES) { + try { + cursor.foreach(config_name, type_name, function(section) { + add_item_section(index, type_name, section); + }); + } + catch (e) { + } + } + + return index; +} + +function fixture_section_list(data, type_name) { + data = object_or_empty(data); + let value = data[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = data[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function item_index_from_data(data) { + let index = empty_item_sections(); + for (let type_name in ITEM_TYPES) + for (let section in fixture_section_list(data, type_name)) + add_item_section(index, type_name, section); + return index; +} + +function item_index_from_uci() { + let index = empty_item_sections(); + for (let type_name in ITEM_TYPES) + for (let section in uci_core.section_objects(CONFIG_NAME, type_name)) + add_item_section(index, type_name, section); + return index; +} + +function set_item_sections(index) { + item_sections = type(index) == "object" ? index : empty_item_sections(); +} + +function set_item_sections_from_cursor(cursor, config_name) { + set_item_sections(item_index_from_cursor(cursor, config_name)); +} + +function set_item_sections_from_data(data) { + set_item_sections(item_index_from_data(data)); +} + +function get_item_sections() { + if (item_sections == null) + item_sections = item_index_from_uci(); + return item_sections; +} + +function child_section(type_name, item_id) { + return object_or_empty(object_or_empty(object_or_empty(get_item_sections())[type_name]).by_name)[as_string(item_id)]; +} + +function owned_child_section(parent, type_name, item_id) { + let child = child_section(type_name, item_id); + if (type(child) != "object") + return null; + return option(child, "section", "") == section_name(parent) ? child : null; +} + +function child_items(parent, type_name) { + let result = []; + for (let child in object_or_empty(object_or_empty(get_item_sections())[type_name]).list || []) + if (option(child, "section", "") == section_name(parent)) + push(result, child); + return result; +} + +function child_items_by_owner(type_name, owner_key, owner_value) { + owner_value = as_string(owner_value); + let result = []; + for (let child in object_or_empty(object_or_empty(get_item_sections())[type_name]).list || []) + if (option(child, owner_key, "") == owner_value) + push(result, child); + return result; +} + +function child_item_by_value(parent, type_name, value_key, value) { + value = as_string(value); + for (let child in child_items(parent, type_name)) + if (option(child, value_key, "") == value) + return child; + return null; +} + +function child_option(child, key, fallback) { + if (type(child) != "object") + return as_string(fallback); + return option(child, key, fallback); +} + +function child_option_alias(child, keys, fallback) { + if (type(child) != "object") + return as_string(fallback); + for (let key in keys) { + let value = raw_option(child, key); + if (value != null) + return as_string(value); + } + return as_string(fallback); +} + +function child_bool(child, key, fallback) { + if (type(child) != "object") + return !!fallback; + return bool_option(child, key, fallback); +} + +function child_list(child, key, fallback) { + if (type(child) != "object") + return fallback == null ? [] : fallback; + let value = raw_option(child, key); + if (value == null) + value = fallback; + if (value == null) + return []; + if (type(value) == "array") + return value; + + value = as_string(value); + return value == "" ? [] : [ value ]; +} + +function child_list_alias(child, keys, fallback) { + if (type(child) == "object") { + for (let key in keys) { + let value = raw_option(child, key); + if (value != null) + return child_list(child, key, []); + } + } + return fallback == null ? [] : fallback; +} + +function child_values(parent, type_name, value_key, legacy_key) { + let items = child_items(parent, type_name); + if (length(items) > 0) { + let result = []; + for (let child in items) { + let value = option(child, value_key, ""); + if (value != "") + push(result, value); + } + return result; + } + return legacy_key == "" ? [] : whitespace_list_value(parent, legacy_key); +} + +function settings_map(section, key) { + let value = option(section, key, ""); + if (value == "") + return {}; + + try { + value = json(value); + } + catch (e) { + return {}; + } + + return object_or_empty(value); +} + +function item_settings(section, key, item) { + return object_or_empty(settings_map(section, key)[as_string(item)]); +} + +function item_option(section, key, item, option_name, fallback) { + let value = item_settings(section, key, item)[option_name]; + if (value == null) + return as_string(fallback); + return as_string(value); +} + +function item_bool(section, key, item, option_name, fallback) { + return bool_value(item_settings(section, key, item)[option_name], fallback); +} + +function item_list(section, key, item, option_name, fallback) { + let value = item_settings(section, key, item)[option_name]; + if (value == null) + value = fallback; + if (value == null) + return []; + if (type(value) == "array") + return value; + + value = as_string(value); + return value == "" ? [] : [ value ]; +} + +function is_legacy_connection_action(action) { + action = as_string(action); + return action == "proxy" || action == "outbound" || action == "vpn"; +} + +function is_connections_action(action) { + action = as_string(action); + return action == "connection" || is_legacy_connection_action(action); +} + +function normalize_action(action) { + action = as_string(action); + return is_connections_action(action) ? "connection" : action; +} + +function action(section) { + return normalize_action(option(section, "action", "")); +} + +function connection_urls(section) { + return whitespace_list_value(section, "selector_proxy_links"); +} + +function subscription_urls(section) { + return child_values(section, "subscription_url", "url", "subscription_urls"); +} + +function interfaces(section) { + let items = child_values(section, "section_interface", "name", ""); + if (length(items) > 0) + return items; + + let result = list_value(section, "interfaces"); + if (length(result) == 0) { + let value = option(section, "interface", ""); + if (value != "") + push(result, value); + } + return result; +} + +function outbound_jsons(section) { + let result = list_value(section, "outbound_jsons"); + if (length(result) == 0) { + let value = option(section, "outbound_json", ""); + if (value != "") + push(result, value); + } + return result; +} + +function urltests(section) { + let result = []; + for (let child in child_items(section, "urltest")) { + let id = section_name(child); + if (id != "") + push(result, id); + } + if (length(result) > 0) + return result; + + return bool_option(section, "urltest_enabled", false) ? [ "urltest" ] : []; +} + +function priority_groups(section) { + let result = []; + for (let child in child_items(section, "priority_group")) { + let id = section_name(child); + if (id != "") + push(result, id); + } + return result; +} + +function dashboard_filter_mode(section) { + return option(section, "dashboard_filter_mode", "disabled") || "disabled"; +} + +function dashboard_detect_server_country(section) { + return option(section, "dashboard_detect_server_country", "flag_emoji") || "flag_emoji"; +} + +function dashboard_include_countries(section) { + return list_value(section, "dashboard_include_countries"); +} + +function dashboard_include_outbounds(section) { + return list_value(section, "dashboard_include_outbounds"); +} + +function dashboard_include_regex(section) { + return list_value(section, "dashboard_include_regex"); +} + +function dashboard_include_proxy_parameters(section) { + return bool_option(section, "dashboard_include_proxy_parameters", false); +} + +function dashboard_include_protocols(section) { + return list_value(section, "dashboard_include_protocols"); +} + +function dashboard_include_transports(section) { + return list_value(section, "dashboard_include_transports"); +} + +function dashboard_include_securities(section) { + return list_value(section, "dashboard_include_securities"); +} + +function dashboard_include_groups(section) { + return list_value(section, "dashboard_include_groups"); +} + +function dashboard_exclude_countries(section) { + return list_value(section, "dashboard_exclude_countries"); +} + +function dashboard_exclude_outbounds(section) { + return list_value(section, "dashboard_exclude_outbounds"); +} + +function dashboard_exclude_regex(section) { + return list_value(section, "dashboard_exclude_regex"); +} + +function dashboard_exclude_proxy_parameters(section) { + return bool_option(section, "dashboard_exclude_proxy_parameters", false); +} + +function dashboard_exclude_protocols(section) { + return list_value(section, "dashboard_exclude_protocols"); +} + +function dashboard_exclude_transports(section) { + return list_value(section, "dashboard_exclude_transports"); +} + +function dashboard_exclude_securities(section) { + return list_value(section, "dashboard_exclude_securities"); +} + +function dashboard_exclude_groups(section) { + return list_value(section, "dashboard_exclude_groups"); +} + +function priority_group_child(section, value) { + return owned_child_section(section, "priority_group", value); +} + +function priority_group_settings(section, value) { + let child = priority_group_child(section, value); + return child != null ? child : {}; +} + +function priority_level_child(group_id, value) { + let child = child_section("priority_level", value); + if (type(child) != "object") + return null; + return option(child, "group", "") == as_string(group_id) ? child : null; +} + +function priority_level_sort(left, right) { + let left_order = int(option(left, "order", "0"), 10); + let right_order = int(option(right, "order", "0"), 10); + if (left_order != right_order) + return left_order < right_order ? -1 : 1; + + let left_name = section_name(left); + let right_name = section_name(right); + return left_name == right_name ? 0 : (left_name < right_name ? -1 : 1); +} + +function priority_level_items(group_id) { + let result = child_items_by_owner("priority_level", "group", group_id); + sort(result, priority_level_sort); + return result; +} + +function priority_levels(group_id) { + let result = []; + for (let child in priority_level_items(group_id)) { + let id = section_name(child); + if (id != "") + push(result, id); + } + return result; +} + +function community_lists(section) { + return whitespace_list_value(section, "community_lists"); +} + +function rule_sets(section) { + return whitespace_list_value(section, "rule_set"); +} + +function rule_sets_with_subnets(section) { + return whitespace_list_value(section, "rule_set_with_subnets"); +} + +function list_option_value_from_array(values) { + return join(" ", values || []); +} + +function community_lists_value(section) { + return list_option_value_from_array(community_lists(section)); +} + +function rule_sets_value(section) { + return list_option_value_from_array(rule_sets(section)); +} + +function rule_sets_with_subnets_value(section) { + return list_option_value_from_array(rule_sets_with_subnets(section)); +} + +function has_connection_sources(section) { + return length(connection_urls(section)) > 0 || + length(subscription_urls(section)) > 0 || + length(interfaces(section)) > 0 || + length(outbound_jsons(section)) > 0; +} + +function subscription_url_settings(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + return child != null ? child : item_settings(section, "subscription_url_settings", value); +} + +function interface_settings(section, value) { + let child = child_item_by_value(section, "section_interface", "name", value); + return child != null ? child : item_settings(section, "interface_settings", value); +} + +function interface_domain_resolver_enabled(section, value) { + let child = child_item_by_value(section, "section_interface", "name", value); + if (child != null) + return child_bool(child, "domain_resolver_enabled", false); + return item_bool(section, "interface_settings", value, "domain_resolver_enabled", + bool_option(section, "domain_resolver_enabled", false)); +} + +function interface_domain_resolver_dns_type(section, value) { + let child = child_item_by_value(section, "section_interface", "name", value); + if (child != null) + return child_option(child, "domain_resolver_dns_type", "udp"); + return item_option(section, "interface_settings", value, "domain_resolver_dns_type", + option(section, "domain_resolver_dns_type", "udp") || "udp"); +} + +function interface_domain_resolver_dns_server(section, value) { + let child = child_item_by_value(section, "section_interface", "name", value); + if (child != null) + return child_option(child, "domain_resolver_dns_server", "8.8.8.8"); + return item_option(section, "interface_settings", value, "domain_resolver_dns_server", + option(section, "domain_resolver_dns_server", "8.8.8.8") || "8.8.8.8"); +} + +function urltest_child(section, value) { + let child = owned_child_section(section, "urltest", value); + if (child != null) + return child; + + return child_item_by_value(section, "urltest", "id", value); +} + +function urltest_settings(section, value) { + let child = urltest_child(section, value); + return child != null ? child : item_settings(section, "urltest_settings", value); +} + +function subscription_update_enabled(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) + return child_bool(child, "subscription_update_enabled", true); + return item_bool(section, "subscription_url_settings", value, "subscription_update_enabled", + bool_option(section, "subscription_update_enabled", true)); +} + +function subscription_update_interval(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) + return child_option(child, "subscription_update_interval", "1h"); + return item_option(section, "subscription_url_settings", value, "subscription_update_interval", + option(section, "subscription_update_interval", "1h") || "1h"); +} + +function subscription_dashboard_metadata_enabled(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) + return child_bool(child, "show_dashboard_metadata", true); + return item_bool(section, "subscription_url_settings", value, "show_dashboard_metadata", true); +} + +function subscription_auto_user_agent(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) { + if (raw_option(child, "auto_user_agent") != null) + return child_bool(child, "auto_user_agent", true); + return child_option(child, "user_agent", "") == ""; + } + + let settings = item_settings(section, "subscription_url_settings", value); + if (raw_option(settings, "auto_user_agent") != null) + return item_bool(section, "subscription_url_settings", value, "auto_user_agent", true); + return item_option(section, "subscription_url_settings", value, "user_agent", "") == ""; +} + +function subscription_auto_hwid(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) { + if (raw_option(child, "auto_hwid") != null) + return child_bool(child, "auto_hwid", true); + return child_option(child, "hwid", "") == ""; + } + + let settings = item_settings(section, "subscription_url_settings", value); + if (raw_option(settings, "auto_hwid") != null) + return item_bool(section, "subscription_url_settings", value, "auto_hwid", true); + return item_option(section, "subscription_url_settings", value, "hwid", "") == ""; +} + +function subscription_include_urltest_groups(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) + return child_bool(child, "include_urltest_groups", true); + return item_bool(section, "subscription_url_settings", value, "include_urltest_groups", true); +} + +function subscription_hide_urltest_group_outbounds(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) + return child_bool(child, "hide_urltest_group_outbounds", true); + return item_bool(section, "subscription_url_settings", value, "hide_urltest_group_outbounds", true); +} + +function subscription_hide_detour_outbounds(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) + return child_bool(child, "hide_detour_outbounds", true); + return item_bool(section, "subscription_url_settings", value, "hide_detour_outbounds", true); +} + +function subscription_prefix_nodes(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) + return child_bool(child, "prefix_nodes", false); + return item_bool(section, "subscription_url_settings", value, "prefix_nodes", false); +} + +function subscription_node_prefix(section, value) { + if (!subscription_prefix_nodes(section, value)) + return ""; + + let child = child_item_by_value(section, "subscription_url", "url", value); + let prefix = child != null + ? child_option(child, "node_prefix", "") + : item_option(section, "subscription_url_settings", value, "node_prefix", ""); + return trim(as_string(prefix)); +} + +function subscription_user_agent(section, value) { + if (subscription_auto_user_agent(section, value)) + return ""; + + let child = child_item_by_value(section, "subscription_url", "url", value); + let configured = child != null + ? child_option(child, "user_agent", "") + : item_option(section, "subscription_url_settings", value, "user_agent", ""); + return configured != "" ? configured : "sing-box"; +} + +function subscription_hwid(section, value) { + if (subscription_auto_hwid(section, value)) + return ""; + + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) + return child_option(child, "hwid", ""); + return item_option(section, "subscription_url_settings", value, "hwid", ""); +} + +function subscription_download_section(section, value) { + let child = child_item_by_value(section, "subscription_url", "url", value); + if (child != null) { + if (!child_bool(child, "download_via_proxy_enabled", false)) + return ""; + return child_option(child, "download_via_proxy_section", ""); + } + + if (!item_bool(section, "subscription_url_settings", value, "download_via_proxy_enabled", false)) + return ""; + + return item_option(section, "subscription_url_settings", value, "download_via_proxy_section", ""); +} + +function urltest_check_interval(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_option_alias(child, [ "check_interval", "urltest_check_interval" ], "3m"); + return item_option(section, "urltest_settings", value, "urltest_check_interval", + option(section, "urltest_check_interval", "3m") || "3m"); +} + +function urltest_tolerance(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_option_alias(child, [ "tolerance", "urltest_tolerance" ], "50"); + return item_option(section, "urltest_settings", value, "urltest_tolerance", + option(section, "urltest_tolerance", "50") || "50"); +} + +function urltest_testing_url(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_option_alias(child, [ "testing_url", "urltest_testing_url" ], "https://www.gstatic.com/generate_204"); + return item_option(section, "urltest_settings", value, "urltest_testing_url", + option(section, "urltest_testing_url", "https://www.gstatic.com/generate_204") || "https://www.gstatic.com/generate_204"); +} + +function urltest_display_name(section, value) { + let fallback = as_string(value); + if (fallback == "urltest" && length(child_items(section, "urltest")) == 0) + fallback = "Fastest"; + let child = urltest_child(section, value); + if (child != null) + return child_option_alias(child, [ "name", "display_name" ], fallback); + return item_option(section, "urltest_settings", value, "display_name", fallback); +} + +function urltest_idle_timeout(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_option(child, "idle_timeout", ""); + return item_option(section, "urltest_settings", value, "idle_timeout", ""); +} + +function urltest_interrupt_exist_connections(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_bool(child, "interrupt_exist_connections", true); + return item_bool(section, "urltest_settings", value, "interrupt_exist_connections", true); +} + +function urltest_filter_mode(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_option_alias(child, [ "filter_mode", "urltest_filter_mode" ], "disabled"); + return item_option(section, "urltest_settings", value, "urltest_filter_mode", + option(section, "urltest_filter_mode", "disabled") || "disabled"); +} + +function urltest_detect_server_country(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_option(child, "detect_server_country", "flag_emoji"); + return item_option(section, "urltest_settings", value, "detect_server_country", + option(section, "detect_server_country", "flag_emoji") || "flag_emoji"); +} + +function urltest_pin_dashboard(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_bool(child, "pin_dashboard", true); + return item_bool(section, "urltest_settings", value, "pin_dashboard", true); +} + +function urltest_include_countries(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_list_alias(child, [ "include_countries", "urltest_include_countries" ], []); + return item_list(section, "urltest_settings", value, "urltest_include_countries", + list_value(section, "urltest_include_countries")); +} + +function urltest_include_outbounds(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_list_alias(child, [ "include_outbounds", "urltest_include_outbounds" ], []); + return item_list(section, "urltest_settings", value, "urltest_include_outbounds", + list_value(section, "urltest_include_outbounds")); +} + +function urltest_include_regex(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_list_alias(child, [ "include_regex", "urltest_include_regex" ], []); + return item_list(section, "urltest_settings", value, "urltest_include_regex", + list_value(section, "urltest_include_regex")); +} + +function urltest_include_proxy_parameters(section, value) { + let child = urltest_child(section, value); + return child != null ? child_bool(child, "include_proxy_parameters", false) : false; +} + +function urltest_include_protocols(section, value) { + let child = urltest_child(section, value); + return child != null ? child_list(child, "include_protocols", []) : []; +} + +function urltest_include_transports(section, value) { + let child = urltest_child(section, value); + return child != null ? child_list(child, "include_transports", []) : []; +} + +function urltest_include_securities(section, value) { + let child = urltest_child(section, value); + return child != null ? child_list(child, "include_securities", []) : []; +} + +function urltest_exclude_countries(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_list_alias(child, [ "exclude_countries", "urltest_exclude_countries" ], []); + return item_list(section, "urltest_settings", value, "urltest_exclude_countries", + list_value(section, "urltest_exclude_countries")); +} + +function urltest_exclude_outbounds(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_list_alias(child, [ "exclude_outbounds", "urltest_exclude_outbounds" ], []); + return item_list(section, "urltest_settings", value, "urltest_exclude_outbounds", + list_value(section, "urltest_exclude_outbounds")); +} + +function urltest_exclude_regex(section, value) { + let child = urltest_child(section, value); + if (child != null) + return child_list_alias(child, [ "exclude_regex", "urltest_exclude_regex" ], []); + return item_list(section, "urltest_settings", value, "urltest_exclude_regex", + list_value(section, "urltest_exclude_regex")); +} + +function urltest_exclude_proxy_parameters(section, value) { + let child = urltest_child(section, value); + return child != null ? child_bool(child, "exclude_proxy_parameters", false) : false; +} + +function urltest_exclude_protocols(section, value) { + let child = urltest_child(section, value); + return child != null ? child_list(child, "exclude_protocols", []) : []; +} + +function urltest_exclude_transports(section, value) { + let child = urltest_child(section, value); + return child != null ? child_list(child, "exclude_transports", []) : []; +} + +function urltest_exclude_securities(section, value) { + let child = urltest_child(section, value); + return child != null ? child_list(child, "exclude_securities", []) : []; +} + +function priority_group_display_name(section, value) { + let child = priority_group_child(section, value); + return child != null ? child_option(child, "name", as_string(value)) : as_string(value); +} + +function priority_group_health_url(section, value) { + let child = priority_group_child(section, value); + return child != null ? child_option(child, "health_url", "https://www.gstatic.com/generate_204") : "https://www.gstatic.com/generate_204"; +} + +function priority_group_active_check_interval(section, value) { + let child = priority_group_child(section, value); + return child != null ? child_option(child, "active_check_interval", "5s") : "5s"; +} + +function priority_group_check_timeout(section, value) { + let child = priority_group_child(section, value); + return child != null ? child_option(child, "check_timeout", "2s") : "2s"; +} + +function priority_group_recovery_check_interval(section, value) { + let child = priority_group_child(section, value); + return child != null ? child_option(child, "recovery_check_interval", "15s") : "15s"; +} + +function priority_group_pick_fastest(section, value) { + let child = priority_group_child(section, value); + return child != null ? child_bool(child, "pick_fastest", false) : false; +} + +function priority_group_switch_to_faster_same_priority(section, value) { + let child = priority_group_child(section, value); + return child != null ? child_bool(child, "switch_to_faster_same_priority", false) : false; +} + +function priority_group_fastest_check_interval(section, value) { + let child = priority_group_child(section, value); + return child != null ? child_option(child, "fastest_check_interval", "3m") : "3m"; +} + +function priority_group_interrupt_exist_connections(section, value) { + let child = priority_group_child(section, value); + return child != null ? child_bool(child, "interrupt_exist_connections", true) : true; +} + +function priority_group_pin_dashboard(section, value) { + let child = priority_group_child(section, value); + return child != null ? child_bool(child, "pin_dashboard", true) : true; +} + +function priority_level_display_name(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_option(child, "name", as_string(value)) : as_string(value); +} + +function priority_level_order(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_option(child, "order", "0") : "0"; +} + +function priority_level_direct(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_bool(child, "direct", false) : false; +} + +function priority_level_filter_mode(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_option(child, "filter_mode", "include") : "include"; +} + +function priority_level_include_countries(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list_alias(child, [ "include_countries", "country" ], []) : []; +} + +function priority_level_include_outbounds(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list_alias(child, [ "include_outbounds", "server_name" ], []) : []; +} + +function priority_level_include_regex(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list_alias(child, [ "include_regex", "regex" ], []) : []; +} + +function priority_level_include_proxy_parameters(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_bool(child, "include_proxy_parameters", false) : false; +} + +function priority_level_include_protocols(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list(child, "include_protocols", []) : []; +} + +function priority_level_include_transports(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list(child, "include_transports", []) : []; +} + +function priority_level_include_securities(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list(child, "include_securities", []) : []; +} + +function priority_level_exclude_countries(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list(child, "exclude_countries", []) : []; +} + +function priority_level_exclude_outbounds(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list(child, "exclude_outbounds", []) : []; +} + +function priority_level_exclude_regex(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list(child, "exclude_regex", []) : []; +} + +function priority_level_exclude_proxy_parameters(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_bool(child, "exclude_proxy_parameters", false) : false; +} + +function priority_level_exclude_protocols(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list(child, "exclude_protocols", []) : []; +} + +function priority_level_exclude_transports(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list(child, "exclude_transports", []) : []; +} + +function priority_level_exclude_securities(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_list(child, "exclude_securities", []) : []; +} + +function priority_level_countries(group_id, value) { + return priority_level_include_countries(group_id, value); +} + +function priority_level_outbounds(group_id, value) { + return priority_level_include_outbounds(group_id, value); +} + +function priority_level_regex(group_id, value) { + return priority_level_include_regex(group_id, value); +} + +function priority_level_detect_server_country(group_id, value) { + let child = priority_level_child(group_id, value); + return child != null ? child_option(child, "detect_server_country", "flag_emoji") : "flag_emoji"; +} + +function append_unique(result, seen, value) { + value = as_string(value); + if (value == "" || seen[value]) + return; + seen[value] = true; + push(result, value); +} + +function subscription_download_targets(sections) { + let result = []; + let seen = {}; + + for (let section in sections) { + section = object_or_empty(section); + let name = option(section, ".name", ""); + + for (let source in subscription_urls(section)) { + let target = subscription_download_section(section, source); + if (target != "" && target != name) + append_unique(result, seen, target); + } + } + + return result; +} + +function subscription_download_target_port(sections, target, base_port) { + target = as_string(target); + base_port = int(base_port || 0); + + let targets = subscription_download_targets(sections); + for (let i = 0; i < length(targets); i++) + if (targets[i] == target) + return base_port + 2 + i; + + return 0; +} + +return { + option, + bool_option, + list_value, + whitespace_list_value, + set_item_sections, + set_item_sections_from_cursor, + set_item_sections_from_data, + settings_map, + item_settings, + item_option, + item_bool, + is_connections_action, + normalize_action, + action, + connection_urls, + subscription_urls, + interfaces, + outbound_jsons, + urltests, + dashboard_filter_mode, + dashboard_detect_server_country, + dashboard_include_countries, + dashboard_include_outbounds, + dashboard_include_regex, + dashboard_include_proxy_parameters, + dashboard_include_protocols, + dashboard_include_transports, + dashboard_include_securities, + dashboard_include_groups, + dashboard_exclude_countries, + dashboard_exclude_outbounds, + dashboard_exclude_regex, + dashboard_exclude_proxy_parameters, + dashboard_exclude_protocols, + dashboard_exclude_transports, + dashboard_exclude_securities, + dashboard_exclude_groups, + community_lists, + rule_sets, + rule_sets_with_subnets, + community_lists_value, + rule_sets_value, + rule_sets_with_subnets_value, + has_connection_sources, + subscription_url_settings, + interface_settings, + urltest_settings, + subscription_update_enabled, + subscription_update_interval, + subscription_dashboard_metadata_enabled, + subscription_auto_user_agent, + subscription_auto_hwid, + subscription_include_urltest_groups, + subscription_hide_urltest_group_outbounds, + subscription_hide_detour_outbounds, + subscription_prefix_nodes, + subscription_node_prefix, + subscription_user_agent, + subscription_hwid, + subscription_download_section, + interface_domain_resolver_enabled, + interface_domain_resolver_dns_type, + interface_domain_resolver_dns_server, + urltest_check_interval, + urltest_tolerance, + urltest_testing_url, + urltest_display_name, + urltest_idle_timeout, + urltest_interrupt_exist_connections, + urltest_filter_mode, + urltest_detect_server_country, + urltest_pin_dashboard, + urltest_include_countries, + urltest_include_outbounds, + urltest_include_regex, + urltest_include_proxy_parameters, + urltest_include_protocols, + urltest_include_transports, + urltest_include_securities, + urltest_exclude_countries, + urltest_exclude_outbounds, + urltest_exclude_regex, + urltest_exclude_proxy_parameters, + urltest_exclude_protocols, + urltest_exclude_transports, + urltest_exclude_securities, + priority_groups, + priority_group_settings, + priority_levels, + priority_group_display_name, + priority_group_health_url, + priority_group_active_check_interval, + priority_group_check_timeout, + priority_group_recovery_check_interval, + priority_group_pick_fastest, + priority_group_switch_to_faster_same_priority, + priority_group_fastest_check_interval, + priority_group_interrupt_exist_connections, + priority_group_pin_dashboard, + priority_level_display_name, + priority_level_order, + priority_level_direct, + priority_level_filter_mode, + priority_level_include_countries, + priority_level_include_outbounds, + priority_level_include_regex, + priority_level_include_proxy_parameters, + priority_level_include_protocols, + priority_level_include_transports, + priority_level_include_securities, + priority_level_exclude_countries, + priority_level_exclude_outbounds, + priority_level_exclude_regex, + priority_level_exclude_proxy_parameters, + priority_level_exclude_protocols, + priority_level_exclude_transports, + priority_level_exclude_securities, + priority_level_countries, + priority_level_outbounds, + priority_level_regex, + priority_level_detect_server_country, + subscription_download_targets, + subscription_download_target_port +}; diff --git a/tachyon/files/usr/lib/config/domain.uc b/tachyon/files/usr/lib/config/domain.uc new file mode 100644 index 00000000..48a73186 --- /dev/null +++ b/tachyon/files/usr/lib/config/domain.uc @@ -0,0 +1,376 @@ +#!/usr/bin/env ucode + +const PUNYCODE_BASE = 36; +const PUNYCODE_TMIN = 1; +const PUNYCODE_TMAX = 26; +const PUNYCODE_SKEW = 38; +const PUNYCODE_DAMP = 700; +const PUNYCODE_INITIAL_BIAS = 72; +const PUNYCODE_INITIAL_N = 128; +const PUNYCODE_DELIMITER = "-"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function ascii_lower(value) { + let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let lower = "abcdefghijklmnopqrstuvwxyz"; + return replace(as_string(value), /[A-Z]/g, function(ch) { + return substr(lower, index(upper, ch), 1); + }); +} + +function byte_at(value, offset) { + return ord(substr(value, offset, 1)); +} + +function valid_continuation(value, offset) { + if (offset >= length(value)) + return -1; + + let byte = byte_at(value, offset); + return (byte & 0xc0) == 0x80 ? byte : -1; +} + +function unicode_lower_codepoint(cp) { + if (cp >= 0x41 && cp <= 0x5a) + return cp + 0x20; + + if (cp >= 0xc0 && cp <= 0xd6) + return cp + 0x20; + if (cp >= 0xd8 && cp <= 0xde) + return cp + 0x20; + + if (cp == 0x401) + return 0x451; + if (cp >= 0x410 && cp <= 0x42f) + return cp + 0x20; + + if (cp >= 0x391 && cp <= 0x3a1) + return cp + 0x20; + if (cp >= 0x3a3 && cp <= 0x3ab) + return cp + 0x20; + + return cp; +} + +function utf8_next_codepoint(value, offset) { + let b1 = byte_at(value, offset); + if (b1 < 0x80) + return { cp: unicode_lower_codepoint(b1), next: offset + 1 }; + + if (b1 >= 0xc2 && b1 <= 0xdf) { + let b2 = valid_continuation(value, offset + 1); + if (b2 < 0) + return null; + return { + cp: unicode_lower_codepoint(((b1 & 0x1f) << 6) | (b2 & 0x3f)), + next: offset + 2 + }; + } + + if (b1 >= 0xe0 && b1 <= 0xef) { + let b2 = valid_continuation(value, offset + 1); + let b3 = valid_continuation(value, offset + 2); + if (b2 < 0 || b3 < 0) + return null; + + let cp = ((b1 & 0x0f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f); + if (cp < 0x800 || (cp >= 0xd800 && cp <= 0xdfff)) + return null; + + return { cp: unicode_lower_codepoint(cp), next: offset + 3 }; + } + + if (b1 >= 0xf0 && b1 <= 0xf4) { + let b2 = valid_continuation(value, offset + 1); + let b3 = valid_continuation(value, offset + 2); + let b4 = valid_continuation(value, offset + 3); + if (b2 < 0 || b3 < 0 || b4 < 0) + return null; + + let cp = ((b1 & 0x07) << 18) | ((b2 & 0x3f) << 12) | ((b3 & 0x3f) << 6) | (b4 & 0x3f); + if (cp < 0x10000 || cp > 0x10ffff) + return null; + + return { cp: unicode_lower_codepoint(cp), next: offset + 4 }; + } + + return null; +} + +function utf8_codepoints(value) { + value = as_string(value); + let result = []; + + for (let i = 0; i < length(value); ) { + let next = utf8_next_codepoint(value, i); + if (next == null) + return null; + + push(result, next.cp); + i = next.next; + } + + return result; +} + +function ascii_label_char(cp) { + return (cp >= 0x30 && cp <= 0x39) || + (cp >= 0x61 && cp <= 0x7a) || + cp == 0x2d; +} + +function valid_ascii_label(value) { + value = ascii_lower(value); + return length(value) >= 1 && + length(value) <= 63 && + match(value, /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/) != null; +} + +function codepoints_to_ascii_label(codepoints) { + let result = ""; + for (let cp in codepoints) { + if (!ascii_label_char(cp)) + return null; + result += chr(cp); + } + return valid_ascii_label(result) ? result : null; +} + +function punycode_digit(value) { + return chr(value < 26 ? 0x61 + value : 0x30 + value - 26); +} + +function punycode_adapt(delta, numpoints, first_time) { + delta = first_time ? int(delta / PUNYCODE_DAMP) : int(delta / 2); + delta += int(delta / numpoints); + + let k = 0; + while (delta > int(((PUNYCODE_BASE - PUNYCODE_TMIN) * PUNYCODE_TMAX) / 2)) { + delta = int(delta / (PUNYCODE_BASE - PUNYCODE_TMIN)); + k += PUNYCODE_BASE; + } + + return k + int(((PUNYCODE_BASE - PUNYCODE_TMIN + 1) * delta) / (delta + PUNYCODE_SKEW)); +} + +function punycode_encode(codepoints) { + let output = ""; + let basic_count = 0; + + for (let cp in codepoints) { + if (cp < 0x80) { + if (!ascii_label_char(cp)) + return null; + output += chr(cp); + basic_count++; + } + } + + let handled = basic_count; + if (basic_count > 0 && basic_count < length(codepoints)) + output += PUNYCODE_DELIMITER; + + let n = PUNYCODE_INITIAL_N; + let delta = 0; + let bias = PUNYCODE_INITIAL_BIAS; + + while (handled < length(codepoints)) { + let m = 0x10ffff; + for (let cp in codepoints) + if (cp >= n && cp < m) + m = cp; + + delta += (m - n) * (handled + 1); + n = m; + + for (let cp in codepoints) { + if (cp < n) { + delta++; + continue; + } + if (cp != n) + continue; + + let q = delta; + for (let k = PUNYCODE_BASE; ; k += PUNYCODE_BASE) { + let t = k <= bias + ? PUNYCODE_TMIN + : (k >= bias + PUNYCODE_TMAX ? PUNYCODE_TMAX : k - bias); + if (q < t) + break; + + output += punycode_digit(t + ((q - t) % (PUNYCODE_BASE - t))); + q = int((q - t) / (PUNYCODE_BASE - t)); + } + + output += punycode_digit(q); + bias = punycode_adapt(delta, handled + 1, handled == basic_count); + delta = 0; + handled++; + } + + delta++; + n++; + } + + return "xn--" + output; +} + +function label_to_ascii(value) { + value = as_string(value); + if (value == "") + return null; + + let codepoints = utf8_codepoints(value); + if (codepoints == null) + return null; + + let ascii = codepoints_to_ascii_label(codepoints); + if (ascii != null) + return ascii; + + let encoded = punycode_encode(codepoints); + return encoded != null && valid_ascii_label(encoded) ? encoded : null; +} + +function domain_to_ascii(value, allow_leading_dot) { + value = trim(as_string(value)); + if (value == "") + return null; + + let leading_dot = false; + if (allow_leading_dot && substr(value, 0, 1) == ".") { + leading_dot = true; + value = substr(value, 1); + } + + if (value == "" || substr(value, length(value) - 1, 1) == ".") + return null; + + let labels = split(value, "."); + let result = []; + for (let label in labels) { + let normalized = label_to_ascii(label); + if (normalized == null) + return null; + push(result, normalized); + } + + let domain = join(".", result); + if (length(domain) > 253) + return null; + + return leading_dot ? "." + domain : domain; +} + +function suffix_to_ascii(value) { + return domain_to_ascii(value, true); +} + +function keyword_to_ascii(value) { + value = trim(as_string(value)); + if (value == "" || match(value, /[,[:space:]]/) != null) + return null; + + let has_non_ascii = false; + for (let i = 0; i < length(value); i++) { + if (byte_at(value, i) >= 0x80) { + has_non_ascii = true; + break; + } + } + + if (!has_non_ascii) + return value; + + return domain_to_ascii(value, false); +} + +function regex_to_ascii(value) { + value = as_string(value); + if (match(value, /[,[:space:]]/) != null) + return null; + + let result = ""; + let label = ""; + let label_has_non_ascii = false; + let escaped = false; + let in_class = false; + + function flush_label() { + if (label == "") + return true; + + let normalized = label_has_non_ascii ? label_to_ascii(label) : label; + if (normalized == null) + return false; + + result += normalized; + label = ""; + label_has_non_ascii = false; + return true; + } + + for (let i = 0; i < length(value); ) { + let next = utf8_next_codepoint(value, i); + if (next == null) + return null; + + let raw = substr(value, i, next.next - i); + let cp = next.cp; + + if (escaped) { + if (!flush_label()) + return null; + result += raw; + escaped = false; + } + else if (cp == 0x5c) { + if (!flush_label()) + return null; + result += raw; + escaped = true; + } + else if (in_class) { + result += raw; + if (cp == 0x5d) + in_class = false; + } + else if (cp == 0x5b) { + if (!flush_label()) + return null; + result += raw; + in_class = true; + } + else if (ascii_label_char(cp) || cp >= 0x80) { + label += raw; + if (cp >= 0x80) + label_has_non_ascii = true; + } + else { + if (!flush_label()) + return null; + result += raw; + } + + i = next.next; + } + + return flush_label() ? result : null; +} + +function valid_suffix(value) { + return suffix_to_ascii(value) != null; +} + +return { + ascii_lower, + label_to_ascii, + suffix_to_ascii, + keyword_to_ascii, + regex_to_ascii, + valid_suffix +}; diff --git a/tachyon/files/usr/lib/config/migration.uc b/tachyon/files/usr/lib/config/migration.uc new file mode 100644 index 00000000..901d0de3 --- /dev/null +++ b/tachyon/files/usr/lib/config/migration.uc @@ -0,0 +1,1482 @@ +#!/usr/bin/env ucode + +// Keep migrations in this file. A migration is identified by a stable name; +// release checks are optional conditions inside the migration itself. + +let fs = require("fs"); +let common = require("core.common"); +let uci_core = require("core.uci"); +let constants_module = require("core.constants"); +let singbox_constants_module = require("singbox.constants"); +let domain_config = require("config.domain"); +let subscription_share_link = require("subscription.share_link"); + +let as_string = common.as_string; +let read_json_file = common.read_json_file; +let write_json = common.write_json; +let option = common.option; +let list_option = common.list_option; +let bool_option = common.bool_option; +let object_or_empty = common.object_or_empty; + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || "/tmp/sing-box/subscriptions"; +const TACHYON_RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const TACHYON_SUBSCRIPTION_LINKS_DIR = getenv("TACHYON_SUBSCRIPTION_LINKS_DIR") || TACHYON_RUNTIME_STATE_DIR + "/subscription-links"; +const TACHYON_SUBSCRIPTION_METADATA_DIR = getenv("TACHYON_SUBSCRIPTION_METADATA_DIR") || TACHYON_RUNTIME_STATE_DIR + "/subscription-metadata"; +const TACHYON_OUTBOUND_METADATA_DIR = getenv("TACHYON_OUTBOUND_METADATA_DIR") || TACHYON_RUNTIME_STATE_DIR + "/outbound-metadata"; +const TACHYON_SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || TACHYON_RUNTIME_STATE_DIR + "/section-cache"; +const TACHYON_RUNTIME_CACHE_FORMAT_FILE = getenv("TACHYON_RUNTIME_CACHE_FORMAT_FILE") || TACHYON_RUNTIME_STATE_DIR + "/cache-format"; +const TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/tachyon/subscription-cache"; +const TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; +const TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT") || "7"; +const TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD = getenv("TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD") || "/var/run/tachyon.internal-config-change"; +const TACHYON_RUNTIME_CACHE_FORMAT = getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8"; +const CONFIG_VERSION_OPTION = "config_version"; +const APPLIED_MIGRATIONS_OPTION = "applied_migrations"; +const SERVER_COUNTRY_METHOD_FLAG_EMOJI = "flag_emoji"; +const SERVER_COUNTRY_METHOD_COUNTRY_IS = "country_is"; +const CHILD_ITEM_TYPES = [ + "subscription_url", + "section_interface", + "urltest" +]; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return replace(as_string(data), /[\r\n]+$/g, ""); +} + +function run(command) { + return system(command) == 0; +} + +function constants_context() { + let constants = object_or_empty(constants_module); + return { + zapret_legacy_default_nfqws_opt: as_string(constants.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT), + zapret_default_nfqws_opt: as_string(constants.ZAPRET_DEFAULT_NFQWS_OPT), + urltest_default_idle_timeout: as_string(object_or_empty(singbox_constants_module).URLTEST_DEFAULT_IDLE_TIMEOUT || "30m") + }; +} + +function section_name(section) { + return option(section, ".name", ""); +} + +function clone_section(section) { + let result = {}; + for (let key in keys(object_or_empty(section))) + result[key] = section[key]; + return result; +} + +function fixture_section_list(data, type_name) { + let value = object_or_empty(data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function model_from_fixture(path) { + let data = object_or_empty(read_json_file(path)); + let model = { + settings: clone_section(object_or_empty(data.settings)), + rules: [], + sections: [] + }; + for (let type_name in CHILD_ITEM_TYPES) + model[type_name] = []; + + if (model.settings[".name"] == null) + model.settings[".name"] = "settings"; + if (model.settings[".type"] == null) + model.settings[".type"] = "settings"; + + for (let section in fixture_section_list(data, "rule")) + push(model.rules, clone_section(section)); + for (let section in fixture_section_list(data, "section")) + push(model.sections, clone_section(section)); + for (let type_name in CHILD_ITEM_TYPES) + for (let section in fixture_section_list(data, type_name)) + push(model[type_name], clone_section(section)); + + return model; +} + +function model_from_uci(cursor) { + let model = { + settings: clone_section(object_or_empty(cursor.get_all(CONFIG_NAME, "settings"))), + rules: [], + sections: [] + }; + for (let type_name in CHILD_ITEM_TYPES) + model[type_name] = []; + + cursor.foreach(CONFIG_NAME, "rule", function(section) { + push(model.rules, clone_section(section)); + }); + cursor.foreach(CONFIG_NAME, "section", function(section) { + push(model.sections, clone_section(section)); + }); + for (let type_name in CHILD_ITEM_TYPES) { + cursor.foreach(CONFIG_NAME, type_name, function(section) { + push(model[type_name], clone_section(section)); + }); + } + + return model; +} + +function export_model(model) { + let result = { + settings: model.settings, + section: model.sections + }; + if (length(model.rules) > 0) + result.rule = model.rules; + for (let type_name in CHILD_ITEM_TYPES) + if (length(model[type_name] || []) > 0) + result[type_name] = model[type_name]; + return result; +} + +function migration_context(model) { + return { + model, + operations: [], + removed_caches: [], + added_lists: {}, + changed: false + }; +} + +function record_operation(ctx, op) { + push(ctx.operations, op); + ctx.changed = true; +} + +function option_exists(section, key) { + return object_or_empty(section)[key] != null; +} + +function set_option(ctx, section, key, value) { + value = as_string(value); + if (option(section, key, "") == value && option_exists(section, key)) + return; + + section[key] = value; + record_operation(ctx, { op: "set", section: section_name(section), option: key, value }); +} + +function set_option_if_missing(ctx, section, key, value) { + if (option_exists(section, key)) + return; + set_option(ctx, section, key, value); +} + +function list_values_equal(left, right) { + if (length(left) != length(right)) + return false; + + for (let i = 0; i < length(left); i++) + if (as_string(left[i]) != as_string(right[i])) + return false; + + return true; +} + +function set_list_option(ctx, section, key, values) { + let normalized = []; + for (let value in values) { + value = as_string(value); + if (value != "") + push(normalized, value); + } + + let current = object_or_empty(section)[key]; + let current_values = []; + if (type(current) == "array") + current_values = current; + else if (current != null && as_string(current) != "") + current_values = [ as_string(current) ]; + + if (option_exists(section, key) && list_values_equal(current_values, normalized)) + return; + + section[key] = normalized; + record_operation(ctx, { op: "set_list", section: section_name(section), option: key, values: normalized }); +} + +function set_list_option_if_not_empty(ctx, section, key, values) { + if (length(values || []) > 0) + set_list_option(ctx, section, key, values); +} + +function delete_option(ctx, section, key) { + if (!option_exists(section, key)) + return; + + delete section[key]; + record_operation(ctx, { op: "delete", section: section_name(section), option: key }); +} + +function list_contains(section, key, value) { + value = as_string(value); + for (let item in list_option(section, key)) + if (as_string(item) == value) + return true; + return false; +} + +function add_list_unique(ctx, section, key, value) { + value = as_string(value); + if (value == "") + return; + + let list_key = section_name(section) + "." + key + "=" + value; + if (ctx.added_lists[list_key] || list_contains(section, key, value)) + return; + + let current = section[key]; + if (type(current) != "array") + current = list_option(section, key); + push(current, value); + section[key] = current; + ctx.added_lists[list_key] = true; + record_operation(ctx, { op: "add_list", section: section_name(section), option: key, values: current }); +} + +function create_child_section(ctx, type_name) { + ctx.child_index = int(ctx.child_index || 0) + 1; + let item_id = "__" + type_name + "_" + ctx.child_index; + let section = { + ".name": item_id, + ".type": type_name + }; + if (ctx.model[type_name] == null) + ctx.model[type_name] = []; + push(ctx.model[type_name], section); + record_operation(ctx, { op: "create", section: item_id, type: type_name, anonymous: true }); + return section; +} + +function create_child_for_section(ctx, parent, type_name) { + let child = create_child_section(ctx, type_name); + set_option(ctx, child, "section", section_name(parent)); + return child; +} + +function option_list_values(section, key) { + let value = object_or_empty(section)[key]; + if (type(value) == "array") + return value; + if (value == null) + return []; + value = as_string(value); + return value == "" ? [] : [ value ]; +} + +function whitespace_list_values(section, key) { + let value = object_or_empty(section)[key]; + if (type(value) == "array") + return value; + return list_option(section, key); +} + +function subscription_url_entry_profile(value) { + let entry = trim(as_string(value)); + let result = { + raw: entry, + value: entry, + user_agent: "", + changed: false + }; + let delimiter = " | "; + let delimiter_index = rindex(entry, delimiter); + + if (delimiter_index < 0) + return result; + + let url = trim(substr(entry, 0, delimiter_index)); + let user_agent = trim(substr(entry, delimiter_index + length(delimiter))); + if (url == "" || user_agent == "") + return result; + + result.value = url; + result.user_agent = user_agent; + result.changed = true; + return result; +} + +function normalize_connections_list(ctx, section, old_key, new_key) { + let old_values = option_list_values(section, old_key); + + for (let value in old_values) + add_list_unique(ctx, section, new_key, value); + + if (length(old_values) > 0) + delete_option(ctx, section, old_key); +} + +function set_section_type(ctx, section, type_name) { + if (option(section, ".type", "") == type_name) + return; + + section[".type"] = type_name; + record_operation(ctx, { op: "set_type", section: section_name(section), type: type_name }); +} + +function normalize_detect_server_country_method(value) { + value = as_string(value); + if (value == SERVER_COUNTRY_METHOD_COUNTRY_IS) + return SERVER_COUNTRY_METHOD_COUNTRY_IS; + return SERVER_COUNTRY_METHOD_FLAG_EMOJI; +} + +function urltest_filter_mode_filters_enabled(value) { + return value == "exclude" || value == "include" || value == "mixed"; +} + +function duration_to_seconds_value(value) { + let rest = as_string(value); + if (rest == "") + return null; + + let total = 0.0; + let multipliers = { + ns: 0.000000001, + us: 0.000001, + ms: 0.001, + s: 1, + m: 60, + h: 3600, + d: 86400 + }; + + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return null; + + let token = as_string(matched[0]); + let amount = matched[1] * 1; + let unit = matched[3]; + total += amount * multipliers[unit]; + rest = substr(rest, length(token)); + } + + return total <= 0 ? null : int(total + 0.5); +} + +function legacy_urltest_idle_timeout(section, constants) { + let interval = option(section, "urltest_check_interval", "3m") || "3m"; + let interval_seconds = duration_to_seconds_value(interval); + let default_idle_seconds = duration_to_seconds_value(object_or_empty(constants).urltest_default_idle_timeout || "30m"); + return interval_seconds != null && default_idle_seconds != null && interval_seconds > default_idle_seconds + ? interval + : ""; +} + +const LEGACY_URLTEST_OPTIONS = [ + "urltest_enabled", + "urltest_check_interval", + "urltest_tolerance", + "urltest_testing_url", + "urltest_filter_mode", + "urltest_hide_filtered_outbounds", + "detect_server_country", + "urltest_include_countries", + "urltest_include_outbounds", + "urltest_include_regex", + "urltest_exclude_countries", + "urltest_exclude_outbounds", + "urltest_exclude_regex" +]; + +function delete_legacy_urltest_options(ctx, section) { + for (let key in LEGACY_URLTEST_OPTIONS) + delete_option(ctx, section, key); +} + +function migrate_urltest_filter_mode(ctx, section) { + if (option_exists(section, "urltest_filter_mode")) + return; + + if (option_exists(section, "urltest_exclude_countries") || + option_exists(section, "urltest_include_countries") || + option_exists(section, "urltest_exclude_outbounds") || + option_exists(section, "urltest_exclude_regex")) { + set_option(ctx, section, "urltest_filter_mode", "exclude"); + } +} + +function migrate_detect_server_country(ctx, section) { + if (!option_exists(section, "detect_server_country")) + return; + + let value = option(section, "detect_server_country", ""); + if (value != "0" && value != "1") + return; + + if (bool_option(section, "urltest_enabled", false) && + urltest_filter_mode_filters_enabled(option(section, "urltest_filter_mode", "disabled"))) { + set_option(ctx, section, "detect_server_country", normalize_detect_server_country_method(value)); + } + else { + delete_option(ctx, section, "detect_server_country"); + } +} + +function trim_lines(value) { + let result = []; + for (let line in split(as_string(value), "\n")) { + line = trim(replace(as_string(line), /\r/g, "")); + if (line != "") + push(result, line); + } + return result; +} + +function migrate_urltest_link(ctx, section, link) { + add_list_unique(ctx, section, "selector_proxy_links", link); +} + +function migrate_proxy_string(ctx, section) { + let proxy_string = option(section, "proxy_string", ""); + if (proxy_string == "") + return; + + let migrated = false; + for (let link in trim_lines(proxy_string)) { + if (substr(link, 0, 2) == "//") + continue; + add_list_unique(ctx, section, "selector_proxy_links", link); + migrated = true; + } + + if (migrated) + delete_option(ctx, section, "proxy_string"); +} + +function cache_section_safe(section) { + section = as_string(section); + return section != "" && index(section, "/") < 0 && index(section, "..") < 0; +} + +function subscription_cache_paths(section) { + return [ + TMP_SUBSCRIPTION_FOLDER + "/" + section + ".json", + TMP_SUBSCRIPTION_FOLDER + "/" + section + ".url", + TMP_SUBSCRIPTION_FOLDER + "/" + section + ".user_agent", + TACHYON_SUBSCRIPTION_METADATA_DIR + "/" + section + ".json", + TACHYON_SUBSCRIPTION_LINKS_DIR + "/" + section + ".json", + TACHYON_OUTBOUND_METADATA_DIR + "/" + section + ".json", + TACHYON_SECTION_CACHE_DIR + "/" + section + ".json", + TMP_SUBSCRIPTION_FOLDER + "/" + section + "-subscription-*.json", + TMP_SUBSCRIPTION_FOLDER + "/" + section + "-subscription-*.url", + TMP_SUBSCRIPTION_FOLDER + "/" + section + "-subscription-*.user_agent" + ]; +} + +function delete_subscription_cache(ctx, section) { + section = as_string(section); + if (!cache_section_safe(section)) + return; + + for (let path in subscription_cache_paths(section)) + push(ctx.removed_caches, path); +} + +function migrate_subscription_url(ctx, section) { + let subscription_url = option(section, "subscription_url", ""); + if (subscription_url == "") + return; + + let subscription_user_agent = option(section, "subscription_user_agent", ""); + let entry = subscription_user_agent != "" + ? subscription_url + " | " + subscription_user_agent + : subscription_url; + add_list_unique(ctx, section, "subscription_urls", entry); + delete_option(ctx, section, "subscription_url"); + delete_option(ctx, section, "subscription_user_agent"); + delete_subscription_cache(ctx, section_name(section)); +} + +function migrate_interval_flags(ctx, section, proxy_config_type) { + if (proxy_config_type == "urltest" || proxy_config_type == "subscription") { + if (option(section, "urltest_check_interval_disabled", "") == "1") + set_option(ctx, section, "urltest_enabled", "0"); + else + set_option_if_missing(ctx, section, "urltest_enabled", "1"); + } + else if (proxy_config_type == "url" || proxy_config_type == "selector") { + set_option_if_missing(ctx, section, "urltest_enabled", "0"); + } + + if (proxy_config_type == "subscription") { + if (option(section, "subscription_update_interval_disabled", "") == "1") + set_option(ctx, section, "subscription_update_enabled", "0"); + else + set_option_if_missing(ctx, section, "subscription_update_enabled", "1"); + } + + delete_option(ctx, section, "urltest_check_interval_disabled"); + delete_option(ctx, section, "subscription_update_interval_disabled"); +} + +function migrate_proxy_rule(ctx, section, proxy_config_type) { + if (proxy_config_type == "url") + migrate_proxy_string(ctx, section); + else if (proxy_config_type == "urltest") { + for (let link in list_option(section, "urltest_proxy_links")) + migrate_urltest_link(ctx, section, link); + delete_option(ctx, section, "urltest_proxy_links"); + } + else if (proxy_config_type == "subscription") { + migrate_subscription_url(ctx, section); + delete_subscription_cache(ctx, section_name(section)); + } + + migrate_interval_flags(ctx, section, proxy_config_type); + delete_option(ctx, section, "proxy_config_type"); +} + +// Podkop Plus -> Tachyon source migration. +function migrated_rule_action(section) { + let action = option(section, "action", ""); + let proxy_config_type = option(section, "proxy_config_type", ""); + let connection_type = option(section, "connection_type", ""); + let iface = option(section, "interface", ""); + let outbound_json = option(section, "outbound_json", ""); + let selector_proxy_links = option(section, "selector_proxy_links", ""); + let subscription_urls = option(section, "subscription_urls", ""); + + if (action == "proxy" || action == "vpn" || action == "outbound") + return "connection"; + + if (action == "direct") + return "bypass"; + + if (action != "") + return action; + + if (connection_type == "proxy") + return "connection"; + if (connection_type == "vpn") + return "connection"; + if (connection_type == "block") + return "block"; + if (connection_type == "exclusion") + return "bypass"; + + if (proxy_config_type == "interface") + return "connection"; + if (proxy_config_type == "outbound") + return "connection"; + if (proxy_config_type == "url" || proxy_config_type == "selector" || + proxy_config_type == "urltest" || proxy_config_type == "subscription") + return "connection"; + + if (outbound_json != "") + return "connection"; + if (iface != "") + return "connection"; + if (selector_proxy_links != "" || subscription_urls != "") + return "connection"; + + return ""; +} + +function legacy_rule_connection_kind(section) { + let action = option(section, "action", ""); + let proxy_config_type = option(section, "proxy_config_type", ""); + let connection_type = option(section, "connection_type", ""); + + if (action == "vpn" || connection_type == "vpn" || proxy_config_type == "interface") + return "interface"; + if (action == "outbound" || proxy_config_type == "outbound") + return "outbound"; + if (action == "proxy" || connection_type == "proxy" || + proxy_config_type == "url" || proxy_config_type == "selector" || + proxy_config_type == "urltest" || proxy_config_type == "subscription") + return "proxy"; + if (option(section, "interface", "") != "") + return "interface"; + if (option(section, "outbound_json", "") != "") + return "outbound"; + return "proxy"; +} + +function migrate_byedpi_cmd_opts(ctx, section) { + let cmd_opts = option(section, "cmd_opts", ""); + if (cmd_opts == "") + return; + + if (option(section, "byedpi_cmd_opts", "") == "") + set_option(ctx, section, "byedpi_cmd_opts", cmd_opts); + delete_option(ctx, section, "cmd_opts"); +} + +function migrate_zapret_nfqws_default(ctx, section, constants) { + if (migrated_rule_action(section) != "zapret") + return; + + let nfqws_opt = option(section, "nfqws_opt", ""); + if (nfqws_opt == "" || nfqws_opt != constants.zapret_legacy_default_nfqws_opt) + return; + + set_option(ctx, section, "nfqws_opt", constants.zapret_default_nfqws_opt); +} + +function migrate_subscription_url_item_settings(ctx, section) { + let values = whitespace_list_values(section, "subscription_urls"); + let seen_values = {}; + let update_enabled = bool_option(section, "subscription_update_enabled", true); + let update_interval = option(section, "subscription_update_interval", ""); + if (update_interval == "") + update_interval = "1h"; + let index = 1; + + for (let value in values) { + let profile = subscription_url_entry_profile(value); + if (profile.value == "") + continue; + if (seen_values[profile.value]) + continue; + seen_values[profile.value] = true; + + let child = create_child_for_section(ctx, section, "subscription_url"); + set_option(ctx, child, "url", profile.value); + set_option(ctx, child, "subscription_update_enabled", update_enabled ? "1" : "0"); + set_option(ctx, child, "subscription_update_interval", update_enabled ? update_interval : ""); + set_option(ctx, child, "auto_user_agent", profile.user_agent != "" ? "0" : "1"); + if (profile.user_agent != "") + set_option(ctx, child, "user_agent", profile.user_agent); + set_option(ctx, child, "auto_hwid", "1"); + set_option(ctx, child, "show_dashboard_metadata", "1"); + set_option(ctx, child, "prefix_nodes", "0"); + set_option(ctx, child, "include_urltest_groups", "1"); + set_option(ctx, child, "hide_urltest_group_outbounds", "1"); + set_option(ctx, child, "hide_detour_outbounds", "1"); + index++; + } + + delete_option(ctx, section, "subscription_urls"); + delete_option(ctx, section, "subscription_url_settings"); +} + +function migrate_urltest_item_settings(ctx, section, constants) { + let legacy_enabled = bool_option(section, "urltest_enabled", false); + + if (legacy_enabled) { + let child = create_child_for_section(ctx, section, "urltest"); + set_option(ctx, child, "name", "Fastest"); + set_option(ctx, child, "check_interval", option(section, "urltest_check_interval", "3m") || "3m"); + set_option(ctx, child, "tolerance", option(section, "urltest_tolerance", "50") || "50"); + set_option(ctx, child, "testing_url", option(section, "urltest_testing_url", "https://www.gstatic.com/generate_204") || "https://www.gstatic.com/generate_204"); + set_option(ctx, child, "filter_mode", option(section, "urltest_filter_mode", "disabled") || "disabled"); + set_option(ctx, child, "detect_server_country", normalize_detect_server_country_method(option(section, "detect_server_country", SERVER_COUNTRY_METHOD_FLAG_EMOJI))); + set_option(ctx, child, "interrupt_exist_connections", "1"); + set_option(ctx, child, "pin_dashboard", "1"); + + let idle_timeout = legacy_urltest_idle_timeout(section, constants); + if (idle_timeout != "") + set_option(ctx, child, "idle_timeout", idle_timeout); + + set_list_option_if_not_empty(ctx, child, "include_countries", option_list_values(section, "urltest_include_countries")); + set_list_option_if_not_empty(ctx, child, "include_outbounds", option_list_values(section, "urltest_include_outbounds")); + set_list_option_if_not_empty(ctx, child, "include_regex", option_list_values(section, "urltest_include_regex")); + set_list_option_if_not_empty(ctx, child, "exclude_countries", option_list_values(section, "urltest_exclude_countries")); + set_list_option_if_not_empty(ctx, child, "exclude_outbounds", option_list_values(section, "urltest_exclude_outbounds")); + set_list_option_if_not_empty(ctx, child, "exclude_regex", option_list_values(section, "urltest_exclude_regex")); + } + + delete_legacy_urltest_options(ctx, section); + delete_option(ctx, section, "urltest_settings"); +} + +function migrate_interface_item_settings(ctx, section) { + let owner = section_name(section); + let resolver_enabled = bool_option(section, "domain_resolver_enabled", false) ? "1" : "0"; + let dns_type = option(section, "domain_resolver_dns_type", "udp") || "udp"; + let dns_server = option(section, "domain_resolver_dns_server", "8.8.8.8") || "8.8.8.8"; + let seen_values = {}; + + for (let child in ctx.model.section_interface || []) { + if (option(child, "section", "") != owner) + continue; + + let value = option(child, "name", ""); + if (value == "" || seen_values[value]) + continue; + + seen_values[value] = true; + set_option_if_missing(ctx, child, "domain_resolver_enabled", resolver_enabled); + set_option_if_missing(ctx, child, "domain_resolver_dns_type", dns_type); + set_option_if_missing(ctx, child, "domain_resolver_dns_server", dns_server); + } + + let values = option_list_values(section, "interfaces"); + let legacy_interface = option(section, "interface", ""); + if (legacy_interface != "") + push(values, legacy_interface); + + for (let value in values) { + value = as_string(value); + if (value == "" || seen_values[value]) + continue; + + seen_values[value] = true; + let child = create_child_for_section(ctx, section, "section_interface"); + set_option(ctx, child, "name", value); + set_option(ctx, child, "domain_resolver_enabled", resolver_enabled); + set_option(ctx, child, "domain_resolver_dns_type", dns_type); + set_option(ctx, child, "domain_resolver_dns_server", dns_server); + } + + delete_option(ctx, section, "interface"); + delete_option(ctx, section, "interfaces"); + delete_option(ctx, section, "interface_settings"); + delete_option(ctx, section, "domain_resolver_enabled"); + delete_option(ctx, section, "domain_resolver_dns_type"); + delete_option(ctx, section, "domain_resolver_dns_server"); +} + +function migrate_legacy_outbound_json_detour(ctx, section, legacy_connection_kind) { + if (legacy_connection_kind != "outbound" || + !bool_option(section, "outbound_detour_enabled", false)) + return; + + let detour_section = option(section, "outbound_detour_section", ""); + let outbound_json = option(section, "outbound_json", ""); + if (detour_section == "" || outbound_json == "") + return; + + let outbound; + try { + outbound = json(outbound_json); + } + catch (e) { + return; + } + if (type(outbound) != "object") + return; + + if (as_string(outbound.detour || "") == "") + outbound.detour = singbox_constants_module.outbound_tag(detour_section); + set_option(ctx, section, "outbound_json", sprintf("%J", outbound)); + delete_option(ctx, section, "outbound_detour_enabled"); + delete_option(ctx, section, "outbound_detour_section"); +} + +function migrate_outbound_json_list(ctx, section, legacy_connection_kind) { + migrate_legacy_outbound_json_detour(ctx, section, legacy_connection_kind); + + let outbound_json = option(section, "outbound_json", ""); + if (outbound_json != "") { + let outbound; + try { + outbound = json(outbound_json); + } + catch (e) { + outbound = null; + } + + if (type(outbound) == "object" && trim(as_string(outbound.tag || "")) == "") { + let existing = option_list_values(section, "outbound_jsons"); + let taken = {}; + for (let value in existing) { + try { + let item = json(value); + let tag_name = type(item) == "object" ? trim(as_string(item.tag || "")) : ""; + if (tag_name != "") + taken[tag_name] = true; + } + catch (e) { + } + } + + let base = singbox_constants_module.outbound_tag( + section_name(section) + "-json-" + (length(existing) + 1) + ); + let tag_name = base; + for (let suffix = 1; taken[tag_name]; suffix++) + tag_name = base + "-" + suffix; + outbound.tag = tag_name; + set_option(ctx, section, "outbound_json", sprintf("%J", outbound)); + } + } + + normalize_connections_list(ctx, section, "outbound_json", "outbound_jsons"); +} + +function migrate_connection_section(ctx, section, constants, legacy_connection_kind) { + migrate_subscription_url_item_settings(ctx, section); + migrate_urltest_item_settings(ctx, section, constants); + migrate_interface_item_settings(ctx, section); + migrate_outbound_json_list(ctx, section, legacy_connection_kind); + + delete_option(ctx, section, "subscription_update_enabled"); + delete_option(ctx, section, "subscription_update_interval"); + delete_option(ctx, section, "enable_udp_over_tcp"); +} + +function strip_list_comment(line) { + line = replace(as_string(line), /[[:space:]]*\/\/.*$/, ""); + return replace(line, /[[:space:]]*#.*$/, ""); +} + +function text_list_values(value, separator_mode) { + let result = []; + separator_mode = as_string(separator_mode); + + for (let line in split(as_string(value), "\n")) { + line = strip_list_comment(line); + line = separator_mode == "comma-space" + ? replace(line, /[ ,]/g, "\n") + : replace(line, /,/g, "\n"); + + for (let item in split(line, "\n")) { + item = trim(replace(item, /\r/g, "")); + if (item != "") + push(result, item); + } + } + + return result; +} + +function filter_domain_values(values) { + let result = []; + for (let value in values) { + let normalized = domain_config.suffix_to_ascii(value); + if (normalized != null) + push(result, normalized); + } + return result; +} + +function generic_values_from_text(value) { + return text_list_values(value, "comma"); +} + +function legacy_condition_values(kind, text_mode, conditions_text_mode, text_value, list_value) { + if (int(text_mode || 0) == 1 || int(conditions_text_mode || 0) == 1) + return kind == "domains" + ? filter_domain_values(text_list_values(text_value, "comma-space")) + : generic_values_from_text(text_value); + + let result = []; + for (let item in list_option({ value: list_value }, "value")) + push(result, item); + if (length(result) > 0) + return result; + + if (as_string(text_value) != "") + return kind == "domains" + ? filter_domain_values(text_list_values(text_value, "comma-space")) + : generic_values_from_text(text_value); + + return []; +} + +function add_unique_value(result, seen, value) { + value = as_string(value); + if (value == "" || seen[value]) + return; + + seen[value] = true; + push(result, value); +} + +function has_domain_condition_prefix(value) { + let colon = index(value, ":"); + if (colon <= 0) + return false; + + let prefix = domain_config.ascii_lower(substr(value, 0, colon)); + return prefix == "full" || prefix == "keyword" || prefix == "regex"; +} + +function add_values_with_prefix(result, seen, values, prefix) { + for (let value in values) { + value = trim(as_string(value)); + add_unique_value(result, seen, has_domain_condition_prefix(value) ? value : prefix + value); + } +} + +function legacy_values_for_option(section, option_name, kind) { + return legacy_condition_values( + kind, + option(section, option_name + "_text_mode", "0"), + option(section, "conditions_text_mode", "0"), + option(section, option_name + "_text", ""), + section[option_name] + ); +} + +function raw_text_condition_values(section, option_name) { + return text_list_values(option(section, option_name, ""), "comma-space"); +} + +function add_domain_values_with_prefix(result, seen, section, option_name, prefix, kind) { + let values = legacy_condition_values( + kind, + option(section, option_name + "_text_mode", "0"), + option(section, "conditions_text_mode", "0"), + option(section, option_name + "_text", ""), + section[option_name] + ); + + add_values_with_prefix(result, seen, values, prefix); +} + +function migrate_combined_domain_conditions(ctx, section) { + let values = []; + let seen = {}; + + for (let value in list_option(section, "domain_suffix")) + add_unique_value(values, seen, value); + for (let value in raw_text_condition_values(section, "domain_suffix_text")) + add_unique_value(values, seen, value); + + add_domain_values_with_prefix(values, seen, section, "domain", "full:", "domains"); + add_domain_values_with_prefix(values, seen, section, "domain_keyword", "keyword:", "generic"); + add_domain_values_with_prefix(values, seen, section, "domain_regex", "regex:", "generic"); + + if (length(values) > 0) + set_option(ctx, section, "domain", join("\n", values)); + else + delete_option(ctx, section, "domain"); + + delete_option(ctx, section, "domain_suffix"); + delete_option(ctx, section, "domain_suffix_text"); + delete_option(ctx, section, "domain_suffix_text_mode"); + delete_option(ctx, section, "domain_keyword"); + delete_option(ctx, section, "domain_regex"); + delete_option(ctx, section, "domain_text"); + delete_option(ctx, section, "domain_keyword_text"); + delete_option(ctx, section, "domain_regex_text"); + delete_option(ctx, section, "domain_text_mode"); + delete_option(ctx, section, "domain_keyword_text_mode"); + delete_option(ctx, section, "domain_regex_text_mode"); +} + +function migrate_text_condition(ctx, section, option_name, kind) { + let values = legacy_values_for_option(section, option_name, kind); + if (length(values) > 0) + set_option(ctx, section, option_name, join("\n", values)); + else if (option_exists(section, option_name) && type(section[option_name]) == "array") + delete_option(ctx, section, option_name); + + delete_option(ctx, section, option_name + "_text"); + delete_option(ctx, section, option_name + "_text_mode"); +} + +function migrate_rule(ctx, section, converted_from_rule, constants) { + let action = migrated_rule_action(section); + let legacy_connection_kind = legacy_rule_connection_kind(section); + let proxy_config_type = option(section, "proxy_config_type", ""); + let subscription_urls = option(section, "subscription_urls", ""); + + if (action != "") + set_option(ctx, section, "action", action); + + delete_option(ctx, section, "connection_type"); + delete_option(ctx, section, "subscription_group_by_countries"); + delete_option(ctx, section, "group_by_countries"); + delete_option(ctx, section, "subscription_detect_server_countries"); + + if (action == "connection") { + migrate_urltest_filter_mode(ctx, section); + migrate_detect_server_country(ctx, section); + if (legacy_connection_kind == "proxy") + migrate_proxy_rule(ctx, section, proxy_config_type); + else { + delete_option(ctx, section, "proxy_config_type"); + delete_option(ctx, section, "proxy_string"); + delete_option(ctx, section, "urltest_proxy_links"); + delete_option(ctx, section, "subscription_url"); + delete_option(ctx, section, "subscription_user_agent"); + delete_option(ctx, section, "urltest_check_interval_disabled"); + delete_option(ctx, section, "subscription_update_interval_disabled"); + } + migrate_connection_section(ctx, section, constants, legacy_connection_kind); + if (converted_from_rule && subscription_urls != "") + delete_subscription_cache(ctx, section_name(section)); + } + else if (action == "block" || + action == "bypass" || action == "zapret" || action == "zapret2" || action == "byedpi") { + delete_option(ctx, section, "proxy_config_type"); + delete_option(ctx, section, "proxy_string"); + delete_option(ctx, section, "urltest_proxy_links"); + delete_option(ctx, section, "subscription_url"); + delete_option(ctx, section, "subscription_user_agent"); + delete_option(ctx, section, "urltest_check_interval_disabled"); + delete_option(ctx, section, "subscription_update_interval_disabled"); + } + + if (action != "connection") + delete_legacy_urltest_options(ctx, section); + + migrate_byedpi_cmd_opts(ctx, section); + migrate_zapret_nfqws_default(ctx, section, constants); +} + +function migrate_rule_section(ctx, section, constants) { + set_section_type(ctx, section, "section"); + migrate_rule(ctx, section, true, constants); + migrate_combined_domain_conditions(ctx, section); + migrate_text_condition(ctx, section, "ip_cidr", "subnets"); +} + +function migrate_list_update_enabled(ctx) { + let settings = ctx.model.settings; + if (option_exists(settings, "list_update_enabled")) { + if (bool_option(settings, "list_update_enabled", true) && + option(settings, "update_interval", "") == "") { + set_option(ctx, settings, "update_interval", "1d"); + } + return; + } + + if (option(settings, "update_interval", "") != "") { + set_option(ctx, settings, "list_update_enabled", "1"); + } + else { + set_option(ctx, settings, "list_update_enabled", "0"); + set_option(ctx, settings, "update_interval", "1d"); + } +} + +function normalize_existing_list_option(ctx, section, key) { + let current = object_or_empty(section)[key]; + if (current == null || type(current) == "array") + return; + + let value = trim(as_string(current)); + let values = value == "" ? [] : [ value ]; + section[key] = values; + record_operation(ctx, { op: "set_list", section: section_name(section), option: key, values }); +} + +function migrate_dns_server_lists(ctx) { + normalize_existing_list_option(ctx, ctx.model.settings, "dns_server"); + normalize_existing_list_option(ctx, ctx.model.settings, "bootstrap_dns_server"); +} + +function migrate_download_via_proxy_flags(ctx) { + let settings = ctx.model.settings; + let legacy_section = option(settings, "download_lists_via_proxy_section", ""); + let lists_enabled = bool_option(settings, "download_lists_via_proxy", false); + let components_enabled = option_exists(settings, "download_components_via_proxy") + ? bool_option(settings, "download_components_via_proxy", false) + : lists_enabled; + + set_option(ctx, settings, "download_lists_via_proxy", lists_enabled ? "1" : "0"); + if (!lists_enabled) + delete_option(ctx, settings, "download_lists_via_proxy_section"); + + set_option(ctx, settings, "download_components_via_proxy", components_enabled ? "1" : "0"); + if (components_enabled) { + if (option(settings, "download_components_via_proxy_section", "") == "" && legacy_section != "") + set_option(ctx, settings, "download_components_via_proxy_section", legacy_section); + } + else { + delete_option(ctx, settings, "download_components_via_proxy_section"); + } +} + +function migrate_subscription_download_via_proxy_settings(ctx) { + let settings_section = ctx.model.settings; + let enabled = bool_option(settings_section, "download_subscriptions_via_proxy", false); + let target_section = option(settings_section, "download_lists_via_proxy_section", ""); + + if (enabled && target_section != "") { + for (let child in ctx.model.subscription_url || []) { + let name = option(child, "section", ""); + if (name == "" || name == target_section) + continue; + set_option(ctx, child, "download_via_proxy_enabled", "1"); + set_option(ctx, child, "download_via_proxy_section", target_section); + } + } + + delete_option(ctx, settings_section, "download_subscriptions_via_proxy"); +} + +function migrate_rule_set_settings(ctx, section) { + let normalize_list = function(key) { + let seen = {}; + let result = []; + for (let reference in option_list_values(section, key)) { + reference = as_string(reference); + if (reference == "" || seen[reference]) + continue; + seen[reference] = true; + push(result, reference); + } + if (length(result) > 0) + set_list_option(ctx, section, key, result); + else + delete_option(ctx, section, key); + }; + + normalize_list("community_lists"); + normalize_list("rule_set"); + normalize_list("rule_set_with_subnets"); + delete_option(ctx, section, "rule_set_settings"); +} + +function version_parts(value) { + let matched = match(as_string(value), /^([0-9]+)\.([0-9]+)\.([0-9]+)$/); + if (!matched) + return [ 0, 0, 0 ]; + return [ int(matched[1]), int(matched[2]), int(matched[3]) ]; +} + +function compare_versions(left, right) { + left = version_parts(left); + right = version_parts(right); + for (let i = 0; i < 3; i++) { + if (left[i] < right[i]) + return -1; + if (left[i] > right[i]) + return 1; + } + return 0; +} + +function release_at_most(ctx, version) { + return compare_versions(ctx.source_release, version) <= 0; +} + +function migrate_interface_sections(ctx) { + if (!release_at_most(ctx, "1.0.1")) + return; + + for (let section in ctx.model.sections) + migrate_interface_item_settings(ctx, section); +} + +function migrate_enable_component_checks(ctx) { + if (!release_at_most(ctx, "1.0.1")) + return; + + set_option(ctx, ctx.model.settings, "component_update_check_enabled", "1"); +} + +const MIGRATIONS = [ + { id: "interface_sections", run: migrate_interface_sections }, + { id: "enable_component_checks", run: migrate_enable_component_checks } +]; + +function apply_migrations(ctx) { + ctx.source_release = option(ctx.model.settings, CONFIG_VERSION_OPTION, ""); + + let applied = []; + let seen = {}; + let added = false; + for (let id in list_option(ctx.model.settings, APPLIED_MIGRATIONS_OPTION)) { + id = as_string(id); + if (id == "" || seen[id]) + continue; + seen[id] = true; + push(applied, id); + } + + for (let migration in MIGRATIONS) { + if (seen[migration.id]) + continue; + + migration.run(ctx); + seen[migration.id] = true; + push(applied, migration.id); + added = true; + } + + if (added) + set_list_option(ctx, ctx.model.settings, APPLIED_MIGRATIONS_OPTION, applied); + + if (release_at_most(ctx, "1.0.1")) + set_option(ctx, ctx.model.settings, CONFIG_VERSION_OPTION, "1.0.2"); +} + +function migrate_podkop_model(model, constants) { + let ctx = migration_context(model); + constants = object_or_empty(constants); + + delete_option(ctx, model.settings, "routing_excluded_ips"); + migrate_dns_server_lists(ctx); + migrate_list_update_enabled(ctx); + + let converted_sections = []; + for (let section in model.rules) { + migrate_rule_section(ctx, section, constants); + push(converted_sections, section); + } + model.rules = []; + + for (let section in model.sections) { + migrate_rule(ctx, section, false, constants); + migrate_combined_domain_conditions(ctx, section); + migrate_text_condition(ctx, section, "ip_cidr", "subnets"); + migrate_rule_set_settings(ctx, section); + } + for (let section in converted_sections) { + migrate_rule_set_settings(ctx, section); + push(model.sections, section); + } + migrate_subscription_download_via_proxy_settings(ctx); + migrate_download_via_proxy_flags(ctx); + apply_migrations(ctx); + + return ctx; +} + +function migrate_tachyon_model(model) { + let ctx = migration_context(model); + apply_migrations(ctx); + return ctx; +} + +// Runtime UCI adapter and external command dispatcher. +function first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function ensure_dir(path) { + run("mkdir -p " + shell_quote(path) + " >/dev/null 2>&1"); +} + +function clear_subscription_runtime_cache() { + run("rm -rf " + + shell_quote(TMP_SUBSCRIPTION_FOLDER) + " " + + shell_quote(TACHYON_SUBSCRIPTION_LINKS_DIR) + " " + + shell_quote(TACHYON_SUBSCRIPTION_METADATA_DIR) + " " + + shell_quote(TACHYON_OUTBOUND_METADATA_DIR) + " " + + shell_quote(TACHYON_SECTION_CACHE_DIR)); +} + +function ensure_runtime_dirs() { + ensure_dir(TMP_SUBSCRIPTION_FOLDER); + ensure_dir(TACHYON_RUNTIME_STATE_DIR); + ensure_dir(TACHYON_SUBSCRIPTION_METADATA_DIR); + ensure_dir(TACHYON_OUTBOUND_METADATA_DIR); + ensure_dir(TACHYON_SECTION_CACHE_DIR); +} + +function ensure_runtime_cache_format() { + ensure_dir(TACHYON_RUNTIME_STATE_DIR); + + if (first_line(TACHYON_RUNTIME_CACHE_FORMAT_FILE) != TACHYON_RUNTIME_CACHE_FORMAT) { + if (first_line(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE) == TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT) + subscription_share_link.populate_subscription_dir(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR); + clear_subscription_runtime_cache(); + ensure_runtime_dirs(); + fs.writefile(TACHYON_RUNTIME_CACHE_FORMAT_FILE, TACHYON_RUNTIME_CACHE_FORMAT + "\n"); + } + + if (first_line(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE) != TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT) { + run("rm -rf " + shell_quote(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR)); + ensure_dir(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR); + run("chmod 700 " + shell_quote(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR) + " >/dev/null 2>&1"); + fs.writefile(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT + "\n"); + run("chmod 600 " + shell_quote(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE) + " >/dev/null 2>&1"); + } +} + +function remove_legacy_server_country_cache() { + fs.unlink(TACHYON_RUNTIME_STATE_DIR + "/server-country-cache.json"); +} + +function remove_cache_path(path) { + path = as_string(path); + if (index(path, "*") >= 0) + run("rm -f " + path); + else + fs.unlink(path); +} + +function apply_operations(cursor, operations) { + let created = {}; + let section_ref = function(name) { + name = as_string(name); + return as_string(created[name] || name); + }; + + for (let op in operations) { + if (op.op == "create") { + if (op.anonymous && type(cursor.add) == "function") + created[as_string(op.section)] = cursor.add(CONFIG_NAME, op.type); + else + cursor.set(CONFIG_NAME, op.section, op.type); + } + else if (op.op == "set") + cursor.set(CONFIG_NAME, section_ref(op.section), op.option, op.value); + else if (op.op == "delete") + cursor.delete(CONFIG_NAME, section_ref(op.section), op.option); + else if (op.op == "add_list") + cursor.set(CONFIG_NAME, section_ref(op.section), op.option, op.values); + else if (op.op == "set_list") + cursor.set(CONFIG_NAME, section_ref(op.section), op.option, op.values); + else if (op.op == "set_type") + cursor.set(CONFIG_NAME, section_ref(op.section), op.type); + } +} + +function runtime_cursor() { + return { + load: function(package_name) { + return uci_core.load(package_name); + }, + get_all: function(package_name, section_name) { + return uci_core.get_all(package_name, section_name); + }, + foreach: function(package_name, type_name, callback) { + for (let section in uci_core.section_objects(package_name, type_name)) + callback(section); + }, + add: function(package_name, type_name) { + return uci_core.add(package_name, type_name); + }, + set: function(package_name, section_name, option_name, value) { + if (value == null) + return uci_core.set_section(package_name + "." + section_name, option_name); + return uci_core.set(package_name + "." + section_name + "." + option_name, value); + }, + delete: function(package_name, section_name, option_name) { + return uci_core.delete(package_name + "." + section_name + "." + option_name); + }, + commit: function(package_name) { + return uci_core.commit(package_name); + } + }; +} + +function current_config_hash() { + let config_path = "/etc/config/" + CONFIG_NAME; + if (fs.stat(config_path) == null) + return ""; + + let output = command_output("md5sum " + shell_quote(config_path) + " 2>/dev/null"); + let fields = split(trim(output), /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[0]) : ""; +} + +function mark_internal_config_guard() { + let hash = current_config_hash(); + if (hash == "") { + fs.unlink(TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD); + return; + } + + let stamp = clock(); + let tmp_path = TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD + "." + stamp[0] + "." + stamp[1]; + fs.writefile(tmp_path, as_string(stamp[0]) + "\n" + hash + "\n"); + if (!fs.rename(tmp_path, TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD)) + fs.unlink(tmp_path); +} + +function commit_cursor(cursor) { + if (!cursor.commit(CONFIG_NAME)) + return false; + mark_internal_config_guard(); + return true; +} + +function migrate_model(model, source) { + return source == "podkop" + ? migrate_podkop_model(model, constants_context()) + : migrate_tachyon_model(model); +} + +function migrate_runtime(source) { + ensure_runtime_cache_format(); + remove_legacy_server_country_cache(); + + let cursor = runtime_cursor(); + cursor.load(CONFIG_NAME); + let ctx = migrate_model(model_from_uci(cursor), source); + if (!ctx.changed) + return true; + + apply_operations(cursor, ctx.operations); + for (let path in ctx.removed_caches) + remove_cache_path(path); + + return commit_cursor(cursor); +} + +function commit_runtime() { + let cursor = runtime_cursor(); + cursor.load(CONFIG_NAME); + return commit_cursor(cursor); +} + +function migrate_fixture(path, source) { + let ctx = migrate_model(model_from_fixture(path), source); + write_json({ + changed: ctx.changed, + config: export_model(ctx.model), + operations: ctx.operations, + removed_caches: ctx.removed_caches + }); +} + +function main(argv) { + let mode = argv[0] || ""; + + if (mode == "migrate") + return migrate_runtime("tachyon") ? 0 : 1; + if (mode == "migrate-podkop") + return migrate_runtime("podkop") ? 0 : 1; + if (mode == "commit") + return commit_runtime() ? 0 : 1; + if (mode == "migrate-fixture") { + migrate_fixture(argv[1], argv[2] || "tachyon"); + return 0; + } + + warn("Usage: config/migration.uc migrate\n"); + warn(" config/migration.uc migrate-podkop\n"); + warn(" config/migration.uc commit\n"); + warn(" config/migration.uc migrate-fixture [tachyon|podkop]\n"); + return 1; +} + +function module_exports() { + return { + main: main, + migrate_model: migrate_model, + migrate_tachyon_model: migrate_tachyon_model, + migrate_podkop_model: migrate_podkop_model, + mark_internal_config_guard: mark_internal_config_guard + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +exit(main(ARGV)); diff --git a/tachyon/files/usr/lib/config/rule.uc b/tachyon/files/usr/lib/config/rule.uc new file mode 100644 index 00000000..11affaae --- /dev/null +++ b/tachyon/files/usr/lib/config/rule.uc @@ -0,0 +1,339 @@ +#!/usr/bin/env ucode + +let ip = require("core.ip"); +let domain_config = require("config.domain"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function strip_list_comment(line) { + line = replace(as_string(line), /[[:space:]]*\/\/.*$/, ""); + return replace(line, /[[:space:]]*#.*$/, ""); +} + +function text_list_values(value, separator_mode) { + let result = []; + separator_mode = as_string(separator_mode); + + for (let line in split(as_string(value), "\n")) { + line = strip_list_comment(line); + line = separator_mode == "comma-space" + ? replace(line, /[ ,]/g, "\n") + : replace(line, /,/g, "\n"); + + for (let item in split(line, "\n")) { + item = trim(replace(item, /\r/g, "")); + if (item != "") + push(result, item); + } + } + + return result; +} + +function whitespace_fields(value) { + let result = []; + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function strip_repeated_domain_prefix(prefix, body) { + let marker = prefix + ":"; + + while (domain_config.ascii_lower(substr(body, 0, length(marker))) == marker) + body = trim(substr(body, length(marker))); + + return body; +} + +function prefixed_domain_kind_value(value) { + value = trim(as_string(value)); + + if (value == "") + return null; + + let colon = index(value, ":"); + let prefix = ""; + let body = value; + if (colon > 0) { + let candidate = domain_config.ascii_lower(substr(value, 0, colon)); + if (candidate == "full" || candidate == "keyword" || candidate == "regex") { + prefix = candidate; + body = substr(value, colon + 1); + } + else { + return null; + } + } + + body = trim(body); + if (prefix != "") + body = strip_repeated_domain_prefix(prefix, body); + + if (body == "") + return null; + + if (prefix == "") { + let normalized = domain_config.suffix_to_ascii(body); + return normalized == null ? null : { kind: "domain_suffix", value: normalized }; + } + + if (prefix == "full") { + let normalized = domain_config.suffix_to_ascii(body); + return normalized == null ? null : { kind: "domain", value: normalized }; + } + + if (prefix == "keyword") { + let normalized = domain_config.keyword_to_ascii(body); + return normalized == null ? null : { kind: "domain_keyword", value: normalized }; + } + + let normalized = domain_config.regex_to_ascii(body); + return normalized == null ? null : { kind: "domain_regex", value: normalized }; +} + +function prefixed_domain_value(value, requested_kind) { + let normalized = prefixed_domain_kind_value(value); + requested_kind = as_string(requested_kind); + + return normalized != null && normalized.kind == requested_kind ? normalized.value : null; +} + +function domain_value_for_key(value, key) { + key = as_string(key); + + if (key == "domain" || key == "domain_suffix") + return domain_config.suffix_to_ascii(value); + if (key == "domain_keyword") + return domain_config.keyword_to_ascii(value); + if (key == "domain_regex") + return domain_config.regex_to_ascii(value); + + return null; +} + +function normalize_domain_subnet_value(value, kind) { + kind = as_string(kind); + if (kind == "domains") + return domain_config.suffix_to_ascii(value); + if (kind == "subnets") + return ip.valid_ip_or_cidr(value) ? value : null; + + exit(1); +} + +function filter_domain_subnet_values(values, kind) { + let result = []; + kind = as_string(kind); + + if (kind != "domains" && kind != "subnets") + exit(1); + + for (let value in values) { + let normalized = normalize_domain_subnet_value(value, kind); + if (normalized != null) + push(result, normalized); + } + + return result; +} + +function condition_text_csv_value(value, kind) { + kind = as_string(kind); + + if (kind == "domains") + return join(",", filter_domain_subnet_values(text_list_values(value, "comma-space"), "domains")); + if (kind == "subnets") + return join(",", filter_domain_subnet_values(text_list_values(value, "comma-space"), "subnets")); + + return join(",", text_list_values(value, "comma")); +} + +function legacy_condition_csv_value(kind, text_mode, conditions_text_mode, text_value, list_value) { + if (int(text_mode || 0) == 1 || int(conditions_text_mode || 0) == 1) + return condition_text_csv_value(text_value, kind); + + list_value = as_string(list_value); + if (list_value != "") + return kind == "subnets" + ? condition_text_csv_value(list_value, kind) + : replace(list_value, / /g, ","); + + text_value = as_string(text_value); + if (text_value != "") + return condition_text_csv_value(text_value, kind); + + return replace(list_value, / /g, ","); +} + +function combined_domain_csv_value(value, requested_kind) { + let result = []; + + for (let item in split(as_string(value), ",")) { + let normalized = prefixed_domain_value(item, requested_kind); + if (normalized != null) + push(result, normalized); + } + + return join(",", result); +} + +function combined_domain_text_csv_value(value, requested_kind) { + let result = []; + + for (let item in text_list_values(value, "comma-space")) { + let normalized = prefixed_domain_value(item, requested_kind); + if (normalized != null) + push(result, normalized); + } + + return join(",", result); +} + +function concat_csv_values(first, second) { + first = as_string(first); + second = as_string(second); + if (first != "" && second != "") + return first + "," + second; + return first + second; +} + +function combined_condition_csv_value(requested_key, legacy_kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value) { + let combined_text_values = combined_domain_text_csv_value(combined_text_value, requested_key); + let combined_list_values = combined_domain_csv_value(replace(as_string(combined_list_value), / /g, ","), requested_key); + let combined_values = concat_csv_values(combined_text_values, combined_list_values); + + if (requested_key == "domain_suffix") + return combined_values; + + let legacy_values = legacy_condition_csv_value(legacy_kind, text_mode, conditions_text_mode, text_value, list_value); + return concat_csv_values(combined_values, legacy_values); +} + +function rule_condition_csv_value(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value) { + if (key == "domain" || key == "domain_suffix" || key == "domain_keyword" || key == "domain_regex") + return combined_condition_csv_value(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value); + + return legacy_condition_csv_value(kind, text_mode, conditions_text_mode, text_value, list_value); +} + +function normalize_port_number_value(value) { + value = trim(as_string(value)); + if (value == "" || match(value, /^[0-9]+$/) == null) + return null; + + let number = int(value); + return number >= 1 && number <= 65535 ? number : null; +} + +function normalize_port_condition_value(value) { + value = trim(as_string(value)); + if (value == "") + return null; + + let dash = index(value, "-"); + if (dash < 0) { + let number = normalize_port_number_value(value); + return number == null ? null : as_string(number); + } + + let start = normalize_port_number_value(substr(value, 0, dash)); + let end = normalize_port_number_value(substr(value, dash + 1)); + if (start == null || end == null || start > end) + return null; + + return start == end ? as_string(start) : sprintf("%d-%d", start, end); +} + +function normalize_port_range_value(value) { + value = trim(as_string(value)); + let dash = index(value, "-"); + if (dash < 0) + return null; + + let start = normalize_port_number_value(substr(value, 0, dash)); + let end = normalize_port_number_value(substr(value, dash + 1)); + if (start == null || end == null || start > end) + return null; + + return sprintf("%d:%d", start, end); +} + +function append_unique_port(result, seen, item) { + let normalized = normalize_port_condition_value(item); + if (normalized == null || seen[normalized]) + return; + + seen[normalized] = true; + push(result, normalized); +} + +function rule_ports_csv_value(list_values, text_value) { + let result = []; + let seen = {}; + let normalized_list = replace(as_string(list_values), /[[:space:]]+/g, "\n"); + + for (let item in split(normalized_list, "\n")) + append_unique_port(result, seen, item); + + for (let item in text_list_values(text_value, "comma-space")) + append_unique_port(result, seen, item); + + return join(",", result); +} + +let community_subnet_lists = { + twitter: true, + meta: true, + telegram: true, + cloudflare: true, + hetzner: true, + ovh: true, + digitalocean: true, + cloudfront: true, + discord: true, + roblox: true +}; + +function community_service_has_subnet_list(value) { + return community_subnet_lists[as_string(value)] == true; +} + +function filter_community_subnet_lists_value(value) { + let result = []; + + for (let item in whitespace_fields(value)) + if (community_service_has_subnet_list(item)) + push(result, item); + + return join(" ", result); +} + +function has_community_subnet_list(value) { + for (let item in whitespace_fields(value)) + if (community_service_has_subnet_list(item)) + return true; + return false; +} + +return { + text_list_values, + normalize_port_number_value, + normalize_port_condition_value, + normalize_port_range_value, + combined_domain_csv_value, + combined_domain_text_csv_value, + prefixed_domain_kind_value, + prefixed_domain_value, + domain_value_for_key, + normalize_domain_subnet_value, + legacy_condition_csv_value, + rule_condition_csv_value, + rule_ports_csv_value, + community_service_has_subnet_list, + filter_community_subnet_lists_value, + has_community_subnet_list +}; diff --git a/tachyon/files/usr/lib/config/validator.uc b/tachyon/files/usr/lib/config/validator.uc new file mode 100644 index 00000000..a221f0c1 --- /dev/null +++ b/tachyon/files/usr/lib/config/validator.uc @@ -0,0 +1,2147 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core_module = null; +let fixture_uci_data = null; +let subscription_parser_module = null; +let zapret_validator_module = null; +let zapret2_validator_module = null; +let byedpi_validator_module = null; +let constants_module = null; +let core_url = require("core.url"); +let core_ip = require("core.ip"); +let rule_config = require("config.rule"); +let connections = require("config.connections"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const DEFAULT_LATENCY_TEST_URL = "https://www.gstatic.com/generate_204"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_stdin_json() { + let data = read_stdin(); + try { + return json(data); + } + catch (e) { + return null; + } +} + +function read_json_file(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function string_starts_with(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function string_ends_with(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix; +} + +function whitespace_values(value) { + let result = []; + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function option_list_values(section, key) { + let value = type(section) == "object" ? section[key] : null; + if (type(value) == "array") + return value; + return whitespace_values(value); +} + +function list_has_value(values, needle) { + needle = as_string(needle); + if (needle == "") + return false; + + for (let value in whitespace_values(values)) + if (value == needle) + return true; + + return false; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function contains(values, needle) { + for (let value in array_or_empty(values)) + if (value == needle) + return true; + return false; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function list_option(section, key) { + let value = object_or_empty(section)[key]; + if (value == null) + return []; + if (type(value) == "array") + return value; + + value = trim(as_string(value)); + return value == "" ? [] : split(value, " "); +} + +function bool_option(section, key, fallback) { + if (fallback == null) + fallback = false; + + let value = option(section, key, fallback ? "1" : "0"); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function file_executable(path) { + let stat = fs.stat(as_string(path)); + if (stat == null || stat.mode == null) + return false; + + return (int(stat.mode) & 73) != 0; +} + +function file_nonempty(path) { + let stat = fs.stat(as_string(path)); + return stat != null && stat.size != null && stat.size > 0; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function run_args(args) { + return system(command_from_args(args)) == 0; +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_exists(name) { + return system("command -v " + shell_quote(name) + " >/dev/null 2>&1") == 0; +} + +function log_message(message, level) { + level = as_string(level || "info"); + run_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]); +} + +function fail_requirement(message, level) { + log_message(message, level || "error"); + exit(1); +} + +function mkdir_p(paths) { + let args = [ "mkdir", "-p" ]; + + for (let path in array_or_empty(paths)) + if (as_string(path) != "") + push(args, path); + + return length(args) <= 2 || run_args(args); +} + +function safe_rm_rf(path) { + path = as_string(path); + if (path == "" || substr(path, 0, length("/var/run/tachyon/")) != "/var/run/tachyon/") + return true; + + return run_args([ "rm", "-rf", path ]); +} + +function first_line_field_from_text(data, field_index) { + let newline = index(as_string(data), "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + + field_index = int(field_index || 0); + return field_index > 0 && field_index <= length(fields) ? as_string(fields[field_index - 1]) : ""; +} + +function first_line_last_field(data) { + let newline = index(as_string(data), "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[length(fields) - 1]) : ""; +} + +function digit_char(value) { + return match(as_string(value), /^[0-9]$/) != null; +} + +function strip_leading_zeroes(value) { + value = as_string(value); + let i = 0; + while (i < length(value) - 1 && substr(value, i, 1) == "0") + i++; + return substr(value, i); +} + +function version_compare(lhs, rhs) { + lhs = as_string(lhs); + rhs = as_string(rhs); + + let li = 0, ri = 0; + while (li < length(lhs) || ri < length(rhs)) { + if (li >= length(lhs)) + return substr(rhs, ri, 1) == "~" ? 1 : -1; + if (ri >= length(rhs)) + return substr(lhs, li, 1) == "~" ? -1 : 1; + + let lc = substr(lhs, li, 1); + let rc = substr(rhs, ri, 1); + if (lc == rc) { + li++; + ri++; + continue; + } + + if (lc == "~" || rc == "~") + return lc == "~" ? -1 : 1; + + if (digit_char(lc) && digit_char(rc)) { + let ls = li, rs = ri; + while (li < length(lhs) && digit_char(substr(lhs, li, 1))) + li++; + while (ri < length(rhs) && digit_char(substr(rhs, ri, 1))) + ri++; + + let lnum = strip_leading_zeroes(substr(lhs, ls, li - ls)); + let rnum = strip_leading_zeroes(substr(rhs, rs, ri - rs)); + if (length(lnum) != length(rnum)) + return length(lnum) < length(rnum) ? -1 : 1; + if (lnum != rnum) + return lnum < rnum ? -1 : 1; + continue; + } + + return lc < rc ? -1 : 1; + } + + return 0; +} + +function version_at_least(current, required) { + return version_compare(current, required) >= 0; +} + +function constant_value(constants, name) { + return as_string(object_or_empty(constants)[name]); +} + +function uci_show_list_value(value) { + return trim(replace(as_string(value), /['"]/g, "")); +} + +function stdin_first_line_field(field_index) { + let data = read_stdin(); + let newline = index(data, "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + + field_index = int(field_index || 0); + if (field_index > 0 && field_index <= length(fields)) + print(fields[field_index - 1], "\n"); +} + +function country_code_valid(value) { + value = uc(as_string(value)); + return match(value, /^[A-Z][A-Z]$/) != null; +} + +function enum_valid(value, start_index) { + value = as_string(value); + for (let i = start_index; i < length(ARGV); i++) + if (value == as_string(ARGV[i])) + return true; + return false; +} + +function regex_valid(pattern) { + pattern = as_string(pattern); + if (pattern == "") + return true; + + try { + regexp(pattern); + return true; + } + catch (e) { + return false; + } +} + +function combined_domain_valid(value) { + value = trim(as_string(value)); + if (value == "") + return true; + + let normalized = rule_config.prefixed_domain_kind_value(value); + if (normalized == null) + return false; + + return normalized.kind != "domain_regex" || regex_valid(normalized.value); +} + +function combined_domain_text_valid(value) { + for (let item in rule_config.text_list_values(value, "comma-space")) + if (!combined_domain_valid(item)) + return false; + return true; +} + +function community_service_valid(value, services) { + return list_has_value(services, value); +} + +function remote_reference(value) { + return string_starts_with(value, "http://") || string_starts_with(value, "https://"); +} + +function absolute_reference_with_extension(value, first_extension, second_extension) { + value = as_string(value); + if (!string_starts_with(value, "/")) + return false; + + return string_ends_with(value, first_extension) || + (second_extension != null && string_ends_with(value, second_extension)); +} + +function ruleset_reference_valid(reference, community_services) { + reference = as_string(reference); + + return reference == "" || + community_service_valid(reference, community_services) || + remote_reference(reference) || + absolute_reference_with_extension(reference, ".srs", ".json"); +} + +function plain_domain_ip_list_reference_valid(reference) { + reference = as_string(reference); + + return reference == "" || + remote_reference(reference) || + absolute_reference_with_extension(reference, ".lst", null); +} + +function valid_outbound() { + let value = read_stdin_json(); + return type(value) == "object" && type(value.type) == "string"; +} + +function valid_inbound() { + let value = read_stdin_json(); + return type(value) == "object" && type(value.type) == "string"; +} + +function bool_flag(value) { + value = as_string(value); + return value == "1" || value == "true"; +} + +function outbound_detour_source_action(action) { + action = as_string(action); + return connections.is_connections_action(action); +} + +function outbound_detour_target_action(action) { + action = as_string(action); + return connections.is_connections_action(action); +} + +function outbound_detour_rows() { + let rows = []; + + for (let line in split(read_stdin(), "\n")) { + line = replace(as_string(line), /\r/g, ""); + if (trim(line) == "") + continue; + + let fields = split(line, "\t"); + let row = { + section: as_string(fields[0]), + enabled: bool_flag(fields[1]), + action: as_string(fields[2]), + detour_enabled: bool_flag(fields[3]), + detour_section: as_string(fields[4]) + }; + + push(rows, row); + } + + return rows; +} + +function outbound_detour_rows_by_section(rows) { + let by_section = {}; + + for (let row in array_or_empty(rows)) + if (row.section != "") + by_section[row.section] = row; + + return { + rows: rows, + by_section: by_section + }; +} + +function outbound_detour_chain_reaches_source(by_section, source_section, current_section) { + let seen = {}; + + while (current_section != "") { + if (current_section == source_section) + return true; + + if (seen[current_section]) + return false; + seen[current_section] = true; + + let row = by_section[current_section]; + if (type(row) != "object" || !row.enabled || !outbound_detour_source_action(row.action)) + return false; + + if (!row.detour_enabled) + return false; + + current_section = row.detour_section; + } + + return false; +} + +function fail_outbound_detour(message) { + print(message, "\n"); + exit(1); +} + +function fail_validation(message) { + print(message, "\n"); + exit(1); +} + +function validate_outbound_detours_rows(rows) { + let parsed = outbound_detour_rows_by_section(rows); + + for (let row in parsed.rows) { + if (!row.enabled || !row.detour_enabled) + continue; + + if (!outbound_detour_source_action(row.action)) + fail_outbound_detour("Outbound cascade is supported only for Connection rules, but rule '" + + row.section + "' uses action '" + row.action + "'. Aborted."); + + if (row.detour_section == "") + fail_outbound_detour("Outbound cascade is enabled for rule '" + row.section + + "', but no intermediate rule is selected. Aborted."); + + if (row.detour_section == row.section) + fail_outbound_detour("Outbound cascade for rule '" + row.section + "' cannot point to itself. Aborted."); + + let target = parsed.by_section[row.detour_section]; + if (type(target) != "object") + fail_outbound_detour("Outbound cascade for rule '" + row.section + "' references missing rule '" + + row.detour_section + "'. Select an enabled Connection rule or disable cascade connection. Aborted."); + + if (!target.enabled) + fail_outbound_detour("Outbound cascade for rule '" + row.section + "' references disabled rule '" + + row.detour_section + "'. Select an enabled Connection rule or disable cascade connection. Aborted."); + + if (!outbound_detour_target_action(target.action)) + fail_outbound_detour("Outbound cascade for rule '" + row.section + "' references rule '" + + row.detour_section + "', but it is not a Connection rule. Select an enabled Connection rule or disable cascade connection. Aborted."); + + if (outbound_detour_chain_reaches_source(parsed.by_section, row.section, row.detour_section)) + fail_outbound_detour("Outbound cascade for rule '" + row.section + "' creates a cycle through '" + + row.detour_section + "'. Aborted."); + + } +} + +function validate_outbound_detours() { + validate_outbound_detours_rows(outbound_detour_rows()); +} + +function basic_rule_rows() { + let rows = []; + + for (let line in split(read_stdin(), "\n")) { + line = replace(as_string(line), /\r/g, ""); + if (trim(line) == "") + continue; + + let fields = split(line, "\t"); + push(rows, { + section: as_string(fields[0]), + enabled: bool_flag(fields[1]), + action: as_string(fields[2]) + }); + } + + return rows; +} + +function download_section_action_available(action, byedpi_installed, zapret_installed, zapret2_installed) { + action = as_string(action); + if (connections.is_connections_action(action)) + return true; + if (action == "byedpi") + return bool_flag(byedpi_installed); + if (action == "zapret") + return bool_flag(zapret_installed); + if (action == "zapret2") + return bool_flag(zapret2_installed); + + return false; +} + +function validate_download_section_rows(target_section, byedpi_installed, zapret_installed, zapret2_installed, rows) { + target_section = as_string(target_section); + + if (target_section == "") + fail_validation("Downloading external resources through a section is enabled, but no download section is selected. Aborted."); + + let found = false; + let enabled = false; + let outbound = false; + + for (let row in array_or_empty(rows)) { + if (row.section != target_section) + continue; + + found = true; + if (!row.enabled) + continue; + + enabled = true; + if (download_section_action_available(row.action, byedpi_installed, zapret_installed, zapret2_installed)) + outbound = true; + } + + if (!found) + fail_validation("Downloading external resources through a section references missing rule '" + target_section + + "'. Select an enabled rule that can provide an outbound or disable the option. Aborted."); + + if (!enabled) + fail_validation("Downloading external resources through a section references disabled rule '" + target_section + + "'. Select an enabled rule that can provide an outbound or disable the option. Aborted."); + + if (!outbound) + fail_validation("Downloading external resources through a section references rule '" + target_section + + "', but it cannot provide an outbound. Select an enabled Connection, Zapret, Zapret2, or ByeDPI rule with its provider installed, or disable the option. Aborted."); +} + +function validate_download_section(target_section, byedpi_installed, zapret_installed, zapret2_installed) { + validate_download_section_rows(target_section, byedpi_installed, zapret_installed, zapret2_installed, basic_rule_rows()); +} + +function dhcp_has_https_dns_proxy_options(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return null; + + return index(data, "doh_backup_noresolv") >= 0 || + index(data, "doh_backup_server") >= 0 || + index(data, "doh_server") >= 0; +} + +function dhcp_has_https_dns_proxy_options_exit(path) { + let result = dhcp_has_https_dns_proxy_options(path); + exit(result == null ? 2 : (result ? 0 : 1)); +} + +function mwan3_has_enabled_interface_text(data) { + let prefix = "mwan3."; + let sections = {}; + let enabled = {}; + + for (let line in split(as_string(data), "\n")) { + let equals = index(as_string(line), "="); + if (equals < 0) + continue; + + let key = substr(line, 0, equals); + let value = uci_show_list_value(substr(line, equals + 1)); + if (!string_starts_with(key, prefix)) + continue; + + let rest = substr(key, length(prefix)); + if (index(rest, ".") < 0) { + if (value == "interface") + sections[key] = true; + continue; + } + + let option_dot = rindex(key, "."); + let section = substr(key, 0, option_dot); + let option = substr(key, option_dot + 1); + if (option == "enabled") + enabled[section] = value; + } + + for (let section, _ in sections) + if (enabled[section] == "1") + return true; + + return false; +} + +function mwan3_has_enabled_interface() { + return mwan3_has_enabled_interface_text(read_stdin()); +} + +function mwan3_has_enabled_interface_from_sections() { + for (let section in uci_core().section_objects("mwan3", "interface")) + if (option(section, "enabled", "0") == "1") + return true; + return false; +} + +function mwan3_is_active() { + if (!file_executable("/etc/init.d/mwan3") || !file_nonempty("/etc/config/mwan3")) + return false; + + if (!mwan3_has_enabled_interface_from_sections()) + return false; + + return command_success_from_args([ "/etc/init.d/mwan3", "status" ]) || + command_success_from_args([ "/etc/init.d/mwan3", "enabled" ]); +} + +function hex_digit_value(value) { + let pos = index("0123456789abcdef", lc(as_string(value))); + return pos >= 0 ? pos : null; +} + +function parse_number(value) { + value = lc(trim(as_string(value))); + if (value == "") + return null; + + if (substr(value, 0, 2) == "0x") { + value = substr(value, 2); + if (value == "") + return null; + + let result = 0; + for (let i = 0; i < length(value); i++) { + let digit = hex_digit_value(substr(value, i, 1)); + if (digit == null) + return null; + result = result * 16 + digit; + } + return result; + } + + return match(value, /^[0-9]+$/) == null ? null : int(value); +} + +function subscription_parser() { + if (subscription_parser_module == null) + subscription_parser_module = require("subscription.parser"); + return subscription_parser_module; +} + +function zapret_validator() { + if (zapret_validator_module == null) + zapret_validator_module = require("providers.zapret.validator"); + return zapret_validator_module; +} + +function zapret2_validator() { + if (zapret2_validator_module == null) + zapret2_validator_module = require("providers.zapret2.validator"); + return zapret2_validator_module; +} + +function byedpi_validator() { + if (byedpi_validator_module == null) + byedpi_validator_module = require("providers.byedpi.validator"); + return byedpi_validator_module; +} + +function runtime_constants() { + if (constants_module == null) + constants_module = require("core.constants"); + return constants_module; +} + +function uci_core() { + if (uci_core_module == null) + uci_core_module = require("core.uci"); + return uci_core_module; +} + +function fixture_section_list(type_name) { + let value = object_or_empty(fixture_uci_data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(fixture_uci_data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function fixture_get_section(section_name) { + let fixture = object_or_empty(fixture_uci_data); + if (section_name == "settings" && type(fixture.settings) == "object") + return fixture.settings; + + for (let type_name in [ "settings", "server", "section", "subscription_url", "section_interface", "urltest", "priority_group", "priority_level" ]) { + for (let section in fixture_section_list(type_name)) { + if (as_string(section[".name"]) == section_name) + return section; + } + } + + return {}; +} + +function use_fixture_cursor(path) { + fixture_uci_data = object_or_empty(read_json_file(path)); + connections.set_item_sections_from_data(fixture_uci_data); +} + +function settings_section() { + if (fixture_uci_data != null) + return object_or_empty(fixture_get_section("settings")); + return object_or_empty(uci_core().get_all(CONFIG_NAME, "settings")); +} + +function sections_by_type(type_name) { + if (fixture_uci_data != null) + return fixture_section_list(type_name); + + let result = []; + for (let section in uci_core().section_objects(CONFIG_NAME, type_name)) + push(result, object_or_empty(section)); + return result; +} + +function section_name(section) { + return option(section, ".name", ""); +} + +function section_enabled(section) { + return bool_option(section, "enabled", true); +} + +function server_enabled(section) { + return bool_option(section, "enabled", false); +} + +function rule_action(section) { + return option(section, "action", ""); +} + +function rule_action_supported(action) { + return contains([ "connection", "proxy", "outbound", "vpn", "bypass", "block", "dns", "zapret", "zapret2", "byedpi" ], as_string(action)); +} + +function server_routing_section_action_supported(action) { + return contains([ "connection", "proxy", "outbound", "vpn", "zapret", "zapret2", "byedpi" ], as_string(action)); +} + +function duration_to_seconds_value(value) { + let rest = as_string(value); + if (rest == "") + return null; + + let total = 0.0; + let multipliers = { + ns: 0.000000001, + us: 0.000001, + ms: 0.001, + s: 1, + m: 60, + h: 3600, + d: 86400 + }; + + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return null; + + let token = as_string(matched[0]); + let amount = matched[1] * 1; + let unit = matched[3]; + total += amount * multipliers[unit]; + rest = substr(rest, length(token)); + } + + return total <= 0 ? null : int(total + 0.5); +} + +function validate_duration_option(value, label) { + if (as_string(value) == "") + return; + + if (duration_to_seconds_value(value) != null) + return; + + fail_validation("Invalid duration value for " + label + ": " + value + ". Use sing-box duration format like 1d, 12h or 30m. Aborted."); +} + +function validate_required_duration_option(value, label) { + if (as_string(value) == "") + fail_validation("Missing duration value for " + label + ". Use sing-box duration format like 1d, 12h or 30m. Aborted."); + + validate_duration_option(value, label); +} + +function dns_server_value_valid(value) { + value = trim(as_string(value)); + if (value == "" || match(value, /[ \t\r\n@]/) != null) + return false; + + let host = core_url.host(value); + if (host == "") + return false; + + let port = core_url.port(value); + if (port != "" && (match(port, /^[0-9]+$/) == null || int(port) < 1 || int(port) > 65535)) + return false; + + if (core_ip.valid_ip(host)) + return true; + + return length(host) <= 253 && + index(host, ".") > 0 && + substr(host, 0, 1) != "." && + substr(host, length(host) - 1, 1) != "." && + match(host, /[\/:\[\]]/) == null; +} + +function dns_setting_values(settings, key) { + let values = []; + for (let value in option_list_values(settings, key)) { + value = trim(as_string(value)); + if (value != "") + push(values, value); + } + return values; +} + +function validate_dns_settings(settings, sections, context) { + let dns_type = option(settings, "dns_type", "udp"); + if (!contains([ "udp", "dot", "doh" ], dns_type)) + fail_validation("Unsupported DNS protocol type '" + dns_type + "'. Use udp, dot, or doh. Aborted."); + + let dns_strategy = option(settings, "dns_strategy", "prefer_ipv4"); + if (!contains([ "prefer_ipv4", "ipv4_only", "prefer_ipv6", "ipv6_only" ], dns_strategy)) + fail_validation("Unsupported DNS strategy '" + dns_strategy + "'. Use prefer_ipv4, ipv4_only, prefer_ipv6, or ipv6_only. Aborted."); + + let main_servers = dns_setting_values(settings, "dns_server"); + let bootstrap_servers = dns_setting_values(settings, "bootstrap_dns_server"); + if (length(main_servers) == 0) + fail_validation("At least one main DNS server is required. Aborted."); + if (length(bootstrap_servers) == 0) + fail_validation("At least one Bootstrap DNS server is required. Aborted."); + for (let value in main_servers) + if (!dns_server_value_valid(value)) + fail_validation("Invalid main DNS server '" + value + "'. Aborted."); + for (let value in bootstrap_servers) + if (!dns_server_value_valid(value)) + fail_validation("Invalid Bootstrap DNS server '" + value + "'. Aborted."); + + if (length(main_servers) > 1 || length(bootstrap_servers) > 1) { + validate_required_duration_option(option(settings, "dns_check_interval", "10s"), "settings.dns_check_interval"); + validate_required_duration_option(option(settings, "dns_recovery_check_interval", "60s"), "settings.dns_recovery_check_interval"); + validate_required_duration_option(option(settings, "dns_check_timeout", "2s"), "settings.dns_check_timeout"); + } + + if (!bool_option(settings, "dns_detour_enabled", false)) + return; + + let target_name = option(settings, "dns_detour_section", ""); + if (target_name == "") + fail_validation("DNS through a section is enabled, but no section is selected. Aborted."); + + let target = null; + for (let section in sections) + if (section_name(section) == target_name) { + target = { + section: target_name, + enabled: section_enabled(section), + action: rule_action(section) + }; + break; + } + + if (target == null) + fail_validation("DNS through a section references missing rule '" + target_name + "'. Aborted."); + if (!target.enabled) + fail_validation("DNS through a section references disabled rule '" + target_name + "'. Aborted."); + if (!download_section_action_available(target.action, context.byedpi_installed, context.zapret_installed, context.zapret2_installed)) + fail_validation("DNS through a section references rule '" + target_name + "' with unsupported action '" + target.action + "'. Select an enabled Connection, VPN, Zapret, Zapret2, or ByeDPI section. Aborted."); +} + +function normalize_port_number_value(value) { + value = trim(as_string(value)); + if (value == "" || match(value, /^[0-9]+$/) == null) + return null; + + let number = int(value); + return number >= 1 && number <= 65535 ? number : null; +} + +function normalize_port_condition_value(value) { + value = trim(as_string(value)); + if (value == "") + return null; + + let dash = index(value, "-"); + if (dash < 0) + return normalize_port_number_value(value); + + let start = normalize_port_number_value(substr(value, 0, dash)); + let end = normalize_port_number_value(substr(value, dash + 1)); + if (start == null || end == null || start > end) + return null; + + return start == end ? start : sprintf("%d-%d", start, end); +} + +function validate_port_condition_value(value, section) { + if (normalize_port_condition_value(value) != null) + return; + + fail_validation("Invalid port condition '" + value + "' in rule '" + section + "'. Use a single port or range like 80 or 1000-2000. Aborted."); +} + +function validate_subscription_source_entry_value(entry, section) { + if (index(as_string(entry), "|") >= 0) + fail_validation("Invalid subscription URL in rule '" + section + "': Configure User-Agent in the subscription item settings. Aborted."); + + let parsed = subscription_parser().parse_subscription_source_entry(entry); + if (parsed.valid) + return; + + fail_validation("Invalid subscription URL in rule '" + section + "': " + parsed.error + ". Aborted."); +} + +function valid_http_url(value) { + value = trim(as_string(value)); + if (value == "" || match(value, /[ \t\r\n]/) != null) + return false; + + let scheme = lc(core_url.scheme(value)); + if (scheme != "http" && scheme != "https") + return false; + + return core_url.host(value) != ""; +} + +function validate_http_url_option(value, label) { + if (valid_http_url(value)) + return; + + fail_validation("Invalid URL value for " + label + ": " + value + ". Use http:// or https:// URL. Aborted."); +} + +function validate_country_code_value(value, section) { + if (as_string(value) == "" || country_code_valid(value)) + return; + + fail_validation("Invalid country code '" + value + "' in rule '" + section + "'. Aborted."); +} + +function validate_urltest_filter_mode_value(value, section) { + if (as_string(value) == "" || contains([ "disabled", "exclude", "include", "mixed" ], value)) + return; + + fail_validation("Invalid URLTest filter mode '" + value + "' in rule '" + section + "'. Aborted."); +} + +function validate_dashboard_filter_mode_value(value, section) { + if (as_string(value) == "" || contains([ "disabled", "exclude", "include", "mixed" ], value)) + return; + + fail_validation("Invalid dashboard filter mode '" + value + "' in rule '" + section + "'. Aborted."); +} + +function validate_priority_filter_mode_value(value, section, group_id, level_id) { + if (contains([ "disabled", "exclude", "include", "mixed" ], value)) + return; + + fail_validation("Invalid Priority filter mode '" + value + "' in rule '" + section + "', priority '" + group_id + "', level '" + level_id + "'. Aborted."); +} + +function validate_detect_server_country_value(value, section) { + if (as_string(value) == "" || contains([ "flag_emoji", "country_is" ], value)) + return; + + fail_validation("Invalid server country detection mode '" + value + "' in rule '" + section + "'. Aborted."); +} + +function validate_proxy_parameter_value(value, allowed, parameter, section) { + value = lc(as_string(value)); + if (value == "" || contains(allowed, value)) + return; + + fail_validation("Invalid proxy " + parameter + " '" + value + "' in rule '" + section + "'. Aborted."); +} + +function validate_proxy_parameter_filters(protocols, transports, securities, section) { + for (let value in protocols) + validate_proxy_parameter_value(value, + [ "direct", "http", "hysteria2", "shadowsocks", "socks", "trojan", "vless", "vmess" ], + "protocol", section); + for (let value in transports) + validate_proxy_parameter_value(value, + [ "tcp", "ws", "grpc", "http", "httpupgrade", "xhttp" ], + "transport", section); + for (let value in securities) + validate_proxy_parameter_value(value, [ "none", "tls", "reality" ], "security", section); +} + +function validate_urltest_regex_value(value, section) { + if (as_string(value) == "" || regex_valid(value)) + return; + + fail_validation("Invalid URLTest regular expression '" + value + "' in rule '" + section + "'. Aborted."); +} + +function validate_urltest_identifier_value(value, section) { + value = as_string(value); + if (value != "" && match(value, /^[A-Za-z0-9_]+$/) != null) + return; + + fail_validation("Invalid URLTest identifier '" + value + "' in rule '" + section + "'. Use latin letters, digits and underscores. Aborted."); +} + +function validate_priority_identifier_value(value, section) { + value = as_string(value); + if (value != "" && match(value, /^[A-Za-z0-9_]+$/) != null) + return; + + fail_validation("Invalid priority identifier '" + value + "' in rule '" + section + "'. Use latin letters, digits and underscores. Aborted."); +} + +function validate_urltest_tolerance_value(value, section, urltest_id) { + value = trim(as_string(value)); + if (match(value, /^[0-9]+$/) != null) { + let parsed = int(value, 10); + if (parsed >= 0 && parsed <= 10000) + return; + } + + fail_validation("Invalid URLTest tolerance '" + value + "' in rule '" + section + "', URLTest '" + urltest_id + "'. Use a number from 0 to 10000. Aborted."); +} + +function validate_priority_level_order(value, section, group_id, level_id) { + value = trim(as_string(value)); + if (match(value, /^[0-9]+$/) != null) + return; + + fail_validation("Invalid priority level order '" + value + "' in rule '" + section + "', priority '" + group_id + "', level '" + level_id + "'. Use a non-negative integer. Aborted."); +} + +function validate_priority_group(section, group_id) { + let name = section_name(section); + validate_priority_identifier_value(group_id, name); + + if (trim(connections.priority_group_display_name(section, group_id)) == "") + fail_validation("Priority group '" + group_id + "' in rule '" + name + "' has no display name. Aborted."); + + validate_http_url_option(connections.priority_group_health_url(section, group_id), "rule." + name + ".priority." + group_id + ".health_url"); + validate_required_duration_option(connections.priority_group_active_check_interval(section, group_id), "rule." + name + ".priority." + group_id + ".active_check_interval"); + validate_required_duration_option(connections.priority_group_check_timeout(section, group_id), "rule." + name + ".priority." + group_id + ".check_timeout"); + validate_required_duration_option(connections.priority_group_recovery_check_interval(section, group_id), "rule." + name + ".priority." + group_id + ".recovery_check_interval"); + if (connections.priority_group_switch_to_faster_same_priority(section, group_id)) + validate_required_duration_option(connections.priority_group_fastest_check_interval(section, group_id), "rule." + name + ".priority." + group_id + ".fastest_check_interval"); + + for (let level_id in connections.priority_levels(group_id)) { + validate_priority_identifier_value(level_id, name); + if (trim(connections.priority_level_display_name(group_id, level_id)) == "") + fail_validation("Priority level '" + level_id + "' in rule '" + name + "', priority '" + group_id + "' has no display name. Aborted."); + + validate_priority_level_order(connections.priority_level_order(group_id, level_id), name, group_id, level_id); + if (connections.priority_level_direct(group_id, level_id)) + continue; + + let filter_mode = connections.priority_level_filter_mode(group_id, level_id); + validate_priority_filter_mode_value(filter_mode, name, group_id, level_id); + if (filter_mode != "disabled") + validate_detect_server_country_value(connections.priority_level_detect_server_country(group_id, level_id), name); + if (filter_mode == "include" || filter_mode == "mixed") { + for (let value in connections.priority_level_include_countries(group_id, level_id)) + validate_country_code_value(value, name); + for (let value in connections.priority_level_include_regex(group_id, level_id)) + validate_urltest_regex_value(value, name); + if (connections.priority_level_include_proxy_parameters(group_id, level_id)) + validate_proxy_parameter_filters( + connections.priority_level_include_protocols(group_id, level_id), + connections.priority_level_include_transports(group_id, level_id), + connections.priority_level_include_securities(group_id, level_id), + name + ); + } + if (filter_mode == "exclude" || filter_mode == "mixed") { + for (let value in connections.priority_level_exclude_countries(group_id, level_id)) + validate_country_code_value(value, name); + for (let value in connections.priority_level_exclude_regex(group_id, level_id)) + validate_urltest_regex_value(value, name); + if (connections.priority_level_exclude_proxy_parameters(group_id, level_id)) + validate_proxy_parameter_filters( + connections.priority_level_exclude_protocols(group_id, level_id), + connections.priority_level_exclude_transports(group_id, level_id), + connections.priority_level_exclude_securities(group_id, level_id), + name + ); + } + } +} + +function validate_dashboard_group_references(section, values) { + let available = {}; + for (let group_id in connections.urltests(section)) + available[connections.urltest_display_name(section, group_id)] = true; + for (let group_id in connections.priority_groups(section)) + available[connections.priority_group_display_name(section, group_id)] = true; + + for (let group_name in values) + if (!available[group_name]) + fail_validation("Unknown dashboard URLTest/Priority group '" + group_name + "' in rule '" + section_name(section) + "'. Aborted."); +} + +function validate_dashboard_filter(section) { + let name = section_name(section); + let filter_mode = connections.dashboard_filter_mode(section); + validate_dashboard_filter_mode_value(filter_mode, name); + if (filter_mode == "disabled") + return; + + validate_detect_server_country_value(connections.dashboard_detect_server_country(section), name); + if (filter_mode == "include" || filter_mode == "mixed") { + for (let value in connections.dashboard_include_countries(section)) + validate_country_code_value(value, name); + for (let value in connections.dashboard_include_regex(section)) + validate_urltest_regex_value(value, name); + validate_dashboard_group_references(section, connections.dashboard_include_groups(section)); + if (connections.dashboard_include_proxy_parameters(section)) + validate_proxy_parameter_filters( + connections.dashboard_include_protocols(section), + connections.dashboard_include_transports(section), + connections.dashboard_include_securities(section), + name + ); + } + if (filter_mode == "exclude" || filter_mode == "mixed") { + for (let value in connections.dashboard_exclude_countries(section)) + validate_country_code_value(value, name); + for (let value in connections.dashboard_exclude_regex(section)) + validate_urltest_regex_value(value, name); + validate_dashboard_group_references(section, connections.dashboard_exclude_groups(section)); + if (connections.dashboard_exclude_proxy_parameters(section)) + validate_proxy_parameter_filters( + connections.dashboard_exclude_protocols(section), + connections.dashboard_exclude_transports(section), + connections.dashboard_exclude_securities(section), + name + ); + } +} + +function validate_combined_domain_value(value, section) { + if (as_string(value) == "" || combined_domain_valid(value)) + return; + + fail_validation("Invalid domain condition '" + value + "' in rule '" + section + "'. Use plain domains or full:, keyword:, regex: prefixes. Aborted."); +} + +function validate_combined_domain_text_value(value, section) { + if (as_string(value) == "" || combined_domain_text_valid(value)) + return; + + fail_validation("Invalid domain conditions in rule '" + section + "'. Use plain domains or full:, keyword:, regex: prefixes. Aborted."); +} + +function validate_service_value(service, context) { + if (community_service_valid(service, context.community_services)) + return; + + fail_validation("Invalid service in community lists: " + service + ". Check config and LuCI cache. Aborted."); +} + +function validate_ruleset_reference_value(reference, context) { + if (ruleset_reference_valid(reference, context.community_services)) + return; + + fail_validation("Unknown rule set reference '" + reference + "'. Aborted."); +} + +function validate_plain_domain_ip_list_reference_value(reference) { + if (plain_domain_ip_list_reference_valid(reference)) + return; + + fail_validation("Unknown plain list reference '" + reference + "'. Aborted."); +} + +function validate_common_rule_references(section, context) { + for (let value in connections.community_lists(section)) + validate_service_value(value, context); + for (let value in connections.rule_sets(section)) + validate_ruleset_reference_value(value, context); + for (let value in connections.rule_sets_with_subnets(section)) + validate_ruleset_reference_value(value, context); + for (let value in list_option(section, "domain_ip_lists")) + validate_plain_domain_ip_list_reference_value(value); +} + +function outbound_json_object(value) { + try { + value = json(as_string(value)); + } + catch (e) { + return null; + } + + return type(value) == "object" ? value : null; +} + +function valid_outbound_json(value) { + value = outbound_json_object(value); + return value != null && type(value.type) == "string" && trim(as_string(value.type)) != ""; +} + +function validate_outbound_json_rule(section) { + let name = section_name(section); + let outbound_json = option(section, "outbound_json", ""); + + if (outbound_json == "") + fail_validation("JSON outbound rule '" + name + "' has empty outbound_json. Aborted."); + + if (!valid_outbound_json(outbound_json)) + fail_validation("JSON outbound rule '" + name + "' must contain a valid sing-box outbound JSON object with a type field. Aborted."); +} + +function validate_outbound_json_values(section) { + let name = section_name(section); + let values = connections.outbound_jsons(section); + let require_user_tag = length(list_option(section, "outbound_jsons")) > 0; + let tags = {}; + + for (let value in values) { + if (as_string(value) == "") + fail_validation("Connection rule '" + name + "' has empty JSON outbound. Aborted."); + + if (!valid_outbound_json(value)) + fail_validation("Connection rule '" + name + "' must contain valid sing-box outbound JSON objects with a type field. Aborted."); + + let outbound = outbound_json_object(value); + let tag_name = trim(as_string(outbound.tag || "")); + if (tag_name == "" && require_user_tag) + fail_validation("Connection rule '" + name + "' has JSON outbound without a non-empty tag. Aborted."); + if (tag_name != "" && tags[tag_name]) + fail_validation("Connection rule '" + name + "' has duplicate JSON outbound tag '" + tag_name + "'. Aborted."); + if (tag_name != "") + tags[tag_name] = true; + } +} + +function rule_has_subscription_urls(section) { + return length(connections.subscription_urls(section)) > 0; +} + +function subscription_update_interval_for_source(section, entry) { + if (!connections.subscription_update_enabled(section, entry)) + return ""; + let value = connections.subscription_update_interval(section, entry); + return value != "" ? value : "1h"; +} + +function validate_subscription_request_profile(section, entry) { + if (connections.subscription_auto_hwid(section, entry)) + return; + + if (connections.subscription_hwid(section, entry) != "") + return; + + fail_validation("Subscription source in rule '" + section_name(section) + "' has manual HWID enabled but HWID is empty. Fill HWID or enable auto-generation. Aborted."); +} + +function validate_provider_strategy(kind, section, context) { + let name = section_name(section); + + if (kind == "zapret") { + let raw = zapret_validator().strategy_or_default(option(section, "nfqws_opt", ""), context.zapret_default_nfqws_opt); + let result = zapret_validator().validate_strategy("nfqws", raw, context.zapret_legacy_default_nfqws_opt); + if (!result.valid) + fail_validation("Zapret rule '" + name + "' uses invalid NFQWS strategy: " + result.message + " Aborted."); + return; + } + + if (kind == "zapret2") { + let raw = zapret2_validator().strategy_or_default(option(section, "nfqws2_opt", ""), context.zapret2_default_nfqws2_opt); + let result = zapret2_validator().validate_strategy("nfqws2", raw, ""); + if (!result.valid) + fail_validation("Zapret2 rule '" + name + "' uses invalid NFQWS2 strategy: " + result.message + " Aborted."); + return; + } + + let raw = byedpi_validator().strategy_or_default(option(section, "byedpi_cmd_opts", ""), context.byedpi_default_cmd_opts); + let result = byedpi_validator().validate_byedpi_strategy(raw); + if (!result.valid) + fail_validation("Invalid ByeDPI strategy for rule '" + name + "': " + result.message); +} + +function dns_action_has_domain_matchers(section) { + for (let key in [ "domain", "domain_suffix", "domain_keyword", "domain_regex" ]) + if (option(section, key, "") != "" || option(section, key + "_text", "") != "" || length(list_option(section, key)) > 0) + return true; + + return length(connections.community_lists(section)) > 0 || + length(connections.rule_sets(section)) > 0 || + length(list_option(section, "domain_ip_lists")) > 0; +} + +function validate_dns_action(section, sections, context) { + let name = section_name(section); + let dns_type = option(section, "dns_type", "udp"); + if (!contains([ "udp", "dot", "doh" ], dns_type)) + fail_validation("DNS rule '" + name + "' uses unsupported protocol '" + dns_type + "'. Use udp, dot, or doh. Aborted."); + let dns_server = option(section, "dns_server", ""); + if (!dns_server_value_valid(dns_server)) + fail_validation("DNS rule '" + name + "' has an invalid DNS server '" + dns_server + "'. Aborted."); + if (length(connections.rule_sets_with_subnets(section)) > 0) + fail_validation("DNS rule '" + name + "' can use domain-only rule sets, but subnet extraction is enabled. Disable 'Include IP addresses and subnets'. Aborted."); + if (!dns_action_has_domain_matchers(section)) + fail_validation("DNS rule '" + name + "' must contain at least one domain condition or domain rule set. Aborted."); + if (!bool_option(section, "dns_detour_enabled", false)) + return; + + let target_name = option(section, "dns_detour_section", ""); + if (target_name == "") + fail_validation("DNS rule '" + name + "' routes DNS through a section, but no section is selected. Aborted."); + if (target_name == name) + fail_validation("DNS rule '" + name + "' cannot use itself as a DNS detour. Aborted."); + + let target = null; + for (let candidate in sections) + if (section_name(candidate) == target_name) { + target = candidate; + break; + } + if (target == null) + fail_validation("DNS rule '" + name + "' references missing detour section '" + target_name + "'. Aborted."); + if (!section_enabled(target)) + fail_validation("DNS rule '" + name + "' references disabled detour section '" + target_name + "'. Aborted."); + if (!download_section_action_available(rule_action(target), context.byedpi_installed, context.zapret_installed, context.zapret2_installed)) + fail_validation("DNS rule '" + name + "' references detour section '" + target_name + "' with unsupported action '" + rule_action(target) + "'. Select an enabled Connection, VPN, Zapret, Zapret2, or ByeDPI section. Aborted."); +} + +function validate_rule(section, sections, context) { + if (!section_enabled(section)) + return; + + let name = section_name(section); + let action = rule_action(section); + if (action == "") + fail_validation("Enabled rule '" + name + "' has no action. Aborted."); + if (!rule_action_supported(action)) + fail_validation("Enabled rule '" + name + "' uses unsupported action '" + action + "'. Aborted."); + + if (action != "dns") + for (let value in list_option(section, "ports")) + validate_port_condition_value(value, name); + + if (action == "dns") + validate_dns_action(section, sections, context); + + if (action == "zapret") { + if (!context.zapret_installed) { + validate_common_rule_references(section, context); + return; + } + validate_provider_strategy("zapret", section, context); + } + + if (action == "zapret2") { + if (!context.zapret2_installed) { + validate_common_rule_references(section, context); + return; + } + validate_provider_strategy("zapret2", section, context); + } + + if (action == "byedpi") { + if (!context.byedpi_installed) { + validate_common_rule_references(section, context); + return; + } + validate_provider_strategy("byedpi", section, context); + } + + if (connections.is_connections_action(action)) { + validate_dashboard_filter(section); + + for (let urltest_id in connections.urltests(section)) { + validate_urltest_identifier_value(urltest_id, name); + + let urltest_filter_mode = connections.urltest_filter_mode(section, urltest_id); + validate_urltest_filter_mode_value(urltest_filter_mode, name); + if (contains([ "exclude", "include", "mixed" ], urltest_filter_mode)) + validate_detect_server_country_value(connections.urltest_detect_server_country(section, urltest_id), name); + + validate_required_duration_option(connections.urltest_check_interval(section, urltest_id), "rule." + name + ".urltest." + urltest_id + ".urltest_check_interval"); + validate_urltest_tolerance_value(connections.urltest_tolerance(section, urltest_id), name, urltest_id); + validate_http_url_option(connections.urltest_testing_url(section, urltest_id), "rule." + name + ".urltest." + urltest_id + ".testing_url"); + let idle_timeout = connections.urltest_idle_timeout(section, urltest_id); + if (idle_timeout != "") + validate_required_duration_option(idle_timeout, "rule." + name + ".urltest." + urltest_id + ".idle_timeout"); + + if (contains([ "include", "mixed" ], urltest_filter_mode)) { + for (let value in connections.urltest_include_regex(section, urltest_id)) + validate_urltest_regex_value(value, name); + for (let value in connections.urltest_include_countries(section, urltest_id)) + validate_country_code_value(value, name); + if (connections.urltest_include_proxy_parameters(section, urltest_id)) + validate_proxy_parameter_filters( + connections.urltest_include_protocols(section, urltest_id), + connections.urltest_include_transports(section, urltest_id), + connections.urltest_include_securities(section, urltest_id), + name + ); + } + if (contains([ "exclude", "mixed" ], urltest_filter_mode)) { + for (let value in connections.urltest_exclude_countries(section, urltest_id)) + validate_country_code_value(value, name); + for (let value in connections.urltest_exclude_regex(section, urltest_id)) + validate_urltest_regex_value(value, name); + if (connections.urltest_exclude_proxy_parameters(section, urltest_id)) + validate_proxy_parameter_filters( + connections.urltest_exclude_protocols(section, urltest_id), + connections.urltest_exclude_transports(section, urltest_id), + connections.urltest_exclude_securities(section, urltest_id), + name + ); + } + } + + for (let group_id in connections.priority_groups(section)) + validate_priority_group(section, group_id); + + if (rule_has_subscription_urls(section)) { + for (let value in connections.subscription_urls(section)) { + validate_subscription_source_entry_value(value, name); + validate_subscription_request_profile(section, value); + let subscription_update_interval = subscription_update_interval_for_source(section, value); + if (subscription_update_interval != "") + validate_required_duration_option(subscription_update_interval, "rule." + name + ".subscription_update_interval"); + } + } + + validate_outbound_json_values(section); + } + + if (action == "outbound") + validate_outbound_json_rule(section); + + validate_combined_domain_text_value(option(section, "domain", ""), name); + for (let value in list_option(section, "domain_suffix")) + validate_combined_domain_value(value, name); + validate_combined_domain_text_value(option(section, "domain_suffix_text", ""), name); + validate_common_rule_references(section, context); +} + +function download_via_proxy_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy"; + if (purpose == "components") + return "download_components_via_proxy"; + return ""; +} + +function download_via_proxy_section_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy_section"; + if (purpose == "components") + return "download_components_via_proxy_section"; + return ""; +} + +function download_via_proxy_section(settings, purpose) { + let enabled_option = download_via_proxy_option_for_purpose(purpose); + if (enabled_option == "" || !bool_option(settings, enabled_option, false)) + return ""; + + let section_option = download_via_proxy_section_option_for_purpose(purpose); + let configured = section_option != "" ? option(settings, section_option, "") : ""; + if (configured != "") + return configured; + + return option(settings, "download_lists_via_proxy_section", ""); +} + +function download_via_proxy_enabled(settings, purpose) { + let enabled_option = download_via_proxy_option_for_purpose(purpose); + return enabled_option != "" && bool_option(settings, enabled_option, false); +} + +function basic_rows_from_sections(sections) { + let rows = []; + for (let section in sections) { + push(rows, { + section: section_name(section), + enabled: section_enabled(section), + action: rule_action(section) + }); + } + return rows; +} + +function detour_rows_from_sections(sections) { + let rows = []; + for (let section in sections) { + push(rows, { + section: section_name(section), + enabled: section_enabled(section), + action: rule_action(section), + detour_enabled: bool_option(section, "outbound_detour_enabled", false), + detour_section: option(section, "outbound_detour_section", "") + }); + } + return rows; +} + +function validate_subscription_download_sections(sections, context) { + let rows = basic_rows_from_sections(sections); + + for (let section in sections) { + section = object_or_empty(section); + if (!section_enabled(section)) + continue; + + let name = section_name(section); + for (let entry in connections.subscription_urls(section)) { + let target = connections.subscription_download_section(section, entry); + if (target == "") + continue; + + if (target == name) + fail_validation("Subscription source in rule '" + name + "' cannot be downloaded through the same rule. Select another rule or disable download-through-section. Aborted."); + + validate_download_section_rows( + target, + context.byedpi_installed, + context.zapret_installed, + context.zapret2_installed, + rows + ); + } + } +} + +function basic_rows_by_section(rows) { + let result = {}; + for (let row in array_or_empty(rows)) + if (type(row) == "object" && row.section != "") + result[row.section] = row; + return result; +} + +function validate_server_routing_sections(sections) { + let by_section = basic_rows_by_section(basic_rows_from_sections(sections)); + + for (let server in sections_by_type("server")) { + server = object_or_empty(server); + if (!server_enabled(server)) + continue; + + let name = section_name(server); + let mode = option(server, "routing_mode", "rules"); + if (!contains([ "rules", "direct", "section" ], mode)) + fail_validation("Server '" + name + "' uses unsupported routing mode '" + mode + "'. Aborted."); + + if (mode != "section") + continue; + + let target_name = option(server, "routing_section", ""); + if (target_name == "") + fail_validation("Server '" + name + "' uses selected-section routing, but no routing section is selected. Aborted."); + + let target = by_section[target_name]; + if (type(target) != "object") + fail_validation("Server '" + name + "' references missing routing section '" + target_name + "'. Aborted."); + if (!target.enabled) + fail_validation("Server '" + name + "' references disabled routing section '" + target_name + "'. Aborted."); + if (!server_routing_section_action_supported(target.action)) + fail_validation("Server '" + name + "' references routing section '" + target_name + "' with unsupported action '" + target.action + "'. Select a Connection, proxy, JSON outbound, VPN, Zapret, Zapret2, or ByeDPI section. Aborted."); + } +} + +function validate_list_update_settings(settings) { + if (!bool_option(settings, "list_update_enabled", true)) + return; + + let update_interval = option(settings, "update_interval", "1d"); + if (update_interval == "") + update_interval = "1d"; + validate_required_duration_option(update_interval, "settings.update_interval"); +} + +function validate_runtime_mark_ranges_context(context) { + let fakeip_mark = parse_number(context.nft_fakeip_mark); + let outbound_mark = parse_number(context.nft_outbound_mark); + let ranges = [ + [ "Zapret", context.zapret_route_mark_base, context.zapret_queue_range_size, fakeip_mark, "FakeIP mark " + context.nft_fakeip_mark ], + [ "Zapret", context.zapret_route_mark_base, context.zapret_queue_range_size, outbound_mark, "outbound mark " + context.nft_outbound_mark ], + [ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, fakeip_mark, "FakeIP mark " + context.nft_fakeip_mark ], + [ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, outbound_mark, "outbound mark " + context.nft_outbound_mark ] + ]; + + for (let range in ranges) { + let label = as_string(range[0]); + let base = parse_number(range[1]); + let range_size = parse_number(range[2]); + let mark_mask = range[3]; + let mark_mask_label = as_string(range[4]); + + if (base == null || range_size == null || mark_mask == null) + fail_validation(label + " route mark range contains invalid numeric constants. Aborted."); + + for (let index_value = 1; index_value <= range_size; index_value++) { + let mark_value = base + index_value; + if ((mark_value & mark_mask) != 0) + fail_validation(label + " route mark range overlaps " + mark_mask_label + ". Aborted."); + } + } +} + +function validate_runtime_config(context) { + let settings = settings_section(); + let sections = sections_by_type("section"); + + validate_runtime_mark_ranges_context(context); + validate_dns_settings(settings, sections, context); + validate_list_update_settings(settings); + validate_http_url_option(option(settings, "latency_test_url", DEFAULT_LATENCY_TEST_URL) || DEFAULT_LATENCY_TEST_URL, "settings.latency_test_url"); + + if (download_via_proxy_enabled(settings, "lists")) { + validate_download_section_rows( + download_via_proxy_section(settings, "lists"), + context.byedpi_installed, + context.zapret_installed, + context.zapret2_installed, + basic_rows_from_sections(sections) + ); + } + if (download_via_proxy_enabled(settings, "components")) { + validate_download_section_rows( + download_via_proxy_section(settings, "components"), + context.byedpi_installed, + context.zapret_installed, + context.zapret2_installed, + basic_rows_from_sections(sections) + ); + } + + validate_outbound_detours_rows(detour_rows_from_sections(sections)); + validate_subscription_download_sections(sections, context); + validate_server_routing_sections(sections); + + for (let section in sections) + validate_rule(section, sections, context); +} + +function context_from_runtime() { + let constants = object_or_empty(runtime_constants()); + + return { + constants, + community_services: constant_value(constants, "COMMUNITY_SERVICES"), + byedpi_default_cmd_opts: constant_value(constants, "BYEDPI_DEFAULT_CMD_OPTS"), + zapret_default_nfqws_opt: constant_value(constants, "ZAPRET_DEFAULT_NFQWS_OPT"), + zapret_legacy_default_nfqws_opt: constant_value(constants, "ZAPRET_LEGACY_DEFAULT_NFQWS_OPT"), + zapret2_default_nfqws2_opt: constant_value(constants, "ZAPRET2_DEFAULT_NFQWS2_OPT"), + byedpi_installed: file_executable(constant_value(constants, "BYEDPI_BIN")), + zapret_installed: file_executable(constant_value(constants, "ZAPRET_PROVIDER_NFQWS_BIN")), + zapret2_installed: file_executable(constant_value(constants, "ZAPRET2_PROVIDER_NFQWS2_BIN")), + zapret_provider_nfqws_bin: constant_value(constants, "ZAPRET_PROVIDER_NFQWS_BIN"), + zapret2_provider_nfqws2_bin: constant_value(constants, "ZAPRET2_PROVIDER_NFQWS2_BIN"), + zapret_route_mark_base: constant_value(constants, "ZAPRET_ROUTE_MARK_BASE"), + zapret_queue_range_size: constant_value(constants, "ZAPRET_QUEUE_RANGE_SIZE"), + zapret2_route_mark_base: constant_value(constants, "ZAPRET2_ROUTE_MARK_BASE"), + zapret2_queue_range_size: constant_value(constants, "ZAPRET2_QUEUE_RANGE_SIZE"), + nft_fakeip_mark: constant_value(constants, "NFT_FAKEIP_MARK"), + nft_outbound_mark: constant_value(constants, "NFT_OUTBOUND_MARK"), + coreutils_base64_required_version: constant_value(constants, "COREUTILS_BASE64_REQUIRED_VERSION"), + sing_box_required_version: constant_value(constants, "SB_REQUIRED_VERSION"), + sing_box_variant_state_file: constant_value(constants, "SB_VARIANT_STATE_FILE"), + sing_box_version_state_file: constant_value(constants, "SB_VERSION_STATE_FILE"), + sing_box_managed_service_marker: constant_value(constants, "SB_MANAGED_SERVICE_MARKER"), + zapret_legacy_runtime_base_dir: constant_value(constants, "ZAPRET_LEGACY_RUNTIME_BASE_DIR"), + zapret_state_dir: constant_value(constants, "ZAPRET_STATE_DIR"), + zapret_pid_dir: constant_value(constants, "ZAPRET_PID_DIR"), + zapret_child_pid_dir: constant_value(constants, "ZAPRET_CHILD_PID_DIR"), + zapret_log_dir: constant_value(constants, "ZAPRET_LOG_DIR"), + zapret2_state_dir: constant_value(constants, "ZAPRET2_STATE_DIR"), + zapret2_pid_dir: constant_value(constants, "ZAPRET2_PID_DIR"), + zapret2_child_pid_dir: constant_value(constants, "ZAPRET2_CHILD_PID_DIR"), + zapret2_log_dir: constant_value(constants, "ZAPRET2_LOG_DIR"), + byedpi_bin: constant_value(constants, "BYEDPI_BIN"), + byedpi_state_dir: constant_value(constants, "BYEDPI_STATE_DIR"), + byedpi_pid_dir: constant_value(constants, "BYEDPI_PID_DIR"), + byedpi_child_pid_dir: constant_value(constants, "BYEDPI_CHILD_PID_DIR"), + byedpi_log_dir: constant_value(constants, "BYEDPI_LOG_DIR") + }; +} + +function sing_box_variant_marker(ctx, value) { + let path = as_string(ctx.sing_box_variant_state_file); + if (path == "") + path = "/etc/tachyon/sing-box-variant"; + + let data = fs.readfile(path); + return trim(as_string(data)) == as_string(value); +} + +function sing_box_compressed_marker_set(ctx) { + return sing_box_variant_marker(ctx, "extended-compressed"); +} + +function sing_box_extended_marker_set(ctx) { + return sing_box_variant_marker(ctx, "extended"); +} + +function sing_box_version_state(ctx) { + let path = as_string(ctx.sing_box_version_state_file); + if (path == "") + path = "/etc/tachyon/sing-box-version"; + + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : as_string(data); +} + +function get_sing_box_version(ctx) { + if (!command_exists("sing-box")) + return ""; + + if (sing_box_compressed_marker_set(ctx)) + return sing_box_version_state(ctx); + + return first_line_last_field(command_output_from_args([ "sing-box", "version" ])); +} + +function sing_box_version_is_extended(version) { + return index(as_string(version), "extended") >= 0; +} + +function sing_box_is_extended(ctx, version) { + if (as_string(version) == "" && command_exists("sing-box") && + (sing_box_compressed_marker_set(ctx) || sing_box_extended_marker_set(ctx))) + return true; + + return sing_box_version_is_extended(version != null ? version : get_sing_box_version(ctx)); +} + +function sing_box_output_has_build_tag(output, tag) { + tag = as_string(tag); + if (tag == "") + return false; + + for (let token in split(replace(as_string(output), /[,: \t\r\n]+/g, " "), " ")) + if (token == tag) + return true; + + return false; +} + +function sing_box_supports_tailscale(ctx, version, version_output) { + if (command_exists("sing-box") && sing_box_compressed_marker_set(ctx)) + return true; + + if (sing_box_is_extended(ctx, version)) + return true; + + if (as_string(version_output) == "") + version_output = command_output_from_args([ "sing-box", "version" ]); + + return sing_box_output_has_build_tag(version_output, "with_tailscale"); +} + +function managed_sing_box_service_script(marker) { + marker = as_string(marker); + if (marker == "") + marker = "Tachyon managed sing-box service for binary variants"; + + return "#!/bin/sh /etc/rc.common\n" + + "# " + marker + "\n" + + "\n" + + "USE_PROCD=1\n" + + "START=99\n" + + "PROG=\"/usr/bin/sing-box\"\n" + + "\n" + + "start_service() {\n" + + " config_load \"sing-box\"\n" + + " local enabled config_file working_directory\n" + + " local log_stderr\n" + + "\n" + + " config_get_bool enabled \"main\" \"enabled\" \"0\"\n" + + " [ \"$enabled\" -eq \"1\" ] || return 0\n" + + "\n" + + " config_get config_file \"main\" \"conffile\" \"/etc/sing-box/config.json\"\n" + + " config_get working_directory \"main\" \"workdir\" \"/usr/share/sing-box\"\n" + + " config_get_bool log_stderr \"main\" \"log_stderr\" \"1\"\n" + + "\n" + + " procd_open_instance\n" + + " procd_set_param command \"$PROG\" run -c \"$config_file\" -D \"$working_directory\"\n" + + " procd_set_param file \"$config_file\"\n" + + " procd_set_param stderr \"$log_stderr\"\n" + + " procd_set_param limits core=\"unlimited\"\n" + + " procd_set_param limits nofile=\"1000000 1000000\"\n" + + " procd_set_param respawn\n" + + " procd_close_instance\n" + + "}\n" + + "\n" + + "service_triggers() {\n" + + " procd_add_reload_trigger \"sing-box\"\n" + + "}\n"; +} + +function install_managed_sing_box_service_script(ctx) { + let stamp = clock(); + let tmp_file = sprintf("/etc/init.d/sing-box.tachyon.%d.%d", stamp[0], stamp[1]); + + if (!fs.writefile(tmp_file, managed_sing_box_service_script(ctx.sing_box_managed_service_marker))) + return false; + + if (!run_args([ "chmod", "0755", tmp_file ]) || !run_args([ "mv", "-f", tmp_file, "/etc/init.d/sing-box" ])) { + fs.unlink(tmp_file); + return false; + } + + return true; +} + +function service_exists(service) { + return file_executable("/etc/init.d/" + as_string(service)); +} + +function validate_extended_server_features(ctx, sing_box_version, sing_box_version_output) { + if (sing_box_is_extended(ctx, sing_box_version)) + return; + + for (let section in sections_by_type("server")) { + if (!server_enabled(section)) + continue; + + let name = section_name(section); + let protocol = option(section, "protocol", "vless"); + let transport = option(section, "transport", "tcp"); + + if (protocol == "mtproto") + fail_requirement("Server '" + name + "' uses MTProto proxy, but sing-box-extended is not installed. Install sing-box-extended or disable this server. Aborted.", "fatal"); + + if (protocol == "tailscale" && !sing_box_supports_tailscale(ctx, sing_box_version, sing_box_version_output)) + fail_requirement("Server '" + name + "' uses Tailscale, but the installed sing-box binary was built without Tailscale support. Install full sing-box or sing-box-extended, or disable this server. Aborted.", "fatal"); + + if (transport == "xhttp") + fail_requirement("Server '" + name + "' uses XHTTP transport, but sing-box-extended is not installed. Install sing-box-extended or change the transport. Aborted.", "fatal"); + } +} + +function has_outbound_section(ctx) { + for (let section in sections_by_type("section")) { + if (!section_enabled(section)) + continue; + + let action = rule_action(section); + if (action == "byedpi" && ctx.byedpi_installed) + return true; + if (action == "zapret" && ctx.zapret_installed) + return true; + if (action == "zapret2" && ctx.zapret2_installed) + return true; + + if (length(connections.connection_urls(section)) > 0 || + length(connections.subscription_urls(section)) > 0 || + length(connections.outbound_jsons(section)) > 0 || + length(connections.interfaces(section)) > 0) + return true; + } + + return false; +} + +function has_enabled_rule_action(action) { + action = as_string(action); + + for (let section in sections_by_type("section")) + if (section_enabled(section) && rule_action(section) == action) + return true; + + return false; +} + +function cleanup_legacy_zapret_runtime(ctx) { + let base = as_string(ctx.zapret_legacy_runtime_base_dir); + if (base == "") + return; + + let needle = base + "/nfq/nfqws"; + for (let line in split(command_output_from_args([ "ps", "w" ]), "\n")) { + if (index(as_string(line), needle) < 0) + continue; + + let fields = split(trim(as_string(line)), /[ \t]+/); + let pid = length(fields) > 0 ? as_string(fields[0]) : ""; + if (match(pid, /^[0-9]+$/) != null) + run_args([ "kill", pid ]); + } + + safe_rm_rf(base); +} + +function check_provider_requirement(action, display_name, bin_path, dirs, missing_message, prepare_failure_message) { + if (!has_enabled_rule_action(action)) + return; + + if (!file_executable(bin_path)) { + log_message(missing_message, "error"); + return; + } + + if (!mkdir_p(dirs)) + fail_requirement(prepare_failure_message, "fatal"); +} + +function check_provider_requirements(ctx) { + cleanup_legacy_zapret_runtime(ctx); + + check_provider_requirement( + "zapret", + "Zapret", + ctx.zapret_provider_nfqws_bin, + [ ctx.zapret_state_dir, ctx.zapret_pid_dir, ctx.zapret_child_pid_dir, ctx.zapret_log_dir ], + "Zapret provider is not available at " + ctx.zapret_provider_nfqws_bin + ". Rules with action 'zapret' will be skipped until the zapret provider is installed.", + "Failed to prepare the Tachyon zapret state directory in " + ctx.zapret_state_dir + ". Aborted." + ); + + check_provider_requirement( + "zapret2", + "Zapret2", + ctx.zapret2_provider_nfqws2_bin, + [ ctx.zapret2_state_dir, ctx.zapret2_pid_dir, ctx.zapret2_child_pid_dir, ctx.zapret2_log_dir ], + "Zapret2 provider is not available at " + ctx.zapret2_provider_nfqws2_bin + ". Rules with action 'zapret2' will be skipped until the zapret2 provider is installed.", + "Failed to prepare the Tachyon zapret2 state directory in " + ctx.zapret2_state_dir + ". Aborted." + ); + + check_provider_requirement( + "byedpi", + "ByeDPI", + ctx.byedpi_bin, + [ ctx.byedpi_state_dir, ctx.byedpi_pid_dir, ctx.byedpi_child_pid_dir, ctx.byedpi_log_dir ], + "ByeDPI provider is not available at " + ctx.byedpi_bin + ". Rules with action 'byedpi' will be skipped until the byedpi package is installed.", + "Failed to prepare the Tachyon ByeDPI state directory in " + ctx.byedpi_state_dir + ". Aborted." + ); +} + +function check_runtime_requirements() { + log_message("Checking required packages and runtime settings", "info"); + + let ctx = context_from_runtime(); + let sing_box_version_output = command_exists("sing-box") ? command_output_from_args([ "sing-box", "version" ]) : ""; + let sing_box_version = sing_box_compressed_marker_set(ctx) ? sing_box_version_state(ctx) : first_line_last_field(sing_box_version_output); + let coreutils_base64_version = first_line_field_from_text(command_output("base64 --version 2>/dev/null"), 4); + + if (sing_box_version == "") { + if (!command_exists("sing-box") || !sing_box_compressed_marker_set(ctx)) + fail_requirement("Package 'sing-box' is not installed. Aborted.", "error"); + } + else if (!version_at_least(sing_box_version, ctx.sing_box_required_version)) { + fail_requirement("Package 'sing-box' version (" + sing_box_version + ") is lower than the required minimum (" + ctx.sing_box_required_version + "). Update sing-box: opkg update && opkg remove sing-box && opkg install sing-box. Aborted.", "error"); + } + + if (!service_exists("sing-box") && sing_box_compressed_marker_set(ctx)) + install_managed_sing_box_service_script(ctx); + + if (!service_exists("sing-box")) + fail_requirement("Service 'sing-box' is missing. Install a sing-box package or reinstall the compressed sing-box-extended binary variant. Aborted.", "error"); + + validate_extended_server_features(ctx, sing_box_version, sing_box_version_output); + + if (coreutils_base64_version == "") + fail_requirement("Package 'coreutils-base64' is not installed. Aborted.", "error"); + else if (!version_at_least(coreutils_base64_version, ctx.coreutils_base64_required_version)) + log_message("Package 'coreutils-base64' version (" + coreutils_base64_version + ") is lower than the required minimum (" + ctx.coreutils_base64_required_version + "). This may cause issues when decoding base64 streams with missing padding, as automatic padding support is not available in older versions.", "warn"); + + if (dhcp_has_https_dns_proxy_options("/etc/config/dhcp") === true) + log_message("https-dns-proxy is enabled in DHCP config. Disable it or edit /etc/config/dhcp before starting Tachyon.", "error"); + + if (has_outbound_section(ctx)) + log_message("Proxy outbound configuration found", "debug"); + else + log_message("No proxy outbound sections found. Tachyon will use direct and/or provider-only routing.", "warn"); + + check_provider_requirements(ctx); +} + +function context_override_value(overrides, defaults, key) { + let value = object_or_empty(overrides)[key]; + if (value != null) + return as_string(value); + return as_string(object_or_empty(defaults)[key]); +} + +function context_override_bool(overrides, defaults, key) { + let value = object_or_empty(overrides)[key]; + if (value != null) + return bool_flag(value); + return bool_flag(object_or_empty(defaults)[key]); +} + +function context_from_json(value, defaults) { + try { + value = json(as_string(value)); + } + catch (e) { + value = {}; + } + + value = object_or_empty(value); + defaults = object_or_empty(defaults); + + return { + community_services: context_override_value(value, defaults, "community_services"), + byedpi_default_cmd_opts: context_override_value(value, defaults, "byedpi_default_cmd_opts"), + zapret_default_nfqws_opt: context_override_value(value, defaults, "zapret_default_nfqws_opt"), + zapret_legacy_default_nfqws_opt: context_override_value(value, defaults, "zapret_legacy_default_nfqws_opt"), + zapret2_default_nfqws2_opt: context_override_value(value, defaults, "zapret2_default_nfqws2_opt"), + byedpi_installed: context_override_bool(value, defaults, "byedpi_installed"), + zapret_installed: context_override_bool(value, defaults, "zapret_installed"), + zapret2_installed: context_override_bool(value, defaults, "zapret2_installed"), + zapret_route_mark_base: context_override_value(value, defaults, "zapret_route_mark_base"), + zapret_queue_range_size: context_override_value(value, defaults, "zapret_queue_range_size"), + zapret2_route_mark_base: context_override_value(value, defaults, "zapret2_route_mark_base"), + zapret2_queue_range_size: context_override_value(value, defaults, "zapret2_queue_range_size"), + nft_fakeip_mark: context_override_value(value, defaults, "nft_fakeip_mark"), + nft_outbound_mark: context_override_value(value, defaults, "nft_outbound_mark") + }; +} + +let mode = ARGV[0] || ""; + +if (mode == "stdin-first-line-field") + stdin_first_line_field(ARGV[1]); +else if (mode == "country-code-valid") + exit(country_code_valid(ARGV[1]) ? 0 : 1); +else if (mode == "enum-valid") + exit(enum_valid(ARGV[1], 2) ? 0 : 1); +else if (mode == "regex-valid") + exit(regex_valid(ARGV[1]) ? 0 : 1); +else if (mode == "combined-domain-valid") + exit(combined_domain_valid(ARGV[1]) ? 0 : 1); +else if (mode == "combined-domain-text-valid") + exit(combined_domain_text_valid(ARGV[1]) ? 0 : 1); +else if (mode == "community-service-valid") + exit(community_service_valid(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "ruleset-reference-valid") + exit(ruleset_reference_valid(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "plain-domain-ip-list-reference-valid") + exit(plain_domain_ip_list_reference_valid(ARGV[1]) ? 0 : 1); +else if (mode == "valid-outbound") + exit(valid_outbound() ? 0 : 1); +else if (mode == "valid-inbound") + exit(valid_inbound() ? 0 : 1); +else if (mode == "validate-outbound-detours") + validate_outbound_detours(); +else if (mode == "validate-download-section") + validate_download_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "dhcp-has-https-dns-proxy-options") + dhcp_has_https_dns_proxy_options_exit(ARGV[1]); +else if (mode == "mwan3-has-enabled-interface") + exit(mwan3_has_enabled_interface() ? 0 : 1); +else if (mode == "mwan3-is-active") + exit(mwan3_is_active() ? 0 : 1); +else if (mode == "check-requirements") + check_runtime_requirements(); +else if (mode == "validate-runtime") + validate_runtime_config(context_from_runtime()); +else if (mode == "validate-runtime-fixture") { + use_fixture_cursor(ARGV[1]); + validate_runtime_config(context_from_json(ARGV[2], context_from_runtime())); +} +else { + warn("Usage: config/validator.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/core/common.uc b/tachyon/files/usr/lib/core/common.uc new file mode 100644 index 00000000..2cea2072 --- /dev/null +++ b/tachyon/files/usr/lib/core/common.uc @@ -0,0 +1,148 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_stdin_json() { + let data = read_stdin(); + try { + return json(data); + } + catch (e) { + return null; + } +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function write_compact_string_array(values) { + print("["); + for (let i = 0; i < length(values); i++) { + if (i > 0) + print(","); + print(sprintf("%J", as_string(values[i]))); + } + print("]\n"); +} + +function csv_to_json_array(value) { + value = as_string(value); + write_compact_string_array(value == "" ? [] : split(value, ",")); +} + +function write_json_file(path, value) { + return fs.writefile(path, sprintf("%J\n", value)); +} + +function strip_internal_fields(value) { + if (type(value) == "array") { + for (let i = 0; i < length(value); i++) + value[i] = strip_internal_fields(value[i]); + return value; + } + + if (type(value) == "object") { + for (let key in keys(value)) { + if (substr(key, 0, 2) == "__") { + delete value[key]; + continue; + } + value[key] = strip_internal_fields(value[key]); + } + } + + return value; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function object_key_count(value) { + return type(value) == "object" ? length(keys(value)) : 0; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function list_option(section, key) { + let value = object_or_empty(section)[key]; + if (value == null) + return []; + if (type(value) == "array") + return value; + let text = trim(as_string(value)); + return text == "" ? [] : split(text, " "); +} + +function bool_option(section, key, fallback) { + if (fallback == null) + fallback = false; + let value = option(section, key, fallback ? "1" : "0"); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function int_option(section, key, fallback) { + let value = option(section, key, fallback); + if (match(value, /[^0-9]/)) + return int(fallback, 10); + return int(value, 10); +} + +return { + as_string, + read_json_file, + read_stdin, + read_stdin_json, + write_json, + write_compact_string_array, + csv_to_json_array, + write_json_file, + strip_internal_fields, + array_or_empty, + object_or_empty, + object_key_count, + option, + list_option, + bool_option, + int_option +}; diff --git a/tachyon/files/usr/lib/core/constants.uc b/tachyon/files/usr/lib/core/constants.uc new file mode 100644 index 00000000..af629781 --- /dev/null +++ b/tachyon/files/usr/lib/core/constants.uc @@ -0,0 +1,174 @@ +#!/usr/bin/env ucode + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function env(name, fallback) { + let value = getenv(name); + return value == null ? as_string(fallback) : as_string(value); +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function constants_map() { + let c = {}; + + c.TACHYON_VERSION = env("TACHYON_VERSION", "__COMPILED_VERSION_VARIABLE__"); + c.TACHYON_CONFIG_NAME = env("TACHYON_CONFIG_NAME", "tachyon"); + c.TACHYON_CONFIG = env("TACHYON_CONFIG", "/etc/config/" + c.TACHYON_CONFIG_NAME); + c.TACHYON_BIN = env("TACHYON_BIN", "/usr/bin/tachyon"); + c.TACHYON_SERVICE_NAME = env("TACHYON_SERVICE_NAME", "tachyon"); + c.TACHYON_SERVICE_INIT = env("TACHYON_SERVICE_INIT", "/etc/init.d/tachyon"); + c.TACHYON_RELEASE_REPO = env("TACHYON_RELEASE_REPO", "ushan0v/tachyon"); + c.TACHYON_LUCI_VIEW_NAMESPACE = env("TACHYON_LUCI_VIEW_NAMESPACE", "tachyon"); + c.TACHYON_LUCI_VIEW_DIR = env("TACHYON_LUCI_VIEW_DIR", "/www/luci-static/resources/view/" + c.TACHYON_LUCI_VIEW_NAMESPACE); + c.TACHYON_LUCI_I18N_DOMAIN = env("TACHYON_LUCI_I18N_DOMAIN", "tachyon"); + + c.RESOLV_CONF = env("RESOLV_CONF", "/etc/resolv.conf"); + c.CHECK_PROXY_IP_DOMAIN = env("CHECK_PROXY_IP_DOMAIN", "ip.podkop.fyi"); + c.FAKEIP_TEST_DOMAIN = env("FAKEIP_TEST_DOMAIN", "fakeip.podkop.fyi"); + c.TMP_SING_BOX_FOLDER = env("TMP_SING_BOX_FOLDER", "/tmp/sing-box"); + c.TMP_RULESET_FOLDER = env("TMP_RULESET_FOLDER", c.TMP_SING_BOX_FOLDER + "/rulesets"); + c.TMP_SUBSCRIPTION_FOLDER = env("TMP_SUBSCRIPTION_FOLDER", c.TMP_SING_BOX_FOLDER + "/subscriptions"); + c.CLOUDFLARE_OCTETS = env("CLOUDFLARE_OCTETS", "8.47 162.159 188.114"); + c.COREUTILS_BASE64_REQUIRED_VERSION = env("COREUTILS_BASE64_REQUIRED_VERSION", "9.7"); + c.RT_TABLE_NAME = env("RT_TABLE_NAME", "tachyon"); + + c.NFT_TABLE_NAME = env("NFT_TABLE_NAME", "TachyonTable"); + c.NFT_LOCALV4_SET_NAME = env("NFT_LOCALV4_SET_NAME", "localv4"); + c.NFT_LOCALV6_SET_NAME = env("NFT_LOCALV6_SET_NAME", "localv6"); + c.NFT_COMMON_SET_NAME = env("NFT_COMMON_SET_NAME", "tachyon_subnets"); + c.NFT_COMMON6_SET_NAME = env("NFT_COMMON6_SET_NAME", "tachyon_subnets6"); + c.NFT_PORT_SET_NAME = env("NFT_PORT_SET_NAME", "tachyon_ports"); + c.NFT_IP_PORT_SET_NAME = env("NFT_IP_PORT_SET_NAME", "tachyon_ip_ports"); + c.NFT_IP_PORT6_SET_NAME = env("NFT_IP_PORT6_SET_NAME", "tachyon_ip6_ports"); + c.NFT_DISCORD_SET_NAME = env("NFT_DISCORD_SET_NAME", "tachyon_discord_subnets"); + c.NFT_DISCORD6_SET_NAME = env("NFT_DISCORD6_SET_NAME", "tachyon_discord_subnets6"); + c.NFT_INTERFACE_SET_NAME = env("NFT_INTERFACE_SET_NAME", "tachyon_interfaces"); + c.NFT_FAKEIP_MARK = env("NFT_FAKEIP_MARK", "0x00100000"); + c.NFT_OUTBOUND_MARK = env("NFT_OUTBOUND_MARK", "0x00200000"); + + c.SB_REQUIRED_VERSION = env("SB_REQUIRED_VERSION", "1.12.0"); + c.SB_MANAGED_SERVICE_MARKER = env("SB_MANAGED_SERVICE_MARKER", "Tachyon managed sing-box service for binary variants"); + c.SB_DNS_SERVER_TAG = env("SB_DNS_SERVER_TAG", "dns-server"); + c.SB_FAKEIP_DNS_SERVER_TAG = env("SB_FAKEIP_DNS_SERVER_TAG", "fakeip-server"); + c.SB_FAKEIP_INET4_RANGE = env("SB_FAKEIP_INET4_RANGE", "198.18.0.0/15"); + c.SB_FAKEIP_INET6_RANGE = env("SB_FAKEIP_INET6_RANGE", "fc00::/18"); + c.SB_BOOTSTRAP_SERVER_TAG = env("SB_BOOTSTRAP_SERVER_TAG", "bootstrap-dns-server"); + c.SB_FAKEIP_DNS_RULE_TAG = env("SB_FAKEIP_DNS_RULE_TAG", "fakeip-dns-rule-tag"); + c.SB_FAKEIP_RULESET_DNS_RULE_TAG = env("SB_FAKEIP_RULESET_DNS_RULE_TAG", "fakeip-ruleset-dns-rule-tag"); + c.SB_SERVICE_FAKEIP_DNS_RULE_TAG = env("SB_SERVICE_FAKEIP_DNS_RULE_TAG", "service-fakeip-dns-rule-tag"); + c.SB_TPROXY_INBOUND_TAG = env("SB_TPROXY_INBOUND_TAG", "tproxy-in"); + c.SB_TPROXY_INBOUND_ADDRESS = env("SB_TPROXY_INBOUND_ADDRESS", "0.0.0.0"); + c.SB_TPROXY_INBOUND6_TAG = env("SB_TPROXY_INBOUND6_TAG", "tproxy6-in"); + c.SB_TPROXY_INBOUND6_ADDRESS = env("SB_TPROXY_INBOUND6_ADDRESS", "::1"); + c.SB_TPROXY_INBOUND_PORT = env("SB_TPROXY_INBOUND_PORT", "1602"); + c.SB_DNS_INBOUND_TAG = env("SB_DNS_INBOUND_TAG", "dns-in"); + c.SB_DNS_INBOUND_ADDRESS = env("SB_DNS_INBOUND_ADDRESS", "127.0.0.42"); + c.SB_DNS_INBOUND_PORT = env("SB_DNS_INBOUND_PORT", "53"); + c.SB_SERVICE_MIXED_INBOUND_TAG = env("SB_SERVICE_MIXED_INBOUND_TAG", "service-mixed-in"); + c.SB_SERVICE_MIXED_INBOUND_ADDRESS = env("SB_SERVICE_MIXED_INBOUND_ADDRESS", "127.0.0.1"); + c.SB_SERVICE_MIXED_INBOUND_PORT = env("SB_SERVICE_MIXED_INBOUND_PORT", "4534"); + c.SB_DIRECT_OUTBOUND_TAG = env("SB_DIRECT_OUTBOUND_TAG", "direct-out"); + c.SB_BYPASS_OUTBOUND_TAG = env("SB_BYPASS_OUTBOUND_TAG", "bypass-out"); + c.SB_CLASH_API_CONTROLLER_PORT = env("SB_CLASH_API_CONTROLLER_PORT", "9090"); + c.SB_VARIANT_STATE_FILE = env("SB_VARIANT_STATE_FILE", "/etc/tachyon/sing-box-variant"); + c.SB_VERSION_STATE_FILE = env("SB_VERSION_STATE_FILE", "/etc/tachyon/sing-box-version"); + + c.GITHUB_RAW_URL = env("GITHUB_RAW_URL", "https://raw.githubusercontent.com/itdoginfo/allow-domains/main"); + c.SRS_MAIN_URL = env("SRS_MAIN_URL", "https://github.com/itdoginfo/allow-domains/releases/latest/download"); + c.SRS_ADS_HAGEZI_PRO_URL = env("SRS_ADS_HAGEZI_PRO_URL", "https://github.com/zxc-rv/ad-filter/releases/latest/download/adlist.srs"); + c.SRS_SUPERCELL_URL = env("SRS_SUPERCELL_URL", "https://raw.githubusercontent.com/ushan0v/sing-box-supercell-ruleset/main/supercell.srs"); + c.SUBNETS_TWITTER = env("SUBNETS_TWITTER", c.GITHUB_RAW_URL + "/Subnets/IPv4/twitter.lst"); + c.SUBNETS_META = env("SUBNETS_META", c.GITHUB_RAW_URL + "/Subnets/IPv4/meta.lst"); + c.SUBNETS_DISCORD = env("SUBNETS_DISCORD", c.GITHUB_RAW_URL + "/Subnets/IPv4/discord.lst"); + c.SUBNETS_ROBLOX = env("SUBNETS_ROBLOX", c.GITHUB_RAW_URL + "/Subnets/IPv4/roblox.lst"); + c.SUBNETS_TELERAM = env("SUBNETS_TELERAM", c.GITHUB_RAW_URL + "/Subnets/IPv4/telegram.lst"); + c.SUBNETS_CLOUDFLARE = env("SUBNETS_CLOUDFLARE", c.GITHUB_RAW_URL + "/Subnets/IPv4/cloudflare.lst"); + c.SUBNETS_HETZNER = env("SUBNETS_HETZNER", c.GITHUB_RAW_URL + "/Subnets/IPv4/hetzner.lst"); + c.SUBNETS_OVH = env("SUBNETS_OVH", c.GITHUB_RAW_URL + "/Subnets/IPv4/ovh.lst"); + c.SUBNETS_DIGITALOCEAN = env("SUBNETS_DIGITALOCEAN", c.GITHUB_RAW_URL + "/Subnets/IPv4/digitalocean.lst"); + c.SUBNETS_CLOUDFRONT = env("SUBNETS_CLOUDFRONT", c.GITHUB_RAW_URL + "/Subnets/IPv4/cloudfront.lst"); + c.COMMUNITY_SERVICES = env("COMMUNITY_SERVICES", "russia_inside russia_outside ukraine_inside geoblock block porn news anime youtube hdrezka tiktok google_ai google_play hodca discord meta twitter cloudflare cloudfront digitalocean hetzner ovh telegram roblox ads_hagezi_pro supercell github"); + + c.ZAPRET_PROVIDER_BASE_DIR = env("ZAPRET_PROVIDER_BASE_DIR", "/opt/zapret"); + c.ZAPRET_PROVIDER_NFQWS_BIN = env("ZAPRET_PROVIDER_NFQWS_BIN", c.ZAPRET_PROVIDER_BASE_DIR + "/nfq/nfqws"); + c.ZAPRET_PROVIDER_FILES_DIR = env("ZAPRET_PROVIDER_FILES_DIR", c.ZAPRET_PROVIDER_BASE_DIR + "/files"); + c.ZAPRET_PROVIDER_IPSET_DIR = env("ZAPRET_PROVIDER_IPSET_DIR", c.ZAPRET_PROVIDER_BASE_DIR + "/ipset"); + c.ZAPRET_LEGACY_RUNTIME_BASE_DIR = env("ZAPRET_LEGACY_RUNTIME_BASE_DIR", "/var/run/tachyon/zapret-runtime"); + c.ZAPRET_NFQWS_BIN = env("ZAPRET_NFQWS_BIN", c.ZAPRET_PROVIDER_NFQWS_BIN); + c.ZAPRET_STATE_DIR = env("ZAPRET_STATE_DIR", "/var/run/tachyon/zapret"); + c.ZAPRET_PID_DIR = env("ZAPRET_PID_DIR", c.ZAPRET_STATE_DIR + "/pid"); + c.ZAPRET_CHILD_PID_DIR = env("ZAPRET_CHILD_PID_DIR", c.ZAPRET_STATE_DIR + "/child-pid"); + c.ZAPRET_LOG_DIR = env("ZAPRET_LOG_DIR", c.ZAPRET_STATE_DIR + "/log"); + c.ZAPRET_HOSTLIST_DIR = env("ZAPRET_HOSTLIST_DIR", c.ZAPRET_STATE_DIR + "/hostlist"); + c.ZAPRET_ROUTE_MARK_BASE = env("ZAPRET_ROUTE_MARK_BASE", "0x01000000"); + c.ZAPRET_QUEUE_BASE = env("ZAPRET_QUEUE_BASE", "4000"); + c.ZAPRET_QUEUE_RANGE_SIZE = env("ZAPRET_QUEUE_RANGE_SIZE", "256"); + c.ZAPRET_NFQWS_RESPAWN_DELAY = env("ZAPRET_NFQWS_RESPAWN_DELAY", "5"); + c.ZAPRET_DESYNC_MARK = env("ZAPRET_DESYNC_MARK", "0x40000000"); + c.ZAPRET_DESYNC_MARK_POSTNAT = env("ZAPRET_DESYNC_MARK_POSTNAT", "0x20000000"); + c.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT = env("ZAPRET_LEGACY_DEFAULT_NFQWS_OPT", "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --new --filter-tcp=443 --dpi-desync=multidisorder --dpi-desync-split-pos=1,sniext+1,host+1,midsld-2,midsld,midsld+2,endhost-1"); + c.ZAPRET_DEFAULT_NFQWS_OPT = env("ZAPRET_DEFAULT_NFQWS_OPT", "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin"); + + c.ZAPRET2_PROVIDER_BASE_DIR = env("ZAPRET2_PROVIDER_BASE_DIR", "/opt/zapret2"); + c.ZAPRET2_PROVIDER_NFQWS2_BIN = env("ZAPRET2_PROVIDER_NFQWS2_BIN", c.ZAPRET2_PROVIDER_BASE_DIR + "/nfq2/nfqws2"); + c.ZAPRET2_PROVIDER_FILES_DIR = env("ZAPRET2_PROVIDER_FILES_DIR", c.ZAPRET2_PROVIDER_BASE_DIR + "/files"); + c.ZAPRET2_PROVIDER_IPSET_DIR = env("ZAPRET2_PROVIDER_IPSET_DIR", c.ZAPRET2_PROVIDER_BASE_DIR + "/ipset"); + c.ZAPRET2_PROVIDER_LUA_DIR = env("ZAPRET2_PROVIDER_LUA_DIR", c.ZAPRET2_PROVIDER_BASE_DIR + "/lua"); + c.ZAPRET2_NFQWS2_BIN = env("ZAPRET2_NFQWS2_BIN", c.ZAPRET2_PROVIDER_NFQWS2_BIN); + c.ZAPRET2_STATE_DIR = env("ZAPRET2_STATE_DIR", "/var/run/tachyon/zapret2"); + c.ZAPRET2_PID_DIR = env("ZAPRET2_PID_DIR", c.ZAPRET2_STATE_DIR + "/pid"); + c.ZAPRET2_CHILD_PID_DIR = env("ZAPRET2_CHILD_PID_DIR", c.ZAPRET2_STATE_DIR + "/child-pid"); + c.ZAPRET2_LOG_DIR = env("ZAPRET2_LOG_DIR", c.ZAPRET2_STATE_DIR + "/log"); + c.ZAPRET2_ROUTE_MARK_BASE = env("ZAPRET2_ROUTE_MARK_BASE", "0x01010000"); + c.ZAPRET2_QUEUE_BASE = env("ZAPRET2_QUEUE_BASE", "4300"); + c.ZAPRET2_QUEUE_RANGE_SIZE = env("ZAPRET2_QUEUE_RANGE_SIZE", "256"); + c.ZAPRET2_NFQWS2_RESPAWN_DELAY = env("ZAPRET2_NFQWS2_RESPAWN_DELAY", "5"); + c.ZAPRET2_DESYNC_MARK = env("ZAPRET2_DESYNC_MARK", "0x40000000"); + c.ZAPRET2_DESYNC_MARK_POSTNAT = env("ZAPRET2_DESYNC_MARK_POSTNAT", "0x20000000"); + c.ZAPRET2_DEFAULT_NFQWS2_OPT = env("ZAPRET2_DEFAULT_NFQWS2_OPT", "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=fake_default_quic:repeats=6"); + + c.BYEDPI_BIN = env("BYEDPI_BIN", "/usr/bin/ciadpi"); + c.BYEDPI_SERVICE_INIT = env("BYEDPI_SERVICE_INIT", "/etc/init.d/byedpi"); + c.BYEDPI_STATE_DIR = env("BYEDPI_STATE_DIR", "/var/run/tachyon/byedpi"); + c.BYEDPI_PID_DIR = env("BYEDPI_PID_DIR", c.BYEDPI_STATE_DIR + "/pid"); + c.BYEDPI_CHILD_PID_DIR = env("BYEDPI_CHILD_PID_DIR", c.BYEDPI_STATE_DIR + "/child-pid"); + c.BYEDPI_LOG_DIR = env("BYEDPI_LOG_DIR", c.BYEDPI_STATE_DIR + "/log"); + c.BYEDPI_LISTEN_ADDRESS = env("BYEDPI_LISTEN_ADDRESS", "127.0.0.1"); + c.BYEDPI_PORT_BASE = env("BYEDPI_PORT_BASE", "1080"); + c.BYEDPI_RESPAWN_DELAY = env("BYEDPI_RESPAWN_DELAY", "5"); + c.BYEDPI_OPEN_FILES_LIMIT = env("BYEDPI_OPEN_FILES_LIMIT", "4096"); + c.BYEDPI_DEFAULT_CMD_OPTS = env("BYEDPI_DEFAULT_CMD_OPTS", "-o 2 --auto=t,r,a,s -d 2"); + + return c; +} + +function print_shell_env(constants) { + for (let name in sort(keys(constants))) + print(name, "=", shell_quote(constants[name]), "\n"); +} + +function module_exports() { + return constants_map(); +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +let mode = ARGV[0] || ""; +let constants = constants_map(); + +if (mode == "shell-env") + print_shell_env(constants); +else if (mode == "json") + print(sprintf("%J", constants), "\n"); +else if (mode == "get") + print(as_string(constants[ARGV[1]]), "\n"); +else { + warn("Usage: core/constants.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/core/helpers.uc b/tachyon/files/usr/lib/core/helpers.uc new file mode 100644 index 00000000..30fa1eb6 --- /dev/null +++ b/tachyon/files/usr/lib/core/helpers.uc @@ -0,0 +1,608 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +const SB_DNS_SERVER_TAG = getenv("SB_DNS_SERVER_TAG") || "dns-server"; +const SB_FAKEIP_DNS_SERVER_TAG = getenv("SB_FAKEIP_DNS_SERVER_TAG") || "fakeip-server"; +const SB_BOOTSTRAP_SERVER_TAG = getenv("SB_BOOTSTRAP_SERVER_TAG") || "bootstrap-dns-server"; +const SB_FAKEIP_DNS_RULE_TAG = getenv("SB_FAKEIP_DNS_RULE_TAG") || "fakeip-dns-rule-tag"; +const SB_FAKEIP_RULESET_DNS_RULE_TAG = getenv("SB_FAKEIP_RULESET_DNS_RULE_TAG") || "fakeip-ruleset-dns-rule-tag"; +const SB_SERVICE_FAKEIP_DNS_RULE_TAG = getenv("SB_SERVICE_FAKEIP_DNS_RULE_TAG") || "service-fakeip-dns-rule-tag"; +const SB_TPROXY_INBOUND_TAG = getenv("SB_TPROXY_INBOUND_TAG") || "tproxy-in"; +const SB_TPROXY_INBOUND6_TAG = getenv("SB_TPROXY_INBOUND6_TAG") || "tproxy6-in"; +const SB_DNS_INBOUND_TAG = getenv("SB_DNS_INBOUND_TAG") || "dns-in"; +const SB_SERVICE_MIXED_INBOUND_TAG = getenv("SB_SERVICE_MIXED_INBOUND_TAG") || "service-mixed-in"; +const SB_DIRECT_OUTBOUND_TAG = getenv("SB_DIRECT_OUTBOUND_TAG") || "direct-out"; +const SB_BYPASS_OUTBOUND_TAG = getenv("SB_BYPASS_OUTBOUND_TAG") || "bypass-out"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function ascii_lower(value) { + let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let lower = "abcdefghijklmnopqrstuvwxyz"; + return replace(as_string(value), /[A-Z]/g, function(ch) { + return substr(lower, index(upper, ch), 1); + }); +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_stdin_json() { + let data = read_stdin(); + try { + return json(data); + } + catch (e) { + return null; + } +} + +function file_has_cr(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return null; + + return index(data, "\r") >= 0; +} + +function file_has_cr_exit(path) { + let result = file_has_cr(path); + exit(result == null ? 2 : (result ? 0 : 1)); +} + +function write_text_file(path, text) { + let result = fs.writefile(path, as_string(text)); + if (result == null) + return false; + if (type(result) == "boolean" && !result) + return false; + return true; +} + +function file_remove_cr(path) { + path = as_string(path); + let data = fs.readfile(path); + if (data == null) + return true; + + if (index(data, "\r") < 0) + return true; + + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); + if (!write_text_file(tmp_path, replace(data, /\r/g, ""))) + return false; + + if (!fs.rename(tmp_path, path)) { + fs.unlink(tmp_path); + return false; + } + + return true; +} + +function write_compact_string_array(values) { + print("["); + for (let i = 0; i < length(values); i++) { + if (i > 0) + print(","); + print(sprintf("%J", as_string(values[i]))); + } + print("]\n"); +} + +function valid_ipv4_octet(value) { + value = as_string(value); + return match(value, /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)$/) != null; +} + +function valid_ipv4(value) { + value = as_string(value); + + let trailing_dot = length(value) > 0 && substr(value, length(value) - 1) == "."; + if (trailing_dot) + value = substr(value, 0, length(value) - 1); + + let parts = split(value, "."); + if (length(parts) != 4) + return false; + + for (let part in parts) + if (!valid_ipv4_octet(part)) + return false; + + return true; +} + +function valid_ipv4_cidr(value) { + value = as_string(value); + let slash = index(value, "/"); + if (slash < 0) + return false; + + let ip = substr(value, 0, slash); + let mask = substr(value, slash + 1); + if (length(ip) > 0 && substr(ip, length(ip) - 1) == ".") + return false; + + if (index(mask, "/") >= 0 || !valid_ipv4(ip)) + return false; + + if (mask == "" || match(mask, /[^0-9]/) != null) + return false; + + let bits = int(mask); + return bits >= 0 && bits <= 32; +} + +function valid_domain(value) { + return match(ascii_lower(value), /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/) != null; +} + +function valid_domain_suffix(value) { + value = as_string(value); + if (substr(value, 0, 1) == ".") + value = substr(value, 1); + + return valid_domain(value); +} + +function digit_char(value) { + return match(as_string(value), /^[0-9]$/) != null; +} + +function strip_leading_zeroes(value) { + value = as_string(value); + let i = 0; + while (i < length(value) - 1 && substr(value, i, 1) == "0") + i++; + return substr(value, i); +} + +function version_compare(lhs, rhs) { + lhs = as_string(lhs); + rhs = as_string(rhs); + + let li = 0, ri = 0; + while (li < length(lhs) || ri < length(rhs)) { + if (li >= length(lhs)) + return substr(rhs, ri, 1) == "~" ? 1 : -1; + if (ri >= length(rhs)) + return substr(lhs, li, 1) == "~" ? -1 : 1; + + let lc = substr(lhs, li, 1); + let rc = substr(rhs, ri, 1); + if (lc == rc) { + li++; + ri++; + continue; + } + + if (lc == "~" || rc == "~") + return lc == "~" ? -1 : 1; + + if (digit_char(lc) && digit_char(rc)) { + let ls = li, rs = ri; + while (li < length(lhs) && digit_char(substr(lhs, li, 1))) + li++; + while (ri < length(rhs) && digit_char(substr(rhs, ri, 1))) + ri++; + + let lnum = strip_leading_zeroes(substr(lhs, ls, li - ls)); + let rnum = strip_leading_zeroes(substr(rhs, rs, ri - rs)); + if (length(lnum) != length(rnum)) + return length(lnum) < length(rnum) ? -1 : 1; + if (lnum != rnum) + return lnum < rnum ? -1 : 1; + continue; + } + + return lc < rc ? -1 : 1; + } + + return 0; +} + +function version_at_least(current, required) { + return version_compare(current, required) >= 0; +} + +function first_index_any(value, needles, start) { + value = as_string(value); + start = int(start || 0); + + for (let i = start; i < length(value); i++) { + let c = substr(value, i, 1); + for (let needle in needles) + if (c == needle) + return i; + } + + return -1; +} + +function strip_anchored_scheme(value) { + value = as_string(value); + let marker = index(value, "://"); + if (marker < 0) + return value; + + let prefix = substr(value, 0, marker); + if (index(prefix, "/") >= 0 || index(prefix, "?") >= 0) + return value; + + return substr(value, marker + 3); +} + +function strip_first_scheme_marker(value) { + value = as_string(value); + let marker = index(value, "://"); + return marker >= 0 ? substr(value, marker + 3) : value; +} + +function url_get_scheme(value) { + value = as_string(value); + let marker = index(value, "://"); + print(marker >= 0 ? substr(value, 0, marker) : value, "\n"); +} + +function url_get_userinfo(value) { + value = strip_anchored_scheme(value); + let at = index(value, "@"); + if (at >= 0) + print(substr(value, 0, at), "\n"); +} + +function url_authority(value) { + value = strip_first_scheme_marker(value); + let at = index(value, "@"); + if (at >= 0) + value = substr(value, at + 1); + + let end = first_index_any(value, ["/", "?", "#"], 0); + return end >= 0 ? substr(value, 0, end) : value; +} + +function url_get_host(value) { + let authority = url_authority(value); + let colon = index(authority, ":"); + print(colon >= 0 ? substr(authority, 0, colon) : authority, "\n"); +} + +function url_get_port(value) { + let authority = url_authority(value); + let colon = index(authority, ":"); + print(colon >= 0 ? substr(authority, colon + 1) : "", "\n"); +} + +function url_get_path(value) { + value = strip_anchored_scheme(value); + let slash = index(value, "/"); + value = slash >= 0 ? substr(value, slash) : ""; + + let query = index(value, "?"); + if (query >= 0) + value = substr(value, 0, query); + + print(value, "\n"); +} + +function url_get_query_param(value, param) { + value = as_string(value); + param = as_string(param); + let result = null; + + for (let i = 0; i < length(value); i++) { + let separator = substr(value, i, 1); + if (separator != "?" && separator != "&") + continue; + + let prefix_start = i + 1; + if (substr(value, prefix_start, length(param) + 1) != param + "=") + continue; + + let value_start = prefix_start + length(param) + 1; + let value_end = first_index_any(value, ["&", "?", "#"], value_start); + result = value_end >= 0 ? substr(value, value_start, value_end - value_start) : substr(value, value_start); + } + + print(result != null ? result : "", "\n"); +} + +function url_file_extension(value) { + let basename = as_string(value); + let slash = rindex(basename, "/"); + if (slash >= 0) + basename = substr(basename, slash + 1); + + let query = index(basename, "?"); + if (query >= 0) + basename = substr(basename, 0, query); + + let fragment = index(basename, "#"); + if (fragment >= 0) + basename = substr(basename, 0, fragment); + + let dot = rindex(basename, "."); + if (dot >= 0) + print(substr(basename, dot + 1), "\n"); + else + print("\n"); +} + +function url_strip_fragment(value) { + value = as_string(value); + let fragment = index(value, "#"); + print(fragment >= 0 ? substr(value, 0, fragment) : value, "\n"); +} + +function normalize_strategy_whitespace(value) { + value = replace(as_string(value), /[\t\r\n]/g, " "); + value = replace(value, / +/g, " "); + value = replace(value, /^ /, ""); + value = replace(value, / $/, ""); + print(value); +} + +function strip_list_comment(line) { + line = replace(as_string(line), /[[:space:]]*\/\/.*$/, ""); + return replace(line, /[[:space:]]*#.*$/, ""); +} + +function print_lines(values) { + for (let value in values) + print(as_string(value), "\n"); +} + +function text_list_values(value, separator_mode) { + let result = []; + separator_mode = as_string(separator_mode); + + for (let line in split(as_string(value), "\n")) { + line = strip_list_comment(line); + line = separator_mode == "comma-space" + ? replace(line, /[ ,]/g, "\n") + : replace(line, /,/g, "\n"); + + for (let item in split(line, "\n")) { + item = trim(replace(item, /\r/g, "")); + if (item != "") + push(result, item); + } + } + + return result; +} + +function text_list_to_lines(value, separator_mode) { + print_lines(text_list_values(value, separator_mode)); +} + +function stdin_lines_to_json_array() { + let input = read_stdin(); + if (input == "") { + print("[]\n"); + return; + } + + if (substr(input, length(input) - 1) == "\n") + input = substr(input, 0, length(input) - 1); + + write_compact_string_array(split(input, "\n")); +} + +function network_status_ipv4_address() { + let value = read_stdin_json(); + if (type(value) != "object") + return; + + let addresses = value["ipv4-address"]; + if (type(addresses) != "array" || length(addresses) == 0 || type(addresses[0]) != "object") + return; + + let address = as_string(addresses[0].address || ""); + if (address != "") + print(address, "\n"); +} + +function stdin_first_line_last_field() { + let input = read_stdin(); + if (input == "") + return; + + let newline = index(input, "\n"); + let line = newline >= 0 ? substr(input, 0, newline) : input; + let trimmed = trim(line); + if (trimmed == "") { + print(line, "\n"); + return; + } + + let fields = split(trimmed, /[ \t\r\n]+/); + if (length(fields) > 0 && fields[0] != "") + print(fields[length(fields) - 1], "\n"); + else + print("\n"); +} + +function stdin_trim_string() { + print(trim(read_stdin()), "\n"); +} + +function whitespace_list_contains(list, needle) { + needle = as_string(needle); + for (let item in split(trim(as_string(list)), /[ \t\r\n]+/)) + if (as_string(item) == needle) + return true; + return false; +} + +function download_via_proxy_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + + if (purpose == "lists") + print("download_lists_via_proxy\n"); + else if (purpose == "components") + print("download_components_via_proxy\n"); + else + exit(1); +} + +function md5sum_hex_prefix(prefix_length) { + let input = read_stdin(); + let newline = index(input, "\n"); + let line = newline >= 0 ? substr(input, 0, newline) : input; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + let hash = length(fields) > 0 ? as_string(fields[0]) : ""; + + prefix_length = int(prefix_length || 0); + if (prefix_length > 0) + print(substr(hash, 0, prefix_length), "\n"); +} + +function md5sum_hwid() { + let input = read_stdin(); + let newline = index(input, "\n"); + let line = newline >= 0 ? substr(input, 0, newline) : input; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + let hash = length(fields) > 0 ? substr(as_string(fields[0]), 0, 16) : ""; + + print( + substr(hash, 0, 4), "-", + substr(hash, 4, 4), "-", + substr(hash, 8, 4), "-", + substr(hash, 12, 4), "\n" + ); +} + +function tag_is_reserved(tag, reserved) { + tag = as_string(tag); + for (let value in reserved) + if (tag == as_string(value)) + return true; + return false; +} + +function allocate_runtime_tag_value(base, postfix, reserved) { + base = as_string(base); + postfix = as_string(postfix); + reserved = type(reserved) == "array" ? reserved : []; + let candidate = base + "-" + postfix; + let suffix = 1; + + while (tag_is_reserved(candidate, reserved)) { + candidate = base + "-" + postfix + "-" + suffix; + suffix++; + } + + return candidate; +} + +function default_reserved_runtime_tags() { + return [ + SB_DNS_SERVER_TAG, + SB_FAKEIP_DNS_SERVER_TAG, + SB_BOOTSTRAP_SERVER_TAG, + SB_FAKEIP_DNS_RULE_TAG, + SB_FAKEIP_RULESET_DNS_RULE_TAG, + SB_SERVICE_FAKEIP_DNS_RULE_TAG, + SB_TPROXY_INBOUND_TAG, + SB_TPROXY_INBOUND6_TAG, + SB_DNS_INBOUND_TAG, + SB_SERVICE_MIXED_INBOUND_TAG, + SB_DIRECT_OUTBOUND_TAG, + SB_BYPASS_OUTBOUND_TAG + ]; +} + +function print_runtime_tag(base, postfix, reserved) { + print(allocate_runtime_tag_value(base, postfix, reserved), "\n"); +} + +function allocate_runtime_tag(base, postfix) { + print_runtime_tag(base, postfix, slice(ARGV, 3)); +} + +function sing_box_version_is_extended(value) { + return index(as_string(value), "extended") >= 0; +} + +let mode = ARGV[0] || ""; + +if (mode == "stdin-lines-to-json-array") + stdin_lines_to_json_array(); +else if (mode == "file-has-cr") + file_has_cr_exit(ARGV[1]); +else if (mode == "file-remove-cr") + exit(file_remove_cr(ARGV[1]) ? 0 : 1); +else if (mode == "valid-ipv4") + exit(valid_ipv4(ARGV[1]) ? 0 : 1); +else if (mode == "valid-ipv4-cidr") + exit(valid_ipv4_cidr(ARGV[1]) ? 0 : 1); +else if (mode == "valid-domain") + exit(valid_domain(ARGV[1]) ? 0 : 1); +else if (mode == "valid-domain-suffix") + exit(valid_domain_suffix(ARGV[1]) ? 0 : 1); +else if (mode == "version-at-least") + exit(version_at_least(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "network-status-ipv4-address") + network_status_ipv4_address(); +else if (mode == "stdin-first-line-last-field") + stdin_first_line_last_field(); +else if (mode == "stdin-trim-string") + stdin_trim_string(); +else if (mode == "whitespace-list-contains") + exit(whitespace_list_contains(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "download-via-proxy-option-for-purpose") + download_via_proxy_option_for_purpose(ARGV[1]); +else if (mode == "md5sum-hex-prefix") + md5sum_hex_prefix(ARGV[1]); +else if (mode == "md5sum-hwid") + md5sum_hwid(); +else if (mode == "allocate-runtime-tag") + allocate_runtime_tag(ARGV[1], ARGV[2]); +else if (mode == "inbound-tag") + print_runtime_tag(ARGV[1], "in", default_reserved_runtime_tags()); +else if (mode == "server-inbound-tag") + print_runtime_tag("server-" + as_string(ARGV[1]), "in", default_reserved_runtime_tags()); +else if (mode == "tailscale-dns-tag") + print_runtime_tag("server-" + as_string(ARGV[1]), "tailscale-dns", default_reserved_runtime_tags()); +else if (mode == "outbound-tag") + print_runtime_tag(ARGV[1], "out", default_reserved_runtime_tags()); +else if (mode == "domain-resolver-tag") + print_runtime_tag(ARGV[1], "domain-resolver", default_reserved_runtime_tags()); +else if (mode == "sing-box-version-is-extended") + exit(sing_box_version_is_extended(ARGV[1]) ? 0 : 1); +else if (mode == "url-get-scheme") + url_get_scheme(ARGV[1]); +else if (mode == "url-get-userinfo") + url_get_userinfo(ARGV[1]); +else if (mode == "url-get-host") + url_get_host(ARGV[1]); +else if (mode == "url-get-port") + url_get_port(ARGV[1]); +else if (mode == "url-get-path") + url_get_path(ARGV[1]); +else if (mode == "url-get-query-param") + url_get_query_param(ARGV[1], ARGV[2]); +else if (mode == "url-file-extension") + url_file_extension(ARGV[1]); +else if (mode == "url-strip-fragment") + url_strip_fragment(ARGV[1]); +else if (mode == "normalize-strategy-whitespace") + normalize_strategy_whitespace(ARGV[1]); +else if (mode == "text-list-to-lines") + text_list_to_lines(ARGV[1], ARGV[2]); +else { + warn("Usage: core/helpers.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/core/ip.uc b/tachyon/files/usr/lib/core/ip.uc new file mode 100644 index 00000000..b462c1ea --- /dev/null +++ b/tachyon/files/usr/lib/core/ip.uc @@ -0,0 +1,152 @@ +#!/usr/bin/env ucode + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function decimal_text(value, strict) { + value = as_string(value); + if (value == "" || match(value, /^[0-9]+$/) == null) + return false; + return !strict || length(value) == 1 || substr(value, 0, 1) != "0"; +} + +function valid_ipv4(value, allow_trailing_dot, strict_decimal) { + value = as_string(value); + if (allow_trailing_dot && length(value) > 0 && substr(value, length(value) - 1, 1) == ".") + value = substr(value, 0, length(value) - 1); + + let parts = split(value, "."); + if (length(parts) != 4) + return false; + + for (let part in parts) { + if (!decimal_text(part, strict_decimal)) + return false; + + let octet = int(part); + if (octet < 0 || octet > 255) + return false; + } + + return true; +} + +function valid_ipv4_cidr(value, strict_decimal) { + value = as_string(value); + let slash = index(value, "/"); + if (slash <= 0 || index(substr(value, slash + 1), "/") >= 0) + return false; + + let prefix = substr(value, slash + 1); + if (!decimal_text(prefix, strict_decimal)) + return false; + + let prefix_number = int(prefix); + return valid_ipv4(substr(value, 0, slash), false, strict_decimal) && prefix_number >= 0 && prefix_number <= 32; +} + +function valid_ipv6_hextet(value) { + value = as_string(value); + return value != "" && length(value) <= 4 && match(value, /^[0-9A-Fa-f]+$/) != null; +} + +function ipv6_parts_count(parts) { + let count = 0; + + for (let i = 0; i < length(parts); i++) { + let part = parts[i]; + if (part == "") + return -1; + + if (index(part, ".") >= 0) { + if (i != length(parts) - 1 || !valid_ipv4(part, false, false)) + return -1; + count += 2; + continue; + } + + if (!valid_ipv6_hextet(part)) + return -1; + count++; + } + + return count; +} + +function valid_ipv6(value) { + value = as_string(value); + if (value == "" || index(value, "/") >= 0 || index(value, "%") >= 0) + return false; + + let marker = index(value, "::"); + if (marker >= 0) { + if (index(substr(value, marker + 2), "::") >= 0) + return false; + + let left = substr(value, 0, marker); + let right = substr(value, marker + 2); + let left_count = left == "" ? 0 : ipv6_parts_count(split(left, ":")); + let right_count = right == "" ? 0 : ipv6_parts_count(split(right, ":")); + + return left_count >= 0 && right_count >= 0 && left_count + right_count < 8; + } + + let count = ipv6_parts_count(split(value, ":")); + return count == 8; +} + +function valid_ipv6_cidr(value) { + value = as_string(value); + let slash = index(value, "/"); + if (slash <= 0 || index(substr(value, slash + 1), "/") >= 0) + return false; + + let prefix = substr(value, slash + 1); + if (!decimal_text(prefix, false)) + return false; + + let prefix_number = int(prefix); + return valid_ipv6(substr(value, 0, slash)) && prefix_number >= 0 && prefix_number <= 128; +} + +function valid_ip(value) { + return valid_ipv4(value, false, false) || valid_ipv6(value); +} + +function valid_ip_cidr(value) { + return valid_ipv4_cidr(value, false) || valid_ipv6_cidr(value); +} + +function valid_ip_or_cidr(value) { + return valid_ip(value) || valid_ip_cidr(value); +} + +function nft_ip_or_cidr(value) { + return valid_ipv4(value, true, true) || valid_ipv4_cidr(value, true) || valid_ipv6(value) || valid_ipv6_cidr(value); +} + +function ip_family(value) { + return valid_ipv4(value, false, false) || valid_ipv4_cidr(value, false) ? 4 : + (valid_ipv6(value) || valid_ipv6_cidr(value) ? 6 : 0); +} + +function format_ipv6_tproxy_target(address, port) { + address = as_string(address); + if (substr(address, 0, 1) == "[" && substr(address, length(address) - 1, 1) == "]") + return address + ":" + as_string(port); + return "[" + address + "]:" + as_string(port); +} + +return { + valid_ipv4, + valid_ipv4_cidr, + valid_ipv6, + valid_ipv6_cidr, + valid_ip, + valid_ip_cidr, + valid_ip_or_cidr, + nft_ip_or_cidr, + ip_family, + format_ipv6_tproxy_target +}; diff --git a/tachyon/files/usr/lib/core/packages.uc b/tachyon/files/usr/lib/core/packages.uc new file mode 100644 index 00000000..287ff37a --- /dev/null +++ b/tachyon/files/usr/lib/core/packages.uc @@ -0,0 +1,201 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_success(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_output(args) { + let pipe = fs.popen(command_from_args(args), "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_exists(name) { + return command_success([ "command", "-v", name ]); +} + +function apk_installed(package_name) { + return command_exists("apk") && command_success([ "apk", "info", "-e", as_string(package_name) ]); +} + +function opkg_installed(package_name) { + package_name = as_string(package_name); + if (!command_exists("opkg")) + return false; + + let prefix = package_name + " - "; + for (let line in split(command_output([ "opkg", "list-installed" ]), "\n")) { + line = trim(as_string(line)); + if (substr(line, 0, length(prefix)) == prefix) + return true; + } + return false; +} + +function installed(package_name) { + return apk_installed(package_name) || opkg_installed(package_name); +} + +function apk_manifest_version(package_name, output) { + package_name = as_string(package_name); + let found = false; + + for (let line in split(as_string(output), "\n")) { + line = trim(as_string(line)); + if (substr(line, 0, 2) == "P:") + found = substr(line, 2) == package_name; + else if (found && substr(line, 0, 2) == "V:") + return substr(line, 2); + } + return ""; +} + +function apk_info_version(package_name, output) { + package_name = as_string(package_name); + let prefix = package_name + "-"; + + for (let line in split(as_string(output), "\n")) { + line = trim(as_string(line)); + if (line == "") + continue; + if (substr(line, 0, length(prefix)) == prefix) + return substr(line, length(prefix)); + } + return ""; +} + +function apk_list_version(package_name, output) { + package_name = as_string(package_name); + let prefix = package_name + "-"; + + for (let line in split(as_string(output), "\n")) { + for (let field in split(trim(as_string(line)), /[ \t]+/)) { + field = as_string(field); + if (substr(field, 0, length(prefix)) != prefix) + continue; + + let version = substr(field, length(prefix)); + if (version != "") + return version; + } + } + return ""; +} + +function apk_query_version(package_name, output) { + package_name = as_string(package_name); + let packages; + + try { + packages = json(as_string(output)); + } + catch (e) { + return ""; + } + + if (type(packages) != "array") + return ""; + + for (let package in packages) { + if (type(package) != "object" || as_string(package.name) != package_name) + continue; + + let version = as_string(package.version); + if (version != "") + return version; + } + return ""; +} + +function apk_version(package_name) { + package_name = as_string(package_name); + if (!apk_installed(package_name)) + return ""; + + let version = apk_list_version(package_name, command_output([ "apk", "list", "--installed", package_name ])); + if (version != "") + return version; + + version = apk_manifest_version(package_name, command_output([ "apk", "list", "--installed", "--manifest", package_name ])); + if (version != "") + return version; + + version = apk_manifest_version(package_name, command_output([ "apk", "list", "--installed", "--manifest" ])); + if (version != "") + return version; + + return apk_info_version(package_name, command_output([ "apk", "info", "-v", package_name ])); +} + +function apk_available_version(package_name) { + package_name = as_string(package_name); + if (!command_exists("apk")) + return ""; + return apk_query_version( + package_name, + command_output([ "apk", "query", "--from", "repositories", "--available", "--format", "json", "--fields", "name,version", package_name ]) + ); +} + +function opkg_version(package_name) { + package_name = as_string(package_name); + if (!command_exists("opkg")) + return ""; + + let prefix = package_name + " - "; + for (let line in split(command_output([ "opkg", "list-installed" ]), "\n")) { + line = trim(as_string(line)); + if (substr(line, 0, length(prefix)) == prefix) + return substr(line, length(prefix)); + } + return ""; +} + +function version(package_name) { + let value = apk_version(package_name); + return value != "" ? value : opkg_version(package_name); +} + +let mode = ARGV[0] || ""; + +if (mode == "installed") + exit(installed(ARGV[1]) ? 0 : 1); +else if (mode == "apk-installed") + exit(apk_installed(ARGV[1]) ? 0 : 1); +else if (mode == "opkg-installed") + exit(opkg_installed(ARGV[1]) ? 0 : 1); +else if (mode == "version") + print(version(ARGV[1]), "\n"); +else if (mode == "apk-version") + print(apk_version(ARGV[1]), "\n"); +else if (mode == "apk-available-version") + print(apk_available_version(ARGV[1]), "\n"); +else if (mode == "opkg-version") + print(opkg_version(ARGV[1]), "\n"); +else { + warn("Usage: core/packages.uc \n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/core/uci.uc b/tachyon/files/usr/lib/core/uci.uc new file mode 100644 index 00000000..81e08f21 --- /dev/null +++ b/tachyon/files/usr/lib/core/uci.uc @@ -0,0 +1,625 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); + +let as_string = common.as_string; + +const UCI_STATE_FILE = getenv("TACHYON_UCI_STATE_FILE") || getenv("UCI_STATE") || ""; +const UCI_LOG_FILE = getenv("TACHYON_UCI_LOG_FILE") || getenv("UCI_LOG") || ""; + +let runtime_cursor = false; +let loaded_packages = {}; + +function words(value) { + value = trim(as_string(value)); + return value == "" ? [] : split(value, /[ \t\r\n]+/); +} + +function path_parts(path) { + path = as_string(path); + let first = index(path, "."); + if (first < 0) + return null; + + let package_name = substr(path, 0, first); + let rest = substr(path, first + 1); + let second = index(rest, "."); + if (second < 0) + return { package: package_name, section: rest, option: "" }; + + return { + package: package_name, + section: substr(rest, 0, second), + option: substr(rest, second + 1) + }; +} + +function anonymous_section_selector(section) { + let matched = match(as_string(section), /^@([A-Za-z0-9_-]+)\[([0-9]+)\]$/); + if (!matched) + return null; + + return { + type: as_string(matched[1]), + index: int(matched[2]) + }; +} + +function resolve_section_name(c, package_name, raw_section_name) { + raw_section_name = as_string(raw_section_name); + + let selector = anonymous_section_selector(raw_section_name); + if (selector == null) + return raw_section_name; + + let found = ""; + let current = 0; + try { + c.foreach(as_string(package_name), selector.type, function(section) { + if (found != "") + return; + if (current == selector.index && type(section) == "object") + found = as_string(section[".name"] || ""); + current++; + }); + } + catch (e) { + return ""; + } + + return found; +} + +function resolve_parts(c, parts) { + if (parts == null) + return null; + + let section = resolve_section_name(c, parts.package, parts.section); + if (section == "") + return null; + + return { + package: parts.package, + section, + option: parts.option + }; +} + +function state_lines() { + let data = fs.readfile(UCI_STATE_FILE); + if (data == null || data == "") + return []; + return split(replace(data, /\r/g, ""), "\n"); +} + +function state_write_lines(lines) { + return fs.writefile(UCI_STATE_FILE, join("\n", lines) + "\n") != null; +} + +function state_get(path) { + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == path) + return equals >= 0 ? substr(line, equals + 1) : ""; + } + return ""; +} + +function state_exists(path) { + let prefix = as_string(path) + "."; + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == path || substr(key, 0, length(prefix)) == prefix) + return true; + } + return false; +} + +function state_delete(path) { + let prefix = as_string(path) + "."; + let changed = false; + let lines = []; + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == path || substr(key, 0, length(prefix)) == prefix) { + changed = true; + continue; + } + push(lines, line); + } + return !changed || state_write_lines(lines); +} + +function state_set(path, value) { + let lines = []; + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key != path) + push(lines, line); + } + push(lines, as_string(path) + "=" + as_string(value)); + return state_write_lines(lines); +} + +function state_add_list(path, value) { + let current = state_get(path); + return state_set(path, current == "" ? value : current + " " + as_string(value)); +} + +function state_del_list(path, value) { + let current = state_get(path); + if (current == "") + return false; + + let values = []; + let removed = false; + for (let item in words(current)) { + if (item == value) { + removed = true; + continue; + } + push(values, item); + } + + if (!removed) + return false; + if (length(values) == 0) + return state_delete(path); + return state_set(path, join(" ", values)); +} + +function state_commit(package_name) { + if (UCI_LOG_FILE == "") + return true; + let existing = fs.readfile(UCI_LOG_FILE); + existing = existing == null ? "" : as_string(existing); + return fs.writefile(UCI_LOG_FILE, existing + "commit " + as_string(package_name) + "\n") != null; +} + +function state_add_section(package_name, type_name) { + package_name = as_string(package_name); + type_name = as_string(type_name); + + let index = 1; + let section = ""; + while (true) { + section = sprintf("cfg%06x", index); + if (!state_exists(package_name + "." + section)) + break; + index++; + } + + return state_set(package_name + "." + section, type_name) ? section : ""; +} + +function state_sections(package_name, type_name) { + let result = []; + let prefix = as_string(package_name) + "."; + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + if (equals < 0) + continue; + + let key = substr(line, 0, equals); + let value = substr(line, equals + 1); + if (value != type_name || substr(key, 0, length(prefix)) != prefix) + continue; + + let section = substr(key, length(prefix)); + if (index(section, ".") < 0) + push(result, section); + } + return result; +} + +function state_get_all(package_name, section_name) { + package_name = as_string(package_name); + section_name = as_string(section_name); + + let result = {}; + let section_type = state_get(package_name + "." + section_name); + if (section_type != "") { + result[".name"] = section_name; + result[".type"] = section_type; + } + + let prefix = package_name + "." + section_name + "."; + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + if (equals < 0) + continue; + + let key = substr(line, 0, equals); + if (substr(key, 0, length(prefix)) != prefix) + continue; + + let option = substr(key, length(prefix)); + if (option != "") + result[option] = substr(line, equals + 1); + } + + return length(keys(result)) > 0 ? result : null; +} + +function fixture_enabled() { + return UCI_STATE_FILE != ""; +} + +function cursor() { + if (runtime_cursor !== false) + return runtime_cursor; + + try { + runtime_cursor = require("uci").cursor(); + } + catch (e) { + runtime_cursor = null; + } + return runtime_cursor; +} + +function available() { + return fixture_enabled() || cursor() != null; +} + +function load(package_name) { + package_name = as_string(package_name); + if (fixture_enabled()) + return true; + if (loaded_packages[package_name]) + return true; + + let c = cursor(); + if (c == null) + return false; + + try { + c.load(package_name); + loaded_packages[package_name] = true; + return true; + } + catch (e) { + return false; + } +} + +function value_to_string(value) { + if (value == null) + return ""; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function value_to_list(value) { + if (value == null) + return []; + if (type(value) == "array") + return value; + return words(value); +} + +function get(path) { + path = as_string(path); + if (fixture_enabled()) + return state_get(path); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null || parts.option == "") + return ""; + if (!load(parts.package)) + return ""; + parts = resolve_parts(c, parts); + if (parts == null) + return ""; + + return value_to_string(c.get(parts.package, parts.section, parts.option)); +} + +function get_all(package_name, section_name) { + if (fixture_enabled()) + return state_get_all(package_name, section_name); + + let c = cursor(); + if (c == null) + return null; + + try { + load(package_name); + section_name = resolve_section_name(c, package_name, section_name); + if (section_name == "") + return null; + return c.get_all(as_string(package_name), as_string(section_name)); + } + catch (e) { + return null; + } +} + +function exists(path) { + path = as_string(path); + if (fixture_enabled()) + return state_exists(path); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null) + return false; + if (!load(parts.package)) + return false; + parts = resolve_parts(c, parts); + if (parts == null) + return false; + + if (parts.option == "") + return c.get_all(parts.package, parts.section) != null; + return c.get(parts.package, parts.section, parts.option) != null; +} + +function delete_path(path) { + path = as_string(path); + if (fixture_enabled()) + return state_delete(path); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null) + return false; + if (!load(parts.package)) + return false; + parts = resolve_parts(c, parts); + if (parts == null) + return false; + + try { + if (parts.option == "") + c.delete(parts.package, parts.section); + else + c.delete(parts.package, parts.section, parts.option); + return true; + } + catch (e) { + return false; + } +} + +function set_section(path, type_name) { + path = as_string(path); + if (fixture_enabled()) + return state_set(path, type_name); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null || parts.option != "") + return false; + if (!load(parts.package)) + return false; + parts = resolve_parts(c, parts); + if (parts == null) + return false; + + try { + c.set(parts.package, parts.section, as_string(type_name)); + return true; + } + catch (e) { + return false; + } +} + +function add(package_name, type_name) { + if (fixture_enabled()) + return state_add_section(package_name, type_name); + + let c = cursor(); + if (c == null) + return ""; + + try { + load(package_name); + let section = c.add(as_string(package_name), as_string(type_name)); + return as_string(section); + } + catch (e) { + return ""; + } +} + +function set(path, value) { + path = as_string(path); + if (fixture_enabled()) + return state_set(path, value_to_string(value)); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null || parts.option == "") + return false; + if (!load(parts.package)) + return false; + parts = resolve_parts(c, parts); + if (parts == null) + return false; + + try { + c.set(parts.package, parts.section, parts.option, type(value) == "array" ? value : as_string(value)); + return true; + } + catch (e) { + return false; + } +} + +function add_list(path, value) { + path = as_string(path); + if (fixture_enabled()) + return state_add_list(path, value); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null || parts.option == "") + return false; + if (!load(parts.package)) + return false; + parts = resolve_parts(c, parts); + if (parts == null) + return false; + + try { + let values = value_to_list(c.get(parts.package, parts.section, parts.option)); + push(values, as_string(value)); + c.set(parts.package, parts.section, parts.option, values); + return true; + } + catch (e) { + return false; + } +} + +function del_list(path, value) { + path = as_string(path); + if (fixture_enabled()) + return state_del_list(path, value); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null || parts.option == "") + return false; + if (!load(parts.package)) + return false; + parts = resolve_parts(c, parts); + if (parts == null) + return false; + + let values = []; + let removed = false; + for (let item in value_to_list(c.get(parts.package, parts.section, parts.option))) { + if (item == value) { + removed = true; + continue; + } + push(values, item); + } + + if (!removed) + return false; + + try { + if (length(values) == 0) + c.delete(parts.package, parts.section, parts.option); + else + c.set(parts.package, parts.section, parts.option, values); + return true; + } + catch (e) { + return false; + } +} + +function commit(package_name) { + if (fixture_enabled()) + return state_commit(package_name); + + let c = cursor(); + if (c == null) + return false; + + try { + return c.commit(package_name) != false; + } + catch (e) { + return false; + } +} + +function section_name(section) { + if (type(section) == "object") + return as_string(section[".name"] || ""); + return as_string(section); +} + +function sections(package_name, type_name) { + if (fixture_enabled()) + return state_sections(package_name, type_name); + + let c = cursor(); + if (c == null) + return []; + + let result = []; + try { + load(package_name); + c.foreach(package_name, as_string(type_name), function(section) { + let name = section_name(section); + if (name != "") + push(result, name); + }); + } + catch (e) { + return []; + } + return result; +} + +function section_objects(package_name, type_name) { + if (fixture_enabled()) { + let result = []; + for (let name in state_sections(package_name, type_name)) { + let section = state_get_all(package_name, name); + if (type(section) == "object") + push(result, section); + } + return result; + } + + let c = cursor(); + if (c == null) + return []; + + let result = []; + try { + load(package_name); + c.foreach(package_name, as_string(type_name), function(section) { + if (type(section) == "object") + push(result, section); + }); + } + catch (e) { + return []; + } + return result; +} + +return { + available, + load, + get, + get_all, + exists, + delete: delete_path, + set_section, + add, + set, + add_list, + del_list, + commit, + sections, + section_objects +}; diff --git a/tachyon/files/usr/lib/core/url.uc b/tachyon/files/usr/lib/core/url.uc new file mode 100644 index 00000000..c8330029 --- /dev/null +++ b/tachyon/files/usr/lib/core/url.uc @@ -0,0 +1,186 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); + +let as_string = common.as_string; + +function first_index_any(value, needles, start) { + value = as_string(value); + start = int(start || 0); + + for (let i = start; i < length(value); i++) { + let c = substr(value, i, 1); + for (let needle in needles) + if (c == needle) + return i; + } + + return -1; +} + +function hex_digit_value(value) { + value = ord(lc(as_string(value))); + if (value >= 48 && value <= 57) + return value - 48; + if (value >= 97 && value <= 102) + return value - 87; + return -1; +} + +function decode(value) { + value = as_string(value); + let result = ""; + + for (let i = 0; i < length(value); i++) { + let c = substr(value, i, 1); + if (c == "+") { + result += " "; + continue; + } + if (c == "%") { + let high = i + 1 < length(value) ? hex_digit_value(substr(value, i + 1, 1)) : -1; + let low = i + 2 < length(value) ? hex_digit_value(substr(value, i + 2, 1)) : -1; + if (high >= 0 && low >= 0) { + result += chr(high * 16 + low); + i += 2; + continue; + } + } + result += c; + } + + return result; +} + +function scheme(value) { + value = as_string(value); + let marker = index(value, "://"); + return marker >= 0 ? lc(substr(value, 0, marker)) : ""; +} + +function fragment(value) { + value = as_string(value); + let marker = index(value, "#"); + return marker >= 0 ? decode(substr(value, marker + 1)) : ""; +} + +function strip_fragment(value) { + value = as_string(value); + let marker = index(value, "#"); + return marker >= 0 ? substr(value, 0, marker) : value; +} + +function strip_anchored_scheme(value) { + value = as_string(value); + let marker = index(value, "://"); + if (marker < 0) + return value; + + let prefix = substr(value, 0, marker); + if (index(prefix, "/") >= 0 || index(prefix, "?") >= 0) + return value; + + return substr(value, marker + 3); +} + +function strip_first_scheme_marker(value) { + value = as_string(value); + let marker = index(value, "://"); + return marker >= 0 ? substr(value, marker + 3) : value; +} + +function authority(value) { + value = strip_first_scheme_marker(value); + let at = rindex(value, "@"); + if (at >= 0) + value = substr(value, at + 1); + + let end = first_index_any(value, ["/", "?", "#"], 0); + return end >= 0 ? substr(value, 0, end) : value; +} + +function host(value) { + let value_authority = authority(value); + if (substr(value_authority, 0, 1) == "[") { + let end = index(value_authority, "]"); + return end > 0 ? substr(value_authority, 1, end - 1) : value_authority; + } + + let colon = index(value_authority, ":"); + if (colon < 0) + return value_authority; + + return rindex(value_authority, ":") == colon ? substr(value_authority, 0, colon) : value_authority; +} + +function port(value) { + let value_authority = authority(value); + if (substr(value_authority, 0, 1) == "[") { + let end = index(value_authority, "]"); + if (end > 0 && substr(value_authority, end + 1, 1) == ":") + return substr(value_authority, end + 2); + return ""; + } + + let colon = index(value_authority, ":"); + if (colon < 0 || rindex(value_authority, ":") != colon) + return ""; + + return substr(value_authority, colon + 1); +} + +function userinfo(value) { + let value_authority = strip_first_scheme_marker(strip_fragment(value)); + let end = first_index_any(value_authority, ["/", "?", "#"], 0); + value_authority = end >= 0 ? substr(value_authority, 0, end) : value_authority; + let at = rindex(value_authority, "@"); + return at >= 0 ? decode(substr(value_authority, 0, at)) : ""; +} + +function path(value) { + value = strip_anchored_scheme(value); + let slash = index(value, "/"); + value = slash >= 0 ? substr(value, slash) : ""; + + let query = index(value, "?"); + if (query >= 0) + value = substr(value, 0, query); + + return value; +} + +function query_params(value) { + let result = {}; + value = as_string(value); + let question = index(value, "?"); + if (question < 0) + return result; + + let query = substr(value, question + 1); + let hash = index(query, "#"); + if (hash >= 0) + query = substr(query, 0, hash); + + for (let pair in split(query, "&")) { + if (pair == "") + continue; + let equals = index(pair, "="); + let key = decode(equals >= 0 ? substr(pair, 0, equals) : pair); + let value = equals >= 0 ? decode(substr(pair, equals + 1)) : ""; + if (key != "") + result[key] = value; + } + return result; +} + +return { + decode, + scheme, + fragment, + strip_fragment, + host, + port, + userinfo, + path, + query_params +}; diff --git a/tachyon/files/usr/lib/diagnostics/runtime.uc b/tachyon/files/usr/lib/diagnostics/runtime.uc new file mode 100644 index 00000000..f92fc5b0 --- /dev/null +++ b/tachyon/files/usr/lib/diagnostics/runtime.uc @@ -0,0 +1,1971 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let constants = require("core.constants"); +let core_ip = require("core.ip"); +let uci_core = require("core.uci"); +let runtime_dns = require("singbox.dns"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || constants.TACHYON_CONFIG_NAME || "tachyon"; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const TACHYON_VERSION = getenv("TACHYON_VERSION") || constants.TACHYON_VERSION || ""; +const TACHYON_CONFIG = getenv("TACHYON_CONFIG") || constants.TACHYON_CONFIG || "/etc/config/" + CONFIG_NAME; +const TACHYON_SERVICE_NAME = getenv("TACHYON_SERVICE_NAME") || constants.TACHYON_SERVICE_NAME || "tachyon"; +const TACHYON_RELEASE_REPO = getenv("TACHYON_RELEASE_REPO") || constants.TACHYON_RELEASE_REPO || "ushan0v/tachyon"; +const TACHYON_LUCI_VIEW_DIR = getenv("TACHYON_LUCI_VIEW_DIR") || constants.TACHYON_LUCI_VIEW_DIR || "/www/luci-static/resources/view/tachyon"; +const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const SYSTEM_INFO_CACHE_FILE = getenv("TACHYON_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json"; +const SYSTEM_INFO_CACHE_TTL = int(getenv("TACHYON_SYSTEM_INFO_CACHE_TTL") || "3600"); +const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || constants.TMP_SING_BOX_FOLDER || "/tmp/sing-box"; +const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || constants.TMP_RULESET_FOLDER || TMP_SING_BOX_FOLDER + "/rulesets"; +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || constants.TMP_SUBSCRIPTION_FOLDER || TMP_SING_BOX_FOLDER + "/subscriptions"; +const SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; +const CHECK_PROXY_IP_DOMAIN = getenv("CHECK_PROXY_IP_DOMAIN") || constants.CHECK_PROXY_IP_DOMAIN || "ip.podkop.fyi"; +const FAKEIP_TEST_DOMAIN = getenv("FAKEIP_TEST_DOMAIN") || constants.FAKEIP_TEST_DOMAIN || "fakeip.podkop.fyi"; +const RT_TABLE_NAME = getenv("RT_TABLE_NAME") || constants.RT_TABLE_NAME || "tachyon"; +const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || constants.NFT_TABLE_NAME || "TachyonTable"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || constants.NFT_FAKEIP_MARK || "0x00100000"; +const NFT_COMMON_SET_NAME = getenv("NFT_COMMON_SET_NAME") || constants.NFT_COMMON_SET_NAME || "tachyon_subnets"; +const NFT_PORT_SET_NAME = getenv("NFT_PORT_SET_NAME") || constants.NFT_PORT_SET_NAME || "tachyon_ports"; +const NFT_IP_PORT_SET_NAME = getenv("NFT_IP_PORT_SET_NAME") || constants.NFT_IP_PORT_SET_NAME || "tachyon_ip_ports"; +const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || constants.NFT_INTERFACE_SET_NAME || "tachyon_interfaces"; +const NFT_DISCORD_SET_NAME = getenv("NFT_DISCORD_SET_NAME") || constants.NFT_DISCORD_SET_NAME || "tachyon_discord_subnets"; +const NFT_LOCALV4_SET_NAME = getenv("NFT_LOCALV4_SET_NAME") || constants.NFT_LOCALV4_SET_NAME || "localv4"; +const SB_DNS_INBOUND_ADDRESS = getenv("SB_DNS_INBOUND_ADDRESS") || constants.SB_DNS_INBOUND_ADDRESS || "127.0.0.42"; +const SB_TPROXY_INBOUND6_ADDRESS = getenv("SB_TPROXY_INBOUND6_ADDRESS") || constants.SB_TPROXY_INBOUND6_ADDRESS || "::1"; +const SB_TPROXY_INBOUND_PORT = getenv("SB_TPROXY_INBOUND_PORT") || constants.SB_TPROXY_INBOUND_PORT || "1602"; +const SB_CLASH_API_CONTROLLER_PORT = getenv("SB_CLASH_API_CONTROLLER_PORT") || constants.SB_CLASH_API_CONTROLLER_PORT || "9090"; +const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || constants.SB_VARIANT_STATE_FILE || "/etc/tachyon/sing-box-variant"; +const SING_BOX_BIN_PATH = getenv("TACHYON_DIAGNOSTICS_SING_BOX_BIN_PATH") || "/usr/bin/sing-box"; +const CLOUDFLARE_OCTETS = getenv("CLOUDFLARE_OCTETS") || constants.CLOUDFLARE_OCTETS || "8.47 162.159 188.114"; +const ZAPRET_LEGACY_DEFAULT_NFQWS_OPT = getenv("ZAPRET_LEGACY_DEFAULT_NFQWS_OPT") || constants.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT || ""; +const DEFAULT_LATENCY_TEST_URL = getenv("DEFAULT_LATENCY_TEST_URL") || "https://www.gstatic.com/generate_204"; +const RUNTIME_STABLE_MIN_AGE = getenv("TACHYON_RUNTIME_STABLE_MIN_AGE") || "2"; + +const STATUS_UC = LIB_DIR + "/diagnostics/status.uc"; +const HELPERS_UC = LIB_DIR + "/core/helpers.uc"; +const PACKAGES_UC = LIB_DIR + "/core/packages.uc"; +const DNS_APPLY_UC = LIB_DIR + "/dns/apply.uc"; +const SERVICE_STATE_UC = LIB_DIR + "/service/state.uc"; +const SERVICE_UI_UC = LIB_DIR + "/service/ui.uc"; +const SUBSCRIPTION_CACHE_UC = LIB_DIR + "/subscription/cache.uc"; +const PROVIDERS_STATUS_UC = LIB_DIR + "/providers/status.uc"; +const SINGBOX_RUNTIME_UC = LIB_DIR + "/singbox/runtime.uc"; +const ZAPRET_RUNTIME_UC = LIB_DIR + "/providers/zapret/runtime.uc"; +const ZAPRET2_RUNTIME_UC = LIB_DIR + "/providers/zapret2/runtime.uc"; +const BYEDPI_RUNTIME_UC = LIB_DIR + "/providers/byedpi/runtime.uc"; +const ZAPRET_VALIDATOR_UC = LIB_DIR + "/providers/zapret/validator.uc"; +const ZAPRET2_VALIDATOR_UC = LIB_DIR + "/providers/zapret2/validator.uc"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function arg_number(value) { + value = as_string(value); + return value == "" || match(value, /[^0-9-]/) != null ? 0 : int(value, 10); +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; +} + +function command_status(command) { + return normalize_status(system(command)); +} + +function command_capture(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return { status: 1, output: "" }; + + let data = pipe.read("all"); + let status = normalize_status(pipe.close()); + return { status, output: data == null ? "" : as_string(data) }; +} + +function command_output(command) { + let result = command_capture(command); + return result.status == 0 ? result.output : ""; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_output_from_args(args) { + return command_output(command_from_args(args) + " 2>/dev/null"); +} + +function command_success_from_args(args) { + return command_success(command_from_args(args)); +} + +function command_exists(name) { + return command_success_from_args([ "command", "-v", as_string(name) ]); +} + +function module_args(module_path, args) { + let result = [ "ucode", "-L", LIB_DIR, module_path ]; + for (let arg in args) + push(result, arg); + return result; +} + +function module_capture(module_path, args) { + return command_capture(command_from_args(module_args(module_path, args))); +} + +function module_capture_stdin(module_path, args, input) { + let tmp = trim(command_output_from_args([ "mktemp" ])); + if (tmp == "") + return { status: 1, output: "" }; + + if (!fs.writefile(tmp, as_string(input))) { + fs.unlink(tmp); + return { status: 1, output: "" }; + } + + let result = command_capture(command_from_args(module_args(module_path, args)) + " < " + shell_quote(tmp)); + fs.unlink(tmp); + return result; +} + +function module_output(module_path, args) { + let result = module_capture(module_path, args); + return result.status == 0 ? result.output : ""; +} + +function module_output_stdin(module_path, args, input) { + let result = module_capture_stdin(module_path, args, input); + return result.status == 0 ? result.output : ""; +} + +function module_success(module_path, args) { + return command_success(command_from_args(module_args(module_path, args))); +} + +function module_passthrough(module_path, args) { + let result = module_capture(module_path, args); + if (result.output != "") + print(result.output); + return result.status; +} + +function status_capture(args, input) { + if (input != null) + return module_capture_stdin(STATUS_UC, args, input); + return command_capture(command_from_args(module_args(STATUS_UC, args))); +} + +function status_output(args, input) { + let result = status_capture(args, input); + return result.status == 0 ? result.output : ""; +} + +function status_success(args, input) { + return status_capture(args, input).status == 0; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : as_string(data); +} + +function read_json_file(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return null; + try { + return json(data); + } + catch (e) { + return null; + } +} + +function parse_json_or_null(value) { + try { + return json(as_string(value)); + } + catch (e) { + return null; + } +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + let value = object_or_empty(section)[key]; + if (value == null) + return as_string(fallback); + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function list_option(section, key) { + let value = object_or_empty(section)[key]; + if (type(value) == "array") + return value; + if (as_string(value) != "") + return [ as_string(value) ]; + return []; +} + +function bool_option(section, key, fallback) { + return arg_bool(option(section, key, fallback ? "1" : "0")); +} + +function settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, type_name); +} + +function uci_get(path) { + return uci_core.get(path); +} + +function uci_show(path) { + return uci_core.exists(path); +} + +function append_unique(values, value) { + value = as_string(value); + if (value == "") + return; + for (let item in values) + if (item == value) + return; + push(values, value); +} + +function config_section_types(config_path) { + let data = as_string(fs.readfile(config_path) || ""); + let result = []; + + for (let line in split(data, "\n")) { + line = trim(as_string(line)); + if (substr(line, 0, 7) != "config ") + continue; + + let fields = split(line, /[ \t\r\n]+/); + if (length(fields) >= 2) + append_unique(result, replace(as_string(fields[1]), /['"]/g, "")); + } + + return result; +} + +function uci_show_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function append_uci_show_option(lines, package_name, section_name, key, value) { + if (key == ".name" || key == ".type") + return; + + let path = as_string(package_name) + "." + as_string(section_name) + "." + as_string(key) + "="; + if (type(value) == "array") { + for (let item in value) + push(lines, path + uci_show_quote(item)); + } + else { + push(lines, path + uci_show_quote(value)); + } +} + +function uci_show_data(package_name, config_path) { + let lines = []; + package_name = as_string(package_name); + for (let type_name in config_section_types(config_path)) { + for (let section in uci_core.section_objects(package_name, type_name)) { + let name = as_string(section[".name"] || ""); + if (name == "") + continue; + push(lines, package_name + "." + name + "=" + as_string(section[".type"] || type_name)); + for (let key, value in section) + append_uci_show_option(lines, package_name, name, key, value); + } + } + return join("\n", lines) + "\n"; +} + +function network_show_data() { + return uci_show_data("network", "/etc/config/network"); +} + +function firewall_show_data() { + return uci_show_data("firewall", "/etc/config/firewall"); +} + +function file_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function file_executable(path) { + return command_success_from_args([ "test", "-x", as_string(path) ]); +} + +function ensure_dir(path) { + return command_success_from_args([ "mkdir", "-p", as_string(path) ]); +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function first_line_value(path, fallback) { + let data = fs.readfile(as_string(path)); + if (data == null) + return as_string(fallback); + let line = split(as_string(data), "\n")[0]; + line = replace(as_string(line), /\r$/, ""); + return line != "" ? line : as_string(fallback); +} + +function stdout_is_tty() { + return command_success_from_args([ "test", "-t", "1" ]); +} + +function nolog(message) { + if (!stdout_is_tty()) + return; + let timestamp = replace(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), /[\r\n]+$/g, ""); + print("\033[0;36m[", timestamp, "]\033[0m \033[0;32m", as_string(message), "\033[0m\n"); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]); +} + +function valid_ipv4(value) { + return core_ip.valid_ipv4(value, true, false); +} + +function valid_public_ipv4(value) { + value = as_string(value); + if (!valid_ipv4(value)) + return false; + + let parts = split(value, "."); + let a = int(parts[0], 10); + let b = int(parts[1], 10); + + if (a == 0 || a == 10 || a == 127 || a >= 224) + return false; + if (a == 169 && b == 254) + return false; + if (a == 192 && (b == 168 || b == 0 || b == 2)) + return false; + if (a == 198 && (b == 18 || b == 19 || b == 51)) + return false; + if (a == 203 && b == 0) + return false; + if (a == 100 && b >= 64 && b <= 127) + return false; + if (a == 172 && b >= 16 && b <= 31) + return false; + + return true; +} + +function valid_public_ipv6(value) { + value = lc(as_string(value)); + if (!core_ip.valid_ipv6(value)) + return false; + if (value == "::" || value == "::1") + return false; + if (substr(value, 0, 4) == "fe80" || substr(value, 0, 2) == "ff") + return false; + if (substr(value, 0, 2) == "fc" || substr(value, 0, 2) == "fd") + return false; + if (substr(value, 0, 4) == "2001" && index(value, "2001:db8") == 0) + return false; + return true; +} + +function valid_public_ip(value) { + return valid_public_ipv4(value) || valid_public_ipv6(value); +} + +function words(value) { + value = trim(as_string(value)); + return value == "" ? [] : split(value, /[ \t\r\n]+/); +} + +function allowed_ips_default_routes(value) { + let result = []; + for (let allowed in words(value)) { + if (allowed == "0.0.0.0/0" || allowed == "::/0") + push(result, allowed); + } + return result; +} + +function push_unique(result, seen, value) { + value = as_string(value); + if (value == "" || seen[value]) + return; + seen[value] = true; + push(result, value); +} + +function network_status_ip_addresses(data, key) { + let value = parse_json_or_null(data); + let addresses = type(value) == "object" ? value[key] : null; + let result = []; + let seen = {}; + if (type(addresses) == "array") { + for (let item in addresses) { + if (type(item) == "object") + push_unique(result, seen, item.address || ""); + } + } + return result; +} + +function get_wan_ip_addresses() { + let result = []; + let seen = {}; + + for (let interface in [ "wan", "wwan" ]) { + let data = command_output_from_args([ + "ubus", "-S", "call", "network.interface." + interface, "status" + ]); + for (let ip in network_status_ip_addresses(data, "ipv4-address")) + push_unique(result, seen, ip); + for (let ip in network_status_ip_addresses(data, "ipv6-address")) + push_unique(result, seen, ip); + } + + let route = command_output_from_args([ "ip", "-4", "route", "show", "default" ]); + let fields = words(route); + let iface = ""; + for (let i = 0; i + 1 < length(fields); i++) { + if (fields[i] == "dev") { + iface = fields[i + 1]; + break; + } + } + if (iface == "") + return ""; + + let addr = command_output_from_args([ "ip", "-4", "addr", "show", "dev", iface ]); + for (let line in split(addr, "\n")) { + line = trim(as_string(line)); + let matched = match(line, /^inet[ \t]+([0-9.]+)\//); + if (matched != null) + push_unique(result, seen, matched[1]); + } + + route = command_output_from_args([ "ip", "-6", "route", "show", "default" ]); + fields = words(route); + iface = ""; + for (let i = 0; i + 1 < length(fields); i++) { + if (fields[i] == "dev") { + iface = fields[i + 1]; + break; + } + } + if (iface != "") { + addr = command_output_from_args([ "ip", "-6", "addr", "show", "dev", iface, "scope", "global" ]); + for (let line in split(addr, "\n")) { + line = trim(as_string(line)); + let matched = match(line, /^inet6[ \t]+([^\/ \t]+)\//); + if (matched != null) + push_unique(result, seen, matched[1]); + } + } + + return join(" ", result); +} + +function helper_output(mode, args) { + let full = [ mode ]; + for (let arg in args) + push(full, arg); + return replace(module_output(HELPERS_UC, full), /[\r\n]+$/g, ""); +} + +function server_inbound_tag(section) { + return helper_output("server-inbound-tag", [ section ]); +} + +function server_required_inbound_proto(protocol) { + protocol = as_string(protocol); + if (protocol == "json_inbound") + return ""; + return protocol == "hysteria2" ? "udp" : "tcp"; +} + +function server_runtime_type_for_protocol(protocol) { + protocol = as_string(protocol); + if (protocol == "json_inbound") + return ""; + if (protocol == "mtproto") + return "mtproxy"; + return protocol; +} + +function server_listen_requires_firewall(listen, wan_ip) { + listen = as_string(listen); + if (listen == "0.0.0.0" || listen == "::" || valid_public_ip(listen)) + return true; + for (let ip in words(wan_ip)) + if (ip == listen) + return true; + return false; +} + +function firewall_required_protocols_open(port, required_proto) { + let firewall = firewall_show_data(); + return status_success([ "firewall-required-protocols-open", port, required_proto ], firewall); +} + +function server_required_port_conflict_owners(listen, port, required_proto) { + return replace(status_output( + [ "server-required-port-conflict-owners", listen, port, required_proto ], + command_output_from_args([ "netstat", "-lnp" ]) + ), /[\r\n]+$/g, ""); +} + +function server_required_ports_listening(listen, port, required_proto) { + return status_success( + [ "server-required-ports-listening", listen, port, required_proto ], + command_output_from_args([ "netstat", "-ln" ]) + ); +} + +function resolve_public_host_ips(host) { + host = as_string(host); + if (substr(host, 0, 1) == "[" && substr(host, length(host) - 1, 1) == "]") + host = substr(host, 1, length(host) - 2); + if (host == "") + return ""; + if (valid_ipv4(host)) + return host; + if (core_ip.valid_ipv6(host)) + return host; + + let seen = {}; + for (let line in split(command_output_from_args([ + "dig", "+short", "A", host, "+timeout=2", "+tries=1" + ]), "\n")) { + line = trim(as_string(line)); + if (valid_ipv4(line)) + seen[line] = true; + } + for (let line in split(command_output_from_args([ + "dig", "+short", "AAAA", host, "+timeout=2", "+tries=1" + ]), "\n")) { + line = trim(as_string(line)); + if (core_ip.valid_ipv6(line)) + seen[line] = true; + } + + return join(" ", sort(keys(seen))); +} + +function public_host_flags(public_host, public_host_ips, wan_ip, wan_public) { + return replace(status_output( + [ "public-host-flags", public_host, public_host_ips, wan_ip, wan_public ], + null + ), /[\r\n]+$/g, ""); +} + +function check_inbounds_config() { + let count = 0; + for (let section in uci_sections("server")) + if (bool_option(section, "enabled", false)) + count++; + write_json({ enabled_count: count }); + return 0; +} + +function check_inbounds() { + let cfg = settings(); + let sing_box_config_path = option(cfg, "config_path", ""); + let wan_ip = get_wan_ip_addresses(); + let wan_public = 0; + for (let ip in words(wan_ip)) { + if (valid_public_ip(ip)) { + wan_public = 1; + break; + } + } + let items = []; + let enabled_count = 0; + + for (let section in uci_sections("server")) { + if (!bool_option(section, "enabled", false)) + continue; + enabled_count++; + + let section_name = as_string(section[".name"] || ""); + let label = option(section, "label", section_name); + let protocol = option(section, "protocol", "vless"); + let listen = option(section, "listen", "0.0.0.0"); + let listen_port = option(section, "listen_port", ""); + let public_host = option(section, "public_host", ""); + let routing_mode = option(section, "routing_mode", "rules"); + let inbound_tag = server_inbound_tag(section_name); + let expected_type = server_runtime_type_for_protocol(protocol); + let required_proto = server_required_inbound_proto(protocol); + let runtime_json = protocol == "tailscale" + ? module_output(PROVIDERS_STATUS_UC, [ "endpoint-summary", sing_box_config_path, inbound_tag ]) + : module_output(PROVIDERS_STATUS_UC, [ "inbound-summary", sing_box_config_path, inbound_tag ]); + + let listening = -1; + let firewall_required = 0; + let firewall_open = -1; + let port_conflict = 0; + let port_conflict_owners = ""; + if (protocol != "tailscale" && protocol != "json_inbound") { + port_conflict_owners = server_required_port_conflict_owners(listen, listen_port, required_proto); + if (port_conflict_owners != "") + port_conflict = 1; + listening = server_required_ports_listening(listen, listen_port, required_proto) ? 1 : 0; + if (server_listen_requires_firewall(listen, wan_ip)) { + firewall_required = 1; + firewall_open = firewall_required_protocols_open(listen_port, required_proto) ? 1 : 0; + } + } + + let routes_configured = module_success(PROVIDERS_STATUS_UC, [ + "has-route-rule-for-inbound", sing_box_config_path, inbound_tag + ]) ? 1 : 0; + + let public_host_ips = protocol == "json_inbound" ? "" : resolve_public_host_ips(public_host); + let flags = words(public_host_flags(public_host, public_host_ips, wan_ip, wan_public)); + while (length(flags) < 3) + push(flags, "-1"); + + let item_json = status_output([ + "inbound-item-json", + runtime_json, + section_name, + label, + protocol, + routing_mode, + inbound_tag, + listen, + listen_port, + public_host, + public_host_ips, + expected_type, + required_proto, + listening, + firewall_required, + firewall_open, + port_conflict, + port_conflict_owners, + routes_configured, + flags[0], + flags[1], + flags[2] + ], null); + let item = parse_json_or_null(item_json); + push(items, type(item) == "object" ? item : {}); + } + + write_json({ + enabled_count, + config_path: sing_box_config_path, + wan_ip, + wan_public, + items + }); + return 0; +} + +function cleanup_check_proxy_dir(dir) { + dir = as_string(dir); + let prefix = TMP_SING_BOX_FOLDER + "/check-proxy-"; + if (substr(dir, 0, length(prefix)) == prefix) + command_success_from_args([ "rm", "-rf", dir ]); +} + +function check_proxy() { + let sing_box_config_path = option(settings(), "config_path", ""); + if (!command_exists("sing-box")) { + nolog("sing-box is not installed"); + return 1; + } + if (!file_exists(sing_box_config_path)) { + nolog("Configuration file not found"); + return 1; + } + + nolog("Checking sing-box configuration..."); + if (!command_success_from_args([ "sing-box", "-c", sing_box_config_path, "check" ])) { + nolog("Invalid configuration"); + return 1; + } + + print(status_output([ "mask-sing-box-config", sing_box_config_path ], null)); + nolog("Checking proxy connection..."); + + let check_proxy_dir = TMP_SING_BOX_FOLDER + "/check-proxy-" + clock()[0] + "-" + clock()[1]; + let check_proxy_config = check_proxy_dir + "/config.json"; + let check_proxy_cache = check_proxy_dir + "/cache.db"; + + cleanup_check_proxy_dir(check_proxy_dir); + ensure_dir(check_proxy_dir); + if (!status_success([ "prepare-check-proxy-config", sing_box_config_path, check_proxy_config, check_proxy_cache ], null)) { + nolog("Failed to prepare temporary configuration"); + cleanup_check_proxy_dir(check_proxy_dir); + return 1; + } + + let outbound_tag = replace(status_output( + [ "check-proxy-outbound-tag", check_proxy_config, CHECK_PROXY_IP_DOMAIN ], + null + ), /[\r\n]+$/g, ""); + + let response = ""; + for (let attempt = 1; attempt <= 5; attempt++) { + let args = [ "sing-box", "tools", "fetch", "ifconfig.me", "-c", check_proxy_config, "-D", check_proxy_dir, "--disable-color" ]; + if (outbound_tag != "") { + push(args, "-o"); + push(args, outbound_tag); + } + response = command_output(command_from_args(args) + " 2>/dev/null"); + if (status_success([ "proxy-response-is-retryable-error" ], response)) + continue; + + let masked_response_ip = replace(status_output([ "proxy-response-ip-mask" ], response), /[\r\n]+$/g, ""); + if (masked_response_ip != "") { + nolog(masked_response_ip + " - should match proxy IP"); + cleanup_check_proxy_dir(check_proxy_dir); + return 0; + } + + if (attempt == 5) { + nolog("Failed to get valid IP address after 5 attempts"); + nolog(response == "" ? "Error: Empty response" : "Error response: " + response); + cleanup_check_proxy_dir(check_proxy_dir); + return 1; + } + } + + cleanup_check_proxy_dir(check_proxy_dir); + return 1; +} + +function domain_lists_contain_cloud_provider() { + for (let section in uci_sections("section")) { + if (!bool_option(section, "domain_list_enabled", false)) + continue; + for (let value in list_option(section, "domain_list")) + if (value == "hetzner" || value == "ovh") + return true; + } + return false; +} + +function check_nft() { + if (!command_exists("nft")) { + nolog("nft is not installed"); + return 1; + } + + nolog("Checking " + NFT_TABLE_NAME + " rules..."); + if (!command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) { + nolog("❌ " + NFT_TABLE_NAME + " not found"); + return 1; + } + + if (domain_lists_contain_cloud_provider()) { + nolog("Sets statistics:"); + for (let set_name in [ + NFT_COMMON_SET_NAME, + NFT_PORT_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_DISCORD_SET_NAME, + NFT_LOCALV4_SET_NAME + ]) { + if (!command_success_from_args([ "nft", "list", "set", "inet", NFT_TABLE_NAME, set_name ])) + continue; + let count = replace(status_output( + [ "nft-set-element-count" ], + command_output_from_args([ "nft", "-j", "list", "set", "inet", NFT_TABLE_NAME, set_name ]) + ), /[\r\n]+$/g, ""); + print("- ", set_name, ": ", count, " elements\n"); + } + + nolog("Chain configurations:"); + print(status_output( + [ "nft-chain-config-blocks", "mangle", "proxy" ], + command_output_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ]) + )); + } + else { + nolog("Sets configuration:"); + print(command_output_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])); + } + + nolog("NFT check completed"); + return 0; +} + +function check_logs() { + if (!command_exists("logread")) { + nolog("Error: logread command not found"); + return 1; + } + let rendered = status_capture([ "tachyon-logs" ], command_output_from_args([ "logread" ])); + if (rendered.output != "") + print(rendered.output); + if (rendered.status != 0) { + nolog("Logs not found"); + return 1; + } + return 0; +} + +function check_sing_box_logs() { + if (!command_exists("logread")) { + nolog("Error: logread command not found"); + return 1; + } + let rendered = status_capture([ "matching-log-tail", "sing-box", "100" ], command_output_from_args([ "logread" ])); + if (rendered.output != "") + print(rendered.output); + if (rendered.status != 0) { + nolog("sing-box logs not found"); + return 1; + } + return 0; +} + +function tachyon_logs_fixture() { + let rendered = status_capture([ "tachyon-logs" ], read_stdin()); + if (rendered.output != "") + print(rendered.output); + return rendered.status; +} + +function show_sing_box_config(visibility) { + visibility = as_string(visibility || "masked"); + let sing_box_config_path = option(settings(), "config_path", ""); + nolog("Current sing-box configuration:"); + if (!file_exists(sing_box_config_path)) { + nolog("Configuration file not found"); + return 1; + } + if (visibility == "raw") + print(as_string(fs.readfile(sing_box_config_path))); + else + print(status_output([ "mask-sing-box-config", sing_box_config_path ], null)); + return 0; +} + +function show_config(visibility) { + visibility = as_string(visibility || "masked"); + if (!file_exists(TACHYON_CONFIG)) { + nolog("Configuration file not found"); + return 1; + } + if (visibility == "raw") + print(as_string(fs.readfile(TACHYON_CONFIG))); + else + print(status_output([ "tachyon-config-masked", TACHYON_CONFIG ], null)); + return 0; +} + +function show_version() { + print(TACHYON_VERSION, "\n"); + return 0; +} + +function show_sing_box_version() { + print(replace(module_output(SINGBOX_RUNTIME_UC, [ "version" ]), /[\r\n]+$/g, ""), "\n"); + return 0; +} + +function get_luci_app_version() { + let path = TACHYON_LUCI_VIEW_DIR + "/main.js"; + let data = fs.readfile(path); + if (data == null) + return "not installed"; + + for (let line in split(as_string(data), "\n")) { + let matched = match(line, /^[ \t]*var[ \t]+([^ \t=]+)[ \t]*=[ \t]*"([^"]*)"/); + if (matched != null && matched[1] == "TACHYON_LUCI_APP_VERSION") + return as_string(matched[2]); + } + return ""; +} + +function system_info_cache_is_valid() { + let cache = read_json_file(SYSTEM_INFO_CACHE_FILE); + if (type(cache) != "object") + return false; + let now = int(clock()[0]); + let generated_at = arg_number(cache.generated_at || 0); + if (now > 0 && generated_at > 0 && SYSTEM_INFO_CACHE_TTL > 0 && now - generated_at >= SYSTEM_INFO_CACHE_TTL) + return false; + return cache.tachyon_version == TACHYON_VERSION && cache.luci_app_version == get_luci_app_version(); +} + +function ensure_subscription_runtime_dirs() { + module_success(SUBSCRIPTION_CACHE_UC, [ + "ensure-runtime-dirs" + ]); + ensure_dir(RUNTIME_STATE_DIR); +} + +function write_system_info_cache(value) { + ensure_subscription_runtime_dirs(); + let tmpfile = SYSTEM_INFO_CACHE_FILE + "." + clock()[0] + "." + clock()[1] + ".tmp"; + if (fs.writefile(tmpfile, as_string(value) + "\n") == null) + return false; + remove_file(SYSTEM_INFO_CACHE_FILE); + if (!fs.rename(tmpfile, SYSTEM_INFO_CACHE_FILE)) { + remove_file(tmpfile); + return false; + } + return true; +} + +function sing_box_marker_is(expected) { + return module_success(SINGBOX_RUNTIME_UC, [ "marker-is", expected ]); +} + +function sing_box_component_action_running() { + return module_success(SERVICE_UI_UC, [ "component-action-running-for", "sing_box" ]); +} + +function sing_box_live_probe_disabled() { + return sing_box_marker_is("extended") || + sing_box_marker_is("extended-compressed") || + sing_box_component_action_running(); +} + +function sing_box_tiny_package_installed() { + return module_success(PACKAGES_UC, [ "installed", "sing-box-tiny" ]); +} + +function sing_box_capability_flags(sing_box_version, sing_box_version_output) { + let extended = 0; + let tiny = 0; + let tailscale = 0; + + if (sing_box_marker_is("extended") || + sing_box_marker_is("extended-compressed") || + module_success(SINGBOX_RUNTIME_UC, [ "is-extended", sing_box_version ])) + extended = 1; + + if (extended == 0 && (sing_box_marker_is("tiny") || sing_box_tiny_package_installed())) + tiny = 1; + + if (extended == 1) + tailscale = 1; + else if (as_string(sing_box_version_output) != "") { + if (module_success(SINGBOX_RUNTIME_UC, [ "supports-tailscale", sing_box_version, sing_box_version_output ])) + tailscale = 1; + } + else if (tiny == 0 && sing_box_component_action_running()) + tailscale = 1; + + return { extended, tiny, tailscale }; +} + +function provider_installed(runtime_uc) { + return module_success(runtime_uc, [ "installed" ]); +} + +function provider_version(runtime_uc) { + let value = replace(module_output(runtime_uc, [ "package-version" ]), /[\r\n]+$/g, ""); + return value != "" ? value : "unknown"; +} + +function openwrt_release() { + let data = fs.readfile("/etc/os-release"); + if (data == null) + return "unknown"; + for (let line in split(as_string(data), "\n")) { + if (substr(line, 0, length("OPENWRT_RELEASE=")) != "OPENWRT_RELEASE=") + continue; + let value = substr(line, length("OPENWRT_RELEASE=")); + if (length(value) >= 2) { + let quote = substr(value, 0, 1); + if ((quote == "\"" || quote == "'") && substr(value, length(value) - 1) == quote) + value = substr(value, 1, length(value) - 2); + } + return value != "" ? value : "unknown"; + } + return "unknown"; +} + +function build_system_info() { + let tachyon_latest_version = first_line_value("/tmp/tachyon.latest-version.cache", "unknown"); + let luci_app_version = get_luci_app_version(); + let sing_box_version = ""; + let sing_box_version_output = ""; + + if (command_exists("sing-box")) { + if (sing_box_live_probe_disabled()) { + sing_box_version = replace(module_output(SINGBOX_RUNTIME_UC, [ "read-version-state" ]), /[\r\n]+$/g, ""); + sing_box_version_output = ""; + } + else { + sing_box_version_output = module_output(SINGBOX_RUNTIME_UC, [ "version-output" ]); + sing_box_version = replace(module_output_stdin(SINGBOX_RUNTIME_UC, [ "version-from-output" ], sing_box_version_output), /[\r\n]+$/g, ""); + } + if (sing_box_version == "") + sing_box_version = "unknown"; + } + else { + sing_box_version = "not installed"; + sing_box_version_output = ""; + } + + let flags = sing_box_capability_flags(sing_box_version, sing_box_version_output); + let sing_box_compressed = flags.extended == 1 && sing_box_marker_is("extended-compressed") ? 1 : 0; + + let zapret_installed = provider_installed(ZAPRET_RUNTIME_UC) ? 1 : 0; + let zapret_version = zapret_installed ? provider_version(ZAPRET_RUNTIME_UC) : "not installed"; + let zapret2_installed = provider_installed(ZAPRET2_RUNTIME_UC) ? 1 : 0; + let zapret2_version = zapret2_installed ? provider_version(ZAPRET2_RUNTIME_UC) : "not installed"; + let byedpi_installed = provider_installed(BYEDPI_RUNTIME_UC) ? 1 : 0; + let byedpi_version = byedpi_installed ? provider_version(BYEDPI_RUNTIME_UC) : "not installed"; + let device_model = first_line_value("/tmp/sysinfo/model", "unknown"); + + return { + tachyon_version: TACHYON_VERSION, + tachyon_latest_version: tachyon_latest_version || "unknown", + luci_app_version, + sing_box_version, + sing_box_extended: flags.extended, + sing_box_tiny: flags.tiny, + sing_box_compressed, + sing_box_tailscale: flags.tailscale, + zapret_version, + zapret_installed, + zapret2_version, + zapret2_installed, + byedpi_version, + byedpi_installed, + openwrt_version: openwrt_release(), + device_model, + generated_at: int(clock()[0]) + }; +} + +function get_system_info() { + if (system_info_cache_is_valid()) { + print(as_string(fs.readfile(SYSTEM_INFO_CACHE_FILE))); + return 0; + } + + let system_info = sprintf("%J", build_system_info()); + write_system_info_cache(system_info); + print(system_info, "\n"); + return 0; +} + +function get_server_capabilities() { + if (!file_executable(SING_BOX_BIN_PATH)) { + write_json({ + sing_box_extended: 0, + sing_box_tiny: 0, + sing_box_tailscale: 0 + }); + return 0; + } + + let sing_box_version_output = ""; + let sing_box_version = ""; + if (sing_box_live_probe_disabled()) + sing_box_version = replace(module_output(SINGBOX_RUNTIME_UC, [ "read-version-state" ]), /[\r\n]+$/g, ""); + else { + sing_box_version_output = module_output(SINGBOX_RUNTIME_UC, [ "version-output" ]); + sing_box_version = replace(module_output_stdin(SINGBOX_RUNTIME_UC, [ "version-from-output" ], sing_box_version_output), /[\r\n]+$/g, ""); + } + let flags = sing_box_capability_flags(sing_box_version, sing_box_version_output); + write_json({ + sing_box_extended: flags.extended, + sing_box_tiny: flags.tiny, + sing_box_tailscale: flags.tailscale + }); + return 0; +} + +function neutralize_zapret_defaults() { + log_message("Standalone zapret is not neutralized automatically; Tachyon uses /opt/zapret/nfq/nfqws as an external provider and manages only its own NFQUEUE range.", "info"); + return 0; +} + +function sing_box_process_is_running() { + return command_success_from_args([ "pgrep", "-x", "sing-box" ]) || + command_success_from_args([ "pgrep", "-f", "^/usr/bin/sing-box[[:space:]]" ]); +} + +function service_status_label(running, enabled) { + if (arg_number(running) == 1) + return arg_number(enabled) == 1 ? "running & enabled" : "running but disabled"; + return arg_number(enabled) == 1 ? "stopped but enabled" : "stopped & disabled"; +} + +function write_service_status(running, enabled, dns_configured) { + write_json({ + running, + enabled, + status: service_status_label(running, enabled), + dns_configured + }); +} + +function dnsmasq_has_tachyon_dns() { + return module_success(DNS_APPLY_UC, [ "has-tachyon-dns" ]); +} + +function get_sing_box_status() { + let running = module_success(SERVICE_STATE_UC, [ + "sing-box-service-stable", + RUNTIME_STABLE_MIN_AGE + ]) ? 1 : 0; + let enabled = file_executable("/etc/rc.d/S99sing-box") ? 1 : 0; + let dns_configured = dnsmasq_has_tachyon_dns() ? 1 : 0; + write_service_status(running, enabled, dns_configured); + return 0; +} + +function get_status() { + let running = module_success(SERVICE_STATE_UC, [ + "tachyon-stably-running", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MARK, RUNTIME_STABLE_MIN_AGE + ]) ? 1 : 0; + let enabled = file_executable("/etc/rc.d/S99" + TACHYON_SERVICE_NAME) ? 1 : 0; + let dns_configured = dnsmasq_has_tachyon_dns() ? 1 : 0; + write_service_status(running, enabled, dns_configured); + return 0; +} + +function subscription_cache(args) { + return module_capture(SUBSCRIPTION_CACHE_UC, args); +} + +function print_subscription_result(result, fallback) { + if (result.status == 0 && result.output != "") { + print(result.output); + return 0; + } + print(as_string(fallback)); + return 0; +} + +function section_safe(section) { + section = as_string(section); + return section != "" && index(section, "/") < 0 && index(section, "..") < 0; +} + +function get_outbound_metadata(section) { + subscription_cache([ "ensure-runtime-dirs" ]); + if (!section_safe(section)) + return print_subscription_result(subscription_cache([ "empty-outbound-metadata" ]), ""); + let metadata_path = replace(module_output(SUBSCRIPTION_CACHE_UC, [ "outbound-metadata-path", section ]), /[\r\n]+$/g, ""); + if (metadata_path == "") + return print_subscription_result(subscription_cache([ "empty-outbound-metadata" ]), ""); + let result = subscription_cache([ "get-outbound-metadata", SECTION_CACHE_DIR, section, metadata_path ]); + if (result.status != 0) + result = subscription_cache([ "empty-outbound-metadata" ]); + return print_subscription_result(result, ""); +} + +function get_subscription_metadata(section) { + subscription_cache([ "ensure-runtime-dirs" ]); + if (!section_safe(section)) { + print("{}\n"); + return 0; + } + let metadata_path = replace(module_output(SUBSCRIPTION_CACHE_UC, [ "subscription-metadata-path", section ]), /[\r\n]+$/g, ""); + if (metadata_path == "") { + print("{}\n"); + return 0; + } + let result = subscription_cache([ "get-subscription-metadata", SECTION_CACHE_DIR, section, metadata_path ]); + return print_subscription_result(result, "{}\n"); +} + +function validate_nfqws_strategy_json(raw_opt) { + let result = module_capture(ZAPRET_VALIDATOR_UC, [ + "validate-json", "nfqws", as_string(raw_opt), ZAPRET_LEGACY_DEFAULT_NFQWS_OPT + ]); + if (result.output != "") + print(result.output); + return 0; +} + +function validate_nfqws2_strategy_json(raw_opt) { + let result = module_capture(ZAPRET2_VALIDATOR_UC, [ "validate-json", "nfqws2", as_string(raw_opt) ]); + if (result.output != "") + print(result.output); + return 0; +} + +function url_host(value) { + return helper_output("url-get-host", [ value ]); +} + +function dns_check_resolve_host(host, resolver, timeout_seconds) { + host = as_string(host); + resolver = as_string(resolver); + if (host == "") + return ""; + if (valid_ipv4(host)) + return host; + if (resolver == "") + return ""; + + timeout_seconds = int(timeout_seconds || 2); + for (let line in split(command_output_from_args([ + "dig", "@" + resolver, host, "A", "+short", "+timeout=" + as_string(timeout_seconds), "+tries=1" + ]), "\n")) { + line = trim(as_string(line)); + if (valid_ipv4(line)) + return line; + } + return ""; +} + +function device_ipv4_address(interface) { + let value = replace(module_output(SINGBOX_RUNTIME_UC, [ "device-ipv4-address", interface ]), /[\r\n]+$/g, ""); + if (value != "") + return value; + + let output = command_output_from_args([ "ip", "-4", "addr", "show", "dev", interface ]); + for (let line in split(output, "\n")) { + line = trim(as_string(line)); + let matched = match(line, /^inet[ \t]+([0-9.]+)\//); + if (matched != null) + return as_string(matched[1]); + } + return ""; +} + +function dns_check_router_resolver_available(domain) { + for (let address in [ "127.0.0.1", SB_DNS_INBOUND_ADDRESS ]) { + if (address != "" && command_success_from_args([ "dig", "@" + address, domain, "+timeout=2", "+tries=1" ])) + return true; + } + + let listen_address = replace(module_output(SINGBOX_RUNTIME_UC, [ "service-listen-address" ]), /[\r\n]+$/g, ""); + if (listen_address != "" && command_success_from_args([ "dig", "@" + listen_address, domain, "+timeout=2", "+tries=1" ])) + return true; + + let source_interfaces = option(settings(), "source_network_interfaces", "br-lan"); + for (let interface in words(source_interfaces)) { + let address = device_ipv4_address(interface); + if (address != "" && command_success_from_args([ "dig", "@" + address, domain, "+timeout=2", "+tries=1" ])) + return true; + } + + return false; +} + +function dns_check_timeout_seconds(value) { + let rest = as_string(value); + let milliseconds = 0.0; + let units = { ns: 0.000001, us: 0.001, ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 }; + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return 2; + milliseconds += (matched[1] * 1) * units[matched[3]]; + rest = substr(rest, length(matched[0])); + } + return milliseconds > 0 ? int((milliseconds + 999) / 1000) : 2; +} + +function check_dns_available() { + let cfg = settings(); + let dns_type = option(cfg, "dns_type", ""); + let active = runtime_dns.active_values(cfg); + let dns_server = active.main; + let bootstrap_dns_server = active.bootstrap; + let dont_touch_dhcp = bool_option(cfg, "dont_touch_dhcp", false) ? 1 : 0; + let domain = "example.com"; + let timeout_seconds = dns_check_timeout_seconds(option(cfg, "dns_check_timeout", "2s")); + let dns_status = 0; + let dns_on_router = 0; + let bootstrap_dns_status = 0; + let dhcp_config_status = 1; + + let active_dns_args = [ "dig" ]; + if (runtime_dns.failover_enabled(cfg)) { + push(active_dns_args, "-p"); + push(active_dns_args, as_string(runtime_dns.health_port("active", 0))); + } + push(active_dns_args, "@" + SB_DNS_INBOUND_ADDRESS); + push(active_dns_args, domain); + push(active_dns_args, "A"); + push(active_dns_args, "+short"); + push(active_dns_args, "+timeout=" + as_string(timeout_seconds)); + push(active_dns_args, "+tries=1"); + for (let line in split(command_output_from_args(active_dns_args), "\n")) + if (valid_ipv4(trim(as_string(line)))) { + dns_status = 1; + break; + } + + if (dns_check_router_resolver_available(domain)) + dns_on_router = 1; + + let dns_server_host = url_host(dns_server); + if (dns_server_host == "") + dns_server_host = dns_server; + if (bootstrap_dns_server != "") { + if (length(active.state.bootstrap_servers) > 1) { + for (let line in split(command_output_from_args([ + "dig", "-p", as_string(runtime_dns.health_port("bootstrap", active.state.bootstrap_index)), + "@" + runtime_dns.DNS_HEALTH_ADDRESS, domain, "A", "+short", + "+timeout=" + as_string(timeout_seconds), "+tries=1" + ]), "\n")) + if (valid_ipv4(trim(as_string(line)))) { + bootstrap_dns_status = 1; + break; + } + } + else { + let bootstrap_check_domain = domain; + if (dns_server_host != "" && !valid_ipv4(dns_server_host)) + bootstrap_check_domain = dns_server_host; + if (dns_check_resolve_host(bootstrap_check_domain, bootstrap_dns_server, timeout_seconds) != "") + bootstrap_dns_status = 1; + } + } + + if (!module_success(DNS_APPLY_UC, [ "default-config-complete" ])) + dhcp_config_status = 0; + + let display_dns_server = replace(status_output([ "mask-dns-server", dns_server ], null), /[\r\n]+$/g, ""); + write_json({ + dns_type, + dns_server: display_dns_server, + dns_server_index: active.state.main_index, + dns_server_count: length(active.state.main_servers), + dns_status, + dns_on_router, + bootstrap_dns_server, + bootstrap_dns_server_index: active.state.bootstrap_index, + bootstrap_dns_server_count: length(active.state.bootstrap_servers), + bootstrap_dns_status, + dhcp_config_status, + dont_touch_dhcp + }); + return 0; +} + +function nft_chain_counter_status(chain) { + let output = command_output_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, chain ]); + let status = words(status_output([ "nft-chain-counter-status" ], output)); + while (length(status) < 2) + push(status, "0"); + return [ arg_number(status[0]), arg_number(status[1]) ]; +} + +function nft_table_has_other_mark_rules(family, table_name) { + let output = command_output_from_args([ "nft", "list", "table", family, table_name ]); + return status_success([ "stdin-contains", "meta mark set" ], output); +} + +function check_nft_rules() { + command_status("sh -c " + shell_quote( + "curl -m 3 -s " + shell_quote("https://" + CHECK_PROXY_IP_DOMAIN + "/check") + " >/dev/null 2>&1 & pid1=$!; " + + "curl -m 3 -s " + shell_quote("https://" + FAKEIP_TEST_DOMAIN + "/check") + " >/dev/null 2>&1 & pid2=$!; " + + "wait $pid1 2>/dev/null; wait $pid2 2>/dev/null; sleep 1" + )); + + let table_exist = 0; + let rules_mangle_exist = 0; + let rules_mangle_counters = 0; + let rules_mangle_output_exist = 0; + let rules_mangle_output_counters = 0; + let rules_proxy_exist = 0; + let rules_proxy_counters = 0; + let rules_other_mark_exist = 0; + + if (command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) { + table_exist = 1; + if (command_success_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, "mangle" ])) { + let status = nft_chain_counter_status("mangle"); + rules_mangle_exist = status[0]; + rules_mangle_counters = status[1]; + } + if (command_success_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, "mangle_output" ])) { + let status = nft_chain_counter_status("mangle_output"); + rules_mangle_output_exist = status[0]; + rules_mangle_output_counters = status[1]; + } + if (command_success_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, "proxy" ])) { + let status = nft_chain_counter_status("proxy"); + rules_proxy_exist = status[0]; + rules_proxy_counters = status[1]; + } + } + + for (let line in split(command_output_from_args([ "nft", "list", "tables" ]), "\n")) { + let fields = words(line); + if (length(fields) < 3) + continue; + let family = fields[1]; + let table_name = fields[2]; + if (table_name == NFT_TABLE_NAME) + continue; + if (nft_table_has_other_mark_rules(family, table_name)) { + rules_other_mark_exist = 1; + break; + } + } + + write_json({ + table_exist, + rules_mangle_exist, + rules_mangle_counters, + rules_mangle_output_exist, + rules_mangle_output_counters, + rules_proxy_exist, + rules_proxy_counters, + rules_other_mark_exist + }); + return 0; +} + +function strip_leading_v(value) { + value = as_string(value); + return substr(value, 0, 1) == "v" ? substr(value, 1) : value; +} + +function sing_box_standard_ports_listening(netstat) { + netstat = as_string(netstat); + let port_53_ok = index(netstat, "127.0.0.42:53") >= 0; + let tproxy_suffix = ":" + SB_TPROXY_INBOUND_PORT; + let port_1602_ok = index(netstat, "0.0.0.0" + tproxy_suffix) >= 0 || + index(netstat, "127.0.0.1" + tproxy_suffix) >= 0; + let port_1602_v6_ok = index(netstat, SB_TPROXY_INBOUND6_ADDRESS + tproxy_suffix) >= 0 || + index(netstat, "[" + SB_TPROXY_INBOUND6_ADDRESS + "]" + tproxy_suffix) >= 0 || + index(netstat, "0:0:0:0:0:0:0:1" + tproxy_suffix) >= 0 || + index(netstat, ":::" + SB_TPROXY_INBOUND_PORT) >= 0; + return port_53_ok && port_1602_ok && port_1602_v6_ok; +} + +function sing_box_standard_ports_listening_fixture() { + exit(sing_box_standard_ports_listening(read_stdin()) ? 0 : 1); +} + +function check_sing_box() { + let sing_box_installed = 0; + let sing_box_version_ok = 0; + let sing_box_extended = 0; + let sing_box_service_exist = 0; + let sing_box_autostart_disabled = 0; + let sing_box_process_running = 0; + let sing_box_ports_listening = 0; + + if (command_exists("sing-box")) { + sing_box_installed = 1; + let version = strip_leading_v(replace(module_output(SINGBOX_RUNTIME_UC, [ "version" ]), /[\r\n]+$/g, "")); + if (version != "") { + if (sing_box_marker_is("extended-compressed") || module_success(SINGBOX_RUNTIME_UC, [ "is-extended", version ])) + sing_box_extended = 1; + if (module_success(HELPERS_UC, [ "version-at-least", version, "1.12.4" ])) + sing_box_version_ok = 1; + } + else if (sing_box_marker_is("extended-compressed")) + sing_box_extended = 1; + } + + if (file_exists("/etc/init.d/sing-box")) { + sing_box_service_exist = 1; + if (!command_success_from_args([ "/etc/init.d/sing-box", "enabled" ])) + sing_box_autostart_disabled = 1; + } + + if (sing_box_process_is_running()) + sing_box_process_running = 1; + + if (sing_box_standard_ports_listening(command_output_from_args([ "netstat", "-ln" ]))) + sing_box_ports_listening = 1; + + write_json({ + sing_box_installed, + sing_box_version_ok, + sing_box_extended, + sing_box_service_exist, + sing_box_autostart_disabled, + sing_box_process_running, + sing_box_ports_listening + }); + return 0; +} + +function check_fakeip() { + let fakeip_address = ""; + let fakeip6_address = ""; + for (let line in split(command_output_from_args([ + "dig", "+short", "@" + SB_DNS_INBOUND_ADDRESS, FAKEIP_TEST_DOMAIN, "A", "+timeout=2", "+tries=1" + ]), "\n")) { + line = trim(as_string(line)); + if (valid_ipv4(line)) { + fakeip_address = line; + break; + } + } + for (let line in split(command_output_from_args([ + "dig", "+short", "@" + SB_DNS_INBOUND_ADDRESS, FAKEIP_TEST_DOMAIN, "AAAA", "+timeout=2", "+tries=1" + ]), "\n")) { + line = lc(trim(as_string(line))); + if (core_ip.valid_ipv6(line)) { + fakeip6_address = line; + break; + } + } + write_json({ + fakeip: match(fakeip_address, /^198\.(18|19)\./) != null || match(fakeip6_address, /^fc[0-3][0-9a-f]:/) != null, + IP: fakeip_address != "" ? fakeip_address : fakeip6_address, + IPv4: fakeip_address, + IPv6: fakeip6_address + }); + return 0; +} + +function clash_json_output(args) { + print(status_output([ "stdin-json" ], command_output(command_from_args(args)))); + return 0; +} + +function clash_api_url() { + let address = replace(module_output(SINGBOX_RUNTIME_UC, [ "service-listen-address" ]), /[\r\n]+$/g, ""); + if (address == "") + address = "127.0.0.1"; + return address + ":" + SB_CLASH_API_CONTROLLER_PORT; +} + +function clash_auth_args() { + let cfg = settings(); + if (!bool_option(cfg, "enable_yacd_wan_access", false)) + return []; + return [ "--header", "Authorization: Bearer " + option(cfg, "yacd_secret_key", "") ]; +} + +function clash_urlencode(value) { + return replace(status_output([ "url-encode", value ], null), /[\r\n]+$/g, ""); +} + +function clash_json_error(message) { + let result = status_capture([ "json-error", message ], null); + if (result.output != "") + print(result.output); + return 1; +} + +function clash_proxy_type_map(base_url, auth) { + let args = [ "curl", "-s" ]; + for (let item in auth) push(args, item); + push(args, base_url + "/proxies"); + + let value = {}; + try { + value = json(command_output(command_from_args(args))); + } + catch (e) { + return {}; + } + + let result = {}; + for (let tag, proxy in object_or_empty(value.proxies)) + result[tag] = as_string(object_or_empty(proxy).type || ""); + return result; +} + +function clash_latency_endpoint(base_url, proxy_tag, proxy_type) { + proxy_type = as_string(proxy_type); + if (proxy_type == "URLTest" || proxy_type == "urltest") + return base_url + "/group/" + clash_urlencode(proxy_tag) + "/delay"; + return base_url + "/proxies/" + clash_urlencode(proxy_tag) + "/delay"; +} + +function latency_test_url() { + let value = option(settings(), "latency_test_url", DEFAULT_LATENCY_TEST_URL); + return value == "" ? DEFAULT_LATENCY_TEST_URL : value; +} + +function clash_api(action, arg1, arg2, arg3) { + let base_url = clash_api_url(); + let test_url = latency_test_url(); + let auth = clash_auth_args(); + + if (action == "get_proxies") { + let args = [ "curl", "-s" ]; + for (let item in auth) push(args, item); + push(args, base_url + "/proxies"); + return clash_json_output(args); + } + + if (action == "get_connections") { + let args = [ "curl", "-s" ]; + for (let item in auth) push(args, item); + push(args, base_url + "/connections"); + return clash_json_output(args); + } + + if (action == "get_proxy_latency") { + if (as_string(arg1) == "") + return clash_json_error("proxy_tag required"); + let url = as_string(arg3 || ""); + if (url == "") + url = test_url; + let args = [ "curl", "-G", "-s", base_url + "/proxies/" + clash_urlencode(arg1) + "/delay" ]; + for (let item in auth) push(args, item); + push(args, "--data-urlencode"); + push(args, "url=" + url); + push(args, "--data-urlencode"); + push(args, "timeout=" + as_string(arg2 || "2000")); + return clash_json_output(args); + } + + if (action == "get_proxy_latencies") { + if (as_string(arg1) == "") + return clash_json_error("proxy_tags_json required"); + let tags = status_capture([ "clash-proxy-tags-lines", arg1 ], null); + if (tags.status != 0) + return clash_json_error("proxy_tags_json must be a JSON array of non-empty strings"); + let proxy_tags = []; + for (let proxy_tag in split(tags.output, "\n")) { + proxy_tag = as_string(proxy_tag); + if (proxy_tag != "") + push(proxy_tags, proxy_tag); + } + + let count = 0; + let failed = 0; + let progress_path = as_string(arg3); + let total = length(proxy_tags); + if (progress_path != "") + module_success(SERVICE_UI_UC, [ "latency-progress-state", progress_path, count, total, failed ]); + + let proxy_types = clash_proxy_type_map(base_url, auth); + for (let proxy_tag in proxy_tags) { + let args = [ "curl", "-G", "-s", clash_latency_endpoint(base_url, proxy_tag, proxy_types[proxy_tag]) ]; + for (let item in auth) push(args, item); + push(args, "--data-urlencode"); + push(args, "url=" + test_url); + push(args, "--data-urlencode"); + push(args, "timeout=" + as_string(arg2 || "5000")); + if (status_capture([ "stdin-json" ], command_output(command_from_args(args))).status != 0) + failed++; + count++; + if (progress_path != "") + module_success(SERVICE_UI_UC, [ "latency-progress-state", progress_path, count, total, failed ]); + } + let result = status_capture([ "clash-proxy-latencies-result", count, failed ], null); + if (result.output != "") + print(result.output); + return result.status; + } + + if (action == "get_group_latency") { + if (as_string(arg1) == "") + return clash_json_error("group_tag required"); + let args = [ "curl", "-G", "-s", base_url + "/group/" + clash_urlencode(arg1) + "/delay" ]; + for (let item in auth) push(args, item); + push(args, "--data-urlencode"); + push(args, "url=" + test_url); + push(args, "--data-urlencode"); + push(args, "timeout=" + as_string(arg2 || "5000")); + return clash_json_output(args); + } + + if (action == "set_group_proxy") { + if (as_string(arg1) == "" || as_string(arg2) == "") + return clash_json_error("group_tag and proxy_tag required"); + let payload = status_output([ "clash-set-group-proxy-payload", arg2 ], null); + let args = [ "curl", "-X", "PUT", "-s", "-w", "\n%{http_code}", base_url + "/proxies/" + clash_urlencode(arg1) ]; + for (let item in auth) push(args, item); + push(args, "--data-raw"); + push(args, payload); + let result = status_capture([ "clash-set-group-proxy-result", arg1, arg2 ], command_output(command_from_args(args))); + if (result.output != "") + print(result.output); + return result.status; + } + + if (action == "close_connection") { + if (as_string(arg1) == "") + return clash_json_error("connection_id required"); + let args = [ "curl", "-X", "DELETE", "-s", "-w", "\n%{http_code}", base_url + "/connections/" + clash_urlencode(arg1) ]; + for (let item in auth) push(args, item); + let result = status_capture([ "clash-close-connection-result", arg1 ], command_output(command_from_args(args))); + if (result.output != "") + print(result.output); + return result.status; + } + + if (action == "close_all_connections") { + let args = [ "curl", "-X", "DELETE", "-s", "-w", "\n%{http_code}", base_url + "/connections" ]; + for (let item in auth) push(args, item); + let result = status_capture([ "clash-close-all-connections-result" ], command_output(command_from_args(args))); + if (result.output != "") + print(result.output); + return result.status; + } + + let unknown = status_capture([ "clash-unknown-action" ], null); + if (unknown.output != "") + print(unknown.output); + return 1; +} + +function print_global(message) { + print(as_string(message), "\n"); +} + +function render_or_fail(mode_args, input, fail_message, ok_statuses) { + let result = status_capture(mode_args, input); + if (result.output != "") + print(result.output); + for (let status in ok_statuses) + if (result.status == status) + return result.status; + print_global(fail_message); + return result.status; +} + +function global_check(arg1, arg2) { + let visibility = as_string(arg2 || "masked"); + if (as_string(arg1) == "raw" || as_string(arg1) == "masked") + visibility = as_string(arg1); + + print_global("📡 Global check run!"); + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("🛠️ System info"); + + let system_info_json = sprintf("%J", build_system_info()); + render_or_fail([ "global-system-info" ], system_info_json, "❌ Failed to parse system info", [ 0 ]); + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("➡️ DNS status"); + + let dns_check_capture = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-dns-available" ]))); + if (dns_check_capture.output != "") { + let dns_render = render_or_fail( + [ "global-dns-check", bool_option(settings(), "dont_touch_dhcp", false) ? "1" : "0" ], + dns_check_capture.output, + "❌ Failed to parse DNS info", + [ 0, 10 ] + ); + if (dns_render == 10) + print(status_output([ "dhcp-dnsmasq-config", "/etc/config/dhcp" ], null)); + } + else + print_global("❌ Failed to get DNS info"); + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("📦 Sing-box status"); + let singbox_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-sing-box" ]))).output; + if (singbox_check_json != "") + render_or_fail([ "global-sing-box-check" ], singbox_check_json, "❌ Failed to parse sing-box info", [ 0 ]); + else + print_global("❌ Failed to get sing-box info"); + + print_global("---------------------------"); + print_global("Inbounds checks"); + let inbounds_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-inbounds" ]))).output; + if (inbounds_check_json != "") + render_or_fail([ "global-inbounds-check" ], inbounds_check_json, "[FAIL] Failed to parse inbounds check details", [ 0 ]); + else + print_global("[FAIL] Failed to get inbounds info"); + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("🧱 NFT rules status"); + let nft_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-nft-rules" ]))).output; + if (nft_check_json != "") { + let nft_render = render_or_fail([ "global-nft-check" ], nft_check_json, "❌ Failed to parse NFT rules info", [ 0 ]); + if (nft_render == 0 && status_success([ "global-nft-other-mark-exists" ], nft_check_json)) + print(status_output([ "nft-ruleset-other-mark-lines", NFT_TABLE_NAME ], command_output_from_args([ "nft", "list", "ruleset" ]))); + } + else + print_global("❌ Failed to get NFT rules info"); + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("📄 Tachyon config"); + show_config(visibility); + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("📄 WAN config"); + if (uci_show("network.wan")) { + if (visibility == "raw") + print(as_string(fs.readfile("/etc/config/network"))); + else + print(status_output([ "wan-config-masked", "/etc/config/network" ], null)); + } + else + print_global("❌ WAN configuration not found"); + + let network_show = network_show_data(); + for (let line in split(status_output([ "network-endpoint-host-warnings", CLOUDFLARE_OCTETS ], network_show), "\n")) { + if (line == "") + continue; + let fields = split(line, "\t"); + if (length(fields) < 2) + continue; + if (fields[0] == "engage") + print_global("⚠️ WARP detected: " + fields[1]); + else if (fields[0] == "prefix") { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ WARP detected: " + fields[1]); + } + } + + for (let peer_section in split(status_output([ "network-wireguard-route-allowed-peers" ], network_show), "\n")) { + peer_section = as_string(peer_section); + if (peer_section == "") + continue; + let default_routes = allowed_ips_default_routes(uci_get(peer_section + ".allowed_ips")); + if (length(default_routes) > 0) { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ WG Route allowed IP enabled with " + join(", ", default_routes)); + } + } + + if (file_executable("/etc/init.d/zapret") && command_success_from_args([ "/etc/init.d/zapret", "status" ])) { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ Standalone zapret service is active. Tachyon uses separate queues, but packet-level policy overlap is possible."); + } + else if (file_executable("/etc/init.d/zapret") && command_success_from_args([ "/etc/init.d/zapret", "enabled" ])) { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ Standalone zapret autostart is enabled. Tachyon will not modify /etc/config/zapret."); + } + + if (file_executable("/etc/init.d/zapret2") && command_success_from_args([ "/etc/init.d/zapret2", "status" ])) { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ Standalone zapret2 service is active. Tachyon uses separate queues, but packet-level policy overlap is possible."); + } + else if (file_executable("/etc/init.d/zapret2") && command_success_from_args([ "/etc/init.d/zapret2", "enabled" ])) { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ Standalone zapret2 autostart is enabled. Tachyon will not modify /etc/config/zapret2."); + } + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("🥸 FakeIP status"); + let fakeip_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-fakeip" ]))).output; + if (fakeip_check_json != "") + render_or_fail([ "global-fakeip-check" ], fakeip_check_json, "❌ Failed to parse FakeIP info", [ 0 ]); + else + print_global("❌ Failed to get FakeIP info"); + + return 0; +} + +let mode = ARGV[0] || ""; + +if (mode == "check-proxy") + exit(check_proxy()); +else if (mode == "check-nft") + exit(check_nft()); +else if (mode == "check-nft-rules") + exit(check_nft_rules()); +else if (mode == "check-sing-box") + exit(check_sing_box()); +else if (mode == "sing-box-standard-ports-listening-fixture") + sing_box_standard_ports_listening_fixture(); +else if (mode == "check-inbounds-config") + exit(check_inbounds_config()); +else if (mode == "check-inbounds") + exit(check_inbounds()); +else if (mode == "check-logs") + exit(check_logs()); +else if (mode == "check-sing-box-logs") + exit(check_sing_box_logs()); +else if (mode == "tachyon-logs-fixture") + exit(tachyon_logs_fixture()); +else if (mode == "check-fakeip") + exit(check_fakeip()); +else if (mode == "check-zapret-runtime") + exit(module_passthrough(ZAPRET_RUNTIME_UC, [ "check" ])); +else if (mode == "check-zapret2-runtime") + exit(module_passthrough(ZAPRET2_RUNTIME_UC, [ "check" ])); +else if (mode == "check-byedpi-runtime") + exit(module_passthrough(BYEDPI_RUNTIME_UC, [ "check" ])); +else if (mode == "neutralize-zapret-defaults") + exit(neutralize_zapret_defaults()); +else if (mode == "clash-api") + exit(clash_api(ARGV[1], ARGV[2], ARGV[3], ARGV[4])); +else if (mode == "show-config") + exit(show_config(ARGV[1] || "masked")); +else if (mode == "show-version") + exit(show_version()); +else if (mode == "show-sing-box-config") + exit(show_sing_box_config(ARGV[1] || "masked")); +else if (mode == "show-sing-box-version") + exit(show_sing_box_version()); +else if (mode == "get-status") + exit(get_status()); +else if (mode == "get-outbound-metadata") + exit(get_outbound_metadata(ARGV[1])); +else if (mode == "get-subscription-metadata") + exit(get_subscription_metadata(ARGV[1])); +else if (mode == "get-sing-box-status") + exit(get_sing_box_status()); +else if (mode == "get-zapret-status") + exit(module_passthrough(ZAPRET_RUNTIME_UC, [ "status" ])); +else if (mode == "get-zapret2-status") + exit(module_passthrough(ZAPRET2_RUNTIME_UC, [ "status" ])); +else if (mode == "get-byedpi-status") + exit(module_passthrough(BYEDPI_RUNTIME_UC, [ "status" ])); +else if (mode == "get-system-info") + exit(get_system_info()); +else if (mode == "get-server-capabilities") + exit(get_server_capabilities()); +else if (mode == "check-dns-available") + exit(check_dns_available()); +else if (mode == "global-check") + exit(global_check(ARGV[1] || "", ARGV[2] || "")); +else if (mode == "validate-nfqws-strategy-json") + exit(validate_nfqws_strategy_json(ARGV[1] || "")); +else if (mode == "validate-nfqws2-strategy-json") + exit(validate_nfqws2_strategy_json(ARGV[1] || "")); +else { + warn("Usage: diagnostics/runtime.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/diagnostics/status.uc b/tachyon/files/usr/lib/diagnostics/status.uc new file mode 100644 index 00000000..6f6a8376 --- /dev/null +++ b/tachyon/files/usr/lib/diagnostics/status.uc @@ -0,0 +1,2029 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let core_ip = require("core.ip"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function url_encode(value) { + value = as_string(value); + for (let i = 0; i < length(value); i++) { + let c = substr(value, i, 1); + let code = ord(c); + if ((code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + c == "-" || c == "_" || c == "." || c == "~") + print(c); + else + print(sprintf("%%%02X", code)); + } + print("\n"); +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_stdin_json() { + try { + return json(read_stdin()); + } + catch (e) { + return null; + } +} + +function proxy_response_is_retryable_error() { + let response = read_stdin(); + return index(response, "= 0; +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function write_json_file(path, value) { + return fs.writefile(path, sprintf("%J", value) + "\n"); +} + +function parse_json_object(value) { + try { + value = json(as_string(value)); + } + catch (e) { + return {}; + } + + return type(value) == "object" ? value : {}; +} + +function parse_json_or_null(value) { + try { + return json(as_string(value)); + } + catch (e) { + return null; + } +} + +function number_value(value) { + value = as_string(value); + return value == "" ? 0 : int(value); +} + +function stdin_first_line_last_field() { + let input = read_stdin(); + if (input == "") + return; + + let newline = index(input, "\n"); + let line = newline >= 0 ? substr(input, 0, newline) : input; + let trimmed = trim(line); + if (trimmed == "") { + print(line, "\n"); + return; + } + + let fields = split(trimmed, /[ \t\r\n]+/); + if (length(fields) > 0 && fields[0] != "") + print(fields[length(fields) - 1], "\n"); + else + print("\n"); +} + +function stdin_first_line() { + let input = read_stdin(); + if (input == "") + return; + + let newline = index(input, "\n"); + print(newline >= 0 ? substr(input, 0, newline + 1) : input); +} + +function stdin_first_ipv4_line() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (match(line, /^[0-9]+\./) != null) { + print(line, "\n"); + return; + } + } +} + +function stdin_contains(needle) { + exit(index(read_stdin(), as_string(needle)) >= 0 ? 0 : 1); +} + +function strip_leading_v(value) { + value = as_string(value); + print(substr(value, 0, 1) == "v" ? substr(value, 1) : value, "\n"); +} + +function uci_show_value(line) { + let equals = index(as_string(line), "="); + if (equals < 0) + return ""; + + let value = substr(line, equals + 1); + let next_equals = index(value, "="); + if (next_equals >= 0) + value = substr(value, 0, next_equals); + + return replace(value, /['" ]/g, ""); +} + +function string_starts_with(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function uci_show_list_value(value) { + return trim(replace(as_string(value), /['"]/g, "")); +} + +function firewall_rules_from_uci_show(data) { + let prefix = "firewall."; + let sections = {}; + let order = []; + + for (let line in split(as_string(data), "\n")) { + let equals = index(as_string(line), "="); + if (equals < 0) + continue; + + let key = substr(line, 0, equals); + let raw_value = substr(line, equals + 1); + if (!string_starts_with(key, prefix)) + continue; + + let rest = substr(key, length(prefix)); + if (index(rest, ".") < 0) { + if (uci_show_list_value(raw_value) == "rule") { + if (sections[key] == null) + sections[key] = {}; + push(order, key); + } + continue; + } + + let option_dot = rindex(key, "."); + let section = substr(key, 0, option_dot); + let option = substr(key, option_dot + 1); + if (sections[section] == null) + sections[section] = {}; + sections[section][option] = uci_show_list_value(raw_value); + } + + let rules = []; + for (let section in order) + push(rules, sections[section] || {}); + + return rules; +} + +function first_two_dot_fields(value) { + let parts = split(as_string(value), "."); + return length(parts) >= 2 ? parts[0] + "." + parts[1] : as_string(value); +} + +function whitespace_values(value) { + let result = []; + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function value_in_list(values, needle) { + for (let value in values) + if (as_string(value) == as_string(needle)) + return true; + return false; +} + +function network_endpoint_host_warnings(octets) { + let cloudflare_octets = whitespace_values(octets); + + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (index(line, "endpoint_host") < 0) + continue; + + let host = uci_show_value(line); + if (host == "") + continue; + + if (host == "engage.cloudflareclient.com") { + print("engage\t", host, "\n"); + continue; + } + + if (value_in_list(cloudflare_octets, first_two_dot_fields(host))) + print("prefix\t", host, "\n"); + } +} + +function network_wireguard_route_allowed_peers() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (index(line, "wireguard_") < 0 || index(line, ".route_allowed_ips=") < 0) + continue; + if (uci_show_value(line) != "1") + continue; + + let route_option = index(line, ".route_allowed_ips="); + if (route_option > 0) + print(substr(line, 0, route_option), "\n"); + } +} + +function wan_config_masked(path) { + let data = fs.readfile(path); + let in_wan = false; + let proto = ""; + + if (data == null) + exit(1); + + let lines = split(as_string(data), "\n"); + for (let i = 0; i < length(lines); i++) { + let line = lines[i]; + if (i == length(lines) - 1 && line == "" && substr(as_string(data), length(data) - 1) == "\n") + continue; + + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + if (length(fields) > 0 && fields[0] == "config") { + in_wan = length(fields) >= 3 && fields[1] == "interface" && fields[2] == "'wan'"; + proto = ""; + } + + if (!in_wan) + continue; + + if (length(fields) >= 3 && fields[0] == "option" && fields[1] == "proto") { + proto = fields[2]; + print(line, "\n"); + } + else if (proto == "'static'" && length(fields) >= 2 && fields[0] == "option" && + (fields[1] == "ipaddr" || fields[1] == "netmask" || fields[1] == "gateway")) { + print(" option ", fields[1], " '******'\n"); + } + else if (proto == "'pppoe'" && length(fields) >= 2 && fields[0] == "option" && + (fields[1] == "username" || fields[1] == "password")) { + print(" option ", fields[1], " '******'\n"); + } + else { + print(line, "\n"); + } + } +} + +function is_space_char(value) { + return value == " " || value == "\t" || value == "\r" || value == "\n"; +} + +function mask_after_token(line, token) { + let pos = index(line, token); + return pos < 0 ? line : substr(line, 0, pos) + token + " 'MASKED'"; +} + +function mask_after_token_space(line, token) { + let pos = index(line, token); + if (pos < 0) + return line; + + let space_pos = pos + length(token); + if (space_pos >= length(line) || !is_space_char(substr(line, space_pos, 1))) + return line; + + return substr(line, 0, space_pos + 1) + "'MASKED'"; +} + +function delete_token_space(line, token) { + let pos = index(line, token); + if (pos < 0) + return false; + + let space_pos = pos + length(token); + return space_pos < length(line) && is_space_char(substr(line, space_pos, 1)); +} + +function mask_option_path(line, token) { + let pos = index(line, token); + if (pos < 0) + return line; + + let slash = index(substr(line, pos + length(token)), "/"); + if (slash < 0) + return line; + slash += pos + length(token); + + let quote = index(substr(line, slash + 1), "'"); + if (quote < 0) + return line; + quote += slash + 1; + + return substr(line, 0, slash) + "/MASKED'" + substr(line, quote + 1); +} + +function tachyon_config_masked_line(line) { + line = mask_after_token(line, "option proxy_string"); + line = mask_after_token(line, "option hwid"); + line = mask_after_token(line, "option subscription_url"); + line = mask_after_token(line, "list subscription_urls"); + line = mask_after_token(line, "list urltest_proxy_links"); + line = mask_after_token(line, "list selector_proxy_links"); + line = mask_after_token_space(line, "option outbound_json"); + line = mask_after_token_space(line, "list domain"); + line = mask_after_token_space(line, "list domain_suffix"); + line = mask_after_token_space(line, "list domain_keyword"); + line = mask_after_token_space(line, "list domain_regex"); + line = mask_after_token_space(line, "list ip_cidr"); + line = mask_after_token_space(line, "list source_ip_cidr"); + line = mask_after_token_space(line, "list fully_routed_ips"); + line = mask_after_token(line, "list server_users"); + line = mask_after_token_space(line, "option dns_server"); + line = mask_after_token_space(line, "option bootstrap_dns_server"); + line = mask_after_token_space(line, "list dns_server"); + line = mask_after_token_space(line, "list bootstrap_dns_server"); + line = mask_after_token_space(line, "option listen"); + line = mask_after_token_space(line, "option listen_port"); + line = mask_after_token_space(line, "option public_host"); + line = mask_after_token(line, "option server_uuid"); + line = mask_after_token(line, "option server_username"); + line = mask_after_token(line, "option server_password"); + line = mask_after_token(line, "option mtproto_secret"); + line = mask_after_token_space(line, "option mtproto_faketls"); + line = mask_after_token_space(line, "option mtproto_domain_fronting_ip"); + line = mask_after_token_space(line, "option tls_server_name"); + line = mask_after_token_space(line, "option reality_handshake_server"); + line = mask_after_token_space(line, "option reality_handshake_server_port"); + line = mask_after_token_space(line, "option transport_host"); + line = mask_after_token_space(line, "list transport_hosts"); + line = mask_after_token_space(line, "option tailscale_auth_key"); + line = mask_after_token_space(line, "option tailscale_control_url"); + line = mask_after_token_space(line, "option tailscale_hostname"); + line = mask_after_token_space(line, "list tailscale_advertise_routes"); + line = mask_after_token(line, "option hysteria2_obfs_password"); + line = mask_after_token(line, "option reality_private_key"); + line = mask_after_token(line, "option reality_public_key"); + line = mask_after_token(line, "option reality_short_id"); + line = mask_after_token(line, "list reality_short_id"); + line = mask_after_token_space(line, "option mixed_proxy_username"); + line = mask_after_token_space(line, "option mixed_proxy_password"); + line = mask_option_path(line, "option dns_server '"); + line = mask_option_path(line, "list dns_server '"); + line = mask_after_token(line, "option yacd_secret_key"); + + return line; +} + +function tachyon_config_masked(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let lines = split(as_string(data), "\n"); + let in_masked_multiline = false; + + for (let i = 0; i < length(lines); i++) { + let line = as_string(lines[i]); + if (i == length(lines) - 1 && line == "" && substr(as_string(data), length(data) - 1) == "\n") + continue; + + if (in_masked_multiline) { + if (index(line, "'") >= 0) + in_masked_multiline = false; + continue; + } + + if (delete_token_space(line, "option tailscale_ephemeral") || + delete_token_space(line, "option tailscale_exit_node") || + delete_token_space(line, "option tailscale_exit_node_allow_lan_access")) + continue; + + let masked = tachyon_config_masked_line(line); + print(masked, "\n"); + if (index(line, "option outbound_json") >= 0) { + let first_quote = index(line, "'"); + if (first_quote >= 0 && index(substr(line, first_quote + 1), "'") < 0) + in_masked_multiline = true; + } + } +} + +function dhcp_dnsmasq_config(path) { + let data = fs.readfile(path); + let in_dnsmasq = false; + + if (data == null) + exit(1); + + let lines = split(as_string(data), "\n"); + for (let i = 0; i < length(lines); i++) { + let line = lines[i]; + if (i == length(lines) - 1 && line == "" && substr(as_string(data), length(data) - 1) == "\n") + continue; + + if (match(as_string(line), /^config /) != null) { + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + in_dnsmasq = length(fields) >= 2 && fields[1] == "dnsmasq"; + } + + if (in_dnsmasq) + print(line, "\n"); + } +} + +function only_digits(value) { + value = as_string(value); + return value != "" && match(value, /^[0-9]+$/) != null; +} + +function firewall_port_token_contains(token, port) { + token = as_string(token); + port = as_string(port); + + let dash = index(token, "-"); + let colon = index(token, ":"); + if (dash < 0 && colon < 0) + return token == port; + + let separator = dash >= 0 ? dash : colon; + let start = substr(token, 0, separator); + let end = substr(token, separator + 1); + if (!only_digits(start) || !only_digits(end) || !only_digits(port)) + return false; + + port = int(port); + return port >= int(start) && port <= int(end); +} + +function firewall_port_spec_contains(spec, port) { + spec = as_string(spec); + if (spec == "") + return true; + + for (let token in split(replace(spec, /,/g, " "), /[ \t\r\n]+/)) + if (token != "" && firewall_port_token_contains(token, port)) + return true; + + return false; +} + +function firewall_proto_spec_contains(spec, proto) { + spec = as_string(spec); + proto = as_string(proto); + if (spec == "") + return true; + + for (let token in split(spec, /[ \t\r\n]+/)) { + if (token == "all" || token == "any" || token == "tcpudp" || token == "tcp/udp" || token == proto) + return true; + } + + return false; +} + +function firewall_rules_allow_port_proto(rules, port, proto) { + for (let rule in rules) { + let enabled = as_string(rule.enabled); + if (enabled != "" && enabled != "1") + continue; + + if (uc(as_string(rule.target)) != "ACCEPT") + continue; + + let src = as_string(rule.src); + if (src != "" && src != "wan" && src != "*") + continue; + + let dest = as_string(rule.dest); + if (dest != "" && dest != "*") + continue; + + let src_port = as_string(rule.src_port); + if (src_port != "" && src_port != "*") + continue; + + if (as_string(rule.family) == "ipv6") + continue; + + if (!firewall_proto_spec_contains(rule.proto, proto)) + continue; + + if (!firewall_port_spec_contains(rule.dest_port, port)) + continue; + + return true; + } + + return false; +} + +function firewall_port_open_for_proto(port, proto) { + return firewall_rules_allow_port_proto(firewall_rules_from_uci_show(read_stdin()), port, proto); +} + +function firewall_required_protocols_open(port, required_proto) { + let rules = firewall_rules_from_uci_show(read_stdin()); + + for (let proto in whitespace_values(required_proto)) + if (!firewall_rules_allow_port_proto(rules, port, proto)) + return false; + + return true; +} + +function server_required_inbound_proto(protocol) { + protocol = as_string(protocol); + if (protocol == "json_inbound") + print("\n"); + else + print(protocol == "hysteria2" ? "udp" : "tcp", "\n"); +} + +function server_runtime_type_for_protocol(protocol) { + protocol = as_string(protocol); + if (protocol == "json_inbound") + print("\n"); + else if (protocol == "mtproto") + print("mtproxy\n"); + else + print(protocol, "\n"); +} + +function arg_bool(value) { + return value === true || value == "true" || value == "1" || value == 1; +} + +function arg_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9-]/)) + return 0; + return int(value); +} + +function flag_is_one(value) { + return as_string(value) == "1"; +} + +function flag_is_true(value) { + return value == true || as_string(value) == "true" || as_string(value) == "1"; +} + +function server_listen_requires_firewall(listen, wan_ip, listen_is_public) { + listen = as_string(listen); + wan_ip = as_string(wan_ip); + + if (listen == "0.0.0.0" || listen == "::" || arg_bool(listen_is_public)) + return true; + for (let ip in whitespace_values(wan_ip)) + if (ip == listen) + return true; + return false; +} + +function object_value(object, key) { + return type(object) == "object" && object[key] != null ? as_string(object[key]) : ""; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function str_startswith(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function str_endswith(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix; +} + +function str_remove_suffix(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return str_endswith(value, suffix) ? substr(value, 0, length(value) - length(suffix)) : value; +} + +function contains(values, needle) { + needle = as_string(needle); + for (let value in array_or_empty(values)) + if (as_string(value) == needle) + return true; + return false; +} + +function netstat_fields(line) { + line = trim(as_string(line)); + return line == "" ? [] : split(line, /[ \t\r\n]+/); +} + +function netstat_addr_port(addr) { + addr = as_string(addr); + let colon = rindex(addr, ":"); + return colon >= 0 ? substr(addr, colon + 1) : addr; +} + +function netstat_addr_host(addr) { + addr = as_string(addr); + if (substr(addr, 0, 1) == "[") { + let end = index(addr, "]"); + return end > 0 ? substr(addr, 1, end - 1) : addr; + } + let colon = rindex(addr, ":"); + return colon >= 0 ? substr(addr, 0, colon) : addr; +} + +function ipv4_like(value) { + return match(as_string(value), /^[0-9]+[.][0-9]+[.][0-9]+[.][0-9]+$/) != null; +} + +function valid_ipv4(value) { + return core_ip.valid_ipv4(value, false, false); +} + +function valid_public_ipv4(value) { + value = as_string(value); + if (!valid_ipv4(value)) + return false; + + let parts = split(value, "."); + let a = int(parts[0]); + let b = int(parts[1]); + + if (a == 0 || a == 10 || a == 127 || a >= 224) + return false; + if (a == 169 && b == 254) + return false; + if (a == 192 && (b == 168 || b == 0 || b == 2)) + return false; + if (a == 198 && (b == 18 || b == 19 || b == 51)) + return false; + if (a == 203 && b == 0) + return false; + if (a == 100 && b >= 64 && b <= 127) + return false; + if (a == 172 && b >= 16 && b <= 31) + return false; + + return true; +} + +function valid_public_ipv6(value) { + value = lc(as_string(value)); + if (!core_ip.valid_ipv6(value)) + return false; + if (value == "::" || value == "::1") + return false; + if (substr(value, 0, 4) == "fe80" || substr(value, 0, 2) == "ff") + return false; + if (substr(value, 0, 2) == "fc" || substr(value, 0, 2) == "fd") + return false; + if (index(value, "2001:db8") == 0) + return false; + return true; +} + +function valid_public_ip(value) { + return valid_public_ipv4(value) || valid_public_ipv6(value); +} + +function netstat_addr_matches(addr, listen, port) { + addr = as_string(addr); + listen = as_string(listen); + port = as_string(port); + + if (netstat_addr_port(addr) != port) + return false; + + let host = netstat_addr_host(addr); + if (host == "0.0.0.0" || host == "::") + return true; + if (listen == "0.0.0.0") + return ipv4_like(host); + if (listen == "::") + return index(host, ":") >= 0; + + return host == listen; +} + +function netstat_server_port_listening_in_data(data, listen, port, proto) { + listen = as_string(listen); + port = as_string(port); + proto = as_string(proto); + + for (let line in split(as_string(data), "\n")) { + let fields = netstat_fields(line); + if (length(fields) < 4 || !str_startswith(fields[0], proto)) + continue; + + if (netstat_addr_matches(fields[3], listen, port)) + return true; + } + + return false; +} + +function netstat_server_port_listening(listen, port, proto) { + return netstat_server_port_listening_in_data(read_stdin(), listen, port, proto); +} + +function server_required_ports_listening(listen, port, required_proto) { + let data = read_stdin(); + + for (let proto in whitespace_values(required_proto)) + if (!netstat_server_port_listening_in_data(data, listen, port, proto)) + return false; + + return true; +} + +function sorted_unique_strings(values) { + let result = []; + for (let value in values) { + value = as_string(value); + if (value != "" && !contains(result, value)) + push(result, value); + } + + sort(result, function(a, b) { + return a < b ? -1 : (a > b ? 1 : 0); + }); + return result; +} + +function sorted_unique_lines(values) { + let result = []; + for (let value in values) { + value = as_string(value); + if (!contains(result, value)) + push(result, value); + } + + sort(result, function(a, b) { + return a < b ? -1 : (a > b ? 1 : 0); + }); + return result; +} + +function stdin_sorted_unique_space_list() { + let lines = split(read_stdin(), "\n"); + + if (length(lines) > 0 && lines[length(lines) - 1] == "") + lines = slice(lines, 0, length(lines) - 1); + + print(replace(join(" ", sorted_unique_lines(lines)), /[ \t\r\n]+$/, ""), "\n"); +} + +function public_host_flags(public_host, public_host_ips, wan_ip, wan_public) { + let resolved = -1; + let public_ip = -1; + let matches_wan = -1; + let ips = whitespace_values(public_host_ips); + + if (as_string(public_host) != "") { + if (length(ips) > 0) { + resolved = 1; + public_ip = 1; + for (let ip in ips) + if (!valid_public_ip(ip)) + public_ip = 0; + + if (as_string(wan_public) == "1") { + matches_wan = 0; + for (let wan in whitespace_values(wan_ip)) { + if (contains(ips, wan)) { + matches_wan = 1; + break; + } + } + } + } + else { + resolved = 0; + } + } + + print(resolved, " ", public_ip, " ", matches_wan, "\n"); +} + +function netstat_server_port_conflict_owner_list(data, listen, port, proto) { + let owners = []; + let line_number = 0; + let owner_supported = false; + + for (let line in split(as_string(data), "\n")) { + line_number++; + if (line_number == 2) { + owner_supported = index(as_string(line), "PID/Program") >= 0; + continue; + } + + if (!owner_supported) + continue; + + let fields = netstat_fields(line); + if (length(fields) < 4 || !str_startswith(fields[0], proto) || !netstat_addr_matches(fields[3], listen, port)) + continue; + + let owner = length(fields) > 0 ? as_string(fields[length(fields) - 1]) : ""; + if (owner == "" || owner == "-" || owner == "LISTEN") + owner = "unknown"; + if (!str_endswith(owner, "/sing-box")) + push(owners, owner); + } + + return sorted_unique_strings(owners); +} + +function netstat_server_port_conflict_owners(listen, port, proto) { + print(join(" ", netstat_server_port_conflict_owner_list(read_stdin(), listen, port, proto)), "\n"); +} + +function server_required_port_conflict_owners(listen, port, required_proto) { + let data = read_stdin(); + let owners = []; + + for (let proto in whitespace_values(required_proto)) + for (let owner in netstat_server_port_conflict_owner_list(data, listen, port, proto)) + if (!contains(owners, owner)) + push(owners, owner); + + print(join(" ", sorted_unique_strings(owners)), "\n"); +} + +function nft_line_is_count_element(line) { + line = trim(as_string(line)); + return line != "" && match(substr(line, 0, 1), /^[0-9]$/) != null; +} + +function render_nft_chain_config_blocks() { + let lines = split(read_stdin(), "\n"); + + for (let i = 1; i < length(ARGV); i++) { + let chain = as_string(ARGV[i]); + let in_block = false; + let needle = "chain " + chain + " {"; + + for (let line in lines) { + line = as_string(line); + if (!in_block && index(line, needle) < 0) + continue; + + if (!in_block) + in_block = true; + + if (index(line, "elements") < 0 && !nft_line_is_count_element(line)) + print(line, "\n"); + + if (index(line, "}") >= 0) + in_block = false; + } + } +} + +function nft_chain_counter_status() { + let rules_exist = 0; + let counters = 0; + + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (index(line, "counter") < 0) + continue; + + rules_exist = 1; + if (index(line, "packets 0 bytes 0") < 0) + counters = 1; + } + + print(rules_exist, " ", counters, "\n"); +} + +function print_line(value) { + print(value, "\n"); +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function stdin_json() { + let value = read_stdin_json(); + if (value == null) + exit(1); + write_json(value); +} + +function json_error(message) { + write_json({ error: as_string(message) }); +} + +function mask_ipv6_line(line) { + let matched = match(line, /([0-9a-fA-F]+:[0-9a-fA-F]+:[0-9a-fA-F]+):.*/); + return matched ? matched[1] + ":XXXX:XXXX:XXXX" : line; +} + +function render_proxy_response_ip_mask() { + let response = read_stdin(); + let lines = split(response, "\n"); + let ipv4_lines = []; + let ipv6_like = false; + + for (let line in lines) { + let matched = match(line, /^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$/); + if (matched) + push(ipv4_lines, "X.X.X." + matched[4]); + + if (match(line, /^[0-9a-fA-F:]*::[0-9a-fA-F:]*$/) || match(line, /^[0-9a-fA-F:]+$/)) + ipv6_like = true; + } + + if (length(ipv4_lines) > 0) { + for (let line in ipv4_lines) + print_line(line); + return; + } + + if (ipv6_like) { + for (let i = 0; i < length(lines); i++) { + if (i == length(lines) - 1 && lines[i] == "") + continue; + print_line(mask_ipv6_line(lines[i])); + } + return; + } + + exit(1); +} + +function render_inbound_item(item, wan_ip) { + let label = object_value(item, "label"); + let protocol = object_value(item, "protocol"); + let listen = object_value(item, "listen"); + let listen_port = object_value(item, "listen_port"); + let public_host = object_value(item, "public_host"); + let tag = object_value(item, "tag"); + let required_proto = object_value(item, "required_proto"); + let runtime_ok = object_value(item, "runtime_ok"); + let listening = object_value(item, "listening"); + let firewall_required = object_value(item, "firewall_required"); + let firewall_open = object_value(item, "firewall_open"); + let port_conflict = object_value(item, "port_conflict"); + let port_conflict_owners = object_value(item, "port_conflict_owners"); + let routes_configured = object_value(item, "routes_configured"); + let public_host_resolved = object_value(item, "public_host_resolved"); + let public_host_public = object_value(item, "public_host_public"); + let public_host_matches_wan = object_value(item, "public_host_matches_wan"); + let public_host_ips = object_value(item, "public_host_ips"); + + if (flag_is_one(runtime_ok)) + print_line("[OK] " + label + ": runtime " + tag + " [" + protocol + "]"); + else + print_line("[FAIL] " + label + ": generated inbound is missing or differs from UCI"); + + if (protocol == "json_inbound") { + print_line("[OK] " + label + ": JSON inbound uses custom listen settings"); + } + else if (protocol != "tailscale") { + if (flag_is_one(listening)) + print_line("[OK] " + label + ": listening on " + listen + ":" + listen_port + " [" + required_proto + "]"); + else + print_line("[FAIL] " + label + ": not listening on " + listen + ":" + listen_port + " [" + required_proto + "]"); + + if (flag_is_one(port_conflict)) + print_line("[FAIL] " + label + ": " + listen + ":" + listen_port + " [" + required_proto + "] is already used by " + port_conflict_owners); + else + print_line("[OK] " + label + ": no local port conflict detected"); + + if (flag_is_one(firewall_required)) { + if (flag_is_one(firewall_open)) + print_line("[OK] " + label + ": firewall accepts " + required_proto + "/" + listen_port + " from WAN"); + else + print_line("[FAIL] " + label + ": firewall does not accept " + required_proto + "/" + listen_port + " from WAN"); + } + else { + print_line("[WARN] " + label + ": firewall WAN check skipped for listen address " + listen); + } + } + else { + print_line("[OK] " + label + ": Tailscale endpoint does not need a public firewall port"); + } + + if (flag_is_one(routes_configured)) + print_line("[OK] " + label + ": route rules for inbound exist"); + else + print_line("[WARN] " + label + ": route rules for inbound were not found"); + + if (protocol != "tailscale" && protocol != "json_inbound") { + if (public_host != "") { + if (public_host_resolved == "0") + print_line("[WARN] " + label + ": public host does not resolve: " + public_host); + else if (public_host_public == "0") + print_line("[WARN] " + label + ": public host is not public: " + public_host); + else if (public_host_matches_wan == "0") + print_line("[WARN] " + label + ": public host resolves to " + public_host_ips + ", WAN is " + wan_ip); + else + print_line("[OK] " + label + ": public host " + public_host); + } + else { + print_line("[WARN] " + label + ": public host is empty"); + } + } +} + +function inbound_runtime_ok(protocol, runtime_exists, runtime_type, runtime_listen, runtime_port_text, expected_type, listen, listen_port) { + if (!flag_is_one(runtime_exists)) + return 0; + + if (protocol == "json_inbound") + return 1; + + if (runtime_type != expected_type) + return 0; + + if (protocol == "tailscale") + return 1; + + return runtime_listen == listen && runtime_port_text == listen_port ? 1 : 0; +} + +function write_inbound_item_json(args) { + let runtime = parse_json_object(args[0]); + let section = as_string(args[1]); + let label = as_string(args[2]); + let protocol = as_string(args[3]); + let routing_mode = as_string(args[4]); + let tag = as_string(args[5]); + let listen = as_string(args[6]); + let listen_port = as_string(args[7]); + let public_host = as_string(args[8]); + let public_host_ips = as_string(args[9]); + let expected_type = as_string(args[10]); + let required_proto = as_string(args[11]); + let runtime_exists = object_value(runtime, "exists") || "0"; + let runtime_type = object_value(runtime, "type"); + let runtime_listen = object_value(runtime, "listen"); + let runtime_port_text = object_value(runtime, "listen_port") || "0"; + + write_json({ + section, + label, + protocol, + routing_mode, + tag, + listen, + listen_port: number_value(listen_port), + public_host, + public_host_ips, + expected_type, + required_proto, + runtime_exists: number_value(runtime_exists), + runtime_type, + runtime_listen, + runtime_port: number_value(runtime_port_text), + runtime_ok: inbound_runtime_ok(protocol, runtime_exists, runtime_type, runtime_listen, runtime_port_text, expected_type, listen, listen_port), + listening: number_value(args[12]), + firewall_required: number_value(args[13]), + firewall_open: number_value(args[14]), + port_conflict: number_value(args[15]), + port_conflict_owners: as_string(args[16]), + routes_configured: number_value(args[17]), + public_host_resolved: number_value(args[18]), + public_host_public: number_value(args[19]), + public_host_matches_wan: number_value(args[20]) + }); +} + +function write_inbounds_config_json(enabled_count) { + write_json({ + enabled_count: number_value(enabled_count) + }); +} + +function write_inbounds_check_json(enabled_count, config_path, wan_ip, wan_public, items_json) { + let items = parse_json_or_null(items_json); + write_json({ + enabled_count: number_value(enabled_count), + config_path: as_string(config_path), + wan_ip: as_string(wan_ip), + wan_public: number_value(wan_public), + items: items != null ? items : null + }); +} + +function write_server_capabilities_json(sing_box_extended, sing_box_tiny, sing_box_tailscale) { + write_json({ + sing_box_extended: arg_number(sing_box_extended), + sing_box_tiny: arg_number(sing_box_tiny), + sing_box_tailscale: arg_number(sing_box_tailscale) + }); +} + +function write_ui_capabilities_json(sing_box_extended, sing_box_tiny, sing_box_compressed, sing_box_tailscale, zapret_installed, zapret2_installed, byedpi_installed, server_inbounds_enabled_count) { + write_json({ + sing_box_extended: arg_number(sing_box_extended), + sing_box_tiny: arg_number(sing_box_tiny), + sing_box_compressed: arg_number(sing_box_compressed), + sing_box_tailscale: arg_number(sing_box_tailscale), + zapret_installed: arg_number(zapret_installed), + zapret2_installed: arg_number(zapret2_installed), + byedpi_installed: arg_number(byedpi_installed), + server_inbounds_enabled_count: arg_number(server_inbounds_enabled_count) + }); +} + +function service_status_label(running, enabled) { + running = arg_number(running); + enabled = arg_number(enabled); + + if (running == 1) + return enabled == 1 ? "running & enabled" : "running but disabled"; + + return enabled == 1 ? "stopped but enabled" : "stopped & disabled"; +} + +function write_service_status_json(running, enabled, status_or_dns_configured, dns_configured) { + if (dns_configured == null) + dns_configured = status_or_dns_configured; + + write_json({ + running: arg_number(running), + enabled: arg_number(enabled), + status: service_status_label(running, enabled), + dns_configured: arg_number(dns_configured) + }); +} + +function stdin_service_status_running() { + let value = read_stdin_json(); + if (type(value) != "object") + exit(1); + + exit(number_value(value.running) == 1 ? 0 : 1); +} + +function service_list_instance_running(name) { + let value = object_or_empty(read_stdin_json()); + let service = object_or_empty(value[as_string(name)]); + let instances = object_or_empty(service.instances); + + for (let key in instances) { + let instance = object_or_empty(instances[key]); + if (flag_is_true(instance.running)) + exit(0); + } + + exit(1); +} + +function write_dns_check_json(dns_type, dns_server, dns_status, dns_on_router, bootstrap_dns_server, bootstrap_dns_status, dhcp_config_status, dont_touch_dhcp) { + write_json({ + dns_type: as_string(dns_type), + dns_server: as_string(dns_server), + dns_status: arg_number(dns_status), + dns_on_router: arg_number(dns_on_router), + bootstrap_dns_server: as_string(bootstrap_dns_server), + bootstrap_dns_status: arg_number(bootstrap_dns_status), + dhcp_config_status: arg_number(dhcp_config_status), + dont_touch_dhcp: arg_number(dont_touch_dhcp) + }); +} + +function write_nft_check_json(table_exist, rules_mangle_exist, rules_mangle_counters, rules_mangle_output_exist, rules_mangle_output_counters, rules_proxy_exist, rules_proxy_counters, rules_other_mark_exist) { + write_json({ + table_exist: arg_number(table_exist), + rules_mangle_exist: arg_number(rules_mangle_exist), + rules_mangle_counters: arg_number(rules_mangle_counters), + rules_mangle_output_exist: arg_number(rules_mangle_output_exist), + rules_mangle_output_counters: arg_number(rules_mangle_output_counters), + rules_proxy_exist: arg_number(rules_proxy_exist), + rules_proxy_counters: arg_number(rules_proxy_counters), + rules_other_mark_exist: arg_number(rules_other_mark_exist) + }); +} + +function write_sing_box_check_json(sing_box_installed, sing_box_version_ok, sing_box_extended, sing_box_service_exist, sing_box_autostart_disabled, sing_box_process_running, sing_box_ports_listening) { + write_json({ + sing_box_installed: arg_number(sing_box_installed), + sing_box_version_ok: arg_number(sing_box_version_ok), + sing_box_extended: arg_number(sing_box_extended), + sing_box_service_exist: arg_number(sing_box_service_exist), + sing_box_autostart_disabled: arg_number(sing_box_autostart_disabled), + sing_box_process_running: arg_number(sing_box_process_running), + sing_box_ports_listening: arg_number(sing_box_ports_listening) + }); +} + +function write_fakeip_check_json(fakeip_status, fakeip_address) { + write_json({ + fakeip: arg_bool(fakeip_status), + IP: as_string(fakeip_address) + }); +} + +function fakeip_address_status(address) { + print(match(as_string(address), /^198\.(18|19)\./) != null ? "true\n" : "false\n"); +} + +function repeat_char(char, count) { + let result = ""; + for (let i = 0; i < count; i++) + result += char; + return result; +} + +function first_field(value, separator) { + let marker = index(value, separator); + return marker < 0 ? value : substr(value, 0, marker); +} + +function mask_dns_server(value) { + value = as_string(value); + if (str_endswith(value, ".dns.nextdns.io")) { + let nextdns_id = first_field(value, "."); + print_line(repeat_char("*", length(nextdns_id)) + ".dns.nextdns.io"); + return; + } + + if (str_startswith(value, "dns.nextdns.io/")) { + let path = substr(value, length("dns.nextdns.io/")); + print_line("dns.nextdns.io/" + repeat_char("*", length(path))); + return; + } + + print_line(value); +} + +function render_global_inbounds_check() { + let value = read_stdin_json(); + if (type(value) != "object") + exit(1); + + let enabled_count = number_value(value.enabled_count); + let wan_ip = as_string(value.wan_ip || ""); + let wan_public = number_value(value.wan_public); + + if (enabled_count == 0) { + print_line("[OK] No enabled server inbounds"); + return; + } + + if (wan_public == 1) + print_line("[OK] WAN public IP: " + wan_ip); + else if (wan_ip != "") + print_line("[WARN] WAN IP is not public: " + wan_ip); + else + print_line("[WARN] WAN IP was not detected"); + + let items = type(value.items) == "array" ? value.items : []; + for (let i = 0; i < enabled_count; i++) + render_inbound_item(type(items[i]) == "object" ? items[i] : {}, wan_ip); +} + +function render_flag_line(value, key, ok_message, fail_message) { + print_line((flag_is_one(value[key]) ? ok_message : fail_message)); +} + +function sing_box_core_label(value) { + if (flag_is_one(value.sing_box_extended) && flag_is_one(value.sing_box_compressed)) + return "extended compressed"; + if (flag_is_one(value.sing_box_extended)) + return "extended"; + if (flag_is_one(value.sing_box_tiny)) + return "tiny"; + return ""; +} + +function sing_box_version_note_label(value) { + if (flag_is_one(value.sing_box_extended) && flag_is_one(value.sing_box_compressed)) + return "compressed"; + if (flag_is_one(value.sing_box_tiny)) + return "tiny"; + return ""; +} + +function sing_box_version_is_known(version) { + version = lc(trim(as_string(version))); + return version != "" && + version != "loading" && + version != "unknown" && + version != "not installed"; +} + +function format_sing_box_version(value, version) { + version = as_string(version); + if (!sing_box_version_is_known(version)) + return version; + + let variant = sing_box_version_note_label(value); + return variant != "" ? version + " (" + variant + ")" : version; +} + +function render_global_sing_box_check() { + let value = object_or_empty(read_stdin_json()); + + render_flag_line(value, "sing_box_installed", "\u2705 Sing-box installed", "\u274c Sing-box installed"); + render_flag_line(value, "sing_box_version_ok", "\u2705 Sing-box version is compatible (newer than 1.12.4)", "\u274c Sing-box version is not compatible (older than 1.12.4)"); + + if (flag_is_one(value.sing_box_extended)) + print_line("Sing-box extended detected"); + else + print_line("Sing-box regular build detected"); + + render_flag_line(value, "sing_box_service_exist", "\u2705 Sing-box service exist", "\u274c Sing-box service exist"); + render_flag_line(value, "sing_box_autostart_disabled", "\u2705 Sing-box autostart disabled", "\u274c Sing-box autostart disabled"); + render_flag_line(value, "sing_box_process_running", "\u2705 Sing-box process running", "\u274c Sing-box process running"); + render_flag_line(value, "sing_box_ports_listening", "\u2705 Sing-box listening ports", "\u274c Sing-box listening ports"); +} + +function render_global_system_info() { + let value = object_or_empty(read_stdin_json()); + let tachyon_version = object_value(value, "tachyon_version") || "unknown"; + let luci_app_version = object_value(value, "luci_app_version") || "unknown"; + let sing_box_version = object_value(value, "sing_box_version") || "unknown"; + let zapret_version = object_value(value, "zapret_version") || "unknown"; + let zapret2_version = object_value(value, "zapret2_version") || "unknown"; + let byedpi_version = object_value(value, "byedpi_version") || "unknown"; + let openwrt_version = object_value(value, "openwrt_version") || "unknown"; + let device_model = object_value(value, "device_model") || "unknown"; + + let sing_box_core = sing_box_core_label(value) || "regular"; + print_line("Sing-box core: " + sing_box_core); + + print_line("\ud83d\udd73\ufe0f Tachyon: " + tachyon_version); + print_line("\ud83d\udd73\ufe0f LuCI App: " + luci_app_version); + print_line("\ud83d\udce6 Sing-box: " + format_sing_box_version(value, sing_box_version)); + if (flag_is_one(value.zapret_installed)) + print_line("\ud83e\uddf5 Zapret: " + zapret_version); + if (flag_is_one(value.zapret2_installed)) + print_line("\ud83e\uddf5 Zapret2: " + zapret2_version); + if (flag_is_one(value.byedpi_installed)) + print_line("\ud83e\uddf5 ByeDPI: " + byedpi_version); + print_line("\ud83d\udedc OpenWrt: " + openwrt_version); + print_line("\ud83d\udedc Device: " + device_model); +} + +function render_global_fakeip_check() { + let value = object_or_empty(read_stdin_json()); + let fakeip_address = object_value(value, "IP"); + + if (flag_is_true(value.fakeip)) + print_line("\u2705 Sing-box FakeIP DNS works: " + fakeip_address); + else + print_line("\u274c Sing-box FakeIP DNS does NOT work"); +} + +function render_global_dns_check(dont_touch_dhcp) { + let value = object_or_empty(read_stdin_json()); + let dns_type = object_value(value, "dns_type") || "unknown"; + let dns_server = object_value(value, "dns_server") || "unknown"; + let bootstrap_dns_server = object_value(value, "bootstrap_dns_server"); + let dns_position = number_value(value.dns_server_count) > 1 + ? " (priority " + as_string(number_value(value.dns_server_index) + 1) + "/" + as_string(number_value(value.dns_server_count)) + ")" + : ""; + let bootstrap_position = number_value(value.bootstrap_dns_server_count) > 1 + ? " (priority " + as_string(number_value(value.bootstrap_dns_server_index) + 1) + "/" + as_string(number_value(value.bootstrap_dns_server_count)) + ")" + : ""; + let dump_dhcp_config = false; + + if (bootstrap_dns_server != "") { + print_line((flag_is_one(value.bootstrap_dns_status) ? "\u2705 Bootstrap DNS: " : "\u274c Bootstrap DNS: ") + bootstrap_dns_server + bootstrap_position); + } + + print_line((flag_is_one(value.dns_status) ? "\u2705 Main DNS: " : "\u274c Main DNS: ") + dns_server + " [" + dns_type + "]" + dns_position); + print_line(flag_is_one(value.dns_on_router) ? "\u2705 DNS on router" : "\u274c DNS on router"); + + if (as_string(dont_touch_dhcp) == "1") { + print_line("\u26a0\ufe0f dont_touch_dhcp is enabled. \ud83d\udcc4 DHCP config:"); + dump_dhcp_config = true; + } + else if (!flag_is_one(value.dhcp_config_status)) { + print_line("\u274c DHCP configuration differs from template. \ud83d\udcc4 DHCP config:"); + dump_dhcp_config = true; + } + else { + print_line("\u2705 /etc/config/dhcp"); + } + + exit(dump_dhcp_config ? 10 : 0); +} + +function render_global_nft_check() { + let value = read_stdin_json(); + if (type(value) != "object") + exit(1); + + render_flag_line(value, "table_exist", "\u2705 Table exist", "\u274c Table exist"); + render_flag_line(value, "rules_mangle_exist", "\u2705 Rules mangle exist", "\u274c Rules mangle exist"); + render_flag_line(value, "rules_mangle_counters", "\u2705 Rules mangle counters", "\u26a0\ufe0f Rules mangle counters"); + render_flag_line(value, "rules_mangle_output_exist", "\u2705 Rules mangle output exist", "\u274c Rules mangle output exist"); + render_flag_line(value, "rules_mangle_output_counters", "\u2705 Rules mangle output counters", "\u26a0\ufe0f Rules mangle output counters"); + render_flag_line(value, "rules_proxy_exist", "\u2705 Rules proxy exist", "\u274c Rules proxy exist"); + render_flag_line(value, "rules_proxy_counters", "\u2705 Rules proxy counters", "\u26a0\ufe0f Rules proxy counters"); + + if (flag_is_one(value.rules_other_mark_exist)) + print_line("\u26a0\ufe0f Additional marking rules found:"); + else + print_line("\u2705 No other marking rules found"); +} + +function global_nft_other_mark_exists() { + let value = read_stdin_json(); + exit(type(value) == "object" && flag_is_one(value.rules_other_mark_exist) ? 0 : 1); +} + +function nft_ruleset_other_mark_lines(table_name) { + table_name = as_string(table_name); + let in_tachyon_table = false; + + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (index(line, "table inet " + table_name) >= 0) { + in_tachyon_table = true; + continue; + } + + if (match(line, /^table/) != null) + in_tachyon_table = false; + + if (!in_tachyon_table && (index(line, "mark set") >= 0 || index(line, "meta mark") >= 0)) + print_line(line); + } +} + +function nft_set_element_count() { + let value = object_or_empty(read_stdin_json()); + + for (let item in array_or_empty(value.nftables)) { + if (type(item) != "object" || type(item.set) != "object") + continue; + + print_line(length(array_or_empty(item.set.elem))); + return; + } + + print_line("0"); +} + +function line_contains(line, needle) { + return index(as_string(line), as_string(needle)) >= 0; +} + +function print_lines(lines, start, end) { + for (let i = start; i < end; i++) + print_line(lines[i]); +} + +function is_udp_unsupported_outbound_log(line) { + return index(as_string(line), "UDP is not supported by outbound:") >= 0; +} + +function render_matching_log_tail(needle, max_lines) { + let lines = split(read_stdin(), "\n"); + let filtered = []; + let udp_http_notice_emitted = false; + + for (let line in lines) { + if (line == "") + continue; + if (line_contains(line, needle)) + { + if (needle == "sing-box" && is_udp_unsupported_outbound_log(line)) { + if (!udp_http_notice_emitted) { + push(filtered, "UDP traffic through HTTP outbounds is not supported by sing-box; repeated UDP warnings for HTTP outbounds are hidden by Tachyon."); + udp_http_notice_emitted = true; + } + continue; + } + + push(filtered, line); + } + } + + if (length(filtered) == 0) + exit(1); + + max_lines = int(max_lines || "100", 10) || 100; + let start = length(filtered) > max_lines ? length(filtered) - max_lines : 0; + print_lines(filtered, start, length(filtered)); +} + +function render_tachyon_logs() { + let lines = split(read_stdin(), "\n"); + let filtered = []; + let start = -1; + let udp_http_notice_emitted = false; + + for (let line in lines) { + if (line == "") + continue; + if (!line_contains(line, "tachyon") && !line_contains(line, "sing-box")) + continue; + + if (line_contains(line, "sing-box") && is_udp_unsupported_outbound_log(line)) { + if (!udp_http_notice_emitted) { + push(filtered, "UDP traffic through HTTP outbounds is not supported by sing-box; repeated UDP warnings for HTTP outbounds are hidden by Tachyon."); + udp_http_notice_emitted = true; + } + continue; + } + + if (line_contains(line, "tachyon") && line_contains(line, "Starting Tachyon")) + start = length(filtered); + + push(filtered, line); + } + + if (length(filtered) == 0) + exit(1); + + if (start >= 0) { + print_lines(filtered, start, length(filtered)); + return; + } + + print_line("No 'Starting Tachyon' message found, showing last 100 lines"); + start = length(filtered) > 100 ? length(filtered) - 100 : 0; + print_lines(filtered, start, length(filtered)); +} + +function file_first_line(path, fallback) { + let data = fs.readfile(path); + let result = ""; + + if (data != null) { + let lines = split(as_string(data), "\n"); + if (length(lines) > 0) + result = str_remove_suffix(as_string(lines[0]), "\r"); + } + + if (result == "") + result = as_string(fallback); + + print_line(result); +} + +function js_var_string_value(path, var_name) { + let data = fs.readfile(path); + var_name = as_string(var_name); + + if (data == null || var_name == "") + return; + + for (let line in split(as_string(data), "\n")) { + let matched = match(line, /^[ \t]*var[ \t]+([^ \t=]+)[ \t]*=[ \t]*"([^"]*)"/); + if (matched != null && matched[1] == var_name) { + print_line(matched[2]); + return; + } + } +} + +function key_value_file_value(path, key) { + let data = fs.readfile(path); + key = as_string(key); + + if (data == null || key == "") + return; + + let prefix = key + "="; + for (let line in split(as_string(data), "\n")) { + line = str_remove_suffix(as_string(line), "\r"); + if (!str_startswith(line, prefix)) + continue; + + let value = substr(line, length(prefix)); + if (length(value) >= 2) { + let quote = substr(value, 0, 1); + if ((quote == "\"" || quote == "'") && str_endswith(value, quote)) + value = substr(value, 1, length(value) - 2); + } + print_line(value); + return; + } +} + +function system_info_cache_valid(path, tachyon_version, luci_app_version, ttl, now) { + let cache = read_json_file(path); + if (type(cache) != "object") + return false; + + now = arg_number(now); + ttl = arg_number(ttl); + let cached_at = arg_number(cache.generated_at || 0); + + if (now > 0 && cached_at > 0 && ttl > 0 && now - cached_at >= ttl) + return false; + + return cache.tachyon_version == tachyon_version && cache.luci_app_version == luci_app_version; +} + +function system_info_json() { + write_json({ + tachyon_version: as_string(ARGV[1]), + tachyon_latest_version: as_string(ARGV[2]), + luci_app_version: as_string(ARGV[3]), + sing_box_version: as_string(ARGV[4]), + sing_box_extended: arg_number(ARGV[5]), + sing_box_tiny: arg_number(ARGV[6]), + sing_box_compressed: arg_number(ARGV[7]), + sing_box_tailscale: arg_number(ARGV[8]), + zapret_version: as_string(ARGV[9]), + zapret_installed: arg_number(ARGV[10]), + zapret2_version: as_string(ARGV[11]), + zapret2_installed: arg_number(ARGV[12]), + byedpi_version: as_string(ARGV[13]), + byedpi_installed: arg_number(ARGV[14]), + openwrt_version: as_string(ARGV[15]), + device_model: as_string(ARGV[16]), + generated_at: arg_number(ARGV[17]) + }); +} + +function nfqws_strategy_validation(valid, message, needle, needles) { + let result = []; + for (let item in split(as_string(needles), "\n")) { + if (item != "") + push(result, item); + } + + write_json({ + valid: arg_bool(valid), + message: as_string(message), + needle: as_string(needle), + needles: result + }); +} + +let masked_sing_box_keys = { + auth_key: true, + control_url: true, + exit_node: true, + hostname: true, + listen: true, + listen_port: true, + username: true, + uuid: true, + server: true, + server_name: true, + secret: true, + password: true, + private_key: true, + public_key: true, + short_id: true, + fingerprint: true, + server_port: true, + server_ports: true, + advertise_routes: true, + domain: true, + domain_suffix: true, + domain_keyword: true, + domain_regex: true, + ip_cidr: true, + source_ip_cidr: true +}; + +function mask_sing_box_value(value) { + if (type(value) == "array") { + let result = []; + for (let item in value) + push(result, mask_sing_box_value(item)); + return result; + } + + if (type(value) == "object") { + let result = {}; + for (let key, item in value) + result[key] = masked_sing_box_keys[key] ? "MASKED" : mask_sing_box_value(item); + return result; + } + + return value; +} + +function mask_sing_box_config(path) { + write_json(mask_sing_box_value(read_json_file(path))); +} + +function prepare_check_proxy_config(input_path, output_path, cache_path) { + let config = object_or_empty(read_json_file(input_path)); + config.inbounds = []; + config.services = []; + if (type(config.experimental) == "object") { + if (type(config.experimental.cache_file) == "object") + config.experimental.cache_file.path = as_string(cache_path); + delete config.experimental.clash_api; + } + + if (!write_json_file(output_path, config)) + exit(1); +} + +function check_proxy_outbound_tag(config_path, domain) { + let config = object_or_empty(read_json_file(config_path)); + for (let rule in array_or_empty(config.route && config.route.rules)) { + if (type(rule) != "object") + continue; + if (contains(array_or_empty(rule.domain), domain)) { + print_line(as_string(rule.outbound || "")); + return; + } + } +} + +function http_response_with_status() { + let lines = split(read_stdin(), "\n"); + let status = length(lines) > 0 ? as_string(lines[length(lines) - 1]) : ""; + let body_lines = length(lines) > 0 ? slice(lines, 0, length(lines) - 1) : []; + + return { + status_text: status, + status: int(status || "0", 10) || 0, + body: join("\n", body_lines) + }; +} + +function write_clash_api_error(status, body) { + let result = { + success: false, + http_code: status + }; + + if (body != "") + result.body = body; + + write_json(result); + exit(1); +} + +function clash_set_group_proxy_result(group_tag, proxy_tag) { + let response = http_response_with_status(); + + if (response.status == 204) { + write_json({ success: true, group: as_string(group_tag), proxy: as_string(proxy_tag) }); + return; + } + + if (response.status == 404) { + write_json({ success: false, error: "group_not_found", message: as_string(group_tag) + " does not exist" }); + exit(1); + } + + if (response.status == 400) { + if (line_contains(response.body, "not found")) + write_json({ success: false, error: "proxy_not_found", message: as_string(proxy_tag) + " not found in group " + as_string(group_tag) }); + else + write_json({ success: false, error: "bad_request", message: "Invalid request" }); + exit(1); + } + + write_clash_api_error(response.status, response.body); +} + +function clash_close_connection_result(connection_id) { + let response = http_response_with_status(); + + if (response.status == 200 || response.status == 204) { + write_json({ success: true, connection_id: as_string(connection_id) }); + return; + } + + if (response.status == 404) { + write_json({ success: false, error: "connection_not_found", connection_id: as_string(connection_id) }); + exit(1); + } + + write_clash_api_error(response.status, response.body); +} + +function clash_close_all_connections_result() { + let response = http_response_with_status(); + + if (response.status == 200 || response.status == 204) { + write_json({ success: true }); + return; + } + + write_clash_api_error(response.status, response.body); +} + +function clash_set_group_proxy_payload(proxy_tag) { + write_json({ name: as_string(proxy_tag) }); +} + +function clash_proxy_tags_lines(proxy_tags_json) { + let proxy_tags = parse_json_or_null(proxy_tags_json); + if (type(proxy_tags) != "array") + exit(1); + + for (let proxy_tag in proxy_tags) { + if (type(proxy_tag) != "string" || proxy_tag == "") + exit(1); + print(proxy_tag, "\n"); + } +} + +function clash_proxy_latencies_result(count, failed) { + let has_failed = as_string(failed) != "0"; + write_json({ + success: !has_failed, + count: int(as_string(count || "0"), 10) || 0, + failed: has_failed + }); + if (has_failed) + exit(1); +} + +function clash_unknown_action() { + write_json({ + error: "unknown action", + available: [ + "get_proxies", + "get_connections", + "get_proxy_latency", + "get_proxy_latencies", + "get_group_latency", + "set_group_proxy", + "close_connection", + "close_all_connections" + ] + }); +} + +let mode = ARGV[0]; + +if (mode == "proxy-response-ip-mask") + render_proxy_response_ip_mask(); +else if (mode == "url-encode") + url_encode(ARGV[1]); +else if (mode == "stdin-json") + stdin_json(); +else if (mode == "stdin-first-line-last-field") + stdin_first_line_last_field(); +else if (mode == "stdin-first-line") + stdin_first_line(); +else if (mode == "stdin-sorted-unique-space-list") + stdin_sorted_unique_space_list(); +else if (mode == "public-host-flags") + public_host_flags(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "stdin-first-ipv4-line") + stdin_first_ipv4_line(); +else if (mode == "stdin-contains") + stdin_contains(ARGV[1]); +else if (mode == "strip-leading-v") + strip_leading_v(ARGV[1]); +else if (mode == "network-endpoint-host-warnings") + network_endpoint_host_warnings(ARGV[1]); +else if (mode == "network-wireguard-route-allowed-peers") + network_wireguard_route_allowed_peers(); +else if (mode == "wan-config-masked") + wan_config_masked(ARGV[1]); +else if (mode == "tachyon-config-masked") + tachyon_config_masked(ARGV[1]); +else if (mode == "dhcp-dnsmasq-config") + dhcp_dnsmasq_config(ARGV[1]); +else if (mode == "firewall-port-token-contains") + exit(firewall_port_token_contains(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "firewall-port-spec-contains") + exit(firewall_port_spec_contains(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "firewall-proto-spec-contains") + exit(firewall_proto_spec_contains(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "firewall-port-open") + exit(firewall_port_open_for_proto(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "firewall-required-protocols-open") + exit(firewall_required_protocols_open(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "server-required-inbound-proto") + server_required_inbound_proto(ARGV[1]); +else if (mode == "server-runtime-type-for-protocol") + server_runtime_type_for_protocol(ARGV[1]); +else if (mode == "server-listen-requires-firewall") + exit(server_listen_requires_firewall(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "server-port-listening") + exit(netstat_server_port_listening(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "server-required-ports-listening") + exit(server_required_ports_listening(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "server-port-conflict-owners") + netstat_server_port_conflict_owners(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "server-required-port-conflict-owners") + server_required_port_conflict_owners(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "json-error") + json_error(ARGV[1]); +else if (mode == "inbound-item-json") + write_inbound_item_json(slice(ARGV, 1)); +else if (mode == "inbounds-config-json") + write_inbounds_config_json(ARGV[1]); +else if (mode == "inbounds-check-json") + write_inbounds_check_json(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "server-capabilities-json") + write_server_capabilities_json(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "ui-capabilities-json") + write_ui_capabilities_json(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]); +else if (mode == "service-status-json") + write_service_status_json(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "service-status-running") + stdin_service_status_running(); +else if (mode == "service-list-instance-running") + service_list_instance_running(ARGV[1]); +else if (mode == "dns-check-json") + write_dns_check_json(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]); +else if (mode == "nft-check-json") + write_nft_check_json(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]); +else if (mode == "sing-box-check-json") + write_sing_box_check_json(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); +else if (mode == "fakeip-check-json") + write_fakeip_check_json(ARGV[1], ARGV[2]); +else if (mode == "fakeip-address-status") + fakeip_address_status(ARGV[1]); +else if (mode == "mask-dns-server") + mask_dns_server(ARGV[1]); +else if (mode == "global-inbounds-check") + render_global_inbounds_check(); +else if (mode == "global-sing-box-check") + render_global_sing_box_check(); +else if (mode == "global-system-info") + render_global_system_info(); +else if (mode == "global-fakeip-check") + render_global_fakeip_check(); +else if (mode == "global-dns-check") + render_global_dns_check(ARGV[1]); +else if (mode == "global-nft-check") + render_global_nft_check(); +else if (mode == "global-nft-other-mark-exists") + global_nft_other_mark_exists(); +else if (mode == "nft-ruleset-other-mark-lines") + nft_ruleset_other_mark_lines(ARGV[1]); +else if (mode == "nft-set-element-count") + nft_set_element_count(); +else if (mode == "nft-chain-config-blocks") + render_nft_chain_config_blocks(); +else if (mode == "nft-chain-counter-status") + nft_chain_counter_status(); +else if (mode == "tachyon-logs") + render_tachyon_logs(); +else if (mode == "matching-log-tail") + render_matching_log_tail(ARGV[1], ARGV[2]); +else if (mode == "file-first-line") + file_first_line(ARGV[1], ARGV[2]); +else if (mode == "js-var-string-value") + js_var_string_value(ARGV[1], ARGV[2]); +else if (mode == "key-value-file-value") + key_value_file_value(ARGV[1], ARGV[2]); +else if (mode == "system-info-cache-valid") + exit(system_info_cache_valid(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]) ? 0 : 1); +else if (mode == "system-info-json") + system_info_json(); +else if (mode == "nfqws-strategy-validation") + nfqws_strategy_validation(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "mask-sing-box-config") + mask_sing_box_config(ARGV[1]); +else if (mode == "proxy-response-is-retryable-error") + exit(proxy_response_is_retryable_error() ? 0 : 1); +else if (mode == "prepare-check-proxy-config") + prepare_check_proxy_config(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "check-proxy-outbound-tag") + check_proxy_outbound_tag(ARGV[1], ARGV[2]); +else if (mode == "clash-set-group-proxy-result") + clash_set_group_proxy_result(ARGV[1], ARGV[2]); +else if (mode == "clash-close-connection-result") + clash_close_connection_result(ARGV[1]); +else if (mode == "clash-close-all-connections-result") + clash_close_all_connections_result(); +else if (mode == "clash-set-group-proxy-payload") + clash_set_group_proxy_payload(ARGV[1]); +else if (mode == "clash-proxy-tags-lines") + clash_proxy_tags_lines(ARGV[1]); +else if (mode == "clash-proxy-latencies-result") + clash_proxy_latencies_result(ARGV[1], ARGV[2]); +else if (mode == "clash-unknown-action") + clash_unknown_action(); +else { + warn("Usage: diagnostics/status.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/dns/apply.uc b/tachyon/files/usr/lib/dns/apply.uc new file mode 100644 index 00000000..f2dbdbbc --- /dev/null +++ b/tachyon/files/usr/lib/dns/apply.uc @@ -0,0 +1,306 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci = require("core.uci"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const SB_DNS_INBOUND_ADDRESS = getenv("SB_DNS_INBOUND_ADDRESS") || "127.0.0.42"; +const DNSMASQ_INIT = getenv("DNSMASQ_INIT") || "/etc/init.d/dnsmasq"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function run(command) { + return system(command) == 0; +} + +function uci_available() { + return uci.available(); +} + +function uci_get(path) { + return uci.get(path); +} + +function uci_exists(path) { + return uci.exists(path); +} + +function uci_delete(path) { + uci.delete(path); +} + +function uci_set(path, value) { + uci.set(path, value); +} + +function uci_add_list(path, value) { + uci.add_list(path, value); +} + +function uci_del_list(path, value) { + return uci.del_list(path, value); +} + +function uci_commit(package_name) { + uci.commit(package_name); +} + +function words(value) { + value = trim(as_string(value)); + return value == "" ? [] : split(value, /[ \t\r\n]+/); +} + +function truthy(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function list_has(values, needle) { + for (let value in words(values)) + if (value == needle) + return true; + return false; +} + +function log(message, level) { + level = as_string(level || "info"); + run("logger -t " + shell_quote("tachyon") + " " + shell_quote("[" + level + "] " + as_string(message))); +} + +function restart_dnsmasq() { + return run("[ -x " + shell_quote(DNSMASQ_INIT) + " ] && " + shell_quote(DNSMASQ_INIT) + " restart"); +} + +function dnsmasq_legacy_instance_exists() { + return uci_exists("dhcp.tachyon"); +} + +function dnsmasq_default_servers() { + return uci_get("dhcp.@dnsmasq[0].server"); +} + +function dnsmasq_default_has_tachyon_dns() { + return list_has(dnsmasq_default_servers(), SB_DNS_INBOUND_ADDRESS); +} + +function dnsmasq_has_tachyon_dns() { + return dnsmasq_default_has_tachyon_dns() || dnsmasq_legacy_instance_exists(); +} + +function dnsmasq_has_tachyon_managed_state() { + return uci_get("dhcp.@dnsmasq[0].tachyon_server") != "" || + uci_get("dhcp.@dnsmasq[0].tachyon_noresolv") != "" || + uci_get("dhcp.@dnsmasq[0].tachyon_cachesize") != "" || + uci_get("dhcp.@dnsmasq[0].tachyon_notinterface") != "" || + dnsmasq_legacy_instance_exists(); +} + +function dnsmasq_management_disabled() { + return truthy(uci_get(CONFIG_NAME + ".settings.dont_touch_dhcp")); +} + +function dnsmasq_default_config_is_complete() { + return dnsmasq_default_has_tachyon_dns() && + uci_get("dhcp.@dnsmasq[0].noresolv") == "1" && + uci_get("dhcp.@dnsmasq[0].cachesize") == "0" && + !dnsmasq_legacy_instance_exists(); +} + +function dnsmasq_legacy_interfaces() { + let legacy_dnsmasq_section = "tachyon"; + let legacy_interfaces = uci_get("dhcp." + legacy_dnsmasq_section + ".interface"); + if (legacy_interfaces == "") + legacy_interfaces = uci_get(CONFIG_NAME + ".settings.source_network_interfaces"); + if (legacy_interfaces == "") + legacy_interfaces = "br-lan"; + + return legacy_interfaces; +} + +function backup_dnsmasq_config_option(key, backup_key) { + if (uci_get("dhcp.@dnsmasq[0]." + backup_key) != "") + return; + + let value = uci_get("dhcp.@dnsmasq[0]." + key); + if (value != "") + uci_set("dhcp.@dnsmasq[0]." + backup_key, value); +} + +function backup_dnsmasq_server_list() { + if (uci_get("dhcp.@dnsmasq[0].tachyon_server") != "") + return; + + for (let server in words(dnsmasq_default_servers())) { + if (server != SB_DNS_INBOUND_ADDRESS) + uci_add_list("dhcp.@dnsmasq[0].tachyon_server", server); + } +} + +function restore_dnsmasq_config_option(key, backup_key, default_value) { + let value = uci_get("dhcp.@dnsmasq[0]." + backup_key); + if (value != "") { + uci_set("dhcp.@dnsmasq[0]." + key, value); + uci_delete("dhcp.@dnsmasq[0]." + backup_key); + } + else if (as_string(default_value) != "") { + uci_set("dhcp.@dnsmasq[0]." + key, default_value); + } + else { + uci_delete("dhcp.@dnsmasq[0]." + key); + } +} + +function dnsmasq_cleanup_legacy_instance() { + let legacy_instance_present = dnsmasq_legacy_instance_exists(); + let legacy_interfaces = legacy_instance_present ? dnsmasq_legacy_interfaces() : ""; + + uci_delete("dhcp.tachyon"); + + let backup_notinterfaces = uci_get("dhcp.@dnsmasq[0].tachyon_notinterface"); + if (backup_notinterfaces != "") { + uci_delete("dhcp.@dnsmasq[0].notinterface"); + for (let value in words(backup_notinterfaces)) + uci_add_list("dhcp.@dnsmasq[0].notinterface", value); + uci_delete("dhcp.@dnsmasq[0].tachyon_notinterface"); + return; + } + + if (legacy_instance_present) { + for (let value in words(legacy_interfaces)) + uci_del_list("dhcp.@dnsmasq[0].notinterface", value); + } + + uci_delete("dhcp.@dnsmasq[0].tachyon_notinterface"); +} + +function dnsmasq_configure_default_instance() { + let default_has_tachyon_dns = dnsmasq_default_has_tachyon_dns(); + + backup_dnsmasq_server_list(); + if (!default_has_tachyon_dns) { + backup_dnsmasq_config_option("noresolv", "tachyon_noresolv"); + backup_dnsmasq_config_option("cachesize", "tachyon_cachesize"); + } + + uci_delete("dhcp.@dnsmasq[0].server"); + uci_add_list("dhcp.@dnsmasq[0].server", SB_DNS_INBOUND_ADDRESS); + uci_set("dhcp.@dnsmasq[0].noresolv", "1"); + uci_set("dhcp.@dnsmasq[0].cachesize", "0"); +} + +function dnsmasq_restore_default_instance() { + let server_list = dnsmasq_default_servers(); + let backup_servers = uci_get("dhcp.@dnsmasq[0].tachyon_server"); + let managed_global_dns = list_has(server_list, SB_DNS_INBOUND_ADDRESS); + + uci_delete("dhcp.@dnsmasq[0].server"); + if (backup_servers != "") { + for (let value in words(backup_servers)) + uci_add_list("dhcp.@dnsmasq[0].server", value); + uci_delete("dhcp.@dnsmasq[0].tachyon_server"); + } + else { + for (let value in words(server_list)) { + if (value != SB_DNS_INBOUND_ADDRESS) + uci_add_list("dhcp.@dnsmasq[0].server", value); + } + } + uci_delete("dhcp.@dnsmasq[0].tachyon_server"); + + let noresolv = uci_get("dhcp.@dnsmasq[0].tachyon_noresolv"); + if (noresolv != "") + restore_dnsmasq_config_option("noresolv", "tachyon_noresolv", ""); + else if (managed_global_dns) + uci_set("dhcp.@dnsmasq[0].noresolv", "0"); + + let cachesize = uci_get("dhcp.@dnsmasq[0].tachyon_cachesize"); + if (cachesize != "") + restore_dnsmasq_config_option("cachesize", "tachyon_cachesize", ""); + else if (managed_global_dns) + uci_set("dhcp.@dnsmasq[0].cachesize", "150"); +} + +function dnsmasq_configure(force) { + if (!uci_available()) + return true; + + if (as_string(force) != "force" && uci_get(CONFIG_NAME + ".settings.shutdown_correctly") == "0") { + if (dnsmasq_default_config_is_complete()) { + log("Previous Tachyon shutdown was unclean; dnsmasq already points to sing-box", "info"); + return true; + } + log("Previous Tachyon shutdown was unclean and dnsmasq is not ready; applying Tachyon DNS settings", "info"); + } + + log("Configuring dnsmasq to forward DNS to sing-box", "info"); + dnsmasq_cleanup_legacy_instance(); + dnsmasq_configure_default_instance(); + uci_commit("dhcp"); + + return restart_dnsmasq(); +} + +function dnsmasq_restore(force, quiet) { + if (!uci_available()) + return true; + + if (!quiet) + log("Restoring DNS settings in dnsmasq", "info"); + if (as_string(force) != "force" && uci_get(CONFIG_NAME + ".settings.shutdown_correctly") == "1") { + if (!dnsmasq_has_tachyon_dns()) { + log("dnsmasq already uses non-Tachyon DNS settings; restore is not required", "info"); + return true; + } + log("Tachyon DNS settings are still present after a clean shutdown; restoring DNS settings in dnsmasq", "info"); + } + + dnsmasq_cleanup_legacy_instance(); + dnsmasq_restore_default_instance(); + uci_commit("dhcp"); + + return restart_dnsmasq(); +} + +function failsafe_restore() { + if (!uci_available()) + return true; + + if (dnsmasq_management_disabled()) { + if (!dnsmasq_has_tachyon_managed_state()) { + log("DNS rollback skipped: dont_touch_dhcp is enabled and no Tachyon dnsmasq changes were found", "info"); + return true; + } + + log("Rolling back previous Tachyon dnsmasq changes because dont_touch_dhcp is enabled", "warn"); + } + else { + log("Rolling back Tachyon DNS changes in dnsmasq", "warn"); + } + + dnsmasq_restore("force", true); + return true; +} + +let mode = ARGV[0] || ""; + +if (mode == "configure") + exit(dnsmasq_configure(ARGV[1]) ? 0 : 1); +else if (mode == "restore") + exit(dnsmasq_restore(ARGV[1]) ? 0 : 1); +else if (mode == "failsafe-restore") + exit(failsafe_restore() ? 0 : 1); +else if (mode == "has-tachyon-dns") + exit(dnsmasq_has_tachyon_dns() ? 0 : 1); +else if (mode == "has-managed-state") + exit(dnsmasq_has_tachyon_managed_state() ? 0 : 1); +else if (mode == "default-config-complete") + exit(dnsmasq_default_config_is_complete() ? 0 : 1); + +warn("Usage: dns/apply.uc \n"); +exit(1); diff --git a/tachyon/files/usr/lib/nft/apply.uc b/tachyon/files/usr/lib/nft/apply.uc new file mode 100644 index 00000000..0d93e063 --- /dev/null +++ b/tachyon/files/usr/lib/nft/apply.uc @@ -0,0 +1,1817 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); +let core_ip = require("core.ip"); +let uci_core = require("core.uci"); +let rule_config = require("config.rule"); +let domain_config = require("config.domain"); +let connections = require("config.connections"); +let routing_rulesets = require("routing.rulesets"); +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; + +let common_read_json_file = common.read_json_file; +let list_option = common.list_option; +let bool_option = common.bool_option; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes"; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function uci_section(section_name) { + return object_or_empty(uci_core.get_all(CONFIG_NAME, section_name)); +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, type_name); +} + +function uci_settings() { + return uci_section("settings"); +} + +function write_compact_string_array(values) { + print("["); + for (let i = 0; i < length(values); i++) { + if (i > 0) + print(","); + print(sprintf("%J", as_string(values[i]))); + } + print("]\n"); +} + +function write_text_file(path, text) { + let result = fs.writefile(path, as_string(text)); + if (result == null) + return false; + if (type(result) == "boolean" && !result) + return false; + return true; +} + +function file_executable(path) { + let stat = fs.stat(as_string(path)); + if (stat == null || stat.mode == null) + return false; + + return (int(stat.mode) & 73) != 0; +} + +function unlink_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function run_args(args) { + return system(command_from_args(args)) == 0; +} + +function run_args_quiet(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_output_from_args(args) { + let pipe = fs.popen(command_from_args(args), "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function command_output_quiet_from_args(args) { + let pipe = fs.popen(command_from_args(args) + " 2>/dev/null", "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function log_debug(message) { + run_args([ "logger", "-t", "tachyon", "[debug] " + as_string(message) ]); +} + +function log_fatal(message) { + run_args([ "logger", "-t", "tachyon", "[fatal] " + as_string(message) ]); +} + +function strip_list_comment(line) { + line = replace(as_string(line), /[[:space:]]*\/\/.*$/, ""); + return replace(line, /[[:space:]]*#.*$/, ""); +} + +function print_csv(values) { + for (let i = 0; i < length(values); i++) { + if (i > 0) + print(","); + print(as_string(values[i])); + } + if (length(values) > 0) + print("\n"); +} + +function text_list_values(value, separator_mode) { + let result = []; + separator_mode = as_string(separator_mode); + + for (let line in split(as_string(value), "\n")) { + line = strip_list_comment(line); + line = separator_mode == "comma-space" + ? replace(line, /[ ,]/g, "\n") + : replace(line, /,/g, "\n"); + + for (let item in split(line, "\n")) { + item = trim(replace(item, /\r/g, "")); + if (item != "") + push(result, item); + } + } + + return result; +} + +function text_list_to_csv(value, separator_mode) { + print_csv(text_list_values(value, separator_mode)); +} + +function csv_to_json_array(value) { + value = as_string(value); + if (value == "") { + print("[]\n"); + return; + } + + write_compact_string_array(split(value, ",")); +} + +function csv_list_contains(value, needle) { + needle = as_string(needle); + if (needle == "") + return false; + + for (let item in split(as_string(value), ",")) { + if (item == needle) + return true; + } + + return false; +} + +function cache_key_is_safe(value) { + value = as_string(value); + return value != "" && match(value, /^[A-Za-z0-9_]+$/) != null; +} + +function cache_path(enabled, cache_dir, namespace, section, key, kind) { + if (as_string(enabled) != "1") + exit(1); + + cache_dir = as_string(cache_dir); + if (cache_dir == "") + exit(1); + + if (!cache_key_is_safe(namespace) || !cache_key_is_safe(section) || + !cache_key_is_safe(key) || !cache_key_is_safe(kind)) + exit(1); + + print(cache_dir, "/", namespace, "_", section, "_", key, "_", kind, "\n"); +} + +function valid_ipv4(value) { + return core_ip.valid_ipv4(value, false, false); +} + +function valid_ipv4_cidr(value) { + return core_ip.valid_ipv4_cidr(value, false); +} + +function nft_ip_or_cidr(value) { + return core_ip.nft_ip_or_cidr(value); +} + +function domain_subnet_line_values(data) { + let result = []; + + for (let line in split(as_string(data), "\n")) { + line = trim(replace(strip_list_comment(line), /\r/g, "")); + if (line != "") + push(result, line); + } + + return result; +} + +function normalize_domain_subnet_value(value, kind) { + kind = as_string(kind); + if (kind == "domains") + return domain_config.suffix_to_ascii(value); + if (kind == "subnets") + return core_ip.valid_ip_or_cidr(value) ? value : null; + + exit(1); +} + +function filter_domain_subnet_values(values, kind) { + let result = []; + kind = as_string(kind); + + if (kind != "domains" && kind != "subnets") + exit(1); + + for (let value in values) { + let normalized = normalize_domain_subnet_value(value, kind); + if (normalized != null) + push(result, normalized); + } + + return result; +} + +function combined_domain_text_csv(value, requested_kind) { + let result = rule_config.combined_domain_text_csv_value(value, requested_kind); + if (result != "") + print(result, "\n"); +} + +function combined_domain_csv(value, requested_kind) { + let result = rule_config.combined_domain_csv_value(value, requested_kind); + if (result != "") + print(result, "\n"); +} + +function list_value_csv(value) { + value = as_string(value); + if (value != "") + print(replace(value, / /g, ","), "\n"); +} + +function legacy_condition_csv_value(kind, text_mode, conditions_text_mode, text_value, list_value) { + return rule_config.legacy_condition_csv_value(kind, text_mode, conditions_text_mode, text_value, list_value); +} + +function rule_condition_csv_value(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value) { + return rule_config.rule_condition_csv_value(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value); +} + +function rule_condition_csv(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value) { + let value = rule_condition_csv_value(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value); + + if (value != "") + print(value, "\n"); +} + +function legacy_condition_csv(kind, text_mode, conditions_text_mode, text_value, list_value) { + let value = legacy_condition_csv_value(kind, text_mode, conditions_text_mode, text_value, list_value); + if (value != "") + print(value, "\n"); +} + +function domain_subnet_text_csv(value, kind) { + print_csv(filter_domain_subnet_values(text_list_values(value, "comma-space"), kind)); +} + +function domain_subnet_file_csv(path, kind) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + print_csv(filter_domain_subnet_values(domain_subnet_line_values(data), kind)); +} + +function split_domain_subnet_file(path, domains_path, subnets_path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let domains = []; + let subnets = []; + + for (let value in domain_subnet_line_values(data)) { + let domain = normalize_domain_subnet_value(value, "domains"); + if (domain != null) + push(domains, domain); + else if (core_ip.valid_ip_or_cidr(value)) + push(subnets, value); + } + + if (!write_text_file(domains_path, length(domains) > 0 ? join("\n", domains) + "\n" : "")) + exit(1); + if (!write_text_file(subnets_path, length(subnets) > 0 ? join("\n", subnets) + "\n" : "")) + exit(1); +} + +function normalize_port_number_value(value) { + return rule_config.normalize_port_number_value(value); +} + +function normalize_port_condition_value(value) { + return rule_config.normalize_port_condition_value(value); +} + +function normalize_port_condition_for_nft(value) { + let normalized = normalize_port_condition_value(value); + if (normalized == null) + exit(1); + print(normalized, "\n"); +} + +function normalize_port_range_value(value) { + return rule_config.normalize_port_range_value(value); +} + +function rule_ports_csv_value(list_values, text_value) { + return rule_config.rule_ports_csv_value(list_values, text_value); +} + +function rule_ports_csv(list_values, text_value) { + let value = rule_ports_csv_value(list_values, text_value); + if (value != "") + print(value, "\n"); +} + +function rule_port_values(csv) { + let result = []; + + for (let item in split(as_string(csv), ",")) { + if (index(item, "-") >= 0) + continue; + + let port = normalize_port_number_value(item); + if (port != null) + push(result, port); + } + + return result; +} + +function rule_port_ranges(csv) { + let result = []; + + for (let item in split(as_string(csv), ",")) { + if (index(item, "-") < 0) + continue; + + let range = normalize_port_range_value(item); + if (range != null) + push(result, range); + } + + return result; +} + +function csv_to_lines_file(csv, path) { + if (!fs.writefile(path, replace(as_string(csv) + "\n", /,/g, "\n"))) + exit(1); +} + +function nft_create_table(name) { + return run_args([ "nft", "add", "table", "inet", name ]); +} + +function nft_create_set(table, name, definition) { + return run_args([ "nft", "add", "set", "inet", table, name, definition ]); +} + +function nft_create_ipv4_set(table, name) { + return nft_create_set(table, name, "{ type ipv4_addr; flags interval; auto-merge; }"); +} + +function nft_create_ipv6_set(table, name) { + return nft_create_set(table, name, "{ type ipv6_addr; flags interval; auto-merge; }"); +} + +function nft_create_inet_service_set(table, name) { + return nft_create_set(table, name, "{ type inet_service; flags interval; auto-merge; }"); +} + +function nft_create_ipv4_port_set(table, name) { + return nft_create_set(table, name, "{ type ipv4_addr . inet_service; flags interval; auto-merge; }"); +} + +function nft_create_ipv6_port_set(table, name) { + return nft_create_set(table, name, "{ type ipv6_addr . inet_service; flags interval; auto-merge; }"); +} + +function nft_create_ifname_set(table, name) { + return nft_create_set(table, name, "{ type ifname; flags interval; }"); +} + +function nft_add_set_elements(table, set_name, elements) { + return run_args([ "nft", "add", "element", "inet", table, set_name, "{ " + as_string(elements) + " }" ]); +} + +function whitespace_values(value) { + let result = []; + + for (let item in split(replace(as_string(value), /[[:space:]]+/g, " "), " ")) { + item = trim(item); + if (item != "") + push(result, item); + } + + return result; +} + +function nft_create_chain(table, name, definition) { + return run_args([ "nft", "add", "chain", "inet", table, name, definition ]); +} + +function nft_add_rule(table, chain, args) { + let command = [ "nft", "add", "rule", "inet", table, chain ]; + for (let arg in args) + push(command, arg); + return run_args(command); +} + +function nft_insert_rule(table, chain, args) { + let command = [ "nft", "insert", "rule", "inet", table, chain ]; + for (let arg in args) + push(command, arg); + return run_args(command); +} + +let LOCALV4_RANGES = [ + "0.0.0.0/8", + "10.0.0.0/8", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0-255.255.255.255" +]; + +let LOCALV6_RANGES = [ + "::/128", + "::1/128", + "64:ff9b::/96", + "100::/64", + "2001:db8::/32", + "fc00::/7", + "fe80::/10", + "ff00::/8" +]; + +function default_arg(value, fallback) { + value = as_string(value); + return value == "" ? fallback : value; +} + +function combined_domain_condition_text(section) { + if (type(object_or_empty(section)["domain"]) != "array") { + let value = option(section, "domain", ""); + if (value != "") + return value; + } + + return option(section, "domain_suffix_text", ""); +} + +function section_rule_condition_csv(section, key, kind) { + return rule_condition_csv_value( + key, + kind, + option(section, key + "_text_mode", "0"), + option(section, "conditions_text_mode", "0"), + option(section, key + "_text", ""), + option(section, key, ""), + combined_domain_condition_text(section), + option(section, "domain_suffix", "") + ); +} + +function section_rule_ports_csv(section) { + return rule_ports_csv_value(option(section, "ports", ""), option(section, "ports_text", "")); +} + +function section_option_nonempty(section, key) { + return option(section, key, "") != ""; +} + +function section_has_destination_matchers(section) { + return section_rule_condition_csv(section, "domain", "domains") != "" || + section_rule_condition_csv(section, "domain_suffix", "domains") != "" || + section_rule_condition_csv(section, "domain_keyword", "generic") != "" || + section_rule_condition_csv(section, "domain_regex", "generic") != "" || + section_rule_condition_csv(section, "ip_cidr", "subnets") != "" || + length(connections.community_lists(section)) > 0 || + length(connections.rule_sets(section)) > 0 || + length(connections.rule_sets_with_subnets(section)) > 0 || + section_option_nonempty(section, "domain_ip_lists"); +} + +function section_action(section) { + return option(section, "action", ""); +} + +function action_captures_traffic(action) { + return action == "connection" || action == "proxy" || action == "outbound" || action == "vpn" || + action == "block" || action == "zapret" || action == "zapret2" || action == "byedpi"; +} + +function section_priority_action(section) { + let action = section_action(section); + if (action == "bypass") + return "bypass"; + if (action_captures_traffic(action)) + return "capture"; + return ""; +} + +function section_priority_prefix(section) { + return "tachyon_rule_" + as_string(section[".name"]); +} + +function section_priority_sets(section) { + let prefix = section_priority_prefix(section); + return { + subnets: prefix + "_subnets", + subnets6: prefix + "_subnets6", + ports: prefix + "_ports", + ip_ports: prefix + "_ip_ports", + ip6_ports: prefix + "_ip6_ports", + sources: prefix + "_sources", + sources6: prefix + "_sources6" + }; +} + +function section_source_ip_values(section) { + return section_rule_condition_csv(section, "source_ip_cidr", "subnets"); +} + +function section_has_source_ip_matchers(section) { + return section_source_ip_values(section) != ""; +} + +function section_has_subnet_update_sources(section) { + return rule_config.has_community_subnet_list(connections.community_lists_value(section)) || + length(connections.rule_sets_with_subnets(section)) > 0 || + option(section, "domain_ip_lists", "") != ""; +} + +function section_has_nft_ip_matchers(section) { + return section_rule_condition_csv(section, "ip_cidr", "subnets") != "" || + section_has_subnet_update_sources(section); +} + +function section_has_nft_port_only_matchers(section) { + return section_rule_ports_csv(section) != "" && !section_has_destination_matchers(section); +} + +function section_priority_needs_plain_ip_rules(section) { + return section_has_nft_ip_matchers(section) && section_rule_ports_csv(section) == ""; +} + +function section_priority_needs_ip_port_rules(section) { + return section_has_nft_ip_matchers(section) && + (section_rule_ports_csv(section) != "" || length(connections.rule_sets_with_subnets(section)) > 0); +} + +function section_needs_priority_sets(section) { + return section_priority_action(section) != "" && + (section_has_nft_ip_matchers(section) || section_has_nft_port_only_matchers(section)); +} + +function nft_create_priority_chains(table) { + return nft_create_chain(table, "priority_rules", "{ }") && + nft_create_chain(table, "priority_output_rules", "{ }"); +} + +function nft_create_priority_sets(table, sets) { + return nft_create_ipv4_set(table, sets.subnets) && + nft_create_ipv6_set(table, sets.subnets6) && + nft_create_inet_service_set(table, sets.ports) && + nft_create_ipv4_port_set(table, sets.ip_ports) && + nft_create_ipv6_port_set(table, sets.ip6_ports) && + nft_create_ipv4_set(table, sets.sources) && + nft_create_ipv6_set(table, sets.sources6); +} + +function nft_priority_verdict_args(priority_action, mark) { + if (priority_action == "bypass") + return [ "counter", "accept" ]; + return [ "meta", "mark", "set", mark, "counter", "accept" ]; +} + +function append_array(target, additions) { + for (let item in additions) + push(target, item); + return target; +} + +function nft_source_match_args(section, family, sets) { + if (!section_has_source_ip_matchers(section)) + return []; + return family == 6 + ? [ "ip6", "saddr", "@" + as_string(sets.sources6) ] + : [ "ip", "saddr", "@" + as_string(sets.sources) ]; +} + +function nft_priority_rule_args(section, family, local_set, match_args, mark) { + let sets = section_priority_sets(section); + let args = []; + if (family == 4) + append_array(args, nft_source_match_args(section, 4, sets)); + else + append_array(args, nft_source_match_args(section, 6, sets)); + append_array(args, [ family == 6 ? "ip6" : "ip", "daddr", "!=", "@" + as_string(local_set) ]); + append_array(args, match_args); + append_array(args, nft_priority_verdict_args(section_priority_action(section), mark)); + return args; +} + +function nft_priority_prerouting_args(section, family, interface_set, local_set, match_args, mark) { + let args = [ "iifname", "@" + as_string(interface_set) ]; + append_array(args, nft_priority_rule_args(section, family, local_set, match_args, mark)); + return args; +} + +function nft_add_priority_rule_pair(table, chain, section, interface_set, localv4_set, localv6_set, match4, match6, mark) { + if (chain == "priority_rules") { + return nft_add_rule(table, chain, nft_priority_prerouting_args(section, 4, interface_set, localv4_set, match4, mark)) && + nft_add_rule(table, chain, nft_priority_prerouting_args(section, 6, interface_set, localv6_set, match6, mark)); + } + + return nft_add_rule(table, chain, nft_priority_rule_args(section, 4, localv4_set, match4, mark)) && + nft_add_rule(table, chain, nft_priority_rule_args(section, 6, localv6_set, match6, mark)); +} + +function nft_add_section_priority_rules(table, section, interface_set, localv4_set, localv6_set, mark) { + if (!section_needs_priority_sets(section)) + return true; + + let sets = section_priority_sets(section); + if (!nft_create_priority_sets(table, sets)) + return false; + + let needs_plain_ip_rules = section_priority_needs_plain_ip_rules(section); + let needs_ip_port_rules = section_priority_needs_ip_port_rules(section); + let has_port_only_matchers = section_has_nft_port_only_matchers(section); + let match_ip4 = [ "ip", "daddr", "@" + as_string(sets.subnets) ]; + let match_ip6 = [ "ip6", "daddr", "@" + as_string(sets.subnets6) ]; + let match_ip_port4_tcp = [ "ip", "daddr", ".", "tcp", "dport", "@" + as_string(sets.ip_ports) ]; + let match_ip_port4_udp = [ "ip", "daddr", ".", "udp", "dport", "@" + as_string(sets.ip_ports) ]; + let match_ip_port6_tcp = [ "ip6", "daddr", ".", "tcp", "dport", "@" + as_string(sets.ip6_ports) ]; + let match_ip_port6_udp = [ "ip6", "daddr", ".", "udp", "dport", "@" + as_string(sets.ip6_ports) ]; + let match_port4_tcp = [ "tcp", "dport", "@" + as_string(sets.ports) ]; + let match_port4_udp = [ "udp", "dport", "@" + as_string(sets.ports) ]; + let match_port6_tcp = [ "tcp", "dport", "@" + as_string(sets.ports) ]; + let match_port6_udp = [ "udp", "dport", "@" + as_string(sets.ports) ]; + + if (needs_plain_ip_rules && + (!nft_add_priority_rule_pair(table, "priority_rules", section, interface_set, localv4_set, localv6_set, match_ip4, match_ip6, mark) || + !nft_add_priority_rule_pair(table, "priority_output_rules", section, interface_set, localv4_set, localv6_set, match_ip4, match_ip6, mark))) + return false; + + if (needs_ip_port_rules && + (!nft_add_priority_rule_pair(table, "priority_rules", section, interface_set, localv4_set, localv6_set, match_ip_port4_tcp, match_ip_port6_tcp, mark) || + !nft_add_priority_rule_pair(table, "priority_rules", section, interface_set, localv4_set, localv6_set, match_ip_port4_udp, match_ip_port6_udp, mark) || + !nft_add_priority_rule_pair(table, "priority_output_rules", section, interface_set, localv4_set, localv6_set, match_ip_port4_tcp, match_ip_port6_tcp, mark) || + !nft_add_priority_rule_pair(table, "priority_output_rules", section, interface_set, localv4_set, localv6_set, match_ip_port4_udp, match_ip_port6_udp, mark))) + return false; + + if (has_port_only_matchers && + (!nft_add_priority_rule_pair(table, "priority_rules", section, interface_set, localv4_set, localv6_set, match_port4_tcp, match_port6_tcp, mark) || + !nft_add_priority_rule_pair(table, "priority_rules", section, interface_set, localv4_set, localv6_set, match_port4_udp, match_port6_udp, mark) || + !nft_add_priority_rule_pair(table, "priority_output_rules", section, interface_set, localv4_set, localv6_set, match_port4_tcp, match_port6_tcp, mark) || + !nft_add_priority_rule_pair(table, "priority_output_rules", section, interface_set, localv4_set, localv6_set, match_port4_udp, match_port6_udp, mark))) + return false; + + return true; +} + +function nft_add_section_priority_rules_from_sections(sections, table, interface_set, localv4_set, localv6_set, mark) { + localv6_set = default_arg(localv6_set, "localv6"); + for (let section in sections) { + section = object_or_empty(section); + if (!bool_option(section, "enabled", true)) + continue; + if (!nft_add_section_priority_rules(table, section, interface_set, localv4_set, localv6_set, mark)) + return false; + } + return true; +} + +function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_port_set, interface_set, source_interfaces, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, exclude_ntp, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address) { + localv6_set = default_arg(localv6_set, "localv6"); + common6_set = default_arg(common6_set, "tachyon_subnets6"); + ip_port6_set = default_arg(ip_port6_set, "tachyon_ip6_ports"); + fakeip6_range = default_arg(fakeip6_range, "fc00::/18"); + tproxy6_address = default_arg(tproxy6_address, "::1"); + + if (!nft_create_table(table) || + !nft_create_ipv4_set(table, localv4_set) || + !nft_add_set_elements(table, localv4_set, join(",", LOCALV4_RANGES)) || + !nft_create_ipv6_set(table, localv6_set) || + !nft_add_set_elements(table, localv6_set, join(",", LOCALV6_RANGES)) || + !nft_create_ipv4_set(table, common_set) || + !nft_create_ipv6_set(table, common6_set) || + !nft_create_inet_service_set(table, port_set) || + !nft_create_ipv4_port_set(table, ip_port_set) || + !nft_create_ipv6_port_set(table, ip_port6_set) || + !nft_create_ifname_set(table, interface_set)) + return false; + + for (let interface in whitespace_values(source_interfaces)) + if (!nft_add_set_elements(table, interface_set, interface)) + return false; + + if (!nft_create_chain(table, "mangle", "{ type filter hook prerouting priority -150; policy accept; }") || + !nft_create_chain(table, "mangle_output", "{ type route hook output priority -150; policy accept; }") || + !nft_create_priority_chains(table) || + !nft_create_chain(table, "proxy", "{ type filter hook prerouting priority -100; policy accept; }")) + return false; + + if (!nft_add_rule(table, "mangle", [ "ct", "status", "dnat", "return" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(localv4_set), "return" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", "@" + as_string(localv6_set), "return" ]) || + !nft_add_rule(table, "mangle", [ "jump", "priority_rules" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(common_set), "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(common_set), "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", "@" + as_string(common6_set), "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", "@" + as_string(common6_set), "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", ".", "tcp", "dport", "@" + as_string(ip_port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", ".", "udp", "dport", "@" + as_string(ip_port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", ".", "tcp", "dport", "@" + as_string(ip_port6_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", ".", "udp", "dport", "@" + as_string(ip_port6_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "!=", "@" + as_string(localv4_set), "tcp", "dport", "@" + as_string(port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "!=", "@" + as_string(localv4_set), "udp", "dport", "@" + as_string(port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", "!=", "@" + as_string(localv6_set), "tcp", "dport", "@" + as_string(port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", "!=", "@" + as_string(localv6_set), "udp", "dport", "@" + as_string(port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", fakeip_range, "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", fakeip_range, "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", fakeip6_range, "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", fakeip6_range, "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "proxy", [ "meta", "mark", "&", fakeip_mark, "==", fakeip_mark, "meta", "l4proto", "tcp", "tproxy", "ip", "to", ":" + as_string(tproxy_port), "counter" ]) || + !nft_add_rule(table, "proxy", [ "meta", "mark", "&", fakeip_mark, "==", fakeip_mark, "meta", "l4proto", "udp", "tproxy", "ip", "to", ":" + as_string(tproxy_port), "counter" ]) || + !nft_add_rule(table, "proxy", [ "meta", "mark", "&", fakeip_mark, "==", fakeip_mark, "meta", "l4proto", "tcp", "tproxy", "ip6", "to", core_ip.format_ipv6_tproxy_target(tproxy6_address, tproxy_port), "counter" ]) || + !nft_add_rule(table, "proxy", [ "meta", "mark", "&", fakeip_mark, "==", fakeip_mark, "meta", "l4proto", "udp", "tproxy", "ip6", "to", core_ip.format_ipv6_tproxy_target(tproxy6_address, tproxy_port), "counter" ]) || + !nft_add_rule(table, "mangle_output", [ "ip", "daddr", "@" + as_string(localv4_set), "return" ]) || + !nft_add_rule(table, "mangle_output", [ "ip6", "daddr", "@" + as_string(localv6_set), "return" ]) || + !nft_add_rule(table, "mangle_output", [ "meta", "mark", outbound_mark, "counter", "return" ]) || + !nft_add_rule(table, "mangle_output", [ "jump", "priority_output_rules" ])) + return false; + + if (arg_bool(exclude_ntp) && !nft_insert_rule(table, "mangle", [ "udp", "dport", "123", "return" ])) + return false; + + return true; +} + +function nft_create_runtime_base_from_uci(table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address) { + let settings = uci_settings(); + + return nft_create_runtime_base( + table, + localv4_set, + common_set, + port_set, + ip_port_set, + interface_set, + option(settings, "source_network_interfaces", "br-lan"), + fakeip_mark, + outbound_mark, + fakeip_range, + tproxy_port, + option(settings, "exclude_ntp", "0"), + localv6_set, + common6_set, + ip_port6_set, + fakeip6_range, + tproxy6_address + ); +} + +function nft_create_runtime_output_rules(table, localv4_set, common_set, port_set, ip_port_set, fakeip_mark, fakeip_range, localv6_set, common6_set, ip_port6_set, fakeip6_range) { + localv6_set = default_arg(localv6_set, "localv6"); + common6_set = default_arg(common6_set, "tachyon_subnets6"); + ip_port6_set = default_arg(ip_port6_set, "tachyon_ip6_ports"); + fakeip6_range = default_arg(fakeip6_range, "fc00::/18"); + + return ( + nft_add_rule(table, "mangle_output", [ "ip", "daddr", "@" + as_string(common_set), "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip", "daddr", "@" + as_string(common_set), "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip6", "daddr", "@" + as_string(common6_set), "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip6", "daddr", "@" + as_string(common6_set), "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip", "daddr", ".", "tcp", "dport", "@" + as_string(ip_port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip", "daddr", ".", "udp", "dport", "@" + as_string(ip_port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip6", "daddr", ".", "tcp", "dport", "@" + as_string(ip_port6_set), "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip6", "daddr", ".", "udp", "dport", "@" + as_string(ip_port6_set), "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "tcp", "dport", "@" + as_string(port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "udp", "dport", "@" + as_string(port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip", "daddr", fakeip_range, "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip", "daddr", fakeip_range, "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip6", "daddr", fakeip6_range, "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip6", "daddr", fakeip6_range, "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) + ); +} + +function hex_digit_value(value) { + let pos = index("0123456789abcdef", lc(as_string(value))); + return pos >= 0 ? pos : null; +} + +function parse_mark_number(value) { + value = lc(trim(as_string(value))); + if (value == "") + return null; + + if (substr(value, 0, 2) == "0x") { + value = substr(value, 2); + if (value == "") + return null; + + let result = 0; + for (let i = 0; i < length(value); i++) { + let digit = hex_digit_value(substr(value, i, 1)); + if (digit == null) + return null; + result = result * 16 + digit; + } + return result; + } + + return match(value, /^[0-9]+$/) == null ? null : int(value); +} + +function nft_provider_mark_hex(route_mark_base, index) { + let base = parse_mark_number(route_mark_base); + index = int(index || 0); + if (base == null || index < 1) + return ""; + + return sprintf("0x%08x", base + index); +} + +function nft_create_provider_output_rules_from_sections(sections, table, action, provider_bin, route_mark_base, queue_base, desync_mark, desync_mark_postnat) { + if (!file_executable(provider_bin)) + return true; + + let index = 0; + let added = false; + + for (let section in sections) { + section = object_or_empty(section); + if (!bool_option(section, "enabled", true) || option(section, "action", "") != action) + continue; + + index++; + let mark_hex = nft_provider_mark_hex(route_mark_base, index); + let queue_number = int(queue_base || 0) + index - 1; + if (mark_hex == "" || queue_number < 0) + return false; + + if (!added) { + if (!nft_add_rule(table, "mangle_output", [ "meta", "mark", "&", desync_mark, "==", desync_mark, "return" ]) || + !nft_add_rule(table, "mangle_output", [ "meta", "mark", "&", desync_mark_postnat, "==", desync_mark_postnat, "return" ])) + return false; + added = true; + } + + if (!nft_add_rule(table, "mangle_output", [ "meta", "mark", mark_hex, "meta", "l4proto", "tcp", "counter", "queue", "num", queue_number, "bypass" ]) || + !nft_add_rule(table, "mangle_output", [ "meta", "mark", mark_hex, "meta", "l4proto", "udp", "counter", "queue", "num", queue_number, "bypass" ])) + return false; + } + + return true; +} + +function nft_write_chunk(chunks, chunk) { + if (length(chunk) > 0) + push(chunks, "" + length(chunk) + "\t" + join(",", chunk)); +} + +function nft_push_chunk_value(chunks, chunk, value, chunk_size) { + push(chunk, value); + if (length(chunk) < chunk_size) + return chunk; + + nft_write_chunk(chunks, chunk); + return []; +} + +function nft_invalid(invalid, value, message) { + push(invalid, as_string(value) + "\t" + message); +} + +function nft_trimmed_lines(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let result = []; + for (let line in split(as_string(data), "\n")) { + line = trim(replace(as_string(line), /\r/g, "")); + if (line != "") + push(result, line); + } + + return result; +} + +function nft_chunk_size(value) { + value = int(value || 5000); + return value > 0 ? value : 5000; +} + +function nft_csv_values(csv) { + let result = []; + + for (let item in split(as_string(csv), ",")) { + item = trim(replace(as_string(item), /\r/g, "")); + if (item != "") + push(result, item); + } + + return result; +} + +function nft_build_chunks_from_values(values, kind, ports_csv, chunk_size_text, family_filter) { + let chunk_size = nft_chunk_size(chunk_size_text); + let chunks = []; + let invalid = []; + let chunk = []; + let ports = split(as_string(ports_csv), ","); + family_filter = int(family_filter || 0); + + for (let line in values) { + if (kind == "ports") { + let port = normalize_port_condition_value(line); + if (port == null) { + nft_invalid(invalid, line, "is not a valid port or port range"); + continue; + } + chunk = nft_push_chunk_value(chunks, chunk, port, chunk_size); + continue; + } + + if (kind == "ip-ports") { + let separator = index(line, " . "); + let last_separator = rindex(line, " . "); + if (separator < 0 || last_separator < 0) { + nft_invalid(invalid, line, "is not an IP/CIDR and port nft tuple"); + continue; + } + + let ip = substr(line, 0, separator); + let port = substr(line, last_separator + 3); + let original_port = port; + if (!nft_ip_or_cidr(ip)) { + nft_invalid(invalid, ip, "is not IP or CIDR"); + continue; + } + + if (family_filter != 0 && core_ip.ip_family(ip) != family_filter) + continue; + + port = normalize_port_condition_value(port); + if (port == null) { + nft_invalid(invalid, original_port, "is not a valid port or port range"); + continue; + } + + chunk = nft_push_chunk_value(chunks, chunk, ip + " . " + port, chunk_size); + continue; + } + + if (!nft_ip_or_cidr(line)) { + nft_invalid(invalid, line, "is not IP or CIDR"); + continue; + } + + if (family_filter != 0 && core_ip.ip_family(line) != family_filter) + continue; + + if (kind == "ip-port-from-ip") { + for (let port in ports) { + if (port == "") + continue; + + let normalized = normalize_port_condition_value(port); + if (normalized == null) { + nft_invalid(invalid, port, "is not a valid port or port range"); + continue; + } + + chunk = nft_push_chunk_value(chunks, chunk, line + " . " + normalized, chunk_size); + } + } + else if (kind == "ips") { + chunk = nft_push_chunk_value(chunks, chunk, line, chunk_size); + } + else { + exit(1); + } + } + + nft_write_chunk(chunks, chunk); + + return { + chunks: chunks, + invalid: invalid + }; +} + +function nft_build_chunks(path, kind, ports_csv, chunk_size_text) { + return nft_build_chunks_from_values(nft_trimmed_lines(path), kind, ports_csv, chunk_size_text, 0); +} + +function nft_prepare_chunks(path, kind, ports_csv, chunk_size_text, chunks_path, invalid_path) { + let prepared = nft_build_chunks(path, kind, ports_csv, chunk_size_text); + + if (!write_text_file(chunks_path, length(prepared.chunks) > 0 ? join("\n", prepared.chunks) + "\n" : "")) + exit(1); + if (!write_text_file(invalid_path, length(prepared.invalid) > 0 ? join("\n", prepared.invalid) + "\n" : "")) + exit(1); +} + +function nft_log_invalid_elements(invalid) { + for (let item in invalid) { + let separator = index(item, "\t"); + if (separator < 0) + continue; + + let value = substr(item, 0, separator); + let message = substr(item, separator + 1); + if (value != "") + log_debug("'" + value + "' " + message); + } +} + +function nft_add_chunks_to_set(table, set_name, chunks, invalid) { + nft_log_invalid_elements(invalid); + + for (let item in chunks) { + let separator = index(item, "\t"); + if (separator < 0) + continue; + + let count = substr(item, 0, separator); + let elements = substr(item, separator + 1); + if (elements == "") + continue; + + log_debug("Adding " + count + " elements to nft set " + set_name); + if (!nft_add_set_elements(table, set_name, elements)) + return false; + } + + return true; +} + +function nft_add_file_chunks_to_set(path, table, set_name, kind, ports_csv, chunk_size_text, family_filter) { + let prepared = nft_build_chunks_from_values(nft_trimmed_lines(path), kind, ports_csv, chunk_size_text, family_filter); + return nft_add_chunks_to_set(table, set_name, prepared.chunks, prepared.invalid); +} + +function nft_add_csv_chunks_to_set(csv, table, set_name, kind, ports_csv, chunk_size_text, family_filter) { + let prepared = nft_build_chunks_from_values(nft_csv_values(csv), kind, ports_csv, chunk_size_text, family_filter); + return nft_add_chunks_to_set(table, set_name, prepared.chunks, prepared.invalid); +} + +function nft_add_file_chunks_to_family_sets(path, table, ipv4_set, ipv6_set, kind, ports_csv, chunk_size_text) { + return nft_add_file_chunks_to_set(path, table, ipv4_set, kind, ports_csv, chunk_size_text, 4) && + nft_add_file_chunks_to_set(path, table, ipv6_set, kind, ports_csv, chunk_size_text, 6); +} + +function nft_add_csv_chunks_to_family_sets(csv, table, ipv4_set, ipv6_set, kind, ports_csv, chunk_size_text) { + return nft_add_csv_chunks_to_set(csv, table, ipv4_set, kind, ports_csv, chunk_size_text, 4) && + nft_add_csv_chunks_to_set(csv, table, ipv6_set, kind, ports_csv, chunk_size_text, 6); +} + +function nft_add_inline_ip_cidr_matchers(csv, ports_csv, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set) { + if (as_string(csv) == "") + return true; + + if (as_string(ports_csv) != "") + return nft_add_csv_chunks_to_family_sets(csv, table, ip_port_set, default_arg(ip_port6_set, "tachyon_ip6_ports"), "ip-port-from-ip", ports_csv, chunk_size_text); + + return nft_add_csv_chunks_to_family_sets(csv, table, common_set, default_arg(common6_set, "tachyon_subnets6"), "ips", "", chunk_size_text); +} + +function nft_insert_fully_routed_ip_rules(source_ip, table, interface_set, localv4_set, localv6_set, mark) { + let family = core_ip.ip_family(source_ip); + let ip_key = family == 6 ? "ip6" : "ip"; + let local_set = family == 6 ? default_arg(localv6_set, "localv6") : localv4_set; + + if (family == 0) + return true; + + return ( + run_args([ "nft", "insert", "rule", "inet", table, "mangle", "iifname", "@" + as_string(interface_set), ip_key, "saddr", source_ip, "meta", "l4proto", "tcp", "meta", "mark", "set", mark, "counter" ]) && + run_args([ "nft", "insert", "rule", "inet", table, "mangle", "iifname", "@" + as_string(interface_set), ip_key, "saddr", source_ip, "meta", "l4proto", "udp", "meta", "mark", "set", mark, "counter" ]) && + run_args([ "nft", "insert", "rule", "inet", table, "mangle", ip_key, "saddr", source_ip, ip_key, "daddr", "@" + as_string(local_set), "return" ]) + ); +} + +function nft_source_ip_display_value(source_ip) { + source_ip = as_string(source_ip); + let suffix = "/32"; + + if (length(source_ip) > length(suffix) && + substr(source_ip, length(source_ip) - length(suffix), length(suffix)) == suffix) { + let address = substr(source_ip, 0, length(source_ip) - length(suffix)); + if (valid_ipv4(address)) + return address; + } + + suffix = "/128"; + if (length(source_ip) > length(suffix) && + substr(source_ip, length(source_ip) - length(suffix), length(suffix)) == suffix) { + let address = substr(source_ip, 0, length(source_ip) - length(suffix)); + if (core_ip.valid_ipv6(address)) + return address; + } + + return source_ip; +} + +function nft_chain_has_source_ip(chain_text, source_ip) { + chain_text = as_string(chain_text); + source_ip = as_string(source_ip); + + let ip_key = core_ip.ip_family(source_ip) == 6 ? "ip6" : "ip"; + + if (index(chain_text, ip_key + " saddr " + source_ip) >= 0) + return true; + + let display_source_ip = nft_source_ip_display_value(source_ip); + return display_source_ip != source_ip && index(chain_text, ip_key + " saddr " + display_source_ip) >= 0; +} + +function nft_ensure_fully_routed_ip_rules_from_chain(source_ip, table, interface_set, localv4_set, localv6_set, mark, chain_text, inserted) { + source_ip = as_string(source_ip); + if (source_ip == "") + return true; + + if (inserted[source_ip] || nft_chain_has_source_ip(chain_text, source_ip)) + return true; + + if (!nft_insert_fully_routed_ip_rules(source_ip, table, interface_set, localv4_set, localv6_set, mark)) + return false; + + inserted[source_ip] = true; + return true; +} + +function normalized_fields(line) { + line = trim(replace(as_string(line), /\r/g, "")); + line = replace(line, /[[:space:]]+/g, " "); + return line == "" ? [] : split(line, " "); +} + +function rule_line_has_lookup_table(fields, table) { + table = as_string(table); + + for (let i = 0; i + 1 < length(fields); i++) + if (fields[i] == "lookup" && fields[i + 1] == table) + return true; + + return false; +} + +function rule_line_has_fwmark(fields, expected_mark) { + for (let i = 0; i + 1 < length(fields); i++) { + if (fields[i] != "fwmark") + continue; + + let parts = split(fields[i + 1], "/"); + if (length(parts) != 2) + continue; + + if (parse_mark_number(parts[0]) == expected_mark && parse_mark_number(parts[1]) == expected_mark) + return true; + } + + return false; +} + +function has_tproxy_marking_rule_text(rule_list, table, mark) { + let expected_mark = parse_mark_number(mark); + let has_lookup = false; + let has_fwmark = false; + + if (expected_mark == null) + return false; + + for (let line in split(rule_list, "\n")) { + let fields = normalized_fields(line); + if (length(fields) == 0) + continue; + + if (!has_lookup && rule_line_has_lookup_table(fields, table)) + has_lookup = true; + if (!has_fwmark && rule_line_has_fwmark(fields, expected_mark)) + has_fwmark = true; + + if (has_lookup && has_fwmark) + return true; + } + + return false; +} + +function has_local_default_route_text(route_list, family) { + family = int(family || 4); + + for (let line in split(as_string(route_list), "\n")) { + line = trim(replace(as_string(line), /\r/g, "")); + line = replace(line, /[[:space:]]+/g, " "); + if (family == 4 && index(line, "local default dev lo scope host") >= 0) + return true; + if (family == 6 && (index(line, "local ::") >= 0 || index(line, "local default") >= 0) && index(line, " dev lo") >= 0) + return true; + } + + return false; +} + +function rt_table_has_entry(text, table_id, table_name) { + table_id = as_string(table_id); + table_name = as_string(table_name); + + for (let line in split(as_string(text), "\n")) { + let fields = normalized_fields(line); + if (length(fields) >= 2 && fields[0] == table_id && fields[1] == table_name) + return true; + } + + return false; +} + +function ensure_rt_table_entry(path, table_id, table_name) { + let data = fs.readfile(path); + if (data != null && rt_table_has_entry(data, table_id, table_name)) + return true; + + data = data == null ? "" : as_string(data); + if (data != "" && substr(data, length(data) - 1, 1) != "\n") + data += "\n"; + + return write_text_file(path, data + as_string(table_id) + " " + as_string(table_name) + "\n"); +} + +function tproxy_route4_present(table) { + return has_local_default_route_text(command_output_quiet_from_args([ "ip", "route", "list", "table", table ]), 4); +} + +function tproxy_route6_present(table) { + return has_local_default_route_text(command_output_quiet_from_args([ "ip", "-6", "route", "list", "table", table ]), 6); +} + +function tproxy_route_present(table) { + return tproxy_route4_present(table) && tproxy_route6_present(table); +} + +function tproxy_marking_rule4_present(table, mark) { + return has_tproxy_marking_rule_text(command_output_from_args([ "ip", "-4", "rule", "list" ]), table, mark); +} + +function tproxy_marking_rule6_present(table, mark) { + return has_tproxy_marking_rule_text(command_output_from_args([ "ip", "-6", "rule", "list" ]), table, mark); +} + +function tproxy_marking_rule_present(table, mark) { + return tproxy_marking_rule4_present(table, mark) && tproxy_marking_rule6_present(table, mark); +} + +function tproxy_route_rule_present(table, mark) { + return tproxy_route_present(table) && tproxy_marking_rule_present(table, mark); +} + +function ensure_tproxy_route_rule(table, mark, rt_tables_path) { + rt_tables_path = as_string(rt_tables_path || "/etc/iproute2/rt_tables"); + + if (!ensure_rt_table_entry(rt_tables_path, "105", table)) { + log_fatal("Failed to update route table registry. Aborted."); + return false; + } + + if (!tproxy_route4_present(table)) { + log_debug("Added IPv4 TPROXY route"); + if (!run_args([ "ip", "route", "add", "local", "0.0.0.0/0", "dev", "lo", "table", table ]) && !tproxy_route4_present(table)) { + log_fatal("Failed to add IPv4 route for tproxy. Aborted."); + return false; + } + } + else { + log_debug("IPv4 TPROXY route already exists"); + } + + if (!tproxy_route6_present(table)) { + log_debug("Added IPv6 TPROXY route"); + if (!run_args([ "ip", "-6", "route", "add", "local", "::/0", "dev", "lo", "table", table ]) && !tproxy_route6_present(table)) { + log_fatal("Failed to add IPv6 route for tproxy. Aborted."); + return false; + } + } + else { + log_debug("IPv6 TPROXY route already exists"); + } + + if (!tproxy_marking_rule4_present(table, mark)) { + log_debug("Creating IPv4 TPROXY marking rule"); + if (!run_args([ "ip", "-4", "rule", "add", "fwmark", as_string(mark) + "/" + as_string(mark), "table", table, "priority", "105" ]) && !tproxy_marking_rule4_present(table, mark)) { + log_fatal("Failed to create IPv4 marking rule. Aborted."); + return false; + } + } + else { + log_debug("IPv4 TPROXY marking rule already exists"); + } + + if (!tproxy_marking_rule6_present(table, mark)) { + log_debug("Creating IPv6 TPROXY marking rule"); + if (!run_args([ "ip", "-6", "rule", "add", "fwmark", as_string(mark) + "/" + as_string(mark), "table", table, "priority", "105" ]) && !tproxy_marking_rule6_present(table, mark)) { + log_fatal("Failed to create IPv6 marking rule. Aborted."); + return false; + } + } + else { + log_debug("IPv6 TPROXY marking rule already exists"); + } + + return true; +} + +function ensure_bridge_netfilter_disabled() { + if (index(command_output_from_args([ "lsmod" ]), "br_netfilter") < 0) + return true; + + if (trim(command_output_from_args([ "sysctl", "-n", "net.bridge.bridge-nf-call-iptables" ])) != "1") + return true; + + log_debug("br_netfilter is enabled; disabling it for transparent proxy routing"); + return run_args([ "sysctl", "-w", "net.bridge.bridge-nf-call-iptables=0" ]) && + run_args([ "sysctl", "-w", "net.bridge.bridge-nf-call-ip6tables=0" ]); +} + +function community_service_has_subnet_list(value) { + return rule_config.community_service_has_subnet_list(value); +} + +function filter_community_subnet_lists_value(value) { + return rule_config.filter_community_subnet_lists_value(value); +} + +function signature_add_value(body, key, value) { + return body + "[" + as_string(key) + "]\n" + as_string(value) + "\n"; +} + +function signature_hash(body) { + let path = trim(command_output_from_args([ "mktemp" ])); + if (path == "") + return ""; + + if (!write_text_file(path, body)) { + unlink_file(path); + return ""; + } + + let hash_line = command_output_from_args([ "md5sum", path ]); + unlink_file(path); + hash_line = trim(hash_line); + + return length(hash_line) >= 32 ? substr(hash_line, 0, 32) : ""; +} + +function nft_rule_signature_body(body, section) { + let section_name = as_string(section[".name"]); + + if (section_name == "" || !bool_option(section, "enabled", true)) + return body; + + let action = option(section, "action", ""); + body = signature_add_value(body, "rule." + section_name + ".action", action); + if (action == "dns") + return body; + body = signature_add_value(body, "rule." + section_name + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets")); + body = signature_add_value(body, "rule." + section_name + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets")); + body = signature_add_value(body, "rule." + section_name + ".ports", section_rule_ports_csv(section)); + body = signature_add_value(body, "rule." + section_name + ".fully_routed_ips", option(section, "fully_routed_ips", "")); + body = signature_add_value(body, "rule." + section_name + ".community_subnet_lists", filter_community_subnet_lists_value(connections.community_lists_value(section))); + body = signature_add_value(body, "rule." + section_name + ".remote_subnet_lists", option(section, "remote_subnet_lists", "")); + body = signature_add_value(body, "rule." + section_name + ".rule_set_with_subnets", connections.rule_sets_with_subnets_value(section)); + body = signature_add_value(body, "rule." + section_name + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + + return body; +} + +function nft_runtime_signature_from_settings_and_sections(settings, sections) { + let body = ""; + + body = signature_add_value(body, "settings.source_network_interfaces", option(settings, "source_network_interfaces", "br-lan")); + body = signature_add_value(body, "settings.exclude_ntp", bool_option(settings, "exclude_ntp", false) ? "1" : "0"); + + for (let section in sections) + body = nft_rule_signature_body(body, object_or_empty(section)); + + return signature_hash(body); +} + +function print_nft_runtime_signature_from_settings_and_sections(settings, sections) { + let hash = nft_runtime_signature_from_settings_and_sections(settings, sections); + if (hash == "") + return false; + + print(hash, "\n"); + return true; +} + +function word_set(value) { + let result = {}; + for (let item in whitespace_values(value)) + result[item] = true; + return result; +} + +function fixture_section_list(data, type_name) { + let value = object_or_empty(data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function section_by_name(sections, section_name) { + section_name = as_string(section_name); + for (let section in sections) + if (as_string(section[".name"]) == section_name) + return section; + return null; +} + +function nft_create_provider_output_rules_from_uci(table, action, provider_bin, route_mark_base, queue_base, desync_mark, desync_mark_postnat) { + return nft_create_provider_output_rules_from_sections( + uci_sections("section"), + table, + action, + provider_bin, + route_mark_base, + queue_base, + desync_mark, + desync_mark_postnat + ); +} + +function nft_create_full_runtime_from_uci(rt_table, table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat, zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address) { + log_debug("Building nftables runtime model"); + + return ensure_tproxy_route_rule(rt_table, fakeip_mark) && + nft_create_runtime_base_from_uci(table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address) && + nft_add_section_priority_rules_from_sections(uci_sections("section"), table, interface_set, localv4_set, localv6_set, fakeip_mark) && + nft_create_provider_output_rules_from_uci(table, "zapret", zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat) && + nft_create_provider_output_rules_from_uci(table, "zapret2", zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat) && + nft_create_runtime_output_rules(table, localv4_set, common_set, port_set, ip_port_set, fakeip_mark, fakeip_range, localv6_set, common6_set, ip_port6_set, fakeip6_range); +} + +function nft_table_present(table) { + return run_args_quiet([ "nft", "list", "table", "inet", table ]); +} + +function nft_delete_table(table) { + return run_args([ "nft", "delete", "table", "inet", table ]); +} + +function nft_rebuild_runtime_from_uci(rt_table, table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat, zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address) { + log_debug("Applying nftables runtime rules"); + + if (nft_table_present(table) && !nft_delete_table(table)) + return false; + + return nft_create_full_runtime_from_uci(rt_table, table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat, zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address); +} + +function nft_runtime_signature_from_uci() { + return print_nft_runtime_signature_from_settings_and_sections( + uci_settings(), + uci_sections("section") + ); +} + +function fixture_section(path, section_name) { + let data = object_or_empty(common_read_json_file(path)); + connections.set_item_sections_from_data(data); + return section_by_name(fixture_section_list(data, "section"), section_name); +} + +function fixture_settings(data) { + return object_or_empty(object_or_empty(data).settings); +} + +function nft_runtime_signature_from_fixture(path) { + let data = object_or_empty(common_read_json_file(path)); + connections.set_item_sections_from_data(data); + return print_nft_runtime_signature_from_settings_and_sections(fixture_settings(data), fixture_section_list(data, "section")); +} + +function nft_mangle_chain_text(context, table) { + if (context.text == null) + context.text = command_output_from_args([ "nft", "list", "chain", "inet", table, "mangle" ]); + return context.text; +} + +function nft_add_section_source_matchers(section, table, chunk_size_text) { + let source_values = section_source_ip_values(section); + if (source_values == "") + return true; + + let sets = section_priority_sets(section); + return nft_add_csv_chunks_to_family_sets(source_values, table, sets.sources, sets.sources6, "ips", "", chunk_size_text); +} + +function nft_populate_runtime_set_for_section(section, deferred_sections, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, mangle_chain_context, inserted_fully_routed_ips, common6_set, ip_port6_set, localv6_set) { + if (!bool_option(section, "enabled", true)) + return true; + if (section_action(section) == "dns") + return true; + + let ports = section_rule_ports_csv(section); + let ip_values = section_rule_condition_csv(section, "ip_cidr", "subnets"); + let sets = section_priority_sets(section); + + if (section_needs_priority_sets(section) && !nft_add_section_source_matchers(section, table, 5000)) + return false; + + if (deferred_sections[as_string(section[".name"])]) + return true; + + if (section_needs_priority_sets(section)) { + if (!nft_add_inline_ip_cidr_matchers(ip_values, ports, table, sets.subnets, sets.ip_ports, 5000, sets.subnets6, sets.ip6_ports)) + return false; + + if (ports != "" && !section_has_destination_matchers(section) && + !nft_add_set_elements(table, sets.ports, ports)) + return false; + } + + for (let source_ip in list_option(section, "fully_routed_ips")) + if (!nft_ensure_fully_routed_ip_rules_from_chain(source_ip, table, interface_set, localv4_set, localv6_set, mark, nft_mangle_chain_text(mangle_chain_context, table), inserted_fully_routed_ips)) + return false; + + return true; +} + +function nft_add_subnet_file_for_section(section, filepath, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set) { + let ports = section_rule_ports_csv(section); + let sets = section_priority_sets(section); + + if (!section_needs_priority_sets(section)) + return true; + + if (ports != "") + return nft_add_file_chunks_to_family_sets(filepath, table, sets.ip_ports, sets.ip6_ports, "ip-port-from-ip", ports, chunk_size_text); + + return nft_add_file_chunks_to_family_sets(filepath, table, sets.subnets, sets.subnets6, "ips", "", chunk_size_text); +} + +function file_nonempty(path) { + let stat = fs.stat(as_string(path)); + return stat != null && int(stat.size) > 0; +} + +function nft_add_extracted_ruleset_subnets(unscoped_path, scoped_path, label, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set) { + let has_entries = false; + + if (file_nonempty(unscoped_path)) { + if (!nft_add_file_chunks_to_family_sets(unscoped_path, table, common_set, default_arg(common6_set, "tachyon_subnets6"), "ips", "", chunk_size_text)) + return false; + has_entries = true; + } + + if (file_nonempty(scoped_path)) { + if (!nft_add_file_chunks_to_family_sets(scoped_path, table, ip_port_set, default_arg(ip_port6_set, "tachyon_ip6_ports"), "ip-ports", "", chunk_size_text)) + return false; + has_entries = true; + } + + if (!has_entries) + run_args([ "logger", "-t", "tachyon", "[warn] " + as_string(label) + " has no ip_cidr entries for nftables" ]); + + return true; +} + +function nft_add_json_ruleset_subnets_for_section(section, json_path, label, table, common_set, ip_port_set, unscoped_path, scoped_path, chunk_size_text, common6_set, ip_port6_set) { + let ports = section_rule_ports_csv(section); + let sets = section_priority_sets(section); + + if (!section_needs_priority_sets(section)) + return true; + + routing_rulesets.extract_ip_cidr_nft_elements( + json_path, + unscoped_path, + scoped_path, + sprintf("%J", rule_port_values(ports)), + sprintf("%J", rule_port_ranges(ports)) + ); + + return nft_add_extracted_ruleset_subnets(unscoped_path, scoped_path, label, table, sets.subnets, sets.ip_ports, chunk_size_text, sets.subnets6, sets.ip6_ports); +} + +function nft_add_community_subnet_file_for_section(section, service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text, common6_set, ip_port6_set, discord6_set) { + return nft_add_subnet_file_for_section(section, filepath, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set); +} + +function nft_add_subnet_file_for_uci_section(section_name, filepath, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set) { + return nft_add_subnet_file_for_section(uci_section(section_name), filepath, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set); +} + +function nft_add_json_ruleset_subnets_for_uci_section(section_name, json_path, label, table, common_set, ip_port_set, unscoped_path, scoped_path, chunk_size_text, common6_set, ip_port6_set) { + return nft_add_json_ruleset_subnets_for_section(uci_section(section_name), json_path, label, table, common_set, ip_port_set, unscoped_path, scoped_path, chunk_size_text, common6_set, ip_port6_set); +} + +function nft_add_community_subnet_file_for_uci_section(section_name, service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text, common6_set, ip_port6_set, discord6_set) { + return nft_add_community_subnet_file_for_section(uci_section(section_name), service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text, common6_set, ip_port6_set, discord6_set); +} + +function nft_add_subnet_file_for_fixture_section(fixture_path, section_name, filepath, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set) { + return nft_add_subnet_file_for_section(fixture_section(fixture_path, section_name), filepath, table, common_set, ip_port_set, chunk_size_text, common6_set, ip_port6_set); +} + +function nft_add_json_ruleset_subnets_for_fixture_section(fixture_path, section_name, json_path, label, table, common_set, ip_port_set, unscoped_path, scoped_path, chunk_size_text, common6_set, ip_port6_set) { + return nft_add_json_ruleset_subnets_for_section(fixture_section(fixture_path, section_name), json_path, label, table, common_set, ip_port_set, unscoped_path, scoped_path, chunk_size_text, common6_set, ip_port6_set); +} + +function nft_add_community_subnet_file_for_fixture_section(fixture_path, section_name, service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text, common6_set, ip_port6_set, discord6_set) { + return nft_add_community_subnet_file_for_section(fixture_section(fixture_path, section_name), service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text, common6_set, ip_port6_set, discord6_set); +} + +function nft_populate_runtime_sets_from_sections(sections, populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, common6_set, ip_port6_set, localv6_set) { + if (!arg_bool(populate_enabled)) + return true; + + let deferred_sections = word_set(deferred_section_names); + let mangle_chain_context = {}; + let inserted_fully_routed_ips = {}; + + for (let section in sections) + if (!nft_populate_runtime_set_for_section(section, deferred_sections, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, mangle_chain_context, inserted_fully_routed_ips, common6_set, ip_port6_set, localv6_set)) + return false; + + return true; +} + +function nft_populate_runtime_sets_from_uci(populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, common6_set, ip_port6_set, localv6_set) { + if (!arg_bool(populate_enabled)) + return true; + + return nft_populate_runtime_sets_from_sections(uci_sections("section"), populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, common6_set, ip_port6_set, localv6_set); +} + +function nft_populate_runtime_sets_fixture(path, populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, common6_set, ip_port6_set, localv6_set) { + let data = object_or_empty(common_read_json_file(path)); + connections.set_item_sections_from_data(data); + return nft_populate_runtime_sets_from_sections(fixture_section_list(data, "section"), populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, common6_set, ip_port6_set, localv6_set); +} + +let mode = ARGV[0] || ""; + +if (mode == "text-list-to-csv") + text_list_to_csv(ARGV[1], ARGV[2]); +else if (mode == "csv-to-json-array") + csv_to_json_array(ARGV[1]); +else if (mode == "cache-path") + cache_path(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]); +else if (mode == "list-value-to-csv") + list_value_csv(ARGV[1]); +else if (mode == "csv-list-contains") + exit(csv_list_contains(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "domain-subnet-text-csv") + domain_subnet_text_csv(ARGV[1], ARGV[2]); +else if (mode == "combined-domain-text-csv") + combined_domain_text_csv(ARGV[1], ARGV[2]); +else if (mode == "combined-domain-csv") + combined_domain_csv(ARGV[1], ARGV[2]); +else if (mode == "rule-condition-csv") + rule_condition_csv(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]); +else if (mode == "legacy-condition-csv") + legacy_condition_csv(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "domain-subnet-file-csv") + domain_subnet_file_csv(ARGV[1], ARGV[2]); +else if (mode == "split-domain-subnet-file") + split_domain_subnet_file(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "normalize-port-condition-for-nft") + normalize_port_condition_for_nft(ARGV[1]); +else if (mode == "rule-ports-csv") + rule_ports_csv(ARGV[1], ARGV[2]); +else if (mode == "csv-to-lines-file") + csv_to_lines_file(ARGV[1], ARGV[2]); +else if (mode == "nft-create-runtime-base") + exit(nft_create_runtime_base(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13], ARGV[14], ARGV[15], ARGV[16], ARGV[17]) ? 0 : 1); +else if (mode == "nft-create-runtime-base-from-uci") + exit(nft_create_runtime_base_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13], ARGV[14], ARGV[15]) ? 0 : 1); +else if (mode == "nft-create-runtime-output-rules") + exit(nft_create_runtime_output_rules(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11]) ? 0 : 1); +else if (mode == "nft-create-provider-output-rules-from-uci") + exit(nft_create_provider_output_rules_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]) ? 0 : 1); +else if (mode == "nft-create-provider-output-rules-fixture") + exit(nft_create_provider_output_rules_from_sections(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "section"), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]) ? 0 : 1); +else if (mode == "nft-add-section-priority-rules-fixture") + exit(nft_add_section_priority_rules_from_sections(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "section"), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]) ? 0 : 1); +else if (mode == "nft-create-full-runtime-from-uci") + exit(nft_create_full_runtime_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13], ARGV[14], ARGV[15], ARGV[16], ARGV[17], ARGV[18], ARGV[19], ARGV[20], ARGV[21], ARGV[22], ARGV[23], ARGV[24], ARGV[25], ARGV[26]) ? 0 : 1); +else if (mode == "nft-rebuild-runtime-from-uci") + exit(nft_rebuild_runtime_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13], ARGV[14], ARGV[15], ARGV[16], ARGV[17], ARGV[18], ARGV[19], ARGV[20], ARGV[21], ARGV[22], ARGV[23], ARGV[24], ARGV[25], ARGV[26]) ? 0 : 1); +else if (mode == "nft-prepare-chunks") + nft_prepare_chunks(ARGV[1], ARGV[2], ARGV[3] || "", ARGV[4], ARGV[5], ARGV[6]); +else if (mode == "nft-add-file-chunks-to-set") + exit(nft_add_file_chunks_to_set(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5] || "", ARGV[6]) ? 0 : 1); +else if (mode == "nft-add-subnet-file-for-uci-section") + exit(nft_add_subnet_file_for_uci_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]) ? 0 : 1); +else if (mode == "nft-add-json-ruleset-subnets-for-uci-section") + exit(nft_add_json_ruleset_subnets_for_uci_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11]) ? 0 : 1); +else if (mode == "nft-add-community-subnet-file-for-uci-section") + exit(nft_add_community_subnet_file_for_uci_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13]) ? 0 : 1); +else if (mode == "nft-add-subnet-file-for-section-fixture") + exit(nft_add_subnet_file_for_fixture_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9]) ? 0 : 1); +else if (mode == "nft-add-json-ruleset-subnets-for-section-fixture") + exit(nft_add_json_ruleset_subnets_for_fixture_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12]) ? 0 : 1); +else if (mode == "nft-add-community-subnet-file-for-section-fixture") + exit(nft_add_community_subnet_file_for_fixture_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13], ARGV[14]) ? 0 : 1); +else if (mode == "nft-populate-runtime-sets-from-uci") + exit(nft_populate_runtime_sets_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12]) ? 0 : 1); +else if (mode == "nft-populate-runtime-sets-fixture") + exit(nft_populate_runtime_sets_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13]) ? 0 : 1); +else if (mode == "nft-runtime-signature") + exit(nft_runtime_signature_from_uci() ? 0 : 1); +else if (mode == "nft-runtime-signature-fixture") + exit(nft_runtime_signature_from_fixture(ARGV[1]) ? 0 : 1); +else if (mode == "nft-table-present-fixture") + exit(nft_table_present(ARGV[1]) ? 0 : 1); +else if (mode == "ensure-tproxy-route-rule") + exit(ensure_tproxy_route_rule(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "tproxy-route-present") + exit(tproxy_route_present(ARGV[1]) ? 0 : 1); +else if (mode == "tproxy-route4-present") + exit(tproxy_route4_present(ARGV[1]) ? 0 : 1); +else if (mode == "tproxy-route6-present") + exit(tproxy_route6_present(ARGV[1]) ? 0 : 1); +else if (mode == "tproxy-marking-rule-present") + exit(tproxy_marking_rule_present(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "tproxy-marking-rule4-present") + exit(tproxy_marking_rule4_present(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "tproxy-marking-rule6-present") + exit(tproxy_marking_rule6_present(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "tproxy-route-rule-present") + exit(tproxy_route_rule_present(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "ensure-bridge-netfilter-disabled") + exit(ensure_bridge_netfilter_disabled() ? 0 : 1); +else { + warn("Usage: nft/apply.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/providers/byedpi/runtime.uc b/tachyon/files/usr/lib/providers/byedpi/runtime.uc new file mode 100644 index 00000000..130c257f --- /dev/null +++ b/tachyon/files/usr/lib/providers/byedpi/runtime.uc @@ -0,0 +1,593 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); +let runtime_constants = require("singbox.constants"); +let validator_module = null; + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const BYEDPI_BIN = getenv("BYEDPI_BIN") || "/usr/bin/ciadpi"; +const BYEDPI_SERVICE_INIT = getenv("BYEDPI_SERVICE_INIT") || "/etc/init.d/byedpi"; +const BYEDPI_STATE_DIR = getenv("BYEDPI_STATE_DIR") || "/var/run/tachyon/byedpi"; +const BYEDPI_PID_DIR = getenv("BYEDPI_PID_DIR") || BYEDPI_STATE_DIR + "/pid"; +const BYEDPI_CHILD_PID_DIR = getenv("BYEDPI_CHILD_PID_DIR") || BYEDPI_STATE_DIR + "/child-pid"; +const BYEDPI_LOG_DIR = getenv("BYEDPI_LOG_DIR") || BYEDPI_STATE_DIR + "/log"; +const BYEDPI_LISTEN_ADDRESS = getenv("BYEDPI_LISTEN_ADDRESS") || "127.0.0.1"; +const BYEDPI_PORT_BASE = getenv("BYEDPI_PORT_BASE") || "1080"; +const BYEDPI_RESPAWN_DELAY = getenv("BYEDPI_RESPAWN_DELAY") || "5"; +const BYEDPI_OPEN_FILES_LIMIT = getenv("BYEDPI_OPEN_FILES_LIMIT") || "4096"; +const BYEDPI_DEFAULT_CMD_OPTS = getenv("BYEDPI_DEFAULT_CMD_OPTS") || "-o 2 --auto=t,r,a,s -d 2"; +const SB_TPROXY_INBOUND_TAG = getenv("SB_TPROXY_INBOUND_TAG") || "tproxy-in"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function bool_value(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_exists(name) { + return command_success_from_args([ "command", "-v", name ]); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function bool_option(section, key, fallback) { + let value = object_or_empty(section)[key]; + return value == null ? !!fallback : bool_value(value); +} + +function section_name(section) { + return as_string(object_or_empty(section)[".name"]); +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, as_string(type_name)); +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function validator() { + if (validator_module == null) + validator_module = require("providers.byedpi.validator"); + return validator_module; +} + +function normalize_strategy(value) { + return validator().strategy_or_default(value, BYEDPI_DEFAULT_CMD_OPTS); +} + +function strategy_words(value) { + value = replace(as_string(value), /[\t\r\n]/g, " "); + value = replace(value, / +/g, " "); + value = replace(value, /^ /, ""); + value = replace(value, / $/, ""); + return value == "" ? [] : split(value, " "); +} + +function enabled_byedpi_sections() { + let result = []; + for (let section in uci_sections("section")) + if (bool_option(section, "enabled", true) && option(section, "action", "") == "byedpi") + push(result, section); + return result; +} + +function enabled_rule_count() { + return length(enabled_byedpi_sections()); +} + +function rule_port(index_value) { + return int(BYEDPI_PORT_BASE) + int(index_value) - 1; +} + +function ensure_runtime_dirs() { + return command_success_from_args([ "mkdir", "-p", BYEDPI_STATE_DIR, BYEDPI_PID_DIR, BYEDPI_CHILD_PID_DIR, BYEDPI_LOG_DIR ]); +} + +function provider_available() { + let stat = fs.stat(BYEDPI_BIN); + return stat != null && stat.mode != null && (int(stat.mode) & 73) != 0; +} + +function package_installed() { + if (command_exists("apk") && command_success_from_args([ "apk", "info", "-e", "byedpi" ])) + return true; + if (!command_exists("opkg")) + return false; + + for (let line in split(command_output_from_args([ "opkg", "list-installed" ]), "\n")) { + let matched = match(trim(as_string(line)), /^byedpi[ \t]+-/); + if (matched) + return true; + } + return false; +} + +function first_nonempty_field(value) { + for (let line in split(as_string(value), "\n")) { + line = trim(as_string(line)); + if (line == "") + continue; + let fields = split(line, /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[0]) : ""; + } + return ""; +} + +function package_version() { + let output = ""; + + if (command_exists("apk") && command_success_from_args([ "apk", "info", "-e", "byedpi" ])) { + output = command_output_from_args([ "apk", "list", "--installed", "--manifest", "byedpi" ]); + for (let line in split(output, "\n")) { + let matched = match(trim(as_string(line)), /^P:byedpi$/); + if (!matched) + continue; + for (let manifest_line in split(output, "\n")) { + let version_match = match(trim(as_string(manifest_line)), /^V:(.+)$/); + if (version_match) + return as_string(version_match[1]); + } + } + + output = command_output_from_args([ "apk", "info", "-v", "byedpi" ]); + for (let line in split(output, "\n")) { + line = trim(as_string(line)); + if (line != "") + return replace(line, /^byedpi-/, ""); + } + } + + if (command_exists("opkg")) { + output = command_output_from_args([ "opkg", "list-installed" ]); + for (let line in split(output, "\n")) { + let matched = match(trim(as_string(line)), /^byedpi[ \t]+-[ \t]*(.+)$/); + if (matched) + return as_string(matched[1]); + } + } + + if (provider_available()) + return first_nonempty_field(command_output_from_args([ BYEDPI_BIN, "--version" ])); + return ""; +} + +function standalone_service_enabled() { + return fs.stat(BYEDPI_SERVICE_INIT) != null && command_success_from_args([ BYEDPI_SERVICE_INIT, "enabled" ]); +} + +function standalone_service_running() { + return fs.stat(BYEDPI_SERVICE_INIT) != null && command_success_from_args([ BYEDPI_SERVICE_INIT, "status" ]); +} + +function runtime_pid_running(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function file_first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + let newline = index(data, "\n"); + return trim(newline >= 0 ? substr(data, 0, newline) : data); +} + +function kill_pidfile_process(path, signal) { + let pid = file_first_line(path); + if (pid == "") + return; + if (signal == "9") { + if (runtime_pid_running(pid)) + command_success_from_args([ "kill", "-9", pid ]); + } + else { + command_success_from_args([ "kill", pid ]); + } +} + +function pidfiles_in_dir(path) { + if (fs.stat(path) == null) + return []; + + let output = command_output_from_args([ "find", path, "-maxdepth", "1", "-type", "f", "-name", "*.pid" ]); + let result = []; + for (let line in split(output, "\n")) { + line = trim(as_string(line)); + if (line != "") + push(result, line); + } + return result; +} + +function stop_runtime() { + for (let pidfile in pidfiles_in_dir(BYEDPI_PID_DIR)) + kill_pidfile_process(pidfile, ""); + for (let pidfile in pidfiles_in_dir(BYEDPI_CHILD_PID_DIR)) + kill_pidfile_process(pidfile, ""); + + command_success_from_args([ "sleep", "1" ]); + + for (let pidfile in pidfiles_in_dir(BYEDPI_PID_DIR)) + kill_pidfile_process(pidfile, "9"); + for (let pidfile in pidfiles_in_dir(BYEDPI_CHILD_PID_DIR)) + kill_pidfile_process(pidfile, "9"); + + command_success_from_args([ "rm", "-rf", BYEDPI_PID_DIR, BYEDPI_CHILD_PID_DIR, BYEDPI_LOG_DIR ]); +} + +function supervisor_command(port, raw_opt, child_pidfile) { + let args = [ BYEDPI_BIN, "--ip", BYEDPI_LISTEN_ADDRESS, "--port", as_string(port) ]; + for (let word in strategy_words(raw_opt)) + push(args, word); + + return "ulimit -n " + shell_quote(BYEDPI_OPEN_FILES_LIMIT) + " >/dev/null 2>&1 || true; " + + command_from_args(args) + " & child=$!; echo $child > " + shell_quote(child_pidfile) + "; wait $child; rc=$?; rm -f " + shell_quote(child_pidfile) + "; exit $rc"; +} + +function supervisor(section, port, raw_opt, child_pidfile) { + while (true) { + if (!provider_available()) { + print(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), " Provider ", BYEDPI_BIN, " is not executable; retrying in ", BYEDPI_RESPAWN_DELAY, " seconds\n"); + command_success_from_args([ "sleep", BYEDPI_RESPAWN_DELAY ]); + continue; + } + + let rc = command_status("sh -c " + shell_quote(supervisor_command(port, raw_opt, child_pidfile))); + print(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), " ciadpi for rule ", as_string(section), " exited with code ", rc, "; respawning in ", BYEDPI_RESPAWN_DELAY, " seconds\n"); + command_success_from_args([ "sleep", BYEDPI_RESPAWN_DELAY ]); + } +} + +function start_rule(section, index_value) { + let name = section_name(section); + let port = rule_port(index_value); + let raw_opt = normalize_strategy(option(section, "byedpi_cmd_opts", "")); + let validation = validator().validate_byedpi_strategy(raw_opt); + if (!validation.valid) { + log_message("Invalid ByeDPI strategy for rule '" + name + "': " + validation.message, "fatal"); + exit(1); + } + + let pidfile = BYEDPI_PID_DIR + "/" + name + ".pid"; + let child_pidfile = BYEDPI_CHILD_PID_DIR + "/" + name + ".pid"; + let logfile = BYEDPI_LOG_DIR + "/" + name + ".log"; + + log_message("Starting ciadpi for rule '" + name + "' on " + BYEDPI_LISTEN_ADDRESS + ":" + port, "info"); + let command = command_from_args([ + "ucode", + "-L", LIB_DIR, + LIB_DIR + "/providers/byedpi/runtime.uc", + "supervisor", + name, + "" + port, + raw_opt, + child_pidfile + ]) + " >>" + shell_quote(logfile) + " 2>&1 1000>&- & echo $!"; + let pid = trim(command_output("sh -c " + shell_quote(command))); + if (pid == "" || !fs.writefile(pidfile, pid + "\n")) { + log_message("ciadpi failed to start for rule '" + name + "'. Check " + logfile + ". Aborted.", "fatal"); + exit(1); + } + + command_success_from_args([ "sleep", "1" ]); + if (!runtime_pid_running(pid)) { + log_message("ciadpi failed to start for rule '" + name + "'. Check " + logfile + ". Aborted.", "fatal"); + exit(1); + } + + let child_pid = file_first_line(child_pidfile); + if (child_pid == "" || !runtime_pid_running(child_pid)) + log_message("ciadpi supervisor started for rule '" + name + "', but ciadpi is not running yet. Check " + logfile + ".", "warn"); +} + +function start_runtime() { + stop_runtime(); + + let sections = enabled_byedpi_sections(); + if (length(sections) == 0 || !provider_available()) + return; + + if (standalone_service_enabled()) + log_message("Standalone byedpi service is enabled. Tachyon manages ciadpi itself for action 'byedpi'; disable standalone byedpi autostart to avoid boot-time port conflicts.", "warn"); + + if (standalone_service_running()) { + log_message("Stopping standalone byedpi service before starting Tachyon-managed ciadpi runtime", "info"); + command_success_from_args([ BYEDPI_SERVICE_INIT, "stop" ]); + command_success_from_args([ "sleep", "1" ]); + if (standalone_service_running()) { + log_message("Standalone byedpi service is still running and may conflict with Tachyon-managed ciadpi runtime. Aborted.", "fatal"); + exit(1); + } + } + + if (!ensure_runtime_dirs()) { + log_message("Failed to prepare the Tachyon ByeDPI state directory in " + BYEDPI_STATE_DIR + ". Aborted.", "fatal"); + exit(1); + } + + let index_value = 1; + for (let section in sections) { + start_rule(section, index_value); + index_value++; + } +} + +function live_pid_count(path) { + let count = 0; + for (let pidfile in pidfiles_in_dir(path)) { + let pid = file_first_line(pidfile); + if (runtime_pid_running(pid)) + count++; + else + fs.unlink(pidfile); + } + return count; +} + +function restart_count() { + let count = 0; + if (fs.stat(BYEDPI_LOG_DIR) == null) + return count; + + let output = command_output_from_args([ "find", BYEDPI_LOG_DIR, "-maxdepth", "1", "-type", "f", "-name", "*.log" ]); + for (let path in split(output, "\n")) { + path = trim(as_string(path)); + if (path == "") + continue; + let data = fs.readfile(path); + if (data == null) + continue; + for (let line in split(data, "\n")) + if (match(as_string(line), /ciadpi for rule .* exited with code/) != null) + count++; + } + return count; +} + +function runtime_tag(base, postfix) { + return runtime_constants.tag(base, postfix); +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + try { + return json(data); + } + catch (e) { + return null; + } +} + +function value_contains(value, needle) { + if (type(value) == "array") { + for (let item in value) + if (as_string(item) == needle) + return true; + return false; + } + return as_string(value) == needle; +} + +function has_socks_outbound(config, tag, address, port) { + for (let outbound in array_or_empty(config && config.outbounds)) { + if (type(outbound) == "object" && + outbound.type == "socks" && + outbound.tag == tag && + outbound.server == address && + int(outbound.server_port || 0) == int(port)) + return true; + } + return false; +} + +function has_route_rule(config, inbound, outbound) { + for (let rule in array_or_empty(config && config.route && config.route.rules)) { + if (type(rule) == "object" && + rule.action == "route" && + value_contains(rule.inbound, inbound) && + value_contains(rule.outbound, outbound)) + return true; + } + return false; +} + +function runtime_config_status(sections) { + let config_path = option(uci_settings(), "config_path", ""); + let config = read_json_file(config_path); + let rules_configured = length(sections) > 0; + let outbounds_configured = rules_configured; + let routes_configured = rules_configured; + + let index_value = 1; + for (let section in sections) { + let outbound = runtime_tag(section_name(section), "out"); + let port = rule_port(index_value); + if (!has_socks_outbound(config, outbound, BYEDPI_LISTEN_ADDRESS, port)) + outbounds_configured = false; + if (!has_route_rule(config, SB_TPROXY_INBOUND_TAG, outbound)) + routes_configured = false; + index_value++; + } + + return { + rules_configured, + outbounds_configured, + routes_configured + }; +} + +function status_json() { + let sections = enabled_byedpi_sections(); + let configured = length(sections) > 0; + let provider = provider_available(); + let pkg = package_installed(); + let version = pkg || provider ? package_version() : "not installed"; + if (version == "") + version = "unknown"; + + let expected = length(sections); + let running = live_pid_count(BYEDPI_CHILD_PID_DIR); + let supervisors = live_pid_count(BYEDPI_PID_DIR); + let restarts = restart_count(); + let standalone_enabled = standalone_service_enabled(); + let standalone_running = standalone_service_running(); + let config_state = runtime_config_status(sections); + let conflict = running > expected || standalone_running; + let unstable = configured && restarts > 0; + let ready = configured && + provider && + !standalone_running && + !conflict && + !unstable && + config_state.outbounds_configured && + config_state.routes_configured && + expected > 0 && + running == expected; + + let message = "byedpi provider status is normal"; + if (configured && !provider) + message = "action=byedpi is configured, but ciadpi is not available at " + BYEDPI_BIN; + else if (configured && standalone_running) + message = "standalone byedpi service is active together with Tachyon action=byedpi; port conflicts are possible"; + else if (configured && standalone_enabled) + message = "standalone byedpi service autostart is enabled; disable it to avoid boot-time port conflicts with Tachyon action=byedpi"; + else if (running > expected || supervisors > expected) + message = "unexpected Tachyon-managed ciadpi processes are running without matching action=byedpi rules"; + else if (configured && unstable) + message = "Tachyon-managed ciadpi has restarted after exiting; the ByeDPI strategy or traffic load may be unstable"; + else if (configured && !ready) + message = "action=byedpi is configured, but the Tachyon-managed ciadpi runtime is not ready"; + else if (!configured && !provider && pkg) + message = "byedpi package is installed, but ciadpi is not available at " + BYEDPI_BIN; + else if (!configured && !provider) + message = "byedpi package is not installed; action=byedpi is unavailable"; + + write_json({ + installed: provider, + package_installed: pkg, + provider_available: provider, + provider_path: BYEDPI_BIN, + version, + configured, + enabled_rule_count: expected, + expected_process_count: expected, + running_process_count: running, + supervisor_process_count: supervisors, + restart_count: restarts, + runtime_unstable: unstable, + standalone_service_enabled: standalone_enabled, + standalone_service_running: standalone_running, + listen_address: BYEDPI_LISTEN_ADDRESS, + port_base: int(BYEDPI_PORT_BASE), + outbounds_configured: config_state.outbounds_configured, + routes_configured: config_state.routes_configured, + ready, + conflict, + status_message: message + }); +} + +function check_json() { + write_json({ + byedpi_installed: provider_available(), + byedpi_package_installed: package_installed(), + byedpi_provider_path: BYEDPI_BIN + }); +} + +let mode = ARGV[0] || ""; + +if (mode == "start-runtime") + start_runtime(); +else if (mode == "stop-runtime") + stop_runtime(); +else if (mode == "supervisor") + supervisor(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "status") + status_json(); +else if (mode == "check") + check_json(); +else if (mode == "installed" || mode == "provider-available") + exit(provider_available() ? 0 : 1); +else if (mode == "package-installed") + exit(package_installed() ? 0 : 1); +else if (mode == "package-version") + print(package_version(), "\n"); +else if (mode == "enabled-rule-count") + print(enabled_rule_count(), "\n"); +else { + warn("Usage: providers/byedpi/runtime.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/providers/byedpi/validator.uc b/tachyon/files/usr/lib/providers/byedpi/validator.uc new file mode 100644 index 00000000..674dfbe8 --- /dev/null +++ b/tachyon/files/usr/lib/providers/byedpi/validator.uc @@ -0,0 +1,249 @@ +#!/usr/bin/env ucode + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function normalize_strategy_whitespace(value) { + value = replace(as_string(value), /[\t\r\n]/g, " "); + value = replace(value, / +/g, " "); + value = replace(value, /^ /, ""); + return replace(value, / $/, ""); +} + +function strategy_or_default(value, default_value) { + value = as_string(value); + if (value == "") + value = as_string(default_value); + return normalize_strategy_whitespace(value); +} + +function print_strategy_or_default(value, default_value) { + print(strategy_or_default(value, default_value), "\n"); +} + +function words(value) { + value = normalize_strategy_whitespace(value); + return value == "" ? [] : split(value, " "); +} + +function contains(values, needle) { + for (let value in values) + if (value == needle) + return true; + return false; +} + +let long_value_options = [ + "--max-conn", "--conn-ip", "--buf-size", "--debug", "--def-ttl", "--auto", "--auto-mode", + "--cache-ttl", "--cache-dump", "--timeout", "--proto", "--hosts", "--ipset", "--pf", + "--round", "--split", "--disorder", "--oob", "--disoob", "--fake", "--fake-sni", + "--ttl", "--fake-offset", "--fake-data", "--fake-tls-mod", "--oob-data", "--mod-http", + "--tlsrec", "--tlsminor", "--udp-fake" +]; + +let long_flag_options = [ + "--md5sig", "--tfo", "--drop-sack", "--no-domain", "--no-udp" +]; + +let short_value_options = [ + "-c", "-I", "-b", "-x", "-g", "-A", "-L", "-u", "-y", "-T", "-K", "-H", "-j", + "-V", "-R", "-s", "-d", "-o", "-q", "-f", "-n", "-t", "-O", "-l", "-Q", "-e", + "-M", "-r", "-m", "-a" +]; + +let short_flag_options = [ + "-N", "-U", "-F", "-S", "-Y" +]; + +function validation_failure(message, needles) { + needles = type(needles) == "array" ? needles : []; + return { + valid: false, + message: as_string(message), + needle: length(needles) > 0 ? as_string(needles[0]) : "", + needles + }; +} + +function validation_success() { + return { + valid: true, + message: "", + needle: "", + needles: [] + }; +} + +function token_looks_like_option(token) { + token = as_string(token); + return match(token, /^--.+/) != null || match(token, /^-[A-Za-z].*/) != null; +} + +function short_option_name(token) { + token = as_string(token); + return length(token) >= 2 ? substr(token, 0, 2) : token; +} + +function controlled_option_failure(token, next_token) { + let base = split(as_string(token), "=")[0]; + let display = as_string(token); + + if (token == "--ip" || index(token, "--ip=") == 0 || token == "-i" || match(token, /^-i.+/) != null || + token == "--port" || index(token, "--port=") == 0 || token == "-p" || match(token, /^-p.+/) != null) { + if ((token == "--ip" || token == "-i" || token == "--port" || token == "-p") && + as_string(next_token) != "" && substr(as_string(next_token), 0, 1) != "-") + display += " " + as_string(next_token); + return validation_failure( + "ByeDPI listen address and port are assigned by Tachyon and must not be set in the strategy: " + display, + [ base ] + ); + } + + if (token == "--transparent" || token == "-E" || match(token, /^-E.+/) != null) + return validation_failure( + "Transparent proxy mode is incompatible with action=byedpi because Tachyon connects to ciadpi through SOCKS.", + [ base ] + ); + + if (token == "--daemon" || token == "-D" || match(token, /^-D.+/) != null) + return validation_failure( + "Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here.", + [ base ] + ); + + if (token == "--pidfile" || index(token, "--pidfile=") == 0 || token == "-w" || match(token, /^-w.+/) != null) + return validation_failure( + "Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here.", + [ base ] + ); + + if (token == "--help" || token == "-h" || match(token, /^-h.+/) != null || + token == "--version" || token == "-v" || match(token, /^-v.+/) != null) + return validation_failure( + "This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here.", + [ base ] + ); + + return null; +} + +function validate_token(tokens, index_value) { + let token = as_string(tokens[index_value]); + let next_token = as_string(tokens[index_value + 1]); + let controlled = controlled_option_failure(token, next_token); + if (controlled != null) + return controlled; + + let equals = index(token, "="); + if (index(token, "--") == 0 && equals > 0) { + let base = substr(token, 0, equals); + let value = substr(token, equals + 1); + + if (contains(long_value_options, base)) + return value == "" ? validation_failure("ByeDPI option requires a value: " + base, [ base ]) : null; + if (contains(long_flag_options, base)) + return validation_failure("ByeDPI option does not accept a value: " + base, [ base ]); + return validation_failure("Unknown ByeDPI option: " + base, [ base ]); + } + + if (index(token, "--") == 0) { + if (contains(long_value_options, token)) { + if (next_token == "" || token_looks_like_option(next_token)) + return validation_failure("ByeDPI option requires a value: " + token, [ token ]); + return { consume_next: true }; + } + if (contains(long_flag_options, token)) + return null; + return validation_failure("Unknown ByeDPI option: " + token, [ token ]); + } + + if (substr(token, 0, 1) == "-") { + if (token == "-") + return validation_failure("Unexpected ByeDPI strategy argument: " + token, [ token ]); + + let short = short_option_name(token); + let value = substr(token, length(short)); + if (contains(short_value_options, short)) { + if (token == short) { + if (next_token == "" || token_looks_like_option(next_token)) + return validation_failure("ByeDPI option requires a value: " + short, [ short ]); + return { consume_next: true }; + } + if (value == "") + return validation_failure("ByeDPI option requires a value: " + short, [ short ]); + return null; + } + + if (contains(short_flag_options, short)) { + if (token != short) + return validation_failure("ByeDPI option does not accept a compact value: " + short, [ short ]); + return null; + } + + return validation_failure("Unknown ByeDPI option: " + short, [ short ]); + } + + return validation_failure("Unexpected ByeDPI strategy argument: " + token, [ token ]); +} + +function validate_byedpi_strategy(raw_opt) { + let tokens = words(raw_opt); + + if (length(tokens) == 0) + return validation_failure("ByeDPI strategy cannot be empty.", []); + + for (let i = 0; i < length(tokens); i++) { + let result = validate_token(tokens, i); + if (result == null) + continue; + if (result.consume_next === true) { + i++; + continue; + } + return result; + } + + return validation_success(); +} + +function write_validation_json(result) { + write_json(result); +} + +function validate_exit(raw_opt) { + let result = validate_byedpi_strategy(raw_opt); + if (result.valid) + exit(0); + + print(result.message, "\n"); + exit(1); +} + +function module_exports() { + return { + normalize_strategy_whitespace, + strategy_or_default, + validate_byedpi_strategy + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +let mode = ARGV[0] || ""; + +if (mode == "validate-json") + write_validation_json(validate_byedpi_strategy(ARGV[1])); +else if (mode == "validate") + validate_exit(ARGV[1]); +else if (mode == "strategy-or-default") + print_strategy_or_default(ARGV[1], ARGV[2]); +else { + warn("Usage: providers/byedpi/validator.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/providers/nfqueue/check.uc b/tachyon/files/usr/lib/providers/nfqueue/check.uc new file mode 100644 index 00000000..334fdd53 --- /dev/null +++ b/tachyon/files/usr/lib/providers/nfqueue/check.uc @@ -0,0 +1,357 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let data = fs.readfile("/dev/stdin"); + return data == null ? "" : data; +} + +function strip_trailing_cr(line) { + line = as_string(line); + return length(line) > 0 && substr(line, length(line) - 1) == "\r" ? substr(line, 0, length(line) - 1) : line; +} + +function trim_left_space(value) { + value = as_string(value); + let i = 0; + while (i < length(value) && match(substr(value, i, 1), /[ \t\r\n]/) != null) + i++; + return substr(value, i); +} + +function strip_binary_prefix(line, binary) { + line = as_string(line); + binary = as_string(binary || "nfqws"); + + let path_marker = "/" + binary + ":"; + let path_index = rindex(line, path_marker); + if (path_index >= 0) + return trim_left_space(substr(line, path_index + length(path_marker))); + + let name_marker = binary + ":"; + if (index(line, name_marker) == 0) + return trim_left_space(substr(line, length(name_marker))); + + return line; +} + +function summary_line_matches(line) { + return index(line, "unrecognized option:") >= 0 || + index(line, "option requires an argument:") >= 0 || + index(line, "option does not take an argument:") >= 0 || + match(line, /[Ii]nvalid /) != null || + match(line, /bad [^ ]/) != null || + index(line, "must be ") >= 0 || + index(line, "fooling allowed values") >= 0 || + index(line, "incompatible") >= 0 || + index(line, "only one ") >= 0 || + index(line, "No such file") >= 0 || + index(line, "not found") >= 0 || + index(line, "cannot ") >= 0 || + index(line, "failed to ") >= 0 || + index(line, "unable to ") >= 0 || + index(line, "should be ") >= 0 || + index(line, "Too much splits") >= 0 || + index(line, "out of memory") >= 0 || + index(line, "not supported") >= 0 || + index(line, "value error") >= 0; +} + +function ignored_fallback_line(line) { + return match(line, /^github version /) != null || + match(line, /^we have [0-9]+ user defined desync profile/) != null || + match(line, /^Running as UID=/) != null || + line == "command line parameters verified" || + trim(line) == ""; +} + +function validation_summary(binary) { + let lines = split(read_stdin(), "\n"); + + for (let line in lines) { + line = strip_trailing_cr(line); + if (summary_line_matches(line)) { + print(strip_binary_prefix(line, binary), "\n"); + return; + } + } + + for (let line in lines) { + line = strip_trailing_cr(line); + if (ignored_fallback_line(line)) + continue; + print(strip_binary_prefix(line, binary), "\n"); + return; + } +} + +function value_after_last_colon(summary) { + let colon = rindex(summary, ":"); + if (colon < 0) + return ""; + + let fields = split(trim_left_space(substr(summary, colon + 1)), /[ \t\r\n]+/); + return length(fields) > 0 ? fields[0] : ""; +} + +function validation_value_hint_value(summary) { + summary = as_string(summary); + + if (index(summary, "Invalid port filter :") >= 0 || + index(summary, "Invalid l7 filter :") >= 0 || + index(summary, "invalid debug mode :") >= 0 || + index(summary, "invalid ip_id mode :") >= 0 || + index(summary, "Invalid fakedsplit mod :") >= 0 || + index(summary, "Invalid hostfakesplit mod :") >= 0 || + index(summary, "Invalid tcp mod :") >= 0 || + index(summary, "Invalid tls mod :") >= 0 || + index(summary, "invalid dup ip_id mode :") >= 0) + return value_after_last_colon(summary); + + return ""; +} + +function validation_value_hint(summary) { + let value = validation_value_hint_value(summary); + if (value != "") + print(value, "\n"); +} + +function first_option_token(summary) { + let matched = match(summary, /(--[[:alnum:]][[:alnum:]-]*)/); + return matched ? matched[1] : ""; +} + +function option_after(summary, pattern, prefix) { + let matched = match(summary, pattern); + if (!matched || length(matched) < 2) + return ""; + return as_string(prefix) + matched[1]; +} + +function validation_option_hint_value(summary) { + summary = as_string(summary); + + let option = first_option_token(summary); + if (option != "") + return option; + + if (index(summary, "unrecognized option:") >= 0) { + option = option_after(summary, /unrecognized option:[ \t]*([^ \t\r\n]+)/, ""); + if (option == "") + return ""; + if (index(option, "--") == 0) + return option; + else if (index(option, "-") == 0) + return "-" + option; + else + return "--" + option; + } + + if (index(summary, "option requires an argument:") >= 0) { + option = option_after(summary, /option requires an argument:[ \t]*([^ \t\r\n]+)/, "--"); + return option; + } + + if (index(summary, "option does not take an argument:") >= 0) { + option = option_after(summary, /option does not take an argument:[ \t]*([^ \t\r\n]+)/, "--"); + return option; + } + + let mappings = [ + ["invalid debug mode :", "--debug"], + ["hostspell must be exactly 4 chars long", "--hostspell"], + ["invalid ip_id mode :", "--ip-id"], + ["invalid dup ip_id mode :", "--dup-ip-id"], + ["dup-autottl value error", "--dup-autottl"], + ["dup-autottl6 value error", "--dup-autottl6"], + ["dpi-desync-autottl value error", "--dpi-desync-autottl"], + ["dpi-desync-autottl6 value error", "--dpi-desync-autottl6"], + ["orig-autottl value error", "--orig-autottl"], + ["orig-autottl6 value error", "--orig-autottl6"], + ["invalid dpi-desync mode", "--dpi-desync"], + ["invalid desync combo :", "--dpi-desync"], + ["invalid wssize-cutoff value", "--wssize-cutoff"], + ["invalid synack-split value", "--synack-split"], + ["invalid ctrack-timeouts value", "--ctrack-timeouts"], + ["invalid ipcache-lifetime value", "--ipcache-lifetime"], + ["dpi-desync-repeats must be within ", "--dpi-desync-repeats"], + ["dup-repeats must be within ", "--dup"], + ["invalid desync-cutoff value", "--dpi-desync-cutoff"], + ["invalid desync-start value", "--dpi-desync-start"], + ["Invalid fakedsplit mod :", "--dpi-desync-fakedsplit-mod"], + ["Invalid hostfakesplit mod :", "--dpi-desync-hostfakesplit-mod"], + ["Invalid tcp mod :", "--dpi-desync-fake-tcp-mod"], + ["Invalid tls mod :", "--dpi-desync-fake-tls-mod"], + ["Invalid argument for dpi-desync-split-http-req", "--dpi-desync-split-http-req"], + ["Invalid argument for dpi-desync-split-tls", "--dpi-desync-split-tls"], + ["Invalid argument for dpi-desync-split-seqovl", "--dpi-desync-split-seqovl"], + ["Invalid argument for dpi-desync-hostfakesplit-midhost", "--dpi-desync-hostfakesplit-midhost"], + ["dpi-desync-ipfrag-pos-tcp must be within ", "--dpi-desync-ipfrag-pos-tcp"], + ["dpi-desync-ipfrag-pos-tcp must be multiple of 8", "--dpi-desync-ipfrag-pos-tcp"], + ["dpi-desync-ipfrag-pos-udp must be within ", "--dpi-desync-ipfrag-pos-udp"], + ["dpi-desync-ipfrag-pos-udp must be multiple of 8", "--dpi-desync-ipfrag-pos-udp"], + ["dpi-desync-ts-increment should be ", "--dpi-desync-ts-increment"], + ["dpi-desync-badseq-increment should be ", "--dpi-desync-badseq-increment"], + ["dpi-desync-badack-increment should be ", "--dpi-desync-badack-increment"], + ["dup-ts-increment should be ", "--dup-ts-increment"], + ["dup-badseq-increment should be ", "--dup-badseq-increment"], + ["dup-badack-increment should be ", "--dup-badack-increment"], + ["bad value for --filter-l3", "--filter-l3"], + ["auto hostlist fail time is not valid", "--hostlist-auto-fail-time"], + ["auto hostlist fail threshold must be within 1..20", "--hostlist-auto-fail-threshold"], + ["auto hostlist fail threshold must be within 2..10", "--hostlist-auto-retrans-threshold"], + ["dpi-desync-udplen-increment must be integer within ", "--dpi-desync-udplen-increment"] + ]; + + for (let mapping in mappings) { + if (index(summary, mapping[0]) >= 0) + return mapping[1]; + } + + return ""; +} + +function validation_option_hint(summary) { + let option = validation_option_hint_value(summary); + if (option != "") + print(option, "\n"); +} + +function validation_needles(summary) { + let option = validation_option_hint_value(summary); + let value = validation_value_hint_value(summary); + + if (option != "") + print(option, "\n"); + if (value != "") + print(value, "\n"); +} + +function cleaned_queue_token(token) { + return replace(as_string(token), /[{},;]/g, ""); +} + +function queue_token_overlaps(token, range_start, range_end) { + token = cleaned_queue_token(token); + if (match(token, /^[0-9]+(-[0-9]+)?$/) == null) + return false; + + let parts = split(token, "-"); + let first = int(parts[0]); + let last = length(parts) > 1 && parts[1] != "" ? int(parts[1]) : first; + + return first <= range_end && last >= range_start; +} + +function nft_queue_overlap(own_table, range_start, range_end) { + own_table = as_string(own_table); + range_start = int(range_start || 0); + range_end = int(range_end || 0); + + let in_own_table = false; + for (let line in split(read_stdin(), "\n")) { + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + if (length(fields) == 0 || fields[0] == "") + continue; + + if (fields[0] == "table") + in_own_table = length(fields) >= 3 && fields[1] == "inet" && fields[2] == own_table; + + if (in_own_table) + continue; + + for (let i = 0; i < length(fields); i++) { + if (fields[i] != "queue") + continue; + + for (let j = i + 1; j < length(fields); j++) { + if ((fields[j] == "num" || fields[j] == "to") && j + 1 < length(fields)) { + if (queue_token_overlaps(fields[j + 1], range_start, range_end)) + return true; + } + else if (queue_token_overlaps(fields[j], range_start, range_end)) { + return true; + } + } + } + } + + return false; +} + +function rewrite_legacy_runtime_path(legacy_path, provider_path) { + let data = read_stdin(); + legacy_path = as_string(legacy_path); + + if (legacy_path == "") { + print(data); + return; + } + + print(replace(data, legacy_path, as_string(provider_path))); +} + +function stdin_contains(needle) { + return index(read_stdin(), as_string(needle)) >= 0; +} + +function ps_matching_pids(needle) { + needle = as_string(needle); + if (needle == "") + return; + + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (index(line, needle) < 0 || + index(line, "grep") >= 0 || + index(line, "ps-matching-pids") >= 0) + continue; + + let fields = split(trim(line), /[ \t\r\n]+/); + if (length(fields) > 0 && fields[0] != "") + print(fields[0], "\n"); + } +} + +function run(argv, usage_path) { + argv = type(argv) == "array" ? argv : []; + usage_path = as_string(usage_path || "providers/nfqueue/check.uc"); + + let mode = argv[0] || ""; + + if (mode == "summary") + validation_summary(argv[1]); + else if (mode == "value-hint") + validation_value_hint(argv[1]); + else if (mode == "option-hint") + validation_option_hint(argv[1]); + else if (mode == "needles") + validation_needles(argv[1]); + else if (mode == "nft-queue-overlap") + exit(nft_queue_overlap(argv[1], argv[2], argv[3]) ? 0 : 1); + else if (mode == "rewrite-legacy-runtime-path") + rewrite_legacy_runtime_path(argv[1], argv[2]); + else if (mode == "stdin-contains") + exit(stdin_contains(argv[1]) ? 0 : 1); + else if (mode == "ps-matching-pids") + ps_matching_pids(argv[1]); + else { + warn("Usage: " + usage_path + " ...\n"); + exit(1); + } +} + +if (sourcepath(1) != null && sourcepath(1) != "") { + return { + run, + nft_queue_overlap + }; +} + +run(ARGV, "providers/nfqueue/check.uc"); diff --git a/tachyon/files/usr/lib/providers/nfqueue/runtime.uc b/tachyon/files/usr/lib/providers/nfqueue/runtime.uc new file mode 100644 index 00000000..b92c6c44 --- /dev/null +++ b/tachyon/files/usr/lib/providers/nfqueue/runtime.uc @@ -0,0 +1,705 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let constants = require("core.constants"); +let uci_core = require("core.uci"); +let runtime_constants = require("singbox.constants"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || constants.TACHYON_CONFIG_NAME || "tachyon"; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const SB_TPROXY_INBOUND_TAG = getenv("SB_TPROXY_INBOUND_TAG") || constants.SB_TPROXY_INBOUND_TAG || "tproxy-in"; +const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || constants.NFT_TABLE_NAME || "TachyonTable"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function bool_value(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function module_command(args) { + let command_args = [ "ucode", "-L", LIB_DIR ]; + for (let arg in args) + push(command_args, arg); + return command_from_args(command_args); +} + +function module_success(args) { + return command_success(module_command(args)); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function bool_option(section, key, fallback) { + let value = object_or_empty(section)[key]; + return value == null ? !!fallback : bool_value(value); +} + +function section_name(section) { + return as_string(object_or_empty(section)[".name"]); +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, as_string(type_name)); +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function provider_config(provider) { + let cfg = object_or_empty(provider.config({ + constants, + lib_dir: LIB_DIR + })); + + cfg.kind = as_string(cfg.kind); + cfg.action = as_string(cfg.action || cfg.kind); + cfg.check_path = as_string(cfg.check_path || (LIB_DIR + "/providers/" + cfg.kind + "/check.uc")); + cfg.legacy_runtime_base = as_string(cfg.legacy_runtime_base); + cfg.provider_base_dir = as_string(cfg.provider_base_dir); + cfg.hostlist_dir = as_string(cfg.hostlist_dir); + cfg.base_args = array_or_empty(cfg.base_args); + return cfg; +} + +function normalize_strategy(cfg, value) { + return cfg.validator().strategy_or_default(value, cfg.default_strategy); +} + +function strategy_words(value) { + value = replace(as_string(value), /[\t\r\n]/g, " "); + value = replace(value, / +/g, " "); + value = replace(value, /^ /, ""); + value = replace(value, / $/, ""); + return value == "" ? [] : split(value, " "); +} + +function enabled_sections(cfg) { + let result = []; + for (let section in uci_sections("section")) + if (bool_option(section, "enabled", true) && option(section, "action", "") == cfg.action) + push(result, section); + return result; +} + +function hex_digit_value(value) { + let pos = index("0123456789abcdef", lc(as_string(value))); + return pos >= 0 ? pos : null; +} + +function parse_number(value) { + value = lc(trim(as_string(value))); + if (value == "") + return null; + if (substr(value, 0, 2) == "0x") { + value = substr(value, 2); + let result = 0; + for (let i = 0; i < length(value); i++) { + let digit = hex_digit_value(substr(value, i, 1)); + if (digit == null) + return null; + result = result * 16 + digit; + } + return result; + } + return match(value, /^[0-9]+$/) == null ? null : int(value); +} + +function route_mark_value(cfg, index_value) { + return parse_number(cfg.route_mark_base) + int(index_value); +} + +function route_mark_hex(cfg, index_value) { + return sprintf("0x%08x", route_mark_value(cfg, index_value)); +} + +function queue_number(cfg, index_value) { + return int(cfg.queue_base) + int(index_value) - 1; +} + +function queue_range_end(cfg) { + return int(cfg.queue_base) + int(cfg.queue_range_size) - 1; +} + +function provider_available(cfg) { + let stat = fs.stat(cfg.provider_bin); + return stat != null && stat.mode != null && (int(stat.mode) & 73) != 0; +} + +function package_installed(cfg) { + return module_success([ LIB_DIR + "/core/packages.uc", "installed", cfg.package_name ]); +} + +function package_version_from_manager(cfg) { + return trim(command_output_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/core/packages.uc", "version", cfg.package_name ])); +} + +function first_line_version_field(value) { + let newline = index(value, "\n"); + let line = newline >= 0 ? substr(value, 0, newline) : value; + if (match(line, /^.*version[ \t]*/) == null) + return ""; + let fields = split(trim(replace(line, /^.*version[ \t]*/, "")), /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[0]) : ""; +} + +function package_version(cfg) { + let version = package_version_from_manager(cfg); + if (version == "" && provider_available(cfg)) + version = first_line_version_field(command_output_from_args([ cfg.provider_bin, "--version" ])); + return version; +} + +function standalone_service_enabled(cfg) { + return fs.stat(cfg.service_init) != null && command_success_from_args([ cfg.service_init, "enabled" ]); +} + +function standalone_service_running(cfg) { + return fs.stat(cfg.service_init) != null && command_success_from_args([ cfg.service_init, "status" ]); +} + +function standalone_uci_config_present(cfg) { + return fs.stat("/etc/config/" + cfg.config_name) != null && uci_core.exists(cfg.config_name + ".config"); +} + +function luci_app_installed(cfg) { + return module_success([ LIB_DIR + "/core/packages.uc", "installed", cfg.luci_package ]) || + fs.stat(cfg.luci_menu) != null || + fs.stat(cfg.luci_acl) != null; +} + +function runtime_pid_running(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function file_first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + let newline = index(data, "\n"); + return trim(newline >= 0 ? substr(data, 0, newline) : data); +} + +function kill_pidfile_process(path, signal) { + let pid = file_first_line(path); + if (pid == "") + return; + if (signal == "9") { + if (runtime_pid_running(pid)) + command_success_from_args([ "kill", "-9", pid ]); + } + else { + command_success_from_args([ "kill", pid ]); + } +} + +function pidfiles_in_dir(path) { + if (fs.stat(path) == null) + return []; + + let output = command_output_from_args([ "find", path, "-maxdepth", "1", "-type", "f", "-name", "*.pid" ]); + let result = []; + for (let line in split(output, "\n")) { + line = trim(as_string(line)); + if (line != "") + push(result, line); + } + return result; +} + +function live_pid_count(path) { + let count = 0; + for (let pidfile in pidfiles_in_dir(path)) { + let pid = file_first_line(pidfile); + if (runtime_pid_running(pid)) + count++; + else { + try { fs.unlink(pidfile); } catch (e) {} + } + } + return count; +} + +function ensure_runtime_dirs(cfg) { + return command_success_from_args([ "mkdir", "-p", cfg.state_dir, cfg.pid_dir, cfg.child_pid_dir, cfg.log_dir ]); +} + +function uci_value_contains(value, needle) { + needle = as_string(needle); + if (needle == "") + return false; + + if (type(value) == "array") { + for (let item in value) + if (uci_value_contains(item, needle)) + return true; + return false; + } + + if (type(value) == "object") { + for (let key in keys(value)) + if (uci_value_contains(value[key], needle)) + return true; + return false; + } + + return index(as_string(value), needle) >= 0; +} + +function legacy_runtime_path_present(cfg) { + if (cfg.legacy_runtime_base == "") + return false; + + if (uci_value_contains(uci_settings(), cfg.legacy_runtime_base)) + return true; + for (let section in uci_sections("section")) + if (uci_value_contains(section, cfg.legacy_runtime_base)) + return true; + + return fs.stat(cfg.legacy_runtime_base) != null; +} + +function stop_legacy_runtime_processes(cfg) { + if (cfg.legacy_runtime_base == "") + return; + let needle = cfg.legacy_runtime_base + "/nfq/nfqws"; + for (let line in split(command_output_from_args([ "ps", "w" ]), "\n")) { + if (index(line, needle) < 0) + continue; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + if (length(fields) > 0) + command_success_from_args([ "kill", fields[0] ]); + } +} + +function cleanup_legacy_runtime(cfg) { + if (cfg.legacy_runtime_base == "") + return; + stop_legacy_runtime_processes(cfg); + command_success_from_args([ "rm", "-rf", cfg.legacy_runtime_base ]); +} + +function expand_strategy(cfg, value) { + value = as_string(value); + if (cfg.legacy_runtime_base == "" || cfg.provider_base_dir == "") + return value; + return replace(value, cfg.legacy_runtime_base, cfg.provider_base_dir); +} + +function base_args(cfg) { + return cfg.base_args; +} + +function raw_strategy(cfg, section) { + return normalize_strategy(cfg, option(section, cfg.strategy_option, "")); +} + +function validate_strategy_or_exit(cfg, section_name_value, raw_opt) { + let result = cfg.validator().validate_strategy(cfg.validator_kind, raw_opt, cfg.legacy_default_strategy); + if (result.valid) + return; + log_message("Invalid " + cfg.binary_name + " strategy for rule '" + section_name_value + "': " + result.message, "fatal"); + exit(1); +} + +function supervisor_command(cfg, queue, raw_opt, child_pidfile) { + let args = [ cfg.binary, "--qnum=" + as_string(queue) ]; + for (let arg in base_args(cfg)) + push(args, arg); + for (let word in strategy_words(raw_opt)) + push(args, word); + + return command_from_args(args) + " & child=$!; echo $child > " + shell_quote(child_pidfile) + "; wait $child; rc=$?; rm -f " + shell_quote(child_pidfile) + "; exit $rc"; +} + +function supervisor(cfg, section, queue, raw_opt, child_pidfile) { + while (true) { + if (!provider_available(cfg)) { + print(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), " Provider ", cfg.binary, " is not executable; retrying in ", cfg.respawn_delay, " seconds\n"); + command_success_from_args([ "sleep", cfg.respawn_delay ]); + continue; + } + + let rc = command_status("sh -c " + shell_quote(supervisor_command(cfg, queue, raw_opt, child_pidfile))); + print(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), " ", cfg.binary_name, " for rule ", as_string(section), " exited with code ", rc, "; respawning in ", cfg.respawn_delay, " seconds\n"); + command_success_from_args([ "sleep", cfg.respawn_delay ]); + } +} + +function stop_runtime(cfg) { + for (let pidfile in pidfiles_in_dir(cfg.pid_dir)) + kill_pidfile_process(pidfile, ""); + for (let pidfile in pidfiles_in_dir(cfg.child_pid_dir)) + kill_pidfile_process(pidfile, ""); + + command_success_from_args([ "sleep", "1" ]); + + for (let pidfile in pidfiles_in_dir(cfg.pid_dir)) + kill_pidfile_process(pidfile, "9"); + for (let pidfile in pidfiles_in_dir(cfg.child_pid_dir)) + kill_pidfile_process(pidfile, "9"); + + let remove_args = [ "rm", "-rf", cfg.pid_dir, cfg.child_pid_dir, cfg.log_dir ]; + if (cfg.hostlist_dir != "") + push(remove_args, cfg.hostlist_dir); + if (cfg.legacy_runtime_base != "") + push(remove_args, cfg.legacy_runtime_base); + command_success_from_args(remove_args); +} + +function start_rule(cfg, section, index_value) { + let name = section_name(section); + let queue = queue_number(cfg, index_value); + let mark = route_mark_hex(cfg, index_value); + let raw_opt = expand_strategy(cfg, raw_strategy(cfg, section)); + validate_strategy_or_exit(cfg, name, raw_opt); + + let pidfile = cfg.pid_dir + "/" + name + ".pid"; + let child_pidfile = cfg.child_pid_dir + "/" + name + ".pid"; + let logfile = cfg.log_dir + "/" + name + ".log"; + + log_message("Starting " + cfg.binary_name + " for rule '" + name + "' on queue " + queue + " with mark " + mark, "info"); + let command = command_from_args([ + "ucode", + "-L", LIB_DIR, + cfg.runtime_path, + "supervisor", + name, + "" + queue, + raw_opt, + child_pidfile + ]) + " >>" + shell_quote(logfile) + " 2>&1 1000>&- & echo $!"; + let pid = trim(command_output("sh -c " + shell_quote(command))); + if (pid == "" || fs.writefile(pidfile, pid + "\n") == null) { + log_message(cfg.binary_name + " failed to start for rule '" + name + "'. Check " + logfile + ". Aborted.", "fatal"); + exit(1); + } + + command_success_from_args([ "sleep", "1" ]); + if (!runtime_pid_running(pid)) { + log_message(cfg.binary_name + " failed to start for rule '" + name + "'. Check " + logfile + ". Aborted.", "fatal"); + exit(1); + } + + let child_pid = file_first_line(child_pidfile); + if (child_pid == "" || !runtime_pid_running(child_pid)) + log_message(cfg.binary_name + " supervisor started for rule '" + name + "', but " + cfg.binary_name + " is not running yet. Check " + logfile + ".", "warn"); +} + +function start_runtime(cfg) { + stop_runtime(cfg); + + let sections = enabled_sections(cfg); + if (length(sections) == 0 || !provider_available(cfg)) + return; + + cleanup_legacy_runtime(cfg); + if (!ensure_runtime_dirs(cfg)) { + log_message("Failed to prepare the Tachyon " + cfg.status_label + " state directory in " + cfg.state_dir + ". Aborted.", "fatal"); + exit(1); + } + + let index_value = 1; + for (let section in sections) { + start_rule(cfg, section, index_value); + index_value++; + } +} + +function runtime_tag(base, postfix) { + return runtime_constants.tag(base, postfix); +} + +function read_json_file(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return null; + try { + return json(data); + } + catch (e) { + return null; + } +} + +function value_contains(value, needle) { + if (type(value) == "array") { + for (let item in value) + if (as_string(item) == needle) + return true; + return false; + } + return as_string(value) == needle; +} + +function has_direct_mark_outbound(config, tag, routing_mark) { + for (let outbound in array_or_empty(config && config.outbounds)) { + if (type(outbound) == "object" && + outbound.type == "direct" && + outbound.tag == tag && + int(outbound.routing_mark || 0) == int(routing_mark)) + return true; + } + return false; +} + +function has_route_rule(config, inbound, outbound) { + for (let rule in array_or_empty(config && config.route && config.route.rules)) { + if (type(rule) == "object" && + rule.action == "route" && + value_contains(rule.inbound, inbound) && + value_contains(rule.outbound, outbound)) + return true; + } + return false; +} + +function runtime_config_status(cfg, sections) { + let config_path = option(uci_settings(), "config_path", ""); + let config = read_json_file(config_path); + let rules_configured = length(sections) > 0; + let outbounds_configured = rules_configured; + let routes_configured = rules_configured; + + let index_value = 1; + for (let section in sections) { + let outbound = runtime_tag(section_name(section), "out"); + let mark = route_mark_value(cfg, index_value); + if (!has_direct_mark_outbound(config, outbound, mark)) + outbounds_configured = false; + if (!has_route_rule(config, SB_TPROXY_INBOUND_TAG, outbound)) + routes_configured = false; + index_value++; + } + + return { + rules_configured, + outbounds_configured, + routes_configured + }; +} + +function external_queue_overlap(cfg) { + let command = "nft list ruleset 2>/dev/null | " + module_command([ + cfg.check_path, + "nft-queue-overlap", + NFT_TABLE_NAME, + cfg.queue_base, + "" + queue_range_end(cfg) + ]); + return command_success(command); +} + +function status_json(cfg) { + let sections = enabled_sections(cfg); + let configured = length(sections) > 0; + let provider = provider_available(cfg); + let pkg = package_installed(cfg); + let version = pkg || provider ? package_version(cfg) : "not installed"; + if (version == "") + version = "unknown"; + + let expected = length(sections); + let running = live_pid_count(cfg.child_pid_dir); + let supervisors = live_pid_count(cfg.pid_dir); + let standalone_enabled = standalone_service_enabled(cfg); + let standalone_running = standalone_service_running(cfg); + let standalone_config = standalone_uci_config_present(cfg); + let queue_overlap = external_queue_overlap(cfg); + let standalone_conflict = configured && standalone_running; + let legacy_runtime = legacy_runtime_path_present(cfg); + let luci_installed = luci_app_installed(cfg); + let config_state = runtime_config_status(cfg, sections); + let conflict = running > expected || queue_overlap || legacy_runtime; + let ready = configured && + provider && + !conflict && + config_state.outbounds_configured && + config_state.routes_configured && + expected > 0 && + running == expected; + + let message = cfg.status_label + " provider status is normal"; + if (configured && !provider) + message = "action=" + cfg.action + " is configured, but " + cfg.status_label + " provider is not available at " + cfg.provider_bin; + else if (queue_overlap) + message = "external NFQUEUE rules overlap with the Tachyon " + cfg.status_label + " range " + cfg.queue_base + "-" + queue_range_end(cfg); + else if (legacy_runtime) + message = "legacy zapret runtime paths are still present and should be migrated"; + else if (running > expected || supervisors > expected) + message = "unexpected Tachyon-managed " + cfg.binary_name + " processes are running without matching action=" + cfg.action + " rules"; + else if (configured && !ready) + message = "action=" + cfg.action + " is configured, but the Tachyon-managed " + cfg.binary_name + " runtime is not ready"; + else if (standalone_conflict) + message = "standalone " + cfg.status_label + " is active together with Tachyon action=" + cfg.action + "; queues are separate, but packet-level policy overlap is possible"; + else if (!configured && !provider && pkg) + message = cfg.status_label + " package is installed, but the provider binary is not available at " + cfg.provider_bin; + else if (!configured && !provider) + message = cfg.status_label + " provider is not installed; action=" + cfg.action + " is unavailable"; + + let value = { + installed: provider, + package_installed: pkg, + provider_available: provider, + provider_path: cfg.provider_bin, + files_available: fs.stat(cfg.provider_files_dir) != null, + ipset_available: fs.stat(cfg.provider_ipset_dir) != null, + version, + configured, + enabled_rule_count: expected, + expected_process_count: expected, + running_process_count: running, + supervisor_process_count: supervisors, + standalone_service_enabled: standalone_enabled, + standalone_service_running: standalone_running, + standalone_config_present: standalone_config, + standalone_conflict, + luci_app_installed: luci_installed, + queue_base: int(cfg.queue_base), + queue_range_end: queue_range_end(cfg), + queue_overlap, + ready, + conflict, + outbounds_configured: config_state.outbounds_configured, + routes_configured: config_state.routes_configured, + status_message: message + }; + + if (cfg.legacy_runtime_base != "") + value.legacy_runtime_present = legacy_runtime; + + write_json(value); +} + +function check_json(cfg) { + let value = {}; + value[cfg.check_prefix + "_installed"] = provider_available(cfg); + value[cfg.check_prefix + "_package_installed"] = package_installed(cfg); + value[cfg.check_prefix + "_provider_path"] = cfg.provider_bin; + write_json(value); +} + +function create_nft_rules(cfg) { + let sections = enabled_sections(cfg); + if (length(sections) == 0 || !provider_available(cfg)) + return; + + command_success_from_args([ "nft", "add", "rule", "inet", NFT_TABLE_NAME, "mangle_output", "meta", "mark", "&", cfg.desync_mark, "==", cfg.desync_mark, "return" ]); + command_success_from_args([ "nft", "add", "rule", "inet", NFT_TABLE_NAME, "mangle_output", "meta", "mark", "&", cfg.desync_mark_postnat, "==", cfg.desync_mark_postnat, "return" ]); + + let index_value = 1; + for (let section in sections) { + let mark = route_mark_hex(cfg, index_value); + let queue = "" + queue_number(cfg, index_value); + command_success_from_args([ "nft", "add", "rule", "inet", NFT_TABLE_NAME, "mangle_output", "meta", "mark", mark, "meta", "l4proto", "tcp", "counter", "queue", "num", queue, "bypass" ]); + command_success_from_args([ "nft", "add", "rule", "inet", NFT_TABLE_NAME, "mangle_output", "meta", "mark", mark, "meta", "l4proto", "udp", "counter", "queue", "num", queue, "bypass" ]); + index_value++; + } +} + +function run(provider, argv) { + argv = type(argv) == "array" ? argv : []; + let mode = argv[0] || ""; + let cfg = provider_config(provider); + let kind = cfg.kind; + + if (mode == "supervisor") + supervisor(cfg, argv[1], argv[2], argv[3], argv[4]); + else if (mode == "start-runtime") + start_runtime(cfg); + else if (mode == "stop-runtime") + stop_runtime(cfg); + else if (mode == "create-nft-rules") + create_nft_rules(cfg); + else if (mode == "status") + status_json(cfg); + else if (mode == "check") + check_json(cfg); + else if (mode == "installed" || mode == "provider-available") + exit(provider_available(cfg) ? 0 : 1); + else if (mode == "package-installed") + exit(package_installed(cfg) ? 0 : 1); + else if (mode == "package-version") + print(package_version(cfg), "\n"); + else if (mode == "enabled-rule-count") + print(length(enabled_sections(cfg)), "\n"); + else { + warn("Usage: providers/" + kind + "/runtime.uc \n"); + exit(1); + } +} + +return { + run +}; diff --git a/tachyon/files/usr/lib/providers/nfqueue/validator.uc b/tachyon/files/usr/lib/providers/nfqueue/validator.uc new file mode 100644 index 00000000..05eac4e0 --- /dev/null +++ b/tachyon/files/usr/lib/providers/nfqueue/validator.uc @@ -0,0 +1,382 @@ +#!/usr/bin/env ucode + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function normalize_strategy_whitespace(value) { + value = replace(as_string(value), /[\t\r\n]/g, " "); + value = replace(value, / +/g, " "); + value = replace(value, /^ /, ""); + return replace(value, / $/, ""); +} + +function strategy_or_default(value, default_value) { + value = as_string(value); + if (value == "") + value = as_string(default_value); + return normalize_strategy_whitespace(value); +} + +function print_strategy_or_default(value, default_value) { + print(strategy_or_default(value, default_value), "\n"); +} + +function words(value) { + value = normalize_strategy_whitespace(value); + return value == "" ? [] : split(value, " "); +} + +function contains(values, needle) { + for (let value in values) + if (value == needle) + return true; + return false; +} + +function starts_with(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function split_option_token(token) { + let equals = index(token, "="); + return equals >= 0 ? substr(token, 0, equals) : token; +} + +function validation_failure(message, needles) { + needles = type(needles) == "array" ? needles : []; + return { + valid: false, + message: as_string(message), + needle: length(needles) > 0 ? as_string(needles[0]) : "", + needles + }; +} + +function validation_success() { + return { + valid: true, + message: "", + needle: "", + needles: [] + }; +} + +let nfqws_optional = [ + "--debug", "--comment", "--synack-split", "--ctrack-disable", "--ipcache-hostname", + "--dup-autottl", "--dup-autottl6", "--dup-tcp-flags-set", "--dup-tcp-flags-unset", + "--dup-replace", "--orig-autottl", "--orig-autottl6", "--orig-tcp-flags-set", + "--orig-tcp-flags-unset", "--dpi-desync-autottl", "--dpi-desync-autottl6", + "--dpi-desync-tcp-flags-set", "--dpi-desync-tcp-flags-unset", + "--dpi-desync-skip-nosni", "--dpi-desync-any-protocol" +]; + +let nfqws_none = [ + "--dry-run", "--version", "--daemon", "--hostcase", "--hostnospace", "--domcase", + "--methodeol", "--new", "--skip", "--bind-fix4", "--bind-fix6" +]; + +let nfqws_required = [ + "--qnum", "--pidfile", "--user", "--uid", "--wsize", "--wssize", "--wssize-cutoff", + "--wssize-forced-cutoff", "--ctrack-timeouts", "--ipcache-lifetime", + "--hostspell", "--ip-id", "--dpi-desync", "--dpi-desync-fwmark", "--dup", + "--dup-ttl", "--dup-ttl6", "--dup-fooling", "--dup-ts-increment", + "--dup-badseq-increment", "--dup-badack-increment", "--dup-ip-id", + "--dup-start", "--dup-cutoff", "--orig-ttl", "--orig-ttl6", + "--orig-mod-start", "--orig-mod-cutoff", "--dpi-desync-ttl", + "--dpi-desync-ttl6", "--dpi-desync-fooling", "--dpi-desync-repeats", + "--dpi-desync-split-pos", "--dpi-desync-split-http-req", + "--dpi-desync-split-tls", "--dpi-desync-split-seqovl", + "--dpi-desync-split-seqovl-pattern", "--dpi-desync-fakedsplit-pattern", + "--dpi-desync-fakedsplit-mod", "--dpi-desync-hostfakesplit-midhost", + "--dpi-desync-hostfakesplit-mod", "--dpi-desync-ipfrag-pos-tcp", + "--dpi-desync-ipfrag-pos-udp", "--dpi-desync-ts-increment", + "--dpi-desync-badseq-increment", "--dpi-desync-badack-increment", + "--dpi-desync-fake-tcp-mod", "--dpi-desync-fake-http", + "--dpi-desync-fake-tls", "--dpi-desync-fake-tls-mod", + "--dpi-desync-fake-unknown", "--dpi-desync-fake-syndata", + "--dpi-desync-fake-quic", "--dpi-desync-fake-wireguard", + "--dpi-desync-fake-dht", "--dpi-desync-fake-discord", + "--dpi-desync-fake-stun", "--dpi-desync-fake-unknown-udp", + "--dpi-desync-udplen-increment", "--dpi-desync-udplen-pattern", + "--dpi-desync-cutoff", "--dpi-desync-start", "--hostlist", + "--hostlist-domains", "--hostlist-exclude", "--hostlist-exclude-domains", + "--hostlist-auto", "--hostlist-auto-fail-threshold", + "--hostlist-auto-fail-time", "--hostlist-auto-retrans-threshold", + "--hostlist-auto-debug", "--filter-l3", "--filter-tcp", "--filter-udp", + "--filter-l7", "--ipset", "--ipset-ip", "--ipset-exclude", + "--ipset-exclude-ip" +]; + +let nfqws2_optional = [ + "--debug", "--comment", "--intercept", "--chdir", "--ctrack-disable", "--payload-disable", + "--server", "--ipcache-hostname", "--reasm-disable", "--writeable", "--new", "--template", + "--hostlist-auto-retrans-reset" +]; + +let nfqws2_none = [ + "--dry-run", "--version", "--daemon", "--skip", "--bind-fix4", "--bind-fix6" +]; + +let nfqws2_required = [ + "--qnum", "--pidfile", "--user", "--uid", "--ctrack-timeouts", "--ipcache-lifetime", + "--fwmark", "--fuzz", "--blob", "--lua-init", "--lua-gc", "--hostlist", + "--hostlist-domains", "--hostlist-exclude", "--hostlist-exclude-domains", + "--hostlist-auto", "--hostlist-auto-fail-threshold", "--hostlist-auto-fail-time", + "--hostlist-auto-retrans-threshold", "--hostlist-auto-retrans-maxseq", + "--hostlist-auto-incoming-maxseq", "--hostlist-auto-udp-in", "--hostlist-auto-udp-out", + "--hostlist-auto-debug", "--name", "--import", "--cookie", "--filter-l3", "--filter-tcp", + "--filter-udp", "--filter-icmp", "--filter-ipp", "--filter-l7", "--filter-ssid", + "--ipset", "--ipset-ip", "--ipset-exclude", "--ipset-exclude-ip", + "--payload", "--in-range", "--out-range", "--lua-desync" +]; + +function option_mode(kind, token) { + if (kind == "nfqws") { + if (contains(nfqws_optional, token)) + return "optional"; + if (contains(nfqws_none, token)) + return "none"; + if (contains(nfqws_required, token)) + return "required"; + return "unknown"; + } + + if (contains(nfqws2_optional, token)) + return "optional"; + if (contains(nfqws2_none, token)) + return "none"; + if (contains(nfqws2_required, token)) + return "required"; + return "unknown"; +} + +function unsupported_reason(kind, token) { + let name = kind == "nfqws2" ? "nfqws2" : "nfqws"; + let action = kind == "nfqws2" ? "zapret2" : "zapret"; + + if (token == "" || token == "") + return "Tachyon does not expand " + action + " hostlist templates in per-rule strategies because sing-box already selects the resources before NFQUEUE."; + + let base = split_option_token(token); + if (contains([ + "--hostlist", "--hostlist-domains", "--hostlist-exclude", "--hostlist-exclude-domains", + "--hostlist-auto", "--hostlist-auto-fail-threshold", "--hostlist-auto-fail-time", + "--hostlist-auto-retrans-threshold", "--hostlist-auto-debug" + ], base) || (kind == "nfqws2" && contains([ + "--hostlist-auto-retrans-maxseq", "--hostlist-auto-retrans-reset", + "--hostlist-auto-incoming-maxseq", "--hostlist-auto-udp-in", "--hostlist-auto-udp-out" + ], base))) + return "Hostname-based selection inside " + name + " is incompatible with the Tachyon architecture because sing-box already chooses which resources enter action=" + action + "."; + + if (contains([ "--ipset", "--ipset-ip", "--ipset-exclude", "--ipset-exclude-ip" ], base)) + return "IP or CIDR selection inside " + name + " is incompatible with the Tachyon architecture because sing-box already chooses which resources enter action=" + action + "."; + + if (base == "--qnum") + return "The NFQUEUE number is assigned by Tachyon per rule and must not be overridden in the strategy."; + + if (kind == "nfqws" && base == "--dpi-desync-fwmark") + return "The desync fwmark is managed by Tachyon for loop prevention and must not be overridden in the strategy."; + + if (kind == "nfqws2" && (base == "--fwmark" || base == "--dpi-desync-fwmark")) + return "The nfqws2 fwmark is managed by Tachyon for loop prevention and must not be overridden in the strategy."; + + if (kind == "nfqws2" && base == "--fuzz") + return "nfqws2 fuzz mode disables normal interception and is incompatible with Tachyon-managed action=zapret2 rules."; + + if (kind == "nfqws2" && token == "--intercept=0") + return "nfqws2 interception must stay enabled for Tachyon-managed action=zapret2 rules."; + + if (base == "--daemon") + return "Tachyon manages the " + name + " process lifecycle itself. The strategy must not daemonize " + name + "."; + if (base == "--dry-run") + return "The strategy must launch a working " + name + " process. --dry-run exits immediately and is not allowed."; + if (base == "--version") + return "The strategy must launch a working " + name + " process. --version exits immediately and is not allowed."; + + return null; +} + +function token_has_external_config_prefix(token) { + return starts_with(token, "@") || starts_with(token, "$"); +} + +function next_is_separate_value(next_token) { + return as_string(next_token) != "" && !starts_with(next_token, "--"); +} + +function label(kind) { + return kind == "nfqws2" ? "NFQWS2" : "NFQWS"; +} + +function validate_strategy(kind, raw_opt, legacy_default) { + kind = as_string(kind); + if (kind != "nfqws" && kind != "nfqws2") + return validation_failure("NFQUEUE validator accepts only nfqws or nfqws2 strategies.", [ kind ]); + + raw_opt = normalize_strategy_whitespace(raw_opt); + + if (kind == "nfqws" && raw_opt == as_string(legacy_default)) + return validation_success(); + + let tokens = words(raw_opt); + let token_count = 0; + + for (let i = 0; i < length(tokens); ) { + let token = as_string(tokens[i]); + let next_token = as_string(tokens[i + 1]); + let base_token = split_option_token(token); + + if (token_count == 0 && token_has_external_config_prefix(token)) + return validation_failure( + "Unsupported " + label(kind) + " token '" + token + "': External " + (kind == "nfqws2" ? "nfqws2" : "nfqws") + " config files bypass Tachyon validation and queue management.", + [ token ] + ); + + let reason = unsupported_reason(kind, token); + if (reason != null) { + let display = token; + let mode = option_mode(kind, base_token); + if (base_token == token && mode == "required" && next_is_separate_value(next_token)) { + display += " " + next_token; + return validation_failure("Unsupported " + label(kind) + " token '" + display + "': " + reason, [ base_token, next_token ]); + } + return validation_failure("Unsupported " + label(kind) + " token '" + display + "': " + reason, [ base_token ]); + } + + if (starts_with(token, "--")) { + let mode = option_mode(kind, base_token); + if (mode == "unknown") + return validation_failure("Unknown " + label(kind) + " flag '" + token + "'.", [ base_token ]); + + if (mode == "none") { + if (base_token != token) + return validation_failure(label(kind) + " flag '" + base_token + "' does not accept a value.", [ base_token ]); + i++; + token_count++; + continue; + } + + if (mode == "optional") { + if (base_token == token && next_is_separate_value(next_token)) + return validation_failure( + "Optional value for '" + base_token + "' must be attached as '" + base_token + "=value'. Separate tokens are ignored by " + kind + " here.", + [ base_token, next_token ] + ); + i++; + token_count++; + continue; + } + + if (base_token != token) { + i++; + token_count++; + continue; + } + + if (!next_is_separate_value(next_token)) + return validation_failure(label(kind) + " option '" + base_token + "' requires a value.", [ base_token ]); + + i += 2; + token_count += 2; + continue; + } + + return validation_failure( + "Unexpected standalone " + label(kind) + " token '" + token + "'. Use explicit flags such as --name or --name=value.", + [ token ] + ); + } + + return validation_success(); +} + +function validate_exit(kind, raw_opt, legacy_default) { + let result = validate_strategy(kind, raw_opt, legacy_default); + if (result.valid) + exit(0); + + print(result.message, "\n"); + exit(1); +} + +function validate_json(kind, raw_opt, legacy_default) { + let result = validate_strategy(kind, raw_opt, legacy_default); + write_json(result); + exit(result.valid ? 0 : 1); +} + +function expected_label(expected_kind) { + return expected_kind == "nfqws2" ? "Zapret2" : "Zapret"; +} + +function validate_expected_strategy(expected_kind, kind, raw_opt, legacy_default) { + expected_kind = as_string(expected_kind); + if (as_string(kind) != expected_kind) + return validation_failure(expected_label(expected_kind) + " validator accepts only " + expected_kind + " strategies.", [ as_string(kind) ]); + return validate_strategy(expected_kind, raw_opt, legacy_default); +} + +function validate_expected_exit(expected_kind, kind, raw_opt, legacy_default) { + let result = validate_expected_strategy(expected_kind, kind, raw_opt, legacy_default); + if (result.valid) + exit(0); + + print(result.message, "\n"); + exit(1); +} + +function validate_expected_json(expected_kind, kind, raw_opt, legacy_default) { + let result = validate_expected_strategy(expected_kind, kind, raw_opt, legacy_default); + write_json(result); + exit(result.valid ? 0 : 1); +} + +function run_expected(expected_kind, usage_path, argv) { + argv = type(argv) == "array" ? argv : []; + + let mode = argv[0] || ""; + + if (mode == "validate") + validate_expected_exit(expected_kind, argv[1], argv[2], argv[3]); + else if (mode == "validate-json") + validate_expected_json(expected_kind, argv[1], argv[2], argv[3]); + else if (mode == "strategy-or-default") + print_strategy_or_default(argv[1], argv[2]); + else { + warn("Usage: " + usage_path + "\n"); + exit(1); + } +} + +function module_exports() { + return { + normalize_strategy_whitespace, + strategy_or_default, + validate_strategy, + validate_expected_strategy, + run_expected + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +let mode = ARGV[0] || ""; + +if (mode == "validate") + validate_exit(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "validate-json") + validate_json(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "strategy-or-default") + print_strategy_or_default(ARGV[1], ARGV[2]); +else { + warn("Usage: providers/nfqueue/validator.uc [legacy-default]\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/providers/rules.uc b/tachyon/files/usr/lib/providers/rules.uc new file mode 100644 index 00000000..6ebf59ad --- /dev/null +++ b/tachyon/files/usr/lib/providers/rules.uc @@ -0,0 +1,170 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function hex_digit_value(value) { + let pos = index("0123456789abcdef", lc(as_string(value))); + return pos >= 0 ? pos : null; +} + +function parse_number(value) { + value = lc(trim(as_string(value))); + if (value == "") + return null; + + if (substr(value, 0, 2) == "0x") { + value = substr(value, 2); + if (value == "") + return null; + + let result = 0; + for (let i = 0; i < length(value); i++) { + let digit = hex_digit_value(substr(value, i, 1)); + if (digit == null) + return null; + result = result * 16 + digit; + } + return result; + } + + return match(value, /^[0-9]+$/) == null ? null : int(value); +} + +function rule_rows() { + let rows = []; + + for (let line in split(read_stdin(), "\n")) { + line = replace(as_string(line), /\r/g, ""); + if (trim(line) == "") + continue; + + let fields = split(line, "\t"); + push(rows, { + section: as_string(fields[0]), + enabled: as_string(fields[1]) == "1", + action: as_string(fields[2]) + }); + } + + return rows; +} + +function bool_value(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function uci_rule_rows() { + let rows = []; + + for (let section in uci_core.section_objects(CONFIG_NAME, "section")) { + let enabled = section.enabled == null ? true : bool_value(section.enabled); + push(rows, { + section: as_string(section[".name"]), + enabled, + action: as_string(section.action) + }); + } + return rows; +} + +function rows_for_source(source) { + return source == "uci" ? uci_rule_rows() : rule_rows(); +} + +function action_rule_count_from_rows(rows, action) { + let count = 0; + action = as_string(action); + + for (let row in rows) + if (row.enabled && row.action == action) + count++; + + return count; +} + +function action_rule_count(action, source) { + print(action_rule_count_from_rows(rows_for_source(source), action), "\n"); +} + +function action_rule_index_from_rows(rows, action, target_section) { + let index = 0; + action = as_string(action); + target_section = as_string(target_section); + + for (let row in rows) { + if (!row.enabled || row.action != action) + continue; + + index++; + if (row.section == target_section) + return index; + } + + return 0; +} + +function action_rule_index(action, target_section, source) { + print(action_rule_index_from_rows(rows_for_source(source), action, target_section), "\n"); +} + +function arithmetic_number(base_text, index_text, offset) { + let base = parse_number(base_text); + let index = parse_number(index_text); + + if (base == null || index == null) + exit(1); + + return base + index + offset; +} + +function mark_value(base_text, index_text) { + print(arithmetic_number(base_text, index_text, 0), "\n"); +} + +function mark_hex(base_text, index_text) { + print(sprintf("0x%08x", arithmetic_number(base_text, index_text, 0)), "\n"); +} + +function offset_number(base_text, index_text) { + print(arithmetic_number(base_text, index_text, -1), "\n"); +} + +let mode = ARGV[0] || ""; + +if (mode == "count") + action_rule_count(ARGV[1]); +else if (mode == "count-uci") + action_rule_count(ARGV[1], "uci"); +else if (mode == "index") + action_rule_index(ARGV[1], ARGV[2]); +else if (mode == "index-uci") + action_rule_index(ARGV[1], ARGV[2], "uci"); +else if (mode == "has-enabled-uci") + exit(action_rule_count_from_rows(uci_rule_rows(), ARGV[1]) > 0 ? 0 : 1); +else if (mode == "mark-value") + mark_value(ARGV[1], ARGV[2]); +else if (mode == "mark-hex") + mark_hex(ARGV[1], ARGV[2]); +else if (mode == "queue-number" || mode == "port-number") + offset_number(ARGV[1], ARGV[2]); +else { + warn("Usage: providers/rules.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/providers/status.uc b/tachyon/files/usr/lib/providers/status.uc new file mode 100644 index 00000000..e5b8f95b --- /dev/null +++ b/tachyon/files/usr/lib/providers/status.uc @@ -0,0 +1,343 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function int_arg(value) { + value = as_string(value); + return value == "" ? 0 : int(value, 10); +} + +function bool_arg(value) { + value = as_string(value); + return value == "1" || value == "true"; +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function first_field(line) { + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[0]) : ""; +} + +function stdin_first_nonempty_field() { + for (let line in split(read_stdin(), "\n")) { + let field = first_field(line); + if (field != "") { + print(field, "\n"); + return; + } + } +} + +function stdin_first_line_version_field() { + let data = read_stdin(); + let newline = index(data, "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + + if (match(line, /^.*version[ \t]*/) == null) + return; + + let field = first_field(replace(line, /^.*version[ \t]*/, "")); + if (field != "") + print(field, "\n"); +} + +function byedpi_restart_count_file(path) { + let data = fs.readfile(path); + let count = 0; + + if (data != null) + for (let line in split(data, "\n")) + if (match(as_string(line), /ciadpi for rule .* exited with code/) != null) + count++; + + print(count, "\n"); +} + +function byedpi_validation_result(valid, message, needle) { + let needles = []; + if (!bool_arg(valid)) { + for (let line in split(read_stdin(), "\n")) { + if (line != "") + push(needles, line); + } + } + + write_json({ + valid: bool_arg(valid), + message: as_string(message), + needle: as_string(needle), + needles + }); +} + +function has_socks_outbound(config_path, tag, address, port) { + let config = read_json_file(config_path); + port = int_arg(port); + for (let outbound in array_or_empty(config && config.outbounds)) { + if (type(outbound) == "object" && + outbound.type == "socks" && + outbound.tag == tag && + outbound.server == address && + int(outbound.server_port || 0) == port) + return true; + } + return false; +} + +function has_direct_mark_outbound(config_path, tag, routing_mark) { + let config = read_json_file(config_path); + routing_mark = int_arg(routing_mark); + for (let outbound in array_or_empty(config && config.outbounds)) { + if (type(outbound) == "object" && + outbound.type == "direct" && + outbound.tag == tag && + int(outbound.routing_mark || 0) == routing_mark) + return true; + } + return false; +} + +function has_route_rule(config_path, inbound, outbound_tag) { + let config = read_json_file(config_path); + for (let rule in array_or_empty(config && config.route && config.route.rules)) { + if (type(rule) == "object" && + rule.action == "route" && + rule.inbound == inbound && + rule.outbound == outbound_tag) + return true; + } + return false; +} + +function value_contains(value, needle) { + if (type(value) == "array") { + for (let item in value) { + if (as_string(item) == needle) + return true; + } + return false; + } + + return as_string(value) == needle; +} + +function find_tagged_item(items, tag) { + for (let item in array_or_empty(items)) { + if (type(item) == "object" && item.tag == tag) + return item; + } + + return null; +} + +function tagged_runtime_summary(config_path, section_name, tag) { + let config = read_json_file(config_path); + let item = find_tagged_item(config && config[section_name], tag); + + if (item == null) { + write_json({ exists: 0 }); + return; + } + + write_json({ + exists: 1, + type: as_string(item.type), + listen: as_string(item.listen), + listen_port: int_arg(item.listen_port) + }); +} + +function has_route_rule_for_inbound(config_path, inbound) { + let config = read_json_file(config_path); + for (let rule in array_or_empty(config && config.route && config.route.rules)) { + if (type(rule) == "object" && + (rule.action == "route" || rule.action == "reject") && + value_contains(rule.inbound, inbound)) + return true; + } + + return false; +} + +function byedpi_status(args) { + write_json({ + installed: bool_arg(args[0]), + package_installed: bool_arg(args[1]), + provider_available: bool_arg(args[2]), + provider_path: as_string(args[3]), + version: as_string(args[4]), + configured: bool_arg(args[5]), + enabled_rule_count: int_arg(args[6]), + expected_process_count: int_arg(args[7]), + running_process_count: int_arg(args[8]), + supervisor_process_count: int_arg(args[9]), + restart_count: int_arg(args[10]), + runtime_unstable: bool_arg(args[11]), + standalone_service_enabled: bool_arg(args[12]), + standalone_service_running: bool_arg(args[13]), + listen_address: as_string(args[14]), + port_base: int_arg(args[15]), + outbounds_configured: bool_arg(args[16]), + routes_configured: bool_arg(args[17]), + ready: bool_arg(args[18]), + conflict: bool_arg(args[19]), + status_message: as_string(args[20]) + }); +} + +function byedpi_check(installed, package_installed, provider_path) { + write_json({ + byedpi_installed: bool_arg(installed), + byedpi_package_installed: bool_arg(package_installed), + byedpi_provider_path: as_string(provider_path) + }); +} + +function zapret_status(args) { + write_json({ + installed: bool_arg(args[0]), + package_installed: bool_arg(args[1]), + provider_available: bool_arg(args[2]), + provider_path: as_string(args[3]), + files_available: bool_arg(args[4]), + ipset_available: bool_arg(args[5]), + version: as_string(args[6]), + configured: bool_arg(args[7]), + enabled_rule_count: int_arg(args[8]), + expected_process_count: int_arg(args[9]), + running_process_count: int_arg(args[10]), + supervisor_process_count: int_arg(args[11]), + standalone_service_enabled: bool_arg(args[12]), + standalone_service_running: bool_arg(args[13]), + standalone_config_present: bool_arg(args[14]), + standalone_conflict: bool_arg(args[15]), + luci_app_installed: bool_arg(args[16]), + queue_base: int_arg(args[17]), + queue_range_end: int_arg(args[18]), + queue_overlap: bool_arg(args[19]), + legacy_runtime_present: bool_arg(args[20]), + ready: bool_arg(args[21]), + conflict: bool_arg(args[22]), + outbounds_configured: bool_arg(args[23]), + routes_configured: bool_arg(args[24]), + status_message: as_string(args[25]) + }); +} + +function zapret_check(installed, package_installed, provider_path) { + write_json({ + zapret_installed: bool_arg(installed), + zapret_package_installed: bool_arg(package_installed), + zapret_provider_path: as_string(provider_path) + }); +} + +function zapret2_status(args) { + write_json({ + installed: bool_arg(args[0]), + package_installed: bool_arg(args[1]), + provider_available: bool_arg(args[2]), + provider_path: as_string(args[3]), + files_available: bool_arg(args[4]), + ipset_available: bool_arg(args[5]), + version: as_string(args[6]), + configured: bool_arg(args[7]), + enabled_rule_count: int_arg(args[8]), + expected_process_count: int_arg(args[9]), + running_process_count: int_arg(args[10]), + supervisor_process_count: int_arg(args[11]), + standalone_service_enabled: bool_arg(args[12]), + standalone_service_running: bool_arg(args[13]), + standalone_config_present: bool_arg(args[14]), + standalone_conflict: bool_arg(args[15]), + luci_app_installed: bool_arg(args[16]), + queue_base: int_arg(args[17]), + queue_range_end: int_arg(args[18]), + queue_overlap: bool_arg(args[19]), + ready: bool_arg(args[20]), + conflict: bool_arg(args[21]), + outbounds_configured: bool_arg(args[22]), + routes_configured: bool_arg(args[23]), + status_message: as_string(args[24]) + }); +} + +function zapret2_check(installed, package_installed, provider_path) { + write_json({ + zapret2_installed: bool_arg(installed), + zapret2_package_installed: bool_arg(package_installed), + zapret2_provider_path: as_string(provider_path) + }); +} + +let mode = ARGV[0] || ""; + +if (mode == "byedpi-validation") + byedpi_validation_result(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "stdin-first-nonempty-field") + stdin_first_nonempty_field(); +else if (mode == "stdin-first-line-version-field") + stdin_first_line_version_field(); +else if (mode == "byedpi-restart-count-file") + byedpi_restart_count_file(ARGV[1]); +else if (mode == "has-socks-outbound") + exit(has_socks_outbound(ARGV[1], ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +else if (mode == "has-direct-mark-outbound") + exit(has_direct_mark_outbound(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "has-route-rule") + exit(has_route_rule(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "inbound-summary") + tagged_runtime_summary(ARGV[1], "inbounds", ARGV[2]); +else if (mode == "endpoint-summary") + tagged_runtime_summary(ARGV[1], "endpoints", ARGV[2]); +else if (mode == "has-route-rule-for-inbound") + exit(has_route_rule_for_inbound(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "byedpi-status") + byedpi_status(slice(ARGV, 1)); +else if (mode == "byedpi-check") + byedpi_check(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "zapret-status") + zapret_status(slice(ARGV, 1)); +else if (mode == "zapret-check") + zapret_check(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "zapret2-status") + zapret2_status(slice(ARGV, 1)); +else if (mode == "zapret2-check") + zapret2_check(ARGV[1], ARGV[2], ARGV[3]); +else { + warn("Usage: providers/status.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/providers/zapret/check.uc b/tachyon/files/usr/lib/providers/zapret/check.uc new file mode 100644 index 00000000..d1745e88 --- /dev/null +++ b/tachyon/files/usr/lib/providers/zapret/check.uc @@ -0,0 +1,5 @@ +#!/usr/bin/env ucode + +let check = require("providers.nfqueue.check"); + +check.run(ARGV, "providers/zapret/check.uc"); diff --git a/tachyon/files/usr/lib/providers/zapret/common.uc b/tachyon/files/usr/lib/providers/zapret/common.uc new file mode 100644 index 00000000..a671a7c3 --- /dev/null +++ b/tachyon/files/usr/lib/providers/zapret/common.uc @@ -0,0 +1,61 @@ +#!/usr/bin/env ucode + +let constants = require("core.constants"); +let validator_module = null; + +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; + +function validator() { + if (validator_module == null) + validator_module = require("providers.zapret.validator"); + return validator_module; +} + +function config(ctx) { + let runtime_constants = (ctx && ctx.constants) || constants; + let lib_dir = (ctx && ctx.lib_dir) || LIB_DIR; + + return { + kind: "zapret", + action: "zapret", + binary_name: "nfqws", + binary: getenv("ZAPRET_NFQWS_BIN") || runtime_constants.ZAPRET_NFQWS_BIN, + provider_bin: getenv("ZAPRET_PROVIDER_NFQWS_BIN") || runtime_constants.ZAPRET_PROVIDER_NFQWS_BIN, + provider_files_dir: getenv("ZAPRET_PROVIDER_FILES_DIR") || runtime_constants.ZAPRET_PROVIDER_FILES_DIR, + provider_ipset_dir: getenv("ZAPRET_PROVIDER_IPSET_DIR") || runtime_constants.ZAPRET_PROVIDER_IPSET_DIR, + state_dir: getenv("ZAPRET_STATE_DIR") || runtime_constants.ZAPRET_STATE_DIR, + pid_dir: getenv("ZAPRET_PID_DIR") || runtime_constants.ZAPRET_PID_DIR, + child_pid_dir: getenv("ZAPRET_CHILD_PID_DIR") || runtime_constants.ZAPRET_CHILD_PID_DIR, + log_dir: getenv("ZAPRET_LOG_DIR") || runtime_constants.ZAPRET_LOG_DIR, + route_mark_base: getenv("ZAPRET_ROUTE_MARK_BASE") || runtime_constants.ZAPRET_ROUTE_MARK_BASE, + queue_base: getenv("ZAPRET_QUEUE_BASE") || runtime_constants.ZAPRET_QUEUE_BASE, + queue_range_size: getenv("ZAPRET_QUEUE_RANGE_SIZE") || runtime_constants.ZAPRET_QUEUE_RANGE_SIZE, + respawn_delay: getenv("ZAPRET_NFQWS_RESPAWN_DELAY") || runtime_constants.ZAPRET_NFQWS_RESPAWN_DELAY, + desync_mark: getenv("ZAPRET_DESYNC_MARK") || runtime_constants.ZAPRET_DESYNC_MARK, + desync_mark_postnat: getenv("ZAPRET_DESYNC_MARK_POSTNAT") || runtime_constants.ZAPRET_DESYNC_MARK_POSTNAT, + default_strategy: getenv("ZAPRET_DEFAULT_NFQWS_OPT") || runtime_constants.ZAPRET_DEFAULT_NFQWS_OPT, + legacy_default_strategy: getenv("ZAPRET_LEGACY_DEFAULT_NFQWS_OPT") || runtime_constants.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT, + strategy_option: "nfqws_opt", + validator_kind: "nfqws", + validator, + package_name: "zapret", + runtime_path: lib_dir + "/providers/zapret/runtime.uc", + check_path: lib_dir + "/providers/zapret/check.uc", + luci_package: "luci-app-zapret", + luci_menu: "/usr/share/luci/menu.d/luci-app-zapret.json", + luci_acl: "/usr/share/rpcd/acl.d/luci-app-zapret.json", + service_init: "/etc/init.d/zapret", + config_name: "zapret", + legacy_runtime_base: getenv("ZAPRET_LEGACY_RUNTIME_BASE_DIR") || runtime_constants.ZAPRET_LEGACY_RUNTIME_BASE_DIR, + provider_base_dir: runtime_constants.ZAPRET_PROVIDER_BASE_DIR, + hostlist_dir: getenv("ZAPRET_HOSTLIST_DIR") || runtime_constants.ZAPRET_HOSTLIST_DIR, + status_label: "zapret", + check_prefix: "zapret", + base_args: [ "--dpi-desync-fwmark=" + (getenv("ZAPRET_DESYNC_MARK") || runtime_constants.ZAPRET_DESYNC_MARK) ] + }; +} + +return { + config, + validator +}; diff --git a/tachyon/files/usr/lib/providers/zapret/runtime.uc b/tachyon/files/usr/lib/providers/zapret/runtime.uc new file mode 100644 index 00000000..7d3e655d --- /dev/null +++ b/tachyon/files/usr/lib/providers/zapret/runtime.uc @@ -0,0 +1,6 @@ +#!/usr/bin/env ucode + +let runtime = require("providers.nfqueue.runtime"); +let provider = require("providers.zapret.common"); + +runtime.run(provider, ARGV); diff --git a/tachyon/files/usr/lib/providers/zapret/validator.uc b/tachyon/files/usr/lib/providers/zapret/validator.uc new file mode 100644 index 00000000..780cdd8c --- /dev/null +++ b/tachyon/files/usr/lib/providers/zapret/validator.uc @@ -0,0 +1,23 @@ +#!/usr/bin/env ucode + +let validator = require("providers.nfqueue.validator"); + +const KIND = "nfqws"; +const USAGE = "providers/zapret/validator.uc [legacy-default]"; + +function validate_strategy(kind, raw_opt, legacy_default) { + return validator.validate_expected_strategy(KIND, kind, raw_opt, legacy_default); +} + +function module_exports() { + return { + normalize_strategy_whitespace: validator.normalize_strategy_whitespace, + strategy_or_default: validator.strategy_or_default, + validate_strategy + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +validator.run_expected(KIND, USAGE, ARGV); diff --git a/tachyon/files/usr/lib/providers/zapret2/check.uc b/tachyon/files/usr/lib/providers/zapret2/check.uc new file mode 100644 index 00000000..f0f80e23 --- /dev/null +++ b/tachyon/files/usr/lib/providers/zapret2/check.uc @@ -0,0 +1,5 @@ +#!/usr/bin/env ucode + +let check = require("providers.nfqueue.check"); + +check.run(ARGV, "providers/zapret2/check.uc"); diff --git a/tachyon/files/usr/lib/providers/zapret2/common.uc b/tachyon/files/usr/lib/providers/zapret2/common.uc new file mode 100644 index 00000000..9ee38baf --- /dev/null +++ b/tachyon/files/usr/lib/providers/zapret2/common.uc @@ -0,0 +1,68 @@ +#!/usr/bin/env ucode + +let constants = require("core.constants"); +let validator_module = null; + +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; + +function validator() { + if (validator_module == null) + validator_module = require("providers.zapret2.validator"); + return validator_module; +} + +function config(ctx) { + let runtime_constants = (ctx && ctx.constants) || constants; + let lib_dir = (ctx && ctx.lib_dir) || LIB_DIR; + let desync_mark = getenv("ZAPRET2_DESYNC_MARK") || runtime_constants.ZAPRET2_DESYNC_MARK; + let provider_lua_dir = getenv("ZAPRET2_PROVIDER_LUA_DIR") || runtime_constants.ZAPRET2_PROVIDER_LUA_DIR; + + return { + kind: "zapret2", + action: "zapret2", + binary_name: "nfqws2", + binary: getenv("ZAPRET2_NFQWS2_BIN") || runtime_constants.ZAPRET2_NFQWS2_BIN, + provider_bin: getenv("ZAPRET2_PROVIDER_NFQWS2_BIN") || runtime_constants.ZAPRET2_PROVIDER_NFQWS2_BIN, + provider_files_dir: getenv("ZAPRET2_PROVIDER_FILES_DIR") || runtime_constants.ZAPRET2_PROVIDER_FILES_DIR, + provider_ipset_dir: getenv("ZAPRET2_PROVIDER_IPSET_DIR") || runtime_constants.ZAPRET2_PROVIDER_IPSET_DIR, + provider_lua_dir, + state_dir: getenv("ZAPRET2_STATE_DIR") || runtime_constants.ZAPRET2_STATE_DIR, + pid_dir: getenv("ZAPRET2_PID_DIR") || runtime_constants.ZAPRET2_PID_DIR, + child_pid_dir: getenv("ZAPRET2_CHILD_PID_DIR") || runtime_constants.ZAPRET2_CHILD_PID_DIR, + log_dir: getenv("ZAPRET2_LOG_DIR") || runtime_constants.ZAPRET2_LOG_DIR, + route_mark_base: getenv("ZAPRET2_ROUTE_MARK_BASE") || runtime_constants.ZAPRET2_ROUTE_MARK_BASE, + queue_base: getenv("ZAPRET2_QUEUE_BASE") || runtime_constants.ZAPRET2_QUEUE_BASE, + queue_range_size: getenv("ZAPRET2_QUEUE_RANGE_SIZE") || runtime_constants.ZAPRET2_QUEUE_RANGE_SIZE, + respawn_delay: getenv("ZAPRET2_NFQWS2_RESPAWN_DELAY") || runtime_constants.ZAPRET2_NFQWS2_RESPAWN_DELAY, + desync_mark, + desync_mark_postnat: getenv("ZAPRET2_DESYNC_MARK_POSTNAT") || runtime_constants.ZAPRET2_DESYNC_MARK_POSTNAT, + default_strategy: getenv("ZAPRET2_DEFAULT_NFQWS2_OPT") || runtime_constants.ZAPRET2_DEFAULT_NFQWS2_OPT, + legacy_default_strategy: "", + strategy_option: "nfqws2_opt", + validator_kind: "nfqws2", + validator, + package_name: "zapret2", + runtime_path: lib_dir + "/providers/zapret2/runtime.uc", + check_path: lib_dir + "/providers/zapret2/check.uc", + luci_package: "luci-app-zapret2", + luci_menu: "/usr/share/luci/menu.d/luci-app-zapret2.json", + luci_acl: "/usr/share/rpcd/acl.d/luci-app-zapret2.json", + service_init: "/etc/init.d/zapret2", + config_name: "zapret2", + legacy_runtime_base: "", + hostlist_dir: "", + status_label: "zapret2", + check_prefix: "zapret2", + base_args: [ + "--fwmark=" + desync_mark, + "--lua-init=@" + provider_lua_dir + "/zapret-lib.lua", + "--lua-init=@" + provider_lua_dir + "/zapret-antidpi.lua", + "--lua-init=@" + provider_lua_dir + "/zapret-auto.lua" + ] + }; +} + +return { + config, + validator +}; diff --git a/tachyon/files/usr/lib/providers/zapret2/runtime.uc b/tachyon/files/usr/lib/providers/zapret2/runtime.uc new file mode 100644 index 00000000..e9a30f53 --- /dev/null +++ b/tachyon/files/usr/lib/providers/zapret2/runtime.uc @@ -0,0 +1,6 @@ +#!/usr/bin/env ucode + +let runtime = require("providers.nfqueue.runtime"); +let provider = require("providers.zapret2.common"); + +runtime.run(provider, ARGV); diff --git a/tachyon/files/usr/lib/providers/zapret2/validator.uc b/tachyon/files/usr/lib/providers/zapret2/validator.uc new file mode 100644 index 00000000..d947beb3 --- /dev/null +++ b/tachyon/files/usr/lib/providers/zapret2/validator.uc @@ -0,0 +1,23 @@ +#!/usr/bin/env ucode + +let validator = require("providers.nfqueue.validator"); + +const KIND = "nfqws2"; +const USAGE = "providers/zapret2/validator.uc "; + +function validate_strategy(kind, raw_opt, legacy_default) { + return validator.validate_expected_strategy(KIND, kind, raw_opt, legacy_default); +} + +function module_exports() { + return { + normalize_strategy_whitespace: validator.normalize_strategy_whitespace, + strategy_or_default: validator.strategy_or_default, + validate_strategy + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +validator.run_expected(KIND, USAGE, ARGV); diff --git a/tachyon/files/usr/lib/routing/rulesets.uc b/tachyon/files/usr/lib/routing/rulesets.uc new file mode 100644 index 00000000..511638d5 --- /dev/null +++ b/tachyon/files/usr/lib/routing/rulesets.uc @@ -0,0 +1,542 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let ip = require("core.ip"); +let domain_config = require("config.domain"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function json_decode_text(text) { + try { + return json(as_string(text)); + } + catch (e) { + return null; + } +} + +function read_json_file(path) { + let data = fs.readfile(path); + return data == null ? null : json_decode_text(data); +} + +function write_text_file(path, text) { + let result = fs.writefile(path, text); + if (result == null) + return false; + if (type(result) == "boolean" && !result) + return false; + return true; +} + +function write_json_file(path, value) { + return write_text_file(path, sprintf("%J", value) + "\n"); +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function array_from_value(value) { + if (value == null) + return []; + if (type(value) == "array") + return value; + return [ value ]; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function trim_string(value) { + let text = as_string(value); + let start_match = match(text, /^[ \t\r\n]*/); + let start = start_match ? length(start_match[0]) : 0; + let end = length(text); + + while (end > start && match(substr(text, end - 1, 1), /[ \t\r\n]/)) + end--; + + return substr(text, start, end - start); +} + +function sort_values(values) { + sort(values, function(first, second) { + first = sprintf("%J", first); + second = sprintf("%J", second); + return first == second ? 0 : (first < second ? -1 : 1); + }); + return values; +} + +function unique_values(values) { + values = sort_values(values); + let result = []; + let previous = null; + let has_previous = false; + + for (let value in values) { + let encoded = sprintf("%J", value); + if (!has_previous || encoded != previous) { + push(result, value); + previous = encoded; + has_previous = true; + } + } + + return result; +} + +function create_source(path) { + if (!write_json_file(path, { version: 3, rules: [] })) + exit(1); +} + +function ruleset_tag(section, name, kind) { + kind = as_string(kind); + return kind == "" + ? as_string(section) + "-" + as_string(name) + "-ruleset" + : as_string(section) + "-" + as_string(name) + "-" + kind + "-ruleset"; +} + +function patch_source_values(path, key, values) { + let ruleset = object_or_empty(read_json_file(path)); + values = array_or_empty(values); + + if (type(ruleset.rules) != "array") + ruleset.rules = []; + + let found = false; + for (let rule in ruleset.rules) { + if (type(rule) == "object" && rule[key] != null) { + let merged = []; + for (let item in array_or_empty(rule[key])) + push(merged, item); + for (let item in values) + push(merged, item); + rule[key] = unique_values(merged); + found = true; + break; + } + } + + if (!found) { + let rule = {}; + rule[key] = values; + push(ruleset.rules, rule); + } + + if (!write_json_file(path, ruleset)) + exit(1); +} + +function patch_source(path, key, value_text) { + patch_source_values(path, key, json_decode_text(value_text)); +} + +function normalize_plain_ruleset_value(value, kind) { + if (kind == "domains") + return domain_config.suffix_to_ascii(value); + if (kind == "subnets") + return ip.nft_ip_or_cidr(value) ? value : null; + + return null; +} + +function write_optional_lines(path, lines) { + if (path == null || path == "") + return; + + if (!write_text_file(path, length(lines) > 0 ? join("\n", lines) + "\n" : "")) + exit(1); +} + +function import_plain_list(plain_path, ruleset_path, key, kind, chunk_size_text, invalid_path, counts_path) { + let data = fs.readfile(plain_path); + if (data == null) + exit(1); + + let chunk_size = int(chunk_size_text || 5000); + if (chunk_size < 1) + chunk_size = 5000; + + let chunk = []; + let invalid = []; + let counts = []; + + for (let line in split(as_string(data), "\n")) { + line = trim_string(line); + + if (line == "") + continue; + + let normalized = normalize_plain_ruleset_value(line, kind); + if (normalized == null) { + push(invalid, line); + continue; + } + + push(chunk, normalized); + + if (length(chunk) == chunk_size) { + push(counts, "" + length(chunk)); + patch_source_values(ruleset_path, key, chunk); + chunk = []; + } + } + + if (length(chunk) > 0) { + push(counts, "" + length(chunk)); + patch_source_values(ruleset_path, key, chunk); + } + + write_optional_lines(invalid_path, invalid); + write_optional_lines(counts_path, counts); +} + +function extract_ip_cidr(json_path, output_path) { + let ruleset = object_or_empty(read_json_file(json_path)); + let lines = []; + + for (let rule in array_or_empty(ruleset.rules)) { + if (type(rule) != "object") + continue; + for (let ip in array_or_empty(rule.ip_cidr)) + push(lines, as_string(ip)); + } + + if (!write_text_file(output_path, length(lines) > 0 ? join("\n", lines) + "\n" : "")) + exit(1); +} + +function parse_port_number(value) { + let text = trim_string(value); + if (text == "" || match(text, /[^0-9]/)) + return null; + + let number = int(text); + if (number < 1 || number > 65535) + return null; + + return number; +} + +function parse_port_interval(value, allow_open) { + let text = trim_string(value); + if (text == "") + return null; + + let separator = index(text, ":") >= 0 ? ":" : (index(text, "-") >= 0 ? "-" : ""); + if (separator == "") { + let port = parse_port_number(text); + return port == null ? null : { start: port, end: port }; + } + + let parts = split(text, separator); + if (length(parts) != 2) + return null; + + let start = parts[0] == "" && allow_open ? 1 : parse_port_number(parts[0]); + let end = parts[1] == "" && allow_open ? 65535 : parse_port_number(parts[1]); + + if (start == null || end == null || start > end) + return null; + + return { start, end }; +} + +function normalize_port_intervals(intervals) { + let sorted = []; + for (let interval in array_or_empty(intervals)) { + if (type(interval) == "object" && interval.start != null && interval.end != null) + push(sorted, { start: int(interval.start), end: int(interval.end) }); + } + + sort(sorted, function(first, second) { + if (first.start == second.start) + return first.end - second.end; + return first.start - second.start; + }); + + let result = []; + for (let interval in sorted) { + if (length(result) == 0) { + push(result, interval); + continue; + } + + let last = result[length(result) - 1]; + if (interval.start <= last.end + 1) { + if (interval.end > last.end) + last.end = interval.end; + } + else { + push(result, interval); + } + } + + return result; +} + +function direct_port_filter(rule) { + if (type(rule) != "object") + return null; + + let intervals = []; + for (let port in array_from_value(rule.port)) { + let interval = parse_port_interval(port, false); + if (interval != null) + push(intervals, interval); + } + + for (let port_range in array_from_value(rule.port_range)) { + let interval = parse_port_interval(port_range, true); + if (interval != null) + push(intervals, interval); + } + + return length(intervals) == 0 ? null : normalize_port_intervals(intervals); +} + +function filter_is_empty(filter) { + return type(filter) == "array" && length(filter) == 0; +} + +function intersect_port_filters(first, second) { + if (first == null) + return second; + if (second == null) + return first; + if (filter_is_empty(first) || filter_is_empty(second)) + return []; + + let result = []; + for (let left in first) { + for (let right in second) { + let start = left.start > right.start ? left.start : right.start; + let end = left.end < right.end ? left.end : right.end; + if (start <= end) + push(result, { start, end }); + } + } + + return normalize_port_intervals(result); +} + +function union_port_filters(first, second) { + if (first == null || second == null) + return null; + + let result = []; + for (let item in first) + push(result, item); + for (let item in second) + push(result, item); + + return normalize_port_intervals(result); +} + +function extract_port_filter(rule) { + if (type(rule) != "object") + return null; + + let own_filter = direct_port_filter(rule); + if (rule.type != "logical" || type(rule.rules) != "array") + return own_filter; + + let mode = as_string(rule.mode); + if (mode == "or") { + let result = null; + let initialized = false; + + for (let child in rule.rules) { + let child_filter = extract_port_filter(child); + if (!initialized) { + result = child_filter; + initialized = true; + } + else { + result = union_port_filters(result, child_filter); + } + + if (result == null) + break; + } + + return intersect_port_filters(own_filter, result); + } + + let result = own_filter; + for (let child in rule.rules) { + result = intersect_port_filters(result, extract_port_filter(child)); + if (filter_is_empty(result)) + return result; + } + + return result; +} + +function nft_port_value(interval) { + return interval.start == interval.end ? "" + interval.start : interval.start + "-" + interval.end; +} + +function add_line(lines, value) { + if (value != "") + lines[value] = true; +} + +function add_ip_cidr_values_to_nft_outputs(rule, port_filter, unscoped_lines, scoped_lines) { + for (let ip in array_from_value(rule.ip_cidr)) { + ip = trim_string(ip); + if (ip == "") + continue; + + if (port_filter == null) { + add_line(unscoped_lines, ip); + continue; + } + + if (filter_is_empty(port_filter)) + continue; + + for (let interval in port_filter) + add_line(scoped_lines, ip + " . " + nft_port_value(interval)); + } +} + +function collect_ip_cidr_nft_outputs(rule, inherited_filter, unscoped_lines, scoped_lines) { + if (type(rule) != "object") + return; + + let own_filter = direct_port_filter(rule); + let filter = intersect_port_filters(inherited_filter, own_filter); + + if (filter_is_empty(filter)) + return; + + if (rule.type == "logical" && type(rule.rules) == "array") { + if (as_string(rule.mode) == "and") { + for (let child in rule.rules) { + filter = intersect_port_filters(filter, extract_port_filter(child)); + if (filter_is_empty(filter)) + return; + } + } + + add_ip_cidr_values_to_nft_outputs(rule, filter, unscoped_lines, scoped_lines); + + for (let child in rule.rules) + collect_ip_cidr_nft_outputs(child, filter, unscoped_lines, scoped_lines); + + return; + } + + add_ip_cidr_values_to_nft_outputs(rule, filter, unscoped_lines, scoped_lines); +} + +function write_lines(path, lines_object) { + let lines = keys(lines_object); + sort(lines, function(first, second) { + return first == second ? 0 : (first < second ? -1 : 1); + }); + + if (!write_text_file(path, length(lines) > 0 ? join("\n", lines) + "\n" : "")) + exit(1); +} + +function extract_ip_cidr_nft_elements(json_path, unscoped_output_path, scoped_output_path, ports_text, port_ranges_text) { + let ruleset = object_or_empty(read_json_file(json_path)); + let unscoped_lines = {}; + let scoped_lines = {}; + let outer_filter = direct_port_filter({ + port: array_or_empty(json_decode_text(ports_text)), + port_range: array_or_empty(json_decode_text(port_ranges_text)) + }); + + for (let rule in array_or_empty(ruleset.rules)) + collect_ip_cidr_nft_outputs(rule, outer_filter, unscoped_lines, scoped_lines); + + write_lines(unscoped_output_path, unscoped_lines); + write_lines(scoped_output_path, scoped_lines); +} + +function value_has_domain_matchers(value) { + if (type(value) == "array") { + for (let item in value) { + if (value_has_domain_matchers(item)) + return true; + } + return false; + } + + if (type(value) != "object") + return false; + + for (let key, item in value) { + if (key == "domain" || key == "domain_suffix" || key == "domain_keyword" || key == "domain_regex") { + if (type(item) == "array" && length(item) > 0) + return true; + if (type(item) == "string" && item != "") + return true; + } + + if (value_has_domain_matchers(item)) + return true; + } + + return false; +} + +function has_domain_matchers(path) { + return value_has_domain_matchers(read_json_file(path)); +} + +function has_rules(path) { + let ruleset = object_or_empty(read_json_file(path)); + return length(array_or_empty(ruleset.rules)) > 0; +} + +function module_exports() { + return { + create_source, + patch_source, + patch_source_values, + import_plain_list, + extract_ip_cidr, + extract_ip_cidr_nft_elements, + has_domain_matchers, + has_rules, + ruleset_tag, + read_json_file, + write_json_file + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +let mode = ARGV[0] || ""; + +if (mode == "create-source") + create_source(ARGV[1]); +else if (mode == "tag") + print(ruleset_tag(ARGV[1], ARGV[2], ARGV[3]), "\n"); +else if (mode == "patch-source") + patch_source(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "import-plain-list") + import_plain_list(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); +else if (mode == "extract-ip-cidr") + extract_ip_cidr(ARGV[1], ARGV[2]); +else if (mode == "extract-ip-cidr-nft") + extract_ip_cidr_nft_elements(ARGV[1], ARGV[2], ARGV[3], ARGV[4] || "[]", ARGV[5] || "[]"); +else if (mode == "has-domain-matchers") + exit(has_domain_matchers(ARGV[1]) ? 0 : 1); +else if (mode == "has-rules") + exit(has_rules(ARGV[1]) ? 0 : 1); +else { + warn("Usage: routing/rulesets.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/server/service.uc b/tachyon/files/usr/lib/server/service.uc new file mode 100644 index 00000000..2435e605 --- /dev/null +++ b/tachyon/files/usr/lib/server/service.uc @@ -0,0 +1,1088 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let core_ip = require("core.ip"); +let core_url = require("core.url"); +let uci = require("core.uci"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function ascii_lower(value) { + let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let lower = "abcdefghijklmnopqrstuvwxyz"; + return replace(as_string(value), /[A-Z]/g, function(ch) { + return substr(lower, index(upper, ch), 1); + }); +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function run(command) { + return system(command) == 0; +} + +function output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return replace(as_string(data), /[\r\n]+$/g, ""); +} + +function log(message, level) { + level = as_string(level || "info"); + run("logger -t " + shell_quote("tachyon") + " " + shell_quote("[" + level + "] " + as_string(message))); +} + +function fatal(message) { + log(message, "fatal"); + exit(1); +} + +function uci_get(path) { + return uci.get(path); +} + +function uci_set(path, value) { + return uci.set(path, value); +} + +function uci_commit(package_name) { + return uci.commit(package_name); +} + +function words(value) { + value = trim(as_string(value)); + return value == "" ? [] : split(value, /[ \t\r\n]+/); +} + +function uci_sections(type_name) { + return uci.sections(CONFIG_NAME, type_name); +} + +function config_path(section, option) { + return CONFIG_NAME + "." + as_string(section) + "." + as_string(option); +} + +function config_get(section, option, fallback) { + let value = uci_get(config_path(section, option)); + return value == "" ? as_string(fallback || "") : value; +} + +let SERVER_DEFAULTS_CHANGED = false; + +function server_default_set_option(section, option, value) { + value = as_string(value); + if (value == "") + return; + if (uci_get(config_path(section, option)) != "") + return; + if (uci_set(config_path(section, option), value)) + SERVER_DEFAULTS_CHANGED = true; +} + +function server_set_option(section, option, value) { + value = as_string(value); + if (value == "") + return; + if (uci_get(config_path(section, option)) == value) + return; + if (uci_set(config_path(section, option), value)) + SERVER_DEFAULTS_CHANGED = true; +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_stdin_json() { + try { + return json(read_stdin()); + } + catch (e) { + return null; + } +} + +function stdin_first_nonempty_line() { + for (let line in split(read_stdin(), "\n")) { + if (trim(as_string(line)) != "") { + print(line, "\n"); + return; + } + } +} + +function stdin_remove_newlines() { + print(replace(read_stdin(), /\n/g, "")); +} + +function shell_single_quote(value) { + value = as_string(value); + if (value == "") + return; + print("'", replace(value, /'/g, "'\\''"), "'\n"); +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function array_append_string(value) { + let result = array_or_empty(read_stdin_json()); + push(result, as_string(value)); + write_json(result); +} + +function regex_matches(value, pattern) { + pattern = as_string(pattern); + if (pattern == "") + return false; + + try { + return match(as_string(value), regexp(pattern)) != null; + } + catch (e) { + return false; + } +} + +function valid_dotted_ipv4(value) { + return regex_matches(value, "^([0-9]{1,3}\\.){3}[0-9]{1,3}$"); +} + +function valid_file_path(value) { + value = as_string(value); + return value != "" && + value != "/" && + substr(value, length(value) - 1) != "/" && + regex_matches(value, "^/[A-Za-z0-9_./-]+$"); +} + +function valid_duration(value) { + value = as_string(value); + return value != "" && regex_matches(value, "^([0-9]+(ns|us|ms|s|m|h))+$"); +} + +function valid_client_value(value) { + value = as_string(value); + return value != "" && !regex_matches(value, "[[:cntrl:]]"); +} + +function valid_port(value) { + value = as_string(value); + if (value == "" || regex_matches(value, "[^0-9]")) + return false; + + value = int(value); + return value >= 1 && value <= 65535; +} + +function normalize_host(value) { + value = as_string(value); + if (substr(value, 0, 1) == "[") + value = substr(value, 1); + if (substr(value, length(value) - 1) == "]") + value = substr(value, 0, length(value) - 1); + return lc(value); +} + +function str_endswith(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix; +} + +function str_remove_suffix(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return str_endswith(value, suffix) ? substr(value, 0, length(value) - length(suffix)) : value; +} + +function valid_ipv4(value) { + return core_ip.valid_ipv4(value, false, false); +} + +function valid_domain(value) { + return regex_matches(ascii_lower(value), "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$"); +} + +function valid_host(value) { + value = normalize_host(value); + return value != "" && (valid_ipv4(value) || core_ip.valid_ipv6(value) || valid_domain(value)); +} + +function valid_absolute_path(value) { + return substr(as_string(value), 0, 1) == "/"; +} + +function valid_http_url(value) { + value = as_string(value); + let scheme = index(value, "://"); + if (scheme < 0) + return false; + + let prefix = substr(value, 0, scheme + 3); + if (prefix != "http://" && prefix != "https://") + return false; + + let rest = substr(value, scheme + 3); + if (rest == "") + return false; + + return valid_host(core_url.host(value)); +} + +function valid_uuid(value) { + return regex_matches(value, "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); +} + +function valid_nonnegative_integer(value) { + return regex_matches(value, "^[0-9]+$"); +} + +function valid_reality_short_id(value) { + return regex_matches(value, "^[0-9a-fA-F]{1,8}$"); +} + +function valid_transport_service_name(value) { + return regex_matches(value, "^[A-Za-z0-9_.-]+$"); +} + +function mtproto_base_secret_value(value) { + value = lc(as_string(value)); + if (regex_matches(value, "^[0-9a-f]{32}$")) + return value; + if (regex_matches(value, "^ee[0-9a-f]{32}([0-9a-f]{2})+$")) + return substr(value, 2, 32); + return ""; +} + +function mtproto_base_secret_from_value(value) { + let result = mtproto_base_secret_value(value); + if (result == "") + exit(1); + print(result, "\n"); +} + +function hex_digit_value(value) { + value = ord(lc(as_string(value))); + if (value >= 48 && value <= 57) + return value - 48; + if (value >= 97 && value <= 102) + return value - 87; + return -1; +} + +function hex_to_string_value(value) { + value = as_string(value); + if (length(value) == 0 || length(value) % 2 != 0 || !regex_matches(value, "^([0-9a-fA-F]{2})+$")) + return null; + + let result = ""; + for (let i = 0; i < length(value); i += 2) { + let high = hex_digit_value(substr(value, i, 1)); + let low = hex_digit_value(substr(value, i + 1, 1)); + if (high < 0 || low < 0) + return null; + result += chr(high * 16 + low); + } + return result; +} + +function string_to_hex(value) { + value = as_string(value); + for (let i = 0; i < length(value); i++) + print(sprintf("%02x", ord(substr(value, i, 1)))); +} + +function mtproto_faketls_value(value) { + value = lc(as_string(value)); + if (!regex_matches(value, "^ee[0-9a-f]{32}([0-9a-f]{2})+$")) + return ""; + let result = hex_to_string_value(substr(value, 34)); + return result == null ? "" : result; +} + +function mtproto_faketls_from_secret(value) { + let result = mtproto_faketls_value(value); + if (result == "") + exit(1); + print(result); +} + +function mtproto_build_secret(base, faketls, padding) { + if (as_string(padding) != "1") + exit(1); + + print("ee", as_string(base)); + string_to_hex(faketls); + print("\n"); +} + +function valid_mtproto_base_secret(value) { + value = lc(as_string(value)); + return regex_matches(value, "^[0-9a-f]{32}$") && value != "00000000000000000000000000000000"; +} + +function default_security_for_protocol(protocol) { + protocol = as_string(protocol); + if (protocol == "vless") + print("reality\n"); + else if (protocol == "trojan" || protocol == "hysteria2") + print("tls\n"); + else + print("none\n"); +} + +function safe_filename(value) { + value = as_string(value); + for (let i = 0; i < length(value); i++) { + let chr = substr(value, i, 1); + print(regex_matches(chr, "^[A-Za-z0-9_.-]$") ? chr : "_"); + } +} + +function valid_public_ipv4(value) { + value = as_string(value); + if (!valid_ipv4(value)) + return false; + + let parts = split(value, "."); + let a = int(parts[0]); + let b = int(parts[1]); + + if (a == 0 || a == 10 || a == 127 || a >= 224) + return false; + if (a == 169 && b == 254) + return false; + if (a == 192 && (b == 168 || b == 0 || b == 2)) + return false; + if (a == 198 && (b == 18 || b == 19 || b == 51)) + return false; + if (a == 203 && b == 0) + return false; + if (a == 100 && b >= 64 && b <= 127) + return false; + if (a == 172 && b >= 16 && b <= 31) + return false; + + return true; +} + +function valid_ipv6_literal(value) { + return core_ip.valid_ipv6(value); +} + +function valid_cidr(value) { + value = as_string(value); + let slash = index(value, "/"); + if (slash < 0 || index(substr(value, slash + 1), "/") >= 0) + return false; + + let address = substr(value, 0, slash); + let prefix = substr(value, slash + 1); + if (prefix == "" || regex_matches(prefix, "[^0-9]")) + return false; + + prefix = int(prefix); + if (valid_ipv4(address)) + return prefix >= 0 && prefix <= 32; + if (valid_ipv6_literal(address)) + return prefix >= 0 && prefix <= 128; + + return false; +} + +function server_users_from_tsv(protocol, path) { + let result = []; + let data = fs.readfile(path); + + if (data != null) { + for (let line in split(as_string(data), "\n")) { + if (line == "") + continue; + + let parts = split(line, "\t"); + let name = as_string(parts[0] || ""); + let credential = as_string(parts[1] || ""); + let flow = as_string(parts[2] || ""); + + if (credential == "") + continue; + + if (protocol == "vless") { + let user = { uuid: credential }; + if (name != "") + user.name = name; + if (flow != "") + user.flow = flow; + push(result, user); + } + else if (protocol == "vmess") { + let user = { uuid: credential, alterId: int(flow || "0", 10) || 0 }; + if (name != "") + user.name = name; + push(result, user); + } + else if (protocol == "trojan") { + let user = { password: credential }; + if (name != "") + user.name = name; + push(result, user); + } + else if (protocol == "hysteria2") { + let user = { password: credential }; + if (name != "") + user.name = name; + push(result, user); + } + else if (protocol == "mtproto") { + let user = { secret: credential }; + if (name != "") + user.name = name; + push(result, user); + } + else if (protocol == "socks") { + let user = { + username: name != "" ? name : "user", + password: credential + }; + push(result, user); + } + } + } + + write_json(result); +} + +function json_length(path) { + let value = read_json_file(path); + if (type(value) == "array" || type(value) == "object") + print(length(value), "\n"); + else + print("0\n"); +} + +function error_response(message) { + write_json({ + success: false, + message: as_string(message) + }); +} + +function reality_keypair_response(private_key, public_key) { + write_json({ + success: true, + private_key: as_string(private_key), + public_key: as_string(public_key) + }); +} + +function tls_fingerprint_response(fingerprint) { + write_json({ + success: true, + sha256: as_string(fingerprint) + }); +} + +function first_key_value_line_value(data, prefix) { + prefix = as_string(prefix); + for (let line in split(as_string(data), "\n")) { + line = as_string(line); + if (!regex_matches(line, "^" + prefix + ":")) + continue; + + let value = substr(line, length(prefix) + 1); + return replace(value, /^[ \t\r]*/, ""); + } + + return null; +} + +function reality_keypair_tsv() { + let data = read_stdin(); + let private_key = first_key_value_line_value(data, "PrivateKey"); + let public_key = first_key_value_line_value(data, "PublicKey"); + + if (private_key == null || public_key == null || private_key == "" || public_key == "") + exit(1); + + print(private_key, "\t", public_key, "\n"); +} + +function pem_block(data, begin_marker, end_marker) { + let lines = []; + let in_block = false; + + for (let line in split(as_string(data), "\n")) { + line = str_remove_suffix(as_string(line), "\r"); + if (!in_block && line != begin_marker) + continue; + + in_block = true; + push(lines, line); + + if (line == end_marker) + return join("\n", lines); + } + + return ""; +} + +function write_tls_keypair_files(key_path, certificate_path) { + let data = read_stdin(); + let key = pem_block(data, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----"); + let cert = pem_block(data, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); + + if (key == "" || cert == "") + exit(1); + + if (!fs.writefile(key_path, key + "\n")) + exit(1); + if (!fs.writefile(certificate_path, cert + "\n")) + exit(1); +} + +function certificate_base64(path) { + let data = fs.readfile(path); + let cert = pem_block(data, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); + let lines = []; + + if (cert == "") + exit(1); + + for (let line in split(cert, "\n")) { + line = replace(as_string(line), /\r/g, ""); + if (line == "-----BEGIN CERTIFICATE-----" || line == "-----END CERTIFICATE-----" || line == "") + continue; + + push(lines, line); + } + + if (length(lines) == 0) + exit(1); + + print(join("\n", lines), "\n"); +} + +function valid_sha256_hex(value) { + return regex_matches(value, "^[0-9a-f]{64}$"); +} + +function command_first_nonempty_line(command) { + for (let line in split(output(command), "\n")) + if (trim(as_string(line)) != "") + return line; + return ""; +} + +function server_generate_uuid() { + return command_first_nonempty_line("sing-box generate uuid 2>/dev/null"); +} + +function server_generate_password() { + return replace(output("sing-box generate rand --base64 18 2>/dev/null"), /\n/g, ""); +} + +function server_generate_short_id() { + return replace(output("sing-box generate rand --hex 4 2>/dev/null"), /\n/g, ""); +} + +function server_generate_mtproto_secret() { + let random_hex = replace(output("sing-box generate rand --hex 16 2>/dev/null"), /\n/g, ""); + if (random_hex == "") + random_hex = replace(output("head -c 16 /dev/urandom | hexdump -ve '1/1 \"%02x\"' 2>/dev/null"), /\n/g, ""); + if (random_hex == "00000000000000000000000000000000") + random_hex = "11111111111111111111111111111111"; + return random_hex; +} + +function default_security_value(protocol) { + protocol = as_string(protocol); + if (protocol == "vless") + return "reality"; + if (protocol == "trojan" || protocol == "hysteria2") + return "tls"; + return "none"; +} + +function server_effective_security_value(section, protocol) { + protocol = as_string(protocol); + let security = config_get(section, "security", ""); + if (security == "") + security = default_security_value(protocol); + + if (protocol == "shadowsocks" || protocol == "socks" || protocol == "mtproto" || protocol == "tailscale" || protocol == "json_inbound") + security = "none"; + else if (protocol == "hysteria2") + security = "tls"; + else if ((protocol == "vmess" || protocol == "trojan") && security == "reality") + security = default_security_value(protocol); + + return security; +} + +function print_effective_security(section, protocol) { + print(server_effective_security_value(section, protocol), "\n"); +} + +function generate_reality_keypair_values() { + let data = output("sing-box generate reality-keypair 2>/dev/null"); + let private_key = first_key_value_line_value(data, "PrivateKey"); + let public_key = first_key_value_line_value(data, "PublicKey"); + + if (private_key == null || public_key == null || private_key == "" || public_key == "") + return null; + + return { + private_key: private_key, + public_key: public_key + }; +} + +function generate_reality_keypair_cli() { + let pair = generate_reality_keypair_values(); + if (pair == null) { + error_response("Failed to generate Reality key pair"); + exit(1); + } + + reality_keypair_response(pair.private_key, pair.public_key); +} + +function write_tls_keypair_data(data, key_path, certificate_path) { + let key = pem_block(data, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----"); + let cert = pem_block(data, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); + + if (key == "" || cert == "") + return false; + + return fs.writefile(key_path, key + "\n") && fs.writefile(certificate_path, cert + "\n"); +} + +function server_generate_tls_keypair_files(server_name, certificate_path, key_path) { + let data = output("sing-box generate tls-keypair " + shell_quote(server_name) + " 2>/dev/null"); + if (data == "") + return false; + + let key_dir = replace(as_string(key_path), /\/[^\/]*$/, ""); + let cert_dir = replace(as_string(certificate_path), /\/[^\/]*$/, ""); + if (!run("mkdir -p " + shell_quote(key_dir) + " " + shell_quote(cert_dir))) + return false; + + if (!write_tls_keypair_data(data, key_path, certificate_path)) + return false; + + run("chmod 600 " + shell_quote(key_path) + " >/dev/null 2>&1"); + return true; +} + +function certificate_base64_text(path) { + let data = fs.readfile(path); + let cert = pem_block(data, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); + let lines = []; + + if (cert == "") + return ""; + + for (let line in split(cert, "\n")) { + line = replace(as_string(line), /\r/g, ""); + if (line == "-----BEGIN CERTIFICATE-----" || line == "-----END CERTIFICATE-----" || line == "") + continue; + push(lines, line); + } + + return length(lines) == 0 ? "" : join("\n", lines) + "\n"; +} + +function server_tls_certificate_sha256_from_file(certificate_path) { + if (!valid_file_path(certificate_path)) + return ""; + let base64_text = certificate_base64_text(certificate_path); + if (base64_text == "") + return ""; + + let tmp_path = output("mktemp 2>/dev/null"); + if (tmp_path == "") + return ""; + + if (!fs.writefile(tmp_path, base64_text)) { + fs.unlink(tmp_path); + return ""; + } + + let fingerprint = output("base64 -d " + shell_quote(tmp_path) + " 2>/dev/null | sha256sum 2>/dev/null"); + fs.unlink(tmp_path); + fingerprint = substr(fingerprint, 0, index(fingerprint, " ") >= 0 ? index(fingerprint, " ") : length(fingerprint)); + return valid_sha256_hex(fingerprint) ? fingerprint : ""; +} + +function get_tls_certificate_sha256_cli(section) { + if (as_string(section) == "") { + error_response("Missing server section"); + exit(1); + } + + let protocol = config_get(section, "protocol", "vless"); + let security = server_effective_security_value(section, protocol); + if (security != "tls") { + error_response("Server does not use TLS"); + exit(1); + } + + let certificate_path = config_get(section, "tls_certificate_path", ""); + if (certificate_path == "") { + error_response("TLS certificate path is empty"); + exit(1); + } + + let fingerprint = server_tls_certificate_sha256_from_file(certificate_path); + if (fingerprint == "") { + error_response("Failed to read TLS certificate fingerprint"); + exit(1); + } + + tls_fingerprint_response(fingerprint); +} + +function network_status_ipv4_address(data) { + try { + data = json(as_string(data)); + } + catch (e) { + return ""; + } + + let addresses = data["ipv4-address"]; + if (type(addresses) != "array") + return ""; + + for (let item in addresses) + if (type(item) == "object" && as_string(item.address) != "") + return as_string(item.address); + + return ""; +} + +function server_detect_default_public_host() { + for (let interface in [ "wan", "wwan" ]) { + let ip = network_status_ipv4_address(output("ubus -S call " + shell_quote("network.interface." + interface) + " status 2>/dev/null")); + if (valid_public_ipv4(ip)) + return ip; + } + + let lan_ip = uci_get("network.lan.ipaddr"); + return valid_dotted_ipv4(lan_ip) ? lan_ip : ""; +} + +function first_server_user(section) { + let users = words(uci_get(config_path(section, "server_users"))); + return length(users) > 0 ? users[0] : ""; +} + +function server_prepare_legacy_user_defaults(section, protocol) { + let entry = first_server_user(section); + if (entry == "") + return; + + let parts = split(entry, "|"); + let name = "client"; + let credential = entry; + let extra = ""; + if (length(parts) > 1) { + name = as_string(parts[0]); + credential = as_string(parts[1]); + extra = as_string(parts[2] || ""); + } + + if (protocol == "vless" || protocol == "vmess") + server_default_set_option(section, "server_uuid", credential); + else if (protocol == "shadowsocks" || protocol == "socks" || protocol == "trojan" || protocol == "hysteria2") + server_default_set_option(section, "server_password", credential); + else if (protocol == "mtproto") { + let mtproto_faketls = mtproto_faketls_value(credential); + let base_secret = mtproto_base_secret_value(credential); + server_default_set_option(section, "mtproto_secret", base_secret != "" ? base_secret : credential); + server_default_set_option(section, "mtproto_faketls", mtproto_faketls != "" ? mtproto_faketls : "google.com"); + server_default_set_option(section, "mtproto_padding", "1"); + } + + if (protocol == "socks" && name != "") + server_default_set_option(section, "server_username", name); + if (protocol == "vless" && extra != "") + server_default_set_option(section, "vless_flow", extra); +} + +function server_prepare_tls_defaults(section, protocol, security) { + if (security != "tls") + return; + if (config_get(section, "enabled", "1") == "0") + return; + + let tls_server_name = config_get(section, "tls_server_name", ""); + if (tls_server_name == "") + tls_server_name = "www.microsoft.com"; + server_default_set_option(section, "tls_server_name", tls_server_name); + + let certificate_path = config_get(section, "tls_certificate_path", ""); + let key_path = config_get(section, "tls_key_path", ""); + if (certificate_path == "" || key_path == "") { + let safe_name = safe_filename_string(section); + let default_dir = "/etc/tachyon/server-certs"; + if (certificate_path == "") + certificate_path = default_dir + "/" + safe_name + ".crt"; + if (key_path == "") + key_path = default_dir + "/" + safe_name + ".key"; + server_set_option(section, "tls_certificate_path", certificate_path); + server_set_option(section, "tls_key_path", key_path); + } + + if (!valid_file_path(certificate_path)) + fatal("Server '" + section + "' has invalid TLS certificate path. Specify a file path. Aborted."); + if (!valid_file_path(key_path)) + fatal("Server '" + section + "' has invalid TLS key path. Specify a file path. Aborted."); + if (certificate_path == key_path) + fatal("Server '" + section + "' has the same TLS certificate and key path. Specify different files. Aborted."); + + if (!run("[ -s " + shell_quote(certificate_path) + " ] && [ -s " + shell_quote(key_path) + " ]")) { + log("Generating self-signed TLS certificate for server '" + section + "'", "info"); + if (!server_generate_tls_keypair_files(tls_server_name, certificate_path, key_path)) + fatal("Failed to generate TLS certificate for server '" + section + "'. Aborted."); + } +} + +function safe_filename_string(value) { + value = as_string(value); + let result = []; + for (let i = 0; i < length(value); i++) { + let chr = substr(value, i, 1); + push(result, regex_matches(chr, "^[A-Za-z0-9_.-]$") ? chr : "_"); + } + return join("", result); +} + +function prepare_server_defaults(section) { + let protocol = config_get(section, "protocol", "vless"); + if (!(protocol == "shadowsocks" || protocol == "socks" || protocol == "vmess" || protocol == "vless" || + protocol == "trojan" || protocol == "hysteria2" || protocol == "mtproto" || protocol == "tailscale" || + protocol == "json_inbound")) + fatal("Server '" + section + "' has unsupported protocol '" + protocol + "'. Aborted."); + + server_default_set_option(section, "protocol", protocol); + server_default_set_option(section, "label", section); + server_default_set_option(section, "enabled", "1"); + server_default_set_option(section, "routing_mode", "rules"); + + if (protocol == "json_inbound") { + server_set_option(section, "security", "none"); + return; + } + + server_default_set_option(section, "listen", "0.0.0.0"); + server_default_set_option(section, "listen_port", "443"); + server_default_set_option(section, "public_host", server_detect_default_public_host()); + + let security = server_effective_security_value(section, protocol); + server_set_option(section, "security", security); + server_prepare_legacy_user_defaults(section, protocol); + + if (protocol == "vless" || protocol == "vmess") { + if (uci_get(config_path(section, "server_uuid")) == "") { + let uuid = server_generate_uuid(); + server_default_set_option(section, "server_uuid", uuid); + } + } + else if (protocol == "shadowsocks" || protocol == "socks" || protocol == "trojan" || protocol == "hysteria2") { + if (uci_get(config_path(section, "server_password")) == "") { + let password = server_generate_password(); + server_default_set_option(section, "server_password", password); + } + } + + if (protocol == "mtproto") { + let mtproto_secret = uci_get(config_path(section, "mtproto_secret")); + let mtproto_faketls = ""; + let mtproto_base_secret = ""; + if (mtproto_secret != "") { + mtproto_faketls = mtproto_faketls_value(mtproto_secret); + mtproto_base_secret = mtproto_base_secret_value(mtproto_secret); + } + if (mtproto_base_secret == "") + mtproto_base_secret = server_generate_mtproto_secret(); + if (mtproto_secret != mtproto_base_secret) + server_set_option(section, "mtproto_secret", mtproto_base_secret); + server_default_set_option(section, "mtproto_faketls", mtproto_faketls != "" ? mtproto_faketls : "google.com"); + server_default_set_option(section, "mtproto_padding", "1"); + server_default_set_option(section, "mtproto_domain_fronting_port", "443"); + server_default_set_option(section, "mtproto_prefer_ip", "prefer-ipv4"); + server_default_set_option(section, "mtproto_tolerate_time_skewness", "3s"); + server_default_set_option(section, "mtproto_idle_timeout", "5m"); + server_default_set_option(section, "mtproto_handshake_timeout", "10s"); + } + + if (protocol == "socks") { + server_default_set_option(section, "socks_auth_enabled", "1"); + if (uci_get(config_path(section, "server_username")) == "") + server_default_set_option(section, "server_username", config_get(section, "label", section)); + } + if (protocol == "vless") + server_default_set_option(section, "vless_flow", "none"); + if (protocol == "vmess") + server_default_set_option(section, "vmess_alter_id", "0"); + if (protocol == "shadowsocks") + server_default_set_option(section, "shadowsocks_method", "aes-128-gcm"); + if (protocol == "tailscale") { + let safe_name = safe_filename_string(section); + server_default_set_option(section, "tailscale_control_url", "https://controlplane.tailscale.com"); + server_default_set_option(section, "tailscale_hostname", "tachyon-" + safe_name); + server_default_set_option(section, "tailscale_advertise_exit_node", "1"); + } + + if (security == "reality") { + server_default_set_option(section, "tls_server_name", "www.microsoft.com"); + server_default_set_option(section, "client_fingerprint", "chrome"); + server_default_set_option(section, "reality_handshake_server", "www.microsoft.com"); + server_default_set_option(section, "reality_handshake_server_port", "443"); + if (uci_get(config_path(section, "reality_short_id")) == "") + server_default_set_option(section, "reality_short_id", server_generate_short_id()); + server_default_set_option(section, "reality_max_time_difference", "1m"); + + let reality_private_key = uci_get(config_path(section, "reality_private_key")); + let reality_public_key = uci_get(config_path(section, "reality_public_key")); + if (reality_private_key == "" || reality_public_key == "") { + log("Generating Reality key pair for server '" + section + "'", "info"); + let pair = generate_reality_keypair_values(); + if (pair == null) + fatal("Failed to generate Reality key pair for server '" + section + "'. Aborted."); + server_set_option(section, "reality_private_key", pair.private_key); + server_set_option(section, "reality_public_key", pair.public_key); + } + } + + server_prepare_tls_defaults(section, protocol, security); +} + +function prepare_all_server_defaults() { + SERVER_DEFAULTS_CHANGED = false; + for (let section in uci_sections("server")) + prepare_server_defaults(section); + if (SERVER_DEFAULTS_CHANGED) + uci_commit(CONFIG_NAME); +} + +let mode = ARGV[0] || ""; + +if (mode == "valid-file-path") + exit(valid_file_path(ARGV[1]) ? 0 : 1); +else if (mode == "valid-dotted-ipv4") + exit(valid_dotted_ipv4(ARGV[1]) ? 0 : 1); +else if (mode == "stdin-first-nonempty-line") + stdin_first_nonempty_line(); +else if (mode == "stdin-remove-newlines") + stdin_remove_newlines(); +else if (mode == "shell-single-quote") + shell_single_quote(ARGV[1]); +else if (mode == "valid-port") + exit(valid_port(ARGV[1]) ? 0 : 1); +else if (mode == "normalize-host") + print(normalize_host(ARGV[1]), "\n"); +else if (mode == "valid-host") + exit(valid_host(ARGV[1]) ? 0 : 1); +else if (mode == "valid-absolute-path") + exit(valid_absolute_path(ARGV[1]) ? 0 : 1); +else if (mode == "valid-http-url") + exit(valid_http_url(ARGV[1]) ? 0 : 1); +else if (mode == "valid-duration") + exit(valid_duration(ARGV[1]) ? 0 : 1); +else if (mode == "valid-client-value") + exit(valid_client_value(ARGV[1]) ? 0 : 1); +else if (mode == "valid-uuid") + exit(valid_uuid(ARGV[1]) ? 0 : 1); +else if (mode == "valid-nonnegative-integer") + exit(valid_nonnegative_integer(ARGV[1]) ? 0 : 1); +else if (mode == "valid-reality-short-id") + exit(valid_reality_short_id(ARGV[1]) ? 0 : 1); +else if (mode == "valid-transport-service-name") + exit(valid_transport_service_name(ARGV[1]) ? 0 : 1); +else if (mode == "mtproto-base-secret") + mtproto_base_secret_from_value(ARGV[1]); +else if (mode == "mtproto-faketls-from-secret") + mtproto_faketls_from_secret(ARGV[1]); +else if (mode == "mtproto-build-secret") + mtproto_build_secret(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "valid-mtproto-base-secret") + exit(valid_mtproto_base_secret(ARGV[1]) ? 0 : 1); +else if (mode == "default-security-for-protocol") + default_security_for_protocol(ARGV[1]); +else if (mode == "effective-security") + print_effective_security(ARGV[1], ARGV[2]); +else if (mode == "safe-filename") + safe_filename(ARGV[1]); +else if (mode == "valid-public-ipv4") + exit(valid_public_ipv4(ARGV[1]) ? 0 : 1); +else if (mode == "valid-cidr") + exit(valid_cidr(ARGV[1]) ? 0 : 1); +else if (mode == "server-users-from-tsv") + server_users_from_tsv(ARGV[1], ARGV[2]); +else if (mode == "json-length") + json_length(ARGV[1]); +else if (mode == "array-append-string") + array_append_string(ARGV[1]); +else if (mode == "error-response") + error_response(ARGV[1]); +else if (mode == "prepare-all-defaults") + prepare_all_server_defaults(); +else if (mode == "generate-reality-keypair") + generate_reality_keypair_cli(); +else if (mode == "tls-certificate-sha256") + get_tls_certificate_sha256_cli(ARGV[1]); +else if (mode == "reality-keypair-response") + reality_keypair_response(ARGV[1], ARGV[2]); +else if (mode == "reality-keypair-tsv") + reality_keypair_tsv(); +else if (mode == "write-tls-keypair-files") + write_tls_keypair_files(ARGV[1], ARGV[2]); +else if (mode == "certificate-base64") + certificate_base64(ARGV[1]); +else if (mode == "valid-sha256-hex") + exit(valid_sha256_hex(ARGV[1]) ? 0 : 1); +else if (mode == "tls-fingerprint-response") + tls_fingerprint_response(ARGV[1]); +else { + warn("Usage: server/service.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/service/initd.uc b/tachyon/files/usr/lib/service/initd.uc new file mode 100644 index 00000000..2696fc94 --- /dev/null +++ b/tachyon/files/usr/lib/service/initd.uc @@ -0,0 +1,781 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let constants = require("core.constants"); +let uci_core = require("core.uci"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function constant_value(name, fallback) { + let value = constants[name]; + return value == null ? as_string(fallback) : as_string(value); +} + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || constant_value("TACHYON_CONFIG_NAME", "tachyon"); +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const BIN_PATH = getenv("TACHYON_BIN") || constant_value("TACHYON_BIN", "/usr/bin/tachyon"); +const SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || constant_value("TACHYON_SERVICE_INIT", "/etc/init.d/tachyon"); +const SERVICE_NAME = getenv("TACHYON_SERVICE_NAME") || constant_value("TACHYON_SERVICE_NAME", "tachyon"); +const CONFIG_FILE = getenv("TACHYON_CONFIG_FILE") || "/etc/config/" + CONFIG_NAME; +const RELOAD_LOCK_DIR = getenv("TACHYON_RELOAD_LOCK_DIR") || "/var/run/tachyon.reload.lock"; +const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending"; +const START_RETRY_FILE = getenv("TACHYON_START_RETRY_FILE") || RUNTIME_STATE_DIR + "/start.retry"; +const START_RETRY_PID_FILE = getenv("TACHYON_START_RETRY_PID_FILE") || RUNTIME_STATE_DIR + "/start-retry.pid"; +const START_RETRY_DELAY_SECONDS = getenv("TACHYON_START_RETRY_DELAY_SECONDS") || "30"; +const SERVICE_TRIGGER_SYNC_FILE = getenv("TACHYON_SERVICE_TRIGGER_SYNC_FILE") || RUNTIME_STATE_DIR + "/service-triggers.sync"; +const INTERNAL_CONFIG_TRIGGER_GUARD = getenv("TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD") || "/var/run/tachyon.internal-config-change"; +const CONFIG_CHANGE_REASON = getenv("TACHYON_CONFIG_CHANGE_REASON") || "on_config_change"; + +const DNS_APPLY_UC = LIB_DIR + "/dns/apply.uc"; +const UI_UC = LIB_DIR + "/service/ui.uc"; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function shell_assignment(name, value) { + print(as_string(name), "=", shell_quote(value), "\n"); +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; +} + +function command_status(command) { + return normalize_status(system(command)); +} + +function command_capture(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return { status: 1, output: "" }; + + let data = pipe.read("all"); + let status = normalize_status(pipe.close()); + return { status, output: data == null ? "" : as_string(data) }; +} + +function command_output(command) { + let result = command_capture(command); + return result.status == 0 ? result.output : ""; +} + +function command_output_from_args(args) { + return command_output(command_from_args(args) + " 2>/dev/null"); +} + +function command_success_from_args(args) { + return command_status(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_status_from_args(args) { + return command_status(command_from_args(args)); +} + +function module_args(module_path, args) { + let result = [ "ucode", "-L", LIB_DIR, module_path ]; + for (let arg in (type(args) == "array" ? args : [])) + push(result, arg); + return result; +} + +function module_command(module_path, args) { + return command_from_args(module_args(module_path, args)); +} + +function module_status(module_path, args) { + return command_status(module_command(module_path, args)); +} + +function module_output(module_path, args) { + let result = command_capture(module_command(module_path, args)); + return result.status == 0 ? result.output : ""; +} + +function trim(value) { + return replace(as_string(value), /^[ \t\r\n]+|[ \t\r\n]+$/g, ""); +} + +function numeric_text(value) { + return match(as_string(value), /^[0-9]+$/) != null; +} + +function current_epoch() { + return as_string(int(clock()[0])); +} + +function bool_text(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + let value = object_or_empty(section)[key]; + if (value == null) + return as_string(fallback); + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function file_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function file_executable(path) { + return command_success_from_args([ "test", "-x", as_string(path) ]); +} + +function unlink_file(path) { + fs.unlink(as_string(path)); +} + +function ensure_parent_dir(path) { + let dir = replace(as_string(path), /\/[^\/]*$/, ""); + if (dir == "" || dir == path) + return true; + return fs.mkdir(dir, 0755) || fs.stat(dir) != null; +} + +function write_text_file(path, text) { + return fs.writefile(as_string(path), as_string(text)); +} + +function first_line_value(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function config_file_hash(path) { + let fields = split(trim(command_output_from_args([ "md5sum", as_string(path) ])), " "); + return length(fields) > 0 ? as_string(fields[0]) : ""; +} + +function pid_alive(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function lock_dir_write_owner(lock_dir, owner_pid) { + return write_text_file(as_string(lock_dir) + "/pid", as_string(owner_pid) + "\n"); +} + +function acquire_runtime_dir_lock(lock_dir, owner_pid) { + lock_dir = as_string(lock_dir); + owner_pid = as_string(owner_pid); + if (lock_dir == "" || owner_pid == "") + return false; + + if (command_success_from_args([ "mkdir", lock_dir ])) { + if (lock_dir_write_owner(lock_dir, owner_pid)) + return true; + release_runtime_dir_lock(lock_dir); + return false; + } + + if (pid_alive(first_line_value(lock_dir + "/pid"))) + return false; + + command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]); + if (!command_success_from_args([ "rmdir", lock_dir ])) + return false; + if (!command_success_from_args([ "mkdir", lock_dir ])) + return false; + + if (lock_dir_write_owner(lock_dir, owner_pid)) + return true; + + release_runtime_dir_lock(lock_dir); + return false; +} + +function release_runtime_dir_lock(lock_dir) { + lock_dir = as_string(lock_dir); + if (lock_dir == "") + return; + + command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]); + command_success_from_args([ "rmdir", lock_dir ]); +} + +function mark_pending_reload(path, reason) { + path = as_string(path || PENDING_RELOAD_FILE); + reason = as_string(reason || "pending"); + + if (!ensure_parent_dir(path)) + return false; + + return write_text_file(path, "reason=" + reason + "\nupdated_at=" + current_epoch() + "\n"); +} + +function mark_start_retry(path, reason) { + path = as_string(path || START_RETRY_FILE); + reason = as_string(reason || "start_failed"); + + if (!ensure_parent_dir(path)) + return false; + + return write_text_file(path, "reason=" + reason + "\nupdated_at=" + current_epoch() + "\n"); +} + +function clear_start_retry(path) { + path = as_string(path || START_RETRY_FILE); + if (file_exists(path)) + unlink_file(path); +} + +function start_retry_pending(path) { + return file_exists(as_string(path || START_RETRY_FILE)); +} + +function cancel_scheduled_start_retry(path) { + path = as_string(path || START_RETRY_PID_FILE); + let pid = first_line_value(path); + if (pid_alive(pid)) + command_success_from_args([ "kill", pid ]); + if (file_exists(path)) + unlink_file(path); +} + +function schedule_start_retry(path, delay_seconds) { + path = as_string(path || START_RETRY_PID_FILE); + delay_seconds = as_string(delay_seconds || START_RETRY_DELAY_SECONDS); + if (!numeric_text(delay_seconds)) + delay_seconds = "30"; + + let scheduled_pid = first_line_value(path); + if (pid_alive(scheduled_pid)) + return true; + if (file_exists(path)) + unlink_file(path); + if (!ensure_parent_dir(path)) + return false; + + let worker = command_from_args([ "sleep", delay_seconds ]) + + "; " + command_from_args([ "rm", "-f", path ]) + + "; exec " + command_from_args([ SERVICE_INIT, "retry_start_on_wan_up" ]); + let result = command_capture(command_from_args([ "sh", "-c", worker ]) + " >/dev/null 2>&1 & echo $!"); + let pid = trim(result.output); + if (result.status != 0 || !numeric_text(pid)) + return false; + + return write_text_file(path, pid + "\n"); +} + +function consume_pending_reload(path) { + path = as_string(path || PENDING_RELOAD_FILE); + if (!file_exists(path)) + return false; + + unlink_file(path); + return true; +} + +function run_pending_reload_if_requested(path, init_script) { + path = as_string(path || PENDING_RELOAD_FILE); + init_script = as_string(init_script || SERVICE_INIT); + + if (!consume_pending_reload(path)) + return; + + command_success_from_args([ "logger", "-t", SERVICE_NAME, "[info] Applying pending Tachyon reload" ]); + system(shell_quote(init_script) + " reload pending >/dev/null 2>&1 1000>&- &"); +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function settings_from_fixture(path) { + let data = fs.readfile(path); + if (data == null) + return {}; + try { + data = json(data); + } + catch (e) { + return {}; + } + return object_or_empty(object_or_empty(data).settings); +} + +function initd_service_trigger_sync_requested(path) { + path = as_string(path); + if (!file_exists(path)) + return false; + + let value = first_line_value(path); + unlink_file(path); + return value == "1"; +} + +function initd_guard_matches_current_config(guard_path, config_path, now_value) { + let guard_timestamp = first_line_value(guard_path); + if (!numeric_text(guard_timestamp)) + return false; + + now_value = now_value == null ? current_epoch() : as_string(now_value); + if (!numeric_text(now_value)) + return true; + + if (int(now_value, 10) - int(guard_timestamp, 10) > 30) + return false; + + let data = fs.readfile(guard_path); + if (data == null) + return false; + + let lines = split(data, "\n"); + let guard_hash = length(lines) > 1 ? as_string(lines[1]) : ""; + if (guard_hash == "") + return false; + + return guard_hash == config_file_hash(config_path); +} + +function initd_should_skip_internal_config_reload(reason, guard_path, config_path, expected_reason, now_value) { + if (as_string(reason) != as_string(expected_reason || CONFIG_CHANGE_REASON)) + return false; + if (!file_exists(as_string(guard_path))) + return false; + + let matches = initd_guard_matches_current_config(guard_path, config_path, now_value); + unlink_file(guard_path); + return matches; +} + +function initd_should_restore_dnsmasq_on_start_from_value(reason, shutdown_correctly) { + if (as_string(reason) == "triggered") + return false; + + shutdown_correctly = as_string(shutdown_correctly); + if (shutdown_correctly == "") + shutdown_correctly = "1"; + return shutdown_correctly == "0"; +} + +function initd_should_ignore_config_change_reload(reason, expected_reason, runtime_running, service_enabled) { + return as_string(reason) == as_string(expected_reason || CONFIG_CHANGE_REASON) && + !bool_text(runtime_running); +} + +function initd_should_queue_config_change_reload(reason, expected_reason, runtime_running, active_service_action) { + return as_string(reason) == as_string(expected_reason || CONFIG_CHANGE_REASON) && + !bool_text(runtime_running) && + as_string(active_service_action) != ""; +} + +function initd_should_sync_service_triggers(reason, expected_reason, sync_file) { + if (as_string(reason) != as_string(expected_reason || CONFIG_CHANGE_REASON)) + return false; + return initd_service_trigger_sync_requested(sync_file); +} + +function restore_dnsmasq_failsafe() { + if (!file_exists(DNS_APPLY_UC)) + return 0; + return module_status(DNS_APPLY_UC, [ "failsafe-restore" ]); +} + +function begin_external_service_action(action, source, owner_pid) { + if (as_string(getenv("TACHYON_UI_ACTION_TRACKED") || "0") == "1") + return ""; + if (!file_exists(UI_UC)) + return ""; + + let job_id = trim(module_output(UI_UC, [ "service-action-begin-if-idle", action, source || "initd" ])); + if (job_id != "") + module_status(UI_UC, [ "service-action-update-pid", job_id, owner_pid || owner_pid_value() ]); + + return job_id; +} + +function finish_external_service_action(action, job_id, status) { + if (as_string(job_id) == "" || !file_exists(UI_UC)) + return 0; + return module_status(UI_UC, [ "service-action-finish-after-command", action, job_id, as_string(status) ]); +} + +function owner_pid_value() { + let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ])); + return match(pid, /^[0-9]+$/) != null ? pid : "0"; +} + +function runtime_status_object() { + let data = command_output_from_args([ BIN_PATH, "get_status" ]); + try { + data = json(data); + } + catch (e) { + return {}; + } + return object_or_empty(data); +} + +function runtime_is_running() { + return bool_text(runtime_status_object().running); +} + +function status_service() { + if (runtime_is_running()) { + print("running\n"); + return 0; + } + + print("not running\n"); + return 1; +} + +function service_is_enabled() { + return file_exists("/etc/rc.d/S99" + SERVICE_NAME); +} + +function retry_start_on_wan_up_action(runtime_running_value, service_enabled_value, retry_pending_value) { + if (bool_text(runtime_running_value)) + return "skip_running"; + if (!bool_text(service_enabled_value)) + return "skip_disabled"; + if (!bool_text(retry_pending_value)) + return "skip_no_retry"; + return "restart"; +} + +function retry_start_on_wan_up(owner_pid) { + let action = retry_start_on_wan_up_action( + runtime_is_running() ? "1" : "0", + service_is_enabled() ? "1" : "0", + start_retry_pending(START_RETRY_FILE) ? "1" : "0" + ); + + if (action == "skip_running" || action == "skip_disabled") { + clear_start_retry(START_RETRY_FILE); + return 0; + } + + if (action != "restart") + return 0; + + command_success_from_args([ "logger", "-t", SERVICE_NAME, "[info] Retrying failed Tachyon start after WAN came up" ]); + return command_status_from_args([ SERVICE_INIT, "restart", "triggered" ]); +} + +function active_service_action_value() { + if (!file_exists(UI_UC)) + return ""; + + return trim(module_output(UI_UC, [ "active-service-action" ])); +} + +function ui_action_tracked() { + return as_string(getenv("TACHYON_UI_ACTION_TRACKED") || "0") == "1"; +} + +function start_plan_value(reason, owner_pid, settings, bin_ok) { + settings = object_or_empty(settings); + let job_id = begin_external_service_action("start", "initd", owner_pid); + bin_ok = bin_ok == null ? file_executable(BIN_PATH) : bool_text(bin_ok); + + if (!bin_ok) { + restore_dnsmasq_failsafe(); + finish_external_service_action("start", job_id, 1); + } + else if (initd_should_restore_dnsmasq_on_start_from_value(reason, option(settings, "shutdown_correctly", "1"))) { + restore_dnsmasq_failsafe(); + } + + let badwan_enabled = option(settings, "enable_badwan_interface_monitoring", "") == "1"; + let badwan_interfaces = option(settings, "badwan_monitored_interfaces", ""); + + return { + job_id, + bin_ok, + badwan_netdev: badwan_enabled ? badwan_interfaces : "" + }; +} + +function start_plan(reason, owner_pid, settings, bin_ok) { + let plan = start_plan_value(reason, owner_pid, settings, bin_ok); + shell_assignment("INITD_UI_JOB_ID", plan.job_id); + shell_assignment("INITD_BIN_OK", plan.bin_ok ? "1" : "0"); + shell_assignment("INITD_BADWAN_NETDEV", plan.badwan_netdev); +} + +function start_service(reason, owner_pid) { + print("Start Tachyon\n"); + let plan = start_plan_value(reason, owner_pid, uci_settings(), null); + if (!plan.bin_ok) + return 1; + + let status = command_status_from_args([ BIN_PATH, "start" ]); + if (status == 0) { + clear_start_retry(START_RETRY_FILE); + cancel_scheduled_start_retry(START_RETRY_PID_FILE); + } + else { + mark_start_retry(START_RETRY_FILE, as_string(reason) == "triggered" ? "wan_retry_failed" : "start_failed"); + schedule_start_retry(START_RETRY_PID_FILE, START_RETRY_DELAY_SECONDS); + command_success_from_args([ "logger", "-t", SERVICE_NAME, "[warn] Tachyon start failed; scheduled an automatic retry" ]); + } + finish_external_service_action("start", plan.job_id, status); + return status; +} + +function stop_plan(owner_pid, bin_ok) { + let job_id = begin_external_service_action("stop", "initd", owner_pid); + bin_ok = bin_ok == null ? file_executable(BIN_PATH) : bool_text(bin_ok); + + if (!bin_ok) { + restore_dnsmasq_failsafe(); + finish_external_service_action("stop", job_id, 1); + } + + shell_assignment("INITD_UI_JOB_ID", job_id); + shell_assignment("INITD_BIN_OK", bin_ok ? "1" : "0"); +} + +function stop_finish(job_id, status) { + status = int(status || 0); + if (status != 0) + restore_dnsmasq_failsafe(); + finish_external_service_action("stop", job_id, status); + return status; +} + +function stop_service(owner_pid) { + clear_start_retry(START_RETRY_FILE); + cancel_scheduled_start_retry(START_RETRY_PID_FILE); + let job_id = begin_external_service_action("stop", "initd", owner_pid); + if (!file_executable(BIN_PATH)) { + restore_dnsmasq_failsafe(); + finish_external_service_action("stop", job_id, 1); + return 1; + } + + let status = command_status_from_args([ BIN_PATH, "stop" ]); + return stop_finish(job_id, status); +} + +function reload_begin_value(reason, owner_pid, runtime_running_value, service_enabled_value, active_service_action) { + reason = as_string(reason); + + if (initd_should_skip_internal_config_reload( + reason, + INTERNAL_CONFIG_TRIGGER_GUARD, + CONFIG_FILE, + CONFIG_CHANGE_REASON, + null + )) { + return { action: "skip", job_id: "" }; + } + + active_service_action = active_service_action == null ? active_service_action_value() : as_string(active_service_action); + if (reason == "pending" && active_service_action != "" && !ui_action_tracked()) { + mark_pending_reload(PENDING_RELOAD_FILE, reason); + return { action: "skip", job_id: "" }; + } + + if (reason == "pending") { + if (!acquire_runtime_dir_lock(RELOAD_LOCK_DIR, owner_pid || owner_pid_value())) { + mark_pending_reload(PENDING_RELOAD_FILE, reason || "reload_busy"); + return { action: "skip", job_id: "" }; + } + + unlink_file(SERVICE_TRIGGER_SYNC_FILE); + let job_id = begin_external_service_action("reload", "initd", owner_pid); + return { action: "run", job_id }; + } + + let running = runtime_running_value == null ? runtime_is_running() : bool_text(runtime_running_value); + let enabled = service_enabled_value == null ? service_is_enabled() : bool_text(service_enabled_value); + + if (initd_should_queue_config_change_reload(reason, CONFIG_CHANGE_REASON, running, active_service_action)) { + mark_pending_reload(PENDING_RELOAD_FILE, reason || "reload_queued"); + return { action: "skip", job_id: "" }; + } + + if (initd_should_ignore_config_change_reload(reason, CONFIG_CHANGE_REASON, running, enabled)) { + return { action: "skip", job_id: "" }; + } + + if (!acquire_runtime_dir_lock(RELOAD_LOCK_DIR, owner_pid || owner_pid_value())) { + mark_pending_reload(PENDING_RELOAD_FILE, reason || "reload_busy"); + return { action: "skip", job_id: "" }; + } + + unlink_file(SERVICE_TRIGGER_SYNC_FILE); + let job_id = begin_external_service_action("reload", "initd", owner_pid); + return { action: "run", job_id }; +} + +function reload_begin(reason, owner_pid, runtime_running_value, service_enabled_value) { + let plan = reload_begin_value(reason, owner_pid, runtime_running_value, service_enabled_value, null); + shell_assignment("INITD_RELOAD_ACTION", plan.action); + if (plan.action == "run") + shell_assignment("INITD_UI_JOB_ID", plan.job_id); + return 0; +} + +function reload_finish_value(reason, job_id, status) { + status = int(status || 0); + finish_external_service_action("reload", job_id, status); + let sync = status == 0 && initd_should_sync_service_triggers(reason, CONFIG_CHANGE_REASON, SERVICE_TRIGGER_SYNC_FILE); + release_runtime_dir_lock(RELOAD_LOCK_DIR); + if (active_service_action_value() == "") + run_pending_reload_if_requested(PENDING_RELOAD_FILE, SERVICE_INIT); + return { status, sync }; +} + +function reload_finish(reason, job_id, status) { + let plan = reload_finish_value(reason, job_id, status); + shell_assignment("INITD_SYNC_SERVICE_TRIGGERS", plan.sync ? "1" : "0"); + return plan.status; +} + +function reload_service(reason, owner_pid) { + let plan = reload_begin_value(reason, owner_pid, null, null); + if (plan.action != "run") + return 0; + + let status = command_status(command_from_args([ "env", "TACHYON_UI_ACTION_TRACKED=1", BIN_PATH, "reload", reason ]) + " >/dev/null 2>&1"); + let finish = reload_finish_value(reason, plan.job_id, status); + if (finish.sync) + print("sync\n"); + return finish.status; +} + +function reload_release() { + release_runtime_dir_lock(RELOAD_LOCK_DIR); + return 0; +} + +function trigger_plan(settings) { + settings = object_or_empty(settings); + let badwan_enabled = option(settings, "enable_badwan_interface_monitoring", "") == "1"; + let badwan_interfaces = split(replace(trim(option(settings, "badwan_monitored_interfaces", "")), /[ \t\r\n]+/g, " "), " "); + let delay = option(settings, "badwan_reload_delay", "2000"); + if (delay == "") + delay = "2000"; + + print("delay\t", delay, "\n"); + print("config\tconfig.change\t", CONFIG_NAME, "\t", SERVICE_INIT, "\treload\t", CONFIG_CHANGE_REASON, "\n"); + print("interface\tinterface.*.up\twan\t", SERVICE_INIT, "\tretry_start_on_wan_up\t\n"); + + if (badwan_enabled) { + for (let iface in badwan_interfaces) { + iface = trim(iface); + if (iface == "" || iface == "wan") + continue; + print("interface\tinterface.*.up\t", iface, "\t", SERVICE_INIT, "\treload\t\n"); + } + } +} + +let mode = ARGV[0] || ""; + +if (mode == "restore-dnsmasq-failsafe") + exit(restore_dnsmasq_failsafe()); +else if (mode == "runtime-running") + exit(runtime_is_running() ? 0 : 1); +else if (mode == "status-service") + exit(status_service()); +else if (mode == "retry-start-on-wan-up") + exit(retry_start_on_wan_up(ARGV[1])); +else if (mode == "retry-start-on-wan-up-action") + print(retry_start_on_wan_up_action(ARGV[1], ARGV[2], ARGV[3]), "\n"); +else if (mode == "service-enabled") + exit(service_is_enabled() ? 0 : 1); +else if (mode == "mark-start-retry") + exit(mark_start_retry(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "clear-start-retry") + clear_start_retry(ARGV[1]); +else if (mode == "start-retry-pending") + exit(start_retry_pending(ARGV[1]) ? 0 : 1); +else if (mode == "schedule-start-retry") + exit(schedule_start_retry(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "cancel-scheduled-start-retry") + cancel_scheduled_start_retry(ARGV[1]); +else if (mode == "begin-action") { + let job_id = begin_external_service_action(ARGV[1], ARGV[2] || "initd", ARGV[3]); + if (job_id != "") + print(job_id, "\n"); +} +else if (mode == "finish-action") + exit(finish_external_service_action(ARGV[1], ARGV[2], ARGV[3])); +else if (mode == "start-plan") + start_plan(ARGV[1], ARGV[2], uci_settings(), null); +else if (mode == "start-service") + exit(start_service(ARGV[1], ARGV[2])); +else if (mode == "start-plan-fixture") { + let settings = { + shutdown_correctly: ARGV[2], + enable_badwan_interface_monitoring: ARGV[3], + badwan_monitored_interfaces: ARGV[4] + }; + start_plan(ARGV[1], ARGV[5] || "0", settings, ARGV[6] == null ? "1" : ARGV[6]); +} +else if (mode == "stop-plan") + stop_plan(ARGV[1], null); +else if (mode == "stop-plan-fixture") + stop_plan(ARGV[1] || "0", ARGV[2] == null ? "1" : ARGV[2]); +else if (mode == "stop-finish") + exit(stop_finish(ARGV[1], ARGV[2])); +else if (mode == "stop-service") + exit(stop_service(ARGV[1])); +else if (mode == "reload-begin") + exit(reload_begin(ARGV[1], ARGV[2], null, null)); +else if (mode == "reload-begin-fixture") { + let plan = reload_begin_value(ARGV[1], ARGV[2] || "0", ARGV[3], ARGV[4], ARGV[5]); + shell_assignment("INITD_RELOAD_ACTION", plan.action); + if (plan.action == "run") + shell_assignment("INITD_UI_JOB_ID", plan.job_id); + exit(plan.action == "run" ? 0 : 1); +} +else if (mode == "reload-finish") + exit(reload_finish(ARGV[1], ARGV[2], ARGV[3])); +else if (mode == "reload-service") + exit(reload_service(ARGV[1], ARGV[2])); +else if (mode == "reload-release") + exit(reload_release()); +else if (mode == "trigger-plan") + trigger_plan(uci_settings()); +else if (mode == "trigger-plan-fixture") + trigger_plan(settings_from_fixture(ARGV[1])); +else if (mode == "initd-service-trigger-sync-requested") + exit(initd_service_trigger_sync_requested(ARGV[1]) ? 0 : 1); +else if (mode == "initd-guard-matches-current-config") + exit(initd_guard_matches_current_config(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "initd-should-skip-internal-config-reload") + exit(initd_should_skip_internal_config_reload(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]) ? 0 : 1); +else if (mode == "initd-should-restore-dnsmasq-on-start-fixture") + exit(initd_should_restore_dnsmasq_on_start_from_value(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "initd-should-ignore-config-change-reload") + exit(initd_should_ignore_config_change_reload(ARGV[1], ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +else if (mode == "initd-should-queue-config-change-reload") + exit(initd_should_queue_config_change_reload(ARGV[1], ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +else if (mode == "initd-should-sync-service-triggers") + exit(initd_should_sync_service_triggers(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else { + warn("Usage: service/initd.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/service/lifecycle.uc b/tachyon/files/usr/lib/service/lifecycle.uc new file mode 100644 index 00000000..e4eb3d08 --- /dev/null +++ b/tachyon/files/usr/lib/service/lifecycle.uc @@ -0,0 +1,1455 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let constants = require("core.constants"); +let uci_core = require("core.uci"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function constant_value(name, fallback) { + let value = constants[name]; + return value == null ? as_string(fallback) : as_string(value); +} + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || constant_value("TACHYON_CONFIG_NAME", "tachyon"); +const CONFIG_FILE = getenv("TACHYON_CONFIG_FILE") || "/etc/config/" + CONFIG_NAME; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const BIN_PATH = getenv("TACHYON_BIN") || constant_value("TACHYON_BIN", "/usr/bin/tachyon"); +const SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || constant_value("TACHYON_SERVICE_INIT", "/etc/init.d/tachyon"); +const SERVICE_NAME = getenv("TACHYON_SERVICE_NAME") || constant_value("TACHYON_SERVICE_NAME", "tachyon"); +const LUCI_VIEW_DIR = getenv("TACHYON_LUCI_VIEW_DIR") || constant_value("TACHYON_LUCI_VIEW_DIR", "/www/luci-static/resources/view/tachyon"); +const LUCI_I18N_DOMAIN = getenv("TACHYON_LUCI_I18N_DOMAIN") || constant_value("TACHYON_LUCI_I18N_DOMAIN", "tachyon"); + +const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const SYSTEM_INFO_CACHE_FILE = getenv("TACHYON_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json"; +const RELOAD_STATE_FILE = getenv("TACHYON_RELOAD_STATE_FILE") || RUNTIME_STATE_DIR + "/reload-state"; +const RELOAD_STATE_SNAPSHOT_FILE = getenv("TACHYON_RELOAD_STATE_SNAPSHOT_FILE") || RUNTIME_STATE_DIR + "/reload-state.snapshot." + clock()[0] + "." + clock()[1]; +const PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending"; +const SERVICE_TRIGGER_SYNC_FILE = getenv("TACHYON_SERVICE_TRIGGER_SYNC_FILE") || RUNTIME_STATE_DIR + "/service-triggers.sync"; +const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update"; +const SUBSCRIPTION_LINKS_DIR = getenv("TACHYON_SUBSCRIPTION_LINKS_DIR") || RUNTIME_STATE_DIR + "/subscription-links"; +const SUBSCRIPTION_METADATA_DIR = getenv("TACHYON_SUBSCRIPTION_METADATA_DIR") || RUNTIME_STATE_DIR + "/subscription-metadata"; +const OUTBOUND_METADATA_DIR = getenv("TACHYON_OUTBOUND_METADATA_DIR") || RUNTIME_STATE_DIR + "/outbound-metadata"; +const SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; +const RULE_CONDITION_CACHE_DIR = getenv("TACHYON_RULE_CONDITION_CACHE_DIR") || RUNTIME_STATE_DIR + "/rule-condition-cache"; +const RUNTIME_CACHE_FORMAT_FILE = getenv("TACHYON_RUNTIME_CACHE_FORMAT_FILE") || RUNTIME_STATE_DIR + "/cache-format"; +const PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/tachyon/subscription-cache"; +const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; +const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT") || "7"; +const SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE = getenv("TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE") || RUNTIME_STATE_DIR + "/subscription-bootstrap-retry.pid"; +const DNS_FAILOVER_STATE_FILE = getenv("TACHYON_DNS_FAILOVER_STATE_FILE") || RUNTIME_STATE_DIR + "/dns-failover.json"; +const DNS_FAILOVER_PID_FILE = getenv("TACHYON_DNS_FAILOVER_PID_FILE") || RUNTIME_STATE_DIR + "/dns-failover.pid"; +const SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR") || RUNTIME_STATE_DIR + "/subscription-update.lock"; +const RELOAD_LOCK_DIR = getenv("TACHYON_RELOAD_LOCK_DIR") || "/var/run/tachyon.reload.lock"; +const INTERNAL_CONFIG_TRIGGER_GUARD = getenv("TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD") || "/var/run/tachyon.internal-config-change"; + +const LIST_UPDATE_CRON_MARKER = getenv("TACHYON_LIST_UPDATE_CRON_MARKER") || "# tachyon-list-update"; +const SUBSCRIPTION_UPDATE_CRON_MARKER = getenv("TACHYON_SUBSCRIPTION_UPDATE_CRON_MARKER") || "# tachyon-subscription-update"; +const COMPONENT_UPDATE_CHECK_CRON_MARKER = getenv("TACHYON_COMPONENT_UPDATE_CHECK_CRON_MARKER") || "# tachyon-component-update-check"; +const RELOAD_STATE_FORMAT = int(getenv("TACHYON_RELOAD_STATE_FORMAT") || "1"); +const RUNTIME_CACHE_FORMAT = int(getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8"); +const RUNTIME_STABLE_MIN_AGE = int(getenv("TACHYON_RUNTIME_STABLE_MIN_AGE") || "2"); +const SING_BOX_START_STABLE_MIN_AGE = int(getenv("TACHYON_SING_BOX_START_STABLE_MIN_AGE") || "8"); +const SING_BOX_START_VERIFY_TIMEOUT = int(getenv("TACHYON_SING_BOX_START_VERIFY_TIMEOUT") || "10"); +const NFT_POPULATE_ENABLED_DEFAULT = int(getenv("TACHYON_NFT_POPULATE_ENABLED") || "1"); + +const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || constant_value("TMP_SING_BOX_FOLDER", "/tmp/sing-box"); +const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || constant_value("TMP_RULESET_FOLDER", TMP_SING_BOX_FOLDER + "/rulesets"); +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || constant_value("TMP_SUBSCRIPTION_FOLDER", TMP_SING_BOX_FOLDER + "/subscriptions"); + +const RT_TABLE_NAME = constant_value("RT_TABLE_NAME", "tachyon"); +const NFT_TABLE_NAME = constant_value("NFT_TABLE_NAME", "TachyonTable"); +const NFT_LOCALV4_SET_NAME = constant_value("NFT_LOCALV4_SET_NAME", "localv4"); +const NFT_LOCALV6_SET_NAME = constant_value("NFT_LOCALV6_SET_NAME", "localv6"); +const NFT_COMMON_SET_NAME = constant_value("NFT_COMMON_SET_NAME", "tachyon_subnets"); +const NFT_COMMON6_SET_NAME = constant_value("NFT_COMMON6_SET_NAME", "tachyon_subnets6"); +const NFT_PORT_SET_NAME = constant_value("NFT_PORT_SET_NAME", "tachyon_ports"); +const NFT_IP_PORT_SET_NAME = constant_value("NFT_IP_PORT_SET_NAME", "tachyon_ip_ports"); +const NFT_IP_PORT6_SET_NAME = constant_value("NFT_IP_PORT6_SET_NAME", "tachyon_ip6_ports"); +const NFT_INTERFACE_SET_NAME = constant_value("NFT_INTERFACE_SET_NAME", "tachyon_interfaces"); +const NFT_FAKEIP_MARK = constant_value("NFT_FAKEIP_MARK", "0x00100000"); +const NFT_OUTBOUND_MARK = constant_value("NFT_OUTBOUND_MARK", "0x00200000"); + +const SB_FAKEIP_INET4_RANGE = constant_value("SB_FAKEIP_INET4_RANGE", "198.18.0.0/15"); +const SB_FAKEIP_INET6_RANGE = constant_value("SB_FAKEIP_INET6_RANGE", "fc00::/18"); +const SB_TPROXY_INBOUND6_ADDRESS = constant_value("SB_TPROXY_INBOUND6_ADDRESS", "::1"); +const SB_TPROXY_INBOUND_PORT = constant_value("SB_TPROXY_INBOUND_PORT", "1602"); +const SB_SERVICE_MIXED_INBOUND_ADDRESS = constant_value("SB_SERVICE_MIXED_INBOUND_ADDRESS", "127.0.0.1"); +const SB_SERVICE_MIXED_INBOUND_PORT = constant_value("SB_SERVICE_MIXED_INBOUND_PORT", "4534"); +const SB_VARIANT_STATE_FILE = constant_value("SB_VARIANT_STATE_FILE", "/etc/tachyon/sing-box-variant"); +const SB_VERSION_STATE_FILE = constant_value("SB_VERSION_STATE_FILE", "/etc/tachyon/sing-box-version"); + +const ZAPRET_PROVIDER_NFQWS_BIN = constant_value("ZAPRET_PROVIDER_NFQWS_BIN", "/opt/zapret/nfq/nfqws"); +const ZAPRET_ROUTE_MARK_BASE = constant_value("ZAPRET_ROUTE_MARK_BASE", "0x01000000"); +const ZAPRET_QUEUE_BASE = constant_value("ZAPRET_QUEUE_BASE", "4000"); +const ZAPRET_DESYNC_MARK = constant_value("ZAPRET_DESYNC_MARK", "0x40000000"); +const ZAPRET_DESYNC_MARK_POSTNAT = constant_value("ZAPRET_DESYNC_MARK_POSTNAT", "0x20000000"); +const ZAPRET2_PROVIDER_NFQWS2_BIN = constant_value("ZAPRET2_PROVIDER_NFQWS2_BIN", "/opt/zapret2/nfq2/nfqws2"); +const ZAPRET2_ROUTE_MARK_BASE = constant_value("ZAPRET2_ROUTE_MARK_BASE", "0x01010000"); +const ZAPRET2_QUEUE_BASE = constant_value("ZAPRET2_QUEUE_BASE", "4300"); +const ZAPRET2_DESYNC_MARK = constant_value("ZAPRET2_DESYNC_MARK", "0x40000000"); +const ZAPRET2_DESYNC_MARK_POSTNAT = constant_value("ZAPRET2_DESYNC_MARK_POSTNAT", "0x20000000"); +const BYEDPI_BIN = constant_value("BYEDPI_BIN", "/usr/bin/ciadpi"); + +const DNS_APPLY_UC = LIB_DIR + "/dns/apply.uc"; +const VALIDATOR_UC = LIB_DIR + "/config/validator.uc"; +const SERVER_UC = LIB_DIR + "/server/service.uc"; +const NFT_UC = LIB_DIR + "/nft/apply.uc"; +const SINGBOX_UC = LIB_DIR + "/singbox/runtime.uc"; +const PRIORITY_UC = LIB_DIR + "/singbox/priority.uc"; +const DNS_FAILOVER_UC = LIB_DIR + "/singbox/dns_failover.uc"; +const SUBSCRIPTION_CACHE_UC = LIB_DIR + "/subscription/cache.uc"; +const UPDATES_UC = LIB_DIR + "/components/updates.uc"; +const STATE_UC = LIB_DIR + "/service/state.uc"; +const RELOAD_UC = LIB_DIR + "/service/reload.uc"; +const UI_UC = LIB_DIR + "/service/ui.uc"; +const DIAGNOSTICS_UC = LIB_DIR + "/diagnostics/runtime.uc"; +const ZAPRET_UC = LIB_DIR + "/providers/zapret/runtime.uc"; +const ZAPRET2_UC = LIB_DIR + "/providers/zapret2/runtime.uc"; +const BYEDPI_UC = LIB_DIR + "/providers/byedpi/runtime.uc"; +const PACKAGES_UC = LIB_DIR + "/core/packages.uc"; + +let start_subscription_update_lock_held = false; +let subscription_caches_prepared = getenv("TACHYON_SUBSCRIPTION_CACHES_PREPARED") || "0"; +let subscription_runtime_no_refresh = getenv("TACHYON_SUBSCRIPTION_RUNTIME_NO_REFRESH") || "0"; +let subscription_deferred_sections = ""; +let nft_populate_enabled = NFT_POPULATE_ENABLED_DEFAULT; +let rule_condition_cache_enabled = 0; +let startup_config_fingerprint = ""; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; +} + +function command_status(command) { + return normalize_status(system(command)); +} + +function command_capture(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return { status: 1, output: "" }; + + let data = pipe.read("all"); + let status = normalize_status(pipe.close()); + return { status, output: data == null ? "" : as_string(data) }; +} + +function command_output(command) { + let result = command_capture(command); + return result.status == 0 ? result.output : ""; +} + +function read_json_file(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_status_from_args(args) { + return command_status(command_from_args(args)); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args) + " 2>/dev/null"); +} + +function command_success_from_args(args) { + return command_status(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function external_config_fingerprint() { + let data = fs.readfile(CONFIG_FILE); + if (data == null) + return ""; + + let lines = []; + for (let line in split(as_string(data), "\n")) + if (match(as_string(line), /^[ \t]*option[ \t]+shutdown_correctly([ \t]|$)/) == null) + push(lines, line); + + return join("\n", lines); +} + +function trim(value) { + return replace(as_string(value), /^[ \t\r\n]+|[ \t\r\n]+$/g, ""); +} + +function owner_pid() { + let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ])); + return match(pid, /^[0-9]+$/) != null ? pid : "0"; +} + +function bool_text(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function string_array_contains(values, needle) { + needle = as_string(needle); + if (needle == "") + return false; + + for (let item in array_or_empty(values)) + if (as_string(item) == needle) + return true; + + return false; +} + +function write_file(path, value) { + return fs.writefile(as_string(path), as_string(value)); +} + +function remove_file(path) { + return fs.unlink(as_string(path)) || true; +} + +function ensure_dir(path) { + path = as_string(path); + return path == "" || fs.mkdir(path, 0755) || fs.stat(path) != null; +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", SERVICE_NAME, "[" + level + "] " + as_string(message) ]); +} + +function lifecycle_env() { + return { + TACHYON_CONFIG_NAME: CONFIG_NAME, + TACHYON_LIB: LIB_DIR, + TACHYON_BIN: BIN_PATH, + TACHYON_SERVICE_INIT: SERVICE_INIT, + TACHYON_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, + TACHYON_SYSTEM_INFO_CACHE_FILE: SYSTEM_INFO_CACHE_FILE, + TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, + TACHYON_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, + TACHYON_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, + TACHYON_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, + TACHYON_SECTION_CACHE_DIR: SECTION_CACHE_DIR, + TACHYON_RULE_CONDITION_CACHE_DIR: RULE_CONDITION_CACHE_DIR, + TACHYON_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, + TACHYON_RUNTIME_CACHE_FORMAT: as_string(RUNTIME_CACHE_FORMAT), + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT, + TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE: SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE, + TACHYON_DNS_FAILOVER_STATE_FILE: DNS_FAILOVER_STATE_FILE, + TACHYON_DNS_FAILOVER_PID_FILE: DNS_FAILOVER_PID_FILE, + TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR: SUBSCRIPTION_UPDATE_LOCK_DIR, + TACHYON_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE, + TACHYON_RELOAD_LOCK_DIR: RELOAD_LOCK_DIR, + TMP_SING_BOX_FOLDER: TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER: TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER: TMP_SUBSCRIPTION_FOLDER, + SB_SERVICE_MIXED_INBOUND_ADDRESS: SB_SERVICE_MIXED_INBOUND_ADDRESS, + SB_SERVICE_MIXED_INBOUND_PORT: SB_SERVICE_MIXED_INBOUND_PORT, + SB_VARIANT_STATE_FILE: SB_VARIANT_STATE_FILE, + SB_VERSION_STATE_FILE: SB_VERSION_STATE_FILE, + ZAPRET_PROVIDER_NFQWS_BIN: ZAPRET_PROVIDER_NFQWS_BIN, + ZAPRET2_PROVIDER_NFQWS2_BIN: ZAPRET2_PROVIDER_NFQWS2_BIN, + BYEDPI_BIN: BYEDPI_BIN, + TACHYON_RULE_CONDITION_CACHE_ENABLED: as_string(rule_condition_cache_enabled) + }; +} + +function command_env(assignments) { + let parts = []; + for (let name, value in assignments) + push(parts, as_string(name) + "=" + shell_quote(value)); + return join(" ", parts); +} + +function module_args(module_path, args) { + let result = [ "ucode", "-L", LIB_DIR, module_path ]; + for (let arg in (type(args) == "array" ? args : [])) + push(result, arg); + return result; +} + +function module_command(module_path, args) { + return command_env(lifecycle_env()) + " " + command_from_args(module_args(module_path, args)); +} + +function module_capture(module_path, args) { + return command_capture(module_command(module_path, args)); +} + +function module_status(module_path, args) { + return command_status(module_command(module_path, args)); +} + +function module_success(module_path, args) { + return module_status(module_path, args) == 0; +} + +function mark_pending_reload(reason) { + return module_success(STATE_UC, [ "mark-pending-reload", PENDING_RELOAD_FILE, reason ]); +} + +function pending_reload_log_context(reason) { + reason = as_string(reason); + if (reason == "config_changed_during_reload") + return "current reload"; + return "startup"; +} + +function mark_pending_reload_if_config_changed(initial_fingerprint, reason) { + initial_fingerprint = as_string(initial_fingerprint); + if (initial_fingerprint == "") + return false; + + let current_fingerprint = external_config_fingerprint(); + if (current_fingerprint == "" || current_fingerprint == initial_fingerprint) + return false; + + let context = pending_reload_log_context(reason); + if (mark_pending_reload(reason)) { + log_message("Configuration changed during " + context + "; queued reload after " + context + " completes", "info"); + return true; + } + + log_message("Configuration changed during " + context + ", but pending reload could not be queued", "warn"); + return false; +} + +function finish_reload_status(status, initial_fingerprint) { + status = int(status || 0); + if (status == 0) + mark_pending_reload_if_config_changed(initial_fingerprint, "config_changed_during_reload"); + return status; +} + +function module_output(module_path, args) { + let result = module_capture(module_path, args); + return result.status == 0 ? result.output : ""; +} + +function selector_state_from_proxies_payload(payload) { + let result = {}; + let proxies = object_or_empty(object_or_empty(payload).proxies); + + for (let tag, proxy in proxies) { + proxy = object_or_empty(proxy); + let proxy_type = lc(as_string(proxy.type || "")); + let selected = as_string(proxy.now || ""); + + if (proxy_type == "selector" && string_array_contains(proxy.all, selected)) + result[as_string(tag)] = selected; + } + + return result; +} + +function selector_restore_pairs(snapshot, payload) { + let result = []; + snapshot = object_or_empty(snapshot); + let proxies = object_or_empty(object_or_empty(payload).proxies); + + for (let group, selected in snapshot) { + group = as_string(group); + selected = as_string(selected); + + if (group == "" || selected == "") + continue; + + let proxy = object_or_empty(proxies[group]); + if (lc(as_string(proxy.type || "")) != "selector") + continue; + if (!string_array_contains(proxy.all, selected)) + continue; + if (as_string(proxy.now || "") == selected) + continue; + + push(result, { group, proxy: selected }); + } + + return result; +} + +function clash_api_json(action, arg1, arg2, arg3) { + let result = module_capture(DIAGNOSTICS_UC, [ + "clash-api", + action, + as_string(arg1 || ""), + as_string(arg2 || ""), + as_string(arg3 || "") + ]); + if (result.status != 0) + return null; + + try { + return json(result.output); + } + catch (e) { + return null; + } +} + +function capture_selector_state() { + return selector_state_from_proxies_payload(clash_api_json("get_proxies")); +} + +function restore_selector_state(snapshot) { + let pairs = selector_restore_pairs(snapshot, clash_api_json("get_proxies")); + + for (let pair in pairs) + module_success(DIAGNOSTICS_UC, [ "clash-api", "set_group_proxy", pair.group, pair.proxy, "" ]); +} + +function module_background(module_path, args) { + return command_status(module_command(module_path, args) + " >/dev/null 2>&1 1000>&- &") == 0; +} + +function config_get(path, fallback) { + path = as_string(path); + if (!uci_core.exists(path)) + return as_string(fallback); + return trim(uci_core.get(path)); +} + +function config_set(path, value) { + return uci_core.set(path, value); +} + +function file_md5(path) { + path = as_string(path); + if (path == "" || fs.stat(path) == null) + return ""; + + let output = command_output("md5sum " + shell_quote(path) + " 2>/dev/null"); + let fields = split(trim(output), /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[0]) : ""; +} + +function current_config_hash() { + return file_md5(CONFIG_FILE); +} + +function mark_internal_config_guard() { + let hash = current_config_hash(); + if (hash == "") { + fs.unlink(INTERNAL_CONFIG_TRIGGER_GUARD); + return; + } + + let stamp = clock(); + let tmp_path = INTERNAL_CONFIG_TRIGGER_GUARD + "." + stamp[0] + "." + stamp[1]; + fs.writefile(tmp_path, as_string(stamp[0]) + "\n" + hash + "\n"); + if (!fs.rename(tmp_path, INTERNAL_CONFIG_TRIGGER_GUARD)) + fs.unlink(tmp_path); +} + +function config_commit() { + if (!uci_core.commit(CONFIG_NAME)) + return 1; + mark_internal_config_guard(); + return 0; +} + +function mark_runtime_stopped_clean() { + let status = 0; + if (!config_set(CONFIG_NAME + ".settings.shutdown_correctly", "1")) + status = 1; + else { + let commit_status = config_commit(); + if (commit_status != 0) + status = commit_status; + } + + module_success(STATE_UC, [ "clear-reload-state", RELOAD_STATE_FILE, RELOAD_STATE_SNAPSHOT_FILE ]); + return status; +} + +function setting_bool(name, fallback) { + let value = config_get(CONFIG_NAME + ".settings." + as_string(name), fallback ? "1" : "0"); + return bool_text(value); +} + +function dns_apply_status(args) { + return module_status(DNS_APPLY_UC, args); +} + +function dns_apply_success(args) { + return dns_apply_status(args) == 0; +} + +function dnsmasq_configure(force) { + let args = [ "configure" ]; + if (force) + push(args, "force"); + return dns_apply_status(args); +} + +function dnsmasq_restore(force) { + let args = [ "restore" ]; + if (force) + push(args, "force"); + return dns_apply_status(args); +} + +function dnsmasq_restore_fail_safe() { + return dns_apply_status([ "failsafe-restore" ]); +} + +function dnsmasq_has_tachyon_managed_state() { + return dns_apply_success([ "has-managed-state" ]); +} + +function validate_start_config() { + let status = module_status(VALIDATOR_UC, [ "check-requirements" ]); + if (status != 0) + return status; + + status = module_status(SERVER_UC, [ "prepare-all-defaults" ]); + if (status != 0) + return status; + + status = module_status(VALIDATOR_UC, [ "validate-runtime" ]); + if (status != 0) { + log_message("Runtime config validation failed. Aborted.", "fatal"); + return status; + } + + return 0; +} + +function acquire_start_subscription_update_lock() { + if (module_success(STATE_UC, [ "acquire-runtime-dir-lock-wait", SUBSCRIPTION_UPDATE_LOCK_DIR, owner_pid(), "300" ])) { + start_subscription_update_lock_held = true; + return true; + } + + log_message("Subscription update is already running during startup. Aborted.", "fatal"); + return false; +} + +function release_start_subscription_update_lock() { + if (!start_subscription_update_lock_held) + return; + + module_success(STATE_UC, [ "release-runtime-dir-lock", SUBSCRIPTION_UPDATE_LOCK_DIR ]); + start_subscription_update_lock_held = false; +} + +function nft_rebuild_runtime() { + return module_status(NFT_UC, [ + "nft-rebuild-runtime-from-uci", + RT_TABLE_NAME, + NFT_TABLE_NAME, + NFT_LOCALV4_SET_NAME, + NFT_COMMON_SET_NAME, + NFT_PORT_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_FAKEIP_MARK, + NFT_OUTBOUND_MARK, + SB_FAKEIP_INET4_RANGE, + SB_TPROXY_INBOUND_PORT, + ZAPRET_PROVIDER_NFQWS_BIN, + ZAPRET_ROUTE_MARK_BASE, + ZAPRET_QUEUE_BASE, + ZAPRET_DESYNC_MARK, + ZAPRET_DESYNC_MARK_POSTNAT, + ZAPRET2_PROVIDER_NFQWS2_BIN, + ZAPRET2_ROUTE_MARK_BASE, + ZAPRET2_QUEUE_BASE, + ZAPRET2_DESYNC_MARK, + ZAPRET2_DESYNC_MARK_POSTNAT, + NFT_LOCALV6_SET_NAME, + NFT_COMMON6_SET_NAME, + NFT_IP_PORT6_SET_NAME, + SB_FAKEIP_INET6_RANGE, + SB_TPROXY_INBOUND6_ADDRESS + ]); +} + +function nft_populate_runtime_sets() { + return module_status(NFT_UC, [ + "nft-populate-runtime-sets-from-uci", + as_string(nft_populate_enabled), + subscription_deferred_sections, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_PORT_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_LOCALV4_SET_NAME, + NFT_FAKEIP_MARK, + NFT_COMMON6_SET_NAME, + NFT_IP_PORT6_SET_NAME, + NFT_LOCALV6_SET_NAME + ]); +} + +function singbox_init_config() { + let result = module_capture(SINGBOX_UC, [ + "init-config", + as_string(nft_populate_enabled), + subscription_caches_prepared, + subscription_runtime_no_refresh + ]); + if (result.status == 0) { + subscription_deferred_sections = trim(result.output); + subscription_caches_prepared = "1"; + } + return result.status; +} + +function refresh_cron() { + return module_status(UPDATES_UC, [ + "refresh-cron-from-uci", + BIN_PATH, + LIST_UPDATE_CRON_MARKER, + SUBSCRIPTION_UPDATE_CRON_MARKER, + COMPONENT_UPDATE_CHECK_CRON_MARKER + ]); +} + +function remove_cron_jobs() { + return module_status(UPDATES_UC, [ + "remove-cron-jobs", + LIST_UPDATE_CRON_MARKER, + SUBSCRIPTION_UPDATE_CRON_MARKER, + COMPONENT_UPDATE_CHECK_CRON_MARKER + ]); +} + +function prepare_subscription_caches(mode) { + let result = module_capture(SUBSCRIPTION_CACHE_UC, [ + "prepare-caches", + mode, + subscription_caches_prepared, + subscription_runtime_no_refresh + ]); + if (result.status == 0) { + subscription_deferred_sections = trim(result.output); + subscription_caches_prepared = "1"; + } + return result.status; +} + +function start_main() { + let status; + + log_message("Starting Tachyon", "info"); + + status = validate_start_config(); + if (status != 0) + return status; + + startup_config_fingerprint = external_config_fingerprint(); + + status = module_status(NFT_UC, [ "ensure-bridge-netfilter-disabled" ]); + if (status != 0) + return status; + + module_success(STATE_UC, [ "sync-time-if-needed" ]); + + status = module_status(SUBSCRIPTION_CACHE_UC, [ "ensure-runtime-dirs" ]); + if (status != 0) + return status; + + if (!acquire_start_subscription_update_lock()) + return 1; + + status = prepare_subscription_caches("startup"); + if (status != 0) { + log_message("Subscription caches are not ready. Aborted.", "fatal"); + return status; + } + + status = nft_rebuild_runtime(); + if (status != 0) + return status; + + status = module_status(SINGBOX_UC, [ "configure-service" ]); + if (status != 0) + return status; + + status = singbox_init_config(); + if (status != 0) + return status; + + status = refresh_cron(); + if (status != 0) + return status; + + module_success(BYEDPI_UC, [ "start-runtime" ]); + + if (!command_success_from_args([ "/etc/init.d/sing-box", "start" ])) { + log_message("Failed to start sing-box. Aborted.", "fatal"); + return 1; + } + + status = module_status(STATE_UC, [ + "wait-tachyon-stable-start", + RT_TABLE_NAME, + NFT_TABLE_NAME, + NFT_FAKEIP_MARK, + as_string(SING_BOX_START_STABLE_MIN_AGE), + as_string(SING_BOX_START_VERIFY_TIMEOUT) + ]); + if (status != 0) { + log_message("sing-box did not reach a stable running state after start. Aborted.", "fatal"); + return status; + } + + status = module_status(PRIORITY_UC, [ "start-runtime" ]); + if (status != 0) { + log_message("Failed to start Priority runtime. Aborted.", "fatal"); + return status; + } + + status = module_status(SUBSCRIPTION_CACHE_UC, [ "run-deferred-bootstrap", subscription_deferred_sections ]); + if (status != 0) + return status; + + release_start_subscription_update_lock(); + module_success(ZAPRET_UC, [ "start-runtime" ]); + module_success(ZAPRET2_UC, [ "start-runtime" ]); + + module_background(UPDATES_UC, [ "list-update" ]); + return 0; +} + +function start_impl() { + let status = start_main(); + if (status != 0) + return status; + + if (!setting_bool("dont_touch_dhcp", false)) { + status = dnsmasq_configure(false); + if (status != 0) + return status; + } + else if (dnsmasq_has_tachyon_managed_state()) { + status = dnsmasq_restore(true); + if (status != 0) + return status; + } + + if (!config_set(CONFIG_NAME + ".settings.shutdown_correctly", "0")) + return 1; + + status = config_commit(); + if (status != 0) + return status; + + status = module_status(STATE_UC, [ + "write-current-reload-state-clean", + RELOAD_STATE_FILE, + as_string(RELOAD_STATE_FORMAT), + RULE_CONDITION_CACHE_DIR + ]); + if (status != 0) + return status; + + status = module_status(DNS_FAILOVER_UC, [ "start-runtime" ]); + if (status != 0) { + log_message("Failed to start DNS failover runtime", "fatal"); + return status; + } + + module_background(DIAGNOSTICS_UC, [ "get-system-info" ]); + return 0; +} + +function stop_main() { + let status = 0; + + log_message("Stopping Tachyon", "info"); + module_success(DNS_FAILOVER_UC, [ "stop-runtime" ]); + module_success(PRIORITY_UC, [ "stop-runtime" ]); + module_success(SUBSCRIPTION_CACHE_UC, [ "stop-deferred-bootstrap-worker" ]); + module_success(UPDATES_UC, [ "stop-list-update" ]); + remove_cron_jobs(); + command_success_from_args([ "find", TMP_RULESET_FOLDER, "-mindepth", "1", "-maxdepth", "1", "-type", "f", "-delete" ]); + + module_success(ZAPRET_UC, [ "stop-runtime" ]); + module_success(ZAPRET2_UC, [ "stop-runtime" ]); + module_success(BYEDPI_UC, [ "stop-runtime" ]); + + if (command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) + command_success_from_args([ "nft", "delete", "table", "inet", NFT_TABLE_NAME ]); + + if (module_success(NFT_UC, [ "tproxy-marking-rule4-present", RT_TABLE_NAME, NFT_FAKEIP_MARK ])) + command_success_from_args([ "ip", "-4", "rule", "del", "fwmark", NFT_FAKEIP_MARK + "/" + NFT_FAKEIP_MARK, "table", RT_TABLE_NAME, "priority", "105" ]); + if (module_success(NFT_UC, [ "tproxy-marking-rule6-present", RT_TABLE_NAME, NFT_FAKEIP_MARK ])) + command_success_from_args([ "ip", "-6", "rule", "del", "fwmark", NFT_FAKEIP_MARK + "/" + NFT_FAKEIP_MARK, "table", RT_TABLE_NAME, "priority", "105" ]); + + if (module_success(NFT_UC, [ "tproxy-route4-present", RT_TABLE_NAME ])) + command_success_from_args([ "ip", "route", "flush", "table", RT_TABLE_NAME ]); + if (module_success(NFT_UC, [ "tproxy-route6-present", RT_TABLE_NAME ])) + command_success_from_args([ "ip", "-6", "route", "flush", "table", RT_TABLE_NAME ]); + + let sing_box_status = command_status_from_args([ "/etc/init.d/sing-box", "stop" ]); + if (sing_box_status != 0) + status = sing_box_status; + + return status; +} + +function cleanup_failed_runtime() { + let status = 0; + + log_message("Cleaning up Tachyon runtime after failed start/reload", "info"); + + let stop_status = stop_main(); + if (stop_status != 0) + status = stop_status; + + let dns_status = dnsmasq_restore_fail_safe(); + if (dns_status != 0 && status == 0) + status = dns_status; + + let mark_status = mark_runtime_stopped_clean(); + if (mark_status != 0 && status == 0) + status = mark_status; + + if (status != 0) + log_message("Failed to fully clean up Tachyon runtime after start/reload failure", "warn"); + + return status; +} + +function abort_reload(status, runtime_changed) { + status = int(status || 0); + if (status == 0) + status = 1; + + if (runtime_changed) + cleanup_failed_runtime(); + else + remove_file(RELOAD_STATE_SNAPSHOT_FILE); + + return status; +} + +function start() { + let status = start_impl(); + release_start_subscription_update_lock(); + + if (status != 0) { + cleanup_failed_runtime(); + return status; + } + + mark_pending_reload_if_config_changed(startup_config_fingerprint, "config_changed_during_start"); + + status = module_status(STATE_UC, [ + "wait-tachyon-stable-start", + RT_TABLE_NAME, + NFT_TABLE_NAME, + NFT_FAKEIP_MARK, + as_string(RUNTIME_STABLE_MIN_AGE), + "8" + ]); + if (status != 0) { + log_message("Startup verification failed after Tachyon was started; rolling back DNS changes", "warn"); + cleanup_failed_runtime(); + return status; + } + + return 0; +} + +function stop_impl() { + let status = 0; + + if (!setting_bool("dont_touch_dhcp", false)) { + let dns_status = dnsmasq_restore(false); + if (dns_status != 0) + status = dns_status; + } + else if (dnsmasq_has_tachyon_managed_state()) { + let dns_status = dnsmasq_restore(true); + if (dns_status != 0) + status = dns_status; + } + + let runtime_status = stop_main(); + if (runtime_status != 0) + status = runtime_status; + + if (!config_set(CONFIG_NAME + ".settings.shutdown_correctly", "1")) + status = 1; + else { + let commit_status = config_commit(); + if (commit_status != 0) + status = commit_status; + } + + module_success(STATE_UC, [ "clear-reload-state", RELOAD_STATE_FILE, RELOAD_STATE_SNAPSHOT_FILE ]); + + if (status != 0) + dnsmasq_restore_fail_safe(); + + return status; +} + +function stop() { + return stop_impl(); +} + +function restart_runtime_for_reload() { + let status; + let selector_state = capture_selector_state(); + + log_message("Reload requires a full Tachyon runtime restart", "info"); + + status = stop_main(); + if (status != 0) + return status; + + status = start_impl(); + if (status != 0) { + cleanup_failed_runtime(); + return status; + } + + status = module_status(STATE_UC, [ + "wait-tachyon-stable-start", + RT_TABLE_NAME, + NFT_TABLE_NAME, + NFT_FAKEIP_MARK, + as_string(RUNTIME_STABLE_MIN_AGE), + "8" + ]); + if (status != 0) { + log_message("Reload runtime restart verification failed after Tachyon was started; rolling back DNS changes", "fatal"); + cleanup_failed_runtime(); + return status; + } + + restore_selector_state(selector_state); + remove_file(RELOAD_STATE_SNAPSHOT_FILE); + return 0; +} + +function write_service_trigger_sync_state(changed) { + ensure_dir(RUNTIME_STATE_DIR); + write_file(SERVICE_TRIGGER_SYNC_FILE, as_string(changed) + "\n"); +} + +function parse_reload_plan(output) { + let result = { + changed_service_triggers: 0, + changed_dnsmasq: 0, + changed_sing_box: 0, + changed_nft: 0, + changed_zapret_queue: 0, + changed_zapret_runtime: 0, + changed_zapret2_queue: 0, + changed_zapret2_runtime: 0, + changed_byedpi_runtime: 0, + changed_cron: 0, + changed_list: 0, + needs_sing_box_reload: 0, + needs_nft_rebuild: 0, + needs_zapret_restart: 0, + needs_zapret2_restart: 0, + needs_byedpi_restart: 0, + needs_dnsmasq_configure: 0, + needs_dnsmasq_restore: 0, + needs_cron_refresh: 0, + needs_list_update: 0, + has_work: 0 + }; + + for (let line in split(as_string(output), "\n")) { + line = as_string(line); + if (line == "") + continue; + let fields = split(line, "\t"); + if (length(fields) < 2) + continue; + let key = fields[0]; + if (result[key] != null) + result[key] = int(fields[1] || 0); + } + + return result; +} + +function append_reload_action(actions, enabled, label) { + if (!(enabled === true || int(enabled || 0) == 1)) + return actions; + return actions + (actions != "" ? ", " : "") + label; +} + +function reload_actions_summary(plan) { + let actions = ""; + actions = append_reload_action(actions, plan.needs_sing_box_reload, "sing-box"); + actions = append_reload_action(actions, plan.needs_nft_rebuild, "nftables"); + actions = append_reload_action(actions, plan.needs_zapret_restart, "Zapret"); + actions = append_reload_action(actions, plan.needs_zapret2_restart, "Zapret2"); + actions = append_reload_action(actions, plan.needs_byedpi_restart, "ByeDPI"); + actions = append_reload_action(actions, plan.needs_dnsmasq_configure || plan.needs_dnsmasq_restore, "dnsmasq"); + actions = append_reload_action(actions, plan.needs_cron_refresh, "scheduled jobs"); + actions = append_reload_action(actions, plan.needs_list_update, "remote lists"); + return actions; +} + +function release_reload_lock() { + module_success(STATE_UC, [ "release-runtime-dir-lock", RELOAD_LOCK_DIR ]); +} + +function sing_box_runtime_pid() { + let result = module_capture(STATE_UC, [ "sing-box-service-runtime-pid" ]); + return result.status == 0 ? trim(result.output) : ""; +} + +function wait_dns_failover_state(candidate_state_path, attempts) { + attempts = int(attempts || 1); + for (let i = 0; i < attempts; i++) { + let status = module_status(DNS_FAILOVER_UC, [ "verify-state", candidate_state_path ]); + if (status == 0) + return 0; + if (i + 1 < attempts) + command_success_from_args([ "sleep", "1" ]); + } + return 1; +} + +function dns_failover_apply(candidate_state_path) { + candidate_state_path = as_string(candidate_state_path); + if (candidate_state_path == "" || fs.stat(candidate_state_path) == null) + return 1; + + if (!module_success(STATE_UC, [ "acquire-runtime-dir-lock-wait", RELOAD_LOCK_DIR, owner_pid(), "2" ])) + return 2; + + let patch_result = module_capture(SINGBOX_UC, [ "patch-dns-config", candidate_state_path ]); + if (patch_result.status != 0) { + release_reload_lock(); + return patch_result.status; + } + + let fields = split(trim(patch_result.output), "\t"); + let changed = as_string(fields[0]) == "1"; + let backup_path = length(fields) > 1 ? as_string(fields[1]) : ""; + let status = 0; + + if (changed) { + let sing_box_pid_before = sing_box_runtime_pid(); + status = module_status(STATE_UC, [ "hup-sing-box-runtime" ]); + if (status == 0) + status = wait_dns_failover_state(candidate_state_path, 5); + + if (status != 0) { + log_message("sing-box SIGHUP DNS reload did not become ready; trying service reload", "warn"); + status = module_status(STATE_UC, [ "reload-sing-box-runtime", sing_box_pid_before, "dns-before", "dns-after" ]); + if (status == 0) + status = wait_dns_failover_state(candidate_state_path, 8); + } + } + + if (status == 0 && !module_success(DNS_FAILOVER_UC, [ "commit-state", candidate_state_path ])) + status = 1; + + if (status != 0 && backup_path != "") { + log_message("DNS failover apply failed; restoring the previous sing-box configuration", "error"); + if (module_success(SINGBOX_UC, [ "restore-dns-config", backup_path ])) { + let sing_box_pid_before_restore = sing_box_runtime_pid(); + module_success(STATE_UC, [ "reload-sing-box-runtime", sing_box_pid_before_restore, "dns-failed", "dns-restored" ]); + } + } + + if (backup_path != "") + remove_file(backup_path); + release_reload_lock(); + return status; +} + +function reload(reason) { + let status; + let force_runtime_reload = as_string(reason || "") == "on_config_change" ? 0 : 1; + let reload_config_fingerprint = external_config_fingerprint(); + rule_condition_cache_enabled = force_runtime_reload; + + log_message("Reloading Tachyon", "info"); + + status = validate_start_config(); + if (status != 0) + return status; + + status = module_status(SUBSCRIPTION_CACHE_UC, [ "ensure-runtime-dirs" ]); + if (status != 0) + return status; + + if (!module_success(STATE_UC, [ "tachyon-running", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MARK ])) { + log_message("Runtime state is incomplete; restarting Tachyon runtime", "info"); + return finish_reload_status(restart_runtime_for_reload(), reload_config_fingerprint); + } + + remove_file(RELOAD_STATE_SNAPSHOT_FILE); + status = module_status(STATE_UC, [ + "capture-reload-state", + RELOAD_STATE_SNAPSHOT_FILE, + as_string(RELOAD_STATE_FORMAT) + ]); + if (status != 0) + return status; + + let current_reload_state_file = trim(command_output_from_args([ "mktemp" ])); + if (current_reload_state_file == "") + return abort_reload(1, false); + + status = module_status(STATE_UC, [ + "write-captured-reload-state", + current_reload_state_file, + RELOAD_STATE_SNAPSHOT_FILE, + as_string(RELOAD_STATE_FORMAT), + "", + "0", + "0" + ]); + if (status != 0) { + remove_file(current_reload_state_file); + return abort_reload(status, false); + } + + let dnsmasq_managed_state = dnsmasq_has_tachyon_managed_state() ? 1 : 0; + let list_update_sources = module_success(STATE_UC, [ "has-list-update-sources" ]) ? 1 : 0; + let nft_list_update_sources = module_success(STATE_UC, [ "has-nft-list-update-sources" ]) ? 1 : 0; + let runtime_cache_needs_rebuild = 0; + if (as_string(getenv("TACHYON_RUNTIME_CACHE_INVALIDATED") || "0") == "1" || + module_success(SUBSCRIPTION_CACHE_UC, [ "runtime-cache-needs-rebuild", SECTION_CACHE_DIR ])) + runtime_cache_needs_rebuild = 1; + + let plan_result = module_capture(RELOAD_UC, [ + "plan-state-files", + RELOAD_STATE_FILE, + current_reload_state_file, + as_string(force_runtime_reload), + as_string(dnsmasq_managed_state), + as_string(list_update_sources), + as_string(nft_list_update_sources), + as_string(runtime_cache_needs_rebuild) + ]); + remove_file(current_reload_state_file); + + if (plan_result.status != 0) { + if (plan_result.status == 2) { + log_message("Reload state is unavailable; restarting Tachyon runtime", "info"); + return finish_reload_status(restart_runtime_for_reload(), reload_config_fingerprint); + } + return abort_reload(plan_result.status, false); + } + + let plan = parse_reload_plan(plan_result.output); + write_service_trigger_sync_state(plan.changed_service_triggers); + + if (plan.has_work == 0) { + status = module_status(STATE_UC, [ + "write-captured-reload-state", + RELOAD_STATE_FILE, + RELOAD_STATE_SNAPSHOT_FILE, + as_string(RELOAD_STATE_FORMAT), + RULE_CONDITION_CACHE_DIR, + "1", + "1" + ]); + if (status == 0) + log_message("Reload skipped: runtime-relevant configuration is unchanged", "info"); + return finish_reload_status(status, reload_config_fingerprint); + } + + let actions = reload_actions_summary(plan); + if (actions != "") + log_message("Applying reload changes: " + actions, "info"); + + if (plan.needs_zapret_restart == 1) + module_success(ZAPRET_UC, [ "stop-runtime" ]); + if (plan.needs_zapret2_restart == 1) + module_success(ZAPRET2_UC, [ "stop-runtime" ]); + if (plan.needs_byedpi_restart == 1) + module_success(BYEDPI_UC, [ "stop-runtime" ]); + + if (plan.needs_nft_rebuild == 1) { + log_message("Rebuilding nftables rules", "info"); + status = nft_rebuild_runtime(); + if (status != 0) + return abort_reload(status, true); + } + + if (plan.needs_sing_box_reload == 1) { + module_success(DNS_FAILOVER_UC, [ "stop-runtime" ]); + module_success(PRIORITY_UC, [ "stop-runtime" ]); + status = module_status(SINGBOX_UC, [ "configure-service" ]); + if (status != 0) + return abort_reload(status, true); + let sing_box_config_path = config_get(CONFIG_NAME + ".settings.config_path", ""); + let sing_box_config_hash_before = file_md5(sing_box_config_path); + let sing_box_pid_result = module_capture(STATE_UC, [ "sing-box-service-runtime-pid" ]); + let sing_box_pid_before = sing_box_pid_result.status == 0 ? trim(sing_box_pid_result.output) : ""; + nft_populate_enabled = plan.needs_nft_rebuild == 1 ? 1 : 0; + status = singbox_init_config(); + if (status != 0) + return abort_reload(status, true); + nft_populate_enabled = NFT_POPULATE_ENABLED_DEFAULT; + status = module_status(STATE_UC, [ + "reload-sing-box-runtime", + sing_box_pid_before, + sing_box_config_hash_before, + file_md5(sing_box_config_path), + as_string(force_runtime_reload) + ]); + if (status != 0) + return abort_reload(status, true); + status = module_status(STATE_UC, [ + "wait-tachyon-stable-start", + RT_TABLE_NAME, + NFT_TABLE_NAME, + NFT_FAKEIP_MARK, + as_string(SING_BOX_START_STABLE_MIN_AGE), + as_string(SING_BOX_START_VERIFY_TIMEOUT) + ]); + if (status != 0) { + log_message("Reload verification failed after sing-box was reloaded; stopping Tachyon runtime", "fatal"); + cleanup_failed_runtime(); + return status; + } + status = module_status(PRIORITY_UC, [ "start-runtime" ]); + if (status != 0) { + log_message("Failed to start Priority runtime after sing-box reload", "fatal"); + cleanup_failed_runtime(); + return status; + } + status = module_status(DNS_FAILOVER_UC, [ "start-runtime" ]); + if (status != 0) { + log_message("Failed to restart DNS failover runtime after sing-box reload", "fatal"); + cleanup_failed_runtime(); + return status; + } + } + else if (plan.needs_nft_rebuild == 1 && nft_populate_enabled == 1) { + status = nft_populate_runtime_sets(); + if (status != 0) + return abort_reload(status, true); + } + + if (plan.needs_zapret_restart == 1) + module_success(ZAPRET_UC, [ "start-runtime" ]); + if (plan.needs_zapret2_restart == 1) + module_success(ZAPRET2_UC, [ "start-runtime" ]); + if (plan.needs_byedpi_restart == 1) + module_success(BYEDPI_UC, [ "start-runtime" ]); + + if (plan.needs_dnsmasq_configure == 1) { + status = dnsmasq_configure(true); + if (status != 0) + return abort_reload(status, true); + module_success(STATE_UC, [ "capture-reload-state", RELOAD_STATE_SNAPSHOT_FILE, as_string(RELOAD_STATE_FORMAT) ]); + } + else if (plan.needs_dnsmasq_restore == 1) { + status = dnsmasq_restore(true); + if (status != 0) + return abort_reload(status, true); + module_success(STATE_UC, [ "capture-reload-state", RELOAD_STATE_SNAPSHOT_FILE, as_string(RELOAD_STATE_FORMAT) ]); + } + + if (plan.needs_cron_refresh == 1) { + status = refresh_cron(); + if (status != 0) + return abort_reload(status, false); + } + + if (plan.needs_list_update == 1) + module_background(UPDATES_UC, [ "list-update" ]); + + return finish_reload_status(module_status(STATE_UC, [ + "write-captured-reload-state", + RELOAD_STATE_FILE, + RELOAD_STATE_SNAPSHOT_FILE, + as_string(RELOAD_STATE_FORMAT), + RULE_CONDITION_CACHE_DIR, + "1", + "1" + ]), reload_config_fingerprint); +} + +function reload_tracked(reason) { + if (as_string(getenv("TACHYON_UI_ACTION_TRACKED") || "0") == "1") + return reload(reason); + + let job_id = trim(module_output(UI_UC, [ "service-action-begin-if-idle", "reload", "runtime_reload" ])); + if (job_id != "") + module_success(UI_UC, [ "service-action-update-pid", job_id, owner_pid() ]); + + let status = reload(reason); + if (job_id != "") + module_success(UI_UC, [ "service-action-finish-after-command", "reload", job_id, as_string(status) ]); + + return status; +} + +function restart() { + log_message("Restarting Tachyon", "info"); + + let selector_state = capture_selector_state(); + let status = stop_impl(); + if (status != 0) + return status; + + status = start_impl(); + if (status != 0) { + cleanup_failed_runtime(); + return status; + } + + if (module_success(STATE_UC, [ + "tachyon-stably-running", + RT_TABLE_NAME, + NFT_TABLE_NAME, + NFT_FAKEIP_MARK, + as_string(RUNTIME_STABLE_MIN_AGE) + ])) { + restore_selector_state(selector_state); + return 0; + } + + log_message("Restart verification failed after Tachyon was started; stopping Tachyon runtime", "fatal"); + cleanup_failed_runtime(); + return 1; +} + +function package_manager_remove_if_installed(package_name) { + package_name = as_string(package_name); + if (command_success_from_args([ "sh", "-c", "command -v apk" ])) { + if (command_success_from_args([ "apk", "info", "-e", package_name ])) + command_success_from_args([ "apk", "del", package_name ]); + return; + } + + if (module_success(PACKAGES_UC, [ "opkg-installed", package_name ])) + command_success_from_args([ "opkg", "remove", "--force-depends", package_name ]); +} + +function uninstall() { + log_message("Uninstalling Tachyon", "info"); + + if (fs.stat(SERVICE_INIT) != null) { + stop(); + command_success_from_args([ SERVICE_INIT, "disable" ]); + } + + dnsmasq_restore_fail_safe(); + + if (fs.stat("/etc/init.d/tachyon") != null) { + command_success_from_args([ "/etc/init.d/tachyon", "stop" ]); + command_success_from_args([ "/etc/init.d/tachyon", "disable" ]); + } + + package_manager_remove_if_installed("luci-i18n-tachyon-ru"); + package_manager_remove_if_installed("luci-app-tachyon"); + + if (module_success(SINGBOX_UC, [ "managed-service-installed" ])) { + module_success(SINGBOX_UC, [ "remove-managed-service-script" ]); + remove_file("/usr/bin/sing-box"); + remove_file("/usr/lib/libcronet.so"); + } + + command_success_from_args([ "rm", "-rf", "/usr/lib/tachyon" ]); + command_success_from_args([ "rm", "-rf", LUCI_VIEW_DIR ]); + remove_file(SERVICE_INIT); + remove_file(BIN_PATH); + remove_file("/usr/share/luci/menu.d/luci-app-tachyon.json"); + remove_file("/usr/share/rpcd/acl.d/luci-app-tachyon.json"); + remove_file("/etc/uci-defaults/50_luci-tachyon"); + command_success_from_args([ "find", "/usr/lib/lua/luci/i18n", "-maxdepth", "1", "-type", "f", "-name", LUCI_I18N_DOMAIN + ".*.lmo", "-delete" ]); + remove_file("/usr/lib/lua/luci/i18n/" + LUCI_I18N_DOMAIN + ".ru.lua"); + remove_file("/usr/lib/lua/luci/i18n/" + LUCI_I18N_DOMAIN + ".en.lua"); + command_success("rm -f /var/luci-indexcache* /tmp/luci-indexcache* 2>/dev/null"); + if (fs.stat("/etc/init.d/rpcd") != null) + command_success_from_args([ "/etc/init.d/rpcd", "reload" ]); + + print("{\"removed\":true}\n"); + return 0; +} + +function enable_service() { + return command_status_from_args([ SERVICE_INIT, "enable" ]); +} + +function disable_service() { + return command_status_from_args([ SERVICE_INIT, "disable" ]); +} + +let mode = ARGV[0] || ""; +let status = 1; + +if (mode == "main") + status = start_main(); +else if (mode == "start") + status = start(); +else if (mode == "stop") + status = stop(); +else if (mode == "reload") + status = reload_tracked(ARGV[1] || ""); +else if (mode == "dns-failover-apply") + status = dns_failover_apply(ARGV[1] || ""); +else if (mode == "restart") + status = restart(); +else if (mode == "enable") + status = enable_service(); +else if (mode == "disable") + status = disable_service(); +else if (mode == "selector-state-from-proxies-fixture") { + write_json(selector_state_from_proxies_payload(read_json_file(ARGV[1]))); + status = 0; +} +else if (mode == "selector-restore-pairs-fixture") { + write_json(selector_restore_pairs(read_json_file(ARGV[1]), read_json_file(ARGV[2]))); + status = 0; +} +else if (mode == "dnsmasq-restore" || mode == "restore-dnsmasq") + status = dnsmasq_restore_fail_safe(); +else if (mode == "uninstall") + status = uninstall(); +else { + warn("Usage: service/lifecycle.uc ...\n"); + status = 1; +} + +release_start_subscription_update_lock(); +exit(status); diff --git a/tachyon/files/usr/lib/service/package.uc b/tachyon/files/usr/lib/service/package.uc new file mode 100644 index 00000000..a1b935d7 --- /dev/null +++ b/tachyon/files/usr/lib/service/package.uc @@ -0,0 +1,188 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function env(name, fallback) { + let value = getenv(name); + return value == null ? as_string(fallback) : as_string(value); +} + +const CONFIG_NAME = env("TACHYON_CONFIG_NAME", "tachyon"); +const RT_TABLES_PATH = env("TACHYON_RT_TABLES", "/etc/iproute2/rt_tables"); +const BIN_PATH = env("TACHYON_BIN", "/usr/bin/tachyon"); +const INIT_PATH = env("TACHYON_INIT", "/etc/init.d/tachyon"); +const DNS_APPLY_UC = env("TACHYON_DNS_APPLY_UC", "/usr/lib/tachyon/dns/apply.uc"); +const SING_BOX_INIT = env("TACHYON_SING_BOX_INIT", "/etc/init.d/sing-box"); +const SING_BOX_BIN = env("TACHYON_SING_BOX_BIN", "/usr/bin/sing-box"); +const SING_BOX_CRONET = env("TACHYON_SING_BOX_CRONET", "/usr/lib/libcronet.so"); +const SING_BOX_MANAGED_MARKER = env("SB_MANAGED_SERVICE_MARKER", "Tachyon managed sing-box service for binary variants"); +const PACKAGE_UPGRADE_STATE = env("TACHYON_PACKAGE_UPGRADE_STATE", "/tmp/tachyon-package-was-running"); +const PACKAGE_TEST_MODE = env("TACHYON_PACKAGE_TEST_MODE", "") != ""; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; +} + +function command_success_from_args(args) { + return normalize_status(system(command_from_args(args) + " >/dev/null 2>&1")) == 0; +} + +function path_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function unlink_if_exists(path) { + if (path_exists(path)) + fs.unlink(as_string(path)); +} + +function remove_rt_tables_entry() { + let data = fs.readfile(RT_TABLES_PATH); + if (data == null) + return true; + + let changed = false; + let lines = []; + for (let line in split(data, "\n")) { + if (index(line, "105 tachyon") >= 0) { + changed = true; + continue; + } + push(lines, line); + } + + return !changed || fs.writefile(RT_TABLES_PATH, join("\n", lines)) != null; +} + +function ascii_lower(value) { + let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let lower = "abcdefghijklmnopqrstuvwxyz"; + return replace(as_string(value), /[A-Z]/g, function(ch) { + return substr(lower, index(upper, ch), 1); + }); +} + +function truthy(value) { + value = ascii_lower(trim(as_string(value))); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function dont_touch_dhcp_enabled() { + return truthy(uci_core.get(CONFIG_NAME + ".settings.dont_touch_dhcp")); +} + +function restore_dnsmasq_if_needed() { + if (dont_touch_dhcp_enabled()) + return; + + command_success_from_args([ BIN_PATH, "restore_dnsmasq" ]); + if (path_exists(DNS_APPLY_UC)) + command_success_from_args([ "ucode", DNS_APPLY_UC, "failsafe-restore" ]); +} + +function remove_managed_sing_box() { + let data = fs.readfile(SING_BOX_INIT); + if (data == null || index(data, SING_BOX_MANAGED_MARKER) < 0) + return; + + command_success_from_args([ SING_BOX_INIT, "stop" ]); + command_success_from_args([ SING_BOX_INIT, "disable" ]); + unlink_if_exists(SING_BOX_INIT); + unlink_if_exists(SING_BOX_BIN); + unlink_if_exists(SING_BOX_CRONET); +} + +function remember_upgrade_state(action) { + if (as_string(action) != "upgrade") { + unlink_if_exists(PACKAGE_UPGRADE_STATE); + return; + } + + if (command_success_from_args([ INIT_PATH, "status" ])) + fs.writefile(PACKAGE_UPGRADE_STATE, "1\n"); +} + +function prerm_cleanup(action) { + if (env("IPKG_INSTROOT", "") != "") + return true; + + remember_upgrade_state(action); + if (!PACKAGE_TEST_MODE) { + command_success_from_args([ INIT_PATH, "stop" ]); + restore_dnsmasq_if_needed(); + remove_managed_sing_box(); + } + return remove_rt_tables_entry(); +} + +function postinst_restore() { + if (env("IPKG_INSTROOT", "") != "" || !path_exists(PACKAGE_UPGRADE_STATE)) + return true; + + if (!command_success_from_args([ INIT_PATH, "start" ])) + return false; + + unlink_if_exists(PACKAGE_UPGRADE_STATE); + return true; +} + +function luci_cache_globs() { + let configured = env("TACHYON_LUCI_CACHE_GLOBS", ""); + if (configured != "") + return split(configured, /[ \t\r\n]+/); + + return [ "/var/luci-indexcache*", "/tmp/luci-indexcache*" ]; +} + +function remove_luci_index_cache() { + for (let pattern in luci_cache_globs()) { + pattern = as_string(pattern); + if (pattern == "") + continue; + + for (let path in fs.glob(pattern)) + unlink_if_exists(path); + } +} + +function luci_postinst() { + remove_luci_index_cache(); + if (!PACKAGE_TEST_MODE) { + if (path_exists("/etc/init.d/rpcd")) + command_success_from_args([ "/etc/init.d/rpcd", "reload" ]); + command_success_from_args([ "logger", "-t", "tachyon", "[info] Package defaults applied" ]); + } + return true; +} + +let mode = ARGV[0] || ""; + +if (mode == "prerm") + exit(prerm_cleanup(ARGV[1]) ? 0 : 1); +else if (mode == "postinst") + exit(postinst_restore() ? 0 : 1); +else if (mode == "remove-rt-tables-entry") + exit(remove_rt_tables_entry() ? 0 : 1); +else if (mode == "luci-postinst") + exit(luci_postinst() ? 0 : 1); +else { + warn("Usage: service/package.uc \n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/service/reload.uc b/tachyon/files/usr/lib/service/reload.uc new file mode 100644 index 00000000..cea56bbf --- /dev/null +++ b/tachyon/files/usr/lib/service/reload.uc @@ -0,0 +1,302 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function arg_bool(value) { + return value === true || value == "true" || value == "1" || value == 1; +} + +function whitespace_fields(value) { + value = as_string(value); + let result = []; + + for (let item in split(trim(value), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + + return result; +} + +function list_has_item(items, needle) { + needle = as_string(needle); + if (needle == "") + return false; + + for (let item in whitespace_fields(items)) + if (item == needle) + return true; + + return false; +} + +function new_urltest_enabled_sections(previous_sections, current_sections, previous_known) { + if (!arg_bool(previous_known)) + return ""; + + let result = []; + for (let section in whitespace_fields(current_sections)) + if (!list_has_item(previous_sections, section)) + push(result, section); + + return join(" ", result); +} + +function emit(name, value) { + print(name, "\t", as_string(value), "\n"); +} + +function emit_bool(name, value) { + emit(name, value ? "1" : "0"); +} + +function emit_reload_plan(previous, current, context) { + let changed = { + service_triggers: current.service_trigger != previous.service_trigger, + dnsmasq: current.dnsmasq != previous.dnsmasq, + sing_box: current.sing_box != previous.sing_box, + nft: current.nft != previous.nft, + zapret_queue: current.zapret_queue != previous.zapret_queue, + zapret_runtime: current.zapret_runtime != previous.zapret_runtime, + zapret2_queue: current.zapret2_queue != previous.zapret2_queue, + zapret2_runtime: current.zapret2_runtime != previous.zapret2_runtime, + byedpi_runtime: current.byedpi_runtime != previous.byedpi_runtime, + list: current.list != previous.list, + cron: current.cron != previous.cron + }; + + let needs = { + sing_box_reload: false, + nft_rebuild: false, + zapret_restart: false, + zapret2_restart: false, + byedpi_restart: false, + dnsmasq_configure: false, + dnsmasq_restore: false, + cron_refresh: false, + list_update: false + }; + + if (changed.sing_box) + needs.sing_box_reload = true; + + if (changed.nft) + needs.nft_rebuild = true; + + if (changed.zapret_queue) { + needs.zapret_restart = true; + needs.nft_rebuild = true; + needs.sing_box_reload = true; + } + + if (changed.zapret_runtime) + needs.zapret_restart = true; + + if (changed.zapret2_queue) { + needs.zapret2_restart = true; + needs.nft_rebuild = true; + needs.sing_box_reload = true; + } + + if (changed.zapret2_runtime) + needs.zapret2_restart = true; + + if (changed.byedpi_runtime) + needs.byedpi_restart = true; + + if (changed.dnsmasq) { + if (!current.dont_touch_dhcp) + needs.dnsmasq_configure = true; + else if (context.dnsmasq_managed_state) + needs.dnsmasq_restore = true; + } + + if (changed.cron) + needs.cron_refresh = true; + + if (changed.list && context.has_list_update_sources) + needs.list_update = true; + + if (needs.nft_rebuild && context.has_nft_list_update_sources) + needs.list_update = true; + + if (context.runtime_cache_needs_rebuild) { + changed.sing_box = true; + needs.sing_box_reload = true; + } + + if (context.force_runtime_reload && + !needs.sing_box_reload && + !needs.nft_rebuild && + !needs.zapret_restart && + !needs.zapret2_restart && + !needs.byedpi_restart && + !needs.dnsmasq_configure && + !needs.dnsmasq_restore) + needs.sing_box_reload = true; + + let has_work = + needs.sing_box_reload || + needs.nft_rebuild || + needs.zapret_restart || + needs.zapret2_restart || + needs.byedpi_restart || + needs.dnsmasq_configure || + needs.dnsmasq_restore || + needs.cron_refresh || + needs.list_update; + + emit("urltest_new_enabled_sections", new_urltest_enabled_sections( + previous.urltest_sections, + current.urltest_sections, + previous.urltest_sections_known + )); + + emit_bool("changed_service_triggers", changed.service_triggers); + emit_bool("changed_dnsmasq", changed.dnsmasq); + emit_bool("changed_sing_box", changed.sing_box); + emit_bool("changed_nft", changed.nft); + emit_bool("changed_zapret_queue", changed.zapret_queue); + emit_bool("changed_zapret_runtime", changed.zapret_runtime); + emit_bool("changed_zapret2_queue", changed.zapret2_queue); + emit_bool("changed_zapret2_runtime", changed.zapret2_runtime); + emit_bool("changed_byedpi_runtime", changed.byedpi_runtime); + emit_bool("changed_cron", changed.cron); + emit_bool("changed_list", changed.list); + + emit_bool("needs_sing_box_reload", needs.sing_box_reload); + emit_bool("needs_nft_rebuild", needs.nft_rebuild); + emit_bool("needs_zapret_restart", needs.zapret_restart); + emit_bool("needs_zapret2_restart", needs.zapret2_restart); + emit_bool("needs_byedpi_restart", needs.byedpi_restart); + emit_bool("needs_dnsmasq_configure", needs.dnsmasq_configure); + emit_bool("needs_dnsmasq_restore", needs.dnsmasq_restore); + emit_bool("needs_cron_refresh", needs.cron_refresh); + emit_bool("needs_list_update", needs.list_update); + emit_bool("has_work", has_work); +} + +function reload_plan() { + let previous = { + service_trigger: as_string(ARGV[1]), + dnsmasq: as_string(ARGV[2]), + sing_box: as_string(ARGV[3]), + nft: as_string(ARGV[4]), + zapret_queue: as_string(ARGV[5]), + zapret_runtime: as_string(ARGV[6]), + zapret2_queue: as_string(ARGV[7]), + zapret2_runtime: as_string(ARGV[8]), + byedpi_runtime: as_string(ARGV[9]), + list: as_string(ARGV[10]), + cron: as_string(ARGV[11]), + urltest_sections_known: arg_bool(ARGV[12]), + urltest_sections: as_string(ARGV[13]) + }; + + let current = { + service_trigger: as_string(ARGV[14]), + dnsmasq: as_string(ARGV[15]), + sing_box: as_string(ARGV[16]), + nft: as_string(ARGV[17]), + zapret_queue: as_string(ARGV[18]), + zapret_runtime: as_string(ARGV[19]), + zapret2_queue: as_string(ARGV[20]), + zapret2_runtime: as_string(ARGV[21]), + byedpi_runtime: as_string(ARGV[22]), + list: as_string(ARGV[23]), + cron: as_string(ARGV[24]), + urltest_sections: as_string(ARGV[25]), + dont_touch_dhcp: arg_bool(ARGV[26]) + }; + + let context = { + force_runtime_reload: arg_bool(ARGV[27]), + dnsmasq_managed_state: arg_bool(ARGV[28]), + has_list_update_sources: arg_bool(ARGV[29]), + has_nft_list_update_sources: arg_bool(ARGV[30]), + runtime_cache_needs_rebuild: arg_bool(ARGV[31]) + }; + + emit_reload_plan(previous, current, context); +} + +function read_state_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + let state = {}; + let has = {}; + + for (let line in split(data, "\n")) { + if (line == "") + continue; + + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == "") + continue; + + state[key] = equals >= 0 ? substr(line, equals + 1) : ""; + has[key] = true; + } + + state.__has = has; + return state; +} + +function plan_state_from_file(path) { + let state = read_state_file(path); + if (state == null) + return null; + + return { + format: as_string(state.format), + service_trigger: as_string(state.service_trigger_signature || state.restart_signature), + dnsmasq: as_string(state.dnsmasq_signature), + sing_box: as_string(state.sing_box_signature), + nft: as_string(state.nft_signature), + zapret_queue: as_string(state.zapret_queue_signature), + zapret_runtime: as_string(state.zapret_runtime_signature), + zapret2_queue: as_string(state.zapret2_queue_signature), + zapret2_runtime: as_string(state.zapret2_runtime_signature), + byedpi_runtime: as_string(state.byedpi_runtime_signature), + list: as_string(state.list_signature), + cron: as_string(state.cron_signature), + urltest_sections_known: state.__has.urltest_enabled_sections === true, + urltest_sections: as_string(state.urltest_enabled_sections), + dont_touch_dhcp: arg_bool(state.dont_touch_dhcp) + }; +} + +function plan_state_files() { + let previous = plan_state_from_file(ARGV[1]); + let current = plan_state_from_file(ARGV[2]); + if (previous == null || current == null || previous.format == "" || previous.format != current.format) + exit(2); + + let context = { + force_runtime_reload: arg_bool(ARGV[3]), + dnsmasq_managed_state: arg_bool(ARGV[4]), + has_list_update_sources: arg_bool(ARGV[5]), + has_nft_list_update_sources: arg_bool(ARGV[6]), + runtime_cache_needs_rebuild: arg_bool(ARGV[7]) + }; + + emit_reload_plan(previous, current, context); +} + +let mode = ARGV[0] || ""; + +if (mode == "plan") + reload_plan(); +else if (mode == "plan-state-files") + plan_state_files(); +else { + warn("Usage: service/reload.uc plan ...\n"); + warn(" service/reload.uc plan-state-files \n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/service/state.uc b/tachyon/files/usr/lib/service/state.uc new file mode 100644 index 00000000..5fbf5538 --- /dev/null +++ b/tachyon/files/usr/lib/service/state.uc @@ -0,0 +1,2001 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); +let rule_config = require("config.rule"); +let connections = require("config.connections"); +let zapret_validator = require("providers.zapret.validator"); +let zapret2_validator = require("providers.zapret2.validator"); +let byedpi_validator = require("providers.byedpi.validator"); +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const DEFAULT_PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || "/var/run/tachyon/reload.pending"; +const DEFAULT_SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || "/etc/init.d/tachyon"; +const ZAPRET_DEFAULT_NFQWS_OPT = getenv("ZAPRET_DEFAULT_NFQWS_OPT") || ""; +const ZAPRET2_DEFAULT_NFQWS2_OPT = getenv("ZAPRET2_DEFAULT_NFQWS2_OPT") || ""; +const BYEDPI_DEFAULT_CMD_OPTS = getenv("BYEDPI_DEFAULT_CMD_OPTS") || ""; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_stdin_json() { + let data = read_stdin(); + try { + return json(data); + } + catch (e) { + return null; + } +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function write_text_file(path, text) { + let result = fs.writefile(path, as_string(text)); + if (result == null) + return false; + if (type(result) == "boolean" && !result) + return false; + return true; +} + +function unlink_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function command_output_from_args(args) { + let pipe = fs.popen(command_from_args(args), "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function command_trimmed_output_from_args(args) { + return replace(command_output_from_args(args), /[\r\n]+$/g, ""); +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function uci_get(path) { + return uci_core.get(path); +} + +function uci_exists(path) { + return uci_core.exists(path); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function file_first_line(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let newline = index(data, "\n"); + print(newline >= 0 ? substr(data, 0, newline) : data, "\n"); +} + +function read_state_value(path, needle) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + needle = as_string(needle); + for (let line in split(data, "\n")) { + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == needle) { + print(equals >= 0 ? substr(line, equals + 1) : line, "\n"); + return; + } + } +} + +function state_has_key(path, needle) { + let data = fs.readfile(path); + if (data == null) + return false; + + needle = as_string(needle); + for (let line in split(data, "\n")) { + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == needle) + return true; + } + + return false; +} + +const RELOAD_STATE_FIELDS = [ + "format", + "service_trigger_signature", + "dnsmasq_signature", + "sing_box_signature", + "nft_signature", + "zapret_queue_signature", + "zapret_runtime_signature", + "zapret2_queue_signature", + "zapret2_runtime_signature", + "byedpi_runtime_signature", + "list_signature", + "cron_signature", + "urltest_enabled_sections", + "dont_touch_dhcp" +]; + +function reload_state_text(values) { + let output = ""; + values = object_or_empty(values); + + for (let field in RELOAD_STATE_FIELDS) + output += field + "=" + as_string(values[field]) + "\n"; + + return output; +} + +function parent_dir(path) { + path = as_string(path); + let slash = rindex(path, "/"); + if (slash < 0) + return ""; + return substr(path, 0, slash); +} + +function ensure_parent_dir(path) { + let dir = parent_dir(path); + if (dir == "" || dir == ".") + return true; + return command_success_from_args([ "mkdir", "-p", dir ]); +} + +function numeric_text(value) { + return match(as_string(value), /^[0-9]+$/) != null; +} + +function current_epoch() { + let value = command_trimmed_output_from_args([ "date", "+%s" ]); + return numeric_text(value) ? value : "0"; +} + +function current_year() { + let value = command_trimmed_output_from_args([ "date", "+%Y" ]); + return numeric_text(value) ? int(value) : null; +} + +function time_sync_needed(year) { + if (year == null) + return false; + return int(year) < 2024; +} + +function sync_time_if_needed() { + if (!time_sync_needed(current_year())) + return; + + command_success_from_args([ + "/usr/sbin/ntpd", + "-q", + "-p", "194.190.168.1", + "-p", "216.239.35.0", + "-p", "216.239.35.4", + "-p", "162.159.200.1", + "-p", "162.159.200.123" + ]); +} + +function mark_pending_reload(path, reason) { + path = as_string(path || DEFAULT_PENDING_RELOAD_FILE); + reason = as_string(reason || "pending"); + + if (!ensure_parent_dir(path)) + exit(1); + + if (!write_text_file(path, "reason=" + reason + "\nupdated_at=" + current_epoch() + "\n")) + exit(1); +} + +function consume_pending_reload(path) { + path = as_string(path || DEFAULT_PENDING_RELOAD_FILE); + if (fs.stat(path) == null) + return false; + + unlink_file(path); + return true; +} + +function run_pending_reload_if_requested(path, init_script) { + path = as_string(path || DEFAULT_PENDING_RELOAD_FILE); + init_script = as_string(init_script || DEFAULT_SERVICE_INIT); + + if (!consume_pending_reload(path)) + return; + + command_success_from_args([ "logger", "-t", "tachyon", "[info] Applying pending Tachyon reload" ]); + system(shell_quote(init_script) + " reload pending >/dev/null 2>&1 1000>&- &"); +} + +function first_line_value(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function pid_alive(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function lock_dir_write_owner(lock_dir, owner_pid) { + return write_text_file(as_string(lock_dir) + "/pid", as_string(owner_pid) + "\n"); +} + +function acquire_runtime_dir_lock(lock_dir, owner_pid) { + lock_dir = as_string(lock_dir); + owner_pid = as_string(owner_pid); + if (lock_dir == "" || owner_pid == "") + return false; + + if (command_success_from_args([ "mkdir", lock_dir ])) { + if (lock_dir_write_owner(lock_dir, owner_pid)) + return true; + release_runtime_dir_lock(lock_dir); + return false; + } + + if (pid_alive(first_line_value(lock_dir + "/pid"))) + return false; + + command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]); + if (!command_success_from_args([ "rmdir", lock_dir ])) + return false; + if (!command_success_from_args([ "mkdir", lock_dir ])) + return false; + + if (lock_dir_write_owner(lock_dir, owner_pid)) + return true; + + release_runtime_dir_lock(lock_dir); + return false; +} + +function acquire_runtime_dir_lock_wait(lock_dir, owner_pid, timeout) { + let timeout_text = as_string(timeout == null ? "300" : timeout); + timeout = numeric_text(timeout_text) ? int(timeout_text, 10) : 300; + let start = int(current_epoch(), 10) || 0; + + while (!acquire_runtime_dir_lock(lock_dir, owner_pid)) { + let now = int(current_epoch(), 10) || start; + if (now - start >= timeout) + return false; + command_success_from_args([ "sleep", "2" ]); + } + + return true; +} + +function release_runtime_dir_lock(lock_dir) { + lock_dir = as_string(lock_dir); + if (lock_dir == "") + return; + + command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]); + command_success_from_args([ "rmdir", lock_dir ]); +} + +function write_reload_state(path, values) { + if (!ensure_parent_dir(path)) + exit(1); + + if (!fs.writefile(path, reload_state_text(values))) + exit(1); +} + +function copy_file(source, target) { + let data = fs.readfile(source); + if (data == null) + return false; + if (!ensure_parent_dir(target)) + return false; + return write_text_file(target, data); +} + +function cleanup_rule_condition_cache(path) { + path = as_string(path); + if (path != "") + command_success_from_args([ "rm", "-rf", path ]); + system("rm -rf /tmp/tachyon-rule-cache.* >/dev/null 2>&1"); +} + +function cleanup_reload_state_snapshots(path) { + path = as_string(path); + if (path == "") + return; + + for (let snapshot_path in fs.glob(path + ".snapshot.*")) + unlink_file(snapshot_path); +} + +function clear_reload_state(path, snapshot_path) { + unlink_file(path); + if (as_string(snapshot_path) != "") + unlink_file(snapshot_path); + cleanup_reload_state_snapshots(path); +} + +function remove_file(path) { + unlink_file(path); +} + +function reload_state_values_from_args(offset) { + let values = {}; + offset = int(offset || 0); + + for (let i = 0; i < length(RELOAD_STATE_FIELDS); i++) + values[RELOAD_STATE_FIELDS[i]] = ARGV[i + offset]; + + return values; +} + +function response_success() { + let value = read_stdin_json(); + return type(value) == "object" && value.success === true; +} + +function stdin_first_field() { + let data = read_stdin(); + let newline = index(data, "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + + if (length(fields) > 0 && fields[0] != "") + print(fields[0], "\n"); +} + +function sing_box_service_pid_from_value(value) { + let service = type(value) == "object" ? value["sing-box"] : null; + let instances = service && type(service.instances) == "object" ? service.instances : {}; + + for (let _, instance in instances) { + if (type(instance) == "object" && instance.running === true && int(instance.pid || 0) > 0) + return int(instance.pid); + } + + return 0; +} + +function sing_box_service_pid() { + let pid = sing_box_service_pid_from_value(read_stdin_json()); + if (pid > 0) + print(pid, "\n"); +} + +function sing_box_service_pid_runtime() { + let data = command_output_from_args([ "ubus", "call", "service", "list", "{\"name\":\"sing-box\"}" ]); + try { + return sing_box_service_pid_from_value(json(data)); + } + catch (e) { + return 0; + } +} + +function path_basename(path) { + path = as_string(path); + let slash = rindex(path, "/"); + return slash >= 0 ? substr(path, slash + 1) : path; +} + +function pid_is_sing_box(pid) { + pid = as_string(pid); + if (match(pid, /^[0-9]+$/) == null) + return false; + + return path_basename(command_trimmed_output_from_args([ "readlink", "/proc/" + pid + "/exe" ])) == "sing-box"; +} + +function hup_sing_box_runtime() { + let pid = sing_box_service_pid_runtime(); + if (pid <= 0 || !pid_is_sing_box(pid)) + exit(1); + + command_success_from_args([ "logger", "-t", "tachyon", "[info] Applying DNS failover with sing-box SIGHUP reload" ]); + if (!command_success_from_args([ "kill", "-HUP", as_string(pid) ])) + exit(1); +} + +function process_start_ticks(stat) { + stat = as_string(stat); + let marker = index(stat, ") "); + if (marker < 0) + return null; + + let fields = split(trim(substr(stat, marker + 2)), /[ \t\r\n]+/); + if (length(fields) < 20) + return null; + + let start_ticks = fields[19]; + if (match(start_ticks, /^[0-9]+$/) == null) + return null; + + return int(start_ticks); +} + +function process_age_seconds_from_ticks(start_ticks, current_ticks) { + start_ticks = as_string(start_ticks); + current_ticks = as_string(current_ticks); + if (match(start_ticks, /^[0-9]+$/) == null || match(current_ticks, /^[0-9]+$/) == null) + return null; + + start_ticks = int(start_ticks); + current_ticks = int(current_ticks); + if (current_ticks < start_ticks) + return null; + + return int((current_ticks - start_ticks) / 100); +} + +function process_age_seconds(pid) { + pid = as_string(pid); + if (match(pid, /^[0-9]+$/) == null) + return null; + + let start_ticks = process_start_ticks(fs.readfile("/proc/" + pid + "/stat")); + if (start_ticks == null) + return null; + + let current_ticks = process_start_ticks(command_output_from_args([ "cat", "/proc/self/stat" ])); + return process_age_seconds_from_ticks(start_ticks, current_ticks); +} + +function sing_box_pid_replaced(previous_pid, current_pid, current_is_sing_box) { + previous_pid = int(previous_pid || 0); + current_pid = int(current_pid || 0); + current_is_sing_box = lc(as_string(current_is_sing_box)); + let executable_matches = current_is_sing_box == "1" || current_is_sing_box == "true" || + current_is_sing_box == "yes" || current_is_sing_box == "on"; + return current_pid > 0 && executable_matches && + (previous_pid <= 0 || current_pid != previous_pid); +} + +function wait_sing_box_pid_replacement(previous_pid, timeout) { + timeout = int(timeout || 15); + while (timeout > 0) { + let current_pid = sing_box_service_pid_runtime(); + if (sing_box_pid_replaced(previous_pid, current_pid, pid_is_sing_box(current_pid))) + return true; + + command_success_from_args([ "sleep", "1" ]); + timeout--; + } + return false; +} + +function sing_box_reload_previous_pid(previous_pid, config_hash_before, config_hash_after) { + previous_pid = as_string(previous_pid); + if (as_string(config_hash_before) == as_string(config_hash_after) || + match(previous_pid, /^[0-9]+$/) == null || int(previous_pid) <= 0) + return 0; + return int(previous_pid); +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function sing_box_runtime_reload_needed(config_hash_before, config_hash_after, force) { + return arg_bool(force) || as_string(config_hash_before) != as_string(config_hash_after); +} + +function reload_sing_box_runtime(previous_pid, config_hash_before, config_hash_after, force) { + if (!sing_box_runtime_reload_needed(config_hash_before, config_hash_after, force)) { + command_success_from_args([ "logger", "-t", "tachyon", "[info] sing-box reload skipped: configuration is unchanged" ]); + return; + } + + previous_pid = sing_box_reload_previous_pid(previous_pid, config_hash_before, config_hash_after); + command_success_from_args([ "logger", "-t", "tachyon", "[info] Reloading sing-box runtime" ]); + if (!command_success_from_args([ "/etc/init.d/sing-box", "reload" ])) { + command_success_from_args([ "logger", "-t", "tachyon", "[fatal] Failed to reload sing-box. Aborted." ]); + exit(1); + } + + let timeout = getenv("TACHYON_SING_BOX_RELOAD_PID_TIMEOUT") || "15"; + if (previous_pid > 0 && !wait_sing_box_pid_replacement(previous_pid, timeout)) { + command_success_from_args([ "logger", "-t", "tachyon", "[fatal] sing-box reload did not replace the running process. Aborted." ]); + exit(1); + } +} + +function sing_box_service_running() { + let pid = sing_box_service_pid_runtime(); + return pid > 0 && pid_is_sing_box(pid); +} + +function sing_box_service_stable(min_age) { + min_age = int(min_age || 2); + + let pid = sing_box_service_pid_runtime(); + if (pid <= 0 || !pid_is_sing_box(pid)) + return false; + + let age = process_age_seconds(pid); + return age != null && age >= min_age; +} + +function tachyon_runtime_network_configured(rt_table, nft_table, mark) { + return command_success_from_args([ "nft", "list", "table", "inet", nft_table ]) && + command_success_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/nft/apply.uc", "tproxy-route-rule-present", rt_table, mark ]); +} + +function tachyon_running(rt_table, nft_table, mark) { + return sing_box_service_running() && tachyon_runtime_network_configured(rt_table, nft_table, mark); +} + +function tachyon_stably_running(rt_table, nft_table, mark, min_age) { + return sing_box_service_stable(min_age) && tachyon_runtime_network_configured(rt_table, nft_table, mark); +} + +function wait_tachyon_stable_start(rt_table, nft_table, mark, min_age, timeout) { + timeout = int(timeout || 8); + while (timeout > 0) { + if (tachyon_stably_running(rt_table, nft_table, mark, min_age)) + return true; + + command_success_from_args([ "sleep", "1" ]); + timeout--; + } + + return tachyon_stably_running(rt_table, nft_table, mark, min_age); +} + +function whitespace_fields(value) { + value = as_string(value); + let result = []; + for (let item in split(trim(value), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function list_option(section, key, fallback) { + let value = object_or_empty(section)[key]; + if (type(value) == "array") + return value; + + value = trim(as_string(value)); + if (value != "") + return split(value, /[ \t\r\n]+/); + + fallback = as_string(fallback); + return fallback == "" ? [] : [ fallback ]; +} + +function bool_option(section, key, fallback) { + return arg_bool(option(section, key, fallback ? "1" : "0")); +} + +function bool_value(value) { + return arg_bool(value) ? "1" : "0"; +} + +function bool_option_value(section, key, fallback) { + return bool_option(section, key, fallback) ? "1" : "0"; +} + +function section_name(section) { + return as_string(object_or_empty(section)[".name"]); +} + +function str_startswith(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function reference_is_remote(value) { + return str_startswith(value, "http://") || str_startswith(value, "https://"); +} + +function list_has_remote_references(value) { + for (let item in whitespace_fields(value)) + if (reference_is_remote(item)) + return true; + return false; +} + +function community_service_has_subnet_list(value) { + return rule_config.community_service_has_subnet_list(value); +} + +function filter_community_subnet_lists(value) { + print(rule_config.filter_community_subnet_lists_value(value), "\n"); +} + +function has_community_subnet_list(value) { + return rule_config.has_community_subnet_list(value); +} + +function rule_has_list_update_source(enabled, action, community_lists, remote_domain_lists, remote_subnet_lists, rule_set_with_subnets, domain_ip_lists) { + if (!arg_bool(enabled)) + return false; + if (as_string(action) == "dns") + return list_has_remote_references(domain_ip_lists); + + return ( + has_community_subnet_list(community_lists) || + as_string(remote_domain_lists) != "" || + as_string(remote_subnet_lists) != "" || + as_string(rule_set_with_subnets) != "" || + list_has_remote_references(domain_ip_lists) + ); +} + +function rule_has_nft_list_update_source(enabled, action, community_lists, remote_subnet_lists, rule_set_with_subnets, domain_ip_lists) { + if (!arg_bool(enabled) || as_string(action) == "dns") + return false; + + return ( + has_community_subnet_list(community_lists) || + as_string(remote_subnet_lists) != "" || + as_string(rule_set_with_subnets) != "" || + list_has_remote_references(domain_ip_lists) + ); +} + +function rule_has_subscription_update_source(is_subscription_proxy, subscription_update_interval) { + return arg_bool(is_subscription_proxy) && as_string(subscription_update_interval) != ""; +} + +function duration_to_seconds_value(value) { + let rest = as_string(value); + if (rest == "") + return null; + + let total = 0.0; + let multipliers = { + ns: 0.000000001, + us: 0.000001, + ms: 0.001, + s: 1, + m: 60, + h: 3600, + d: 86400 + }; + + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return null; + + let token = as_string(matched[0]); + let amount = matched[1] * 1; + let unit = matched[3]; + total += amount * multipliers[unit]; + rest = substr(rest, length(token)); + } + + return total <= 0 ? null : int(total + 0.5); +} + +function download_via_proxy_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy"; + if (purpose == "components") + return "download_components_via_proxy"; + return ""; +} + +function download_via_proxy_enabled(settings, purpose) { + let enabled_option = download_via_proxy_option_for_purpose(purpose); + return enabled_option != "" && bool_option(settings, enabled_option, false); +} + +function signature_add_value(body, key, value) { + return body + "[" + as_string(key) + "]\n" + as_string(value) + "\n"; +} + +function signature_hash(body) { + let path = trim(command_output_from_args([ "mktemp" ])); + if (path == "") + return ""; + + if (!write_text_file(path, body)) { + unlink_file(path); + return ""; + } + + let hash_line = command_output_from_args([ "md5sum", path ]); + unlink_file(path); + hash_line = trim(hash_line); + + return length(hash_line) >= 32 ? substr(hash_line, 0, 32) : ""; +} + +function print_signature_hash(body) { + let hash = signature_hash(body); + if (hash == "") + return false; + + print(hash, "\n"); + return true; +} + +function dont_touch_dhcp_value(settings) { + return bool_option(settings, "dont_touch_dhcp", false) ? "1" : "0"; +} + +function service_trigger_signature_body(settings) { + let enabled = bool_option(settings, "enable_badwan_interface_monitoring", false); + let body = signature_add_value("", "settings.enable_badwan_interface_monitoring", enabled ? "1" : "0"); + + if (enabled) { + body = signature_add_value(body, "settings.badwan_monitored_interfaces", option(settings, "badwan_monitored_interfaces", "")); + body = signature_add_value(body, "settings.badwan_reload_delay", option(settings, "badwan_reload_delay", "2000")); + } + + return body; +} + +function normalize_detect_server_country_method(value) { + value = as_string(value); + if (value == "country_is") + return "country_is"; + return "flag_emoji"; +} + +function dnsmasq_signature_body(settings, dnsmasq, legacy_dnsmasq_present) { + let body = ""; + + body = signature_add_value(body, "settings.dont_touch_dhcp", dont_touch_dhcp_value(settings)); + body = signature_add_value(body, "dhcp.@dnsmasq[0].server", option(dnsmasq, "server", "")); + body = signature_add_value(body, "dhcp.@dnsmasq[0].noresolv", option(dnsmasq, "noresolv", "")); + body = signature_add_value(body, "dhcp.@dnsmasq[0].cachesize", option(dnsmasq, "cachesize", "")); + body = signature_add_value(body, "dhcp.@dnsmasq[0].tachyon_server", option(dnsmasq, "tachyon_server", "")); + body = signature_add_value(body, "dhcp.@dnsmasq[0].tachyon_noresolv", option(dnsmasq, "tachyon_noresolv", "")); + body = signature_add_value(body, "dhcp.@dnsmasq[0].tachyon_cachesize", option(dnsmasq, "tachyon_cachesize", "")); + body = signature_add_value(body, "dhcp.tachyon.present", arg_bool(legacy_dnsmasq_present) ? "1" : "0"); + + return body; +} + +function settings_update_interval(settings) { + settings = object_or_empty(settings); + + if (!bool_option(settings, "list_update_enabled", true)) + return ""; + + let value = option(settings, "update_interval", "1d"); + return value != "" ? value : "1d"; +} + +function settings_component_update_check_interval(settings) { + settings = object_or_empty(settings); + + if (!bool_option(settings, "component_update_check_enabled", false)) + return ""; + + let value = option(settings, "component_update_check_interval", "1d"); + return value != "" ? value : "1d"; +} + +function section_rule_ports_csv(section) { + return rule_config.rule_ports_csv_value(option(section, "ports", ""), option(section, "ports_text", "")); +} + +function combined_domain_condition_text(section) { + if (type(object_or_empty(section)["domain"]) != "array") { + let value = option(section, "domain", ""); + if (value != "") + return value; + } + + return option(section, "domain_suffix_text", ""); +} + +function section_rule_condition_csv(section, key, kind) { + return rule_config.rule_condition_csv_value( + key, + kind, + option(section, key + "_text_mode", "0"), + option(section, "conditions_text_mode", "0"), + option(section, key + "_text", ""), + option(section, key, ""), + combined_domain_condition_text(section), + option(section, "domain_suffix", "") + ); +} + +function nft_runtime_signature_body(settings, sections) { + let body = ""; + + body = signature_add_value(body, "settings.source_network_interfaces", option(settings, "source_network_interfaces", "br-lan")); + body = signature_add_value(body, "settings.exclude_ntp", bool_option(settings, "exclude_ntp", false) ? "1" : "0"); + + for (let section in sections) { + section = object_or_empty(section); + let name = section_name(section); + if (name == "" || !bool_option(section, "enabled", true)) + continue; + + let action = option(section, "action", ""); + body = signature_add_value(body, "rule." + name + ".action", action); + if (action == "dns") + continue; + body = signature_add_value(body, "rule." + name + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets")); + body = signature_add_value(body, "rule." + name + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets")); + body = signature_add_value(body, "rule." + name + ".ports", section_rule_ports_csv(section)); + body = signature_add_value(body, "rule." + name + ".fully_routed_ips", option(section, "fully_routed_ips", "")); + body = signature_add_value(body, "rule." + name + ".community_subnet_lists", rule_config.filter_community_subnet_lists_value(connections.community_lists_value(section))); + body = signature_add_value(body, "rule." + name + ".remote_subnet_lists", option(section, "remote_subnet_lists", "")); + body = signature_add_value(body, "rule." + name + ".rule_set_with_subnets", connections.rule_sets_with_subnets_value(section)); + body = signature_add_value(body, "rule." + name + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + } + + return body; +} + +function section_subscription_update_interval(section) { + let result = ""; + let result_seconds = 0; + + for (let entry in connections.subscription_urls(section)) { + if (!connections.subscription_update_enabled(section, entry)) + continue; + + let value = connections.subscription_update_interval(section, entry); + if (value == "") + value = "1h"; + let seconds = duration_to_seconds_value(value); + if (result == "" || (seconds != null && (result_seconds == 0 || seconds < result_seconds))) { + result = value; + result_seconds = seconds == null ? 0 : seconds; + } + } + + return result; +} + +function connection_urls_signature(section) { + return sprintf("%J", connections.connection_urls(section)); +} + +function subscription_urls_signature(section) { + let result = []; + for (let entry in connections.subscription_urls(section)) { + push(result, { + url: entry, + subscription_update_enabled: connections.subscription_update_enabled(section, entry) ? "1" : "0", + subscription_update_interval: connections.subscription_update_interval(section, entry), + download_via_proxy_section: connections.subscription_download_section(section, entry), + auto_user_agent: connections.subscription_auto_user_agent(section, entry) ? "1" : "0", + user_agent: connections.subscription_user_agent(section, entry), + auto_hwid: connections.subscription_auto_hwid(section, entry) ? "1" : "0", + hwid: connections.subscription_hwid(section, entry), + show_dashboard_metadata: connections.subscription_dashboard_metadata_enabled(section, entry) ? "1" : "0", + prefix_nodes: connections.subscription_prefix_nodes(section, entry) ? "1" : "0", + node_prefix: connections.subscription_node_prefix(section, entry), + include_urltest_groups: connections.subscription_include_urltest_groups(section, entry) ? "1" : "0", + hide_urltest_group_outbounds: connections.subscription_hide_urltest_group_outbounds(section, entry) ? "1" : "0", + hide_detour_outbounds: connections.subscription_hide_detour_outbounds(section, entry) ? "1" : "0" + }); + } + return sprintf("%J", result); +} + +function interfaces_signature(section) { + let result = []; + for (let entry in connections.interfaces(section)) { + push(result, { + name: entry, + domain_resolver_enabled: connections.interface_domain_resolver_enabled(section, entry) ? "1" : "0", + domain_resolver_dns_type: connections.interface_domain_resolver_dns_type(section, entry), + domain_resolver_dns_server: connections.interface_domain_resolver_dns_server(section, entry) + }); + } + return sprintf("%J", result); +} + +function dashboard_filter_signature(section) { + return sprintf("%J", { + filter_mode: connections.dashboard_filter_mode(section), + detect_server_country: connections.dashboard_detect_server_country(section), + include_countries: connections.dashboard_include_countries(section), + include_outbounds: connections.dashboard_include_outbounds(section), + include_regex: connections.dashboard_include_regex(section), + include_proxy_parameters: connections.dashboard_include_proxy_parameters(section) ? "1" : "0", + include_protocols: connections.dashboard_include_protocols(section), + include_transports: connections.dashboard_include_transports(section), + include_securities: connections.dashboard_include_securities(section), + include_groups: connections.dashboard_include_groups(section), + exclude_countries: connections.dashboard_exclude_countries(section), + exclude_outbounds: connections.dashboard_exclude_outbounds(section), + exclude_regex: connections.dashboard_exclude_regex(section), + exclude_proxy_parameters: connections.dashboard_exclude_proxy_parameters(section) ? "1" : "0", + exclude_protocols: connections.dashboard_exclude_protocols(section), + exclude_transports: connections.dashboard_exclude_transports(section), + exclude_securities: connections.dashboard_exclude_securities(section), + exclude_groups: connections.dashboard_exclude_groups(section) + }); +} + +function urltests_signature(section) { + let result = []; + for (let entry in connections.urltests(section)) { + push(result, { + id: entry, + display_name: connections.urltest_display_name(section, entry), + check_interval: connections.urltest_check_interval(section, entry), + tolerance: connections.urltest_tolerance(section, entry), + testing_url: connections.urltest_testing_url(section, entry), + idle_timeout: connections.urltest_idle_timeout(section, entry), + interrupt_exist_connections: connections.urltest_interrupt_exist_connections(section, entry) ? "1" : "0", + pin_dashboard: connections.urltest_pin_dashboard(section, entry) ? "1" : "0", + filter_mode: connections.urltest_filter_mode(section, entry), + detect_server_country: connections.urltest_detect_server_country(section, entry), + include_countries: connections.urltest_include_countries(section, entry), + include_outbounds: connections.urltest_include_outbounds(section, entry), + include_regex: connections.urltest_include_regex(section, entry), + exclude_countries: connections.urltest_exclude_countries(section, entry), + exclude_outbounds: connections.urltest_exclude_outbounds(section, entry), + exclude_regex: connections.urltest_exclude_regex(section, entry) + }); + } + return sprintf("%J", result); +} + +function priority_groups_signature(section) { + let result = []; + for (let group_id in connections.priority_groups(section)) { + let levels = []; + for (let level_id in connections.priority_levels(group_id)) { + push(levels, { + id: level_id, + display_name: connections.priority_level_display_name(group_id, level_id), + order: connections.priority_level_order(group_id, level_id), + direct: connections.priority_level_direct(group_id, level_id) ? "1" : "0", + filter_mode: connections.priority_level_filter_mode(group_id, level_id), + detect_server_country: connections.priority_level_detect_server_country(group_id, level_id), + include_countries: connections.priority_level_include_countries(group_id, level_id), + include_outbounds: connections.priority_level_include_outbounds(group_id, level_id), + include_regex: connections.priority_level_include_regex(group_id, level_id), + exclude_countries: connections.priority_level_exclude_countries(group_id, level_id), + exclude_outbounds: connections.priority_level_exclude_outbounds(group_id, level_id), + exclude_regex: connections.priority_level_exclude_regex(group_id, level_id) + }); + } + + push(result, { + id: group_id, + display_name: connections.priority_group_display_name(section, group_id), + health_url: connections.priority_group_health_url(section, group_id), + active_check_interval: connections.priority_group_active_check_interval(section, group_id), + check_timeout: connections.priority_group_check_timeout(section, group_id), + recovery_check_interval: connections.priority_group_recovery_check_interval(section, group_id), + pick_fastest: connections.priority_group_pick_fastest(section, group_id) ? "1" : "0", + switch_to_faster_same_priority: connections.priority_group_switch_to_faster_same_priority(section, group_id) ? "1" : "0", + fastest_check_interval: connections.priority_group_fastest_check_interval(section, group_id), + interrupt_exist_connections: connections.priority_group_interrupt_exist_connections(section, group_id) ? "1" : "0", + pin_dashboard: connections.priority_group_pin_dashboard(section, group_id) ? "1" : "0", + levels + }); + } + return sprintf("%J", result); +} + +function section_is_subscription_proxy(section) { + return bool_option(section, "enabled", true) && + connections.is_connections_action(option(section, "action", "")) && + length(connections.subscription_urls(section)) > 0; +} + +function section_urltest_check_interval(section) { + let urltests = connections.urltests(section); + if (length(urltests) == 0) + return ""; + + let result = ""; + let result_seconds = 0; + for (let urltest_id in urltests) { + let value = connections.urltest_check_interval(section, urltest_id); + if (value == "") + value = "3m"; + let seconds = duration_to_seconds_value(value); + if (result == "" || (seconds != null && (result_seconds == 0 || seconds < result_seconds))) { + result = value; + result_seconds = seconds == null ? 0 : seconds; + } + } + + return result; +} + +function append_list_update_signature_body(body, section) { + let name = section_name(section); + if (name == "" || !bool_option(section, "enabled", true)) + return body; + + let action = option(section, "action", ""); + body = signature_add_value(body, "lists." + name + ".action", action); + if (action == "dns") { + body = signature_add_value(body, "lists." + name + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + return body; + } + + body = signature_add_value(body, "lists." + name + ".ports", section_rule_ports_csv(section)); + body = signature_add_value(body, "lists." + name + ".community_subnet_lists", rule_config.filter_community_subnet_lists_value(connections.community_lists_value(section))); + body = signature_add_value(body, "lists." + name + ".remote_domain_lists", option(section, "remote_domain_lists", "")); + body = signature_add_value(body, "lists." + name + ".remote_subnet_lists", option(section, "remote_subnet_lists", "")); + body = signature_add_value(body, "lists." + name + ".rule_set_with_subnets", connections.rule_sets_with_subnets_value(section)); + body = signature_add_value(body, "lists." + name + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + + return body; +} + +function list_update_signature_body(sections) { + let body = ""; + + for (let section in sections) + body = append_list_update_signature_body(body, object_or_empty(section)); + + return body; +} + +function cron_signature_body(settings, sections) { + let body = signature_add_value("", "settings.update_interval", settings_update_interval(settings)); + body = signature_add_value(body, "settings.component_update_check_interval", settings_component_update_check_interval(settings)); + + for (let section in sections) + body = append_list_update_signature_body(body, object_or_empty(section)); + + for (let section in sections) { + section = object_or_empty(section); + if (!section_is_subscription_proxy(section)) + continue; + + let name = section_name(section); + body = signature_add_value(body, "subscription." + name + ".subscription_urls", subscription_urls_signature(section)); + body = signature_add_value(body, "subscription." + name + ".subscription_update_interval", section_subscription_update_interval(section)); + } + + return body; +} + +function urltest_enabled_sections_value(sections) { + let result = []; + + for (let section in sections) { + section = object_or_empty(section); + if (bool_option(section, "enabled", true) && + connections.is_connections_action(option(section, "action", "")) && + length(connections.urltests(section)) > 0) + push(result, section_name(section)); + } + + return join(" ", result); +} + +function print_urltest_enabled_sections(sections) { + print(urltest_enabled_sections_value(sections), "\n"); +} + +function sing_box_signature_has_remote_ruleset_sources(sections) { + for (let section in sections) { + section = object_or_empty(section); + if (!bool_option(section, "enabled", true)) + continue; + + if (length(connections.community_lists(section)) > 0 || + list_has_remote_references(connections.rule_sets_value(section)) || + list_has_remote_references(connections.rule_sets_with_subnets_value(section))) + return true; + } + + return false; +} + +function sing_box_signature_enabled_action_index(sections, target_section, action) { + let result = 0; + target_section = as_string(target_section); + action = as_string(action); + + for (let section in sections) { + section = object_or_empty(section); + if (section_name(section) == "" || + !bool_option(section, "enabled", true) || + option(section, "action", "") != action) + continue; + + result++; + if (section_name(section) == target_section) + return result; + } + + return 0; +} + +function signature_add_mixed_proxy_body(body, section, prefix) { + let mixed_proxy_enabled = bool_option_value(section, "mixed_proxy_enabled", false); + body = signature_add_value(body, prefix + ".mixed_proxy_enabled", mixed_proxy_enabled); + + if (mixed_proxy_enabled == "1") { + body = signature_add_value(body, prefix + ".mixed_proxy_port", option(section, "mixed_proxy_port", "")); + let auth_enabled = bool_option_value(section, "mixed_proxy_auth_enabled", false); + body = signature_add_value(body, prefix + ".mixed_proxy_auth_enabled", auth_enabled); + if (auth_enabled == "1") { + body = signature_add_value(body, prefix + ".mixed_proxy_username", option(section, "mixed_proxy_username", "")); + body = signature_add_value(body, prefix + ".mixed_proxy_password", option(section, "mixed_proxy_password", "")); + } + } + + return body; +} + +function signature_add_outbound_detour_body(body, section, prefix) { + let outbound_detour_enabled = bool_option_value(section, "outbound_detour_enabled", false); + body = signature_add_value(body, prefix + ".outbound_detour_enabled", outbound_detour_enabled); + + if (outbound_detour_enabled == "1") + body = signature_add_value(body, prefix + ".outbound_detour_section", option(section, "outbound_detour_section", "")); + + return body; +} + +function append_sing_box_rule_signature_body(body, section, sections) { + section = object_or_empty(section); + let name = section_name(section); + if (name == "" || !bool_option(section, "enabled", true)) + return body; + + let action = option(section, "action", ""); + if (action == "") + return body; + + let prefix = "rule." + name; + body = signature_add_value(body, prefix + ".action", action); + + if (connections.is_connections_action(action)) { + body = signature_add_value(body, prefix + ".connection_urls", connection_urls_signature(section)); + body = signature_add_value(body, prefix + ".subscription_urls", subscription_urls_signature(section)); + body = signature_add_value(body, prefix + ".interfaces", interfaces_signature(section)); + body = signature_add_value(body, prefix + ".outbound_jsons", option(section, "outbound_jsons", "")); + body = signature_add_value(body, prefix + ".legacy_interface", option(section, "interface", "")); + body = signature_add_value(body, prefix + ".legacy_outbound_json", option(section, "outbound_json", "")); + body = signature_add_value(body, prefix + ".urltests", urltests_signature(section)); + body = signature_add_value(body, prefix + ".priority_groups", priority_groups_signature(section)); + body = signature_add_value(body, prefix + ".dashboard_filter", dashboard_filter_signature(section)); + body = signature_add_value(body, prefix + ".urltest_enabled", bool_option_value(section, "urltest_enabled", false)); + body = signature_add_value(body, prefix + ".detect_server_country", normalize_detect_server_country_method(option(section, "detect_server_country", "flag_emoji"))); + body = signature_add_value(body, prefix + ".urltest_check_interval", section_urltest_check_interval(section)); + body = signature_add_value(body, prefix + ".urltest_tolerance", option(section, "urltest_tolerance", "50")); + body = signature_add_value(body, prefix + ".urltest_testing_url", option(section, "urltest_testing_url", "https://www.gstatic.com/generate_204")); + body = signature_add_value(body, prefix + ".urltest_filter_mode", option(section, "urltest_filter_mode", "disabled")); + body = signature_add_value(body, prefix + ".urltest_exclude_countries", option(section, "urltest_exclude_countries", "")); + body = signature_add_value(body, prefix + ".urltest_include_countries", option(section, "urltest_include_countries", "")); + body = signature_add_value(body, prefix + ".urltest_exclude_outbounds", option(section, "urltest_exclude_outbounds", "")); + body = signature_add_value(body, prefix + ".urltest_exclude_regex", option(section, "urltest_exclude_regex", "")); + body = signature_add_value(body, prefix + ".urltest_include_outbounds", option(section, "urltest_include_outbounds", "")); + body = signature_add_value(body, prefix + ".urltest_include_regex", option(section, "urltest_include_regex", "")); + body = signature_add_value(body, prefix + ".subscription_update_interval", section_subscription_update_interval(section)); + body = signature_add_outbound_detour_body(body, section, prefix); + body = signature_add_mixed_proxy_body(body, section, prefix); + body = signature_add_value(body, prefix + ".resolve_real_ip_for_routing", bool_option_value(section, "resolve_real_ip_for_routing", false)); + } + else if (action == "byedpi") { + body = signature_add_value(body, prefix + ".byedpi_index", sing_box_signature_enabled_action_index(sections, name, "byedpi")); + body = signature_add_mixed_proxy_body(body, section, prefix); + body = signature_add_value(body, prefix + ".resolve_real_ip_for_routing", "1"); + } + else if (action == "zapret" || action == "zapret2") { + body = signature_add_mixed_proxy_body(body, section, prefix); + } + else if (action == "dns") { + body = signature_add_value(body, prefix + ".dns_type", option(section, "dns_type", "udp")); + body = signature_add_value(body, prefix + ".dns_server", option(section, "dns_server", "")); + body = signature_add_value(body, prefix + ".dns_detour_enabled", bool_option_value(section, "dns_detour_enabled", false)); + if (bool_option(section, "dns_detour_enabled", false)) + body = signature_add_value(body, prefix + ".dns_detour_section", option(section, "dns_detour_section", "")); + } + + body = signature_add_value(body, prefix + ".domain", section_rule_condition_csv(section, "domain", "domains")); + body = signature_add_value(body, prefix + ".domain_suffix", section_rule_condition_csv(section, "domain_suffix", "domains")); + body = signature_add_value(body, prefix + ".domain_keyword", section_rule_condition_csv(section, "domain_keyword", "generic")); + body = signature_add_value(body, prefix + ".domain_regex", section_rule_condition_csv(section, "domain_regex", "generic")); + if (action != "dns") { + body = signature_add_value(body, prefix + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets")); + body = signature_add_value(body, prefix + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets")); + body = signature_add_value(body, prefix + ".ports", section_rule_ports_csv(section)); + body = signature_add_value(body, prefix + ".fully_routed_ips", option(section, "fully_routed_ips", "")); + } + body = signature_add_value(body, prefix + ".community_lists", connections.community_lists_value(section)); + body = signature_add_value(body, prefix + ".rule_set", connections.rule_sets_value(section)); + if (action != "dns") + body = signature_add_value(body, prefix + ".rule_set_with_subnets", connections.rule_sets_with_subnets_value(section)); + body = signature_add_value(body, prefix + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + + return body; +} + +function append_sing_box_server_signature_body(body, server) { + server = object_or_empty(server); + let name = section_name(server); + if (name == "") + return body; + + let prefix = "server." + name; + let enabled = bool_option_value(server, "enabled", false); + body = signature_add_value(body, prefix + ".enabled", enabled); + if (enabled != "1") + return body; + + if (option(server, "protocol", "vless") == "socks") + body = signature_add_value(body, prefix + ".socks_auth_enabled", bool_option_value(server, "socks_auth_enabled", true)); + + let fields = [ + [ "label", name ], + [ "protocol", "vless" ], + [ "listen", "0.0.0.0" ], + [ "listen_port", "" ], + [ "public_host", "" ], + [ "inbound_json", "" ], + [ "routing_mode", "rules" ], + [ "routing_section", "" ], + [ "security", "reality" ], + [ "server_users", "" ], + [ "tls_server_name", "" ], + [ "tls_alpn", "" ], + [ "tls_certificate_path", "" ], + [ "tls_key_path", "" ], + [ "reality_handshake_server", "" ], + [ "reality_handshake_server_port", "" ], + [ "reality_private_key", "" ], + [ "reality_public_key", "" ], + [ "reality_short_id", "" ], + [ "reality_max_time_difference", "" ], + [ "transport", "tcp" ], + [ "transport_path", "" ], + [ "transport_host", "" ], + [ "transport_service_name", "" ], + [ "transport_hosts", "" ], + [ "transport_xhttp_mode", "" ], + [ "client_fingerprint", "" ], + [ "server_uuid", "" ], + [ "server_username", "" ], + [ "server_password", "" ], + [ "vless_flow", "" ], + [ "vmess_alter_id", "" ], + [ "shadowsocks_method", "" ], + [ "hysteria2_up_mbps", "" ], + [ "hysteria2_down_mbps", "" ], + [ "hysteria2_obfs_type", "" ], + [ "hysteria2_obfs_password", "" ], + [ "mtproto_secret", "" ], + [ "mtproto_faketls", "" ], + [ "mtproto_padding", "" ], + [ "mtproto_concurrency", "" ], + [ "mtproto_domain_fronting_port", "" ], + [ "mtproto_domain_fronting_ip", "" ], + [ "mtproto_domain_fronting_proxy_protocol", "" ], + [ "mtproto_prefer_ip", "" ], + [ "mtproto_auto_update", "" ], + [ "mtproto_allow_fallback_on_unknown_dc", "" ], + [ "mtproto_tolerate_time_skewness", "" ], + [ "mtproto_idle_timeout", "" ], + [ "mtproto_handshake_timeout", "" ], + [ "tailscale_auth_key", "" ], + [ "tailscale_control_url", "" ], + [ "tailscale_hostname", "" ], + [ "tailscale_accept_routes", "" ], + [ "tailscale_advertise_routes", "" ], + [ "tailscale_advertise_exit_node", "" ] + ]; + + for (let field in fields) + body = signature_add_value(body, prefix + "." + field[0], option(server, field[0], field[1])); + + return body; +} + +function sing_box_signature_body(settings, sections, servers, mwan3_active) { + settings = object_or_empty(settings); + let body = ""; + + body = signature_add_value(body, "settings.dns_type", option(settings, "dns_type", "doh")); + body = signature_add_value(body, "settings.dns_strategy", option(settings, "dns_strategy", "prefer_ipv4")); + for (let value in list_option(settings, "dns_server", "77.88.8.8")) + body = signature_add_value(body, "settings.dns_server", value); + for (let value in list_option(settings, "bootstrap_dns_server", "77.88.8.8")) + body = signature_add_value(body, "settings.bootstrap_dns_server", value); + body = signature_add_value(body, "settings.dns_check_interval", option(settings, "dns_check_interval", "10s")); + body = signature_add_value(body, "settings.dns_recovery_check_interval", option(settings, "dns_recovery_check_interval", "60s")); + body = signature_add_value(body, "settings.dns_check_timeout", option(settings, "dns_check_timeout", "2s")); + let dns_detour_enabled = bool_option_value(settings, "dns_detour_enabled", false); + body = signature_add_value(body, "settings.dns_detour_enabled", dns_detour_enabled); + if (dns_detour_enabled == "1") + body = signature_add_value(body, "settings.dns_detour_section", option(settings, "dns_detour_section", "")); + body = signature_add_value(body, "settings.dns_rewrite_ttl", option(settings, "dns_rewrite_ttl", "60")); + body = signature_add_value(body, "settings.output_network_interface", option(settings, "output_network_interface", "")); + body = signature_add_value(body, "settings.disable_quic", bool_option_value(settings, "disable_quic", false)); + if (sing_box_signature_has_remote_ruleset_sources(sections)) + body = signature_add_value(body, "settings.update_interval", settings_update_interval(settings)); + body = signature_add_value(body, "settings.cache_path", option(settings, "cache_path", "/tmp/sing-box/cache.db")); + body = signature_add_value(body, "settings.config_path", option(settings, "config_path", "")); + body = signature_add_value(body, "settings.log_level", option(settings, "log_level", "warn")); + body = signature_add_value(body, "settings.service_listen_address", option(settings, "service_listen_address", "")); + body = signature_add_value(body, "runtime.mwan3_active", bool_value(mwan3_active)); + + let enable_yacd = bool_option_value(settings, "enable_yacd", false); + body = signature_add_value(body, "settings.enable_yacd", enable_yacd); + if (enable_yacd == "1") { + body = signature_add_value(body, "settings.enable_yacd_wan_access", bool_option_value(settings, "enable_yacd_wan_access", false)); + body = signature_add_value(body, "settings.yacd_secret_key", option(settings, "yacd_secret_key", "")); + } + + body = signature_add_value(body, "settings.download_lists_via_proxy", bool_option_value(settings, "download_lists_via_proxy", false)); + body = signature_add_value(body, "settings.download_components_via_proxy", bool_option_value(settings, "download_components_via_proxy", false)); + if (download_via_proxy_enabled(settings, "lists")) + body = signature_add_value(body, "settings.download_lists_via_proxy_section", option(settings, "download_lists_via_proxy_section", "")); + if (download_via_proxy_enabled(settings, "components")) + body = signature_add_value(body, "settings.download_components_via_proxy_section", option(settings, "download_components_via_proxy_section", "")); + + for (let section in sections) + body = append_sing_box_rule_signature_body(body, object_or_empty(section), sections); + + for (let server in servers) + body = append_sing_box_server_signature_body(body, object_or_empty(server)); + + return body; +} + +function section_action_is_enabled(section, action) { + return section_name(section) != "" && + bool_option(section, "enabled", true) && + option(section, "action", "") == as_string(action); +} + +function action_queue_signature_body(sections, action, signature_key) { + let body = ""; + + for (let section in sections) { + section = object_or_empty(section); + if (section_action_is_enabled(section, action)) + body = signature_add_value(body, signature_key, section_name(section)); + } + + return body; +} + +function section_user_domains_for_list_type(section) { + let list_type = option(section, "user_domain_list_type", "disabled"); + + if (list_type == "dynamic") + return option(section, "user_domains", ""); + if (list_type == "text") + return option(section, "user_domains_text", ""); + return ""; +} + +function append_zapret_runtime_signature_body(body, section, signature_prefix, opt_key, normalized_opt) { + let name = section_name(section); + let user_domain_list_type = option(section, "user_domain_list_type", "disabled"); + + body = signature_add_value(body, signature_prefix + "." + name + "." + opt_key, normalized_opt); + body = signature_add_value(body, signature_prefix + "." + name + ".domain", section_rule_condition_csv(section, "domain", "domains")); + body = signature_add_value(body, signature_prefix + "." + name + ".domain_suffix", section_rule_condition_csv(section, "domain_suffix", "domains")); + body = signature_add_value(body, signature_prefix + "." + name + ".community_lists", connections.community_lists_value(section)); + body = signature_add_value(body, signature_prefix + "." + name + ".rule_set", connections.rule_sets_value(section)); + body = signature_add_value(body, signature_prefix + "." + name + ".rule_set_with_subnets", connections.rule_sets_with_subnets_value(section)); + body = signature_add_value(body, signature_prefix + "." + name + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + body = signature_add_value(body, signature_prefix + "." + name + ".user_domain_list_type", user_domain_list_type); + body = signature_add_value(body, signature_prefix + "." + name + ".local_domain_lists", option(section, "local_domain_lists", "")); + body = signature_add_value(body, signature_prefix + "." + name + ".remote_domain_lists", option(section, "remote_domain_lists", "")); + body = signature_add_value(body, signature_prefix + "." + name + ".user_domains", section_user_domains_for_list_type(section)); + + return body; +} + +function zapret_runtime_signature_body(sections) { + let body = ""; + + for (let section in sections) { + section = object_or_empty(section); + if (!section_action_is_enabled(section, "zapret")) + continue; + + body = append_zapret_runtime_signature_body( + body, + section, + "zapret", + "nfqws_opt", + zapret_validator.strategy_or_default(option(section, "nfqws_opt", ""), ZAPRET_DEFAULT_NFQWS_OPT) + ); + } + + return body; +} + +function zapret2_runtime_signature_body(sections) { + let body = ""; + + for (let section in sections) { + section = object_or_empty(section); + if (!section_action_is_enabled(section, "zapret2")) + continue; + + body = append_zapret_runtime_signature_body( + body, + section, + "zapret2", + "nfqws2_opt", + zapret2_validator.strategy_or_default(option(section, "nfqws2_opt", ""), ZAPRET2_DEFAULT_NFQWS2_OPT) + ); + } + + return body; +} + +function enabled_action_index(sections, target_section, action) { + let result = 0; + target_section = as_string(target_section); + action = as_string(action); + + for (let section in sections) { + section = object_or_empty(section); + if (!section_action_is_enabled(section, action)) + continue; + + result++; + if (section_name(section) == target_section) + return result; + } + + return 0; +} + +function byedpi_runtime_signature_body(sections) { + let body = ""; + + for (let section in sections) { + section = object_or_empty(section); + if (!section_action_is_enabled(section, "byedpi")) + continue; + + let name = section_name(section); + body = signature_add_value(body, "byedpi." + name + ".index", enabled_action_index(sections, name, "byedpi")); + body = signature_add_value(body, "byedpi." + name + ".byedpi_cmd_opts", byedpi_validator.strategy_or_default(option(section, "byedpi_cmd_opts", ""), BYEDPI_DEFAULT_CMD_OPTS)); + } + + return body; +} + +function reload_state_values_from_sources(format, settings, sections, servers, dnsmasq, legacy_dnsmasq_present, mwan3_active_value) { + return { + format: as_string(format), + service_trigger_signature: signature_hash(service_trigger_signature_body(settings)), + dnsmasq_signature: signature_hash(dnsmasq_signature_body(settings, dnsmasq, legacy_dnsmasq_present)), + sing_box_signature: signature_hash(sing_box_signature_body(settings, sections, servers, mwan3_active_value)), + nft_signature: signature_hash(nft_runtime_signature_body(settings, sections)), + zapret_queue_signature: signature_hash(action_queue_signature_body(sections, "zapret", "zapret_queue.section")), + zapret_runtime_signature: signature_hash(zapret_runtime_signature_body(sections)), + zapret2_queue_signature: signature_hash(action_queue_signature_body(sections, "zapret2", "zapret2_queue.section")), + zapret2_runtime_signature: signature_hash(zapret2_runtime_signature_body(sections)), + byedpi_runtime_signature: signature_hash(byedpi_runtime_signature_body(sections)), + list_signature: signature_hash(list_update_signature_body(sections)), + cron_signature: signature_hash(cron_signature_body(settings, sections)), + urltest_enabled_sections: urltest_enabled_sections_value(sections), + dont_touch_dhcp: dont_touch_dhcp_value(settings) + }; +} + +function fixture_section_list(data, type_name) { + type_name = as_string(type_name || "section"); + let value = object_or_empty(data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, type_name); +} + +function uci_servers() { + return uci_sections("server"); +} + +function mwan3_has_enabled_interface() { + for (let section in uci_core.section_objects("mwan3", "interface")) + if (option(section, "enabled", "0") == "1") + return true; + + return false; +} + +function mwan3_active() { + if (!command_success_from_args([ "test", "-x", "/etc/init.d/mwan3" ])) + return false; + if (!command_success_from_args([ "test", "-s", "/etc/config/mwan3" ])) + return false; + if (!mwan3_has_enabled_interface()) + return false; + + return command_success_from_args([ "/etc/init.d/mwan3", "status" ]) || + command_success_from_args([ "/etc/init.d/mwan3", "enabled" ]); +} + +function has_remote_sing_box_ruleset_sources_from_sections(sections) { + for (let section in sections) { + if (!bool_option(section, "enabled", true)) + continue; + + if (length(connections.community_lists(section)) > 0 || + list_has_remote_references(connections.rule_sets_value(section)) || + list_has_remote_references(connections.rule_sets_with_subnets_value(section))) + return true; + } + + return false; +} + +function has_list_update_sources_from_sections(sections) { + for (let section in sections) + if (rule_has_list_update_source( + bool_option(section, "enabled", true), + option(section, "action", ""), + connections.community_lists_value(section), + option(section, "remote_domain_lists", ""), + option(section, "remote_subnet_lists", ""), + connections.rule_sets_with_subnets_value(section), + option(section, "domain_ip_lists", "") + )) + return true; + + return false; +} + +function has_nft_list_update_sources_from_sections(sections) { + for (let section in sections) + if (rule_has_nft_list_update_source( + bool_option(section, "enabled", true), + option(section, "action", ""), + connections.community_lists_value(section), + option(section, "remote_subnet_lists", ""), + connections.rule_sets_with_subnets_value(section), + option(section, "domain_ip_lists", "") + )) + return true; + + return false; +} + +function has_subscription_update_sources_from_sections(sections) { + for (let section in sections) { + section = object_or_empty(section); + if (section_is_subscription_proxy(section) && section_subscription_update_interval(section) != "") + return true; + } + + return false; +} + +function fixture_sections(path) { + let data = object_or_empty(read_json_file(path)); + connections.set_item_sections_from_data(data); + return fixture_section_list(data); +} + +function fixture_servers(data) { + return fixture_section_list(data, "server"); +} + +function fixture_data(path) { + let data = object_or_empty(read_json_file(path)); + connections.set_item_sections_from_data(data); + return data; +} + +function fixture_settings(data) { + return object_or_empty(object_or_empty(data).settings); +} + +function fixture_dnsmasq(data) { + return object_or_empty(object_or_empty(data).dhcp_dnsmasq); +} + +function fixture_legacy_dnsmasq_present(data) { + return object_or_empty(data).legacy_dnsmasq_present; +} + +function fixture_mwan3_active(data) { + return arg_bool(object_or_empty(object_or_empty(data).runtime).mwan3_active); +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function uci_section(section_name) { + return object_or_empty(uci_core.get_all(CONFIG_NAME, section_name)); +} + +function uci_dnsmasq() { + return { + server: uci_get("dhcp.@dnsmasq[0].server"), + noresolv: uci_get("dhcp.@dnsmasq[0].noresolv"), + cachesize: uci_get("dhcp.@dnsmasq[0].cachesize"), + tachyon_server: uci_get("dhcp.@dnsmasq[0].tachyon_server"), + tachyon_noresolv: uci_get("dhcp.@dnsmasq[0].tachyon_noresolv"), + tachyon_cachesize: uci_get("dhcp.@dnsmasq[0].tachyon_cachesize") + }; +} + +function current_reload_state_values(format) { + let settings = uci_settings(); + let sections = uci_sections("section"); + + return reload_state_values_from_sources( + format, + settings, + sections, + uci_servers(), + uci_dnsmasq(), + uci_exists("dhcp.tachyon"), + mwan3_active() + ); +} + +function capture_reload_state(path, format) { + write_reload_state(path, current_reload_state_values(format || "1")); +} + +function write_current_reload_state_clean(path, format, cache_dir) { + write_reload_state(path, current_reload_state_values(format || "1")); + cleanup_rule_condition_cache(cache_dir); + cleanup_reload_state_snapshots(path); +} + +function write_captured_reload_state(path, snapshot_path, format, cache_dir, cleanup_cache, clear_snapshot) { + snapshot_path = as_string(snapshot_path); + if (snapshot_path == "") { + if (as_string(cleanup_cache) == "1") + write_current_reload_state_clean(path, format, cache_dir); + else + write_reload_state(path, current_reload_state_values(format || "1")); + return; + } + + if (fs.stat(snapshot_path) == null) + capture_reload_state(snapshot_path, format || "1"); + + if (!copy_file(snapshot_path, path)) + exit(1); + + if (as_string(cleanup_cache) == "1") + cleanup_rule_condition_cache(cache_dir); + if (as_string(clear_snapshot) == "1") + unlink_file(snapshot_path); +} + +function fixture_reload_state_values(data, format) { + data = object_or_empty(data); + let settings = fixture_settings(data); + let sections = fixture_section_list(data); + + return reload_state_values_from_sources( + format, + settings, + sections, + fixture_servers(data), + fixture_dnsmasq(data), + fixture_legacy_dnsmasq_present(data), + fixture_mwan3_active(data) + ); +} + +let mode = ARGV[0]; + +if (mode == "file-first-line") + file_first_line(ARGV[1]); +else if (mode == "get") + read_state_value(ARGV[1], ARGV[2]); +else if (mode == "has-key") + exit(state_has_key(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "time-sync-needed") + exit(time_sync_needed(ARGV[1]) ? 0 : 1); +else if (mode == "sync-time-if-needed") + sync_time_if_needed(); +else if (mode == "mark-pending-reload") + mark_pending_reload(ARGV[1], ARGV[2]); +else if (mode == "consume-pending-reload") + exit(consume_pending_reload(ARGV[1]) ? 0 : 1); +else if (mode == "run-pending-reload-if-requested") + run_pending_reload_if_requested(ARGV[1], ARGV[2]); +else if (mode == "acquire-runtime-dir-lock") + exit(acquire_runtime_dir_lock(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "acquire-runtime-dir-lock-wait") + exit(acquire_runtime_dir_lock_wait(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "release-runtime-dir-lock") + release_runtime_dir_lock(ARGV[1]); +else if (mode == "reload-sing-box-runtime") + reload_sing_box_runtime(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "hup-sing-box-runtime") + hup_sing_box_runtime(); +else if (mode == "clear-reload-state") + clear_reload_state(ARGV[1], ARGV[2]); +else if (mode == "remove-file") + remove_file(ARGV[1]); +else if (mode == "capture-reload-state") + capture_reload_state(ARGV[1], ARGV[2] || "1"); +else if (mode == "write-current-reload-state-clean") + write_current_reload_state_clean(ARGV[1], ARGV[2] || "1", ARGV[3]); +else if (mode == "write-captured-reload-state") + write_captured_reload_state(ARGV[1], ARGV[2], ARGV[3] || "1", ARGV[4], ARGV[5], ARGV[6]); +else if (mode == "write-reload-state") + write_reload_state(ARGV[1], reload_state_values_from_args(2)); +else if (mode == "write-current-reload-state") + write_reload_state(ARGV[1], current_reload_state_values(ARGV[2] || "1")); +else if (mode == "reload-state-text-fixture") { + let data = fixture_data(ARGV[1]); + print(reload_state_text(fixture_reload_state_values(data, ARGV[2] || "1"))); +} +else if (mode == "response-success") + exit(response_success() ? 0 : 1); +else if (mode == "stdin-first-field") + stdin_first_field(); +else if (mode == "sing-box-service-pid") + sing_box_service_pid(); +else if (mode == "sing-box-service-runtime-pid") { + let pid = sing_box_service_pid_runtime(); + if (pid <= 0) + exit(1); + print(pid, "\n"); +} +else if (mode == "sing-box-service-running") + exit(sing_box_service_running() ? 0 : 1); +else if (mode == "sing-box-service-stable") + exit(sing_box_service_stable(ARGV[1]) ? 0 : 1); +else if (mode == "process-age-seconds-fixture") { + let age = process_age_seconds_from_ticks(ARGV[1], ARGV[2]); + if (age == null) + exit(1); + print(age, "\n"); +} +else if (mode == "sing-box-pid-replaced-fixture") + exit(sing_box_pid_replaced(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "sing-box-reload-previous-pid-fixture") + print(sing_box_reload_previous_pid(ARGV[1], ARGV[2], ARGV[3]), "\n"); +else if (mode == "sing-box-runtime-reload-needed-fixture") + exit(sing_box_runtime_reload_needed(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "tachyon-running") + exit(tachyon_running(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "tachyon-stably-running") + exit(tachyon_stably_running(ARGV[1], ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +else if (mode == "wait-tachyon-stable-start") + exit(wait_tachyon_stable_start(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]) ? 0 : 1); +else if (mode == "list-has-remote-references" || mode == "list-has-remote-sing-box-rulesets") + exit(list_has_remote_references(ARGV[1]) ? 0 : 1); +else if (mode == "community-service-has-subnet-list") + exit(community_service_has_subnet_list(ARGV[1]) ? 0 : 1); +else if (mode == "filter-community-subnet-lists") + filter_community_subnet_lists(ARGV[1]); +else if (mode == "rule-has-list-update-source") + exit(rule_has_list_update_source(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]) ? 0 : 1); +else if (mode == "rule-has-nft-list-update-source") + exit(rule_has_nft_list_update_source(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]) ? 0 : 1); +else if (mode == "rule-has-subscription-update-source") + exit(rule_has_subscription_update_source(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "service-trigger-signature") + exit(print_signature_hash(service_trigger_signature_body(uci_settings())) ? 0 : 1); +else if (mode == "service-trigger-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(service_trigger_signature_body(fixture_settings(data))) ? 0 : 1); +} +else if (mode == "dnsmasq-signature") + exit(print_signature_hash(dnsmasq_signature_body(uci_settings(), uci_dnsmasq(), uci_exists("dhcp.tachyon"))) ? 0 : 1); +else if (mode == "dnsmasq-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(dnsmasq_signature_body(fixture_settings(data), fixture_dnsmasq(data), fixture_legacy_dnsmasq_present(data))) ? 0 : 1); +} +else if (mode == "sing-box-signature") + exit(print_signature_hash(sing_box_signature_body(uci_settings(), uci_sections("section"), uci_servers(), mwan3_active())) ? 0 : 1); +else if (mode == "sing-box-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(sing_box_signature_body(fixture_settings(data), fixture_section_list(data), fixture_servers(data), fixture_mwan3_active(data))) ? 0 : 1); +} +else if (mode == "sing-box-signature-body-fixture") { + let data = fixture_data(ARGV[1]); + print(sing_box_signature_body(fixture_settings(data), fixture_section_list(data), fixture_servers(data), fixture_mwan3_active(data))); +} +else if (mode == "nft-signature") + exit(print_signature_hash(nft_runtime_signature_body(uci_settings(), uci_sections("section"))) ? 0 : 1); +else if (mode == "nft-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(nft_runtime_signature_body(fixture_settings(data), fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "zapret-queue-signature") + exit(print_signature_hash(action_queue_signature_body(uci_sections("section"), "zapret", "zapret_queue.section")) ? 0 : 1); +else if (mode == "zapret-queue-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(action_queue_signature_body(fixture_section_list(data), "zapret", "zapret_queue.section")) ? 0 : 1); +} +else if (mode == "zapret-runtime-signature") + exit(print_signature_hash(zapret_runtime_signature_body(uci_sections("section"))) ? 0 : 1); +else if (mode == "zapret-runtime-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(zapret_runtime_signature_body(fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "zapret2-queue-signature") + exit(print_signature_hash(action_queue_signature_body(uci_sections("section"), "zapret2", "zapret2_queue.section")) ? 0 : 1); +else if (mode == "zapret2-queue-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(action_queue_signature_body(fixture_section_list(data), "zapret2", "zapret2_queue.section")) ? 0 : 1); +} +else if (mode == "zapret2-runtime-signature") + exit(print_signature_hash(zapret2_runtime_signature_body(uci_sections("section"))) ? 0 : 1); +else if (mode == "zapret2-runtime-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(zapret2_runtime_signature_body(fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "byedpi-runtime-signature") + exit(print_signature_hash(byedpi_runtime_signature_body(uci_sections("section"))) ? 0 : 1); +else if (mode == "byedpi-runtime-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(byedpi_runtime_signature_body(fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "dont-touch-dhcp") + print(dont_touch_dhcp_value(uci_settings()), "\n"); +else if (mode == "dont-touch-dhcp-fixture") { + let data = fixture_data(ARGV[1]); + print(dont_touch_dhcp_value(fixture_settings(data)), "\n"); +} +else if (mode == "settings-update-interval") + print(settings_update_interval(uci_settings()), "\n"); +else if (mode == "settings-update-interval-fixture") { + let data = fixture_data(ARGV[1]); + print(settings_update_interval(fixture_settings(data)), "\n"); +} +else if (mode == "remote-ruleset-update-interval") { + let update_interval = settings_update_interval(uci_settings()); + print(update_interval != "" ? update_interval : as_string(ARGV[1]), "\n"); +} +else if (mode == "subscription-update-interval") + print(section_subscription_update_interval(uci_section(ARGV[1])), "\n"); +else if (mode == "subscription-update-interval-fixture") { + let data = fixture_data(ARGV[1]); + let sections = fixture_section_list(data); + let name = as_string(ARGV[2]); + for (let section in sections) { + section = object_or_empty(section); + if (section_name(section) == name) { + print(section_subscription_update_interval(section), "\n"); + exit(0); + } + } + print("\n"); +} +else if (mode == "urltest-check-interval") + print(section_urltest_check_interval(uci_section(ARGV[1])), "\n"); +else if (mode == "urltest-check-interval-fixture") { + let data = fixture_data(ARGV[1]); + let sections = fixture_section_list(data); + let name = as_string(ARGV[2]); + for (let section in sections) { + section = object_or_empty(section); + if (section_name(section) == name) { + print(section_urltest_check_interval(section), "\n"); + exit(0); + } + } + print("\n"); +} +else if (mode == "list-update-signature") + exit(print_signature_hash(list_update_signature_body(uci_sections("section"))) ? 0 : 1); +else if (mode == "list-update-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(list_update_signature_body(fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "cron-signature") + exit(print_signature_hash(cron_signature_body(uci_settings(), uci_sections("section"))) ? 0 : 1); +else if (mode == "cron-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(cron_signature_body(fixture_settings(data), fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "cron-signature-body-fixture") { + let data = fixture_data(ARGV[1]); + print(cron_signature_body(fixture_settings(data), fixture_section_list(data))); +} +else if (mode == "urltest-enabled-sections") + print_urltest_enabled_sections(uci_sections("section")); +else if (mode == "urltest-enabled-sections-fixture") { + let data = fixture_data(ARGV[1]); + print_urltest_enabled_sections(fixture_section_list(data)); +} +else if (mode == "has-remote-sing-box-ruleset-sources") + exit(has_remote_sing_box_ruleset_sources_from_sections(uci_sections("section")) ? 0 : 1); +else if (mode == "has-remote-sing-box-ruleset-sources-fixture") + exit(has_remote_sing_box_ruleset_sources_from_sections(fixture_sections(ARGV[1])) ? 0 : 1); +else if (mode == "has-list-update-sources") + exit(has_list_update_sources_from_sections(uci_sections("section")) ? 0 : 1); +else if (mode == "has-list-update-sources-fixture") + exit(has_list_update_sources_from_sections(fixture_sections(ARGV[1])) ? 0 : 1); +else if (mode == "has-subscription-update-sources") + exit(has_subscription_update_sources_from_sections(uci_sections("section")) ? 0 : 1); +else if (mode == "has-subscription-update-sources-fixture") + exit(has_subscription_update_sources_from_sections(fixture_sections(ARGV[1])) ? 0 : 1); +else if (mode == "has-nft-list-update-sources") + exit(has_nft_list_update_sources_from_sections(uci_sections("section")) ? 0 : 1); +else if (mode == "has-nft-list-update-sources-fixture") + exit(has_nft_list_update_sources_from_sections(fixture_sections(ARGV[1])) ? 0 : 1); +else { + warn("Usage: service/state.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/service/ui.uc b/tachyon/files/usr/lib/service/ui.uc new file mode 100644 index 00000000..fd4976bd --- /dev/null +++ b/tachyon/files/usr/lib/service/ui.uc @@ -0,0 +1,1585 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const BIN_PATH = getenv("TACHYON_BIN") || "/usr/bin/tachyon"; +const SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || "/etc/init.d/tachyon"; +const SERVICE_NAME = getenv("TACHYON_SERVICE_NAME") || "tachyon"; +const STATE_UC = LIB_DIR + "/service/state.uc"; +const UI_UC = LIB_DIR + "/service/ui.uc"; +const STATE_DIR = getenv("TACHYON_UI_STATE_DIR") || "/var/run/tachyon/ui-state"; +const PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || "/var/run/tachyon/reload.pending"; +const SERVICE_ACTION_DIR = getenv("TACHYON_UI_SERVICE_ACTION_DIR") || STATE_DIR + "/service-actions"; +const SERVICE_ACTION_LOCK_DIR = getenv("TACHYON_UI_SERVICE_ACTION_LOCK_DIR") || STATE_DIR + "/service-actions.lock"; +const LATENCY_ACTION_DIR = getenv("TACHYON_UI_LATENCY_ACTION_DIR") || STATE_DIR + "/latency-actions"; +const COMPONENT_ACTION_DIR = getenv("TACHYON_UI_COMPONENT_ACTION_DIR") || getenv("UPDATES_JOB_DIR") || "/var/run/tachyon/component-actions"; +const SUBSCRIPTION_ACTION_DIR = getenv("TACHYON_UI_SUBSCRIPTION_ACTION_DIR") || getenv("TACHYON_SUBSCRIPTION_UPDATE_JOB_DIR") || "/var/run/tachyon/subscription-update-jobs"; +const SING_BOX_VERSION_CACHE_FILE = getenv("TACHYON_UI_SING_BOX_VERSION_CACHE_FILE") || STATE_DIR + "/sing-box-version"; +const SING_BOX_VERSION_CACHE_LOCK_DIR = getenv("TACHYON_UI_SING_BOX_VERSION_CACHE_LOCK_DIR") || SING_BOX_VERSION_CACHE_FILE + ".lock"; +const SING_BOX_VARIANT_STATE_FILE = getenv("TACHYON_UI_SING_BOX_VARIANT_STATE_FILE") || "/etc/tachyon/sing-box-variant"; +const SING_BOX_BIN_PATH = getenv("TACHYON_UI_SING_BOX_BIN_PATH") || "/usr/bin/sing-box"; +const SING_BOX_VERSION_PROBE_TIMEOUT_SECONDS = getenv("TACHYON_UI_SING_BOX_VERSION_PROBE_TIMEOUT_SECONDS") || "1"; +const SING_BOX_VERSION_PROBE_FAILURE_TTL_SECONDS = getenv("TACHYON_UI_SING_BOX_VERSION_PROBE_FAILURE_TTL_SECONDS") || "30"; +const ACTION_FINISHED_TTL_MINUTES = getenv("TACHYON_UI_ACTION_FINISHED_TTL_MINUTES") || "60"; +const ACTION_ACKED_TTL_SECONDS = getenv("TACHYON_UI_ACTION_ACKED_TTL_SECONDS") || "15"; +const ACTION_STALE_GRACE_SECONDS = getenv("TACHYON_UI_ACTION_STALE_GRACE_SECONDS") || "15"; +const SERVICE_ACTION_TIMEOUT_SECONDS = getenv("TACHYON_UI_SERVICE_ACTION_TIMEOUT_SECONDS") || "120"; +const SERVICE_ACTION_SETTLE_SECONDS = getenv("TACHYON_UI_SERVICE_ACTION_SETTLE_SECONDS") || "2"; +const RUNTIME_STABLE_MIN_AGE = getenv("TACHYON_RUNTIME_STABLE_MIN_AGE") || "2"; +const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || "TachyonTable"; +const RT_TABLE_NAME = getenv("RT_TABLE_NAME") || "tachyon"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x00100000"; +const SB_DNS_INBOUND_ADDRESS = getenv("SB_DNS_INBOUND_ADDRESS") || "127.0.0.42"; +const ZAPRET_PROVIDER_NFQWS_BIN = getenv("ZAPRET_PROVIDER_NFQWS_BIN") || "/opt/zapret/nfq/nfqws"; +const ZAPRET2_PROVIDER_NFQWS2_BIN = getenv("ZAPRET2_PROVIDER_NFQWS2_BIN") || "/opt/zapret2/nfq2/nfqws2"; +const BYEDPI_BIN = getenv("BYEDPI_BIN") || "/usr/bin/ciadpi"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function parse_json_or_null(value) { + try { + return json(as_string(value)); + } + catch (e) { + return null; + } +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function json_text(value) { + return sprintf("%J", value) + "\n"; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function command_env(assignments) { + let parts = []; + + for (let name, value in assignments) + push(parts, name + "=" + shell_quote(value)); + + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_success_from_args(args) { + return command_success(command_from_args(args)); +} + +function module_success(module_path, args) { + let command_args = [ "ucode", "-L", LIB_DIR, module_path ]; + for (let arg in args) + push(command_args, arg); + return command_success_from_args(command_args); +} + +function now_seconds() { + return int(clock()[0]); +} + +function ensure_dir(path) { + return fs.mkdir(path, 0755) || fs.stat(path) != null; +} + +function ensure_dirs() { + for (let dir in [ + STATE_DIR, + SERVICE_ACTION_DIR, + LATENCY_ACTION_DIR, + COMPONENT_ACTION_DIR, + SUBSCRIPTION_ACTION_DIR + ]) + ensure_dir(dir); +} + +function write_file(path, value) { + return fs.writefile(as_string(path), as_string(value)) != null; +} + +function write_state_file(path, value) { + path = as_string(path); + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); + + if (!write_file(tmp_path, json_text(value))) { + fs.unlink(tmp_path); + return false; + } + if (!fs.rename(tmp_path, path)) { + fs.unlink(tmp_path); + return false; + } + return true; +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function remove_state_file(path) { + path = as_string(path); + let base = path; + let suffix = ".json"; + if (length(base) >= length(suffix) && substr(base, length(base) - length(suffix)) == suffix) + base = substr(base, 0, length(base) - length(suffix)); + remove_file(path); + remove_file(base + ".out"); + remove_file(base + ".out.json"); +} + +function arg_bool(value) { + return value === true || value == "true" || value == "1" || value == 1; +} + +function arg_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9-]/)) + return 0; + return int(value); +} + +function non_negative_number(value) { + let number = arg_number(value); + return number < 0 ? 0 : number; +} + +function unsigned_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9]/) != null) + return null; + return int(value); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function path_basename(path) { + let parts = split(as_string(path), "/"); + return length(parts) > 0 ? as_string(parts[length(parts) - 1]) : ""; +} + +function str_remove_suffix(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + if (length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix) + return substr(value, 0, length(value) - length(suffix)); + return value; +} + +function job_id_from_path(path) { + return str_remove_suffix(path_basename(path), ".json"); +} + +function valid_action_state(value) { + return type(value) == "object" && (value.running === true || value.running === false); +} + +function read_state_paths() { + let result = { + service: [], + latency: [], + component: [], + subscription: [] + }; + + for (let line in split(read_stdin(), "\n")) { + line = trim(as_string(line)); + if (line == "") + continue; + + let tab = index(line, "\t"); + if (tab <= 0) + continue; + + let kind = substr(line, 0, tab); + let path = substr(line, tab + 1); + let value = read_json_file(path); + if (!valid_action_state(value)) + continue; + + value.job_id = job_id_from_path(path); + + if (type(result[kind]) == "array") + push(result[kind], value); + } + + return result; +} + +function service_status_text(running, enabled) { + if (arg_bool(running)) + return arg_bool(enabled) ? "running & enabled" : "running but disabled"; + + return arg_bool(enabled) ? "stopped but enabled" : "stopped & disabled"; +} + +function print_service_status_text(running, enabled) { + print(service_status_text(running, enabled), "\n"); +} + +function ui_state_json() { + let action_state = read_state_paths(); + let tachyon_running = arg_number(ARGV[1]); + let tachyon_enabled = arg_number(ARGV[2]); + let tachyon_status = as_string(ARGV[3]); + let tachyon_dns_configured = arg_number(ARGV[4]); + let sing_box_running = arg_number(ARGV[5]); + let sing_box_enabled = arg_number(ARGV[6]); + let sing_box_status = as_string(ARGV[7]); + + if (tachyon_status == "") + tachyon_status = service_status_text(tachyon_running, tachyon_enabled); + + if (sing_box_status == "") + sing_box_status = service_status_text(sing_box_running, sing_box_enabled); + + write_json({ + service: { + tachyon: { + running: tachyon_running, + enabled: tachyon_enabled, + status: tachyon_status, + dns_configured: tachyon_dns_configured + }, + sing_box: { + running: sing_box_running, + enabled: sing_box_enabled, + status: sing_box_status + } + }, + capabilities: { + sing_box_extended: arg_number(ARGV[8]), + sing_box_tiny: arg_number(ARGV[9]), + sing_box_compressed: arg_number(ARGV[10]), + sing_box_tailscale: arg_number(ARGV[11]), + zapret_installed: arg_number(ARGV[12]), + zapret2_installed: arg_number(ARGV[13]), + byedpi_installed: arg_number(ARGV[14]), + server_inbounds_enabled_count: arg_number(ARGV[15]) + }, + actions: action_state + }); +} + +function action_start_response(success, job_id, message) { + write_json({ + success: arg_bool(success), + job_id: as_string(job_id), + message: as_string(message) + }); +} + +function service_action_valid(action) { + action = as_string(action); + return action == "start" || action == "stop" || action == "restart" || action == "reload"; +} + +function latency_type_valid(latency_type) { + latency_type = as_string(latency_type); + return latency_type == "group" || latency_type == "proxy" || latency_type == "proxy_list"; +} + +function service_action_expected_running(action) { + action = as_string(action); + + if (action == "start" || action == "restart" || action == "reload") + print("1\n"); + else if (action == "stop") + print("0\n"); + else + exit(1); +} + +function running_service_action(action, source, started_at) { + write_json({ + success: true, + running: true, + kind: "service", + action: as_string(action), + source: as_string(source), + message: "Service action is running", + pid: null, + started_at: arg_number(started_at), + updated_at: null, + exit_code: null + }); +} + +function running_latency_action(latency_type, section, tag, started_at) { + write_json({ + success: true, + running: true, + kind: "latency", + latency_type: as_string(latency_type), + section: as_string(section), + tag: as_string(tag), + message: "Latency test is running", + pid: null, + started_at: arg_number(started_at), + updated_at: null, + exit_code: null + }); +} + +function set_running_job_pid(path, pid) { + let value = object_or_empty(read_json_file(path)); + if (value.running === true) + value.pid = as_string(pid); + write_json(value); +} + +function finished_action_state(path, success, message, exit_code, updated_at) { + let value = object_or_empty(read_json_file(path)); + value.success = arg_bool(success); + value.running = false; + value.message = as_string(message); + value.exit_code = as_string(exit_code) == "" ? null : arg_number(exit_code); + value.updated_at = arg_number(updated_at); + write_json(value); +} + +function stale_action_state(path, message, updated_at) { + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.success = false; + value.running = false; + value.message = as_string(message); + value.exit_code = null; + value.updated_at = arg_number(updated_at); + } + write_json(value); +} + +function ack_action_state(path, acked_at) { + let value = object_or_empty(read_json_file(path)); + if (value.running === false) + value.acked_at = arg_number(acked_at); + write_json(value); +} + +function action_ack_expired(path, now_value, ttl_value) { + let value = read_json_file(path); + if (type(value) != "object") + exit(1); + + let acked_at = unsigned_number(value.acked_at); + let now = unsigned_number(now_value); + let ttl = unsigned_number(ttl_value); + + if (acked_at == null || now == null || now <= 0 || ttl == null) + exit(1); + + exit(now - acked_at >= ttl ? 0 : 1); +} + +function json_file_field(path, key, fallback) { + let value = read_json_file(path); + if (type(value) == "object" && value[key] != null) + print(as_string(value[key]), "\n"); + else + print(as_string(fallback), "\n"); +} + +function job_state_path(dir, job_id) { + dir = as_string(dir); + job_id = as_string(job_id); + + if (job_id == "" || job_id == "." || job_id == ".." || match(job_id, /[^A-Za-z0-9._-]/) != null) + exit(1); + + print(dir, "/", job_id, ".json\n"); +} + +function job_started_at_within_grace(value, now, grace_seconds) { + let started_at = arg_number(value); + now = arg_number(now); + grace_seconds = arg_number(grace_seconds); + + if (started_at <= 0 || now <= 0) + return false; + + return now - started_at < grace_seconds; +} + +function job_pid_valid(pid) { + pid = as_string(pid); + return pid != "" && match(pid, /^[0-9]+$/) != null; +} + +function job_refresh_plan(path, now, grace_seconds) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) { + print("skip\n"); + return; + } + + let within_grace = job_started_at_within_grace(value.started_at, now, grace_seconds); + let pid = as_string(value.pid || ""); + if (!job_pid_valid(pid)) { + print(within_grace ? "skip\n" : "stale\n"); + return; + } + + print("pid\t", pid, "\t", within_grace ? "0" : "1", "\n"); +} + +function active_service_action(dir) { + dir = as_string(dir || SERVICE_ACTION_DIR); + + for (let path in fs.glob(as_string(dir) + "/*.json")) { + let value = read_json_file(path); + if (type(value) == "object" && value.running === true && as_string(value.action) != "") { + print(as_string(value.action), "\n"); + return; + } + } + + exit(1); +} + +function active_service_action_value() { + for (let path in fs.glob(SERVICE_ACTION_DIR + "/*.json")) { + let value = read_json_file(path); + if (type(value) == "object" && value.running === true && as_string(value.action) != "") + return as_string(value.action); + } + + return ""; +} + +function file_executable(path) { + let stat = fs.stat(as_string(path)); + return stat != null && stat.mode != null && (int(stat.mode) & 73) != 0; +} + +function first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return trim(newline >= 0 ? substr(data, 0, newline) : data); +} + +function valid_job_id(job_id) { + job_id = as_string(job_id); + return job_id != "" && job_id != "." && job_id != ".." && match(job_id, /[^A-Za-z0-9._-]/) == null; +} + +function job_state_path_value(dir, job_id) { + if (!valid_job_id(job_id)) + return ""; + return as_string(dir) + "/" + as_string(job_id) + ".json"; +} + +function job_id() { + let stamp = clock(); + return sprintf("%d-%d", stamp[0], stamp[1]); +} + +function running_service_action_value(action, source, started_at) { + return { + success: true, + running: true, + kind: "service", + action: as_string(action), + source: as_string(source), + message: "Service action is running", + pid: null, + started_at: arg_number(started_at), + updated_at: null, + exit_code: null + }; +} + +function latency_progress_value(completed, total, failed) { + let progress_total = non_negative_number(total); + let progress_completed = non_negative_number(completed); + if (progress_total > 0 && progress_completed > progress_total) + progress_completed = progress_total; + + return { + completed: progress_completed, + total: progress_total, + failed: non_negative_number(failed) + }; +} + +function proxy_list_total(tag) { + let value = parse_json_or_null(tag); + if (type(value) != "array") + return 0; + + let total = 0; + for (let proxy_tag in value) { + if (type(proxy_tag) == "string" && proxy_tag != "") + total++; + } + + return total; +} + +function initial_latency_progress(latency_type, tag) { + if (as_string(latency_type) != "proxy_list") + return null; + + return latency_progress_value(0, proxy_list_total(tag), 0); +} + +function running_latency_action_value(latency_type, section, tag, started_at) { + let value = { + success: true, + running: true, + kind: "latency", + latency_type: as_string(latency_type), + section: as_string(section), + tag: as_string(tag), + message: "Latency test is running", + pid: null, + started_at: arg_number(started_at), + updated_at: null, + exit_code: null + }; + + let progress = initial_latency_progress(latency_type, tag); + if (type(progress) == "object") + value.progress = progress; + + return value; +} + +function latency_action_path_allowed(path) { + path = as_string(path); + let prefix = LATENCY_ACTION_DIR + "/"; + let suffix = ".json"; + return path != "" && + substr(path, 0, length(prefix)) == prefix && + length(path) > length(prefix) + length(suffix) && + substr(path, length(path) - length(suffix)) == suffix; +} + +function update_latency_progress_state(path, completed, total, failed) { + if (!latency_action_path_allowed(path)) + return false; + + let value = read_json_file(path); + if (type(value) != "object" || value.kind != "latency" || value.running !== true) + return false; + + value.progress = latency_progress_value(completed, total, failed); + value.updated_at = now_seconds(); + return write_state_file(path, value); +} + +function update_latency_progress_state_mode(path, completed, total, failed) { + exit(update_latency_progress_state(path, completed, total, failed) ? 0 : 1); +} + +function finished_action_state_value(path, success, message, exit_code, updated_at) { + let value = object_or_empty(read_json_file(path)); + value.success = arg_bool(success); + value.running = false; + value.message = as_string(message); + value.exit_code = as_string(exit_code) == "" ? null : arg_number(exit_code); + value.updated_at = arg_number(updated_at); + return value; +} + +function stale_action_state_value(path, message, updated_at) { + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.success = false; + value.running = false; + value.message = as_string(message); + value.exit_code = null; + value.updated_at = arg_number(updated_at); + } + return value; +} + +function ack_action_state_value(path, acked_at) { + let value = object_or_empty(read_json_file(path)); + if (value.running === false) + value.acked_at = arg_number(acked_at); + return value; +} + +function set_running_job_pid_file(path, pid) { + pid = as_string(pid); + if (!job_pid_valid(pid)) + return false; + + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.pid = pid; + return write_state_file(path, value); + } + + return false; +} + +function write_finished_action_state(path, success, message, exit_code) { + return write_state_file(path, finished_action_state_value(path, success, message, exit_code, now_seconds())); +} + +function write_stale_action_state(path, message) { + return write_state_file(path, stale_action_state_value(path, message, now_seconds())); +} + +function pid_running(pid) { + pid = as_string(pid); + return job_pid_valid(pid) && command_success_from_args([ "kill", "-0", pid ]); +} + +function current_pid() { + let stat = as_string(fs.readfile("/proc/self/stat")); + let separator = index(stat, " "); + return separator > 0 ? substr(stat, 0, separator) : ""; +} + +function refresh_pid_job_state(path, stale_message) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) + return; + + let now = now_seconds(); + let within_grace = job_started_at_within_grace(value.started_at, now, ACTION_STALE_GRACE_SECONDS); + let pid = as_string(value.pid || ""); + + if (job_pid_valid(pid) && pid_running(pid)) + return; + + if (!within_grace) + write_stale_action_state(path, stale_message); +} + +function state_file_ack_expired(path) { + let value = read_json_file(path); + if (type(value) != "object") + return false; + + let acked_at = unsigned_number(value.acked_at); + let now = now_seconds(); + let ttl = unsigned_number(ACTION_ACKED_TTL_SECONDS); + return acked_at != null && now > 0 && ttl != null && now - acked_at >= ttl; +} + +function cleanup_dir(dir) { + dir = as_string(dir); + let now = now_seconds(); + let ttl_minutes = unsigned_number(ACTION_FINISHED_TTL_MINUTES); + + for (let path in fs.glob(dir + "/*.json")) { + let value = read_json_file(path); + if (!valid_action_state(value)) { + remove_state_file(path); + continue; + } + + if (value.running !== false) + continue; + + if (state_file_ack_expired(path)) { + remove_state_file(path); + continue; + } + + let stat = fs.stat(path); + if (stat != null && ttl_minutes != null && now - int(stat.mtime || 0) > ttl_minutes * 60) + remove_state_file(path); + } +} + +function refresh_action_dirs() { + ensure_dirs(); + cleanup_dir(SERVICE_ACTION_DIR); + cleanup_dir(LATENCY_ACTION_DIR); + cleanup_dir(COMPONENT_ACTION_DIR); + cleanup_dir(SUBSCRIPTION_ACTION_DIR); + + for (let path in fs.glob(SERVICE_ACTION_DIR + "/*.json")) + refresh_pid_job_state(path, "Service action worker exited unexpectedly"); + for (let path in fs.glob(LATENCY_ACTION_DIR + "/*.json")) + refresh_pid_job_state(path, "Latency test worker exited unexpectedly"); + for (let path in fs.glob(COMPONENT_ACTION_DIR + "/*.json")) + refresh_pid_job_state(path, "Component action worker exited unexpectedly"); + for (let path in fs.glob(SUBSCRIPTION_ACTION_DIR + "/*.json")) + refresh_pid_job_state(path, "Subscription update worker exited unexpectedly"); +} + +function active_service_action_default() { + refresh_action_dirs(); + active_service_action(SERVICE_ACTION_DIR); +} + +function action_state_from_dir(dir) { + let result = []; + + for (let path in fs.glob(as_string(dir) + "/*.json")) { + let value = read_json_file(path); + if (!valid_action_state(value)) + continue; + + value.job_id = job_id_from_path(path); + push(result, value); + } + + return result; +} + +function action_state_from_dirs() { + return { + service: action_state_from_dir(SERVICE_ACTION_DIR), + latency: action_state_from_dir(LATENCY_ACTION_DIR), + component: action_state_from_dir(COMPONENT_ACTION_DIR), + subscription: action_state_from_dir(SUBSCRIPTION_ACTION_DIR) + }; +} + +function acquire_dir_lock(lock_dir) { + lock_dir = as_string(lock_dir); + let owner_pid = current_pid(); + if (!job_pid_valid(owner_pid)) + return false; + + if (command_success_from_args([ "mkdir", lock_dir ])) { + if (write_file(lock_dir + "/pid", owner_pid + "\n")) + return true; + release_dir_lock(lock_dir); + return false; + } + + let current_owner_pid = first_line(lock_dir + "/pid"); + if (pid_running(current_owner_pid)) + return false; + + let lock_stat = fs.stat(lock_dir); + if (current_owner_pid == "" && lock_stat != null) { + let lock_age = now_seconds() - int(lock_stat.mtime || 0); + if (lock_age >= 0 && lock_age < 5) + return false; + } + + remove_file(lock_dir + "/pid"); + command_success_from_args([ "rmdir", lock_dir ]); + if (!command_success_from_args([ "mkdir", lock_dir ])) + return false; + + if (write_file(lock_dir + "/pid", owner_pid + "\n")) + return true; + release_dir_lock(lock_dir); + return false; +} + +function release_dir_lock(lock_dir) { + remove_file(as_string(lock_dir) + "/pid"); + command_success_from_args([ "rmdir", lock_dir ]); +} + +function service_enabled() { + return file_executable("/etc/rc.d/S99" + SERVICE_NAME); +} + +function sing_box_enabled() { + return file_executable("/etc/rc.d/S99sing-box"); +} + +function sing_box_running() { + return module_success(LIB_DIR + "/service/state.uc", [ + "sing-box-service-stable", + RUNTIME_STABLE_MIN_AGE + ]); +} + +function tachyon_running() { + return module_success(LIB_DIR + "/service/state.uc", [ + "tachyon-stably-running", + RT_TABLE_NAME, + NFT_TABLE_NAME, + NFT_FAKEIP_MARK, + RUNTIME_STABLE_MIN_AGE + ]); +} + +function dns_configured() { + return index(uci_core.get("dhcp.@dnsmasq[0].server"), SB_DNS_INBOUND_ADDRESS) >= 0; +} + +function marker_is(expected) { + return first_line(SING_BOX_VARIANT_STATE_FILE) == as_string(expected); +} + +function tiny_package_installed() { + if (command_success_from_args([ "sh", "-c", "command -v apk" ])) + return command_success_from_args([ "apk", "info", "-e", "sing-box-tiny" ]); + + let installed = command_output_from_args([ "opkg", "list-installed" ]); + for (let line in split(installed, "\n")) + if (split(trim(as_string(line)), /[ \t]+/)[0] == "sing-box-tiny") + return true; + + return false; +} + +function component_action_running_for(component) { + component = as_string(component); + for (let path in fs.glob(COMPONENT_ACTION_DIR + "/*.json")) { + let value = read_json_file(path); + if (type(value) == "object" && value.running === true && as_string(value.component) == component) + return true; + } + + return false; +} + +function sing_box_signature() { + let stat = fs.stat(SING_BOX_BIN_PATH); + if (stat == null) + return ""; + + return join(":", [ stat.inode, stat.size, stat.mtime, stat.ctime ]); +} + +function sing_box_version_info_from_output(output) { + let first = split(as_string(output), "\n")[0] || ""; + let fields = split(trim(as_string(first)), /[ \t]+/); + let version = length(fields) > 0 ? fields[length(fields) - 1] : ""; + if (version == "") + return null; + + let tags = ""; + for (let line in split(as_string(output), "\n")) { + line = as_string(line); + if (substr(line, 0, 5) == "Tags:") { + tags = trim(substr(line, 5)); + break; + } + } + + return { version, tags }; +} + +function sing_box_cached_version_info(signature) { + let cache = read_json_file(SING_BOX_VERSION_CACHE_FILE); + if (type(cache) == "object" && as_string(cache.signature) == signature) { + if (cache.success === true && as_string(cache.version) != "") + return { hit: true, info: { version: as_string(cache.version), tags: as_string(cache.tags) } }; + + let checked_at = arg_number(cache.checked_at); + let failure_ttl = arg_number(SING_BOX_VERSION_PROBE_FAILURE_TTL_SECONDS); + if (cache.success === false && checked_at > 0) { + let failure_age = now_seconds() - checked_at; + if (failure_age >= 0 && failure_age < failure_ttl) + return { hit: true, info: null }; + } + } + + let legacy = split(as_string(fs.readfile(SING_BOX_VERSION_CACHE_FILE)), "\n"); + let legacy_version = as_string(legacy[1] || ""); + if (as_string(legacy[0] || "") == signature && index(legacy_version, "extended") >= 0) + return { hit: true, info: { version: legacy_version, tags: "" } }; + + return null; +} + +function bounded_command_output_from_args(args, timeout_seconds) { + timeout_seconds = arg_number(timeout_seconds); + if (timeout_seconds <= 0) + timeout_seconds = 1; + + let pid = current_pid(); + if (!job_pid_valid(pid)) + return ""; + + let output_path = SING_BOX_VERSION_CACHE_FILE + "." + pid + ".out"; + remove_file(output_path); + + let script = command_from_args(args) + " >" + shell_quote(output_path) + " 2>/dev/null & child=$!; " + + "(sleep " + as_string(timeout_seconds) + "; kill -KILL \"$child\" 2>/dev/null) & watchdog=$!; " + + "wait \"$child\"; rc=$?; kill \"$watchdog\" 2>/dev/null; wait \"$watchdog\" 2>/dev/null; exit \"$rc\""; + let status = command_status(command_from_args([ "sh", "-c", script ]) + " 2>/dev/null"); + let output = as_string(fs.readfile(output_path)); + remove_file(output_path); + return status == 0 ? output : ""; +} + +function sing_box_version_info() { + if (!file_executable(SING_BOX_BIN_PATH)) + return null; + + let signature = sing_box_signature(); + if (signature == "") + return null; + + let cached = sing_box_cached_version_info(signature); + if (cached != null) + return cached.info; + + ensure_dir(STATE_DIR); + if (!acquire_dir_lock(SING_BOX_VERSION_CACHE_LOCK_DIR)) { + cached = sing_box_cached_version_info(signature); + return cached != null ? cached.info : null; + } + + cached = sing_box_cached_version_info(signature); + if (cached != null) { + release_dir_lock(SING_BOX_VERSION_CACHE_LOCK_DIR); + return cached.info; + } + + let output = bounded_command_output_from_args([ SING_BOX_BIN_PATH, "version" ], SING_BOX_VERSION_PROBE_TIMEOUT_SECONDS); + let info = sing_box_version_info_from_output(output); + write_state_file(SING_BOX_VERSION_CACHE_FILE, { + signature, + success: info != null, + version: info != null ? info.version : "", + tags: info != null ? info.tags : "", + checked_at: now_seconds() + }); + release_dir_lock(SING_BOX_VERSION_CACHE_LOCK_DIR); + return info; +} + +function server_inbounds_enabled_count() { + let count = 0; + for (let section in uci_core.section_objects(CONFIG_NAME, "server")) { + section = object_or_empty(section); + let enabled = section.enabled == null ? "1" : as_string(section.enabled); + if (enabled != "0") + count++; + } + return count; +} + +function capability_flags() { + let result = { + sing_box_extended: 0, + sing_box_tiny: 0, + sing_box_compressed: 0, + sing_box_tailscale: 0, + zapret_installed: file_executable(ZAPRET_PROVIDER_NFQWS_BIN) ? 1 : 0, + zapret2_installed: file_executable(ZAPRET2_PROVIDER_NFQWS2_BIN) ? 1 : 0, + byedpi_installed: file_executable(BYEDPI_BIN) ? 1 : 0, + server_inbounds_enabled_count: 0 + }; + + if (file_executable(SING_BOX_BIN_PATH)) { + if (marker_is("extended-compressed")) { + result.sing_box_extended = 1; + result.sing_box_compressed = 1; + result.sing_box_tailscale = 1; + } + else if (marker_is("extended")) { + result.sing_box_extended = 1; + result.sing_box_tailscale = 1; + } + else if (marker_is("tiny") || tiny_package_installed()) { + result.sing_box_tiny = 1; + } + else if (component_action_running_for("sing_box")) { + result.sing_box_tailscale = 1; + } + else { + let info = sing_box_version_info(); + if (info != null && index(info.version, "extended") >= 0) { + result.sing_box_extended = 1; + result.sing_box_tailscale = 1; + } + else if (info != null) { + if (match(info.tags, /(^|[,: \t])with_tailscale([, \t]|$)/) != null) + result.sing_box_tailscale = 1; + if (result.sing_box_tailscale == 0) + result.sing_box_tiny = 1; + } + } + } + + result.server_inbounds_enabled_count = server_inbounds_enabled_count(); + return result; +} + +function ui_capabilities_json() { + write_json(capability_flags()); +} + +function current_ui_state_json() { + refresh_action_dirs(); + + let capabilities = capability_flags(); + let tachyon_is_running = tachyon_running() ? 1 : 0; + let tachyon_is_enabled = service_enabled() ? 1 : 0; + let sing_box_is_running = tachyon_is_running ? 1 : (sing_box_running() ? 1 : 0); + let sing_box_is_enabled = sing_box_enabled() ? 1 : 0; + let tachyon_status = service_status_text(tachyon_is_running, tachyon_is_enabled); + let sing_box_status = service_status_text(sing_box_is_running, sing_box_is_enabled); + let active_action = active_service_action_value(); + + if (active_action == "start") + tachyon_status = "starting"; + else if (active_action == "stop") + tachyon_status = "stopping"; + else if (active_action == "restart") + tachyon_status = "restarting"; + else if (active_action == "reload") + tachyon_status = "reloading"; + + write_json({ + service: { + tachyon: { + running: tachyon_is_running, + enabled: tachyon_is_enabled, + status: tachyon_status, + dns_configured: dns_configured() ? 1 : 0 + }, + sing_box: { + running: sing_box_is_running, + enabled: sing_box_is_enabled, + status: sing_box_status + } + }, + capabilities, + actions: action_state_from_dirs() + }); +} + +function service_action_expected_running_value(action) { + action = as_string(action); + if (action == "start" || action == "restart" || action == "reload") + return 1; + if (action == "stop") + return 0; + return -1; +} + +function service_action_reached_expected_state(action) { + let expected = service_action_expected_running_value(action); + if (expected < 0) + return false; + return expected == 1 ? tachyon_running() : !tachyon_running(); +} + +function service_action_wait_for_expected_state(action, timeout, settle_seconds) { + timeout = arg_number(timeout || SERVICE_ACTION_TIMEOUT_SECONDS); + settle_seconds = arg_number(settle_seconds || SERVICE_ACTION_SETTLE_SECONDS); + let deadline = now_seconds() + timeout; + let stable_seconds = 0; + + while (true) { + if (service_action_reached_expected_state(action)) { + stable_seconds++; + if (stable_seconds >= settle_seconds) + return true; + } + else { + stable_seconds = 0; + } + + if (now_seconds() >= deadline) + return false; + system("sleep 1"); + } +} + +function begin_service_action_if_idle(action, source) { + action = as_string(action); + source = as_string(source || "ui"); + if (!service_action_valid(action)) + return { status: 1, job_id: "" }; + + ensure_dirs(); + if (!acquire_dir_lock(SERVICE_ACTION_LOCK_DIR)) + return { status: 2, job_id: "" }; + + refresh_action_dirs(); + if (active_service_action_value() != "") { + release_dir_lock(SERVICE_ACTION_LOCK_DIR); + return { status: 2, job_id: "" }; + } + + let id = job_id(); + let path = job_state_path_value(SERVICE_ACTION_DIR, id); + if (path == "" || !write_state_file(path, running_service_action_value(action, source, now_seconds()))) { + release_dir_lock(SERVICE_ACTION_LOCK_DIR); + return { status: 1, job_id: "" }; + } + + release_dir_lock(SERVICE_ACTION_LOCK_DIR); + return { status: 0, job_id: id }; +} + +function begin_service_action_mode(action, source) { + let result = begin_service_action_if_idle(action, source || "ui"); + if (result.status == 0) + print(result.job_id, "\n"); + exit(result.status); +} + +function finish_service_action(job_id_value, success, message, exit_code) { + let path = job_state_path_value(SERVICE_ACTION_DIR, job_id_value); + if (path == "" || fs.stat(path) == null) + return false; + return write_finished_action_state(path, success, message, exit_code); +} + +function finish_service_action_mode(job_id_value, success, message, exit_code) { + exit(finish_service_action(job_id_value, success, message, exit_code) ? 0 : 1); +} + +function launch_worker(args) { + let command_args = [ "ucode", "-L", LIB_DIR, UI_UC ]; + for (let arg in args) + push(command_args, arg); + + let command = command_env({ + TACHYON_CONFIG_NAME: CONFIG_NAME, + TACHYON_LIB: LIB_DIR, + TACHYON_BIN: BIN_PATH, + TACHYON_SERVICE_INIT: SERVICE_INIT, + TACHYON_SERVICE_NAME: SERVICE_NAME, + TACHYON_UI_STATE_DIR: STATE_DIR, + TACHYON_UI_SERVICE_ACTION_DIR: SERVICE_ACTION_DIR, + TACHYON_UI_SERVICE_ACTION_LOCK_DIR: SERVICE_ACTION_LOCK_DIR, + TACHYON_UI_LATENCY_ACTION_DIR: LATENCY_ACTION_DIR, + TACHYON_UI_COMPONENT_ACTION_DIR: COMPONENT_ACTION_DIR, + TACHYON_UI_SUBSCRIPTION_ACTION_DIR: SUBSCRIPTION_ACTION_DIR, + TACHYON_UI_SING_BOX_VERSION_CACHE_FILE: SING_BOX_VERSION_CACHE_FILE, + TACHYON_UI_SING_BOX_VERSION_CACHE_LOCK_DIR: SING_BOX_VERSION_CACHE_LOCK_DIR, + TACHYON_UI_SING_BOX_VARIANT_STATE_FILE: SING_BOX_VARIANT_STATE_FILE, + TACHYON_UI_SING_BOX_BIN_PATH: SING_BOX_BIN_PATH, + TACHYON_UI_SING_BOX_VERSION_PROBE_TIMEOUT_SECONDS: SING_BOX_VERSION_PROBE_TIMEOUT_SECONDS, + TACHYON_UI_SING_BOX_VERSION_PROBE_FAILURE_TTL_SECONDS: SING_BOX_VERSION_PROBE_FAILURE_TTL_SECONDS, + TACHYON_UI_ACTION_FINISHED_TTL_MINUTES: ACTION_FINISHED_TTL_MINUTES, + TACHYON_UI_ACTION_ACKED_TTL_SECONDS: ACTION_ACKED_TTL_SECONDS, + TACHYON_UI_ACTION_STALE_GRACE_SECONDS: ACTION_STALE_GRACE_SECONDS, + TACHYON_UI_SERVICE_ACTION_TIMEOUT_SECONDS: SERVICE_ACTION_TIMEOUT_SECONDS, + TACHYON_UI_SERVICE_ACTION_SETTLE_SECONDS: SERVICE_ACTION_SETTLE_SECONDS, + TACHYON_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE, + NFT_TABLE_NAME, + RT_TABLE_NAME, + NFT_FAKEIP_MARK, + SB_DNS_INBOUND_ADDRESS, + ZAPRET_PROVIDER_NFQWS_BIN, + ZAPRET2_PROVIDER_NFQWS2_BIN, + BYEDPI_BIN + }) + " " + + command_from_args(command_args) + + " >/dev/null 2>&1 1000>&- & echo $!"; + return trim(command_output("sh -c " + shell_quote(command))); +} + +function start_service_action(action, source, reason) { + let begin = begin_service_action_if_idle(action, source || "ui"); + if (begin.status != 0) + return { success: false, job_id: "" }; + + let path = job_state_path_value(SERVICE_ACTION_DIR, begin.job_id); + if (path == "") + return { success: false, job_id: begin.job_id }; + + let pid = launch_worker([ "service-action-worker", path, action, begin.job_id, reason || "" ]); + if (pid == "" || !set_running_job_pid_file(path, pid)) { + if (pid != "") + command_success_from_args([ "kill", pid ]); + write_finished_action_state(path, false, "Failed to write service action worker pid", 1); + return { success: false, job_id: begin.job_id }; + } + + return { success: true, job_id: begin.job_id }; +} + +function service_action_allows_pending_reload(action) { + action = as_string(action); + return action == "start" || action == "restart" || action == "reload"; +} + +function consume_pending_reload() { + return command_success_from_args([ + "ucode", + "-L", LIB_DIR, + STATE_UC, + "consume-pending-reload", + PENDING_RELOAD_FILE + ]); +} + +function mark_pending_reload(reason) { + return command_success_from_args([ + "ucode", + "-L", LIB_DIR, + STATE_UC, + "mark-pending-reload", + PENDING_RELOAD_FILE, + reason + ]); +} + +function run_pending_reload_after_service_action(action, success) { + if (!success || !service_action_allows_pending_reload(action) || fs.stat(STATE_UC) == null) + return; + + if (!consume_pending_reload()) + return; + + command_success_from_args([ "logger", "-t", SERVICE_NAME, "[info] Applying pending Tachyon reload" ]); + let started = start_service_action("reload", "initd", "pending"); + if (!started.success) + mark_pending_reload("pending"); +} + +function write_finished_service_action_state(path, action, success, message, exit_code) { + let written = write_finished_action_state(path, success, message, exit_code); + if (written) + run_pending_reload_after_service_action(action, success); + return written; +} + +function finish_service_action_after_command(action, job_id_value, status, spawn_waiter) { + status = arg_number(status); + if (as_string(job_id_value) == "") + return 0; + + let path = job_state_path_value(SERVICE_ACTION_DIR, job_id_value); + if (path == "" || fs.stat(path) == null) + return 0; + + if (status != 0) { + write_finished_service_action_state(path, action, false, "Service " + as_string(action) + " failed", status); + return 0; + } + + if (!service_enabled() && !tachyon_running()) { + write_finished_service_action_state(path, action, true, "Service " + as_string(action) + " completed", 0); + return 0; + } + + if (spawn_waiter) { + let pid = launch_worker([ "service-action-wait-worker", path, as_string(action), as_string(job_id_value) ]); + if (pid != "") + set_running_job_pid_file(path, pid); + return 0; + } + + if (service_action_wait_for_expected_state(action, SERVICE_ACTION_TIMEOUT_SECONDS, SERVICE_ACTION_SETTLE_SECONDS)) + write_finished_service_action_state(path, action, true, "Service " + as_string(action) + " completed", 0); + else + write_finished_service_action_state(path, action, false, "Service " + as_string(action) + " did not reach expected state", 1); + return 0; +} + +function finish_service_action_after_command_mode(action, job_id_value, status) { + finish_service_action_after_command(action, job_id_value, status, true); +} + +function update_service_action_pid_mode(job_id_value, pid) { + let path = job_state_path_value(SERVICE_ACTION_DIR, job_id_value); + exit(path != "" && set_running_job_pid_file(path, pid) ? 0 : 1); +} + +function service_action_worker(path, action, job_id_value, reason) { + let args = [ SERVICE_INIT, action ]; + reason = as_string(reason || ""); + if (reason != "") + push(args, reason); + let command = "TACHYON_UI_ACTION_TRACKED=1 " + command_from_args(args) + " >/dev/null 2>&1"; + let status = command_status(command); + finish_service_action_after_command(action, job_id_value, status, false); +} + +function service_action_wait_worker(path, action, job_id_value) { + if (service_action_wait_for_expected_state(action, SERVICE_ACTION_TIMEOUT_SECONDS, SERVICE_ACTION_SETTLE_SECONDS)) + write_finished_service_action_state(path, action, true, "Service " + as_string(action) + " completed", 0); + else + write_finished_service_action_state(path, action, false, "Service " + as_string(action) + " did not reach expected state", 1); +} + +function service_action_async(action) { + action = as_string(action); + if (!service_action_valid(action)) { + action_start_response(false, "", "Invalid service action"); + exit(1); + } + + let started = start_service_action(action, "ui", ""); + if (!started.success && active_service_action_value() != "") { + action_start_response(false, "", "Another service action is already running"); + exit(1); + } + if (!started.success) { + action_start_response(false, "", "Failed to write service action worker pid"); + exit(1); + } + + action_start_response(true, started.job_id, "Service " + action + " started"); +} + +function service_action_status(job_id_value) { + let path = job_state_path_value(SERVICE_ACTION_DIR, job_id_value); + if (path == "") { + action_start_response(false, "", "Invalid service action job id"); + exit(1); + } + if (fs.stat(path) == null) { + action_start_response(false, "", "Service action job was not found"); + exit(1); + } + + refresh_pid_job_state(path, "Service action worker exited unexpectedly"); + print(as_string(fs.readfile(path))); +} + +function latency_clash_method(latency_type) { + latency_type = as_string(latency_type); + if (latency_type == "group") + return { method: "get_group_latency", timeout: "10000" }; + if (latency_type == "proxy_list") + return { method: "get_proxy_latencies", timeout: "5000" }; + return { method: "get_proxy_latency", timeout: "5000" }; +} + +function latency_worker(path, latency_type, tag, timeout) { + let method = latency_clash_method(latency_type).method; + let status = command_status(command_from_args([ BIN_PATH, "clash_api", method, tag, timeout, path ]) + " >/dev/null 2>&1"); + if (status == 0) + write_finished_action_state(path, true, "Latency test completed", status); + else + write_finished_action_state(path, false, "Latency test failed", status); +} + +function latency_test_async(latency_type, section, tag, requested_timeout) { + latency_type = as_string(latency_type); + tag = as_string(tag); + if (!latency_type_valid(latency_type)) { + action_start_response(false, "", "Invalid latency test type"); + exit(1); + } + if (tag == "") { + action_start_response(false, "", "Latency test tag is required"); + exit(1); + } + + ensure_dirs(); + let id = job_id(); + let path = job_state_path_value(LATENCY_ACTION_DIR, id); + if (path == "" || !write_state_file(path, running_latency_action_value(latency_type, section, tag, now_seconds()))) { + action_start_response(false, "", "Failed to write latency test state"); + exit(1); + } + + let plan = latency_clash_method(latency_type); + let timeout = as_string(requested_timeout) != "" ? as_string(requested_timeout) : plan.timeout; + let pid = launch_worker([ "latency-worker", path, latency_type, tag, timeout ]); + if (pid == "" || !set_running_job_pid_file(path, pid)) { + if (pid != "") + command_success_from_args([ "kill", pid ]); + action_start_response(false, "", "Failed to write latency test worker pid"); + exit(1); + } + + action_start_response(true, id, "Latency test started"); +} + +function latency_test_status(job_id_value) { + let path = job_state_path_value(LATENCY_ACTION_DIR, job_id_value); + if (path == "") { + action_start_response(false, "", "Invalid latency test job id"); + exit(1); + } + if (fs.stat(path) == null) { + action_start_response(false, "", "Latency test job was not found"); + exit(1); + } + + refresh_pid_job_state(path, "Latency test worker exited unexpectedly"); + print(as_string(fs.readfile(path))); +} + +function action_dir(kind) { + kind = as_string(kind); + if (kind == "service") + return SERVICE_ACTION_DIR; + if (kind == "latency") + return LATENCY_ACTION_DIR; + if (kind == "component") + return COMPONENT_ACTION_DIR; + if (kind == "subscription") + return SUBSCRIPTION_ACTION_DIR; + return ""; +} + +function action_ack(kind, job_id_value) { + let dir = action_dir(kind); + if (dir == "") { + action_start_response(false, "", "Invalid UI action kind"); + exit(1); + } + + let path = job_state_path_value(dir, job_id_value); + if (path == "") { + action_start_response(false, "", "Invalid UI action job id"); + exit(1); + } + + if (fs.stat(path) == null) { + action_start_response(true, job_id_value, "UI action already acknowledged"); + return; + } + + let value = read_json_file(path); + if (type(value) == "object" && value.running === true) { + action_start_response(false, job_id_value, "UI action is still running"); + exit(1); + } + + if (!write_state_file(path, ack_action_state_value(path, now_seconds()))) { + action_start_response(false, job_id_value, "Failed to acknowledge UI action"); + exit(1); + } + + action_start_response(true, job_id_value, "UI action acknowledged"); +} + +let mode = ARGV[0] || ""; + +if (mode == "ui-state-json") + ui_state_json(); +else if (mode == "get-ui-capabilities") + ui_capabilities_json(); +else if (mode == "get-ui-state") + current_ui_state_json(); +else if (mode == "service-status-text") + print_service_status_text(ARGV[1], ARGV[2]); +else if (mode == "action-start-response") + action_start_response(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "service-action-valid") + exit(service_action_valid(ARGV[1]) ? 0 : 1); +else if (mode == "latency-type-valid") + exit(latency_type_valid(ARGV[1]) ? 0 : 1); +else if (mode == "service-action-expected-running") + service_action_expected_running(ARGV[1]); +else if (mode == "running-service-action") + running_service_action(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "running-latency-action") + running_latency_action(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "set-running-job-pid") + set_running_job_pid(ARGV[1], ARGV[2]); +else if (mode == "finished-action-state") + finished_action_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "stale-action-state") + stale_action_state(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "ack-action-state") + ack_action_state(ARGV[1], ARGV[2]); +else if (mode == "action-ack-expired") + action_ack_expired(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "json-file-field") + json_file_field(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "job-state-path") + job_state_path(ARGV[1], ARGV[2]); +else if (mode == "job-refresh-plan") + job_refresh_plan(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "active-service-action") + ARGV[1] == null ? active_service_action_default() : active_service_action(ARGV[1]); +else if (mode == "component-action-running-for") + exit(component_action_running_for(ARGV[1]) ? 0 : 1); +else if (mode == "service-action-begin-if-idle") + begin_service_action_mode(ARGV[1], ARGV[2] || "ui"); +else if (mode == "service-action-update-pid") + update_service_action_pid_mode(ARGV[1], ARGV[2]); +else if (mode == "service-action-finish") + finish_service_action_mode(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "latency-progress-state") + update_latency_progress_state_mode(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "service-action-finish-after-command") + finish_service_action_after_command_mode(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "service-action-wait-worker") + service_action_wait_worker(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "service-action-worker") + service_action_worker(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "service-action-async") + service_action_async(ARGV[1]); +else if (mode == "service-action-status") + service_action_status(ARGV[1]); +else if (mode == "latency-worker") + latency_worker(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "latency-test-async") + latency_test_async(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "latency-test-status") + latency_test_status(ARGV[1]); +else if (mode == "action-ack") + action_ack(ARGV[1], ARGV[2]); +else if (mode == "cleanup-action-dir-fixture") + cleanup_dir(ARGV[1]); +else { + warn("Usage: service/ui.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/singbox/constants.uc b/tachyon/files/usr/lib/singbox/constants.uc new file mode 100644 index 00000000..d21ed4da --- /dev/null +++ b/tachyon/files/usr/lib/singbox/constants.uc @@ -0,0 +1,123 @@ +#!/usr/bin/env ucode + +const DNS_SERVER_TAG = "dns-server"; +const FAKEIP_DNS_SERVER_TAG = "fakeip-server"; +const BOOTSTRAP_DNS_SERVER_TAG = "bootstrap-dns-server"; +const FAKEIP_DNS_RULE_TAG = "fakeip-dns-rule-tag"; +const FAKEIP_RULESET_DNS_RULE_TAG = "fakeip-ruleset-dns-rule-tag"; +const SERVICE_FAKEIP_DNS_RULE_TAG = "service-fakeip-dns-rule-tag"; + +const TPROXY_INBOUND_TAG = "tproxy-in"; +const TPROXY_INBOUND_ADDRESS = "0.0.0.0"; +const TPROXY_INBOUND6_TAG = "tproxy6-in"; +const TPROXY_INBOUND6_ADDRESS = "::1"; +const TPROXY_INBOUND_PORT = 1602; +const DNS_INBOUND_TAG = "dns-in"; +const DNS_INBOUND_ADDRESS = "127.0.0.42"; +const DNS_INBOUND_PORT = 53; + +const SERVICE_MIXED_INBOUND_TAG = "service-mixed-in"; +const SERVICE_MIXED_INBOUND_ADDRESS = "127.0.0.1"; +const SERVICE_MIXED_INBOUND_PORT = 4534; +const DIRECT_OUTBOUND_TAG = "direct-out"; +const BYPASS_OUTBOUND_TAG = "bypass-out"; +const OUTBOUND_MARK = 2097152; +const FAKEIP_INET4_RANGE = "198.18.0.0/15"; +const FAKEIP_INET6_RANGE = "fc00::/18"; + +const DISABLED_UPDATE_INTERVAL = "876000h"; +const URLTEST_DEFAULT_IDLE_TIMEOUT = "30m"; +const CHECK_PROXY_IP_DOMAIN = "ip.podkop.fyi"; +const FAKEIP_TEST_DOMAIN = "fakeip.podkop.fyi"; +const TMP_SING_BOX_FOLDER = "/tmp/sing-box"; +const TMP_RULESET_FOLDER = TMP_SING_BOX_FOLDER + "/rulesets"; +const ZAPRET_ROUTE_MARK_BASE = 0x01000000; +const ZAPRET2_ROUTE_MARK_BASE = 0x01010000; +const BYEDPI_LISTEN_ADDRESS = "127.0.0.1"; +const BYEDPI_PORT_BASE = 1080; + +const RESERVED_TAGS = { + [DNS_SERVER_TAG]: true, + [FAKEIP_DNS_SERVER_TAG]: true, + [BOOTSTRAP_DNS_SERVER_TAG]: true, + [FAKEIP_DNS_RULE_TAG]: true, + [FAKEIP_RULESET_DNS_RULE_TAG]: true, + [SERVICE_FAKEIP_DNS_RULE_TAG]: true, + [TPROXY_INBOUND_TAG]: true, + [TPROXY_INBOUND6_TAG]: true, + [DNS_INBOUND_TAG]: true, + [SERVICE_MIXED_INBOUND_TAG]: true, + [DIRECT_OUTBOUND_TAG]: true, + [BYPASS_OUTBOUND_TAG]: true +}; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function tag(base, postfix) { + let candidate = as_string(base) + "-" + as_string(postfix); + return RESERVED_TAGS[candidate] ? candidate + "-1" : candidate; +} + +function inbound_tag(section_name) { + return tag(section_name, "in"); +} + +function outbound_tag(section_name) { + return tag(section_name, "out"); +} + +function domain_resolver_tag(section_name) { + return tag(section_name, "domain-resolver"); +} + +function server_inbound_tag(section_name) { + return tag("server-" + as_string(section_name), "in"); +} + +function tailscale_dns_server_tag(section_name) { + return tag("server-" + as_string(section_name), "tailscale-dns"); +} + +return { + DNS_SERVER_TAG, + FAKEIP_DNS_SERVER_TAG, + BOOTSTRAP_DNS_SERVER_TAG, + FAKEIP_DNS_RULE_TAG, + FAKEIP_RULESET_DNS_RULE_TAG, + SERVICE_FAKEIP_DNS_RULE_TAG, + TPROXY_INBOUND_TAG, + TPROXY_INBOUND_ADDRESS, + TPROXY_INBOUND6_TAG, + TPROXY_INBOUND6_ADDRESS, + TPROXY_INBOUND_PORT, + DNS_INBOUND_TAG, + DNS_INBOUND_ADDRESS, + DNS_INBOUND_PORT, + SERVICE_MIXED_INBOUND_TAG, + SERVICE_MIXED_INBOUND_ADDRESS, + SERVICE_MIXED_INBOUND_PORT, + DIRECT_OUTBOUND_TAG, + BYPASS_OUTBOUND_TAG, + OUTBOUND_MARK, + FAKEIP_INET4_RANGE, + FAKEIP_INET6_RANGE, + DISABLED_UPDATE_INTERVAL, + URLTEST_DEFAULT_IDLE_TIMEOUT, + CHECK_PROXY_IP_DOMAIN, + FAKEIP_TEST_DOMAIN, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + ZAPRET_ROUTE_MARK_BASE, + ZAPRET2_ROUTE_MARK_BASE, + BYEDPI_LISTEN_ADDRESS, + BYEDPI_PORT_BASE, + RESERVED_TAGS, + tag, + inbound_tag, + outbound_tag, + domain_resolver_tag, + server_inbound_tag, + tailscale_dns_server_tag +}; diff --git a/tachyon/files/usr/lib/singbox/country.uc b/tachyon/files/usr/lib/singbox/country.uc new file mode 100644 index 00000000..185f9fd1 --- /dev/null +++ b/tachyon/files/usr/lib/singbox/country.uc @@ -0,0 +1,328 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); + +let as_string = common.as_string; +let array_or_empty = common.array_or_empty; +let object_or_empty = common.object_or_empty; + +const COUNTRY_IS_URL = getenv("TACHYON_COUNTRY_IS_URL") || "https://api.country.is/"; +const COUNTRY_IS_BATCH_SIZE = 100; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_output_from_args(args) { + let pipe = fs.popen(command_from_args(args) + " 2>/dev/null", "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function remove_file(path) { + if (as_string(path) != "") + fs.unlink(as_string(path)); +} + +function valid_ipv4(value) { + let parts = split(as_string(value), "."); + if (length(parts) != 4) + return false; + for (let part in parts) { + if (match(part, /^[0-9]+$/) == null || int(part, 10) < 0 || int(part, 10) > 255) + return false; + } + return true; +} + +function valid_ipv6(value) { + value = as_string(value); + return index(value, ":") >= 0 && match(value, /^[0-9A-Fa-f:.]+$/) != null; +} + +function public_ipv4(value) { + if (!valid_ipv4(value)) + return false; + + let parts = split(value, "."); + let a = int(parts[0], 10); + let b = int(parts[1], 10); + let c = int(parts[2], 10); + + if (a == 0 || a == 10 || a == 127 || a >= 224) + return false; + if (a == 100 && b >= 64 && b <= 127) + return false; + if (a == 169 && b == 254) + return false; + if (a == 172 && b >= 16 && b <= 31) + return false; + if (a == 192 && (b == 168 || (b == 0 && (c == 0 || c == 2)))) + return false; + if (a == 198 && ((b == 18 || b == 19) || (b == 51 && c == 100))) + return false; + if (a == 203 && b == 0 && c == 113) + return false; + return true; +} + +function public_ipv6(value) { + if (!valid_ipv6(value)) + return false; + + value = lc(value); + let first = split(value, ":")[0]; + let first_value = int(first, 16); + if (first_value < 0x2000 || first_value > 0x3fff) + return false; + return match(value, /^2001:0?db8:/) == null; +} + +function public_ip(value) { + return public_ipv4(value) || public_ipv6(value); +} + +function first_ipv4_line(value) { + for (let line in split(as_string(value), "\n")) { + line = trim(as_string(line)); + if (valid_ipv4(line)) + return line; + } + return ""; +} + +function first_ipv6_line(value) { + for (let line in split(as_string(value), "\n")) { + line = trim(as_string(line)); + if (valid_ipv6(line)) + return line; + } + return ""; +} + +function first_nslookup_address(value) { + for (let line in split(as_string(value), "\n")) { + line = trim(as_string(line)); + let matched = match(line, /^Address[ \t]*[0-9]*:[ \t]*([^ \t]+)$/); + if (!matched) + continue; + + let address = as_string(matched[1]); + if (valid_ipv4(address) || valid_ipv6(address)) + return address; + } + return ""; +} + +function normalized_server(value) { + value = lc(trim(as_string(value))); + if (value != "" && substr(value, 0, 1) == "[" && substr(value, length(value) - 1) == "]") + value = substr(value, 1, length(value) - 2); + return value; +} + +function country_is_endpoint() { + let matched = match(COUNTRY_IS_URL, /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^\/:?#]+)(:([0-9]+))?/); + if (!matched) + return { host: "", port: "" }; + + let scheme = lc(as_string(matched[1])); + let port = as_string(matched[4] || ""); + if (port == "") + port = scheme == "https" ? "443" : "80"; + return { host: as_string(matched[2]), port }; +} + +function normalized_resolver(value) { + value = trim(as_string(value)); + let separator = index(value, "#"); + if (separator >= 0) + value = substr(value, 0, separator); + if (substr(value, 0, 1) == "[" && substr(value, length(value) - 1) == "]") + value = substr(value, 1, length(value) - 2); + return valid_ipv4(value) || valid_ipv6(value) ? value : ""; +} + +function resolve_service_host(host, resolver) { + resolver = normalized_resolver(resolver); + if (host == "" || resolver == "") + return ""; + + return first_ipv4_line(command_output_from_args([ + "dig", "+short", "@" + resolver, host, "A", "+time=2", "+tries=1" + ])); +} + +function resolve_server(value) { + let server = normalized_server(value); + if (server == "") + return ""; + if (valid_ipv4(server) || valid_ipv6(server)) + return server; + + let output = command_output_from_args([ "dig", "+short", server, "A", "+time=2", "+tries=1" ]); + let address = first_ipv4_line(output); + if (address == "") { + output = command_output_from_args([ "dig", "+short", server, "AAAA", "+time=2", "+tries=1" ]); + address = first_ipv6_line(output); + } + if (address != "") + return address; + + return first_nslookup_address(command_output_from_args([ "nslookup", server ])); +} + +function lookup_ip_batch(ips, resolver) { + let body_path = trim(command_output_from_args([ "mktemp" ])); + if (body_path == "") + return { countries: {}, stop: true }; + + let args = [ + "curl", "-sS", "-m", "10", "-o", body_path, "-w", "%{http_code}", + "-H", "Content-Type: application/json", "-d", sprintf("%J", ips) + ]; + let endpoint = country_is_endpoint(); + if (endpoint.host != "" && !valid_ipv4(endpoint.host) && !valid_ipv6(endpoint.host)) { + let service_ip = resolve_service_host(endpoint.host, resolver); + if (normalized_resolver(resolver) != "" && !public_ipv4(service_ip)) { + remove_file(body_path); + warn("Server country service host resolution failed\n"); + return { countries: {}, stop: true }; + } + if (public_ipv4(service_ip)) { + push(args, "--resolve"); + push(args, endpoint.host + ":" + endpoint.port + ":" + service_ip); + } + } + push(args, COUNTRY_IS_URL); + + let http_code = trim(command_output_from_args(args)); + let raw_body = common.read_json_file(body_path); + remove_file(body_path); + let body = object_or_empty(raw_body); + + let error_code = ""; + if (type(body.error) == "object") + error_code = as_string(body.error.code || ""); + else + error_code = as_string(body.code || body.error || ""); + + if (http_code == "429" || error_code == "rate_limit") { + warn("Server country lookup is rate-limited\n"); + return { countries: {}, stop: true }; + } + if (http_code != "200" || type(raw_body) != "array") { + warn("Server country lookup failed\n"); + return { countries: {}, stop: false }; + } + + let countries = {}; + for (let item in raw_body) { + item = object_or_empty(item); + let ip = as_string(item.ip || ""); + let country = uc(as_string(item.country || "")); + if (ip != "" && country != "") + countries[ip] = country; + } + return { countries, stop: false }; +} + +function lookup_ips(ips, resolver) { + let result = {}; + for (let start = 0; start < length(ips); start += COUNTRY_IS_BATCH_SIZE) { + let batch = slice(ips, start, start + COUNTRY_IS_BATCH_SIZE); + let response = lookup_ip_batch(batch, resolver); + for (let ip, country in response.countries) + result[ip] = country; + if (response.stop) + break; + if (start + COUNTRY_IS_BATCH_SIZE < length(ips)) + system("sleep 1"); + } + return result; +} + +function previous_countries_by_server(previous_state) { + previous_state = object_or_empty(previous_state); + let servers = object_or_empty(previous_state.servers); + let countries = object_or_empty(object_or_empty(previous_state.outboundMetadata).countries); + let result = {}; + + for (let tag_name, server in servers) { + let country = uc(as_string(countries[tag_name] || "")); + server = normalized_server(server); + if (server != "" && country != "") + result[server] = country; + } + return result; +} + +function detect(servers, previous_state, resolver) { + servers = object_or_empty(servers); + let cached = previous_countries_by_server(previous_state); + let result = {}; + let pending_tags_by_server = {}; + let tags_by_ip = {}; + let ips = []; + + for (let tag_name, server in servers) { + server = normalized_server(server); + if (server == "") + continue; + if ((valid_ipv4(server) || valid_ipv6(server)) && !public_ip(server)) + continue; + + if (cached[server]) { + result[tag_name] = cached[server]; + continue; + } + + if (!pending_tags_by_server[server]) + pending_tags_by_server[server] = []; + push(pending_tags_by_server[server], tag_name); + } + + for (let server, tags in pending_tags_by_server) { + let ip = resolve_server(server); + if (!public_ip(ip)) + continue; + if (!tags_by_ip[ip]) { + tags_by_ip[ip] = []; + push(ips, ip); + } + for (let tag_name in tags) + push(tags_by_ip[ip], tag_name); + } + + let countries_by_ip = length(ips) > 0 ? lookup_ips(ips, resolver) : {}; + for (let ip, tags in tags_by_ip) { + let country = as_string(countries_by_ip[ip] || ""); + if (country == "") + continue; + for (let tag_name in tags) + result[tag_name] = country; + } + + return result; +} + +return { + detect, + normalized_server, + public_ip, + previous_countries_by_server +}; diff --git a/tachyon/files/usr/lib/singbox/dns.uc b/tachyon/files/usr/lib/singbox/dns.uc new file mode 100644 index 00000000..ecf97d1c --- /dev/null +++ b/tachyon/files/usr/lib/singbox/dns.uc @@ -0,0 +1,274 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); +let core_ip = require("core.ip"); +let runtime_constants = require("singbox.constants"); +let runtime_url = require("core.url"); + +let as_string = common.as_string; +let bool_option = common.bool_option; +let list_option = common.list_option; +let object_or_empty = common.object_or_empty; +let option = common.option; +let read_json_file = common.read_json_file; + +const DNS_FAILOVER_STATE_FILE = getenv("TACHYON_DNS_FAILOVER_STATE_FILE") || "/var/run/tachyon/dns-failover.json"; +const DNS_HEALTH_ADDRESS = getenv("TACHYON_DNS_HEALTH_ADDRESS") || "127.0.0.42"; +const DNS_HEALTH_PORT_BASE = int(getenv("TACHYON_DNS_HEALTH_PORT_BASE") || "10053"); + +function server_list(settings, key, fallback) { + let result = []; + for (let value in list_option(settings, key)) { + value = trim(as_string(value)); + if (value != "") + push(result, value); + } + if (length(result) == 0) + push(result, fallback); + return result; +} + +function arrays_equal(left, right) { + if (length(left || []) != length(right || [])) + return false; + for (let i = 0; i < length(left); i++) + if (as_string(left[i]) != as_string(right[i])) + return false; + return true; +} + +function detour_tag(settings) { + if (!bool_option(settings, "dns_detour_enabled", false)) + return ""; + let section_name = option(settings, "dns_detour_section", ""); + return section_name == "" ? "" : runtime_constants.outbound_tag(section_name); +} + +function state_template(settings) { + return { + version: 1, + dns_type: option(settings, "dns_type", "udp"), + dns_detour: detour_tag(settings), + main_servers: server_list(settings, "dns_server", "77.88.8.8"), + bootstrap_servers: server_list(settings, "bootstrap_dns_server", "77.88.8.8"), + main_index: 0, + bootstrap_index: 0 + }; +} + +function state_matches(template, state) { + state = object_or_empty(state); + return int(state.version || 0) == 1 && + as_string(state.dns_type) == template.dns_type && + as_string(state.dns_detour) == template.dns_detour && + arrays_equal(state.main_servers, template.main_servers) && + arrays_equal(state.bootstrap_servers, template.bootstrap_servers); +} + +function bounded_index(value, values) { + let index_value = int(value || 0); + return index_value >= 0 && index_value < length(values) ? index_value : 0; +} + +function normalize_state(settings, state) { + let result = state_template(settings); + if (!state_matches(result, state)) + return result; + + result.main_index = bounded_index(object_or_empty(state).main_index, result.main_servers); + result.bootstrap_index = bounded_index(object_or_empty(state).bootstrap_index, result.bootstrap_servers); + return result; +} + +function runtime_state(settings, override_state) { + let state = override_state; + if (state == null) + state = read_json_file(DNS_FAILOVER_STATE_FILE); + return normalize_state(settings, state); +} + +function active_values(settings, override_state) { + let state = runtime_state(settings, override_state); + return { + state, + main: state.main_servers[state.main_index], + bootstrap: state.bootstrap_servers[state.bootstrap_index] + }; +} + +function server_from_options(tag_name, dns_type, dns_server, detour) { + let server = runtime_url.host(dns_server); + let port = runtime_url.port(dns_server); + let result = { + type: "udp", + tag: tag_name, + server, + server_port: 53 + }; + + if (dns_type == "udp") { + if (port != "") + result.server_port = int(port, 10); + } + else if (dns_type == "dot") { + result.type = "tls"; + result.server_port = port != "" ? int(port, 10) : 853; + } + else if (dns_type == "doh") { + result.type = "https"; + result.server_port = port != "" ? int(port, 10) : 443; + let path = runtime_url.path(dns_server); + if (path != "") + result.path = path; + } + else { + return { unsupported: "unsupported dns_type " + dns_type }; + } + + if (!core_ip.valid_ip(server)) + result.domain_resolver = runtime_constants.BOOTSTRAP_DNS_SERVER_TAG; + if (as_string(detour) != "") + result.detour = as_string(detour); + + return result; +} + +function bootstrap_server(tag_name, value) { + let server = runtime_url.host(value); + let port = runtime_url.port(value); + return { + type: "udp", + tag: tag_name, + server: server != "" ? server : value, + server_port: port != "" ? int(port, 10) : 53 + }; +} + +function server_config(settings, override_state) { + let active = active_values(settings, override_state); + return server_from_options( + runtime_constants.DNS_SERVER_TAG, + active.state.dns_type, + active.main, + active.state.dns_detour + ); +} + +function bootstrap_config(settings, override_state) { + let active = active_values(settings, override_state); + return bootstrap_server(runtime_constants.BOOTSTRAP_DNS_SERVER_TAG, active.bootstrap); +} + +function failover_enabled(settings) { + let state = state_template(settings); + return length(state.main_servers) > 1 || length(state.bootstrap_servers) > 1; +} + +function health_tag(kind, index_value, suffix) { + return "dns-health-" + as_string(kind) + "-" + as_string(index_value + 1) + "-" + as_string(suffix); +} + +function health_port(kind, index_value) { + if (kind == "active") + return DNS_HEALTH_PORT_BASE + 2000; + return DNS_HEALTH_PORT_BASE + int(index_value) * 2 + (kind == "bootstrap" ? 1 : 0); +} + +function add_active_health_inbound(result) { + let inbound_tag = "dns-health-active-main-in"; + push(result.inbounds, { + type: "direct", + tag: inbound_tag, + listen: DNS_HEALTH_ADDRESS, + listen_port: health_port("active", 0) + }); + push(result.rules, { + action: "route", + inbound: inbound_tag, + server: runtime_constants.DNS_SERVER_TAG, + disable_cache: true + }); + push(result.sniff_inbounds, inbound_tag); +} + +function add_health_candidate(result, kind, index_value, server) { + let server_tag = health_tag(kind, index_value, "server"); + let inbound_tag = health_tag(kind, index_value, "in"); + let dns_server = kind == "main" + ? server_from_options(server_tag, result.state.dns_type, server, result.state.dns_detour) + : bootstrap_server(server_tag, server); + + if (dns_server.unsupported) { + result.unsupported = dns_server.unsupported; + return; + } + + push(result.servers, dns_server); + push(result.inbounds, { + type: "direct", + tag: inbound_tag, + listen: DNS_HEALTH_ADDRESS, + listen_port: health_port(kind, index_value) + }); + push(result.rules, { + action: "route", + inbound: inbound_tag, + server: server_tag, + disable_cache: true + }); + push(result.sniff_inbounds, inbound_tag); +} + +function config(settings, override_state) { + let state = runtime_state(settings, override_state); + let main = server_config(settings, state); + if (main.unsupported) + return { unsupported: main.unsupported }; + + let result = { + state, + servers: [ bootstrap_config(settings, state), main ], + inbounds: [], + rules: [], + sniff_inbounds: [] + }; + + if (length(state.main_servers) > 1 || length(state.bootstrap_servers) > 1) + add_active_health_inbound(result); + + if (length(state.main_servers) > 1) + for (let i = 0; i < length(state.main_servers); i++) + add_health_candidate(result, "main", i, state.main_servers[i]); + + if (length(state.bootstrap_servers) > 1) + for (let i = 0; i < length(state.bootstrap_servers); i++) + add_health_candidate(result, "bootstrap", i, state.bootstrap_servers[i]); + + return result; +} + +function default_domain_resolver(settings) { + return bool_option(settings, "dns_detour_enabled", false) + ? runtime_constants.BOOTSTRAP_DNS_SERVER_TAG + : runtime_constants.DNS_SERVER_TAG; +} + +return { + DNS_FAILOVER_STATE_FILE, + DNS_HEALTH_ADDRESS, + active_values, + arrays_equal, + bootstrap_config, + config, + default_domain_resolver, + detour_tag, + failover_enabled, + health_port, + normalize_state, + runtime_state, + server_config, + server_from_options, + server_list, + state_matches, + state_template +}; diff --git a/tachyon/files/usr/lib/singbox/dns_failover.uc b/tachyon/files/usr/lib/singbox/dns_failover.uc new file mode 100644 index 00000000..e2a7cd28 --- /dev/null +++ b/tachyon/files/usr/lib/singbox/dns_failover.uc @@ -0,0 +1,379 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); +let common = require("core.common"); +let core_ip = require("core.ip"); +let runtime_dns = require("singbox.dns"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const STATE_FILE = getenv("TACHYON_DNS_FAILOVER_STATE_FILE") || RUNTIME_STATE_DIR + "/dns-failover.json"; +const PID_FILE = getenv("TACHYON_DNS_FAILOVER_PID_FILE") || RUNTIME_STATE_DIR + "/dns-failover.pid"; +const DNS_FAILOVER_UC = getenv("TACHYON_DNS_FAILOVER_UC") || LIB_DIR + "/singbox/dns_failover.uc"; +const SERVICE_BIN = getenv("TACHYON_BIN") || "/usr/bin/tachyon"; +const CHECK_DOMAIN = "example.com"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let result = []; + for (let arg in args) + push(result, shell_quote(arg)); + return join(" ", result); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success_from_args(args) { + return command_status(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_output_from_args(args) { + let pipe = fs.popen(command_from_args(args), "r"); + if (!pipe) + return ""; + let data = pipe.read("all"); + let status = pipe.close(); + return status == 0 && data != null ? as_string(data) : ""; +} + +function settings() { + return common.object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function ensure_dir(path) { + return command_success_from_args([ "mkdir", "-p", path ]); +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function write_state(path, value) { + if (!ensure_dir(RUNTIME_STATE_DIR)) + return false; + let stamp = clock(); + let temporary = as_string(path) + ".tmp." + as_string(stamp[0]) + "." + as_string(stamp[1]); + if (!common.write_json_file(temporary, value)) + return false; + if (!fs.rename(temporary, path)) { + remove_file(temporary); + return false; + } + return true; +} + +function log_message(message, level) { + command_success_from_args([ "logger", "-t", "tachyon", "[" + as_string(level || "info") + "] DNS failover: " + as_string(message) ]); +} + +function duration_milliseconds(value, fallback) { + let rest = as_string(value); + let total = 0.0; + let units = { ns: 0.000001, us: 0.001, ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 }; + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return fallback; + total += (matched[1] * 1) * units[matched[3]]; + rest = substr(rest, length(matched[0])); + } + return total > 0 ? int(total + 0.5) : fallback; +} + +function duration_seconds(value, fallback) { + let milliseconds = duration_milliseconds(value, fallback * 1000); + return int((milliseconds + 999) / 1000); +} + +function probe_timeout(settings_value) { + return duration_seconds(settings_value, 2); +} + +function probe_port(kind, index_value, timeout_seconds) { + let args = [ + "dig", "-p", as_string(runtime_dns.health_port(kind, index_value)), + "@" + runtime_dns.DNS_HEALTH_ADDRESS, + CHECK_DOMAIN, "A", "+short", + "+timeout=" + as_string(timeout_seconds), "+tries=1" + ]; + for (let line in split(command_output_from_args(args), "\n")) + if (core_ip.valid_ipv4(trim(as_string(line)))) + return true; + return false; +} + +function probe_canonical_main(timeout_seconds) { + let args = [ + "dig", "-p", as_string(runtime_dns.health_port("active", 0)), + "@" + runtime_dns.DNS_HEALTH_ADDRESS, CHECK_DOMAIN, "A", "+short", + "+timeout=" + as_string(timeout_seconds), "+tries=1" + ]; + for (let line in split(command_output_from_args(args), "\n")) + if (core_ip.valid_ipv4(trim(as_string(line)))) + return true; + return false; +} + +function verification_plan(previous, candidate) { + previous = common.object_or_empty(previous); + candidate = common.object_or_empty(candidate); + return { + main: int(candidate.main_index || 0) != int(previous.main_index || 0), + bootstrap: int(candidate.bootstrap_index || 0) != int(previous.bootstrap_index || 0) && + length(candidate.bootstrap_servers || []) > 1 + }; +} + +function verify_state(path) { + let cfg = settings(); + let state = common.read_json_file(path); + let expected = runtime_dns.state_template(cfg); + if (!runtime_dns.state_matches(expected, state)) + return false; + + state = runtime_dns.normalize_state(cfg, state); + let previous = runtime_dns.normalize_state(cfg, common.read_json_file(STATE_FILE)); + let plan = verification_plan(previous, state); + let timeout_seconds = probe_timeout(common.option(cfg, "dns_check_timeout", "2s")); + if (plan.main && !probe_canonical_main(timeout_seconds)) + return false; + if (plan.bootstrap && !probe_port("bootstrap", state.bootstrap_index, timeout_seconds)) + return false; + return true; +} + +function commit_state(path) { + let cfg = settings(); + let state = common.read_json_file(path); + if (!runtime_dns.state_matches(runtime_dns.state_template(cfg), state)) + return false; + return fs.rename(path, STATE_FILE); +} + +function now_seconds() { + return int(clock()[0]); +} + +function choose_index(kind, state, current_index, timeout_seconds, recovery) { + let values = kind == "main" ? state.main_servers : state.bootstrap_servers; + if (length(values) <= 1) + return { index: 0, reason: "single", alive: true }; + + if (recovery && current_index > 0) { + for (let i = 0; i < current_index; i++) + if (probe_port(kind, i, timeout_seconds)) + return { index: i, reason: "recovery", alive: true }; + return { index: current_index, reason: "unchanged", alive: true }; + } + + if (probe_port(kind, current_index, timeout_seconds)) + return { index: current_index, reason: "alive", alive: true }; + + for (let i = 0; i < length(values); i++) { + if (i == current_index) + continue; + if (probe_port(kind, i, timeout_seconds)) + return { index: i, reason: i < current_index ? "recovery" : "active_dead", alive: true }; + } + return { index: current_index, reason: "all_down", alive: false }; +} + +function clone_state(value) { + return json(sprintf("%J", value)); +} + +function apply_selections(state, selections) { + let candidate = clone_state(state); + let changed = []; + for (let kind in [ "bootstrap", "main" ]) { + let selected = common.object_or_empty(common.object_or_empty(selections)[kind]); + if (selected.index == null) + continue; + let key = kind == "main" ? "main_index" : "bootstrap_index"; + if (int(candidate[key]) == int(selected.index)) + continue; + candidate[key] = int(selected.index); + push(changed, { kind, selected }); + } + if (length(changed) == 0) + return true; + + let stamp = clock(); + let candidate_path = STATE_FILE + ".candidate." + as_string(stamp[0]) + "." + as_string(stamp[1]); + if (!write_state(candidate_path, candidate)) + return false; + + let status = command_status(command_from_args([ SERVICE_BIN, "dns_failover_apply", candidate_path ]) + " >/dev/null 2>&1"); + remove_file(candidate_path); + if (status != 0) + return false; + + state.main_index = candidate.main_index; + state.bootstrap_index = candidate.bootstrap_index; + for (let item in changed) { + let values = item.kind == "main" ? state.main_servers : state.bootstrap_servers; + let selected = item.selected; + log_message("switched " + item.kind + " DNS to priority " + as_string(selected.index + 1) + " (" + values[selected.index] + ", " + selected.reason + ")", "info"); + } + return true; +} + +function worker() { + let cfg = settings(); + let state = runtime_dns.normalize_state(cfg, common.read_json_file(STATE_FILE)); + if (length(state.main_servers) <= 1 && length(state.bootstrap_servers) <= 1) + return 0; + if (!write_state(STATE_FILE, state)) + return 1; + + let active_interval = duration_seconds(common.option(cfg, "dns_check_interval", "10s"), 10); + let recovery_interval = duration_seconds(common.option(cfg, "dns_recovery_check_interval", "60s"), 60); + let timeout_seconds = probe_timeout(common.option(cfg, "dns_check_timeout", "2s")); + let next_active = now_seconds(); + let next_recovery = now_seconds() + recovery_interval; + let all_down = { main: false, bootstrap: false }; + + while (true) { + let now = now_seconds(); + let current_cfg = settings(); + if (!runtime_dns.state_matches(runtime_dns.state_template(current_cfg), state)) + return 0; + + if (now >= next_active) { + let bootstrap = choose_index("bootstrap", state, int(state.bootstrap_index), timeout_seconds, false); + if (!bootstrap.alive && !all_down.bootstrap) + log_message("all configured bootstrap DNS servers are unavailable", "warn"); + all_down.bootstrap = !bootstrap.alive; + + if (bootstrap.index != int(state.bootstrap_index)) { + let main = choose_index("main", state, int(state.main_index), timeout_seconds, false); + let selections = { bootstrap }; + if (main.alive) + selections.main = main; + let applied = apply_selections(state, selections); + next_active = now_seconds() + (applied && !main.alive ? 1 : active_interval); + } + else { + let main = choose_index("main", state, int(state.main_index), timeout_seconds, false); + if (!main.alive && !all_down.main) + log_message("all configured main DNS servers are unavailable", "warn"); + all_down.main = !main.alive; + apply_selections(state, { main }); + next_active = now_seconds() + active_interval; + } + } + + if (now >= next_recovery) { + let bootstrap = choose_index("bootstrap", state, int(state.bootstrap_index), timeout_seconds, true); + let main = choose_index("main", state, int(state.main_index), timeout_seconds, true); + let has_changes = bootstrap.index != int(state.bootstrap_index) || main.index != int(state.main_index); + let applied = apply_selections(state, { bootstrap, main }); + let completed = now_seconds(); + if (has_changes && applied) + next_active = completed + active_interval; + next_recovery = completed + recovery_interval; + } + + command_success_from_args([ "sleep", "1" ]); + } +} + +function file_first_line(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + return trim(split(as_string(data), "\n")[0]); +} + +function process_running(pid) { + return match(as_string(pid), /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function stop_runtime() { + let pid = file_first_line(PID_FILE); + if (process_running(pid)) + command_success_from_args([ "kill", pid ]); + remove_file(PID_FILE); + return 0; +} + +function start_runtime() { + let cfg = settings(); + stop_runtime(); + if (!runtime_dns.failover_enabled(cfg)) { + remove_file(STATE_FILE); + return 0; + } + if (!ensure_dir(RUNTIME_STATE_DIR)) + return 1; + + let command = command_from_args([ "ucode", "-L", LIB_DIR, DNS_FAILOVER_UC, "worker" ]) + + " >/dev/null 2>&1 1000>&- & echo $! >" + shell_quote(PID_FILE); + return command_status(command); +} + +function select_fixture(state_path, alive_path, kind, recovery) { + let state = common.object_or_empty(common.read_json_file(state_path)); + let alive = common.object_or_empty(common.read_json_file(alive_path)); + let key = kind == "main" ? "main_index" : "bootstrap_index"; + let values = kind == "main" ? state.main_servers : state.bootstrap_servers; + let current = int(state[key] || 0); + let selected = { index: current, reason: "all_down", alive: false }; + let probe = function(index_value) { return alive[as_string(index_value)] === true || alive[as_string(index_value)] == 1; }; + + if (as_string(recovery) == "1" && current > 0) { + for (let i = 0; i < current; i++) + if (probe(i)) { + selected = { index: i, reason: "recovery", alive: true }; + break; + } + } + else if (probe(current)) { + selected = { index: current, reason: "alive", alive: true }; + } + else { + for (let i = 0; i < length(values); i++) + if (i != current && probe(i)) { + selected = { index: i, reason: i < current ? "recovery" : "active_dead", alive: true }; + break; + } + } + common.write_json(selected); +} + +let mode = ARGV[0] || ""; + +if (mode == "start-runtime") + exit(start_runtime()); +else if (mode == "stop-runtime") + exit(stop_runtime()); +else if (mode == "worker") + exit(worker()); +else if (mode == "verify-state") + exit(verify_state(ARGV[1]) ? 0 : 1); +else if (mode == "commit-state") + exit(commit_state(ARGV[1]) ? 0 : 1); +else if (mode == "select-fixture") + select_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "verification-plan-fixture") + common.write_json(verification_plan(common.read_json_file(ARGV[1]), common.read_json_file(ARGV[2]))); +else { + warn("Usage: singbox/dns_failover.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/singbox/generator.uc b/tachyon/files/usr/lib/singbox/generator.uc new file mode 100644 index 00000000..9eccbaa7 --- /dev/null +++ b/tachyon/files/usr/lib/singbox/generator.uc @@ -0,0 +1,3084 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); +let uci_core = require("core.uci"); +let runtime_constants = require("singbox.constants"); +let runtime_country = require("singbox.country"); +let runtime_dns = require("singbox.dns"); +let runtime_route = require("singbox.route"); +let runtime_rulesets = require("singbox.rulesets"); +let runtime_servers = require("singbox.servers"); +let runtime_subscription = require("singbox.subscription"); +let runtime_url = require("core.url"); +let runtime_urltest = require("singbox.urltest"); +let source_rulesets = require("routing.rulesets"); +let rule_config = require("config.rule"); +let connections = require("config.connections"); +let subscription_share_link = require("subscription.share_link"); +let uci = null; +let fixture_uci_data = null; +let runtime_settings_cache = null; +let runtime_ruleset_folder = runtime_constants.TMP_RULESET_FOLDER; +let runtime_supports_xhttp = true; + +let as_string = common.as_string; +let read_json_file = common.read_json_file; +let read_stdin = common.read_stdin; +let read_stdin_json = common.read_stdin_json; +let write_json = common.write_json; +let csv_to_json_array = common.csv_to_json_array; +let write_json_file = common.write_json_file; +let strip_internal_fields = common.strip_internal_fields; +let array_or_empty = common.array_or_empty; +let object_or_empty = common.object_or_empty; +let option = common.option; +let list_option = common.list_option; +let bool_option = common.bool_option; +let int_option = common.int_option; +let url_decode = runtime_url.decode; +let url_scheme = runtime_url.scheme; +let url_fragment = runtime_url.fragment; +let url_strip_fragment_value = runtime_url.strip_fragment; +let url_host = runtime_url.host; +let url_port = runtime_url.port; +let url_userinfo = runtime_url.userinfo; +let url_path = runtime_url.path; +let url_query_params = runtime_url.query_params; + +const CONFIG_NAME = "tachyon"; + +function parent_dir(path) { + path = as_string(path); + let slash = rindex(path, "/"); + return slash <= 0 ? "" : substr(path, 0, slash); +} + +function ensure_dir(path) { + path = as_string(path); + if (path == "" || path == "/") + return true; + if (fs.stat(path) != null) + return true; + + let parent = parent_dir(path); + if (parent != "" && !ensure_dir(parent)) + return false; + + return fs.mkdir(path, 0755) || fs.stat(path) != null; +} + +function ensure_parent_dir(path) { + return ensure_dir(parent_dir(path)); +} + +function atomic_write_json_file(path, value) { + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); + + if (!ensure_parent_dir(path)) + return false; + if (!write_json_file(tmp_path, value)) + return false; + if (!fs.rename(tmp_path, path)) { + fs.unlink(tmp_path); + return false; + } + return true; +} + +function fixture_section_list(type_name) { + let value = object_or_empty(fixture_uci_data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(fixture_uci_data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function fixture_get_section(section_name) { + let fixture = object_or_empty(fixture_uci_data); + if (section_name == "settings" && type(fixture.settings) == "object") + return fixture.settings; + + for (let type_name in [ "settings", "server", "section", "subscription_url", "section_interface", "urltest", "priority_group", "priority_level" ]) { + for (let section in fixture_section_list(type_name)) { + if (as_string(section[".name"]) == section_name) + return section; + } + } + + return {}; +} + +function fixture_cursor(path) { + fixture_uci_data = object_or_empty(read_json_file(path)); + connections.set_item_sections_from_data(fixture_uci_data); + return { + load: function(_config_name) { + return true; + }, + get_all: function(_config_name, section_name) { + return fixture_get_section(section_name); + }, + foreach: function(_config_name, type_name, callback) { + for (let section in fixture_section_list(type_name)) + callback(section); + } + }; +} + +function use_fixture_cursor(path) { + uci = fixture_cursor(path); + runtime_settings_cache = null; +} + +function runtime_uci_cursor() { + return { + load: function(package_name) { + return uci_core.load(package_name); + }, + get_all: function(package_name, section_name) { + return uci_core.get_all(package_name, section_name); + }, + foreach: function(package_name, type_name, callback) { + for (let section in uci_core.section_objects(package_name, type_name)) + callback(section); + } + }; +} + +function uci_cursor() { + if (uci == null) + uci = runtime_uci_cursor(); + return uci; +} + +function runtime_generate_unsupported(reason) { + warn(reason, "\n"); + exit(2); +} + +function valid_section_name(name) { + return match(name, /^[A-Za-z0-9_]+$/); +} + +function section_enabled(section) { + return bool_option(section, "enabled", true); +} + +function runtime_settings() { + if (runtime_settings_cache == null) + runtime_settings_cache = object_or_empty(uci_cursor().get_all(CONFIG_NAME, "settings")); + return runtime_settings_cache; +} + +function settings_update_interval() { + let settings = runtime_settings(); + if (!bool_option(settings, "list_update_enabled", true)) + return ""; + + let update_interval = option(settings, "update_interval", "1d"); + return update_interval != "" ? update_interval : "1d"; +} + +function remote_ruleset_update_interval() { + let update_interval = settings_update_interval(); + return update_interval != "" ? update_interval : runtime_constants.DISABLED_UPDATE_INTERVAL; +} + +function internal_flag(value) { + return value === true || value == 1 || value == "1" || value == "true" || value == "yes"; +} + +function subscription_group_outbound(outbound) { + if (type(outbound) != "object") + return false; + let t = as_string(outbound.type); + return (t == "selector" || t == "urltest") && internal_flag(outbound.__tachyon_allow_group); +} + +function subscription_urltest_group_outbound(outbound) { + if (type(outbound) != "object") + return false; + return as_string(outbound.type || "") == "urltest" && internal_flag(outbound.__tachyon_allow_group); +} + +function subscription_outbound_tag(outbound) { + return type(outbound) == "object" ? as_string(outbound.tag || "") : ""; +} + +function subscription_visibility_refs(outbounds) { + let refs = { + urltest: {}, + detour: {} + }; + + for (let outbound in array_or_empty(outbounds)) { + if (type(outbound) != "object") + continue; + + if (subscription_urltest_group_outbound(outbound)) { + for (let tag_name in array_or_empty(outbound.outbounds)) { + tag_name = as_string(tag_name); + if (tag_name != "") + refs.urltest[tag_name] = true; + } + } + + let detour = as_string(outbound.detour || ""); + if (detour != "") + refs.detour[detour] = true; + } + + return refs; +} + +function subscription_hidden_outbound(outbound, refs, hide_urltest_group_outbounds, hide_detour_outbounds) { + if (type(outbound) != "object") + return false; + + let tag_name = subscription_outbound_tag(outbound); + let urltest_refs = object_or_empty(object_or_empty(refs).urltest); + let detour_refs = object_or_empty(object_or_empty(refs).detour); + let hidden_by_urltest = tag_name != "" && urltest_refs[tag_name]; + let hidden_by_detour = tag_name != "" && detour_refs[tag_name]; + + if (hidden_by_urltest && hide_urltest_group_outbounds !== false) + return true; + if (hidden_by_detour && hide_detour_outbounds !== false) + return true; + return internal_flag(outbound.__tachyon_hidden) && !hidden_by_urltest && !hidden_by_detour; +} + +function tag(base, postfix) { + return runtime_constants.tag(base, postfix); +} + +function outbound_tag(section_name) { + return runtime_constants.outbound_tag(section_name); +} + +function download_via_proxy_section_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy_section"; + if (purpose == "components") + return "download_components_via_proxy_section"; + return ""; +} + +function download_via_proxy_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy"; + if (purpose == "components") + return "download_components_via_proxy"; + return ""; +} + +function download_via_proxy_section(settings, purpose) { + let enabled_option = download_via_proxy_option_for_purpose(purpose); + if (enabled_option == "" || !bool_option(settings, enabled_option, false)) + return ""; + + let section_option = download_via_proxy_section_option_for_purpose(purpose); + let configured = section_option != "" ? option(settings, section_option, "") : ""; + if (configured != "") + return configured; + + return option(settings, "download_lists_via_proxy_section", ""); +} + +function download_via_proxy_enabled(settings, purpose) { + let enabled_option = download_via_proxy_option_for_purpose(purpose); + return enabled_option != "" && bool_option(settings, enabled_option, false); +} + +function download_via_proxy_any_enabled(settings, sections) { + return download_via_proxy_enabled(settings, "lists") || + download_via_proxy_enabled(settings, "components") || + length(connections.subscription_download_targets(sections || [])) > 0; +} + +function download_detour_tag(settings, purpose) { + let section_name = download_via_proxy_section(settings, purpose); + return section_name == "" ? "" : outbound_tag(section_name); +} + +function ruleset_tag(section_name, name, kind) { + kind = as_string(kind); + return kind == "" + ? section_name + "-" + name + "-ruleset" + : section_name + "-" + name + "-" + kind + "-ruleset"; +} + +function ruleset_registered(config, tag_name) { + for (let rule_set in array_or_empty(config.route && config.route.rule_set)) { + if (type(rule_set) == "object" && rule_set.tag == tag_name) + return true; + } + return false; +} + +function ensure_custom_ruleset(config, reference) { + let tag_name; + let kind = runtime_rulesets.kind_from_reference_hint(reference); + + if (runtime_rulesets.is_community(reference)) { + tag_name = "builtin-" + reference + "-ruleset"; + kind = "domains"; + if (!ruleset_registered(config, tag_name)) { + let rule_set = { + type: "remote", + tag: tag_name, + format: "binary", + url: runtime_rulesets.community_url(reference) + }; + let detour = download_detour_tag(runtime_settings()); + if (detour != "") + rule_set.download_detour = detour; + rule_set.update_interval = remote_ruleset_update_interval(); + push(config.route.rule_set, rule_set); + } + return { tag: tag_name, kind }; + } + + tag_name = "inline-custom-" + runtime_rulesets.hash12(reference) + "-ruleset"; + if (kind == "unknown") + kind = "domains"; + if (ruleset_registered(config, tag_name)) + return { tag: tag_name, kind }; + + let extension = runtime_rulesets.file_extension(reference); + if (substr(reference, 0, 1) == "/") { + if (extension != "srs" && extension != "json") + runtime_generate_unsupported("local rule_set extension is not supported by sing-box config generation"); + push(config.route.rule_set, { + type: "local", + tag: tag_name, + format: extension == "json" ? "source" : "binary", + path: reference + }); + } + else if (substr(reference, 0, 7) == "http://" || substr(reference, 0, 8) == "https://") { + let rule_set = { + type: "remote", + tag: tag_name, + format: runtime_rulesets.remote_format(reference), + url: reference + }; + let detour = download_detour_tag(runtime_settings()); + if (detour != "") + rule_set.download_detour = detour; + rule_set.update_interval = remote_ruleset_update_interval(); + push(config.route.rule_set, rule_set); + } + else { + runtime_generate_unsupported("rule_set reference is not supported by sing-box config generation"); + } + + return { tag: tag_name, kind }; +} + +function clash_api_config(settings, service_address) { + let controller = as_string(service_address || ""); + if (bool_option(settings, "enable_yacd", false) && bool_option(settings, "enable_yacd_wan_access", false)) + controller = "0.0.0.0"; + else if (controller == "") + controller = "127.0.0.1"; + + let result = { + external_controller: controller + ":9090" + }; + if (bool_option(settings, "enable_yacd", false)) { + result.external_ui = "ui"; + let secret = option(settings, "yacd_secret_key", ""); + if (secret != "") + result.secret = secret; + } + return result; +} + +function cli_bool(value) { + return value === true || value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function tproxy_inbound_matcher() { + return [ runtime_constants.TPROXY_INBOUND_TAG, runtime_constants.TPROXY_INBOUND6_TAG ]; +} + +function base_config(settings, service_address, runtime_context) { + let log_level = option(settings, "log_level", "warn"); + let rewrite_ttl = int_option(settings, "dns_rewrite_ttl", "60"); + let cache_path = option(settings, "cache_path", "/tmp/sing-box/cache.db"); + let dns_config = runtime_dns.config(settings); + if (dns_config.unsupported) + runtime_generate_unsupported(dns_config.unsupported); + + let dns_rules = []; + for (let rule in dns_config.rules) + push(dns_rules, rule); + for (let rule in [ + { action: "reject", query_type: "HTTPS" }, + { action: "reject", domain_suffix: "use-application-dns.net" }, + { + action: "route", + server: runtime_constants.FAKEIP_DNS_SERVER_TAG, + rewrite_ttl, + domain: [ runtime_constants.FAKEIP_TEST_DOMAIN, runtime_constants.CHECK_PROXY_IP_DOMAIN ] + } + ]) + push(dns_rules, rule); + + let dns_servers = []; + for (let server in dns_config.servers) + push(dns_servers, server); + push(dns_servers, { + type: "fakeip", + tag: runtime_constants.FAKEIP_DNS_SERVER_TAG, + inet4_range: runtime_constants.FAKEIP_INET4_RANGE, + inet6_range: runtime_constants.FAKEIP_INET6_RANGE + }); + + let inbounds = [ + { type: "tproxy", tag: runtime_constants.TPROXY_INBOUND_TAG, listen: runtime_constants.TPROXY_INBOUND_ADDRESS, listen_port: runtime_constants.TPROXY_INBOUND_PORT, tcp_fast_open: true, udp_fragment: true }, + { type: "tproxy", tag: runtime_constants.TPROXY_INBOUND6_TAG, listen: runtime_constants.TPROXY_INBOUND6_ADDRESS, listen_port: runtime_constants.TPROXY_INBOUND_PORT, tcp_fast_open: true, udp_fragment: true }, + { type: "direct", tag: runtime_constants.DNS_INBOUND_TAG, listen: runtime_constants.DNS_INBOUND_ADDRESS, listen_port: runtime_constants.DNS_INBOUND_PORT } + ]; + for (let inbound in dns_config.inbounds) + push(inbounds, inbound); + + runtime_context = object_or_empty(runtime_context); + runtime_context.dns_health_inbounds = dns_config.sniff_inbounds; + runtime_context.default_domain_resolver = runtime_dns.default_domain_resolver(settings); + + return { + log: { + disabled: false, + level: log_level, + timestamp: false + }, + dns: { + servers: dns_servers, + rules: dns_rules, + final: runtime_constants.DNS_SERVER_TAG, + strategy: option(settings, "dns_strategy", "prefer_ipv4"), + independent_cache: true + }, + ntp: {}, + certificate: {}, + endpoints: [], + inbounds, + outbounds: [ + { type: "direct", tag: runtime_constants.DIRECT_OUTBOUND_TAG }, + { type: "direct", tag: runtime_constants.BYPASS_OUTBOUND_TAG } + ], + route: runtime_route.config(settings, runtime_context), + services: [], + experimental: { + cache_file: { + enabled: true, + path: cache_path, + store_fakeip: true + }, + clash_api: clash_api_config(settings, service_address) + } + }; +} + +function supported_subscription_outbound(outbound) { + if (type(outbound) != "object") + return false; + let t = as_string(outbound.type); + if (subscription_group_outbound(outbound)) + return true; + if (t == "direct" || t == "selector" || t == "urltest" || t == "dns" || t == "block") + return false; + return t == "vless" || t == "vmess" || t == "trojan" || t == "shadowsocks" || + t == "socks" || t == "http" || t == "hysteria2"; +} + +function outbound_uses_xhttp(outbound) { + return type(outbound) == "object" && type(outbound.transport) == "object" && + lc(as_string(outbound.transport.type || "")) == "xhttp"; +} + +function ensure_explicit_outbound_supported(outbound, source, name) { + if (!runtime_supports_xhttp && outbound_uses_xhttp(outbound)) + runtime_generate_unsupported(as_string(source) + " '" + as_string(name) + "' uses XHTTP transport, but sing-box-extended is not installed"); +} + +function subscription_outbound_display_name(outbound) { + return type(outbound) == "object" + ? as_string(outbound.remark || outbound.tag || "unknown") + : "unknown"; +} + +function add_subscription_reference(refs, value) { + value = as_string(value); + if (value != "") + refs[value] = true; +} + +function subscription_reference_set(outbounds) { + let refs = {}; + for (let outbound in array_or_empty(outbounds)) { + if (type(outbound) != "object") + continue; + add_subscription_reference(refs, outbound.tag); + add_subscription_reference(refs, outbound.remark); + } + return refs; +} + +function subscription_reference_available(reference, source_refs, retained_refs) { + reference = as_string(reference); + return reference == "" || !source_refs[reference] || retained_refs[reference]; +} + +function subscription_group_has_retained_member(outbound, retained_refs) { + for (let reference in array_or_empty(outbound.outbounds)) + if (retained_refs[as_string(reference)]) + return true; + return false; +} + +function warn_skipped_subscription_outbound(section_name, outbound, reason) { + warn("skipped incompatible subscription outbound for rule '", section_name, "': ", + subscription_outbound_display_name(outbound), " (", reason, ")\n"); +} + +function compatible_subscription_outbounds(outbounds, section_name) { + let source_refs = subscription_reference_set(outbounds); + let retained = []; + for (let outbound in array_or_empty(outbounds)) { + if (!supported_subscription_outbound(outbound)) + continue; + if (!runtime_supports_xhttp && outbound_uses_xhttp(outbound)) { + warn_skipped_subscription_outbound(section_name, outbound, "XHTTP requires sing-box-extended"); + continue; + } + push(retained, outbound); + } + + while (true) { + let retained_refs = subscription_reference_set(retained); + let next = []; + let changed = false; + for (let outbound in retained) { + let detour = as_string(outbound.detour || ""); + if (!subscription_reference_available(detour, source_refs, retained_refs)) { + warn_skipped_subscription_outbound(section_name, outbound, + "detour depends on unavailable outbound '" + detour + "'"); + changed = true; + continue; + } + if (subscription_group_outbound(outbound) && + !subscription_group_has_retained_member(outbound, retained_refs)) { + warn_skipped_subscription_outbound(section_name, outbound, "group has no compatible outbounds"); + changed = true; + continue; + } + push(next, outbound); + } + retained = next; + if (!changed) + return retained; + } +} + +function copy_subscription_outbound(outbound, new_tag) { + let copy = {}; + for (let key, value in outbound) { + if (key != "tag" && key != "remark" && key != "share_link" && + key != "__tachyon_hidden" && key != "__tachyon_allow_group") + copy[key] = value; + } + if (as_string(copy.type || "") == "hysteria2" && + type(copy.tls) == "object" && + copy.tls.utls != null) { + let tls = {}; + for (let key, value in copy.tls) { + if (key != "utls") + tls[key] = value; + } + copy.tls = tls; + } + copy.tag = new_tag; + return copy; +} + +function string_array_contains(values, needle) { + for (let value in array_or_empty(values)) + if (as_string(value) == as_string(needle)) + return true; + return false; +} + +function rewrite_subscription_outbound_references(outbounds, tag_map, source_refs) { + for (let outbound in outbounds) { + if (type(outbound) != "object") + continue; + + let detour = as_string(outbound.detour || ""); + if (detour != "" && tag_map[detour]) + outbound.detour = tag_map[detour]; + else if (detour != "" && source_refs[detour]) + delete outbound.detour; + + if (type(outbound.outbounds) == "array") { + let rewritten = []; + for (let tag_name in outbound.outbounds) { + tag_name = as_string(tag_name); + if (tag_map[tag_name]) + push(rewritten, tag_map[tag_name]); + } + outbound.outbounds = rewritten; + + if (as_string(outbound.type || "") == "urltest") { + delete outbound.default; + } + else { + let default_tag = as_string(outbound.default || ""); + if (default_tag != "" && tag_map[default_tag]) + default_tag = tag_map[default_tag]; + if (default_tag == "" || !string_array_contains(rewritten, default_tag)) + default_tag = length(rewritten) > 0 ? rewritten[0] : ""; + if (default_tag != "") + outbound.default = default_tag; + else + delete outbound.default; + } + } + } +} + +function subscription_skip_summary(skipped) { + let parts = []; + for (let t in sort(keys(skipped))) + push(parts, skipped[t] + "x " + t); + return join("; ", parts); +} + +function reportable_skipped_subscription_type(t) { + return t != "direct" && t != "selector" && t != "urltest" && t != "dns" && t != "block"; +} + +function urltest_leaf_candidate_outbound(outbound) { + if (type(outbound) != "object") + return false; + + let t = lc(as_string(outbound.type || "")); + return t != "selector" && t != "urltest" && t != "dns" && t != "block"; +} + +function unique_tag(base, taken) { + base = as_string(base); + if (base == "") + base = "server"; + if (!taken[base]) + return base; + for (let i = 1; i < 100000; i++) { + let candidate = base + "-" + i; + if (!taken[candidate]) + return candidate; + } + return base + "-overflow"; +} + +function reserved_runtime_tag_set(outbounds) { + let result = {}; + for (let tag_name in keys(object_or_empty(runtime_constants.RESERVED_TAGS))) + result[tag_name] = true; + + for (let outbound in array_or_empty(outbounds)) { + if (type(outbound) != "object") + continue; + + let tag_name = as_string(outbound.tag || ""); + if (tag_name != "") + result[tag_name] = true; + } + return result; +} + +function assert_unique_outbound_tags(config) { + let seen = {}; + for (let outbound in array_or_empty(config.outbounds)) { + if (type(outbound) != "object") + runtime_generate_unsupported("generated sing-box outbound is not an object"); + + let tag_name = as_string(outbound.tag || ""); + if (tag_name == "") + runtime_generate_unsupported("generated sing-box outbound has an empty tag"); + if (seen[tag_name]) + runtime_generate_unsupported("generated sing-box config has duplicate outbound tag '" + tag_name + "'"); + seen[tag_name] = true; + } +} + +function add_subscription_source_with_state(config, section, source_index, source_entry, taken, selector_tags, urltest_candidate_tags, state, show_metadata, include_urltest_groups, hide_urltest_group_outbounds, hide_detour_outbounds, node_prefix) { + let section_name = section[".name"]; + let source_section = runtime_subscription.source_id(section_name, source_index); + if (!runtime_subscription.source_cache_is_current( + source_section, + source_entry, + connections.subscription_user_agent(section, source_entry), + connections.subscription_hwid(section, source_entry) + )) + return 0; + + let source_outbounds = runtime_subscription.read_source_outbounds(source_section); + if (length(source_outbounds) == 0) + return 0; + + let skipped = {}; + for (let outbound in source_outbounds) { + if (supported_subscription_outbound(outbound)) + continue; + let t = type(outbound) == "object" ? as_string(outbound.type || "missing-type") : "non-object"; + if (reportable_skipped_subscription_type(t)) + skipped[t] = (skipped[t] || 0) + 1; + } + let outbounds = compatible_subscription_outbounds(source_outbounds, section_name); + + if (show_metadata !== false) + runtime_subscription.merge_source_metadata(state, section_name, source_section, source_index, source_entry); + let visibility_refs = subscription_visibility_refs(outbounds); + if (include_urltest_groups === false) + hide_urltest_group_outbounds = false; + node_prefix = trim(as_string(node_prefix)); + let prepared = []; + let display_names = []; + let source_links = []; + let group_flags = []; + let hidden_flags = []; + let tag_map = {}; + for (let i = 0; i < length(outbounds); i++) { + let outbound = outbounds[i]; + if (include_urltest_groups === false && subscription_urltest_group_outbound(outbound)) + continue; + let display_name = as_string(outbound.remark || outbound.tag || ("server-" + (i + 1))); + let base = as_string(outbound.tag || outbound.remark || ("server-" + (i + 1))); + if (node_prefix != "") { + display_name = node_prefix + " " + display_name; + base = display_name; + } + let new_tag = unique_tag(base, taken); + taken[new_tag] = true; + tag_map[base] = new_tag; + if (as_string(outbound.tag || "") != "") + tag_map[as_string(outbound.tag)] = new_tag; + if (as_string(outbound.remark || "") != "") + tag_map[as_string(outbound.remark)] = new_tag; + push(prepared, copy_subscription_outbound(outbound, new_tag)); + push(display_names, display_name); + let source_link = as_string(outbound.share_link || ""); + if (!subscription_share_link.is_copyable_link(source_link)) + source_link = subscription_share_link.serialize_outbound_link(outbound); + push(source_links, source_link); + push(group_flags, subscription_group_outbound(outbound)); + push(hidden_flags, subscription_hidden_outbound(outbound, visibility_refs, hide_urltest_group_outbounds, hide_detour_outbounds)); + } + + if (length(keys(skipped)) > 0) + warn("skipped unsupported subscription outbounds for rule '", section_name, "': ", subscription_skip_summary(skipped), "\n"); + + rewrite_subscription_outbound_references(prepared, tag_map, subscription_reference_set(source_outbounds)); + let added = 0; + for (let i = 0; i < length(prepared); i++) { + let outbound = prepared[i]; + let is_group = group_flags[i] === true; + if (is_group && length(array_or_empty(outbound.outbounds)) == 0) { + warn("skipped empty subscription group for rule '", section_name, "': ", as_string(display_names[i] || outbound.tag || "unknown"), "\n"); + continue; + } + + push(config.outbounds, outbound); + added++; + if (!is_group) + push(urltest_candidate_tags, outbound.tag); + runtime_subscription.remember_source_outbound( + state, + outbound.tag, + display_names[i], + outbound, + source_links[i] + ); + if (hidden_flags[i] !== true) { + push(selector_tags, outbound.tag); + runtime_subscription.remember_urltest_group(state, outbound.tag, display_names[i], outbound); + } + } + return added; +} + +function duration_to_seconds(value) { + value = as_string(value); + if (value == "") + return null; + if (match(value, /^[0-9]+$/) != null) + return int(value, 10); + + let suffix = substr(value, length(value) - 1); + let number = substr(value, 0, length(value) - 1); + if (match(number, /^[0-9]+$/) == null) + return null; + + let multiplier = null; + if (suffix == "s") + multiplier = 1; + else if (suffix == "m") + multiplier = 60; + else if (suffix == "h") + multiplier = 3600; + else if (suffix == "d") + multiplier = 86400; + + return multiplier == null ? null : int(number, 10) * multiplier; +} + +function urltest_check_interval(section, urltest_id) { + let interval = connections.urltest_check_interval(section, urltest_id); + return interval != "" ? interval : "3m"; +} + +function legacy_urltest_idle_timeout(section, urltest_id) { + if (urltest_id != "urltest") + return ""; + + let settings = connections.urltest_settings(section, urltest_id); + if (type(settings) == "object" && as_string(settings[".type"] || "") == "urltest") + return ""; + + let interval = urltest_check_interval(section, urltest_id); + let interval_seconds = duration_to_seconds(interval); + let default_idle_seconds = duration_to_seconds(runtime_constants.URLTEST_DEFAULT_IDLE_TIMEOUT); + return interval_seconds != null && interval_seconds > default_idle_seconds ? interval : ""; +} + +function urltest_idle_timeout(section, urltest_id) { + let configured = connections.urltest_idle_timeout(section, urltest_id); + return configured != "" ? configured : legacy_urltest_idle_timeout(section, urltest_id); +} + +function supported_urltest_filter_mode(mode) { + return mode == "include" || mode == "exclude" || mode == "mixed"; +} + +function filter_mode_uses_include(mode) { + return mode == "include" || mode == "mixed"; +} + +function filter_mode_uses_exclude(mode) { + return mode == "exclude" || mode == "mixed"; +} + +function configured_country_filter(mode, include_countries, exclude_countries) { + return (filter_mode_uses_include(mode) && length(array_or_empty(include_countries)) > 0) || + (filter_mode_uses_exclude(mode) && length(array_or_empty(exclude_countries)) > 0); +} + +function section_needs_country_is(section) { + let dashboard_mode = connections.dashboard_filter_mode(section); + if (connections.dashboard_detect_server_country(section) == "country_is" && + configured_country_filter( + dashboard_mode, + connections.dashboard_include_countries(section), + connections.dashboard_exclude_countries(section) + )) + return true; + + for (let urltest_id in connections.urltests(section)) { + let mode = connections.urltest_filter_mode(section, urltest_id); + if (connections.urltest_detect_server_country(section, urltest_id) == "country_is" && + configured_country_filter( + mode, + connections.urltest_include_countries(section, urltest_id), + connections.urltest_exclude_countries(section, urltest_id) + )) + return true; + } + + for (let group_id in connections.priority_groups(section)) { + for (let level_id in connections.priority_levels(group_id)) { + if (connections.priority_level_direct(group_id, level_id)) + continue; + let mode = connections.priority_level_filter_mode(group_id, level_id); + if (connections.priority_level_detect_server_country(group_id, level_id) == "country_is" && + configured_country_filter( + mode, + connections.priority_level_include_countries(group_id, level_id), + connections.priority_level_exclude_countries(group_id, level_id) + )) + return true; + } + } + return false; +} + +function section_has_direct_priority_level(section) { + for (let group_id in connections.priority_groups(section)) + for (let level_id in connections.priority_levels(group_id)) + if (connections.priority_level_direct(group_id, level_id)) + return true; + return false; +} + +function urltest_country_metadata(section, urltest_id, state) { + let metadata = object_or_empty(object_or_empty(state.outboundMetadata).countries); + let detect_method = connections.urltest_detect_server_country(section, urltest_id); + if (detect_method == "flag_emoji") + return runtime_urltest.countries_from_flag_names(object_or_empty(object_or_empty(state.outboundMetadata).names)); + return metadata; +} + +function array_contains(values, needle) { + for (let value in array_or_empty(values)) { + if (value == needle) + return true; + } + return false; +} + +function unique_string_array(values) { + let result = []; + let seen = {}; + for (let value in array_or_empty(values)) { + value = as_string(value); + if (value == "" || seen[value]) + continue; + seen[value] = true; + push(result, value); + } + return result; +} + +function object_keys_set(values) { + let result = {}; + for (let value in array_or_empty(values)) + result[value] = true; + return result; +} + +function tag_display_name(tag, names) { + let name = as_string(object_or_empty(names)[tag] || ""); + return name != "" ? name : tag; +} + +function regex_match_set(tags, names, regexes) { + return object_keys_set(runtime_urltest.regex_matching_tag_array(tags, names, regexes)); +} + +function tag_name_filter_matches(tag, names, name_filter, regex_set) { + let name = tag_display_name(tag, names); + return array_contains(name_filter, name) || regex_set[tag]; +} + +function tag_country_filter_matches(tag, countries, country_filter) { + let country = uc(as_string(object_or_empty(countries)[tag] || "")); + return country != "" && array_contains(country_filter, country); +} + +function tag_attribute_filter_matches(tag, metadata, selected_values) { + selected_values = array_or_empty(selected_values); + if (length(selected_values) == 0) + return true; + + let value = lc(as_string(object_or_empty(metadata)[tag] || "")); + if (value == "") + return false; + for (let selected in selected_values) + if (lc(as_string(selected)) == value) + return true; + return false; +} + +function proxy_parameter_filter_matches_all(tag, metadata, protocols, transports, securities) { + metadata = object_or_empty(metadata); + return tag_attribute_filter_matches(tag, metadata.protocols, protocols) && + tag_attribute_filter_matches(tag, metadata.transports, transports) && + tag_attribute_filter_matches(tag, metadata.securities, securities); +} + +function proxy_parameter_filter_matches_any(tag, metadata, protocols, transports, securities) { + metadata = object_or_empty(metadata); + return (length(array_or_empty(protocols)) > 0 && + tag_attribute_filter_matches(tag, metadata.protocols, protocols)) || + (length(array_or_empty(transports)) > 0 && + tag_attribute_filter_matches(tag, metadata.transports, transports)) || + (length(array_or_empty(securities)) > 0 && + tag_attribute_filter_matches(tag, metadata.securities, securities)); +} + +function name_or_country_filter_configured(name_filter, regexes, country_filter) { + return length(array_or_empty(name_filter)) > 0 || + length(array_or_empty(regexes)) > 0 || + length(array_or_empty(country_filter)) > 0; +} + +function urltest_all_candidate_outbounds(urltest_candidate_tags) { + return unique_string_array(urltest_candidate_tags); +} + +function urltest_matching_candidate_outbounds(urltest_candidate_tags, names, countries, name_filter, regexes, country_filter, + metadata, proxy_parameters_enabled, proxy_parameters_operator, protocols, transports, securities, additional_matches) { + names = object_or_empty(names); + countries = object_or_empty(countries); + country_filter = runtime_urltest.normalized_country_list(country_filter); + + let regex_set = regex_match_set(urltest_candidate_tags, names, regexes); + let base_filter_configured = name_or_country_filter_configured(name_filter, regexes, country_filter); + let additional_set = object_keys_set(additional_matches); + let result = []; + + for (let tag in array_or_empty(urltest_candidate_tags)) { + let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set) || + tag_country_filter_matches(tag, countries, country_filter); + let matches = additional_set[tag] || base_matches; + if (proxy_parameters_enabled && proxy_parameters_operator == "or") { + matches = additional_set[tag] || base_matches || proxy_parameter_filter_matches_any( + tag, metadata, protocols, transports, securities + ); + } + else if (proxy_parameters_enabled) { + if (!base_filter_configured) + base_matches = true; + matches = additional_set[tag] || + (base_matches && proxy_parameter_filter_matches_all( + tag, metadata, protocols, transports, securities + )); + } + + if (matches) + push(result, tag); + } + + return unique_string_array(result); +} + +function urltest_exclude_outbounds(all_outbounds, excluded_outbounds) { + let excluded = object_keys_set(excluded_outbounds); + let result = []; + for (let tag in array_or_empty(all_outbounds)) { + if (!excluded[tag]) + push(result, tag); + } + return result; +} + +function filter_candidate_outbounds(filter_mode, urltest_candidate_tags, names, countries, metadata, + include_names, include_regex, include_countries, + include_proxy_parameters, include_protocols, include_transports, include_securities, + exclude_names, exclude_regex, exclude_countries, + exclude_proxy_parameters, exclude_protocols, exclude_transports, exclude_securities, + include_additional_matches, exclude_additional_matches) { + let all_outbounds = urltest_all_candidate_outbounds(urltest_candidate_tags); + if (filter_mode == "" || filter_mode == "disabled") + return all_outbounds; + if (!supported_urltest_filter_mode(filter_mode)) + return all_outbounds; + + let include_outbounds = urltest_matching_candidate_outbounds( + urltest_candidate_tags, + names, + countries, + include_names, + include_regex, + include_countries, + metadata, + include_proxy_parameters, + "and", + include_protocols, + include_transports, + include_securities, + include_additional_matches + ); + let exclude_outbounds = urltest_matching_candidate_outbounds( + urltest_candidate_tags, + names, + countries, + exclude_names, + exclude_regex, + exclude_countries, + metadata, + exclude_proxy_parameters, + "or", + exclude_protocols, + exclude_transports, + exclude_securities, + exclude_additional_matches + ); + + if (filter_mode == "include") + return include_outbounds; + if (filter_mode == "exclude") + return urltest_exclude_outbounds(all_outbounds, exclude_outbounds); + if (filter_mode == "mixed") + return urltest_exclude_outbounds(include_outbounds, exclude_outbounds); + return all_outbounds; +} + +function urltest_filtered_outbounds(section, urltest_id, urltest_candidate_tags, state) { + return filter_candidate_outbounds( + connections.urltest_filter_mode(section, urltest_id), + urltest_candidate_tags, + object_or_empty(object_or_empty(state.outboundMetadata).names), + urltest_country_metadata(section, urltest_id, state), + object_or_empty(state.outboundMetadata), + connections.urltest_include_outbounds(section, urltest_id), + connections.urltest_include_regex(section, urltest_id), + connections.urltest_include_countries(section, urltest_id), + connections.urltest_include_proxy_parameters(section, urltest_id), + connections.urltest_include_protocols(section, urltest_id), + connections.urltest_include_transports(section, urltest_id), + connections.urltest_include_securities(section, urltest_id), + connections.urltest_exclude_outbounds(section, urltest_id), + connections.urltest_exclude_regex(section, urltest_id), + connections.urltest_exclude_countries(section, urltest_id), + connections.urltest_exclude_proxy_parameters(section, urltest_id), + connections.urltest_exclude_protocols(section, urltest_id), + connections.urltest_exclude_transports(section, urltest_id), + connections.urltest_exclude_securities(section, urltest_id) + ); +} + +function priority_level_country_metadata(group_id, level_id, state) { + let metadata = object_or_empty(object_or_empty(state.outboundMetadata).countries); + let detect_method = connections.priority_level_detect_server_country(group_id, level_id); + if (detect_method == "flag_emoji") + return runtime_urltest.countries_from_flag_names(object_or_empty(object_or_empty(state.outboundMetadata).names)); + return metadata; +} + +function priority_level_filtered_outbounds(group_id, level_id, urltest_candidate_tags, state) { + if (connections.priority_level_direct(group_id, level_id)) + return [ runtime_constants.DIRECT_OUTBOUND_TAG ]; + + return filter_candidate_outbounds( + connections.priority_level_filter_mode(group_id, level_id), + urltest_candidate_tags, + object_or_empty(object_or_empty(state.outboundMetadata).names), + priority_level_country_metadata(group_id, level_id, state), + object_or_empty(state.outboundMetadata), + connections.priority_level_include_outbounds(group_id, level_id), + connections.priority_level_include_regex(group_id, level_id), + connections.priority_level_include_countries(group_id, level_id), + connections.priority_level_include_proxy_parameters(group_id, level_id), + connections.priority_level_include_protocols(group_id, level_id), + connections.priority_level_include_transports(group_id, level_id), + connections.priority_level_include_securities(group_id, level_id), + connections.priority_level_exclude_outbounds(group_id, level_id), + connections.priority_level_exclude_regex(group_id, level_id), + connections.priority_level_exclude_countries(group_id, level_id), + connections.priority_level_exclude_proxy_parameters(group_id, level_id), + connections.priority_level_exclude_protocols(group_id, level_id), + connections.priority_level_exclude_transports(group_id, level_id), + connections.priority_level_exclude_securities(group_id, level_id) + ); +} + +function dashboard_country_metadata(section, state) { + let metadata = object_or_empty(object_or_empty(state.outboundMetadata).countries); + if (connections.dashboard_detect_server_country(section) == "flag_emoji") + return runtime_urltest.countries_from_flag_names(object_or_empty(object_or_empty(state.outboundMetadata).names)); + return metadata; +} + +function selected_group_outbounds(group_names, group_outbounds) { + group_outbounds = object_or_empty(group_outbounds); + let result = []; + for (let group_name in array_or_empty(group_names)) + for (let tag_name in array_or_empty(group_outbounds[group_name])) + push(result, tag_name); + return unique_string_array(result); +} + +function remember_dashboard_group_outbounds(group_outbounds, group_name, outbounds) { + group_name = as_string(group_name); + if (group_name == "") + return; + + let combined = array_or_empty(group_outbounds[group_name]); + for (let tag_name in array_or_empty(outbounds)) + push(combined, tag_name); + group_outbounds[group_name] = unique_string_array(combined); +} + +function dashboard_filtered_outbounds(section, selector_tags, state, group_outbounds) { + return filter_candidate_outbounds( + connections.dashboard_filter_mode(section), + selector_tags, + object_or_empty(object_or_empty(state.outboundMetadata).names), + dashboard_country_metadata(section, state), + object_or_empty(state.outboundMetadata), + connections.dashboard_include_outbounds(section), + connections.dashboard_include_regex(section), + connections.dashboard_include_countries(section), + connections.dashboard_include_proxy_parameters(section), + connections.dashboard_include_protocols(section), + connections.dashboard_include_transports(section), + connections.dashboard_include_securities(section), + connections.dashboard_exclude_outbounds(section), + connections.dashboard_exclude_regex(section), + connections.dashboard_exclude_countries(section), + connections.dashboard_exclude_proxy_parameters(section), + connections.dashboard_exclude_protocols(section), + connections.dashboard_exclude_transports(section), + connections.dashboard_exclude_securities(section), + selected_group_outbounds(connections.dashboard_include_groups(section), group_outbounds), + selected_group_outbounds(connections.dashboard_exclude_groups(section), group_outbounds) + ); +} + +function priority_levels_with_outbounds(group_id, urltest_candidate_tags, state) { + let result = []; + let assigned = {}; + + for (let level_id in connections.priority_levels(group_id)) { + let outbounds = []; + for (let tag_name in priority_level_filtered_outbounds(group_id, level_id, urltest_candidate_tags, state)) { + if (!assigned[tag_name]) { + assigned[tag_name] = true; + push(outbounds, tag_name); + } + } + + push(result, { + id: level_id, + displayName: connections.priority_level_display_name(group_id, level_id), + order: int(connections.priority_level_order(group_id, level_id), 10), + direct: connections.priority_level_direct(group_id, level_id), + filter_mode: connections.priority_level_filter_mode(group_id, level_id), + detect_server_country: connections.priority_level_detect_server_country(group_id, level_id), + outbounds + }); + } + + return result; +} + +function priority_group_outbounds(levels) { + let result = []; + let seen = {}; + for (let level in array_or_empty(levels)) { + for (let tag_name in array_or_empty(level.outbounds)) { + tag_name = as_string(tag_name); + if (tag_name != "" && !seen[tag_name]) { + seen[tag_name] = true; + push(result, tag_name); + } + } + } + return result; +} + +function urltest_outbound_tag(section_name, urltest_id) { + urltest_id = as_string(urltest_id); + return urltest_id == "urltest" + ? outbound_tag(section_name + "-urltest") + : outbound_tag(section_name + "-urltest-" + urltest_id); +} + +function priority_outbound_tag(section_name, group_id) { + return outbound_tag(section_name + "-priority-" + as_string(group_id)); +} + +function add_urltest_outbound(config, section, urltest_id, urltest_candidate_tags, state) { + let section_name = section[".name"]; + let urltest_outbounds = urltest_filtered_outbounds(section, urltest_id, urltest_candidate_tags, state); + let urltest_tag = urltest_outbound_tag(section_name, urltest_id); + let display_name = connections.urltest_display_name(section, urltest_id); + let urltest_outbound = { + type: "urltest", + tag: urltest_tag, + outbounds: urltest_outbounds, + url: connections.urltest_testing_url(section, urltest_id), + interval: urltest_check_interval(section, urltest_id), + tolerance: int(connections.urltest_tolerance(section, urltest_id), 10), + interrupt_exist_connections: connections.urltest_interrupt_exist_connections(section, urltest_id) + }; + let idle_timeout = urltest_idle_timeout(section, urltest_id); + if (idle_timeout != "") + urltest_outbound.idle_timeout = idle_timeout; + + runtime_subscription.remember_outbound_metadata(state, urltest_tag, display_name, urltest_outbound); + runtime_subscription.remember_urltest_group_config(state, urltest_tag, { + displayName: display_name, + outbounds: urltest_outbounds, + url: urltest_outbound.url, + interval: urltest_outbound.interval, + tolerance: urltest_outbound.tolerance, + idle_timeout: urltest_outbound.idle_timeout, + interrupt_exist_connections: urltest_outbound.interrupt_exist_connections + }); + + if (length(urltest_outbounds) == 0) + return { + tag: "", + outbounds: [] + }; + + push(config.outbounds, urltest_outbound); + return { + tag: urltest_tag, + outbounds: urltest_outbounds + }; +} + +function add_priority_group_outbound(config, section, group_id, urltest_candidate_tags, state) { + let section_name = section[".name"]; + let levels = priority_levels_with_outbounds(group_id, urltest_candidate_tags, state); + let outbounds = priority_group_outbounds(levels); + let priority_tag = priority_outbound_tag(section_name, group_id); + let display_name = connections.priority_group_display_name(section, group_id); + let outbound = { + type: "selector", + tag: priority_tag, + outbounds, + default: outbounds[0], + interrupt_exist_connections: connections.priority_group_interrupt_exist_connections(section, group_id) + }; + + runtime_subscription.remember_outbound_metadata(state, priority_tag, display_name, outbound); + runtime_subscription.remember_priority_group(state, priority_tag, { + id: group_id, + tag: priority_tag, + section: section_name, + displayName: display_name, + health_url: connections.priority_group_health_url(section, group_id), + active_check_interval: connections.priority_group_active_check_interval(section, group_id), + check_timeout: connections.priority_group_check_timeout(section, group_id), + recovery_check_interval: connections.priority_group_recovery_check_interval(section, group_id), + pick_fastest: connections.priority_group_pick_fastest(section, group_id), + switch_to_faster_same_priority: connections.priority_group_switch_to_faster_same_priority(section, group_id), + fastest_check_interval: connections.priority_group_fastest_check_interval(section, group_id), + interrupt_exist_connections: connections.priority_group_interrupt_exist_connections(section, group_id), + pin_dashboard: connections.priority_group_pin_dashboard(section, group_id), + outbounds, + levels + }); + + if (length(outbounds) == 0) + return { + tag: "", + outbounds: [] + }; + + push(config.outbounds, outbound); + return { + tag: priority_tag, + outbounds + }; +} + +function add_proxy_selector(config, section, selector_tags, urltest_candidate_tags, state) { + let section_name = section[".name"]; + let selector_tag = outbound_tag(section_name); + let selector_outbounds = selector_tags; + let selector_default = selector_tags[0]; + let urltest_tags = []; + let priority_tags = []; + let group_outbounds = {}; + + for (let urltest_id in connections.urltests(section)) { + let urltest = add_urltest_outbound(config, section, urltest_id, urltest_candidate_tags, state); + remember_dashboard_group_outbounds( + group_outbounds, + connections.urltest_display_name(section, urltest_id), + urltest.outbounds + ); + if (urltest.tag == "") + continue; + + push(urltest_tags, urltest.tag); + } + + for (let group_id in connections.priority_groups(section)) { + let priority = add_priority_group_outbound(config, section, group_id, urltest_candidate_tags, state); + remember_dashboard_group_outbounds( + group_outbounds, + connections.priority_group_display_name(section, group_id), + priority.outbounds + ); + if (priority.tag == "") + continue; + + push(priority_tags, priority.tag); + } + + selector_outbounds = dashboard_filtered_outbounds(section, selector_tags, state, group_outbounds); + selector_default = selector_outbounds[0]; + if (length(urltest_tags) > 0 || length(priority_tags) > 0) { + for (let tag in urltest_tags) + push(selector_outbounds, tag); + for (let tag in priority_tags) + push(selector_outbounds, tag); + selector_default = length(urltest_tags) > 0 ? urltest_tags[0] : priority_tags[0]; + } + + if (length(selector_outbounds) == 0) + runtime_generate_unsupported("dashboard server filtering produced no usable outbounds"); + + push(config.outbounds, { + type: "selector", + tag: selector_tag, + outbounds: selector_outbounds, + default: selector_default, + interrupt_exist_connections: true + }); +} + +function outbound_detour_tag_for_section(section) { + if (!bool_option(section, "outbound_detour_enabled", false)) + return ""; + + let detour_section = option(section, "outbound_detour_section", ""); + return detour_section == "" ? "" : outbound_tag(detour_section); +} + +function apply_section_detour_to_connection_outbounds(config, start_index, detour_tag) { + if (detour_tag == "") + return; + + let outbounds = array_or_empty(config.outbounds); + for (let i = int(start_index || 0); i < length(outbounds); i++) { + let outbound = outbounds[i]; + if (type(outbound) != "object") + continue; + + let outbound_type = lc(as_string(outbound.type || "")); + if (outbound_type == "" || + outbound_type == "selector" || + outbound_type == "urltest" || outbound_type == "dns" || + outbound_type == "block") + continue; + + // Preserve subscription chains: only their terminal dial outbound receives the section detour. + if (as_string(outbound.detour || "") == "") + outbound.detour = detour_tag; + } +} + +function mixed_proxy_enabled_action(action) { + return action == "connection" || action == "proxy" || action == "outbound" || action == "vpn" || + action == "byedpi" || action == "zapret" || action == "zapret2"; +} + +function add_mixed_proxy_for_section(config, section, service_address) { + if (!bool_option(section, "mixed_proxy_enabled", false)) + return; + + let action = option(section, "action", ""); + if (!mixed_proxy_enabled_action(action)) + runtime_generate_unsupported("mixed proxy inbound is not supported for action " + action); + + let listen_port_value = option(section, "mixed_proxy_port", ""); + if (match(listen_port_value, /^[0-9]+$/) == null) + runtime_generate_unsupported("mixed proxy port is invalid"); + let listen_port = int(listen_port_value, 10); + if (listen_port < 1 || listen_port > 65535) + runtime_generate_unsupported("mixed proxy port is invalid"); + + let listen = as_string(service_address || ""); + if (listen == "") + runtime_generate_unsupported("mixed proxy listen address is not set"); + + let inbound = { + type: "mixed", + tag: runtime_constants.inbound_tag(section[".name"] + "-mixed"), + listen, + listen_port + }; + + if (bool_option(section, "mixed_proxy_auth_enabled", false)) { + let username = option(section, "mixed_proxy_username", ""); + let password = option(section, "mixed_proxy_password", ""); + if (username == "" || password == "") + runtime_generate_unsupported("mixed proxy authentication is enabled but username or password is empty"); + inbound.users = [{ username, password }]; + } + push(config.inbounds, inbound); + push(config.route.rules, { + action: "route", + inbound: inbound.tag, + outbound: runtime_constants.outbound_tag(section[".name"]) + }); +} + +function add_service_mixed_proxy_inbound(config, tag_name, listen_port, outbound) { + push(config.inbounds, { + type: "mixed", + tag: tag_name, + listen: runtime_constants.SERVICE_MIXED_INBOUND_ADDRESS, + listen_port + }); + push(config.route.rules, { + action: "route", + inbound: tag_name, + outbound + }); +} + +function service_mixed_proxy_inbound_tag_for_purpose(purpose) { + return as_string(purpose || "lists") == "components" + ? runtime_constants.inbound_tag("service-components") + : runtime_constants.SERVICE_MIXED_INBOUND_TAG; +} + +function service_mixed_proxy_port_for_purpose(purpose) { + return runtime_constants.SERVICE_MIXED_INBOUND_PORT + + (as_string(purpose || "lists") == "components" ? 1 : 0); +} + +function add_global_download_service_mixed_proxy(config, settings, purpose) { + let outbound = download_detour_tag(settings, purpose); + if (outbound == "") + return; + + add_service_mixed_proxy_inbound( + config, + service_mixed_proxy_inbound_tag_for_purpose(purpose), + service_mixed_proxy_port_for_purpose(purpose), + outbound + ); +} + +function add_subscription_download_service_mixed_proxies(config, sections) { + for (let target in connections.subscription_download_targets(sections)) { + let port = connections.subscription_download_target_port(sections, target, runtime_constants.SERVICE_MIXED_INBOUND_PORT); + if (port <= 0) + runtime_generate_unsupported("subscription download proxy port could not be resolved"); + + add_service_mixed_proxy_inbound( + config, + runtime_constants.inbound_tag("service-subscription-" + target), + port, + outbound_tag(target) + ); + } +} + +function add_service_mixed_proxy(config, settings, sections) { + if (!download_via_proxy_any_enabled(settings, sections)) + return; + + add_global_download_service_mixed_proxy(config, settings, "lists"); + add_global_download_service_mixed_proxy(config, settings, "components"); + add_subscription_download_service_mixed_proxies(config, sections); + + if (download_via_proxy_enabled(settings, "lists") && download_detour_tag(settings, "lists") == "") + runtime_generate_unsupported("download lists via proxy section is not set"); + if (download_via_proxy_enabled(settings, "components") && download_detour_tag(settings, "components") == "") + runtime_generate_unsupported("download components via proxy section is not set"); +} + +function parse_port(value) { + value = as_string(value); + if (match(value, /^[0-9]+$/) == null) + return null; + let port = int(value, 10); + return port >= 1 && port <= 65535 ? port : null; +} + +function bool_query(value) { + return value == "1" || value == "true"; +} + +function base64_decode_value(value) { + value = replace(as_string(value), /[\r\n\t ]/g, ""); + value = replace(replace(value, /-/g, "+"), /_/g, "/"); + while (length(value) % 4 != 0) + value += "="; + try { + return b64dec(value); + } + catch (e) { + return null; + } +} + +function shadowsocks_userinfo_valid(value) { + value = as_string(value); + let first = index(value, ":"); + if (first <= 0 || first >= length(value) - 1) + return false; + let rest = substr(value, first + 1); + let second = index(rest, ":"); + return second < 0 || index(substr(rest, second + 1), ":") < 0; +} + +function split_host_port(value) { + value = as_string(value); + if (substr(value, 0, 1) == "[") { + let close = index(value, "]"); + if (close > 0 && substr(value, close + 1, 1) == ":") + return [ substr(value, 1, close - 1), substr(value, close + 2) ]; + } + + let colon = rindex(value, ":"); + if (colon < 0) + return [ "", "" ]; + return [ substr(value, 0, colon), substr(value, colon + 1) ]; +} + +function tls_alpn_array(value, transport) { + value = as_string(value); + transport = lc(as_string(transport)); + if (value == "" && transport == "xhttp") + return [ "h2", "http/1.1" ]; + if (value != "" && (transport == "ws" || transport == "httpupgrade")) + return [ "http/1.1" ]; + return value == "" ? [] : split(value, ","); +} + +function apply_link_tls(outbound, scheme, query) { + query = object_or_empty(query); + let security = as_string(query.security || ""); + if (security == "" && (scheme == "hysteria2" || scheme == "hy2")) + security = "tls"; + + if (security == "" || security == "none") + return; + if (security != "tls" && security != "reality") { + warn("unknown manual proxy link security '", security, "' ignored\n"); + return; + } + + let tls = { enabled: true }; + if (as_string(query.sni || "") != "") + tls.server_name = as_string(query.sni); + if (bool_query(query.allowInsecure || query.insecure || "")) + tls.insecure = true; + + let alpn = tls_alpn_array(query.alpn, query.type); + if (length(alpn) > 0) + tls.alpn = alpn; + + if (scheme != "hysteria2" && scheme != "hy2" && as_string(query.fp || "") != "") { + tls.utls = { + enabled: true, + fingerprint: as_string(query.fp) + }; + } + if (security == "reality") { + tls.reality = { + enabled: true, + public_key: as_string(query.pbk || ""), + short_id: as_string(query.sid || "") + }; + } + outbound.tls = tls; +} + +function csv_array(value) { + value = as_string(value); + if (value == "") + return []; + let result = []; + for (let item in split(value, ",")) { + item = trim(as_string(item)); + if (item != "") + push(result, item); + } + return result; +} + +function optional_query_string(object, key, value) { + value = as_string(value); + if (value != "") + object[key] = value; +} + +function optional_query_number(object, key, value) { + value = as_string(value); + if (value != "" && match(value, /^[0-9]+$/) != null) + object[key] = int(value, 10); +} + +function apply_link_transport(outbound, query) { + let transport = lc(as_string(object_or_empty(query).type || "")); + if (transport == "" || transport == "tcp" || transport == "raw") + return; + + if (transport == "h2") + transport = "http"; + + let result = { type: transport }; + if (transport == "http") { + optional_query_string(result, "path", query.path); + let hosts = csv_array(query.host); + if (length(hosts) > 0) + result.host = hosts; + } + else if (transport == "ws") { + result.path = as_string(query.path || ""); + if (as_string(query.host || "") != "") + result.headers = { Host: as_string(query.host) }; + optional_query_number(result, "max_early_data", query.ed); + } + else if (transport == "grpc") { + optional_query_string(result, "service_name", query.serviceName); + } + else if (transport == "httpupgrade") { + optional_query_string(result, "path", query.path); + optional_query_string(result, "host", query.host); + } + else if (transport == "xhttp") { + let mode = as_string(query.mode || "auto"); + if (mode != "auto" && mode != "packet-up" && mode != "stream-up" && mode != "stream-one") + mode = "auto"; + result.mode = mode; + result.path = as_string(query.path || "") != "" ? as_string(query.path) : "/"; + result.x_padding_bytes = "100-1000"; + result.no_grpc_header = false; + result.sc_max_each_post_bytes = 1000000; + result.sc_min_posts_interval_ms = 30; + optional_query_string(result, "host", as_string(query.host || "") != "" ? query.host : query.sni); + } + else { + warn("unknown manual proxy link transport '", transport, "' ignored\n"); + return; + } + + outbound.transport = result; +} + +function manual_http_outbound(link, tag_name) { + let scheme = url_scheme(link); + let host = url_host(link); + let port = parse_port(url_port(link)); + let path = url_path(link); + if (host == "" || port == null || (path != "" && path != "/") || index(link, "?") >= 0) + runtime_generate_unsupported("manual HTTP proxy link is invalid"); + + let outbound = { + type: "http", + tag: tag_name, + server: host, + server_port: port + }; + let userinfo = url_userinfo(link); + if (userinfo != "") { + let colon = index(userinfo, ":"); + outbound.username = colon >= 0 ? substr(userinfo, 0, colon) : userinfo; + if (colon >= 0) + outbound.password = substr(userinfo, colon + 1); + } + if (scheme == "https") + outbound.tls = { enabled: true }; + return outbound; +} + +function manual_socks_outbound(link, tag_name) { + let scheme = url_scheme(link); + let host = url_host(link); + let port = parse_port(url_port(link)); + if (host == "" || port == null) + runtime_generate_unsupported("manual SOCKS proxy link is invalid"); + + let outbound = { + type: "socks", + tag: tag_name, + server: host, + server_port: port, + version: scheme == "socks4" || scheme == "socks4a" ? "4" : "5" + }; + if (scheme == "socks5") { + let userinfo = url_userinfo(link); + if (userinfo != "") { + let colon = index(userinfo, ":"); + outbound.username = colon >= 0 ? substr(userinfo, 0, colon) : userinfo; + if (colon >= 0) + outbound.password = substr(userinfo, colon + 1); + } + } + return outbound; +} + +function manual_shadowsocks_outbound(link, tag_name) { + let raw = url_strip_fragment_value(url_decode(link)); + let body = substr(raw, 5); + let question = index(body, "?"); + let query_string = ""; + if (question >= 0) { + query_string = substr(body, question + 1); + body = substr(body, 0, question); + } + + let at = rindex(body, "@"); + let userinfo = ""; + let hostport = ""; + if (at >= 0) { + userinfo = substr(body, 0, at); + hostport = substr(body, at + 1); + } + else { + let decoded = base64_decode_value(body); + if (decoded == null) + runtime_generate_unsupported("manual Shadowsocks proxy link is invalid"); + at = rindex(decoded, "@"); + if (at < 0) + runtime_generate_unsupported("manual Shadowsocks proxy link is invalid"); + userinfo = substr(decoded, 0, at); + hostport = substr(decoded, at + 1); + } + + userinfo = url_decode(userinfo); + if (!shadowsocks_userinfo_valid(userinfo)) { + let decoded = base64_decode_value(userinfo); + if (decoded == null) + runtime_generate_unsupported("manual Shadowsocks proxy link is invalid"); + userinfo = decoded; + } + + let cred_colon = index(userinfo, ":"); + let host_port = split_host_port(hostport); + let port = parse_port(host_port[1]); + if (cred_colon <= 0 || host_port[0] == "" || port == null) + runtime_generate_unsupported("manual Shadowsocks proxy link is invalid"); + + let outbound = { + type: "shadowsocks", + tag: tag_name, + server: host_port[0], + server_port: port, + method: substr(userinfo, 0, cred_colon), + password: substr(userinfo, cred_colon + 1) + }; + let query = url_query_params("ss://placeholder/?" + query_string); + if (as_string(query.plugin || "") != "") + outbound.plugin = as_string(query.plugin); + if (as_string(query["plugin-opts"] || "") != "") + outbound.plugin_opts = as_string(query["plugin-opts"]); + return outbound; +} + +function manual_vless_outbound(link, tag_name) { + let query = url_query_params(link); + + let host = url_host(link); + let port = parse_port(url_port(link)); + let uuid = url_userinfo(link); + if (host == "" || port == null || uuid == "") + runtime_generate_unsupported("manual VLESS proxy link is invalid"); + + let outbound = { + type: "vless", + tag: tag_name, + server: host, + server_port: port, + uuid + }; + let flow = as_string(query.flow || ""); + if (flow != "") + outbound.flow = flow; + let encryption = as_string(query.encryption || ""); + if (encryption != "" && encryption != "none") + outbound.encryption = encryption; + let packet_encoding = as_string(query.packetEncoding || ""); + if (packet_encoding == "xudp" || packet_encoding == "packetaddr") + outbound.packet_encoding = packet_encoding; + apply_link_tls(outbound, "vless", query); + apply_link_transport(outbound, query); + return outbound; +} + +function vmess_json_value(value) { + return value == null ? "" : as_string(value); +} + +function manual_vmess_outbound(link, tag_name) { + let encoded = substr(url_strip_fragment_value(link), 8); + let decoded = base64_decode_value(encoded); + if (decoded == null) + runtime_generate_unsupported("manual VMess proxy link is invalid"); + + if (index(decoded, "\r") >= 0 || index(decoded, "\n") >= 0) + decoded = replace(decoded, /[\r\n]/g, ""); + decoded = trim(decoded); + + let vmess; + try { + vmess = json(decoded); + } + catch (e) { + runtime_generate_unsupported("manual VMess proxy link is invalid"); + } + if (type(vmess) != "object") + runtime_generate_unsupported("manual VMess proxy link is invalid"); + + let host = vmess_json_value(vmess.add); + let port = parse_port(vmess_json_value(vmess.port)); + let uuid = vmess_json_value(vmess.id); + if (host == "" || port == null || uuid == "") + runtime_generate_unsupported("manual VMess proxy link is invalid"); + + let outbound = { + type: "vmess", + tag: tag_name, + server: host, + server_port: port, + uuid, + security: vmess_json_value(vmess.scy) != "" ? vmess_json_value(vmess.scy) : "auto" + }; + + if (vmess_json_value(vmess.aid) != "") + outbound.alter_id = int(vmess.aid || 0); + + let network = lc(vmess_json_value(vmess.net)); + if (vmess.tls === true || vmess.tls == "tls" || vmess.tls == "true") { + let tls = { enabled: true }; + optional_query_string(tls, "server_name", vmess_json_value(vmess.sni)); + let alpn = tls_alpn_array(vmess_json_value(vmess.alpn), network); + if (length(alpn) > 0) + tls.alpn = alpn; + if (vmess_json_value(vmess.fp) != "") { + tls.utls = { + enabled: true, + fingerprint: vmess_json_value(vmess.fp) + }; + } + outbound.tls = tls; + } + + if (network == "ws") { + outbound.transport = { + type: "ws", + path: vmess_json_value(vmess.path) != "" ? vmess_json_value(vmess.path) : "/" + }; + if (vmess_json_value(vmess.host) != "") + outbound.transport.headers = { Host: vmess_json_value(vmess.host) }; + } + else if (network == "grpc") { + outbound.transport = { type: "grpc" }; + optional_query_string(outbound.transport, "service_name", vmess_json_value(vmess.path)); + } + else if (network == "http" || network == "h2") { + outbound.transport = { type: "http" }; + optional_query_string(outbound.transport, "path", vmess_json_value(vmess.path)); + let hosts = csv_array(vmess_json_value(vmess.host)); + if (length(hosts) > 0) + outbound.transport.host = hosts; + } + + return outbound; +} + +function manual_trojan_outbound(link, tag_name) { + let query = url_query_params(link); + + let host = url_host(link); + let port = parse_port(url_port(link)); + let password = url_userinfo(link); + if (host == "" || port == null || password == "") + runtime_generate_unsupported("manual Trojan proxy link is invalid"); + + let outbound = { + type: "trojan", + tag: tag_name, + server: host, + server_port: port, + password + }; + apply_link_tls(outbound, "trojan", query); + apply_link_transport(outbound, query); + return outbound; +} + +function manual_hysteria2_outbound(link, tag_name) { + let query = url_query_params(link); + let host = url_host(link); + let port = parse_port(as_string(query.mport || "") != "" ? query.mport : url_port(link)); + let password = url_userinfo(link); + if (host == "" || port == null || password == "") + runtime_generate_unsupported("manual Hysteria2 proxy link is invalid"); + + let outbound = { + type: "hysteria2", + tag: tag_name, + server: host, + server_port: port, + password + }; + if (as_string(query.obfs || "") != "") + outbound.obfs = { type: as_string(query.obfs), password: as_string(query["obfs-password"] || "") }; + if (as_string(query.upmbps || "") != "") + outbound.up_mbps = int(query.upmbps, 10); + if (as_string(query.downmbps || "") != "") + outbound.down_mbps = int(query.downmbps, 10); + apply_link_tls(outbound, url_scheme(link), query); + return outbound; +} + +function manual_link_outbound(link, tag_name) { + let scheme = url_scheme(link); + if (scheme == "vmess") + return manual_vmess_outbound(link, tag_name); + + link = url_strip_fragment_value(url_decode(link)); + scheme = url_scheme(link); + if (scheme == "http" || scheme == "https") + return manual_http_outbound(link, tag_name); + if (scheme == "socks4" || scheme == "socks4a" || scheme == "socks5") + return manual_socks_outbound(link, tag_name); + if (scheme == "ss") + return manual_shadowsocks_outbound(link, tag_name); + if (scheme == "vless") + return manual_vless_outbound(link, tag_name); + if (scheme == "trojan") + return manual_trojan_outbound(link, tag_name); + if (scheme == "hysteria2" || scheme == "hy2") + return manual_hysteria2_outbound(link, tag_name); + runtime_generate_unsupported("manual proxy link scheme is not supported by sing-box config generation yet"); +} + +function add_manual_proxy_link(config, state, section_name, manual_index, link, taken, selector_tags, urltest_candidate_tags) { + let tag_name = outbound_tag(section_name + "-" + manual_index); + if (taken[tag_name]) + tag_name = unique_tag(tag_name, taken); + taken[tag_name] = true; + + let outbound = manual_link_outbound(link, tag_name); + let display_name = url_fragment(link); + if (display_name == "") + display_name = tag_name; + ensure_explicit_outbound_supported(outbound, "manual outbound", display_name); + push(config.outbounds, outbound); + push(selector_tags, tag_name); + push(urltest_candidate_tags, tag_name); + + state.links[tag_name] = as_string(link); + runtime_subscription.remember_outbound_metadata(state, tag_name, display_name, outbound); + return tag_name; +} + +function connection_item_tag(section_name, kind, item_index) { + return outbound_tag(section_name + "-" + as_string(kind) + "-" + item_index); +} + +function add_connection_manual_links(config, state, section, taken, selector_tags, urltest_candidate_tags) { + let section_name = section[".name"]; + let manual_links = connections.connection_urls(section); + for (let i = 0; i < length(manual_links); i++) { + let link = manual_links[i]; + add_manual_proxy_link( + config, + state, + section_name, + i + 1, + link, + taken, + selector_tags, + urltest_candidate_tags + ); + } +} + +function add_connection_subscriptions(config, state, section, taken, selector_tags, urltest_candidate_tags) { + let subscription_urls = connections.subscription_urls(section); + + for (let i = 0; i < length(subscription_urls); i++) + add_subscription_source_with_state( + config, + section, + i + 1, + subscription_urls[i], + taken, + selector_tags, + urltest_candidate_tags, + state, + connections.subscription_dashboard_metadata_enabled(section, subscription_urls[i]), + connections.subscription_include_urltest_groups(section, subscription_urls[i]), + connections.subscription_hide_urltest_group_outbounds(section, subscription_urls[i]), + connections.subscription_hide_detour_outbounds(section, subscription_urls[i]), + connections.subscription_node_prefix(section, subscription_urls[i]) + ); +} + +function add_interface_connection_outbound(config, state, section, interface_index, interface_name, taken, selector_tags, urltest_candidate_tags) { + let section_name = section[".name"]; + let tag_name = connection_item_tag(section_name, "interface", interface_index); + if (taken[tag_name]) + tag_name = unique_tag(tag_name, taken); + taken[tag_name] = true; + + let domain_resolver = ""; + if (connections.interface_domain_resolver_enabled(section, interface_name)) { + domain_resolver = runtime_constants.domain_resolver_tag(section_name + "-interface-" + interface_index); + let dns_server = runtime_dns.server_from_options( + domain_resolver, + connections.interface_domain_resolver_dns_type(section, interface_name), + connections.interface_domain_resolver_dns_server(section, interface_name), + tag_name + ); + if (dns_server.unsupported) + runtime_generate_unsupported(dns_server.unsupported); + push(config.dns.servers, dns_server); + } + + let outbound = { + type: "direct", + tag: tag_name, + bind_interface: interface_name, + domain_resolver, + routing_mark: runtime_constants.OUTBOUND_MARK + }; + if (domain_resolver == "") + delete outbound.domain_resolver; + + push(config.outbounds, outbound); + push(selector_tags, tag_name); + push(urltest_candidate_tags, tag_name); + runtime_subscription.remember_outbound_metadata(state, tag_name, interface_name, outbound); +} + +function add_connection_interfaces(config, state, section, taken, selector_tags, urltest_candidate_tags) { + let items = connections.interfaces(section); + for (let i = 0; i < length(items); i++) + add_interface_connection_outbound(config, state, section, i + 1, items[i], taken, selector_tags, urltest_candidate_tags); +} + +function parse_outbound_json(value) { + try { + value = json(as_string(value)); + } + catch (e) { + return null; + } + + return type(value) == "object" ? value : null; +} + +function rewrite_json_outbound_references(outbounds, tag_map) { + for (let outbound in array_or_empty(outbounds)) { + if (type(outbound) != "object") + continue; + + for (let key in [ "detour", "default" ]) { + let reference = as_string(outbound[key] || ""); + if (reference != "" && tag_map[reference]) + outbound[key] = tag_map[reference]; + } + + if (type(outbound.outbounds) == "array") { + let rewritten = []; + for (let tag_name in outbound.outbounds) { + tag_name = as_string(tag_name); + if (tag_name != "") + push(rewritten, as_string(tag_map[tag_name] || tag_name)); + } + outbound.outbounds = rewritten; + } + } +} + +function prepare_json_connection_outbounds(section, taken) { + let items = connections.outbound_jsons(section); + let prepared = []; + let outbounds = []; + let tag_map = {}; + let legacy_tags = []; + + for (let i = 0; i < length(items); i++) { + let outbound = parse_outbound_json(items[i]); + if (outbound == null) + runtime_generate_unsupported("JSON outbound is invalid"); + + let display_name = trim(as_string(outbound.tag || "")); + let legacy_tag = connection_item_tag(section[".name"], "json", i + 1); + let base = display_name != "" ? display_name : legacy_tag; + let tag_name = unique_tag(base, taken); + ensure_explicit_outbound_supported(outbound, "JSON outbound", display_name != "" ? display_name : legacy_tag); + taken[tag_name] = true; + if (display_name != "" && !tag_map[display_name]) + tag_map[display_name] = tag_name; + push(legacy_tags, [ legacy_tag, tag_name ]); + + outbound.tag = tag_name; + push(outbounds, outbound); + push(prepared, { + outbound, + displayName: display_name != "" ? display_name : "JSON outbound " + (i + 1) + }); + } + + for (let entry in legacy_tags) + if (!tag_map[entry[0]]) + tag_map[entry[0]] = entry[1]; + + rewrite_json_outbound_references(outbounds, tag_map); + return prepared; +} + +function add_connection_json_outbounds(config, state, section, taken, selector_tags, urltest_candidate_tags) { + for (let item in prepare_json_connection_outbounds(section, taken)) { + let outbound = item.outbound; + let tag_name = outbound.tag; + push(config.outbounds, outbound); + push(selector_tags, tag_name); + if (urltest_leaf_candidate_outbound(outbound)) + push(urltest_candidate_tags, tag_name); + runtime_subscription.remember_outbound_metadata(state, tag_name, item.displayName, outbound); + runtime_subscription.remember_urltest_group(state, tag_name, item.displayName, outbound); + } +} + +function add_connections_outbound(config, section, taken) { + let section_name = section[".name"]; + let selector_tags = []; + let urltest_candidate_tags = []; + let state = runtime_subscription.new_section_state(section_name); + let cascade_start = length(array_or_empty(config.outbounds)); + + add_connection_manual_links(config, state, section, taken, selector_tags, urltest_candidate_tags); + add_connection_subscriptions(config, state, section, taken, selector_tags, urltest_candidate_tags); + // Apply before interface and JSON items are added: those source kinds are intentionally excluded. + apply_section_detour_to_connection_outbounds( + config, + cascade_start, + outbound_detour_tag_for_section(section) + ); + add_connection_interfaces(config, state, section, taken, selector_tags, urltest_candidate_tags); + add_connection_json_outbounds(config, state, section, taken, selector_tags, urltest_candidate_tags); + + if (length(selector_tags) == 0) + runtime_generate_unsupported("connection section has no usable outbounds"); + + if (section_needs_country_is(section)) { + let previous_state = read_json_file(runtime_subscription.section_cache_path(section_name)); + state.outboundMetadata.countries = runtime_country.detect( + state.servers, + previous_state, + option(runtime_settings(), "bootstrap_dns_server", "77.88.8.8") + ); + } + if (section_has_direct_priority_level(section)) + state.outboundMetadata.names[runtime_constants.DIRECT_OUTBOUND_TAG] = "Direct"; + + state.urltestCandidateTags = unique_string_array(urltest_candidate_tags); + add_proxy_selector(config, section, selector_tags, urltest_candidate_tags, state); + if (!atomic_write_json_file(runtime_subscription.section_cache_path(section_name), state)) + runtime_generate_unsupported("failed to write section cache for " + section_name); +} + +function enabled_action_index(sections, target_section, action_name) { + let index = 0; + for (let section in sections) { + if (option(section, "action", "") != action_name) + continue; + index++; + if (section[".name"] == target_section[".name"]) + return index; + } + return 0; +} + +function add_zapret_outbound(config, section, sections) { + let index = enabled_action_index(sections, section, "zapret"); + if (index <= 0) + runtime_generate_unsupported("unable to resolve Zapret index for " + section[".name"]); + push(config.outbounds, { + type: "direct", + tag: outbound_tag(section[".name"]), + routing_mark: runtime_constants.ZAPRET_ROUTE_MARK_BASE + index + }); +} + +function add_zapret2_outbound(config, section, sections) { + let index = enabled_action_index(sections, section, "zapret2"); + if (index <= 0) + runtime_generate_unsupported("unable to resolve Zapret2 index for " + section[".name"]); + push(config.outbounds, { + type: "direct", + tag: outbound_tag(section[".name"]), + routing_mark: runtime_constants.ZAPRET2_ROUTE_MARK_BASE + index + }); +} + +function add_byedpi_outbound(config, section, sections) { + let index = enabled_action_index(sections, section, "byedpi"); + if (index <= 0) + runtime_generate_unsupported("unable to resolve ByeDPI index for " + section[".name"]); + push(config.outbounds, { + type: "socks", + tag: outbound_tag(section[".name"]), + server: runtime_constants.BYEDPI_LISTEN_ADDRESS, + server_port: runtime_constants.BYEDPI_PORT_BASE + index - 1, + version: "5" + }); +} + +function ensure_community_ruleset(config, section_name, community) { + if (!runtime_rulesets.is_community(community)) + runtime_generate_unsupported("unknown community list " + community); + + let tag_name = ruleset_tag(section_name, community, "community"); + if (!ruleset_registered(config, tag_name)) { + let rule_set = { + type: "remote", + tag: tag_name, + format: "binary", + url: runtime_rulesets.community_url(community), + update_interval: remote_ruleset_update_interval() + }; + let detour = download_detour_tag(runtime_settings(), "lists"); + if (detour != "") + rule_set.download_detour = detour; + push(config.route.rule_set, rule_set); + } + return { + tag: tag_name, + kind: "domains" + }; +} + +function domain_ip_list_ruleset_tag(section_name) { + return ruleset_tag(section_name, "lists", ""); +} + +function domain_ip_list_ruleset_path(section_name) { + return runtime_ruleset_folder + "/" + domain_ip_list_ruleset_tag(section_name) + ".json"; +} + +function reference_is_local(reference) { + return substr(as_string(reference), 0, 1) == "/"; +} + +function source_file_exists(path) { + return fs.readfile(path) != null; +} + +function rebuild_local_domain_ip_list_ruleset(section_name, references, domains_only) { + let ruleset_path = domain_ip_list_ruleset_path(section_name); + let has_local = false; + + for (let reference in references) { + if (reference_is_local(reference)) { + has_local = true; + break; + } + } + + if (!has_local) + return; + + fs.unlink(ruleset_path); + source_rulesets.create_source(ruleset_path); + + for (let reference in references) { + reference = as_string(reference); + if (!reference_is_local(reference)) + continue; + if (!source_file_exists(reference)) { + warn("local domain/IP list not found: ", reference, "\n"); + continue; + } + + source_rulesets.import_plain_list(reference, ruleset_path, "domain_suffix", "domains", "5000"); + if (!domains_only) + source_rulesets.import_plain_list(reference, ruleset_path, "ip_cidr", "subnets", "5000"); + } +} + +function add_domain_ip_list_ruleset(config, section_name, rule_set_tags, dns_rule_set_tags, references, domains_only) { + if (length(references) == 0) + return; + + rebuild_local_domain_ip_list_ruleset(section_name, references, domains_only); + + let ruleset_path = domain_ip_list_ruleset_path(section_name); + if (!source_rulesets.has_rules(ruleset_path)) + return; + + let tag_name = domain_ip_list_ruleset_tag(section_name); + if (!ruleset_registered(config, tag_name)) { + push(config.route.rule_set, { + type: "local", + tag: tag_name, + format: "source", + path: ruleset_path + }); + } + + if (!domains_only) + push(rule_set_tags, tag_name); + if (source_rulesets.has_domain_matchers(ruleset_path)) + push(dns_rule_set_tags, tag_name); +} + +function legacy_condition_values(section, key) { + let raw_values = object_or_empty(section)[key]; + let list_values = type(raw_values) == "array" + ? raw_values + : []; + let option_text_values = type(raw_values) == "array" || key == "domain" + ? [] + : rule_config.text_list_values(raw_values, "comma-space"); + let text_value = option(section, key + "_text", ""); + let text_values = rule_config.text_list_values(text_value, "comma-space"); + + if (bool_option(section, key + "_text_mode", false) || bool_option(section, "conditions_text_mode", false)) + return text_values; + if (length(list_values) > 0) + return list_values; + if (length(option_text_values) > 0) + return option_text_values; + return text_values; +} + +function combined_domain_source_values(section) { + let values = []; + if (type(object_or_empty(section)["domain"]) != "array") { + for (let value in rule_config.text_list_values(option(section, "domain", ""), "comma-space")) + if (as_string(value) != "") + push(values, as_string(value)); + } + for (let value in rule_config.text_list_values(option(section, "domain_suffix_text", ""), "comma-space")) + if (as_string(value) != "") + push(values, as_string(value)); + for (let value in list_option(section, "domain_suffix")) + if (as_string(value) != "") + push(values, as_string(value)); + return values; +} + +function domain_suffix_condition_value_kind(value) { + return rule_config.prefixed_domain_kind_value(value); +} + +function domain_conditions(section) { + let result = { + domain: [], + domain_suffix: [], + domain_keyword: [], + domain_regex: [] + }; + + for (let key in [ "domain", "domain_keyword", "domain_regex" ]) { + for (let value in legacy_condition_values(section, key)) { + let normalized = rule_config.domain_value_for_key(value, key); + if (normalized != null) + push(result[key], normalized); + } + } + + for (let value in combined_domain_source_values(section)) { + let normalized = domain_suffix_condition_value_kind(value); + if (normalized != null) + push(result[normalized.kind], normalized.value); + } + + return result; +} + +function add_domain_array(rule, key, values) { + if (length(values) > 0) + rule[key] = values; +} + +function push_dns_matcher_rule(config, rule) { + push(config.dns.rules, rule); +} + +function section_dns_server(section) { + return option(section, "action", "") == "bypass" + ? runtime_constants.DNS_SERVER_TAG + : runtime_constants.FAKEIP_DNS_SERVER_TAG; +} + +function single_or_array(values) { + return length(values) == 1 ? values[0] : values; +} + +function dns_action_server_tag(section_name) { + return runtime_constants.tag(section_name, "dns-server"); +} + +function dns_action_detour_tag(section) { + if (!bool_option(section, "dns_detour_enabled", false)) + return ""; + let target_section = option(section, "dns_detour_section", ""); + return target_section == "" ? "" : outbound_tag(target_section); +} + +function add_dns_server_for_section(config, section) { + let server = runtime_dns.server_from_options( + dns_action_server_tag(section[".name"]), + option(section, "dns_type", "udp"), + option(section, "dns_server", ""), + dns_action_detour_tag(section) + ); + if (server.unsupported) + runtime_generate_unsupported(server.unsupported); + push(config.dns.servers, server); +} + +function add_dns_action_rules_for_section(config, section) { + let domains = domain_conditions(section); + let domain = domains.domain; + let domain_suffix = domains.domain_suffix; + let domain_keyword = domains.domain_keyword; + let domain_regex = domains.domain_regex; + let rule_set_tags = []; + let section_name = section[".name"]; + + for (let community in connections.community_lists(section)) { + let ensured = ensure_community_ruleset(config, section_name, as_string(community)); + push(rule_set_tags, ensured.tag); + } + for (let reference in connections.rule_sets(section)) { + let ensured = ensure_custom_ruleset(config, as_string(reference)); + if (ensured.kind == "domains") + push(rule_set_tags, ensured.tag); + } + add_domain_ip_list_ruleset( + config, + section_name, + [], + rule_set_tags, + list_option(section, "domain_ip_lists"), + true + ); + + let rewrite_ttl = int_option(runtime_settings(), "dns_rewrite_ttl", "60"); + let server_tag = dns_action_server_tag(section_name); + let has_inline_domains = length(domain) > 0 || length(domain_suffix) > 0 || + length(domain_keyword) > 0 || length(domain_regex) > 0; + + if (has_inline_domains) { + let dns_rule = { + action: "route", + server: server_tag, + rewrite_ttl + }; + add_domain_array(dns_rule, "domain", domain); + add_domain_array(dns_rule, "domain_suffix", domain_suffix); + add_domain_array(dns_rule, "domain_keyword", domain_keyword); + add_domain_array(dns_rule, "domain_regex", domain_regex); + push_dns_matcher_rule(config, dns_rule); + } + if (length(rule_set_tags) > 0) { + push_dns_matcher_rule(config, { + action: "route", + server: server_tag, + rewrite_ttl, + rule_set: single_or_array(rule_set_tags) + }); + } + if (!has_inline_domains && length(rule_set_tags) == 0) + runtime_generate_unsupported("DNS action '" + section_name + "' has no domain matchers"); +} + +function normalize_port_number_value(value) { + return rule_config.normalize_port_number_value(value); +} + +function add_port_matchers(rule, section) { + let values = []; + for (let value in list_option(section, "ports")) + push(values, value); + for (let value in rule_config.text_list_values(option(section, "ports_text", ""), "comma-space")) + push(values, value); + + let ports = []; + let port_ranges = []; + let seen = {}; + for (let value in values) { + value = trim(as_string(value)); + if (value == "" || seen[value]) + continue; + seen[value] = true; + + let dash = index(value, "-"); + if (dash < 0) { + let port = normalize_port_number_value(value); + if (port != null) + push(ports, port); + continue; + } + + let start = normalize_port_number_value(substr(value, 0, dash)); + let end = normalize_port_number_value(substr(value, dash + 1)); + if (start != null && end != null && start <= end) + push(port_ranges, start == end ? as_string(start) : sprintf("%d:%d", start, end)); + } + + if (length(ports) > 0) + rule.port = ports; + if (length(port_ranges) > 0) + rule.port_range = port_ranges; +} + +function add_fully_routed_ips_rule(config, section) { + let source_ip_cidr = list_option(section, "fully_routed_ips"); + if (length(source_ip_cidr) == 0) + return; + + let target = runtime_route.target(section, outbound_tag(section[".name"])); + if (target.unsupported) + runtime_generate_unsupported(target.unsupported); + + let route_rule = { + action: target.action, + inbound: tproxy_inbound_matcher() + }; + if (target.outbound) + route_rule.outbound = target.outbound; + route_rule.source_ip_cidr = single_or_array(source_ip_cidr); + push(config.route.rules, route_rule); +} + +function add_combined_route_for_section(config, section) { + let domains = domain_conditions(section); + let domain = domains.domain; + let domain_suffix = domains.domain_suffix; + let domain_keyword = domains.domain_keyword; + let domain_regex = domains.domain_regex; + let ip_cidr = legacy_condition_values(section, "ip_cidr"); + let source_ip_cidr = legacy_condition_values(section, "source_ip_cidr"); + let rule_set_tags = []; + let dns_rule_set_tags = []; + let section_name = section[".name"]; + + add_fully_routed_ips_rule(config, section); + + for (let community in connections.community_lists(section)) { + let ensured = ensure_community_ruleset(config, section_name, as_string(community)); + push(rule_set_tags, ensured.tag); + push(dns_rule_set_tags, ensured.tag); + } + for (let reference in connections.rule_sets(section)) { + let ensured = ensure_custom_ruleset(config, as_string(reference)); + push(rule_set_tags, ensured.tag); + if (ensured.kind == "domains") + push(dns_rule_set_tags, ensured.tag); + } + for (let reference in connections.rule_sets_with_subnets(section)) { + let ensured = ensure_custom_ruleset(config, as_string(reference)); + push(rule_set_tags, ensured.tag); + if (ensured.kind == "domains") + push(dns_rule_set_tags, ensured.tag); + } + add_domain_ip_list_ruleset( + config, + section_name, + rule_set_tags, + dns_rule_set_tags, + list_option(section, "domain_ip_lists"), + false + ); + + let target = runtime_route.target(section, outbound_tag(section_name)); + if (target.unsupported) + runtime_generate_unsupported(target.unsupported); + let route_rule = { + action: target.action, + inbound: tproxy_inbound_matcher() + }; + if (target.outbound) + route_rule.outbound = target.outbound; + add_domain_array(route_rule, "domain", domain); + add_domain_array(route_rule, "domain_suffix", domain_suffix); + add_domain_array(route_rule, "domain_keyword", domain_keyword); + add_domain_array(route_rule, "domain_regex", domain_regex); + if (length(ip_cidr) > 0) + route_rule.ip_cidr = ip_cidr; + if (length(source_ip_cidr) > 0) + route_rule.source_ip_cidr = source_ip_cidr; + add_port_matchers(route_rule, section); + if (length(rule_set_tags) > 0) + route_rule.rule_set = single_or_array(rule_set_tags); + + let has_route_matchers = route_rule.domain != null || route_rule.domain_suffix != null || + route_rule.domain_keyword != null || route_rule.domain_regex != null || + route_rule.ip_cidr != null || route_rule.port != null || route_rule.port_range != null || + route_rule.rule_set != null; + if (has_route_matchers) { + let resolve = runtime_route.resolve_rule_for_section(section, route_rule); + if (type(resolve) == "object" && resolve.warning) + warn(resolve.warning, "\n"); + else if (type(resolve) == "object" && resolve.rule) + push(config.route.rules, resolve.rule); + push(config.route.rules, route_rule); + } + + let rewrite_ttl = int_option(runtime_settings(), "dns_rewrite_ttl", "60"); + if (length(domain) > 0 || length(domain_suffix) > 0 || length(domain_keyword) > 0 || length(domain_regex) > 0) { + let dns_rule = { + action: "route", + server: section_dns_server(section), + rewrite_ttl + }; + add_domain_array(dns_rule, "domain", domain); + add_domain_array(dns_rule, "domain_suffix", domain_suffix); + add_domain_array(dns_rule, "domain_keyword", domain_keyword); + add_domain_array(dns_rule, "domain_regex", domain_regex); + push_dns_matcher_rule(config, dns_rule); + } + if (length(dns_rule_set_tags) > 0) { + push_dns_matcher_rule(config, { + action: "route", + server: section_dns_server(section), + rewrite_ttl, + rule_set: single_or_array(dns_rule_set_tags) + }); + } +} + +function unsupported_matcher_key(section) { + let unsupported_options = [ + "subnet", "subnet_text", + "local_domain_lists", "local_subnet_lists", + "remote_domain_lists", "remote_subnet_lists" + ]; + for (let key in unsupported_options) { + if (length(list_option(section, key)) > 0 || option(section, key, "") != "") + return key; + } + return ""; +} + +function add_outbound_for_section(config, section, taken, sections) { + let action = option(section, "action", ""); + let section_name = section[".name"]; + if (!valid_section_name(section_name)) + runtime_generate_unsupported("section name is not safe for sing-box config generation"); + let unsupported_matcher = unsupported_matcher_key(section); + if (unsupported_matcher != "") + runtime_generate_unsupported("section has unsupported matcher " + unsupported_matcher); + + if (connections.is_connections_action(action)) + add_connections_outbound(config, section, taken); + else if (action == "zapret") + add_zapret_outbound(config, section, sections); + else if (action == "zapret2") + add_zapret2_outbound(config, section, sections); + else if (action == "byedpi") + add_byedpi_outbound(config, section, sections); + else if (action == "bypass") { + /* route-only action */ + } + else if (action == "block") { + /* route-only action */ + } + else if (action == "dns") { + add_dns_server_for_section(config, section); + } + else { + runtime_generate_unsupported("unsupported action " + action); + } +} + +function reserve_section_outbound_tags(sections, taken) { + for (let section in sections) { + let action = option(section, "action", ""); + if (connections.is_connections_action(action) || + action == "byedpi" || action == "zapret" || action == "zapret2") + taken[outbound_tag(section[".name"])] = true; + + if (!connections.is_connections_action(action)) + continue; + + for (let urltest_id in connections.urltests(section)) + taken[urltest_outbound_tag(section[".name"], urltest_id)] = true; + for (let group_id in connections.priority_groups(section)) + taken[priority_outbound_tag(section[".name"], group_id)] = true; + } +} + +function add_route_for_section(config, section) { + if (option(section, "action", "") == "dns") + add_dns_action_rules_for_section(config, section); + else + add_combined_route_for_section(config, section); +} + +function add_service_route_rules(config, sections) { + let first = null; + for (let section in sections) { + let action = option(section, "action", ""); + if (connections.is_connections_action(action) || + action == "byedpi" || action == "zapret" || action == "zapret2") { + first = section; + break; + } + } + if (first != null) { + push(config.route.rules, { + action: "route", + inbound: tproxy_inbound_matcher(), + outbound: outbound_tag(first[".name"]), + domain: runtime_constants.CHECK_PROXY_IP_DOMAIN + }); + } + push(config.route.rules, { + action: "route-options", + domain: runtime_constants.FAKEIP_TEST_DOMAIN, + override_port: 8443 + }); +} + +function enabled_sections() { + let result = []; + uci_cursor().foreach(CONFIG_NAME, "section", function(section) { + if (section_enabled(section)) + push(result, section); + }); + return result; +} + +function enabled_servers() { + let result = []; + uci_cursor().foreach(CONFIG_NAME, "server", function(section) { + if (section_enabled(section)) + push(result, section); + }); + return result; +} + +function section_by_name(sections, name) { + name = as_string(name); + for (let section in sections) + if (as_string(section[".name"]) == name) + return section; + return null; +} + +function add_server_routes(config, servers, sections) { + for (let server in servers) { + runtime_servers.add_sniff_rule(config, server); + + let inbound = runtime_constants.server_inbound_tag(server[".name"]); + let routing_mode = option(server, "routing_mode", "rules"); + if (routing_mode == "rules") { + runtime_servers.clone_rules_for_inbound( + config, + runtime_constants.TPROXY_INBOUND_TAG, + inbound, + runtime_constants.CHECK_PROXY_IP_DOMAIN + ); + } + else if (routing_mode == "direct") { + push(config.route.rules, { + action: "route", + inbound, + outbound: runtime_constants.DIRECT_OUTBOUND_TAG + }); + } + else if (routing_mode == "section") { + let routing_section_name = option(server, "routing_section", ""); + let routing_section = section_by_name(sections, routing_section_name); + if (routing_section == null) + runtime_generate_unsupported("server references missing routing section " + routing_section_name); + let action = option(routing_section, "action", ""); + if (action == "bypass" || action == "block") + runtime_generate_unsupported("server routing section " + routing_section_name + " cannot use action " + action); + let target = runtime_route.target(routing_section, outbound_tag(routing_section[".name"])); + if (target.unsupported) + runtime_generate_unsupported(target.unsupported); + let rule = { + action: target.action, + inbound + }; + if (target.outbound) + rule.outbound = target.outbound; + push(config.route.rules, rule); + } + else { + runtime_generate_unsupported("unsupported server routing_mode " + routing_mode); + } + } +} + +function generate_config(output_path, service_address, mwan3_active, supports_xhttp) { + runtime_supports_xhttp = supports_xhttp == null || as_string(supports_xhttp) == "" + ? true + : cli_bool(supports_xhttp); + let cursor = uci_cursor(); + cursor.load(CONFIG_NAME); + runtime_settings_cache = object_or_empty(cursor.get_all(CONFIG_NAME, "settings")); + let settings = runtime_settings_cache; + + let sections = enabled_sections(); + let servers = enabled_servers(); + if (length(sections) == 0 && length(servers) == 0) + runtime_generate_unsupported("no enabled sections"); + + let config = base_config(settings, service_address, { mwan3_active: cli_bool(mwan3_active) }); + let taken = reserved_runtime_tag_set(config.outbounds); + reserve_section_outbound_tags(sections, taken); + for (let server in servers) + runtime_servers.add_server(config, server); + for (let section in sections) + add_outbound_for_section(config, section, taken, sections); + add_service_route_rules(config, sections); + for (let section in sections) + add_route_for_section(config, section); + add_server_routes(config, servers, sections); + add_service_mixed_proxy(config, settings, sections); + for (let section in sections) + add_mixed_proxy_for_section(config, section, service_address); + + assert_unique_outbound_tags(config); + strip_internal_fields(config); + if (!write_json_file(output_path, config)) { + warn("failed to write ", output_path, "\n"); + exit(1); + } +} + +function generate_config_fixture(fixture_path, output_path, service_address, mwan3_active, supports_xhttp) { + use_fixture_cursor(fixture_path); + runtime_subscription.set_section_cache_dir(output_path + ".section-cache"); + runtime_ruleset_folder = output_path + ".rulesets"; + generate_config(output_path, service_address, mwan3_active, supports_xhttp); +} + +function stdin_length() { + let value = read_stdin_json(); + if (type(value) == "array" || type(value) == "object") + print(length(value), "\n"); + else + print("0\n"); +} + +function stdin_contains(needle) { + return index(read_stdin(), as_string(needle)) >= 0; +} + +function stdin_regex_matches(pattern) { + pattern = as_string(pattern); + if (pattern == "") + return false; + + try { + return match(read_stdin(), regexp(pattern)) != null; + } + catch (e) { + return false; + } +} + +function ip_addr_first_inet4() { + for (let line in split(read_stdin(), "\n")) { + let fields = split(trim(as_string(line)), /[ \t]+/); + if (length(fields) < 2 || fields[0] != "inet") + continue; + + let slash = index(fields[1], "/"); + print(slash >= 0 ? substr(fields[1], 0, slash) : fields[1], "\n"); + return; + } +} + +function stdin_first_dns_a_address() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (match(line, /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) != null) { + print(line, "\n"); + return; + } + } +} + +function stdin_first_dns_aaaa_address() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (match(line, /^[0-9A-Fa-f:]+$/) != null) { + print(line, "\n"); + return; + } + } +} + +function stdin_first_nslookup_address() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (match(line, /^Address[ \t]*[0-9]*:[ \t]*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) == null && + match(line, /^Address[ \t]*[0-9]*:[ \t]*[0-9A-Fa-f:]+$/) == null) + continue; + + let fields = split(trim(line), /[ \t]+/); + if (length(fields) > 0) + print(fields[length(fields) - 1], "\n"); + return; + } +} + +function stdin_first_field() { + let data = read_stdin(); + let newline = index(data, "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + + if (length(fields) > 0 && fields[0] != "") + print(fields[0], "\n"); +} + +function array_append_string(value) { + let result = array_or_empty(read_stdin_json()); + push(result, as_string(value)); + write_json(result); +} + +function normalized_country_list() { + write_json(runtime_urltest.normalized_country_list(read_stdin_json())); +} + +function urltest_filter(mode, tags_path, names_path, countries_path, names_filter_path, regex_tags_path, countries_filter_path) { + write_json(runtime_urltest.filter_array( + mode, + read_json_file(tags_path), + read_json_file(names_path), + read_json_file(countries_path), + read_json_file(names_filter_path), + read_json_file(regex_tags_path), + read_json_file(countries_filter_path) + )); +} + +function object_nonempty_stdin() { + let value = read_stdin_json(); + return (type(value) == "array" || type(value) == "object") && length(value) > 0; +} + +let mode = ARGV[0] || ""; + +if (mode == "generate-config") + generate_config(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "generate-config-fixture") + generate_config_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "stdin-length") + stdin_length(); +else if (mode == "stdin-contains") + exit(stdin_contains(ARGV[1]) ? 0 : 1); +else if (mode == "stdin-regex-matches") + exit(stdin_regex_matches(ARGV[1]) ? 0 : 1); +else if (mode == "csv-to-json-array") + csv_to_json_array(ARGV[1]); +else if (mode == "ip-addr-first-inet4") + ip_addr_first_inet4(); +else if (mode == "stdin-first-dns-a-address") + stdin_first_dns_a_address(); +else if (mode == "stdin-first-dns-aaaa-address") + stdin_first_dns_aaaa_address(); +else if (mode == "stdin-first-nslookup-address") + stdin_first_nslookup_address(); +else if (mode == "stdin-first-field") + stdin_first_field(); +else if (mode == "array-append-string") + array_append_string(ARGV[1]); +else if (mode == "normalized-country-list") + normalized_country_list(); +else if (mode == "urltest-filter") + urltest_filter(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); +else if (mode == "object-nonempty") + exit(object_nonempty_stdin() ? 0 : 1); +else { + warn("Usage: singbox/generator.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/singbox/priority.uc b/tachyon/files/usr/lib/singbox/priority.uc new file mode 100644 index 00000000..cf65bd99 --- /dev/null +++ b/tachyon/files/usr/lib/singbox/priority.uc @@ -0,0 +1,492 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); + +let as_string = common.as_string; +let read_json_file = common.read_json_file; +let write_json = common.write_json; +let array_or_empty = common.array_or_empty; +let object_or_empty = common.object_or_empty; + +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; +const PRIORITY_PID_FILE = getenv("TACHYON_PRIORITY_PID_FILE") || RUNTIME_STATE_DIR + "/priority.pid"; +const PRIORITY_UC = getenv("TACHYON_PRIORITY_UC") || LIB_DIR + "/singbox/priority.uc"; +const DIAGNOSTICS_UC = getenv("TACHYON_DIAGNOSTICS_UC") || LIB_DIR + "/diagnostics/runtime.uc"; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success_from_args(args) { + return command_status(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function ensure_dir(path) { + return command_success_from_args([ "mkdir", "-p", path ]); +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function file_first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + let newline = index(data, "\n"); + return trim(newline >= 0 ? substr(data, 0, newline) : data); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] priority: " + as_string(message) ]); +} + +function now_seconds() { + return int(clock()[0]); +} + +function duration_to_milliseconds(value, fallback_ms) { + let rest = as_string(value); + if (rest == "") + return fallback_ms; + + let total = 0.0; + let multipliers = { + ns: 0.000001, + us: 0.001, + ms: 1, + s: 1000, + m: 60000, + h: 3600000, + d: 86400000 + }; + + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return fallback_ms; + + let token = as_string(matched[0]); + total += (matched[1] * 1) * multipliers[matched[3]]; + rest = substr(rest, length(token)); + } + + return total <= 0 ? fallback_ms : int(total + 0.5); +} + +function duration_to_seconds(value, fallback_seconds) { + let ms = duration_to_milliseconds(value, fallback_seconds * 1000); + let seconds = int((ms + 999) / 1000); + return seconds > 0 ? seconds : fallback_seconds; +} + +function bool_value(value, fallback) { + if (value == null || value == "") + return !!fallback; + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function normalize_group(group, tag_name) { + group = object_or_empty(group); + let levels = []; + for (let level in array_or_empty(group.levels)) { + let outbounds = []; + for (let outbound in array_or_empty(level.outbounds)) { + outbound = as_string(outbound); + if (outbound != "") + push(outbounds, outbound); + } + if (length(outbounds) > 0) { + push(levels, { + id: as_string(level.id || ""), + displayName: as_string(level.displayName || level.name || ""), + order: int(level.order || 0), + outbounds + }); + } + } + + return { + id: as_string(group.id || ""), + tag: as_string(group.tag || tag_name), + section: as_string(group.section || ""), + displayName: as_string(group.displayName || group.name || tag_name), + health_url: as_string(group.health_url || "https://www.gstatic.com/generate_204"), + active_check_interval: as_string(group.active_check_interval || "5s"), + check_timeout: as_string(group.check_timeout || "2s"), + recovery_check_interval: as_string(group.recovery_check_interval || "15s"), + pick_fastest: bool_value(group.pick_fastest, false), + switch_to_faster_same_priority: bool_value(group.switch_to_faster_same_priority, false), + fastest_check_interval: as_string(group.fastest_check_interval || "3m"), + levels + }; +} + +function section_cache_files() { + let result = []; + for (let path in split(command_output_from_args([ + "find", + SECTION_CACHE_DIR, + "-mindepth", + "1", + "-maxdepth", + "1", + "-type", + "f", + "-name", + "*.json" + ]), "\n")) { + path = as_string(path); + if (path != "") + push(result, path); + } + return result; +} + +function priority_groups_from_cache() { + let result = []; + for (let path in section_cache_files()) { + let cache = object_or_empty(read_json_file(path)); + for (let tag_name, group in object_or_empty(cache.priorityGroups)) { + let normalized = normalize_group(group, tag_name); + if (normalized.tag != "" && length(normalized.levels) > 0) + push(result, normalized); + } + } + return result; +} + +function module_capture(args) { + let output_path = trim(command_output_from_args([ "mktemp" ])); + if (output_path == "") + return { status: 1, output: "" }; + + let command = command_from_args([ "ucode", "-L", LIB_DIR, DIAGNOSTICS_UC, "clash-api" ]); + for (let arg in args) + command += " " + shell_quote(arg); + + let status = command_status(command + " >" + shell_quote(output_path) + " 2>&1"); + let output = as_string(fs.readfile(output_path) || ""); + remove_file(output_path); + return { status, output }; +} + +function parse_delay_output(output) { + let value = null; + try { + value = json(output); + } + catch (e) { + return null; + } + + if (type(value) != "object") + return null; + + let delay = value.delay; + if (delay == null || as_string(delay) == "") + return null; + + delay = int(delay, 10); + return delay >= 0 ? delay : null; +} + +function clash_probe(tag_name, group) { + let timeout = as_string(duration_to_milliseconds(group.check_timeout, 2000)); + let result = module_capture([ "get_proxy_latency", tag_name, timeout, group.health_url ]); + if (result.status != 0) + return { alive: false, delay: 0 }; + + let delay = parse_delay_output(result.output); + if (delay == null) + return { alive: false, delay: 0 }; + + return { alive: true, delay }; +} + +function fixture_probe(latencies, tag_name) { + let value = object_or_empty(latencies)[tag_name]; + if (value == null || as_string(value) == "" || int(value, 10) < 0) + return { alive: false, delay: 0 }; + return { alive: true, delay: int(value, 10) }; +} + +function choose_from_level(group, level_index, probe, skip_tag) { + let level = object_or_empty(array_or_empty(group.levels)[level_index]); + let best = null; + + for (let tag_name in array_or_empty(level.outbounds)) { + if (as_string(skip_tag) != "" && tag_name == skip_tag) + continue; + let result = probe(tag_name, group); + if (!result.alive) + continue; + + if (!group.pick_fastest) + return { + tag: tag_name, + levelIndex: level_index, + delay: result.delay + }; + + if (best == null || result.delay < best.delay) + best = { + tag: tag_name, + levelIndex: level_index, + delay: result.delay + }; + } + + return best; +} + +function choose_from_level_range(group, start_index, end_index, probe, skip_tag) { + let levels = array_or_empty(group.levels); + if (length(levels) == 0) + return null; + + start_index = int(start_index || 0); + end_index = int(end_index || 0); + if (start_index < 0) + start_index = 0; + if (end_index >= length(levels)) + end_index = length(levels) - 1; + if (start_index > end_index) + return null; + + for (let i = start_index; i <= end_index; i++) { + let selected = choose_from_level(group, i, probe, skip_tag); + if (selected != null) + return selected; + } + return null; +} + +function choose_fastest_same_level(group, level_index, active_tag, probe) { + let level = object_or_empty(array_or_empty(group.levels)[level_index]); + let active = null; + let best = null; + + for (let tag_name in array_or_empty(level.outbounds)) { + let result = probe(tag_name, group); + if (tag_name == active_tag) + active = result; + if (!result.alive) + continue; + if (best == null || result.delay < best.delay) { + best = { + tag: tag_name, + levelIndex: level_index, + delay: result.delay + }; + } + } + + if (best == null || best.tag == active_tag) + return null; + if (active == null || !active.alive || best.delay < active.delay) + return best; + return null; +} + +function set_group_proxy(group, tag_name) { + let result = module_capture([ "set_group_proxy", group.tag, tag_name, "" ]); + return result.status == 0; +} + +function switch_group(state, group, selected) { + if (selected == null || selected.tag == "") + return false; + + if (state.active == selected.tag) { + state.levelIndex = selected.levelIndex; + state.activeDelay = selected.delay; + return true; + } + + if (!set_group_proxy(group, selected.tag)) { + log_message("failed to switch " + group.tag + " to " + selected.tag, "warn"); + return false; + } + + state.active = selected.tag; + state.levelIndex = selected.levelIndex; + state.activeDelay = selected.delay; + return true; +} + +function init_group_state(group) { + return { + active: "", + levelIndex: -1, + activeDelay: 0, + nextActiveCheck: now_seconds(), + nextRecoveryCheck: now_seconds() + duration_to_seconds(group.recovery_check_interval, 15), + nextFastestCheck: now_seconds() + duration_to_seconds(group.fastest_check_interval, 180) + }; +} + +function tick_group(state, group) { + let now = now_seconds(); + + if (state.active == "" && now >= state.nextActiveCheck) { + let selected = choose_from_level_range(group, 0, length(group.levels) - 1, clash_probe); + switch_group(state, group, selected); + state.nextActiveCheck = now + duration_to_seconds(group.active_check_interval, 5); + return; + } + + if (state.active != "" && now >= state.nextActiveCheck) { + let active = clash_probe(state.active, group); + if (active.alive) { + state.activeDelay = active.delay; + } + else { + let selected = choose_from_level_range( + group, + state.levelIndex, + length(group.levels) - 1, + clash_probe, + state.active + ); + if (switch_group(state, group, selected)) { + state.nextRecoveryCheck = now + duration_to_seconds(group.recovery_check_interval, 15); + state.nextFastestCheck = now + duration_to_seconds(group.fastest_check_interval, 180); + } + else { + state.active = ""; + state.levelIndex = -1; + } + } + state.nextActiveCheck = now + duration_to_seconds(group.active_check_interval, 5); + } + + if (state.active != "" && state.levelIndex > 0 && now >= state.nextRecoveryCheck) { + let selected = choose_from_level_range(group, 0, state.levelIndex - 1, clash_probe); + if (switch_group(state, group, selected)) + state.nextFastestCheck = now + duration_to_seconds(group.fastest_check_interval, 180); + state.nextRecoveryCheck = now + duration_to_seconds(group.recovery_check_interval, 15); + } + + if (state.active != "" && group.switch_to_faster_same_priority && now >= state.nextFastestCheck) { + let selected = choose_fastest_same_level(group, state.levelIndex, state.active, clash_probe); + if (selected != null) + switch_group(state, group, selected); + state.nextFastestCheck = now + duration_to_seconds(group.fastest_check_interval, 180); + } +} + +function worker() { + let groups = priority_groups_from_cache(); + if (length(groups) == 0) + return 0; + + let states = {}; + for (let group in groups) + states[group.tag] = init_group_state(group); + + while (true) { + for (let group in groups) + tick_group(states[group.tag], group); + system("sleep 1"); + } +} + +function process_running(pid) { + pid = trim(as_string(pid)); + return pid != "" && match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function stop_runtime() { + let pid = file_first_line(PRIORITY_PID_FILE); + if (process_running(pid)) + command_success_from_args([ "kill", pid ]); + remove_file(PRIORITY_PID_FILE); + return 0; +} + +function start_runtime() { + let groups = priority_groups_from_cache(); + stop_runtime(); + if (length(groups) == 0) + return 0; + + if (!ensure_dir(RUNTIME_STATE_DIR)) + return 1; + + let command = command_from_args([ "ucode", "-L", LIB_DIR, PRIORITY_UC, "worker" ]) + + " >/dev/null 2>&1 1000>&- & echo $! >" + shell_quote(PRIORITY_PID_FILE); + return command_status(command); +} + +function select_fixture(group_path, latency_path, start_index, end_index, skip_tag) { + let group = normalize_group(read_json_file(group_path), "fixture"); + let latencies = object_or_empty(read_json_file(latency_path)); + let selected = choose_from_level_range(group, start_index, end_index, function(tag_name, _group) { + return fixture_probe(latencies, tag_name); + }, skip_tag); + write_json(selected == null ? {} : selected); +} + +function select_faster_fixture(group_path, latency_path, level_index, active_tag) { + let group = normalize_group(read_json_file(group_path), "fixture"); + let latencies = object_or_empty(read_json_file(latency_path)); + let selected = choose_fastest_same_level(group, int(level_index || 0), as_string(active_tag), function(tag_name, _group) { + return fixture_probe(latencies, tag_name); + }); + write_json(selected == null ? {} : selected); +} + +let mode = ARGV[0] || ""; + +if (mode == "start-runtime") + exit(start_runtime()); +else if (mode == "stop-runtime") + exit(stop_runtime()); +else if (mode == "worker") + exit(worker()); +else if (mode == "select-fixture") + select_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "select-faster-fixture") + select_faster_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else { + warn("Usage: singbox/priority.uc \n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/singbox/route.uc b/tachyon/files/usr/lib/singbox/route.uc new file mode 100644 index 00000000..652f0f83 --- /dev/null +++ b/tachyon/files/usr/lib/singbox/route.uc @@ -0,0 +1,96 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); +let connections = require("config.connections"); +let runtime_constants = require("singbox.constants"); + +let option = common.option; +let bool_option = common.bool_option; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function bool_value(value) { + return value === true || value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function config(settings, runtime) { + let output_network_interface = option(settings, "output_network_interface", ""); + let mwan3_active = type(runtime) == "object" && bool_value(runtime.mwan3_active); + let sniff_inbounds = [ runtime_constants.TPROXY_INBOUND_TAG, runtime_constants.DNS_INBOUND_TAG ]; + if (type(runtime) == "object" && type(runtime.dns_health_inbounds) == "array") + for (let inbound in runtime.dns_health_inbounds) + push(sniff_inbounds, inbound); + let result = { + rules: [ + { action: "sniff", inbound: sniff_inbounds }, + { action: "hijack-dns", port: 53 }, + { action: "hijack-dns", protocol: "dns" } + ], + rule_set: [], + final: runtime_constants.DIRECT_OUTBOUND_TAG, + auto_detect_interface: output_network_interface == "" && !mwan3_active, + default_domain_resolver: type(runtime) == "object" && as_string(runtime.default_domain_resolver) != "" + ? as_string(runtime.default_domain_resolver) + : runtime_constants.DNS_SERVER_TAG, + default_mark: runtime_constants.OUTBOUND_MARK + }; + + if (output_network_interface != "") + result.default_interface = output_network_interface; + if (bool_option(settings, "disable_quic", false)) + push(result.rules, { action: "reject", inbound: runtime_constants.TPROXY_INBOUND_TAG, protocol: "quic" }); + + return result; +} + +function target(section, outbound_tag_name) { + let action = option(section, "action", ""); + if (connections.is_connections_action(action) || + action == "byedpi" || action == "zapret" || action == "zapret2") + return { action: "route", outbound: outbound_tag_name }; + if (action == "bypass") + return { action: "route", outbound: runtime_constants.BYPASS_OUTBOUND_TAG }; + if (action == "block") + return { action: "reject" }; + return { unsupported: "unsupported action " + action }; +} + +function has_resolve_matchers(rule) { + return rule.domain != null || rule.domain_suffix != null || rule.domain_keyword != null || + rule.domain_regex != null || rule.rule_set != null; +} + +function resolve_rule_for_section(section, route_rule) { + let action = option(section, "action", ""); + let should_resolve = action == "byedpi" || + (connections.is_connections_action(action) && + bool_option(section, "resolve_real_ip_for_routing", false)); + + if (!should_resolve) + return null; + + if (!has_resolve_matchers(route_rule)) { + return { + warning: "Resolve real IP is enabled for '" + section[".name"] + "', but no domain or rule-set matchers found" + }; + } + + let resolve_rule = {}; + for (let key in [ "inbound", "source_ip_cidr", "domain", "domain_suffix", "domain_keyword", "domain_regex", "rule_set", "port", "port_range" ]) { + if (route_rule[key] != null) + resolve_rule[key] = route_rule[key]; + } + resolve_rule.action = "resolve"; + resolve_rule.server = runtime_constants.DNS_SERVER_TAG; + + return { rule: resolve_rule }; +} + +return { + config, + target, + has_resolve_matchers, + resolve_rule_for_section +}; diff --git a/tachyon/files/usr/lib/singbox/rulesets.uc b/tachyon/files/usr/lib/singbox/rulesets.uc new file mode 100644 index 00000000..dee144c3 --- /dev/null +++ b/tachyon/files/usr/lib/singbox/rulesets.uc @@ -0,0 +1,132 @@ +#!/usr/bin/env ucode + +const SRS_MAIN_URL = "https://github.com/itdoginfo/allow-domains/releases/latest/download"; +const SRS_ADS_HAGEZI_PRO_URL = "https://github.com/zxc-rv/ad-filter/releases/latest/download/adlist.srs"; +const SRS_SUPERCELL_URL = "https://raw.githubusercontent.com/ushan0v/sing-box-supercell-ruleset/main/supercell.srs"; +const SRS_GITHUB_URL = "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geosite/github.srs"; + +const COMMUNITY_SERVICES = { + russia_inside: true, + russia_outside: true, + ukraine_inside: true, + geoblock: true, + block: true, + porn: true, + news: true, + anime: true, + youtube: true, + hdrezka: true, + tiktok: true, + google_ai: true, + google_play: true, + hodca: true, + discord: true, + meta: true, + twitter: true, + cloudflare: true, + cloudfront: true, + digitalocean: true, + hetzner: true, + ovh: true, + telegram: true, + roblox: true, + ads_hagezi_pro: true, + supercell: true, + github: true +}; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function is_community(name) { + return COMMUNITY_SERVICES[as_string(name)] === true; +} + +function community_url(name) { + name = as_string(name); + if (name == "ads_hagezi_pro") + return SRS_ADS_HAGEZI_PRO_URL; + if (name == "supercell") + return SRS_SUPERCELL_URL; + if (name == "github") + return SRS_GITHUB_URL; + return SRS_MAIN_URL + "/" + name + ".srs"; +} + +function hash12(value) { + value = as_string(value); + let first = 2166136261; + let second = 16777619; + + for (let i = 0; i < length(value); i++) { + let code = ord(substr(value, i, 1)); + first = (first * 33 + code) % 4294967296; + second = (second * 131 + code) % 4294967296; + } + + return sprintf("%06x%06x", first % 16777216, second % 16777216); +} + +function file_extension(value) { + let basename = as_string(value); + let slash = rindex(basename, "/"); + if (slash >= 0) + basename = substr(basename, slash + 1); + + let query = index(basename, "?"); + if (query >= 0) + basename = substr(basename, 0, query); + + let fragment = index(basename, "#"); + if (fragment >= 0) + basename = substr(basename, 0, fragment); + + let dot = rindex(basename, "."); + return dot >= 0 ? lc(substr(basename, dot + 1)) : ""; +} + +function kind_from_reference_hint(reference) { + reference = lc(as_string(reference)); + if (index(reference, "geosite") >= 0 || index(reference, "domain") >= 0 || + index(reference, "domains") >= 0 || index(reference, "adguard") >= 0 || + index(reference, "filter") >= 0) + return "domains"; + if (index(reference, "geoip") >= 0 || index(reference, "subnet") >= 0 || + index(reference, "subnets") >= 0 || index(reference, "cidr") >= 0) + return "subnets"; + return "unknown"; +} + +function remote_format(reference) { + return file_extension(reference) == "json" ? "source" : "binary"; +} + +function module_exports() { + return { + is_community, + community_url, + hash12, + file_extension, + kind_from_reference_hint, + remote_format + }; +} + +if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null) + return module_exports(); + +let mode = ARGV[0] || ""; + +if (mode == "file-extension") + print(file_extension(ARGV[1]), "\n"); +else if (mode == "is-community") + exit(is_community(ARGV[1]) ? 0 : 1); +else if (mode == "kind-from-reference-hint") + print(kind_from_reference_hint(ARGV[1]), "\n"); +else if (mode == "remote-format") + print(remote_format(ARGV[1]), "\n"); +else { + warn("Usage: singbox/rulesets.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/singbox/runtime.uc b/tachyon/files/usr/lib/singbox/runtime.uc new file mode 100644 index 00000000..5f775451 --- /dev/null +++ b/tachyon/files/usr/lib/singbox/runtime.uc @@ -0,0 +1,926 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); +let common = require("core.common"); +let runtime_dns = require("singbox.dns"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || "/tmp/sing-box"; +const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || TMP_SING_BOX_FOLDER + "/rulesets"; +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || TMP_SING_BOX_FOLDER + "/subscriptions"; +const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update"; +const SUBSCRIPTION_LINKS_DIR = getenv("TACHYON_SUBSCRIPTION_LINKS_DIR") || RUNTIME_STATE_DIR + "/subscription-links"; +const SUBSCRIPTION_METADATA_DIR = getenv("TACHYON_SUBSCRIPTION_METADATA_DIR") || RUNTIME_STATE_DIR + "/subscription-metadata"; +const OUTBOUND_METADATA_DIR = getenv("TACHYON_OUTBOUND_METADATA_DIR") || RUNTIME_STATE_DIR + "/outbound-metadata"; +const SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; +const RUNTIME_CACHE_FORMAT_FILE = getenv("TACHYON_RUNTIME_CACHE_FORMAT_FILE") || RUNTIME_STATE_DIR + "/cache-format"; +const RUNTIME_CACHE_FORMAT = getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8"; +const PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/tachyon/subscription-cache"; +const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; +const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT") || "7"; +const PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending"; +const SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || "/etc/init.d/tachyon"; +const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || "TachyonTable"; +const NFT_COMMON_SET_NAME = getenv("NFT_COMMON_SET_NAME") || "tachyon_subnets"; +const NFT_COMMON6_SET_NAME = getenv("NFT_COMMON6_SET_NAME") || "tachyon_subnets6"; +const NFT_PORT_SET_NAME = getenv("NFT_PORT_SET_NAME") || "tachyon_ports"; +const NFT_IP_PORT_SET_NAME = getenv("NFT_IP_PORT_SET_NAME") || "tachyon_ip_ports"; +const NFT_IP_PORT6_SET_NAME = getenv("NFT_IP_PORT6_SET_NAME") || "tachyon_ip6_ports"; +const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || "tachyon_interfaces"; +const NFT_LOCALV4_SET_NAME = getenv("NFT_LOCALV4_SET_NAME") || "localv4"; +const NFT_LOCALV6_SET_NAME = getenv("NFT_LOCALV6_SET_NAME") || "localv6"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x00100000"; +const SB_SERVICE_MIXED_INBOUND_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1"; +const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534"; +const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || "/etc/tachyon/sing-box-variant"; +const SB_VERSION_STATE_FILE = getenv("SB_VERSION_STATE_FILE") || "/etc/tachyon/sing-box-version"; +const SB_MANAGED_SERVICE_MARKER = getenv("SB_MANAGED_SERVICE_MARKER") || "Tachyon managed sing-box service for binary variants"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_env(assignments) { + let parts = []; + for (let name, value in assignments) + push(parts, name + "=" + shell_quote(value)); + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_exists(name) { + return command_success_from_args([ "command", "-v", name ]); +} + +function write_file(path, value) { + return fs.writefile(as_string(path), as_string(value)) != null; +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function remove_files(paths) { + for (let path in paths) + if (as_string(path) != "") + remove_file(path); +} + +function file_first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + let newline = index(data, "\n"); + return trim(newline >= 0 ? substr(data, 0, newline) : data); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "true" || value == "1" || value == "yes" || value == "on"; +} + +function bool_option(section, key, fallback) { + let value = object_or_empty(section)[key]; + return value == null ? !!fallback : arg_bool(value); +} + +function whitespace_items(value) { + let result = []; + if (type(value) == "array") { + for (let item in value) { + item = as_string(item); + if (item != "") + push(result, item); + } + return result; + } + + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function file_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function parent_dir(path) { + path = as_string(path); + let slash = rindex(path, "/"); + return slash >= 0 ? substr(path, 0, slash) : ""; +} + +function ensure_dir(path) { + return command_success_from_args([ "mkdir", "-p", path ]); +} + +function ensure_parent_dir(path) { + let dir = parent_dir(path); + return dir == "" || dir == "." || ensure_dir(dir); +} + +function temp_path() { + return trim(command_output_from_args([ "mktemp" ])); +} + +function md5_file(path) { + let line = trim(command_output_from_args([ "md5sum", as_string(path) ])); + return length(line) >= 32 ? substr(line, 0, 32) : ""; +} + +function first_line_last_field(value) { + value = as_string(value); + let newline = index(value, "\n"); + let line = trim(newline >= 0 ? substr(value, 0, newline) : value); + let fields = split(line, /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[length(fields) - 1]) : ""; +} + +function sing_box_version_output() { + return command_exists("sing-box") ? command_output_from_args([ "sing-box", "version" ]) : ""; +} + +function sing_box_marker_is(value) { + return file_first_line(SB_VARIANT_STATE_FILE) == as_string(value); +} + +function sing_box_version_state() { + return file_first_line(SB_VERSION_STATE_FILE); +} + +function sing_box_write_version_state(version) { + version = as_string(version); + return version != "" && ensure_parent_dir(SB_VERSION_STATE_FILE) && write_file(SB_VERSION_STATE_FILE, version + "\n"); +} + +function sing_box_clear_version_state() { + remove_file(SB_VERSION_STATE_FILE); + return true; +} + +function sing_box_restore_version_state(version) { + version = as_string(version); + return version != "" ? sing_box_write_version_state(version) : sing_box_clear_version_state(); +} + +function sing_box_variant_marker() { + return file_first_line(SB_VARIANT_STATE_FILE); +} + +function sing_box_write_variant_marker(variant) { + variant = as_string(variant); + return variant != "" && ensure_parent_dir(SB_VARIANT_STATE_FILE) && write_file(SB_VARIANT_STATE_FILE, variant + "\n"); +} + +function sing_box_clear_variant_marker() { + remove_file(SB_VARIANT_STATE_FILE); + return true; +} + +function sing_box_restore_variant_marker(variant) { + variant = as_string(variant); + return variant != "" ? sing_box_write_variant_marker(variant) : sing_box_clear_variant_marker(); +} + +function sing_box_version() { + if (!command_exists("sing-box")) + return ""; + if (sing_box_marker_is("extended-compressed")) + return sing_box_version_state(); + return first_line_last_field(sing_box_version_output()); +} + +function sing_box_version_is_extended(value) { + return index(as_string(value), "extended") >= 0; +} + +function sing_box_is_extended(value) { + value = as_string(value); + if (value == "" && command_exists("sing-box") && (sing_box_marker_is("extended-compressed") || sing_box_marker_is("extended"))) + return true; + + return sing_box_version_is_extended(value != "" ? value : sing_box_version()); +} + +function output_has_build_tag(output, tag) { + tag = as_string(tag); + if (tag == "") + return false; + + for (let item in split(trim(replace(as_string(output), /[,: \t\r\n]+/g, " ")), " ")) + if (as_string(item) == tag) + return true; + return false; +} + +function sing_box_supports_tailscale(version, version_output) { + version = as_string(version); + version_output = as_string(version_output); + + if (command_exists("sing-box") && sing_box_marker_is("extended-compressed")) + return true; + if (sing_box_is_extended(version)) + return true; + if (version_output != "") + return output_has_build_tag(version_output, "with_tailscale"); + return output_has_build_tag(sing_box_version_output(), "with_tailscale"); +} + +function module_command(args) { + let command_args = [ "ucode", "-L", LIB_DIR ]; + for (let arg in args) + push(command_args, arg); + return command_from_args(command_args); +} + +function module_success(args) { + return command_status(module_command(args)) == 0; +} + +function sing_box_package_installed(name) { + return module_success([ LIB_DIR + "/core/packages.uc", "installed", as_string(name) ]); +} + +function sing_box_is_tiny(version, version_output) { + version = as_string(version); + version_output = as_string(version_output); + + if (command_exists("sing-box") && sing_box_marker_is("extended-compressed")) + return false; + if (sing_box_is_extended(version != "" ? version : sing_box_version())) + return false; + if (sing_box_package_installed("sing-box-tiny")) + return true; + if (!sing_box_marker_is("tiny")) + return false; + return !sing_box_supports_tailscale(version, version_output); +} + +function sing_box_variant() { + let version = ""; + + if (!command_exists("sing-box")) + return "not-installed"; + if (sing_box_marker_is("extended-compressed")) + return "extended-compressed"; + + version = sing_box_version(); + if (sing_box_is_extended(version)) + return sing_box_marker_is("extended-compressed") ? "extended-compressed" : "extended"; + if (sing_box_is_tiny(version, "")) + return "tiny"; + return "stable"; +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]); +} + +function module_env_capture(env, args) { + let output_path = temp_path(); + if (output_path == "") + return { status: 1, output: "" }; + + let status = command_status(command_env(env) + " " + module_command(args) + " >" + shell_quote(output_path) + " 2>&1"); + let output = as_string(fs.readfile(output_path) || ""); + remove_file(output_path); + return { status, output }; +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function uci_path(config, section, key) { + return as_string(config) + "." + as_string(section) + "." + as_string(key); +} + +function uci_get_option(config, section, key) { + return trim(uci_core.get(uci_path(config, section, key))); +} + +function ensure_sing_box_main_section() { + if (uci_core.exists("sing-box.main")) + return true; + return uci_core.set_section("sing-box.main", "sing-box"); +} + +function uci_set_option(config, section, key, value) { + if (!uci_core.exists(as_string(config) + "." + as_string(section))) + return false; + return uci_core.set(uci_path(config, section, key), value); +} + +function uci_commit(config) { + return uci_core.commit(config); +} + +function owner_pid() { + let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ])); + return match(pid, /^[0-9]+$/) != null ? pid : "0"; +} + +function managed_service_installed() { + let data = fs.readfile("/etc/init.d/sing-box"); + return data != null && index(as_string(data), SB_MANAGED_SERVICE_MARKER) >= 0; +} + +function managed_service_text() { + return "#!/bin/sh /etc/rc.common\n" + + "# " + SB_MANAGED_SERVICE_MARKER + "\n\n" + + "USE_PROCD=1\n" + + "START=99\n" + + "PROG=\"/usr/bin/sing-box\"\n\n" + + "start_service() {\n" + + " config_load \"sing-box\"\n" + + " local enabled config_file working_directory\n" + + " local log_stderr\n\n" + + " config_get_bool enabled \"main\" \"enabled\" \"0\"\n" + + " [ \"$enabled\" -eq \"1\" ] || return 0\n\n" + + " config_get config_file \"main\" \"conffile\" \"/etc/sing-box/config.json\"\n" + + " config_get working_directory \"main\" \"workdir\" \"/usr/share/sing-box\"\n" + + " config_get_bool log_stderr \"main\" \"log_stderr\" \"1\"\n\n" + + " procd_open_instance\n" + + " procd_set_param command \"$PROG\" run -c \"$config_file\" -D \"$working_directory\"\n" + + " procd_set_param file \"$config_file\"\n" + + " procd_set_param stderr \"$log_stderr\"\n" + + " procd_set_param limits core=\"unlimited\"\n" + + " procd_set_param limits nofile=\"1000000 1000000\"\n" + + " procd_set_param respawn\n" + + " procd_close_instance\n" + + "}\n\n" + + "service_triggers() {\n" + + " procd_add_reload_trigger \"sing-box\"\n" + + "}\n"; +} + +function sing_box_compressed_marker_set() { + return trim(as_string(fs.readfile(SB_VARIANT_STATE_FILE) || "")) == "extended-compressed"; +} + +function install_managed_service_script() { + let tmp = "/etc/init.d/sing-box.tachyon." + owner_pid(); + if (!write_file(tmp, managed_service_text())) + return false; + if (!command_success_from_args([ "chmod", "0755", tmp ])) { + remove_file(tmp); + return false; + } + return fs.rename(tmp, "/etc/init.d/sing-box"); +} + +function remove_managed_service_script() { + if (!managed_service_installed()) + return; + + command_success_from_args([ "/etc/init.d/sing-box", "stop" ]); + command_success_from_args([ "/etc/init.d/sing-box", "disable" ]); + remove_file("/etc/init.d/sing-box"); +} + +function disable_service_config() { + if (!ensure_sing_box_main_section()) + return; + uci_set_option("sing-box", "main", "enabled", "0"); + uci_commit("sing-box"); +} + +function prepare_service_disabled() { + disable_service_config(); + if (file_exists("/etc/init.d/sing-box")) { + command_success_from_args([ "/etc/init.d/sing-box", "stop" ]); + command_success_from_args([ "/etc/init.d/sing-box", "disable" ]); + } +} + +function configure_service() { + let settings = uci_settings(); + + if (sing_box_compressed_marker_set() && !install_managed_service_script()) { + log_message("Failed to install managed sing-box service for compressed binary. Aborted.", "fatal"); + exit(1); + } + + if (!ensure_sing_box_main_section()) + exit(1); + + let changed = false; + if (uci_get_option("sing-box", "main", "enabled") != "1") { + if (!uci_set_option("sing-box", "main", "enabled", "1")) + exit(1); + changed = true; + log_message("sing-box service has been enabled", "info"); + } + + if (uci_get_option("sing-box", "main", "user") != "root") { + if (!uci_set_option("sing-box", "main", "user", "root")) + exit(1); + changed = true; + log_message("sing-box service user has been changed to root", "info"); + } + + let config_path = option(settings, "config_path", ""); + let conffile = uci_get_option("sing-box", "main", "conffile"); + if (conffile != config_path) { + if (!uci_set_option("sing-box", "main", "conffile", config_path)) + exit(1); + changed = true; + log_message("sing-box service config path set to " + config_path, "info"); + } + + if (changed && !uci_commit("sing-box")) + exit(1); + + if (file_exists("/etc/rc.d/S99sing-box")) { + log_message("Disabling standalone sing-box autostart", "info"); + command_success_from_args([ "/etc/init.d/sing-box", "disable" ]); + } +} + +function download_via_proxy_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy"; + if (purpose == "components") + return "download_components_via_proxy"; + return ""; +} + +function download_via_proxy_section_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy_section"; + if (purpose == "components") + return "download_components_via_proxy_section"; + return ""; +} + +function download_via_proxy_section(settings, purpose) { + let enabled_option = download_via_proxy_option_for_purpose(purpose); + if (enabled_option == "" || !bool_option(settings, enabled_option, false)) + return ""; + + let section_option = download_via_proxy_section_option_for_purpose(purpose); + let configured = section_option != "" ? option(settings, section_option, "") : ""; + if (configured != "") + return configured; + + return option(settings, "download_lists_via_proxy_section", ""); +} + +function service_proxy_port_for_purpose(purpose) { + return int(SB_SERVICE_MIXED_INBOUND_PORT) + (as_string(purpose || "lists") == "components" ? 1 : 0); +} + +function service_proxy_address(settings, purpose) { + return download_via_proxy_section(settings, purpose) != "" ? + SB_SERVICE_MIXED_INBOUND_ADDRESS + ":" + service_proxy_port_for_purpose(purpose) : ""; +} + +function ip_addr_first_inet4(data) { + for (let line in split(as_string(data), "\n")) { + let matched = match(line, /inet[ \t]+([0-9.]+)\//); + if (matched) + return as_string(matched[1]); + } + return ""; +} + +function network_interface_ipv4(name) { + let data = command_output_from_args([ "ubus", "call", "network.interface." + as_string(name), "status" ]); + try { + let value = json(data); + let addresses = array_or_empty(object_or_empty(value)["ipv4-address"]); + if (length(addresses) > 0) + return as_string(object_or_empty(addresses[0]).address); + } + catch (e) { + } + return ""; +} + +function device_ipv4_address_value(device) { + return ip_addr_first_inet4(command_output_from_args([ "ip", "-4", "addr", "show", "dev", as_string(device) ])); +} + +function service_listen_address_value(settings) { + let configured = option(settings, "service_listen_address", ""); + if (configured != "") { + log_message("service_listen_address is set manually; automatic listen-address detection is skipped", "warn"); + return configured; + } + + let address = network_interface_ipv4("lan"); + if (address != "") + return address; + + for (let iface in whitespace_items(option(settings, "source_network_interfaces", "br-lan"))) { + address = network_interface_ipv4(iface); + if (address != "") + return address; + address = device_ipv4_address_value(iface); + if (address != "") + return address; + } + + log_message("Failed to determine the listening IP address. Please open an issue to report this problem: https://github.com/ushan0v/tachyon/issues", "error"); + return ""; +} + +function subscription_cache_env() { + return { + TACHYON_CONFIG_NAME: CONFIG_NAME, + TACHYON_LIB: LIB_DIR, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER, + TACHYON_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, + TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, + TACHYON_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, + TACHYON_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, + TACHYON_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, + TACHYON_SECTION_CACHE_DIR: SECTION_CACHE_DIR, + TACHYON_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, + TACHYON_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT, + TACHYON_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE, + TACHYON_SERVICE_INIT: SERVICE_INIT + }; +} + +function subscription_cache_capture(args) { + let command_args = [ LIB_DIR + "/subscription/cache.uc" ]; + for (let arg in args) + push(command_args, arg); + return module_env_capture(subscription_cache_env(), command_args); +} + +function log_lines(text, level, prefix) { + for (let line in split(as_string(text), "\n")) + if (trim(as_string(line)) != "") + log_message(as_string(prefix) + as_string(line), level); +} + +function first_nonblank_line(path) { + let data = as_string(fs.readfile(path) || ""); + for (let line in split(data, "\n")) + if (trim(as_string(line)) != "") + return as_string(line); + return ""; +} + +function last_nonblank_line(path) { + let result = ""; + let data = as_string(fs.readfile(path) || ""); + for (let line in split(data, "\n")) + if (trim(as_string(line)) != "") + result = as_string(line); + return result; +} + +function generator_failure_reason(path, status) { + let reason = last_nonblank_line(path); + return reason != "" ? reason : "exit status " + status; +} + +function log_file_lines(path, level, prefix) { + log_lines(fs.readfile(path), level, prefix); +} + +function sing_box_check(config_path, output_path) { + let status = command_status( + command_from_args([ "sing-box", "-c", config_path, "check" ]) + + " >" + shell_quote(output_path) + " 2>&1" + ); + let reason = status == 0 ? "" : first_nonblank_line(output_path); + if (status != 0 && reason == "") + reason = "exit status " + status; + return { status, reason }; +} + +function prepare_subscription_caches(prepared, no_refresh) { + let result = subscription_cache_capture([ "prepare-caches", "runtime", prepared ? "1" : "0", no_refresh ? "1" : "0" ]); + if (result.status != 0) { + log_message("Subscription caches are not ready for sing-box config generation. Aborted.", "fatal"); + log_lines(result.output, "debug", "subscription cache: "); + return null; + } + return trim(result.output); +} + +function save_config_file(temp_file_path, config_path) { + let current_hash = md5_file(config_path); + let temp_hash = md5_file(temp_file_path); + + if (current_hash != temp_hash) { + log_message("sing-box configuration changed; updating " + config_path, "info"); + if (!ensure_parent_dir(config_path)) + return false; + return command_success_from_args([ "mv", "-f", temp_file_path, config_path ]); + } + + log_message("sing-box configuration is unchanged", "info"); + remove_file(temp_file_path); + return true; +} + +function replace_dns_server(config, replacement) { + let servers = array_or_empty(object_or_empty(config.dns).servers); + for (let i = 0; i < length(servers); i++) { + if (as_string(object_or_empty(servers[i]).tag) == as_string(replacement.tag)) { + servers[i] = replacement; + return true; + } + } + return false; +} + +function patch_dns_config(state_path) { + let settings = uci_settings(); + let candidate_state = common.read_json_file(state_path); + let expected = runtime_dns.state_template(settings); + if (!runtime_dns.state_matches(expected, candidate_state)) { + log_message("DNS failover state does not match the current UCI configuration", "warn"); + exit(2); + } + + let config_path = option(settings, "config_path", ""); + let config = common.read_json_file(config_path); + if (config_path == "" || type(config) != "object") { + log_message("Cannot read the current sing-box configuration for DNS failover", "error"); + exit(1); + } + + let main = runtime_dns.server_config(settings, candidate_state); + let bootstrap = runtime_dns.bootstrap_config(settings, candidate_state); + if (main.unsupported || !replace_dns_server(config, main) || !replace_dns_server(config, bootstrap)) { + log_message("Cannot locate or build canonical DNS servers for failover", "error"); + exit(1); + } + + let backup_path = temp_path(); + let temp_config = temp_path(); + if (backup_path == "" || temp_config == "" || + fs.writefile(backup_path, fs.readfile(config_path)) == null || + !common.write_json_file(temp_config, config)) { + remove_files([ backup_path, temp_config ]); + exit(1); + } + + let check_log = temp_path(); + let check_result = check_log == "" + ? { status: 1, reason: "unable to create check output file" } + : sing_box_check(temp_config, check_log); + if (check_result.status != 0) { + log_message("DNS failover produced an invalid sing-box configuration: " + check_result.reason, "error"); + remove_files([ backup_path, temp_config, check_log ]); + exit(1); + } + remove_file(check_log); + + let changed = md5_file(config_path) != md5_file(temp_config); + if (!save_config_file(temp_config, config_path)) { + remove_file(backup_path); + exit(1); + } + + if (!changed) { + remove_file(backup_path); + print("0\n"); + return; + } + + print("1\t", backup_path, "\n"); +} + +function restore_dns_config(backup_path) { + let config_path = option(uci_settings(), "config_path", ""); + if (config_path == "" || !file_exists(backup_path)) + return false; + return command_success_from_args([ "mv", "-f", backup_path, config_path ]); +} + +function init_config(populate_nft, caches_prepared, no_refresh) { + let settings = uci_settings(); + let config_path = option(settings, "config_path", ""); + if (config_path == "") { + log_message("sing-box config path is empty. Aborted.", "fatal"); + exit(1); + } + + let mwan3_active = module_success([ LIB_DIR + "/config/validator.uc", "mwan3-is-active" ]); + let output_interface = option(settings, "output_network_interface", ""); + if (mwan3_active && output_interface != "") + log_message("mwan3 is active and Output Network Interface is set to '" + output_interface + "'; sing-box egress is pinned to this interface", "warn"); + else if (mwan3_active) + log_message("mwan3 is active; disabling sing-box auto_detect_interface so mwan3 can control egress routing", "warn"); + + let deferred_sections = prepare_subscription_caches(caches_prepared, no_refresh); + if (deferred_sections == null) + exit(1); + + let temp_config = temp_path(); + let runtime_log = temp_path(); + if (temp_config == "" || runtime_log == "") { + remove_files([ temp_config, runtime_log ]); + exit(1); + } + + let generate_status = command_status( + module_command([ + LIB_DIR + "/singbox/generator.uc", + "generate-config", + temp_config, + service_listen_address_value(settings), + mwan3_active ? "1" : "0", + sing_box_is_extended(sing_box_version()) ? "1" : "0" + ]) + " >" + shell_quote(runtime_log) + " 2>&1" + ); + if (generate_status != 0) { + let reason = generator_failure_reason(runtime_log, generate_status); + log_message("Failed to generate sing-box configuration: " + reason, "fatal"); + remove_files([ temp_config, runtime_log ]); + exit(1); + } + log_file_lines(runtime_log, "warn", "sing-box config generator: "); + + let check_result = sing_box_check(temp_config, runtime_log); + if (check_result.status != 0) { + log_message("Generated sing-box configuration is invalid: " + check_result.reason + ". Aborted.", "fatal"); + remove_files([ temp_config, runtime_log ]); + exit(1); + } + + if (populate_nft && !module_success([ + LIB_DIR + "/nft/apply.uc", + "nft-populate-runtime-sets-from-uci", + "1", + deferred_sections, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_PORT_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_LOCALV4_SET_NAME, + NFT_FAKEIP_MARK, + NFT_COMMON6_SET_NAME, + NFT_IP_PORT6_SET_NAME, + NFT_LOCALV6_SET_NAME + ])) { + log_message("Failed to update nftables runtime sets from the generated sing-box configuration. Aborted.", "fatal"); + remove_files([ temp_config, runtime_log ]); + exit(1); + } + + if (!save_config_file(temp_config, config_path)) { + remove_file(runtime_log); + exit(1); + } + remove_file(runtime_log); + print(deferred_sections, "\n"); +} + +let mode = ARGV[0] || ""; + +if (mode == "configure-service") + configure_service(); +else if (mode == "init-config") + init_config(arg_bool(ARGV[1] || "1"), arg_bool(ARGV[2] || "0"), arg_bool(ARGV[3] || "0")); +else if (mode == "save-config-file-fixture") + exit(save_config_file(ARGV[1] || "", ARGV[2] || "") ? 0 : 1); +else if (mode == "check-config-fixture") { + let result = sing_box_check(ARGV[1] || "", ARGV[2] || ""); + if (result.reason != "") + print(result.reason, "\n"); + exit(result.status); +} +else if (mode == "generator-failure-reason-fixture") + print(generator_failure_reason(ARGV[1] || "", int(ARGV[2] || "1")), "\n"); +else if (mode == "patch-dns-config") + patch_dns_config(ARGV[1] || ""); +else if (mode == "restore-dns-config") + exit(restore_dns_config(ARGV[1] || "") ? 0 : 1); +else if (mode == "managed-service-installed") + exit(managed_service_installed() ? 0 : 1); +else if (mode == "remove-managed-service-script") + remove_managed_service_script(); +else if (mode == "prepare-service-disabled") + prepare_service_disabled(); +else if (mode == "service-proxy-address") + print(service_proxy_address(uci_settings(), ARGV[1] || "lists"), "\n"); +else if (mode == "service-listen-address") { + let address = service_listen_address_value(uci_settings()); + if (address == "") + exit(1); + print(address, "\n"); +} +else if (mode == "device-ipv4-address") { + let address = device_ipv4_address_value(ARGV[1]); + if (address == "") + exit(1); + print(address, "\n"); +} +else if (mode == "ip-addr-first-inet4") + print(ip_addr_first_inet4(fs.readfile("/dev/stdin")), "\n"); +else if (mode == "version") + print(sing_box_version(), "\n"); +else if (mode == "version-output") + print(sing_box_version_output()); +else if (mode == "version-from-output") + print(first_line_last_field(fs.readfile("/dev/stdin")), "\n"); +else if (mode == "read-version-state") + print(sing_box_version_state(), "\n"); +else if (mode == "write-version-state") + exit(sing_box_write_version_state(ARGV[1]) ? 0 : 1); +else if (mode == "clear-version-state") + exit(sing_box_clear_version_state() ? 0 : 1); +else if (mode == "restore-version-state") + exit(sing_box_restore_version_state(ARGV[1]) ? 0 : 1); +else if (mode == "read-variant-marker") + print(sing_box_variant_marker(), "\n"); +else if (mode == "write-variant-marker") + exit(sing_box_write_variant_marker(ARGV[1]) ? 0 : 1); +else if (mode == "clear-variant-marker") + exit(sing_box_clear_variant_marker() ? 0 : 1); +else if (mode == "restore-variant-marker") + exit(sing_box_restore_variant_marker(ARGV[1]) ? 0 : 1); +else if (mode == "marker-is") + exit(sing_box_marker_is(ARGV[1]) ? 0 : 1); +else if (mode == "is-extended") + exit(sing_box_is_extended(ARGV[1]) ? 0 : 1); +else if (mode == "is-tiny") + exit(sing_box_is_tiny(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "supports-tailscale") + exit(sing_box_supports_tailscale(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "variant") + print(sing_box_variant(), "\n"); +else { + warn("Usage: singbox/runtime.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/singbox/servers.uc b/tachyon/files/usr/lib/singbox/servers.uc new file mode 100644 index 00000000..036d9d33 --- /dev/null +++ b/tachyon/files/usr/lib/singbox/servers.uc @@ -0,0 +1,390 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); +let runtime_constants = require("singbox.constants"); + +let as_string = common.as_string; +let option = common.option; +let list_option = common.list_option; +let bool_option = common.bool_option; +let int_option = common.int_option; +let array_or_empty = common.array_or_empty; + +function safe_filename(value) { + value = as_string(value); + let result = ""; + for (let i = 0; i < length(value); i++) { + let ch = substr(value, i, 1); + result += match(ch, /^[A-Za-z0-9_.-]$/) ? ch : "_"; + } + return result; +} + +function string_to_hex(value) { + value = as_string(value); + let result = ""; + for (let i = 0; i < length(value); i++) + result += sprintf("%02x", ord(substr(value, i, 1))); + return result; +} + +function mtproto_secret(section) { + let secret = option(section, "mtproto_secret", ""); + let faketls = option(section, "mtproto_faketls", "google.com"); + if (substr(lc(secret), 0, 2) == "ee") + return secret; + return "ee" + secret + string_to_hex(faketls); +} + +function users(section, protocol) { + let name = option(section, protocol == "socks" ? "server_username" : "label", section[".name"]); + if (name == "") + name = section[".name"]; + + if (protocol == "vless") { + let user = { uuid: option(section, "server_uuid", "") }; + if (name != "") + user.name = name; + let flow = option(section, "vless_flow", ""); + if (flow != "" && flow != "none") + user.flow = flow; + return [ user ]; + } + if (protocol == "vmess") { + let user = { + uuid: option(section, "server_uuid", ""), + alterId: int_option(section, "vmess_alter_id", "0") + }; + if (name != "") + user.name = name; + return [ user ]; + } + if (protocol == "trojan" || protocol == "hysteria2") { + let user = { password: option(section, "server_password", "") }; + if (name != "") + user.name = name; + return [ user ]; + } + if (protocol == "socks") { + return [{ + username: name != "" ? name : "user", + password: option(section, "server_password", "") + }]; + } + if (protocol == "mtproto") { + let user = { secret: mtproto_secret(section) }; + if (name != "") + user.name = name; + return [ user ]; + } + return []; +} + +function effective_security(section, protocol) { + let security = option(section, "security", ""); + if (security == "") { + if (protocol == "vless") + security = "reality"; + else if (protocol == "trojan" || protocol == "hysteria2") + security = "tls"; + else + security = "none"; + } + + if (protocol == "shadowsocks" || protocol == "socks" || protocol == "mtproto" || + protocol == "tailscale" || protocol == "json_inbound") + return "none"; + if (protocol == "hysteria2") + return "tls"; + if ((protocol == "vmess" || protocol == "trojan") && security == "reality") + return protocol == "trojan" ? "tls" : "none"; + return security; +} + +function maybe_string(object, key, value) { + value = as_string(value); + if (value != "") + object[key] = value; +} + +function apply_tls(inbound, section, protocol) { + let security = effective_security(section, protocol); + if (security == "" || security == "none") + return; + + let tls = { enabled: true }; + maybe_string(tls, "server_name", option(section, "tls_server_name", "")); + let alpn = list_option(section, "tls_alpn"); + if (length(alpn) > 0) + tls.alpn = alpn; + + if (security == "tls") { + maybe_string(tls, "certificate_path", option(section, "tls_certificate_path", "")); + maybe_string(tls, "key_path", option(section, "tls_key_path", "")); + } + else if (security == "reality") { + let reality = { + enabled: true, + handshake: { + server: option(section, "reality_handshake_server", "www.microsoft.com"), + server_port: int_option(section, "reality_handshake_server_port", "443") + }, + private_key: option(section, "reality_private_key", "") + }; + let short_id = list_option(section, "reality_short_id"); + if (length(short_id) > 0) + reality.short_id = short_id; + maybe_string(reality, "max_time_difference", option(section, "reality_max_time_difference", "1m")); + tls.reality = reality; + } + + inbound.tls = tls; +} + +function apply_transport(inbound, section, protocol) { + if (protocol != "vless" && protocol != "vmess" && protocol != "trojan") + return; + + let transport_type = option(section, "transport", "tcp"); + if (transport_type == "" || transport_type == "tcp" || transport_type == "raw") + return; + + let transport = { type: transport_type }; + let path = option(section, "transport_path", ""); + let host = option(section, "transport_host", ""); + if (transport_type == "ws") { + maybe_string(transport, "path", path); + if (host != "") + transport.headers = { Host: host }; + } + else if (transport_type == "grpc") { + maybe_string(transport, "service_name", option(section, "transport_service_name", "")); + } + else if (transport_type == "http") { + maybe_string(transport, "path", path); + let hosts = list_option(section, "transport_hosts"); + if (length(hosts) > 0) + transport.host = hosts; + } + else if (transport_type == "httpupgrade") { + maybe_string(transport, "path", path); + maybe_string(transport, "host", host); + } + else if (transport_type == "xhttp") { + transport.mode = option(section, "transport_xhttp_mode", "auto"); + transport.path = path != "" ? path : "/"; + maybe_string(transport, "host", host); + transport.headers = {}; + transport.x_padding_bytes = "100-1000"; + transport.no_sse_header = false; + transport.sc_max_each_post_bytes = 1000000; + transport.sc_max_buffered_posts = 30; + transport.sc_stream_up_server_secs = "20-80"; + transport.server_max_header_bytes = 8192; + } + else { + return; + } + + inbound.transport = transport; +} + +function add_standard_inbound(config, section, protocol, tag_name) { + let inbound = { + type: protocol, + tag: tag_name, + listen: option(section, "listen", "0.0.0.0"), + listen_port: int_option(section, "listen_port", "443") + }; + + if (protocol == "shadowsocks") { + inbound.method = option(section, "shadowsocks_method", "aes-128-gcm"); + inbound.password = option(section, "server_password", ""); + } + else if (protocol == "socks") { + if (bool_option(section, "socks_auth_enabled", true)) { + let server_users = users(section, protocol); + if (length(server_users) > 0) + inbound.users = server_users; + } + } + else if (protocol == "hysteria2") { + inbound.users = users(section, protocol); + let up_mbps = option(section, "hysteria2_up_mbps", ""); + let down_mbps = option(section, "hysteria2_down_mbps", ""); + if (up_mbps != "") + inbound.up_mbps = int(up_mbps, 10); + if (down_mbps != "") + inbound.down_mbps = int(down_mbps, 10); + let obfs_type = option(section, "hysteria2_obfs_type", ""); + let obfs_password = option(section, "hysteria2_obfs_password", ""); + if (obfs_type != "" && obfs_password != "") + inbound.obfs = { type: obfs_type, password: obfs_password }; + } + else if (protocol == "mtproto") { + inbound.type = "mtproxy"; + inbound.users = users(section, protocol); + let concurrency = option(section, "mtproto_concurrency", ""); + if (concurrency != "") + inbound.concurrency = int(concurrency, 10); + let fronting_port = option(section, "mtproto_domain_fronting_port", "443"); + if (fronting_port != "") + inbound.domain_fronting_port = int(fronting_port, 10); + maybe_string(inbound, "domain_fronting_ip", option(section, "mtproto_domain_fronting_ip", "")); + if (bool_option(section, "mtproto_domain_fronting_proxy_protocol", false)) + inbound.domain_fronting_proxy_protocol = true; + maybe_string(inbound, "prefer_ip", option(section, "mtproto_prefer_ip", "prefer-ipv4")); + if (bool_option(section, "mtproto_auto_update", false)) + inbound.auto_update = true; + if (bool_option(section, "mtproto_allow_fallback_on_unknown_dc", false)) + inbound.allow_fallback_on_unknown_dc = true; + maybe_string(inbound, "tolerate_time_skewness", option(section, "mtproto_tolerate_time_skewness", "3s")); + maybe_string(inbound, "idle_timeout", option(section, "mtproto_idle_timeout", "5m")); + maybe_string(inbound, "handshake_timeout", option(section, "mtproto_handshake_timeout", "10s")); + } + else { + inbound.users = users(section, protocol); + } + + apply_tls(inbound, section, protocol); + apply_transport(inbound, section, protocol); + push(config.inbounds, inbound); +} + +function add_json_inbound(config, section, tag_name) { + let inbound = {}; + try { + inbound = json(option(section, "inbound_json", "")); + } + catch (e) { + inbound = {}; + } + if (type(inbound) != "object") + inbound = {}; + inbound.tag = tag_name; + push(config.inbounds, inbound); +} + +function add_tailscale_endpoint(config, section, tag_name) { + let section_name = section[".name"]; + let endpoint = { + type: "tailscale", + tag: tag_name, + state_directory: "/etc/tachyon/tailscale/" + safe_filename(section_name), + auth_key: option(section, "tailscale_auth_key", ""), + control_url: option(section, "tailscale_control_url", "https://controlplane.tailscale.com"), + hostname: option(section, "tailscale_hostname", "tachyon-" + safe_filename(section_name)) + }; + if (bool_option(section, "tailscale_accept_routes", false)) + endpoint.accept_routes = true; + let advertise_routes = list_option(section, "tailscale_advertise_routes"); + if (length(advertise_routes) > 0) + endpoint.advertise_routes = advertise_routes; + if (bool_option(section, "tailscale_advertise_exit_node", false)) + endpoint.advertise_exit_node = true; + push(config.endpoints, endpoint); +} + +function add_dns_bypass(config, section) { + if (option(section, "protocol", "vless") != "tailscale") + return; + + let section_name = section[".name"]; + let inbound = runtime_constants.server_inbound_tag(section_name); + let dns_tag = runtime_constants.tailscale_dns_server_tag(section_name); + let rule_tag = "tailscale-server-dns-" + safe_filename(section_name); + push(config.dns.servers, { + type: "tailscale", + tag: dns_tag, + endpoint: inbound, + accept_default_resolvers: true + }); + push(config.dns.rules, { + action: "route", + server: dns_tag, + inbound, + __service_tag: rule_tag + }); +} + +function add_server(config, section) { + let section_name = section[".name"]; + let protocol = option(section, "protocol", "vless"); + let tag_name = runtime_constants.server_inbound_tag(section_name); + + if (protocol == "tailscale") + add_tailscale_endpoint(config, section, tag_name); + else if (protocol == "json_inbound") + add_json_inbound(config, section, tag_name); + else + add_standard_inbound(config, section, protocol, tag_name); + add_dns_bypass(config, section); +} + +function add_sniff_rule(config, section) { + let rules = array_or_empty(config.route.rules); + let rule = { + action: "sniff", + inbound: runtime_constants.server_inbound_tag(section[".name"]) + }; + let insert_at = 0; + while (insert_at < length(rules) && type(rules[insert_at]) == "object" && rules[insert_at].action == "sniff") + insert_at++; + + let result = []; + for (let i = 0; i < insert_at; i++) + push(result, rules[i]); + push(result, rule); + for (let i = insert_at; i < length(rules); i++) + push(result, rules[i]); + config.route.rules = result; +} + +function clone(value) { + try { + return json(sprintf("%J", value)); + } + catch (e) { + return null; + } +} + +function value_contains(value, item) { + if (type(value) == "array") { + for (let entry in value) + if (entry == item) + return true; + return false; + } + return value == item; +} + +function clone_rules_for_inbound(config, source_inbound, target_inbound, skip_domain) { + let cloned_rules = []; + for (let rule in array_or_empty(config.route.rules)) { + if (type(rule) != "object") + continue; + if (rule.action != "route" && rule.action != "reject") + continue; + if (!value_contains(rule.inbound, source_inbound)) + continue; + if (skip_domain != "" && value_contains(rule.domain, skip_domain)) + continue; + if (rule.source_ip_cidr != null) + continue; + + let cloned = clone(rule); + if (type(cloned) != "object") + continue; + cloned.inbound = target_inbound; + push(cloned_rules, cloned); + } + for (let cloned_rule in cloned_rules) + push(config.route.rules, cloned_rule); +} + +return { + add_server, + add_sniff_rule, + clone_rules_for_inbound +}; diff --git a/tachyon/files/usr/lib/singbox/subscription.uc b/tachyon/files/usr/lib/singbox/subscription.uc new file mode 100644 index 00000000..0a9edfea --- /dev/null +++ b/tachyon/files/usr/lib/singbox/subscription.uc @@ -0,0 +1,356 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); +let subscription_share_link = require("subscription.share_link"); + +let as_string = common.as_string; +let read_json_file = common.read_json_file; +let array_or_empty = common.array_or_empty; +let object_or_empty = common.object_or_empty; +let object_key_count = common.object_key_count; + +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || "/tmp/sing-box/subscriptions"; +const TACHYON_RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const TACHYON_SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || TACHYON_RUNTIME_STATE_DIR + "/section-cache"; +const TACHYON_SUBSCRIPTION_METADATA_DIR = getenv("TACHYON_SUBSCRIPTION_METADATA_DIR") || TACHYON_RUNTIME_STATE_DIR + "/subscription-metadata"; +const TACHYON_RUNTIME_CACHE_FORMAT = int(getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8"); +const TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/tachyon/subscription-cache"; + +let section_cache_dir = TACHYON_SECTION_CACHE_DIR; + +function set_section_cache_dir(path) { + path = as_string(path); + if (path != "") + section_cache_dir = path; +} + +function source_id(section_name, index) { + return section_name + "-subscription-" + index; +} + +function section_cache_path(section_name) { + return section_cache_dir + "/" + section_name + ".json"; +} + +function legacy_metadata_path(section_name) { + return TACHYON_SUBSCRIPTION_METADATA_DIR + "/" + section_name + ".json"; +} + +function persistent_source_path(source_section, suffix) { + return TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/" + source_section + suffix; +} + +function persistent_metadata_path(source_section) { + return persistent_source_path(source_section, ".metadata.json"); +} + +function trim_string(value) { + return replace(as_string(value), /^[ \t\r\n]+|[ \t\r\n]+$/g, ""); +} + +function parse_source_entry(entry) { + entry = trim_string(entry); + let delimiter = " | "; + let delimiter_index = index(entry, delimiter); + if (delimiter_index < 0) + return { url: entry, user_agent: "" }; + + return { + url: trim_string(substr(entry, 0, delimiter_index)), + user_agent: trim_string(substr(entry, delimiter_index + length(delimiter))) + }; +} + +function file_first_line(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function valid_metadata_object(value) { + return type(value) == "object" && object_key_count(value) > 1; +} + +function metadata_items_from_value(value) { + let result = []; + if (type(value) == "array") { + for (let item in value) + if (valid_metadata_object(item)) + push(result, item); + } + else if (valid_metadata_object(value)) { + push(result, value); + } + return result; +} + +function metadata_source_index(item) { + if (type(item) != "object") + return null; + + let value = item.sourceIndex != null ? item.sourceIndex : item.source_index; + if (value == null || value == "") + return null; + + return int(value, 10); +} + +function metadata_source_section(item) { + if (type(item) != "object") + return ""; + + return as_string(item.sourceSection != null ? item.sourceSection : item.source_section); +} + +function metadata_items_have_source_markers(items) { + for (let item in items) { + if (metadata_source_index(item) != null || metadata_source_section(item) != "") + return true; + } + return false; +} + +function metadata_matches_source(item, source_index, source_section, has_source_markers) { + if (!has_source_markers) + return false; + + let item_section = metadata_source_section(item); + let item_index = metadata_source_index(item); + + if (item_section != "") + return item_section == source_section; + + return item_index == source_index; +} + +function persistent_source_matches_entry(source_section, source_entry) { + if (source_entry == null || source_entry == "") + return true; + + let parsed = parse_source_entry(source_entry); + let cached_url = file_first_line(persistent_source_path(source_section, ".url")); + let cached_user_agent = file_first_line(persistent_source_path(source_section, ".user_agent")); + + if (cached_url != parsed.url) + return false; + if (parsed.user_agent != "" && cached_user_agent != parsed.user_agent) + return false; + return cached_user_agent != ""; +} + +function read_persistent_source_metadata(source_section, source_entry) { + if (!persistent_source_matches_entry(source_section, source_entry)) + return []; + return metadata_items_from_value(read_json_file(persistent_metadata_path(source_section))); +} + +function read_section_metadata(section_name, source_section, source_index) { + let cache = object_or_empty(read_json_file(section_cache_path(section_name))); + let metadata = metadata_items_from_value(cache.subscriptionMetadata); + if (length(metadata) == 0) + metadata = metadata_items_from_value(read_json_file(legacy_metadata_path(section_name))); + if (length(metadata) == 0) { + cache = object_or_empty(read_json_file(section_cache_path(source_section))); + metadata = metadata_items_from_value(cache.subscriptionMetadata); + } + if (length(metadata) == 0) + metadata = metadata_items_from_value(read_json_file(legacy_metadata_path(source_section))); + + let selected = []; + let has_source_markers = metadata_items_have_source_markers(metadata); + if (has_source_markers) { + for (let item in metadata) { + if (metadata_matches_source(item, source_index, source_section, true)) + push(selected, item); + } + } + else if (source_index > 0 && source_index <= length(metadata)) { + push(selected, metadata[source_index - 1]); + } + + return selected; +} + +function read_source_metadata(section_name, source_section, source_index, source_entry) { + let metadata = read_section_metadata(section_name, source_section, source_index); + if (length(metadata) == 0) + metadata = read_persistent_source_metadata(source_section, source_entry); + + let result = []; + for (let item in metadata) { + item.sourceIndex = source_index; + item.sourceSection = source_section; + push(result, item); + } + return result; +} + +function merge_source_metadata(state, section_name, source_section, source_index, source_entry) { + if (type(state) != "object") + return; + for (let item in read_source_metadata(section_name, source_section, source_index, source_entry)) + push(state.subscriptionMetadata, item); +} + +function remember_outbound_metadata(state, tag_name, display_name, outbound) { + if (type(state) != "object") + return; + state.outboundMetadata.names[tag_name] = display_name; + let protocol = lc(as_string(outbound.type || "")); + if (protocol != "") + state.outboundMetadata.protocols[tag_name] = protocol; + + let transport = lc(as_string(object_or_empty(outbound.transport).type || "")); + if (transport == "" || transport == "raw") + transport = "tcp"; + else if (transport == "h2") + transport = "http"; + state.outboundMetadata.transports[tag_name] = transport; + + let tls = type(outbound.tls) == "object" ? outbound.tls : null; + let security = "none"; + if (tls != null && tls.enabled !== false) { + let reality = type(tls.reality) == "object" ? tls.reality : null; + security = reality != null && reality.enabled !== false ? "reality" : "tls"; + } + state.outboundMetadata.securities[tag_name] = security; + + let server = as_string(outbound.server || ""); + if (server != "") + state.servers[tag_name] = server; +} + +function remember_source_outbound(state, tag_name, display_name, outbound, source_link) { + if (type(state) != "object") + return; + remember_outbound_metadata(state, tag_name, display_name, outbound); + let outbound_type = as_string(outbound.type || ""); + if (outbound_type != "selector" && outbound_type != "urltest") { + if (subscription_share_link.is_copyable_link(source_link)) + state.links[tag_name] = source_link; + } +} + +function remember_urltest_group(state, tag_name, display_name, outbound) { + if (type(state) != "object" || as_string(outbound.type || "") != "urltest") + return; + + let group = { + displayName: as_string(display_name) != "" ? as_string(display_name) : tag_name, + outbounds: array_or_empty(outbound.outbounds) + }; + + for (let key in [ "url", "interval", "tolerance", "idle_timeout", "interrupt_exist_connections" ]) { + if (outbound[key] != null) + group[key] = outbound[key]; + } + + state.urltestGroups[tag_name] = group; +} + +function remember_urltest_group_config(state, tag_name, input_group) { + if (type(state) != "object") + return; + + input_group = object_or_empty(input_group); + let output_group = { + displayName: as_string(input_group.displayName || "") != "" ? as_string(input_group.displayName) : tag_name, + outbounds: array_or_empty(input_group.outbounds) + }; + + for (let key in [ "url", "interval", "tolerance", "idle_timeout", "interrupt_exist_connections" ]) { + if (input_group[key] != null) + output_group[key] = input_group[key]; + } + + state.urltestGroups[tag_name] = output_group; +} + +function remember_priority_group(state, tag_name, group) { + if (type(state) != "object") + return; + + state.priorityGroups[tag_name] = object_or_empty(group); +} + +function source_hwid_path(source_section) { + return TMP_SUBSCRIPTION_FOLDER + "/" + source_section + ".hwid"; +} + +function hwid_matches_config(configured_hwid, cached_hwid) { + configured_hwid = as_string(configured_hwid); + cached_hwid = as_string(cached_hwid); + + if (configured_hwid != "") + return cached_hwid == configured_hwid; + return true; +} + +function source_cache_is_current(source_section, source_entry, expected_user_agent, expected_hwid) { + let parsed = parse_source_entry(source_entry); + let cached_url = file_first_line(TMP_SUBSCRIPTION_FOLDER + "/" + source_section + ".url"); + let cached_user_agent = file_first_line(TMP_SUBSCRIPTION_FOLDER + "/" + source_section + ".user_agent"); + let cached_hwid = file_first_line(source_hwid_path(source_section)); + let configured_user_agent = as_string(expected_user_agent || ""); + if (configured_user_agent != "") + parsed.user_agent = configured_user_agent; + + if (cached_url != parsed.url) + return false; + + if (parsed.user_agent != "" && cached_user_agent != parsed.user_agent) + return false; + + if (!hwid_matches_config(expected_hwid, cached_hwid)) + return false; + + return true; +} + +function source_json_path(source_section) { + return TMP_SUBSCRIPTION_FOLDER + "/" + source_section + ".json"; +} + +function read_source_outbounds(source_section) { + let subscription = object_or_empty(read_json_file(source_json_path(source_section))); + return array_or_empty(subscription.outbounds); +} + +function new_section_state(section_name) { + return { + version: TACHYON_RUNTIME_CACHE_FORMAT, + section: section_name, + links: {}, + outboundMetadata: { + names: {}, + countries: {}, + protocols: {}, + transports: {}, + securities: {} + }, + servers: {}, + urltestCandidateTags: [], + urltestGroups: {}, + priorityGroups: {}, + subscriptionMetadata: [] + }; +} + +return { + set_section_cache_dir, + source_id, + section_cache_path, + merge_source_metadata, + remember_outbound_metadata, + remember_source_outbound, + remember_urltest_group, + remember_urltest_group_config, + remember_priority_group, + source_cache_is_current, + read_source_outbounds, + new_section_state +}; diff --git a/tachyon/files/usr/lib/singbox/urltest.uc b/tachyon/files/usr/lib/singbox/urltest.uc new file mode 100644 index 00000000..14581306 --- /dev/null +++ b/tachyon/files/usr/lib/singbox/urltest.uc @@ -0,0 +1,158 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); + +let as_string = common.as_string; +let array_or_empty = common.array_or_empty; +let object_or_empty = common.object_or_empty; + +function contains(values, needle) { + for (let value in values) { + if (value == needle) + return true; + } + return false; +} + +function regex_matches(value, pattern) { + pattern = as_string(pattern); + if (pattern == "") + return false; + + try { + return match(as_string(value), regexp(pattern)) != null; + } + catch (e) { + return false; + } +} + +function normalized_country_list(values) { + let result = []; + for (let value in array_or_empty(values)) { + value = uc(as_string(value)); + if (length(value) == 2) + push(result, value); + } + return result; +} + +function byte_at(value, index) { + return ord(substr(value, index, 1)); +} + +function regional_indicator_letter(value, index) { + if (index + 3 >= length(value)) + return ""; + + if (byte_at(value, index) != 240 || + byte_at(value, index + 1) != 159 || + byte_at(value, index + 2) != 135) + return ""; + + let letter = byte_at(value, index + 3) - 166; + if (letter < 0 || letter > 25) + return ""; + + return chr(65 + letter); +} + +function country_from_flag_emoji(value) { + value = as_string(value); + + for (let i = 0; i + 7 < length(value); i++) { + let first = regional_indicator_letter(value, i); + if (first == "") + continue; + + let second = regional_indicator_letter(value, i + 4); + if (second != "") + return first + second; + } + + return ""; +} + +function countries_from_flag_names(names) { + names = object_or_empty(names); + let result = {}; + + for (let tag, name in names) { + let country = country_from_flag_emoji(name); + if (country != "") + result[tag] = country; + } + + return result; +} + +function regex_matching_tag_array(tags, names, regexes) { + tags = array_or_empty(tags); + names = object_or_empty(names); + regexes = array_or_empty(regexes); + let result = []; + + for (let tag in tags) { + let name = names[tag]; + name = name == null || as_string(name) == "" ? tag : as_string(name); + + for (let pattern in regexes) { + if (regex_matches(name, pattern)) { + push(result, tag); + break; + } + } + } + + return result; +} + +function filter_array(mode, tags, names, countries, name_filter, regex_tags, country_filter) { + tags = array_or_empty(tags); + names = object_or_empty(names); + countries = object_or_empty(countries); + name_filter = array_or_empty(name_filter); + regex_tags = array_or_empty(regex_tags); + country_filter = array_or_empty(country_filter); + let result = []; + + for (let tag in tags) { + let name = as_string(names[tag] || tag); + let country = uc(as_string(countries[tag] || "")); + let matched = contains(name_filter, name) || + contains(name_filter, tag) || + (country != "" && contains(country_filter, country)) || + contains(regex_tags, tag); + + if ((mode == "include" && matched) || (mode == "exclude" && !matched)) + push(result, tag); + } + + return result; +} + +function filter_mode(mode, tags, names, countries, include_names, include_regexes, include_countries, exclude_names, exclude_regexes, exclude_countries) { + include_countries = normalized_country_list(include_countries); + exclude_countries = normalized_country_list(exclude_countries); + + let include_regex_tags = regex_matching_tag_array(tags, names, include_regexes); + let exclude_regex_tags = regex_matching_tag_array(tags, names, exclude_regexes); + + if (mode == "include") + return filter_array("include", tags, names, countries, include_names, include_regex_tags, include_countries); + if (mode == "exclude") + return filter_array("exclude", tags, names, countries, exclude_names, exclude_regex_tags, exclude_countries); + if (mode == "mixed") { + let included = filter_array("include", tags, names, countries, include_names, include_regex_tags, include_countries); + return filter_array("exclude", included, names, countries, exclude_names, exclude_regex_tags, exclude_countries); + } + return array_or_empty(tags); +} + +return { + normalized_country_list, + countries_from_flag_names, + regex_matching_tag_array, + filter_array, + filter_mode +}; diff --git a/tachyon/files/usr/lib/subscription/cache.uc b/tachyon/files/usr/lib/subscription/cache.uc new file mode 100644 index 00000000..5be0cea0 --- /dev/null +++ b/tachyon/files/usr/lib/subscription/cache.uc @@ -0,0 +1,2621 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); +let connections = require("config.connections"); +let subscription_share_link = require("subscription.share_link"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon"; +const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || "/tmp/sing-box"; +const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || TMP_SING_BOX_FOLDER + "/rulesets"; +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || TMP_SING_BOX_FOLDER + "/subscriptions"; +const TACHYON_RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; +const TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR") || TACHYON_RUNTIME_STATE_DIR + "/subscription-update"; +const TACHYON_SUBSCRIPTION_LINKS_DIR = getenv("TACHYON_SUBSCRIPTION_LINKS_DIR") || TACHYON_RUNTIME_STATE_DIR + "/subscription-links"; +const TACHYON_SUBSCRIPTION_METADATA_DIR = getenv("TACHYON_SUBSCRIPTION_METADATA_DIR") || TACHYON_RUNTIME_STATE_DIR + "/subscription-metadata"; +const TACHYON_OUTBOUND_METADATA_DIR = getenv("TACHYON_OUTBOUND_METADATA_DIR") || TACHYON_RUNTIME_STATE_DIR + "/outbound-metadata"; +const TACHYON_SECTION_CACHE_DIR = getenv("TACHYON_SECTION_CACHE_DIR") || TACHYON_RUNTIME_STATE_DIR + "/section-cache"; +const TACHYON_RUNTIME_CACHE_FORMAT_FILE = getenv("TACHYON_RUNTIME_CACHE_FORMAT_FILE") || TACHYON_RUNTIME_STATE_DIR + "/cache-format"; +const TACHYON_RUNTIME_CACHE_FORMAT = getenv("TACHYON_RUNTIME_CACHE_FORMAT") || "8"; +const TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/tachyon/subscription-cache"; +const TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; +const TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT = getenv("TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT") || "7"; +const TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE = getenv("TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE") || TACHYON_RUNTIME_STATE_DIR + "/subscription-bootstrap-retry.pid"; +const TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR") || TACHYON_RUNTIME_STATE_DIR + "/subscription-update.lock"; +const TACHYON_PENDING_RELOAD_FILE = getenv("TACHYON_PENDING_RELOAD_FILE") || TACHYON_RUNTIME_STATE_DIR + "/reload.pending"; +const TACHYON_SERVICE_INIT = getenv("TACHYON_SERVICE_INIT") || "/etc/init.d/tachyon"; +const SB_SERVICE_MIXED_INBOUND_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1"; +const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534"; +const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || "/etc/tachyon/sing-box-variant"; +const SB_VERSION_STATE_FILE = getenv("SB_VERSION_STATE_FILE") || "/etc/tachyon/sing-box-version"; +const ZAPRET_PROVIDER_NFQWS_BIN = getenv("ZAPRET_PROVIDER_NFQWS_BIN") || "/opt/zapret/nfq/nfqws"; +const ZAPRET2_PROVIDER_NFQWS2_BIN = getenv("ZAPRET2_PROVIDER_NFQWS2_BIN") || "/opt/zapret2/nfq2/nfqws2"; +const BYEDPI_BIN = getenv("BYEDPI_BIN") || "/usr/bin/ciadpi"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let data = fs.readfile("/dev/stdin"); + return data == null ? "" : data; +} + +function read_json(path) { + if (path == null || path == "" || path == "-") + return null; + + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function write_file(path, value) { + return fs.writefile(path, value) != null; +} + +function write_json(path, value) { + return write_file(path, sprintf("%J", value) + "\n"); +} + +function write_stdout_json(value) { + print(sprintf("%J", value), "\n"); +} + +function file_first_line(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let newline = index(data, "\n"); + print(newline >= 0 ? substr(data, 0, newline) : data, "\n"); +} + +function file_first_line_value(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function file_has_exact_line(path, needle) { + let data = fs.readfile(path); + if (data == null) + return false; + + needle = as_string(needle); + for (let line in split(data, "\n")) + if (as_string(line) == needle) + return true; + + return false; +} + +function whitespace_values(value) { + let result = []; + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function state_list_contains(list, value) { + value = as_string(value); + if (value == "") + return false; + + for (let item in whitespace_values(list)) + if (item == value) + return true; + + return false; +} + +function append_state_list_once(list, value) { + list = trim(as_string(list)); + value = as_string(value); + + if (value == "" || state_list_contains(list, value)) { + print(list, "\n"); + return; + } + + print(list, list != "" ? " " : "", value, "\n"); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function bool_value(value, fallback) { + if (value == null) + return !!fallback; + + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function bool_option(section, key, fallback) { + return bool_value(object_or_empty(section)[key], fallback); +} + +function unsigned_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9]/) != null) + return null; + return int(value); +} + +function duration_to_seconds_value(value) { + let rest = as_string(value); + if (rest == "") + return null; + + let total = 0.0; + let multipliers = { + ns: 0.000000001, + us: 0.000001, + ms: 0.001, + s: 1, + m: 60, + h: 3600, + d: 86400 + }; + + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return null; + + let token = as_string(matched[0]); + let amount = matched[1] * 1; + let unit = matched[3]; + total = total + amount * multipliers[unit]; + rest = substr(rest, length(token)); + } + + if (total <= 0) + return null; + + return int(total + 0.5); +} + +function update_due_status(now_value, last_run_value, interval_value) { + let now = unsigned_number(now_value); + let interval = unsigned_number(interval_value); + + if (now == null || interval == null || interval <= 0) + return 2; + + let last_run = unsigned_number(last_run_value); + if (last_run == null) + last_run = 0; + + if (last_run > 0 && now - last_run < interval) + return 1; + + return 0; +} + +function cache_section_is_safe(section) { + section = as_string(section); + return section != "" && index(section, "/") < 0 && index(section, "..") < 0; +} + +function append_word_once(words, value) { + words = trim(as_string(words)); + value = as_string(value); + + if (value == "" || state_list_contains(words, value)) + return words; + + return words + (words != "" ? " " : "") + value; +} + +function section_name(section) { + return as_string(object_or_empty(section)[".name"]); +} + +function source_id(section, index) { + return as_string(section) + "-subscription-" + as_string(index); +} + +function subscription_metadata_path(section) { + section = as_string(section); + if (!cache_section_is_safe(section)) + return ""; + return TACHYON_SUBSCRIPTION_METADATA_DIR + "/" + section + ".json"; +} + +function outbound_metadata_path(section) { + section = as_string(section); + if (!cache_section_is_safe(section)) + return ""; + return TACHYON_OUTBOUND_METADATA_DIR + "/" + section + ".json"; +} + +function section_has_subscription_urls(section) { + return length(connections.subscription_urls(section)) > 0; +} + +function section_is_subscription_proxy(section) { + return bool_option(section, "enabled", true) && + connections.is_connections_action(option(section, "action", "")) && + section_has_subscription_urls(section); +} + +function section_subscription_update_interval(section) { + let result = ""; + let result_seconds = 0; + + for (let entry in connections.subscription_urls(section)) { + if (!connections.subscription_update_enabled(section, entry)) + continue; + + let value = connections.subscription_update_interval(section, entry); + if (value == "") + value = "1h"; + let seconds = duration_to_seconds_value(value); + if (result == "" || (seconds != null && (result_seconds == 0 || seconds < result_seconds))) { + result = value; + result_seconds = seconds == null ? 0 : seconds; + } + } + + return result; +} + +function subscription_source_count(section) { + return length(connections.subscription_urls(section)); +} + +function cache_keep_sets(sections) { + let keep = { + sections: "", + sources: "", + all: "" + }; + + for (let section in sections) { + section = object_or_empty(section); + let name = section_name(section); + if (!cache_section_is_safe(name)) + continue; + + keep.sections = append_word_once(keep.sections, name); + keep.all = append_word_once(keep.all, name); + + let count = subscription_source_count(section); + for (let i = 1; i <= count; i++) { + let source = source_id(name, i); + if (!cache_section_is_safe(source)) + continue; + + keep.sources = append_word_once(keep.sources, source); + keep.all = append_word_once(keep.all, source); + } + } + + return keep; +} + +function runtime_cache_missing(sections, section_cache_dir) { + section_cache_dir = as_string(section_cache_dir); + + for (let section in sections) { + section = object_or_empty(section); + let name = section_name(section); + if (!section_is_subscription_proxy(section)) + continue; + if (!cache_section_is_safe(name)) + continue; + if (fs.stat(section_cache_dir + "/" + name + ".json") == null) + return true; + } + + return false; +} + +function path_starts_with(path, prefix) { + path = as_string(path); + prefix = as_string(prefix); + if (prefix == "") + return false; + return path == prefix || substr(path, 0, length(prefix) + 1) == prefix + "/"; +} + +function ends_with(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix; +} + +function cache_name_from_file(path) { + let slash = rindex(path, "/"); + let file_name = slash >= 0 ? substr(path, slash + 1) : path; + + if (file_name == "cache-format") + return ""; + if (ends_with(file_name, ".metadata.json")) + return substr(file_name, 0, length(file_name) - 14); + if (ends_with(file_name, ".json")) + return substr(file_name, 0, length(file_name) - 5); + if (ends_with(file_name, ".url")) + return substr(file_name, 0, length(file_name) - 4); + if (ends_with(file_name, ".user_agent")) + return substr(file_name, 0, length(file_name) - 11); + if (ends_with(file_name, ".hwid")) + return substr(file_name, 0, length(file_name) - 5); + + return ""; +} + +function keep_names_for_path(path, keep, tmp_dir, persistent_dir, section_cache_dir, links_dir, metadata_dir, outbound_metadata_dir) { + if (path_starts_with(path, tmp_dir) || path_starts_with(path, persistent_dir)) + return keep.sources; + if (path_starts_with(path, section_cache_dir)) + return keep.all; + if (path_starts_with(path, links_dir) || path_starts_with(path, metadata_dir) || path_starts_with(path, outbound_metadata_dir)) + return keep.sections; + return ""; +} + +function stale_cache_delete_paths(sections, tmp_dir, persistent_dir, section_cache_dir, links_dir, metadata_dir, outbound_metadata_dir) { + let keep = cache_keep_sets(sections); + + for (let path in split(read_stdin(), "\n")) { + path = as_string(path); + if (path == "") + continue; + + let cache_name = cache_name_from_file(path); + if (cache_name == "") + continue; + + let keep_names = keep_names_for_path(path, keep, tmp_dir, persistent_dir, section_cache_dir, links_dir, metadata_dir, outbound_metadata_dir); + if (state_list_contains(keep_names, cache_name)) + continue; + + print(path, "\n"); + } +} + +function fixture_section_list(data) { + data = object_or_empty(data); + if (type(data.section) == "array") + return data.section; + if (type(data.section) == "object") + return [ data.section ]; + if (type(data.sections) == "array") + return data.sections; + return []; +} + +function uci_sections() { + return uci_core.section_objects(CONFIG_NAME, "section"); +} + +function maintenance_plan(sections, section_cache_dir) { + let keep = cache_keep_sets(sections); + print("sections\t", keep.sections, "\n"); + print("sources\t", keep.sources, "\n"); + print("all\t", keep.all, "\n"); + print("missing\t", runtime_cache_missing(sections, section_cache_dir) ? "1" : "0", "\n"); +} + +let auto_user_agent_profiles = [ + "Happ", + "v2rayN", + "v2rayNG", + "Mihomo", + "Clash.Meta" +]; + +let auto_user_agents = {}; +for (let profile in auto_user_agent_profiles) + auto_user_agents[profile] = true; + +function user_agent_supported(user_agent, default_user_agent) { + user_agent = as_string(user_agent); + default_user_agent = as_string(default_user_agent); + + if (user_agent == "") + return false; + if (user_agent == default_user_agent) + return true; + + return auto_user_agents[user_agent] == true; +} + +function user_agent_matches_config(configured_user_agent, cached_user_agent, default_user_agent) { + configured_user_agent = as_string(configured_user_agent); + cached_user_agent = as_string(cached_user_agent); + + if (configured_user_agent != "") + return cached_user_agent == configured_user_agent; + + return user_agent_supported(cached_user_agent, default_user_agent); +} + +let parser_module = null; + +function subscription_parser() { + if (parser_module == null) + parser_module = require("subscription.parser"); + return parser_module; +} + +function subscription_cache_is_usable(path) { + if (fs.stat(as_string(path)) == null) + return false; + return subscription_parser().validate_subscription(path); +} + +function source_json_path(dir, source_section) { + return as_string(dir) + "/" + as_string(source_section) + ".json"; +} + +function source_url_path(dir, source_section) { + return as_string(dir) + "/" + as_string(source_section) + ".url"; +} + +function source_user_agent_path(dir, source_section) { + return as_string(dir) + "/" + as_string(source_section) + ".user_agent"; +} + +function source_hwid_path(dir, source_section) { + return as_string(dir) + "/" + as_string(source_section) + ".hwid"; +} + +function read_text(path) { + let data = fs.readfile(as_string(path)); + return data == null ? "" : as_string(data); +} + +function write_text(path, value) { + return fs.writefile(as_string(path), as_string(value)) != null; +} + +function copy_file(source, target) { + let data = fs.readfile(as_string(source)); + if (data == null) + return false; + + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", as_string(target), stamp[0], stamp[1]); + if (!write_file(tmp_path, data)) + return false; + if (!fs.rename(tmp_path, as_string(target))) { + fs.unlink(tmp_path); + return false; + } + return true; +} + +function unlink_path(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function file_nonempty(path) { + let stat = fs.stat(as_string(path)); + return stat != null && stat.size != null && stat.size > 0; +} + +function files_equal(left, right) { + let left_data = fs.readfile(as_string(left)); + let right_data = fs.readfile(as_string(right)); + return left_data != null && right_data != null && left_data == right_data; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function command_env(assignments) { + let parts = []; + + for (let name, value in assignments) + push(parts, name + "=" + shell_quote(value)); + + return join(" ", parts); +} + +function command_args_with(base, extra) { + let result = []; + + for (let arg in base) + push(result, arg); + for (let arg in extra) + push(result, arg); + + return result; +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_status_from_args(args) { + let status = int(system(command_from_args(args))); + return status > 255 ? int(status / 256) : status; +} + +function run_silent(command) { + system(command + " >/dev/null 2>&1"); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]); +} + +function ensure_dir(path) { + run_silent("mkdir -p " + shell_quote(path)); +} + +function chmod_path(path, mode) { + run_silent("chmod " + shell_quote(mode) + " " + shell_quote(path)); +} + +function ensure_runtime_dirs() { + ensure_dir(TMP_SING_BOX_FOLDER); + ensure_dir(TMP_RULESET_FOLDER); + ensure_dir(TMP_SUBSCRIPTION_FOLDER); + ensure_dir(TACHYON_RUNTIME_STATE_DIR); + ensure_dir(TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR); + ensure_dir(TACHYON_SUBSCRIPTION_METADATA_DIR); + ensure_dir(TACHYON_OUTBOUND_METADATA_DIR); + ensure_dir(TACHYON_SECTION_CACHE_DIR); +} + +function clear_subscription_runtime_cache() { + run_silent("rm -rf " + + shell_quote(TMP_SUBSCRIPTION_FOLDER) + " " + + shell_quote(TACHYON_SUBSCRIPTION_LINKS_DIR) + " " + + shell_quote(TACHYON_SUBSCRIPTION_METADATA_DIR) + " " + + shell_quote(TACHYON_OUTBOUND_METADATA_DIR) + " " + + shell_quote(TACHYON_SECTION_CACHE_DIR)); +} + +function ensure_runtime_cache_format() { + ensure_dir(TACHYON_RUNTIME_STATE_DIR); + + if (file_first_line_value(TACHYON_RUNTIME_CACHE_FORMAT_FILE) != TACHYON_RUNTIME_CACHE_FORMAT) { + log_message("Runtime subscription cache format changed; clearing old subscription cache", "info"); + if (file_first_line_value(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE) == TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT) + subscription_share_link.populate_subscription_dir(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR); + clear_subscription_runtime_cache(); + ensure_runtime_dirs(); + write_file(TACHYON_RUNTIME_CACHE_FORMAT_FILE, TACHYON_RUNTIME_CACHE_FORMAT + "\n"); + } + + if (file_first_line_value(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE) != TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT) { + run_silent("rm -rf " + shell_quote(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR)); + ensure_dir(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR); + chmod_path(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, "700"); + write_file(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT + "\n"); + chmod_path(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, "600"); + } +} + +function temp_path(dir, section, kind) { + ensure_dir(dir); + let stamp = clock(); + return sprintf("%s/%s.%s.%d.%d", dir, as_string(section), as_string(kind), stamp[0], stamp[1]); +} + +function move_file(source, target) { + return fs.rename(as_string(source), as_string(target)); +} + +function write_text_if_changed(path, value) { + path = as_string(path); + value = as_string(value); + + if (fs.readfile(path) == value) { + chmod_path(path, "600"); + return true; + } + + let tmp_path = sprintf("%s.%d.%d.tmp", path, clock()[0], clock()[1]); + if (!write_file(tmp_path, value)) { + unlink_path(tmp_path); + return false; + } + if (!fs.rename(tmp_path, path)) { + unlink_path(tmp_path); + return false; + } + + chmod_path(path, "600"); + return true; +} + +function copy_file_if_changed(source, target) { + if (!file_nonempty(source)) + return false; + if (files_equal(source, target)) { + chmod_path(target, "600"); + return true; + } + if (!copy_file(source, target)) + return false; + + chmod_path(target, "600"); + return true; +} + +function get_device_model() { + let model = fs.readfile("/tmp/sysinfo/model"); + model = trim(as_string(model)); + return model != "" ? model : "OpenWrt Router"; +} + +function generate_hwid() { + let mac = trim(as_string(fs.readfile("/sys/class/net/eth0/address"))); + if (mac == "") + mac = trim(as_string(fs.readfile("/sys/class/net/br-lan/address"))); + + let input = mac + "-" + get_device_model(); + let output = command_output("printf %s " + shell_quote(input) + " | md5sum"); + let hash = length(whitespace_values(output)) > 0 ? substr(whitespace_values(output)[0], 0, 16) : ""; + return substr(hash, 0, 4) + "-" + substr(hash, 4, 4) + "-" + substr(hash, 8, 4) + "-" + substr(hash, 12, 4); +} + +function hwid_matches_config(configured_hwid, cached_hwid) { + configured_hwid = as_string(configured_hwid); + cached_hwid = as_string(cached_hwid); + + if (configured_hwid != "") + return cached_hwid == configured_hwid; + + return cached_hwid == "" || cached_hwid == generate_hwid(); +} + +function restore_persistent_subscription_cache(source_section, tmp_dir, persistent_dir, expected_url, expected_user_agent, expected_hwid, default_user_agent) { + if (!cache_section_is_safe(source_section)) + return false; + + let runtime_json = source_json_path(tmp_dir, source_section); + if (subscription_cache_is_usable(runtime_json)) + return true; + + let persistent_json = source_json_path(persistent_dir, source_section); + if (!subscription_cache_is_usable(persistent_json)) + return false; + + let cached_url = read_text(source_url_path(persistent_dir, source_section)); + let cached_user_agent = read_text(source_user_agent_path(persistent_dir, source_section)); + let cached_hwid = read_text(source_hwid_path(persistent_dir, source_section)); + if (cached_url != as_string(expected_url)) + return false; + if (!user_agent_matches_config(expected_user_agent, cached_user_agent, default_user_agent)) + return false; + if (!hwid_matches_config(expected_hwid, cached_hwid)) + return false; + + return copy_file(persistent_json, runtime_json) && + write_text(source_url_path(tmp_dir, source_section), cached_url) && + write_text(source_user_agent_path(tmp_dir, source_section), cached_user_agent) && + write_text(source_hwid_path(tmp_dir, source_section), cached_hwid); +} + +function subscription_source_profile(section, entry) { + let parsed = subscription_parser().parse_subscription_source_entry(entry); + if (type(parsed) != "object" || parsed.valid !== true) + return parsed; + + let user_agent = connections.subscription_user_agent(section, entry); + if (user_agent != "") + parsed.user_agent = user_agent; + + parsed.hwid = connections.subscription_hwid(section, entry); + parsed.download_section = connections.subscription_download_section(section, entry); + parsed.update_enabled = connections.subscription_update_enabled(section, entry); + parsed.update_interval = connections.subscription_update_interval(section, entry); + parsed.show_dashboard_metadata = connections.subscription_dashboard_metadata_enabled(section, entry); + return parsed; +} + +function source_cache_profile_matches(parsed, cached_url, cached_user_agent, cached_hwid, default_user_agent) { + return cached_url == as_string(parsed.url) && + user_agent_matches_config(parsed.user_agent, cached_user_agent, default_user_agent) && + hwid_matches_config(parsed.hwid, cached_hwid); +} + +function section_current_usable_cache(section, tmp_dir, persistent_dir, default_user_agent) { + section = object_or_empty(section); + let name = section_name(section); + if (!section_has_subscription_urls(section) || !cache_section_is_safe(name)) + return false; + + let index = 0; + for (let entry in connections.subscription_urls(section)) { + index++; + let parsed = subscription_source_profile(section, entry); + if (type(parsed) != "object" || parsed.valid !== true) + continue; + + let source_section = source_id(name, index); + if (!restore_persistent_subscription_cache( + source_section, + tmp_dir, + persistent_dir, + parsed.url, + parsed.user_agent, + parsed.hwid, + default_user_agent + )) + continue; + + if (!subscription_cache_is_usable(source_json_path(tmp_dir, source_section))) + continue; + + let cached_url = read_text(source_url_path(tmp_dir, source_section)); + let cached_user_agent = read_text(source_user_agent_path(tmp_dir, source_section)); + let cached_hwid = read_text(source_hwid_path(tmp_dir, source_section)); + if (source_cache_profile_matches(parsed, cached_url, cached_user_agent, cached_hwid, default_user_agent)) + return true; + } + + return false; +} + +function uci_section(section_name_value) { + return object_or_empty(uci_core.get_all(CONFIG_NAME, section_name_value)); +} + +function uci_named_section(section_name_value) { + return object_or_empty(uci_core.get_all(CONFIG_NAME, section_name_value)); +} + +function find_section(sections, name) { + name = as_string(name); + for (let section in sections) { + section = object_or_empty(section); + if (section_name(section) == name) + return section; + } + return {}; +} + +function file_executable(path) { + let stat = fs.stat(as_string(path)); + if (stat == null || stat.mode == null) + return false; + + return (int(stat.mode) & 73) != 0; +} + +function sing_box_service_running() { + return command_success_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/service/state.uc", "sing-box-service-running" ]); +} + +function provider_action_is_available(action) { + if (action == "byedpi") + return file_executable(BYEDPI_BIN); + if (action == "zapret") + return file_executable(ZAPRET_PROVIDER_NFQWS_BIN); + if (action == "zapret2") + return file_executable(ZAPRET2_PROVIDER_NFQWS2_BIN); + return false; +} + +function section_current_usable_cache_by_name(sections, section, default_user_agent) { + return section_current_usable_cache(find_section(sections, section), TMP_SUBSCRIPTION_FOLDER, TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, default_user_agent); +} + +function section_has_non_subscription_connection_sources(section) { + return length(connections.connection_urls(section)) > 0 || + length(connections.interfaces(section)) > 0 || + length(connections.outbound_jsons(section)) > 0; +} + +function subscription_download_target_section_is_ready(sections, download_section, startup_blocked_sections, default_user_agent) { + download_section = as_string(download_section); + if (download_section == "" || state_list_contains(startup_blocked_sections, download_section)) + return false; + + let section = find_section(sections, download_section); + if (!bool_option(section, "enabled", true)) + return false; + + let action = option(section, "action", ""); + if (connections.is_connections_action(action)) { + if (section_has_non_subscription_connection_sources(section)) + return true; + if (section_current_usable_cache_by_name(sections, download_section, default_user_agent)) + return true; + + return false; + } + + return provider_action_is_available(action); +} + +function subscription_bootstrap_download_section_is_ready(sections, startup_blocked_sections, default_user_agent) { + let any_target = false; + + for (let section_name_value in whitespace_values(startup_blocked_sections)) { + let section = find_section(sections, section_name_value); + let source_can_retry = false; + + for (let entry in connections.subscription_urls(section)) { + let parsed = subscription_source_profile(section, entry); + let download_section = as_string(object_or_empty(parsed).download_section); + if (download_section == "" || download_section == section_name_value) + continue; + any_target = true; + + if (subscription_download_target_section_is_ready(sections, download_section, startup_blocked_sections, default_user_agent)) { + source_can_retry = true; + break; + } + } + + if (!source_can_retry) + return false; + } + + if (!any_target) + log_message("Subscription startup cannot be deferred because unavailable subscription-only rules have no download-through-section target", "error"); + + return any_target; +} + +function cache_candidate_paths() { + let result = []; + for (let dir in [ + TMP_SUBSCRIPTION_FOLDER, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, + TACHYON_SECTION_CACHE_DIR, + TACHYON_SUBSCRIPTION_LINKS_DIR, + TACHYON_SUBSCRIPTION_METADATA_DIR, + TACHYON_OUTBOUND_METADATA_DIR + ]) { + let entries = fs.lsdir(dir); + if (type(entries) != "array") + continue; + for (let entry in entries) { + let path = dir + "/" + as_string(entry); + let stat = fs.stat(path); + if (stat != null && stat.type == "file") + push(result, path); + } + } + return result; +} + +function prune_stale_subscription_caches_for_sections(sections) { + ensure_runtime_dirs(); + let keep = cache_keep_sets(sections); + + for (let path in cache_candidate_paths()) { + let cache_name = cache_name_from_file(path); + if (cache_name == "") + continue; + + let keep_names = keep_names_for_path( + path, + keep, + TMP_SUBSCRIPTION_FOLDER, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, + TACHYON_SECTION_CACHE_DIR, + TACHYON_SUBSCRIPTION_LINKS_DIR, + TACHYON_SUBSCRIPTION_METADATA_DIR, + TACHYON_OUTBOUND_METADATA_DIR + ); + if (!state_list_contains(keep_names, cache_name)) + unlink_path(path); + } +} + +function append_user_agent_candidate(result, seen, candidate, default_user_agent) { + candidate = as_string(candidate); + if (!user_agent_supported(candidate, default_user_agent) || seen[candidate]) + return; + + seen[candidate] = true; + push(result, candidate); +} + +function write_user_agent_candidates(path, configured_user_agent, preferred_user_agent, default_user_agent) { + path = as_string(path); + configured_user_agent = as_string(configured_user_agent); + preferred_user_agent = as_string(preferred_user_agent); + default_user_agent = as_string(default_user_agent); + + if (configured_user_agent != "") { + if (!write_file(path, configured_user_agent + "\n")) + exit(1); + return; + } + + let result = []; + let seen = {}; + let candidates = [ default_user_agent, preferred_user_agent ]; + for (let profile in auto_user_agent_profiles) + push(candidates, profile); + + for (let candidate in candidates) + append_user_agent_candidate(result, seen, candidate, default_user_agent); + + if (!write_file(path, length(result) > 0 ? join("\n", result) + "\n" : "")) + exit(1); +} + +function write_empty_outbound_metadata() { + write_stdout_json({ + names: {}, + countries: {} + }); +} + +function is_array(value) { + return type(value) == "array"; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function int_or_zero(value) { + value = as_string(value); + if (match(value, /^[0-9]+$/) == null) + return 0; + return int(value); +} + +function count_label(count, singular, plural) { + count = int(count || 0); + return as_string(count) + " " + (count == 1 ? singular : plural); +} + +function subscription_file_stats(path) { + let value = object_or_empty(read_json(path)); + return { + outbounds: length(array_or_empty(value.outbounds)), + skipped: int_or_zero(value.skipped) + }; +} + +function subscription_import_stats_text(path) { + let stats = subscription_file_stats(path); + let text = count_label(stats.outbounds, "proxy entry", "proxy entries"); + if (stats.skipped > 0) + text += ", " + count_label(stats.skipped, "skipped entry", "skipped entries"); + return text; +} + +function subscription_source_summary(section_name_value, source_index, normalized_path, result) { + let prefix = "Subscription source " + as_string(source_index) + " for rule '" + as_string(section_name_value) + "'"; + let stats = subscription_import_stats_text(normalized_path); + + if (result == "unchanged") + return prefix + " is unchanged: " + stats; + if (result == "runtime-unchanged") + return prefix + " refreshed: runtime proxy entries are unchanged (" + stats + ")"; + return prefix + " imported: " + stats; +} + +function log_subscription_source_summary(section_name_value, source_index, normalized_path, result) { + log_message(subscription_source_summary(section_name_value, source_index, normalized_path, result), "info"); +} + +function object_key_count(value) { + return type(value) == "object" ? length(keys(value)) : 0; +} + +function valid_metadata_object(value) { + return type(value) == "object" && object_key_count(value) > 1; +} + +function json_length(path) { + let value = read_json(path); + if (type(value) == "array" || type(value) == "object") + print(length(value), "\n"); + else + print("0\n"); +} + +function object_has_extra_keys(path) { + return object_key_count(read_json(path)) > 1; +} + +function safe_section(section) { + return type(section) == "string" && match(section, /^[A-Za-z0-9_-]+$/); +} + +function cache_path(cache_dir, section) { + return cache_dir + "/" + section + ".json"; +} + +function load_cache(cache_dir, section) { + return object_or_empty(read_json(cache_path(cache_dir, section))); +} + +function normalize_cache(cache, section, format_version) { + cache.version = int(format_version || 0); + cache.section = as_string(section); + cache.links = object_or_empty(cache.links); + cache.outboundMetadata = object_or_empty(cache.outboundMetadata); + cache.outboundMetadata.names = object_or_empty(cache.outboundMetadata.names); + cache.outboundMetadata.countries = object_or_empty(cache.outboundMetadata.countries); + cache.servers = object_or_empty(cache.servers); + cache.urltestCandidateTags = array_or_empty(cache.urltestCandidateTags); + cache.urltestGroups = object_or_empty(cache.urltestGroups); + cache.priorityGroups = object_or_empty(cache.priorityGroups); + cache.subscriptionMetadata = array_or_empty(cache.subscriptionMetadata); + return cache; +} + +function save_cache(cache_dir, section, format_version, cache) { + cache = normalize_cache(cache, section, format_version); + let path = cache_path(cache_dir, section); + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); + + if (!write_json(tmp_path, cache)) + exit(1); + if (!fs.rename(tmp_path, path)) { + fs.unlink(tmp_path); + exit(1); + } +} + +function write_outbound_metadata(cache_dir, format_version, section, names_path, countries_path, servers_path) { + let cache = load_cache(cache_dir, section); + cache.outboundMetadata = { + names: object_or_empty(read_json(names_path)), + countries: object_or_empty(read_json(countries_path)) + }; + cache.servers = object_or_empty(read_json(servers_path)); + save_cache(cache_dir, section, format_version, cache); +} + +function metadata_array_from_file(metadata_path) { + let metadata = read_json(metadata_path); + let result = []; + + if (is_array(metadata)) { + for (let item in metadata) { + if (valid_metadata_object(item)) + push(result, item); + } + } + else if (valid_metadata_object(metadata)) { + push(result, metadata); + } + + return result; +} + +function write_subscription_metadata(cache_dir, format_version, section, metadata_path) { + let cache = load_cache(cache_dir, section); + cache.subscriptionMetadata = metadata_array_from_file(metadata_path); + save_cache(cache_dir, section, format_version, cache); +} + +function read_metadata_items_from_cache(cache_dir, section, legacy_path) { + let cache = load_cache(cache_dir, section); + let metadata = cache.subscriptionMetadata; + + if (type(metadata) != "array") + metadata = read_json(legacy_path); + + let result = []; + if (is_array(metadata)) { + for (let item in metadata) { + if (valid_metadata_object(item)) + push(result, item); + } + } + else if (valid_metadata_object(metadata)) { + push(result, metadata); + } + + return result; +} + +function metadata_source_index(item) { + if (type(item) != "object") + return null; + let value = item.sourceIndex != null ? item.sourceIndex : item.source_index; + if (value == null || as_string(value) == "") + return null; + return int(value); +} + +function metadata_source_section(item) { + if (type(item) != "object") + return ""; + return as_string(item.sourceSection != null ? item.sourceSection : item.source_section); +} + +function metadata_items_have_source_markers(items) { + for (let item in items) { + if (metadata_source_index(item) != null || metadata_source_section(item) != "") + return true; + } + return false; +} + +function metadata_matches_source(item, source_index, source_section, has_source_markers) { + if (!has_source_markers) + return false; + + let item_section = metadata_source_section(item); + let item_index = metadata_source_index(item); + return (item_section != "" && item_section == source_section) || + (item_section == "" && item_index == source_index); +} + +function attach_source_metadata(item, source_index, source_section) { + if (type(item) != "object") + item = {}; + item.sourceIndex = source_index; + item.sourceSection = as_string(source_section); + return item; +} + +function append_metadata_file(array_path, metadata_path, source_index, source_section) { + if (array_path == null || array_path == "") + return; + + let array = array_or_empty(read_json(array_path)); + let metadata = read_json(metadata_path); + source_index = int(source_index || 0); + source_section = as_string(source_section); + + if (valid_metadata_object(metadata)) { + push(array, attach_source_metadata(metadata, source_index, source_section)); + write_json(array_path, array); + } +} + +function append_cached_metadata(array_path, cache_dir, section, legacy_path, source_index, source_section) { + if (array_path == null || array_path == "") + return; + + let array = array_or_empty(read_json(array_path)); + let items = read_metadata_items_from_cache(cache_dir, section, legacy_path); + source_index = int(source_index || 0); + source_section = as_string(source_section); + + let has_source_markers = metadata_items_have_source_markers(items); + let selected = null; + if (has_source_markers) { + for (let item in items) { + if (metadata_matches_source(item, source_index, source_section, true)) { + selected = item; + break; + } + } + } + else if (source_index > 0 && source_index <= length(items)) { + selected = items[source_index - 1]; + } + + if (valid_metadata_object(selected)) { + push(array, attach_source_metadata(selected, source_index, source_section)); + write_json(array_path, array); + } +} + +function write_source_metadata(cache_dir, format_version, section, source_index, source_section, metadata_path, legacy_path) { + let cache = load_cache(cache_dir, section); + let items = read_metadata_items_from_cache(cache_dir, section, legacy_path); + let kept = []; + let has_source_markers = metadata_items_have_source_markers(items); + source_index = int(source_index || 0); + source_section = as_string(source_section); + + for (let index, item in items) { + let keep = has_source_markers ? + !metadata_matches_source(item, source_index, source_section, true) : + (index + 1) != source_index; + if (keep && valid_metadata_object(item)) + push(kept, item); + } + + let metadata = read_json(metadata_path); + if (valid_metadata_object(metadata)) + push(kept, attach_source_metadata(metadata, source_index, source_section)); + + sort(kept, function(first, second) { + let first_index = metadata_source_index(first) || 999999; + let second_index = metadata_source_index(second) || 999999; + return first_index == second_index ? 0 : (first_index < second_index ? -1 : 1); + }); + + cache.subscriptionMetadata = kept; + save_cache(cache_dir, section, format_version, cache); +} + +function get_outbound_metadata(cache_dir, section, legacy_path) { + let cache = load_cache(cache_dir, section); + let metadata = cache.outboundMetadata; + if (type(metadata) != "object") + metadata = read_json(legacy_path); + metadata = object_or_empty(metadata); + let names = object_or_empty(metadata.names); + let countries = object_or_empty(metadata.countries); + let candidate_tags = array_or_empty(cache.urltestCandidateTags); + let groups = object_or_empty(cache.urltestGroups); + let priority_groups = object_or_empty(cache.priorityGroups); + let result = { + names: {}, + countries: {} + }; + + if (length(candidate_tags) > 0) { + for (let tag in candidate_tags) { + tag = as_string(tag); + if (tag == "") + continue; + if (names[tag] != null) + result.names[tag] = names[tag]; + if (countries[tag] != null) + result.countries[tag] = countries[tag]; + } + } + else { + for (let tag, name in names) { + if (!groups[tag] && !priority_groups[tag]) + result.names[tag] = name; + } + for (let tag, country in countries) { + if (!groups[tag] && !priority_groups[tag]) + result.countries[tag] = country; + } + } + + write_stdout_json(result); +} + +function get_subscription_metadata(cache_dir, section, legacy_path) { + let items = read_metadata_items_from_cache(cache_dir, section, legacy_path); + if (length(items) > 0) + write_stdout_json(items); + else + print("{}\n"); +} + +function persistent_metadata_path(source_section) { + return TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/" + as_string(source_section) + ".metadata.json"; +} + +function remove_subscription_source_runtime_cache(source_section) { + if (!cache_section_is_safe(source_section)) + return; + + unlink_path(source_json_path(TMP_SUBSCRIPTION_FOLDER, source_section)); + unlink_path(source_url_path(TMP_SUBSCRIPTION_FOLDER, source_section)); + unlink_path(source_user_agent_path(TMP_SUBSCRIPTION_FOLDER, source_section)); + unlink_path(source_hwid_path(TMP_SUBSCRIPTION_FOLDER, source_section)); +} + +function persist_subscription_cache(source_section, subscription_json_path, subscription_url, effective_user_agent, effective_hwid, metadata_path) { + if (!cache_section_is_safe(source_section) || !subscription_cache_is_usable(subscription_json_path)) + return false; + + ensure_dir(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR); + chmod_path(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, "700"); + + let persistent_json = source_json_path(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, source_section); + let persistent_url = source_url_path(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, source_section); + let persistent_user_agent = source_user_agent_path(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, source_section); + let persistent_hwid = source_hwid_path(TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, source_section); + let persistent_metadata = persistent_metadata_path(source_section); + let previous_url = read_text(persistent_url); + let previous_user_agent = read_text(persistent_user_agent); + let previous_hwid = read_text(persistent_hwid); + let can_keep_previous_metadata = previous_url == as_string(subscription_url) && + previous_user_agent == as_string(effective_user_agent) && + previous_hwid == as_string(effective_hwid) && + file_nonempty(persistent_metadata) && + object_has_extra_keys(persistent_metadata); + + if (!copy_file_if_changed(subscription_json_path, persistent_json) || + !write_text_if_changed(persistent_url, subscription_url) || + !write_text_if_changed(persistent_user_agent, effective_user_agent) || + !write_text_if_changed(persistent_hwid, effective_hwid)) + return false; + + if (metadata_path != null && metadata_path != "" && file_nonempty(metadata_path) && object_has_extra_keys(metadata_path)) { + if (!copy_file_if_changed(metadata_path, persistent_metadata)) + return false; + } + else if (!can_keep_previous_metadata) { + unlink_path(persistent_metadata); + } + + return true; +} + +function append_persistent_source_metadata(array_path, source_index, source_section) { + let metadata_path = persistent_metadata_path(source_section); + if (!file_nonempty(metadata_path)) + return false; + + append_metadata_file(array_path, metadata_path, source_index, source_section); + return true; +} + +function append_available_cached_metadata(array_path, section, source_index, source_section) { + if (array_path == null || array_path == "") + return false; + + let before = length(array_or_empty(read_json(array_path))); + append_persistent_source_metadata(array_path, source_index, source_section) || + append_cached_metadata(array_path, TACHYON_SECTION_CACHE_DIR, section, TACHYON_SUBSCRIPTION_METADATA_DIR + "/" + section + ".json", source_index, source_section); + return length(array_or_empty(read_json(array_path))) > before; +} + +function get_kernel_version() { + return trim(command_output_from_args([ "uname", "-r" ])); +} + +function sing_box_version_from_output(output) { + let first = split(as_string(output), "\n")[0] || ""; + let fields = whitespace_values(first); + return length(fields) > 0 ? fields[length(fields) - 1] : ""; +} + +function get_sing_box_version() { + if (trim(as_string(fs.readfile(SB_VARIANT_STATE_FILE))) == "extended-compressed") { + let version_state = trim(as_string(fs.readfile(SB_VERSION_STATE_FILE))); + if (version_state != "") + return version_state; + } + + if (!command_success_from_args([ "sh", "-c", "command -v sing-box" ])) + return ""; + + return sing_box_version_from_output(command_output_from_args([ "sing-box", "version" ])); +} + +function get_subscription_user_agent(custom_user_agent) { + custom_user_agent = as_string(custom_user_agent); + if (custom_user_agent != "") + return custom_user_agent; + + let version = get_sing_box_version(); + return "sing-box/" + (version != "" ? version : "unknown"); +} + +function user_agent_candidates(configured_user_agent, preferred_user_agent, default_user_agent) { + configured_user_agent = as_string(configured_user_agent); + if (configured_user_agent != "") + return [ configured_user_agent ]; + + let result = []; + let seen = {}; + let candidates = [ default_user_agent, preferred_user_agent ]; + for (let profile in auto_user_agent_profiles) + push(candidates, profile); + + for (let candidate in candidates) { + candidate = as_string(candidate); + if (user_agent_supported(candidate, default_user_agent) && !seen[candidate]) { + seen[candidate] = true; + push(result, candidate); + } + } + + return result; +} + +function get_subscription_hwid(custom_hwid) { + custom_hwid = as_string(custom_hwid); + return custom_hwid != "" ? custom_hwid : generate_hwid(); +} + +function download_subscription(url, filepath, http_proxy_address, headers_filepath, effective_user_agent, effective_hwid) { + let retries = 3; + let wait_seconds = 2; + let timeout = 15; + let stamp = clock(); + let suffix = sprintf(".part.%d.%d", stamp[0], stamp[1]); + let tmpfile = filepath + suffix; + let headers_tmpfile = headers_filepath != "" ? headers_filepath + suffix : ""; + let resolution_failed = false; + + unlink_path(tmpfile); + if (headers_tmpfile != "") + unlink_path(headers_tmpfile); + + for (let attempt = 1; attempt <= retries; attempt++) { + let args = [ + "curl", "-fL", "-sS", + "--connect-timeout", timeout, + "--speed-time", timeout, + "--speed-limit", "1" + ]; + + if (http_proxy_address != "") { + push(args, "-x"); + push(args, "http://" + http_proxy_address); + } + if (headers_tmpfile != "") { + push(args, "-D"); + push(args, headers_tmpfile); + } + + push(args, "-o"); + push(args, tmpfile); + for (let header in [ + "User-Agent: " + get_subscription_user_agent(effective_user_agent), + "X-HWID: " + get_subscription_hwid(effective_hwid), + "X-Device-OS: OpenWrt Linux", + "X-Device-Model: " + get_device_model(), + "X-Ver-OS: " + get_kernel_version(), + "Accept-Language: ru-RU,en,*", + "X-Device-Locale: EN" + ]) { + push(args, "-H"); + push(args, header); + } + push(args, url); + + let status = command_status_from_args(args); + if (status == 0 && file_nonempty(tmpfile)) { + move_file(tmpfile, filepath); + if (headers_filepath != "") { + if (file_nonempty(headers_tmpfile)) + move_file(headers_tmpfile, headers_filepath); + else { + unlink_path(headers_filepath); + unlink_path(headers_tmpfile); + } + } + return 0; + } + + unlink_path(tmpfile); + unlink_path(headers_tmpfile); + + if (status == 5 || status == 6) { + resolution_failed = true; + break; + } + + if (attempt < retries) + system("sleep " + int(wait_seconds)); + } + + unlink_path(tmpfile); + unlink_path(headers_tmpfile); + return resolution_failed ? 6 : 1; +} + +function copy_valid_metadata_output(metadata_tmpfile, metadata_output_path) { + if (metadata_output_path == null || metadata_output_path == "") + return; + + if (file_nonempty(metadata_tmpfile) && object_has_extra_keys(metadata_tmpfile)) + copy_file(metadata_tmpfile, metadata_output_path) || unlink_path(metadata_output_path); + else + unlink_path(metadata_output_path); +} + +function copy_persistent_metadata_output(source_section, metadata_output_path) { + if (metadata_output_path == null || metadata_output_path == "") + return; + + let metadata_path = persistent_metadata_path(source_section); + if (file_nonempty(metadata_path) && object_has_extra_keys(metadata_path)) + copy_file(metadata_path, metadata_output_path) || unlink_path(metadata_output_path); +} + +function subscription_config_is_current(section_name_value, subscription_url, subscription_user_agent, subscription_hwid, sections) { + let section = find_section(sections, section_name_value); + for (let entry in connections.subscription_urls(section)) { + let parsed = subscription_source_profile(section, entry); + if (type(parsed) == "object" && parsed.valid === true && + as_string(parsed.url) == as_string(subscription_url) && + as_string(parsed.user_agent) == as_string(subscription_user_agent) && + as_string(parsed.hwid) == as_string(subscription_hwid)) + return true; + } + + return false; +} + +function get_subscription_download_proxy_address(section_name_value, sections, parsed, phase) { + let download_section = as_string(object_or_empty(parsed).download_section); + if (download_section == "" || download_section == as_string(section_name_value)) + return ""; + + let port = connections.subscription_download_target_port(sections, download_section, int(SB_SERVICE_MIXED_INBOUND_PORT)); + if (port <= 0) + return ""; + + if (!sing_box_service_running()) { + if (phase == "startup") + log_message("Subscription source for rule '" + section_name_value + "' is configured to download via rule '" + download_section + "', but sing-box is not running yet; downloading it directly during startup", "warn"); + else + log_message("Subscription source for rule '" + section_name_value + "' is configured to download via rule '" + download_section + "', but sing-box service proxy is not running; downloading it directly", "warn"); + return ""; + } + + let address = SB_SERVICE_MIXED_INBOUND_ADDRESS + ":" + as_string(port); + log_message("Downloading subscription for rule '" + section_name_value + "' via service proxy " + address, "debug"); + return address; +} + +function download_subscription_into_cache(section_name_value, subscription_url, subscription_json_path, subscription_url_cache_path, service_proxy_address_value, subscription_user_agent, subscription_hwid, source_index, cache_section, metadata_output_path, sections) { + ensure_dir(TMP_SUBSCRIPTION_FOLDER); + let subscription_user_agent_cache_path = source_user_agent_path(TMP_SUBSCRIPTION_FOLDER, cache_section); + let subscription_hwid_cache_path = source_hwid_path(TMP_SUBSCRIPTION_FOLDER, cache_section); + let raw_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, cache_section, "download"); + let headers_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, cache_section, "headers"); + let normalized_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, cache_section, "normalized"); + let metadata_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, cache_section, "metadata"); + let cached_user_agent = read_text(subscription_user_agent_cache_path); + let default_user_agent = get_subscription_user_agent(""); + let parser = subscription_parser(); + + let attempt_index = 0; + for (let effective_user_agent in user_agent_candidates(subscription_user_agent, cached_user_agent, default_user_agent)) { + attempt_index++; + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(normalized_tmpfile); + unlink_path(metadata_tmpfile); + + let effective_hwid = get_subscription_hwid(subscription_hwid); + let download_status = download_subscription(subscription_url, raw_tmpfile, service_proxy_address_value, headers_tmpfile, effective_user_agent, effective_hwid); + if (download_status != 0) { + if (metadata_output_path != "") + unlink_path(metadata_output_path); + if (download_status == 6) { + log_message("Subscription download failed for rule '" + section_name_value + "' because the subscription host could not be resolved; compatibility retries skipped", "warn"); + break; + } + else if (subscription_user_agent != "") + log_message("Subscription download failed for rule '" + section_name_value + "' with the configured request profile", "warn"); + else + log_message("Subscription download attempt " + attempt_index + " failed for rule '" + section_name_value + "'; trying another compatibility profile", "warn"); + continue; + } + + if (parser.try_decode_gzip_content_file(raw_tmpfile)) + log_message("Decoded gzip-compressed subscription body for rule '" + section_name_value + "'", "info"); + + parser.extract_ui_metadata_file(headers_tmpfile, raw_tmpfile, metadata_tmpfile) || unlink_path(metadata_tmpfile); + copy_valid_metadata_output(metadata_tmpfile, metadata_output_path); + + if (!parser.normalize_content_validated(raw_tmpfile, normalized_tmpfile)) { + if (metadata_output_path != "") + unlink_path(metadata_output_path); + if (subscription_user_agent != "") + log_message("Downloaded subscription for rule '" + section_name_value + "' is invalid with the configured request profile", "error"); + else + log_message("Downloaded subscription for rule '" + section_name_value + "' is invalid; trying another compatibility profile", "warn"); + continue; + } + + if (!subscription_share_link.populate_subscription_file(normalized_tmpfile)) + log_message("Failed to cache direct proxy links for subscription rule '" + section_name_value + "'", "warn"); + + if (!subscription_config_is_current(section_name_value, subscription_url, subscription_user_agent, subscription_hwid, sections)) { + log_message("Subscription source settings changed while updating rule '" + section_name_value + "'; discarding superseded download", "warn"); + if (metadata_output_path != "") + unlink_path(metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(normalized_tmpfile); + unlink_path(metadata_tmpfile); + return 4; + } + + if (files_equal(normalized_tmpfile, subscription_json_path)) { + write_text(subscription_url_cache_path, subscription_url); + write_text(subscription_user_agent_cache_path, effective_user_agent); + write_text(subscription_hwid_cache_path, effective_hwid); + persist_subscription_cache(cache_section, subscription_json_path, subscription_url, effective_user_agent, effective_hwid, metadata_tmpfile) || + log_message("Failed to persist last working subscription cache for source '" + cache_section + "'", "warn"); + if (!file_nonempty(metadata_output_path)) + copy_persistent_metadata_output(cache_section, metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(normalized_tmpfile); + unlink_path(metadata_tmpfile); + log_subscription_source_summary(section_name_value, source_index, subscription_json_path, "unchanged"); + return 2; + } + + if (file_nonempty(subscription_json_path) && parser.runtime_outbounds_equal(normalized_tmpfile, subscription_json_path)) { + if (!move_file(normalized_tmpfile, subscription_json_path)) { + if (metadata_output_path != "") + unlink_path(metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(metadata_tmpfile); + return 1; + } + + write_text(subscription_url_cache_path, subscription_url); + write_text(subscription_user_agent_cache_path, effective_user_agent); + write_text(subscription_hwid_cache_path, effective_hwid); + persist_subscription_cache(cache_section, subscription_json_path, subscription_url, effective_user_agent, effective_hwid, metadata_tmpfile) || + log_message("Failed to persist last working subscription cache for source '" + cache_section + "'", "warn"); + if (!file_nonempty(metadata_output_path)) + copy_persistent_metadata_output(cache_section, metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(metadata_tmpfile); + log_subscription_source_summary(section_name_value, source_index, subscription_json_path, "runtime-unchanged"); + return 2; + } + + if (!move_file(normalized_tmpfile, subscription_json_path)) { + if (metadata_output_path != "") + unlink_path(metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(metadata_tmpfile); + return 1; + } + + write_text(subscription_url_cache_path, subscription_url); + write_text(subscription_user_agent_cache_path, effective_user_agent); + write_text(subscription_hwid_cache_path, effective_hwid); + persist_subscription_cache(cache_section, subscription_json_path, subscription_url, effective_user_agent, effective_hwid, metadata_tmpfile) || + log_message("Failed to persist last working subscription cache for source '" + cache_section + "'", "warn"); + if (!file_nonempty(metadata_output_path)) + copy_persistent_metadata_output(cache_section, metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(metadata_tmpfile); + log_subscription_source_summary(section_name_value, source_index, subscription_json_path, "imported"); + return 0; + } + + if (metadata_output_path != "") + unlink_path(metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(normalized_tmpfile); + unlink_path(metadata_tmpfile); + if (subscription_user_agent != "") + log_message("Configured subscription request profile for rule '" + section_name_value + "' did not produce valid proxy entries", "error"); + else + log_message("No compatible subscription request profile produced valid proxy entries for rule '" + section_name_value + "'", "error"); + return 1; +} + +function cached_source_status(source_section, parsed) { + restore_persistent_subscription_cache( + source_section, + TMP_SUBSCRIPTION_FOLDER, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, + parsed.url, + parsed.user_agent, + parsed.hwid, + get_subscription_user_agent("") + ); + + let json_path = source_json_path(TMP_SUBSCRIPTION_FOLDER, source_section); + let url_path = source_url_path(TMP_SUBSCRIPTION_FOLDER, source_section); + let user_agent_path = source_user_agent_path(TMP_SUBSCRIPTION_FOLDER, source_section); + let hwid_path = source_hwid_path(TMP_SUBSCRIPTION_FOLDER, source_section); + let had_usable_cache = subscription_cache_is_usable(json_path); + if (!had_usable_cache) + unlink_path(json_path); + + let cached_url = read_text(url_path); + let cached_user_agent = read_text(user_agent_path); + let cached_hwid = read_text(hwid_path); + if (had_usable_cache && + !source_cache_profile_matches(parsed, cached_url, cached_user_agent, cached_hwid, get_subscription_user_agent(""))) { + remove_subscription_source_runtime_cache(source_section); + return { + usable: false, + current: false, + cached_url: "", + cached_user_agent: "", + cached_hwid: "" + }; + } + + return { + usable: had_usable_cache, + current: had_usable_cache, + cached_url, + cached_user_agent, + cached_hwid + }; +} + +function update_subscription_source(section_name_value, index_value, entry, phase, metadata_output_path) { + ensure_runtime_dirs(); + let sections = uci_sections(); + let settings = uci_named_section("settings"); + let index = int(index_value || 0); + let section = find_section(sections, section_name_value); + let parsed = subscription_source_profile(section, entry); + if (type(parsed) != "object" || parsed.valid !== true) { + log_message("Invalid subscription URL in rule '" + section_name_value + "': " + as_string(parsed.error || "Invalid subscription source entry"), "error"); + return 1; + } + + let source_section = source_id(section_name_value, index); + let status = cached_source_status(source_section, parsed); + if (status.usable && !status.current) { + log_message("Discarding stale subscription cache for rule '" + section_name_value + "' source '" + index + "' because source settings changed", "info"); + remove_subscription_source_runtime_cache(source_section); + } + + let proxy = get_subscription_download_proxy_address(section_name_value, sections, parsed, phase || "runtime"); + return download_subscription_into_cache( + section_name_value, + parsed.url, + source_json_path(TMP_SUBSCRIPTION_FOLDER, source_section), + source_url_path(TMP_SUBSCRIPTION_FOLDER, source_section), + proxy, + parsed.user_agent, + parsed.hwid, + index, + source_section, + as_string(metadata_output_path), + sections + ); +} + +function subscription_update_timestamp_path(section_name_value) { + return TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR + "/" + as_string(section_name_value) + ".timestamp"; +} + +function current_timestamp_value() { + let value = trim(command_output_from_args([ "date", "+%s" ])); + return unsigned_number(value) == null ? "" : value; +} + +function write_subscription_update_timestamp(section_name_value) { + let timestamp = current_timestamp_value(); + if (timestamp == "") + return; + + ensure_dir(TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR); + write_file(subscription_update_timestamp_path(section_name_value), timestamp + "\n"); +} + +function subscription_update_due_result(section) { + section = object_or_empty(section); + let interval = section_subscription_update_interval(section); + if (interval == "") + return 1; + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + log_message("Invalid subscription_update_interval value for rule '" + section_name(section) + "': " + interval, "error"); + return 2; + } + + return update_due_status(current_timestamp_value(), file_first_line_value(subscription_update_timestamp_path(section_name(section))), seconds); +} + +function finalize_subscription_section_metadata(section_name_value, metadata_tmpfile, superseded) { + if (as_string(metadata_tmpfile) == "") + return; + + if (!superseded) { + if (length(array_or_empty(read_json(metadata_tmpfile))) > 0) + write_subscription_metadata(TACHYON_SECTION_CACHE_DIR, TACHYON_RUNTIME_CACHE_FORMAT, section_name_value, metadata_tmpfile); + else + write_subscription_metadata(TACHYON_SECTION_CACHE_DIR, TACHYON_RUNTIME_CACHE_FORMAT, section_name_value, ""); + } + + unlink_path(metadata_tmpfile); +} + +function update_count_summary(updated, unchanged, failed) { + let parts = []; + if (updated > 0) + push(parts, count_label(updated, "source updated", "sources updated")); + if (unchanged > 0) + push(parts, count_label(unchanged, "source unchanged", "sources unchanged")); + if (failed > 0) + push(parts, count_label(failed, "source failed", "sources failed")); + return length(parts) > 0 ? join(", ", parts) : "no subscription sources checked"; +} + +function subscription_update_section(section, force) { + section = object_or_empty(section); + if (!section_is_subscription_proxy(section)) + return 3; + + let section_name_value = section_name(section); + if (!force) { + let due_result = subscription_update_due_result(section); + if (due_result == 1) + return 3; + if (due_result != 0) + return 1; + } + + ensure_runtime_dirs(); + log_message("Updating subscriptions for rule '" + section_name_value + "'", "info"); + + let metadata_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, section_name_value, "metadata-section"); + write_json(metadata_tmpfile, []); + + let changed = 0; + let unchanged = 0; + let failed = 0; + let superseded = 0; + let total = 0; + + for (let entry in connections.subscription_urls(section)) { + total++; + if (!force && !connections.subscription_update_enabled(section, entry)) + continue; + let source_section = source_id(section_name_value, total); + let metadata_output_path = temp_path(TMP_SUBSCRIPTION_FOLDER, source_section, "metadata-output"); + let update_result = update_subscription_source(section_name_value, total, entry, "runtime", metadata_output_path); + + if (update_result == 0) { + append_metadata_file(metadata_tmpfile, metadata_output_path, total, source_section); + changed++; + } + else if (update_result == 2) { + append_metadata_file(metadata_tmpfile, metadata_output_path, total, source_section); + unchanged++; + } + else if (update_result == 4) { + superseded++; + } + else { + append_available_cached_metadata(metadata_tmpfile, section_name_value, total, source_section); + failed++; + } + + unlink_path(metadata_output_path); + } + + if (total == 0) { + unlink_path(metadata_tmpfile); + log_message("Subscription URL is not set for rule '" + section_name_value + "'", "info"); + return 1; + } + + let update_result = 2; + if (changed > 0) + update_result = 0; + else if (failed > 0) + update_result = 1; + else if (superseded > 0) + update_result = 4; + + finalize_subscription_section_metadata(section_name_value, metadata_tmpfile, superseded > 0); + + if (update_result == 0) { + write_subscription_update_timestamp(section_name_value); + log_message("Subscription update for rule '" + section_name_value + "' completed: " + update_count_summary(changed, unchanged, failed), "info"); + } + else if (update_result == 2) { + write_subscription_update_timestamp(section_name_value); + log_message("Subscription update for rule '" + section_name_value + "' completed: no changes (" + count_label(unchanged, "source checked", "sources checked") + ")", "info"); + } + else if (update_result == 4) { + log_message("Subscription update for rule '" + section_name_value + "' was superseded by newer URLs", "info"); + } + else { + log_message("Subscription update for rule '" + section_name_value + "' failed: " + update_count_summary(changed, unchanged, failed), "warn"); + } + + return update_result; +} + +function source_index_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9]/) != null) + return null; + + let result = int(value); + return result > 0 ? result : null; +} + +function subscription_update_selected_source(sections, section_name_value, source_index_value, force) { + if (!cache_section_is_safe(section_name_value)) { + log_message("Invalid subscription rule name", "error"); + return 1; + } + + let source_index = source_index_number(source_index_value); + if (source_index == null) { + log_message("Invalid subscription source index for rule '" + section_name_value + "'", "error"); + return 1; + } + + let section = find_section(sections, section_name_value); + if (!section_is_subscription_proxy(section)) { + log_message("Rule '" + section_name_value + "' has no subscription sources", "info"); + return 1; + } + + if (!force) { + let due_result = subscription_update_due_result(section); + if (due_result == 1) + return 3; + if (due_result != 0) + return 1; + } + + ensure_runtime_dirs(); + log_message("Updating subscription source " + source_index + " for rule '" + section_name_value + "'", "info"); + + let current_index = 0; + let selected_entry = null; + for (let entry in connections.subscription_urls(section)) { + current_index++; + if (current_index == source_index) { + selected_entry = entry; + break; + } + } + + if (selected_entry == null) { + log_message("Subscription source '" + source_index + "' was not found for rule '" + section_name_value + "'", "info"); + return 1; + } + + let source_section = source_id(section_name_value, source_index); + let metadata_output_path = temp_path(TMP_SUBSCRIPTION_FOLDER, source_section, "metadata-output"); + let update_result = update_subscription_source(section_name_value, source_index, selected_entry, "runtime", metadata_output_path); + + if (update_result == 0 || update_result == 2) + write_source_metadata( + TACHYON_SECTION_CACHE_DIR, + TACHYON_RUNTIME_CACHE_FORMAT, + section_name_value, + source_index, + source_section, + metadata_output_path, + TACHYON_SUBSCRIPTION_METADATA_DIR + "/" + section_name_value + ".json" + ); + + unlink_path(metadata_output_path); + + if (update_result == 0) { + write_subscription_update_timestamp(section_name_value); + } + else if (update_result == 2) { + write_subscription_update_timestamp(section_name_value); + } + else if (update_result == 4) { + log_message("Subscription source '" + source_index + "' update for rule '" + section_name_value + "' was superseded by newer URLs", "info"); + } + else { + log_message("Failed to download subscription source '" + source_index + "' for rule '" + section_name_value + "'", "info"); + } + + return update_result; +} + +function empty_subscription_update_summary() { + return { + updated: 0, + failed: 0, + unchanged: 0, + superseded: 0 + }; +} + +function add_subscription_update_result(summary, result) { + if (result == 0) + summary.updated++; + else if (result == 1) + summary.failed++; + else if (result == 2) + summary.unchanged++; + else if (result == 4) + summary.superseded++; +} + +function print_subscription_update_summary(summary) { + print(summary.updated, "\t", summary.failed, "\t", summary.unchanged, "\t", summary.superseded, "\n"); +} + +function subscription_update_request(force_value, target_section_name, target_source_index) { + let force = as_string(force_value) == "1"; + let sections = uci_sections(); + let summary = empty_subscription_update_summary(); + + target_section_name = as_string(target_section_name); + target_source_index = as_string(target_source_index); + + if (target_section_name != "") { + if (target_source_index != "") + add_subscription_update_result(summary, subscription_update_selected_source(sections, target_section_name, target_source_index, force)); + else + add_subscription_update_result(summary, subscription_update_section(find_section(sections, target_section_name), force)); + print_subscription_update_summary(summary); + return; + } + + for (let section in sections) + add_subscription_update_result(summary, subscription_update_section(section, force)); + + print_subscription_update_summary(summary); +} + +function ensure_subscription_source_for_prepare(state, section, source_index, entry, metadata_tmpfile) { + let section_name_value = section_name(section); + let parsed = subscription_source_profile(section, entry); + if (type(parsed) != "object" || parsed.valid !== true) { + log_message("Invalid subscription source for rule '" + section_name_value + "': " + as_string(parsed.error || "Invalid subscription source entry"), "error"); + state.startup_blocked = true; + return false; + } + + let source_section = source_id(section_name_value, source_index); + let cached = cached_source_status(source_section, parsed); + if (cached.usable && cached.current) { + append_available_cached_metadata(metadata_tmpfile, section_name_value, source_index, source_section); + return true; + } + + if (state.no_refresh) { + log_message("No current subscription cache for rule '" + section_name_value + "' source '" + source_index + "'; skipping refresh during scoped runtime rebuild", "warn"); + return false; + } + + let metadata_output_path = metadata_tmpfile != "" ? temp_path(TMP_SUBSCRIPTION_FOLDER, source_section, "metadata-output") : ""; + let proxy = get_subscription_download_proxy_address(section_name_value, state.sections, parsed, state.phase); + let update_result = download_subscription_into_cache( + section_name_value, + parsed.url, + source_json_path(TMP_SUBSCRIPTION_FOLDER, source_section), + source_url_path(TMP_SUBSCRIPTION_FOLDER, source_section), + proxy, + parsed.user_agent, + parsed.hwid, + source_index, + source_section, + metadata_output_path, + state.sections + ); + + if (update_result == 0 || update_result == 2) { + append_metadata_file(metadata_tmpfile, metadata_output_path, source_index, source_section); + unlink_path(metadata_output_path); + return true; + } + + unlink_path(metadata_output_path); + + if (cached.usable && cached.current) { + log_message("Keeping cached subscription for rule '" + section_name_value + "' until a fresh download succeeds", "warn"); + append_available_cached_metadata(metadata_tmpfile, section_name_value, source_index, source_section); + return true; + } + + log_message("No usable subscription cache for rule '" + section_name_value + "'", "warn"); + return false; +} + +function prepare_subscription_cache_section(state, section) { + section = object_or_empty(section); + if (!section_is_subscription_proxy(section)) + return; + + let section_name_value = section_name(section); + let metadata_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, section_name_value, "metadata-section"); + write_json(metadata_tmpfile, []); + + let total = 0; + let ready = 0; + let failed = 0; + for (let entry in connections.subscription_urls(section)) { + total++; + if (ensure_subscription_source_for_prepare(state, section, total, entry, metadata_tmpfile)) + ready++; + else { + failed++; + state.unavailable_sources = append_word_once(state.unavailable_sources, source_id(section_name_value, total)); + } + } + + if (total > 0 && ready == 0) { + if (section_has_non_subscription_connection_sources(section)) { + log_message("All subscription sources for rule '" + section_name_value + "' are unavailable; starting with manual proxy links only", "warn"); + } + else { + if (state.phase == "startup") + log_message("No usable subscription cache for rule '" + section_name_value + "' and no manual proxy links are configured; checking whether startup can be bootstrapped through the selected proxy/VPN rule", "warn"); + else + log_message("No usable subscription cache for rule '" + section_name_value + "' and no manual proxy links are configured; fast runtime generation cannot use this rule", "warn"); + state.startup_blocked_sections = append_word_once(state.startup_blocked_sections, section_name_value); + } + } + else if (failed > 0) { + log_message("Skipping unavailable subscription source(s) for rule '" + section_name_value + "'; using available outbounds", "warn"); + } + + let metadata_count = length(array_or_empty(read_json(metadata_tmpfile))); + if (metadata_count > 0) + write_subscription_metadata(TACHYON_SECTION_CACHE_DIR, TACHYON_RUNTIME_CACHE_FORMAT, section_name_value, metadata_tmpfile); + else + write_subscription_metadata(TACHYON_SECTION_CACHE_DIR, TACHYON_RUNTIME_CACHE_FORMAT, section_name_value, ""); + unlink_path(metadata_tmpfile); +} + +function prepared_runtime_cache_should_skip(sections, section_cache_dir, phase, already_prepared) { + return as_string(phase) == "runtime" && + as_string(already_prepared) == "1" && + !runtime_cache_missing(sections, section_cache_dir); +} + +function prepare_subscription_caches(phase, already_prepared, no_refresh) { + phase = as_string(phase || "startup"); + let sections = uci_sections(); + + if (prepared_runtime_cache_should_skip(sections, TACHYON_SECTION_CACHE_DIR, phase, already_prepared)) { + print("\n"); + return 0; + } + + ensure_runtime_dirs(); + let state = { + phase, + sections, + settings: uci_named_section("settings"), + startup_blocked: false, + startup_blocked_sections: "", + unavailable_sources: "", + no_refresh: as_string(no_refresh) == "1" + }; + + prune_stale_subscription_caches_for_sections(sections); + for (let section in sections) + prepare_subscription_cache_section(state, section); + + if (state.startup_blocked) + return 1; + + if (state.startup_blocked_sections == "") { + print("\n"); + return 0; + } + + if (phase == "startup" && subscription_bootstrap_download_section_is_ready(sections, state.startup_blocked_sections, get_subscription_user_agent(""))) { + log_message("Starting temporarily without subscription-only rule(s): " + state.startup_blocked_sections + ". They will be retried through the service proxy after sing-box starts", "warn"); + print(state.startup_blocked_sections, "\n"); + return 0; + } + + for (let section in whitespace_values(state.startup_blocked_sections)) + log_message("No usable subscription cache for rule '" + section + "' and no manual proxy links are configured; startup cannot continue", "error"); + + return 1; +} + +function current_pid() { + return trim(command_output("sh -c 'echo $PPID'")); +} + +function pid_running(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function state_ucode_status(args) { + return command_status_from_args(command_args_with([ "ucode", "-L", LIB_DIR, LIB_DIR + "/service/state.uc" ], args)) == 0; +} + +function mark_pending_subscription_recovery_reload() { + state_ucode_status([ "mark-pending-reload", TACHYON_PENDING_RELOAD_FILE, "subscription_deferred_recovery" ]); +} + +function trigger_subscription_recovery_reload(worker) { + if (worker) + command_status_from_args([ TACHYON_SERVICE_INIT, "reload", "subscription_deferred_recovery" ]); + else + mark_pending_subscription_recovery_reload(); +} + +function subscription_bootstrap_retry_result(deferred_sections) { + let sections = uci_sections(); + let default_user_agent = get_subscription_user_agent(""); + let result = { + recovered: "", + remaining: "" + }; + + for (let section in whitespace_values(deferred_sections)) { + let value = find_section(sections, section); + if (!section_is_subscription_proxy(value)) { + log_message("Deferred subscription rule '" + section + "' no longer has subscription proxy sources; removing it from bootstrap retry list", "warn"); + result.recovered = append_word_once(result.recovered, section); + continue; + } + + log_message("Retrying deferred subscription rule '" + section + "' through the service proxy", "info"); + let update_result = subscription_update_section(value, true); + if (update_result == 0 || update_result == 2) { + result.recovered = append_word_once(result.recovered, section); + continue; + } + + if (section_current_usable_cache_by_name(sections, section, default_user_agent)) { + log_message("Deferred subscription rule '" + section + "' has a usable cache after retry despite partial update errors", "warn"); + result.recovered = append_word_once(result.recovered, section); + continue; + } + + result.remaining = append_word_once(result.remaining, section); + log_message("Deferred subscription rule '" + section + "' is still unavailable; keeping it disabled for this startup", "warn"); + } + + return result; +} + +function worker_env() { + return { + TACHYON_CONFIG_NAME: CONFIG_NAME, + TACHYON_LIB: LIB_DIR, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER, + TACHYON_RUNTIME_STATE_DIR, + TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR, + TACHYON_SUBSCRIPTION_LINKS_DIR, + TACHYON_SUBSCRIPTION_METADATA_DIR, + TACHYON_OUTBOUND_METADATA_DIR, + TACHYON_SECTION_CACHE_DIR, + TACHYON_RUNTIME_CACHE_FORMAT_FILE, + TACHYON_RUNTIME_CACHE_FORMAT, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT, + TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE, + TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR, + TACHYON_PENDING_RELOAD_FILE, + TACHYON_SERVICE_INIT, + SB_SERVICE_MIXED_INBOUND_ADDRESS, + SB_SERVICE_MIXED_INBOUND_PORT, + SB_VARIANT_STATE_FILE, + SB_VERSION_STATE_FILE, + ZAPRET_PROVIDER_NFQWS_BIN, + ZAPRET2_PROVIDER_NFQWS2_BIN, + BYEDPI_BIN + }; +} + +function launch_self_worker(args) { + let command_args = command_args_with([ "ucode", "-L", LIB_DIR, LIB_DIR + "/subscription/cache.uc" ], args); + let command = command_env(worker_env()) + " " + command_from_args(command_args) + " >/dev/null 2>&1 1000>&- & echo $!"; + return trim(command_output("sh -c " + shell_quote(command))); +} + +function start_deferred_subscription_bootstrap_retry_worker(deferred_sections) { + deferred_sections = trim(as_string(deferred_sections)); + if (deferred_sections == "") + return; + + ensure_runtime_dirs(); + let existing_pid = trim(file_first_line_value(TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE)); + if (pid_running(existing_pid)) { + log_message("Subscription bootstrap retry worker is already running with PID " + existing_pid, "debug"); + return; + } + + let pid = launch_self_worker([ "deferred-bootstrap-worker", deferred_sections ]); + if (pid != "") + write_file(TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE, pid + "\n"); + log_message("Started subscription bootstrap retry worker for rule(s): " + deferred_sections, "info"); +} + +function stop_deferred_subscription_bootstrap_retry_worker() { + let pid = trim(file_first_line_value(TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE)); + if (pid_running(pid)) { + command_success_from_args([ "kill", pid ]); + log_message("Stopped subscription bootstrap retry worker", "info"); + } + unlink_path(TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE); +} + +function run_deferred_subscription_bootstrap(deferred_sections) { + deferred_sections = trim(as_string(deferred_sections)); + if (deferred_sections == "") + return; + + log_message("Waiting for sing-box service proxy before retrying deferred subscription rule(s): " + deferred_sections, "info"); + let ready = false; + for (let attempt = 1; attempt <= 10; attempt++) { + if (sing_box_service_running()) { + ready = true; + break; + } + system("sleep 1"); + } + + if (!ready) { + log_message("sing-box service proxy did not become ready in time; deferred subscription rule(s) will remain disabled until the next successful subscription update", "warn"); + start_deferred_subscription_bootstrap_retry_worker(deferred_sections); + return; + } + + let result = subscription_bootstrap_retry_result(deferred_sections); + if (result.recovered != "") { + log_message("Recovered deferred subscription rule(s): " + result.recovered + "; scheduling Tachyon reload", "info"); + trigger_subscription_recovery_reload(false); + } + + if (result.remaining != "") { + log_message("Some deferred subscription rule(s) are still disabled for this startup: " + result.remaining, "warn"); + start_deferred_subscription_bootstrap_retry_worker(result.remaining); + } +} + +function deferred_subscription_bootstrap_retry_worker(remaining_sections) { + remaining_sections = trim(as_string(remaining_sections)); + + while (remaining_sections != "") { + system("sleep 30"); + if (!sing_box_service_running()) { + log_message("Stopping subscription bootstrap retry worker because sing-box is not running", "warn"); + break; + } + + ensure_runtime_dirs(); + if (!state_ucode_status([ "acquire-runtime-dir-lock", TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR, current_pid() ])) { + log_message("Subscription bootstrap retry skipped because another subscription update is running", "debug"); + continue; + } + + let result = subscription_bootstrap_retry_result(remaining_sections); + state_ucode_status([ "release-runtime-dir-lock", TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR ]); + + if (result.recovered != "") { + log_message("Recovered deferred subscription rule(s): " + result.recovered + "; reloading Tachyon", "info"); + trigger_subscription_recovery_reload(true); + } + + remaining_sections = result.remaining; + } + + unlink_path(TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE); +} + +let mode = ARGV[0] || ""; + +if (mode == "file-first-line") { + file_first_line(ARGV[1]); +} +else if (mode == "file-has-exact-line") { + exit(file_has_exact_line(ARGV[1], ARGV[2]) ? 0 : 1); +} +else if (mode == "state-list-contains") { + exit(state_list_contains(ARGV[1], ARGV[2]) ? 0 : 1); +} +else if (mode == "append-state-list-once") { + append_state_list_once(ARGV[1], ARGV[2]); +} +else if (mode == "maintenance-plan") { + maintenance_plan(uci_sections(), ARGV[1]); +} +else if (mode == "maintenance-plan-fixture") { + let data = object_or_empty(read_json(ARGV[1])); + connections.set_item_sections_from_data(data); + maintenance_plan(fixture_section_list(data), ARGV[2]); +} +else if (mode == "runtime-cache-needs-rebuild") { + exit(runtime_cache_missing(uci_sections(), ARGV[1]) ? 0 : 1); +} +else if (mode == "runtime-cache-needs-rebuild-fixture") { + let data = object_or_empty(read_json(ARGV[1])); + connections.set_item_sections_from_data(data); + exit(runtime_cache_missing(fixture_section_list(data), ARGV[2]) ? 0 : 1); +} +else if (mode == "prepared-runtime-cache-should-skip-fixture") { + let data = object_or_empty(read_json(ARGV[1])); + connections.set_item_sections_from_data(data); + exit(prepared_runtime_cache_should_skip(fixture_section_list(data), ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +} +else if (mode == "ensure-runtime-dirs") { + ensure_runtime_dirs(); +} +else if (mode == "clear-runtime-cache") { + clear_subscription_runtime_cache(); +} +else if (mode == "ensure-runtime-cache-format") { + ensure_runtime_cache_format(); +} +else if (mode == "stale-cache-delete-paths") { + stale_cache_delete_paths(uci_sections(), ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]); +} +else if (mode == "stale-cache-delete-paths-fixture") { + let data = object_or_empty(read_json(ARGV[1])); + connections.set_item_sections_from_data(data); + stale_cache_delete_paths(fixture_section_list(data), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); +} +else if (mode == "section-current-usable-cache") { + exit(section_current_usable_cache(uci_section(ARGV[1]), ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +} +else if (mode == "restore-persistent-source") { + let expected_hwid = ARGV[7] == null ? "" : ARGV[6]; + let default_user_agent = ARGV[7] == null ? ARGV[6] : ARGV[7]; + exit(restore_persistent_subscription_cache(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], expected_hwid, default_user_agent) ? 0 : 1); +} +else if (mode == "persist-source-cache") { + let effective_hwid = ARGV[6] == null ? "" : ARGV[5]; + let metadata_path = ARGV[6] == null ? ARGV[5] : ARGV[6]; + exit(persist_subscription_cache(ARGV[1], ARGV[2], ARGV[3], ARGV[4], effective_hwid, metadata_path) ? 0 : 1); +} +else if (mode == "section-current-usable-cache-fixture") { + let data = object_or_empty(read_json(ARGV[1])); + connections.set_item_sections_from_data(data); + let target = as_string(ARGV[2]); + let selected = {}; + for (let section in fixture_section_list(data)) { + if (section_name(section) == target) { + selected = section; + break; + } + } + exit(section_current_usable_cache(selected, ARGV[3], ARGV[4], ARGV[5]) ? 0 : 1); +} +else if (mode == "user-agent-supported") { + exit(user_agent_supported(ARGV[1], ARGV[2]) ? 0 : 1); +} +else if (mode == "user-agent-matches-config") { + exit(user_agent_matches_config(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +} +else if (mode == "write-user-agent-candidates") { + write_user_agent_candidates(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +} +else if (mode == "source-id") { + print(source_id(ARGV[1], int(ARGV[2] || 0)), "\n"); +} +else if (mode == "subscription-metadata-path") { + let path = subscription_metadata_path(ARGV[1]); + if (path == "") + exit(1); + print(path, "\n"); +} +else if (mode == "outbound-metadata-path") { + let path = outbound_metadata_path(ARGV[1]); + if (path == "") + exit(1); + print(path, "\n"); +} +else if (mode == "section-is-subscription-proxy") { + exit(section_is_subscription_proxy(uci_section(ARGV[1])) ? 0 : 1); +} +else if (mode == "update-source") { + exit(update_subscription_source(ARGV[1], ARGV[2], ARGV[3], ARGV[4] || "runtime", ARGV[5] || "")); +} +else if (mode == "update-section") { + exit(subscription_update_section(find_section(uci_sections(), ARGV[1] || ""), as_string(ARGV[2] || "0") == "1")); +} +else if (mode == "update-request") { + subscription_update_request(ARGV[1] || "0", ARGV[2] || "", ARGV[3] || ""); +} +else if (mode == "prepare-caches") { + exit(prepare_subscription_caches(ARGV[1] || "startup", ARGV[2] || "0", ARGV[3] || "0")); +} +else if (mode == "run-deferred-bootstrap") { + run_deferred_subscription_bootstrap(ARGV[1] || ""); +} +else if (mode == "start-deferred-bootstrap-worker") { + start_deferred_subscription_bootstrap_retry_worker(ARGV[1] || ""); +} +else if (mode == "stop-deferred-bootstrap-worker") { + stop_deferred_subscription_bootstrap_retry_worker(); +} +else if (mode == "deferred-bootstrap-worker") { + deferred_subscription_bootstrap_retry_worker(ARGV[1] || ""); +} +else if (mode == "json-length") { + json_length(ARGV[1]); +} +else if (mode == "subscription-import-stats") { + print(subscription_import_stats_text(ARGV[1]), "\n"); +} +else if (mode == "subscription-source-summary") { + print(subscription_source_summary(ARGV[1], ARGV[2], ARGV[3], ARGV[4]), "\n"); +} +else if (mode == "object-has-extra-keys") { + exit(object_has_extra_keys(ARGV[1]) ? 0 : 1); +} +else if (mode == "write-outbound-metadata") { + let cache_dir = ARGV[1], format_version = ARGV[2], section = ARGV[3]; + if (!safe_section(section)) + exit(1); + write_outbound_metadata(cache_dir, format_version, section, ARGV[4], ARGV[5], ARGV[6]); +} +else if (mode == "write-subscription-metadata") { + let cache_dir = ARGV[1], format_version = ARGV[2], section = ARGV[3]; + if (!safe_section(section)) + exit(1); + write_subscription_metadata(cache_dir, format_version, section, ARGV[4]); +} +else if (mode == "append-metadata-file") { + append_metadata_file(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +} +else if (mode == "append-cached-metadata") { + append_cached_metadata(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]); +} +else if (mode == "write-source-metadata") { + let cache_dir = ARGV[1], format_version = ARGV[2], section = ARGV[3]; + if (!safe_section(section)) + exit(1); + write_source_metadata(cache_dir, format_version, section, ARGV[4], ARGV[5], ARGV[6], ARGV[7]); +} +else if (mode == "get-outbound-metadata") { + let cache_dir = ARGV[1], section = ARGV[2]; + if (!safe_section(section)) + write_empty_outbound_metadata(); + else + get_outbound_metadata(cache_dir, section, ARGV[3]); +} +else if (mode == "empty-outbound-metadata") { + write_empty_outbound_metadata(); +} +else if (mode == "get-subscription-metadata") { + let cache_dir = ARGV[1], section = ARGV[2]; + if (!safe_section(section)) + print("{}\n"); + else + get_subscription_metadata(cache_dir, section, ARGV[3]); +} +else { + warn("Usage: subscription/cache.uc ...\n"); + exit(1); +} diff --git a/tachyon/files/usr/lib/subscription/parser.uc b/tachyon/files/usr/lib/subscription/parser.uc new file mode 100644 index 00000000..a8803285 --- /dev/null +++ b/tachyon/files/usr/lib/subscription/parser.uc @@ -0,0 +1,2998 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function trim(value) { + value = as_string(value); + let start = 0; + let end = length(value); + + while (start < end) { + let c = substr(value, start, 1); + if (c != " " && c != "\t" && c != "\r" && c != "\n") + break; + start++; + } + + while (end > start) { + let c = substr(value, end - 1, 1); + if (c != " " && c != "\t" && c != "\r" && c != "\n") + break; + end--; + } + + return substr(value, start, end - start); +} + +function first_non_ws_char(value) { + value = as_string(value); + for (let i = 0; i < length(value); i++) { + let c = substr(value, i, 1); + if (c != " " && c != "\t" && c != "\r" && c != "\n") + return c; + } + return ""; +} + +function starts_with(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function is_supported_share_link(line) { + return starts_with(line, "ss://") || + starts_with(line, "vmess://") || + starts_with(line, "vless://") || + starts_with(line, "trojan://") || + starts_with(line, "hysteria2://") || + starts_with(line, "hy2://") || + starts_with(line, "socks://") || + starts_with(line, "socks4://") || + starts_with(line, "socks4a://") || + starts_with(line, "socks5://"); +} + +function split_csv(value) { + let result = []; + for (let item in split(as_string(value), ",")) { + if (item != "") + push(result, item); + } + return result; +} + +function tls_alpn_for_transport(alpn, transport) { + transport = lc(as_string(transport)); + if (transport == "xhttp" && length(alpn) == 0) + return ["h2", "http/1.1"]; + if ((transport == "ws" || transport == "httpupgrade") && length(alpn) > 0) + return ["http/1.1"]; + return alpn; +} + +function is_true(value) { + if (value == null || value == "") + return false; + let normalized = lc(as_string(value)); + return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "on"; +} + +function is_integer_string(value) { + if (type(value) != "string" || value == "") + return false; + for (let i = 0; i < length(value); i++) { + let code = ord(substr(value, i, 1)); + if (code < 48 || code > 57) + return false; + } + return true; +} + +function xhttp_value_present(value) { + return value != null && as_string(value) != ""; +} + +function xhttp_object_arg(value) { + if (type(value) == "object") + return value; + + try { + let parsed = json(as_string(value)); + return type(parsed) == "object" ? parsed : {}; + } + catch (e) { + return {}; + } +} + +function xhttp_copy_known_settings(target, source) { + if (type(source) != "object") + return; + + for (let key in [ + "xPaddingBytes", + "x_padding_bytes", + "noGRPCHeader", + "no_grpc_header", + "scMaxEachPostBytes", + "sc_max_each_post_bytes", + "scMinPostsIntervalMs", + "sc_min_posts_interval_ms", + "scStreamUpServerSecs", + "sc_stream_up_server_secs", + "xmux" + ]) { + if (xhttp_value_present(source[key])) + target[key] = source[key]; + } +} + +function xhttp_merge_extra_settings(target, value) { + let extra = xhttp_object_arg(value); + if (type(extra) != "object") + return; + + xhttp_copy_known_settings(target, extra); + + let xhttp_settings = xhttp_object_arg(extra.xhttpSettings); + xhttp_copy_known_settings(target, xhttp_settings); + xhttp_copy_known_settings(target, xhttp_object_arg(xhttp_settings.extra)); + + let download_settings = xhttp_object_arg(extra.downloadSettings); + let download_xhttp_settings = xhttp_object_arg(download_settings.xhttpSettings); + xhttp_copy_known_settings(target, download_xhttp_settings); + xhttp_copy_known_settings(target, xhttp_object_arg(download_xhttp_settings.extra)); +} + +function xhttp_extra_settings(query) { + let result = {}; + query = type(query) == "object" ? query : {}; + xhttp_merge_extra_settings(result, query.extra); + return result; +} + +function xhttp_setting_value(query, extra_settings, camel_key, snake_key) { + query = type(query) == "object" ? query : {}; + extra_settings = type(extra_settings) == "object" ? extra_settings : {}; + for (let value in [query[camel_key], query[snake_key], extra_settings[camel_key], extra_settings[snake_key]]) { + if (xhttp_value_present(value)) + return value; + } + return null; +} + +function xhttp_non_negative_integer_value(value) { + if (type(value) == "int" || type(value) == "double") { + let number = int(value); + return number == value && number >= 0 ? number : null; + } + + value = trim(as_string(value)); + if (!is_integer_string(value)) + return null; + + return int(value, 10); +} + +function xhttp_positive_integer_value(value) { + let number = xhttp_non_negative_integer_value(value); + return number != null && number > 0 ? number : null; +} + +function xhttp_range_value(value, positive) { + if (!xhttp_value_present(value)) + return null; + + if (type(value) == "object") { + let from = positive ? xhttp_positive_integer_value(value.from) : xhttp_non_negative_integer_value(value.from); + let to = positive ? xhttp_positive_integer_value(value.to) : xhttp_non_negative_integer_value(value.to); + return from != null && to != null && from <= to ? { from, to } : null; + } + + let number = positive ? xhttp_positive_integer_value(value) : xhttp_non_negative_integer_value(value); + if (number != null) + return number; + + value = trim(as_string(value)); + let dash = index(value, "-"); + if (dash < 0 || index(substr(value, dash + 1), "-") >= 0) + return null; + + let from = positive ? xhttp_positive_integer_value(substr(value, 0, dash)) : xhttp_non_negative_integer_value(substr(value, 0, dash)); + let to = positive ? xhttp_positive_integer_value(substr(value, dash + 1)) : xhttp_non_negative_integer_value(substr(value, dash + 1)); + return from != null && to != null && from <= to ? from + "-" + to : null; +} + +function xhttp_optional_range(object, key, value) { + let normalized = xhttp_range_value(value, false); + if (normalized != null) + object[key] = normalized; +} + +function xhttp_optional_positive_range(object, key, value) { + let normalized = xhttp_range_value(value, true); + if (normalized != null) + object[key] = normalized; +} + +function xhttp_positive_range_or_default(value, default_value) { + let normalized = xhttp_range_value(value, true); + return normalized != null ? normalized : default_value; +} + +function xhttp_present_positive_range_or_default(value, default_value) { + if (!xhttp_value_present(value)) + return null; + + return xhttp_positive_range_or_default(value, default_value); +} + +function xhttp_optional_bool(object, key, value) { + if (xhttp_value_present(value)) + object[key] = is_true(value); +} + +function xhttp_object_setting_value(source, camel_key, snake_key) { + source = type(source) == "object" ? source : {}; + for (let value in [source[camel_key], source[snake_key]]) { + if (xhttp_value_present(value)) + return value; + } + return null; +} + +function xhttp_optional_xmux_range(object, key, value) { + let normalized = xhttp_range_value(value, false); + if (normalized != null) + object[key] = normalized; +} + +function xhttp_optional_xmux_integer(object, key, value) { + let normalized = xhttp_non_negative_integer_value(value); + if (normalized != null) + object[key] = normalized; +} + +function xhttp_normalize_xmux(value) { + let source = xhttp_object_arg(value); + if (type(source) != "object") + return null; + + let result = {}; + xhttp_optional_xmux_range(result, "max_concurrency", xhttp_object_setting_value(source, "maxConcurrency", "max_concurrency")); + xhttp_optional_xmux_range(result, "max_connections", xhttp_object_setting_value(source, "maxConnections", "max_connections")); + xhttp_optional_xmux_range(result, "c_max_reuse_times", xhttp_object_setting_value(source, "cMaxReuseTimes", "c_max_reuse_times")); + xhttp_optional_xmux_range(result, "h_max_request_times", xhttp_object_setting_value(source, "hMaxRequestTimes", "h_max_request_times")); + xhttp_optional_xmux_range(result, "h_max_reusable_secs", xhttp_object_setting_value(source, "hMaxReusableSecs", "h_max_reusable_secs")); + xhttp_optional_xmux_integer(result, "h_keep_alive_period", xhttp_object_setting_value(source, "hKeepAlivePeriod", "h_keep_alive_period")); + + return length(keys(result)) > 0 ? result : null; +} + +function json_decode_text(text) { + try { + return json(as_string(text)); + } + catch (e) { + return null; + } +} + +function read_file(path) { + return fs.readfile(path); +} + +function read_json_file(path) { + let data = read_file(path); + return data == null ? null : json_decode_text(data); +} + +function validate_subscription(path) { + let value = read_json_file(path); + return type(value) == "object" && type(value.outbounds) == "array" && length(value.outbounds) > 0; +} + +function write_json_file(path, value) { + return fs.writefile(path, sprintf("%J", value) + "\n"); +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function canonical_runtime_value(value) { + if (type(value) == "array") { + let result = []; + for (let item in value) + push(result, canonical_runtime_value(item)); + return result; + } + + if (type(value) == "object") { + let result = {}; + let names = keys(value); + sort(names, function(a, b) { + return a < b ? -1 : (a > b ? 1 : 0); + }); + + for (let name in names) { + if (name == "share_link") + continue; + result[name] = canonical_runtime_value(value[name]); + } + + return result; + } + + return value; +} + +function runtime_outbounds_signature(path) { + let subscription = read_json_file(path); + if (type(subscription) != "object" || type(subscription.outbounds) != "array") + return null; + + return canonical_runtime_value(subscription.outbounds); +} + +function runtime_outbounds_equal(left_path, right_path) { + let left = runtime_outbounds_signature(left_path); + let right = runtime_outbounds_signature(right_path); + + if (left == null || right == null) + return false; + + return sprintf("%J", left) == sprintf("%J", right); +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function base64_decode(value) { + value = as_string(value); + if (index(value, "\n") >= 0 || index(value, "\r") >= 0 || index(value, "\t") >= 0 || index(value, " ") >= 0) + value = replace(value, /\s+/g, ""); + if (index(value, "-") >= 0) + value = replace(value, /-/g, "+"); + if (index(value, "_") >= 0) + value = replace(value, /_/g, "/"); + if (value == "") + return null; + + let remainder = length(value) % 4; + if (remainder == 1) + return null; + if (remainder > 1) { + for (let i = 0; i < 4 - remainder; i++) + value += "="; + } + + return b64dec(value); +} + +function urldecode(value) { + value = as_string(value); + if (index(value, "%") < 0 && index(value, "+") < 0) + return value; + + if (index(value, "+") >= 0) + value = replace(value, /\+/g, " "); + if (index(value, "%") < 0) + return value; + return replace(value, /%([0-9A-Fa-f][0-9A-Fa-f])/g, function(all, hex_value) { + return chr(hex(hex_value)); + }); +} + +let fragment_prefix_decode_cache = {}; +let fragment_decode_cache = {}; +let query_parse_cache = {}; + +function urldecode_fragment(value) { + value = as_string(value); + if (index(value, "%") < 0 && index(value, "+") < 0) + return value; + if (value in fragment_decode_cache) + return fragment_decode_cache[value]; + + let marker = rindex(value, "%23"); + if (marker >= 0) { + let suffix = substr(value, marker + 3); + if (is_integer_string(suffix)) { + let prefix = substr(value, 0, marker + 3); + if (!(prefix in fragment_prefix_decode_cache)) + fragment_prefix_decode_cache[prefix] = urldecode(prefix); + let decoded = fragment_prefix_decode_cache[prefix] + suffix; + fragment_decode_cache[value] = decoded; + return decoded; + } + } + + let decoded = urldecode(value); + fragment_decode_cache[value] = decoded; + return decoded; +} + +function subscription_url_fragment(url) { + url = as_string(url); + let marker = index(url, "#"); + if (marker < 0) { + print("\n"); + return true; + } + + let fragment = substr(url, marker + 1); + if (index(fragment, "%") >= 0 || index(fragment, "+") >= 0) + print(urldecode_fragment(fragment)); + else + print(fragment, "\n"); + return true; +} + +function parse_query(query) { + let params = {}; + query = as_string(query); + if (query == "") + return params; + if (query in query_parse_cache) + return query_parse_cache[query]; + + for (let pair in split(query, "&")) { + if (pair == "") + continue; + let equals = index(pair, "="); + let key = equals >= 0 ? substr(pair, 0, equals) : pair; + let value = equals >= 0 ? substr(pair, equals + 1) : ""; + key = urldecode(key); + if (key != "") + params[key] = urldecode(value); + } + + query_parse_cache[query] = params; + return params; +} + +function parse_port_value(value) { + value = as_string(value); + return is_integer_string(value) ? int(value, 10) : value; +} + +function parse_host_port(value) { + value = as_string(value); + if (length(value) > 0 && substr(value, length(value) - 1) == "/") + value = substr(value, 0, length(value) - 1); + if (starts_with(value, "[")) { + let m = match(value, /^\[([^\]]+)\]:(.+)$/); + return m ? [m[1], parse_port_value(m[2])] : ["", null]; + } + + let colon = rindex(value, ":"); + if (colon < 0) + return ["", null]; + + let host = substr(value, 0, colon); + let port = substr(value, colon + 1); + return [host, parse_port_value(port)]; +} + +function parse_url(url) { + let scheme_pos = index(url, "://"); + if (scheme_pos <= 0) + return null; + + let scheme = lc(substr(url, 0, scheme_pos)); + let rest = substr(url, scheme_pos + 3); + let fragment = ""; + let hash_pos = index(rest, "#"); + if (hash_pos >= 0) { + fragment = urldecode_fragment(substr(rest, hash_pos + 1)); + rest = substr(rest, 0, hash_pos); + } + + let query = ""; + let question_pos = index(rest, "?"); + if (question_pos >= 0) { + query = substr(rest, question_pos + 1); + rest = substr(rest, 0, question_pos); + } + + let slash_pos = index(rest, "/"); + let authority = slash_pos >= 0 ? substr(rest, 0, slash_pos) : rest; + let userinfo = ""; + let hostport = authority; + let at_pos = rindex(authority, "@"); + if (at_pos >= 0) { + userinfo = urldecode(substr(authority, 0, at_pos)); + hostport = substr(authority, at_pos + 1); + } + + let host_port = parse_host_port(hostport); + return { + scheme: scheme, + userinfo: userinfo, + host: host_port[0] || "", + port: host_port[1], + query: parse_query(query), + fragment: fragment + }; +} + +function normalize_utls_fingerprint(value) { + if (value == null || value == "") + return ""; + + let allowed = { + "": true, + chrome: true, + firefox: true, + edge: true, + safari: true, + "360": true, + ios: true, + android: true, + randomized: true, + randomizedalpn: true, + randomizednoalpn: true + }; + + value = as_string(value); + return allowed[value] ? value : "chrome"; +} + +function add_tls(url, security, default_tls) { + let query = object_or_empty(url.query); + let sni = query.sni || query.peer || ""; + let insecure = query.allowInsecure || query.insecure || ""; + let transport = query.type || ""; + let alpn = tls_alpn_for_transport(split_csv(query.alpn || ""), transport); + + let fingerprint = normalize_utls_fingerprint(query.fp || ""); + let public_key = query.pbk || ""; + let short_id = query.sid || ""; + + if (security == "reality" && public_key == "") + return [null, false]; + if ((security == "reality" || public_key != "") && fingerprint == "") + fingerprint = "chrome"; + + let tls_enabled = false; + if (security == "tls" || security == "xtls" || security == "reality") + tls_enabled = true; + else if (security == null || security == "") + tls_enabled = default_tls || sni != "" || length(alpn) > 0 || fingerprint != "" || public_key != ""; + + if (!tls_enabled) + return [null, true]; + + let tls = { enabled: true }; + if (sni != "") + tls.server_name = sni; + if (is_true(insecure)) + tls.insecure = true; + if (length(alpn) > 0) + tls.alpn = alpn; + if (fingerprint != "") + tls.utls = { enabled: true, fingerprint: fingerprint }; + if (security == "reality" || public_key != "") { + tls.reality = { enabled: true }; + if (public_key != "") + tls.reality.public_key = public_key; + if (short_id != "") + tls.reality.short_id = short_id; + } + + return [tls, true]; +} + +function add_transport(url) { + let query = object_or_empty(url.query); + let transport = query.type || ""; + if (transport == "" || transport == "tcp") + return null; + + let path = query.path || ""; + let host = query.host || ""; + let early_data = query.ed || ""; + let grpc_service_name = query.serviceName || ""; + let xhttp_mode = query.mode || "auto"; + let sni = query.sni || ""; + + if (xhttp_mode != "auto" && xhttp_mode != "packet-up" && xhttp_mode != "stream-up" && xhttp_mode != "stream-one") + xhttp_mode = "auto"; + + if (transport == "ws") { + let result = { type: "ws", path: path != "" ? path : "/" }; + if (host != "") + result.headers = { Host: host }; + if (is_integer_string(early_data)) + result.max_early_data = int(early_data); + return result; + } + if (transport == "grpc") { + let result = { type: "grpc" }; + if (grpc_service_name != "") + result.service_name = grpc_service_name; + return result; + } + if (transport == "http" || transport == "h2") { + let result = { type: "http" }; + if (path != "") + result.path = path; + if (host != "") + result.host = split_csv(host); + return result; + } + if (transport == "httpupgrade") { + let result = { type: "httpupgrade" }; + if (path != "") + result.path = path; + if (host != "") + result.host = host; + return result; + } + if (transport == "xhttp") { + if (path == "") + path = "/"; + if (host == "") + host = sni; + let result = { + type: "xhttp", + mode: xhttp_mode, + path: path, + x_padding_bytes: "100-1000", + no_grpc_header: false, + sc_max_each_post_bytes: 1000000, + sc_min_posts_interval_ms: 30 + }; + if (host != "") + result.host = host; + + let extra_settings = xhttp_extra_settings(query); + xhttp_optional_positive_range(result, "x_padding_bytes", xhttp_setting_value(query, extra_settings, "xPaddingBytes", "x_padding_bytes")); + xhttp_optional_bool(result, "no_grpc_header", xhttp_setting_value(query, extra_settings, "noGRPCHeader", "no_grpc_header")); + xhttp_optional_positive_range(result, "sc_max_each_post_bytes", xhttp_setting_value(query, extra_settings, "scMaxEachPostBytes", "sc_max_each_post_bytes")); + xhttp_optional_range(result, "sc_min_posts_interval_ms", xhttp_setting_value(query, extra_settings, "scMinPostsIntervalMs", "sc_min_posts_interval_ms")); + xhttp_optional_range(result, "sc_stream_up_server_secs", xhttp_setting_value(query, extra_settings, "scStreamUpServerSecs", "sc_stream_up_server_secs")); + let xmux = xhttp_normalize_xmux(xhttp_setting_value(query, extra_settings, "xmux", "xmux")); + if (xmux) + result.xmux = xmux; + return result; + } + + return null; +} + +function valid_port(port) { + let port_type = type(port); + return (port_type == "int" || port_type == "double") && port >= 1 && port <= 65535 && int(port) == port; +} + +function normalize_port_number(value) { + value = trim(value); + if (!is_integer_string(value)) + return ""; + + let port = int(value, 10); + if (port < 1 || port > 65535) + return ""; + + return "" + port; +} + +function parse_hysteria2_server_ports(value) { + value = as_string(value); + if (index(value, ",") < 0 && index(value, "-") < 0) + return null; + + let result = []; + for (let entry in split(value, ",")) { + entry = trim(entry); + if (entry == "") + return null; + + let dash = index(entry, "-"); + if (dash >= 0) { + if (index(substr(entry, dash + 1), "-") >= 0) + return null; + + let start = normalize_port_number(substr(entry, 0, dash)); + let end = normalize_port_number(substr(entry, dash + 1)); + if (start == "" || end == "" || int(start, 10) > int(end, 10)) + return null; + + push(result, start + ":" + end); + } + else { + let port = normalize_port_number(entry); + if (port == "") + return null; + + push(result, port + ":" + port); + } + } + + return length(result) > 0 ? result : null; +} + +function normalize_vless_encryption(value) { + value = as_string(value); + return value != "" && value != "none" ? value : ""; +} + +function process_vless(raw, url) { + if (url.host == "" || !valid_port(url.port) || url.userinfo == "") + return null; + + let flow = url.query.flow || ""; + if (flow != "" && flow != "xtls-rprx-vision") + return null; + + let packet_encoding = url.query.packetEncoding || ""; + if (packet_encoding != "xudp" && packet_encoding != "packetaddr") + packet_encoding = ""; + let encryption = normalize_vless_encryption(url.query.encryption || ""); + + let outbound = { + type: "vless", + tag: url.fragment != "" ? url.fragment : (url.host + ":" + url.port), + share_link: raw, + server: url.host, + server_port: url.port, + uuid: url.userinfo + }; + if (encryption != "") + outbound.encryption = encryption; + if (flow != "") + outbound.flow = flow; + if (packet_encoding != "") + outbound.packet_encoding = packet_encoding; + + let tls_result = add_tls(url, url.query.security || "", false); + if (!tls_result[1]) + return null; + if (tls_result[0]) + outbound.tls = tls_result[0]; + + let transport = add_transport(url); + if (transport) + outbound.transport = transport; + return outbound; +} + +function process_trojan(raw, url) { + if (url.host == "" || !valid_port(url.port) || url.userinfo == "") + return null; + + let outbound = { + type: "trojan", + tag: url.fragment != "" ? url.fragment : (url.host + ":" + url.port), + share_link: raw, + server: url.host, + server_port: url.port, + password: url.userinfo + }; + + let tls_result = add_tls(url, url.query.security || "", true); + if (!tls_result[1]) + return null; + if (tls_result[0]) + outbound.tls = tls_result[0]; + + let transport = add_transport(url); + if (transport) + outbound.transport = transport; + return outbound; +} + +function process_socks(raw, url) { + if (url.host == "" || !valid_port(url.port)) + return null; + + let username = "", password = ""; + if (url.userinfo != "") { + let colon = index(url.userinfo, ":"); + if (colon >= 0) { + username = urldecode(substr(url.userinfo, 0, colon)); + password = urldecode(substr(url.userinfo, colon + 1)); + if (username == password) + password = ""; + } + else { + username = urldecode(url.userinfo); + } + } + + let outbound = { + type: "socks", + tag: url.fragment != "" ? url.fragment : (url.host + ":" + url.port), + share_link: raw, + server: url.host, + server_port: url.port + }; + let version = substr(url.scheme, 5); + if (version != "") + outbound.version = version; + if (username != "") + outbound.username = username; + if (password != "") + outbound.password = password; + return outbound; +} + +function is_shadowsocks_userinfo_format(value) { + if (type(value) != "string") + return false; + let first = index(value, ":"); + if (first <= 0 || first >= length(value) - 1) + return false; + let rest = substr(value, first + 1); + let second = index(rest, ":"); + return second < 0 || index(substr(rest, second + 1), ":") < 0; +} + +function process_shadowsocks(raw) { + if (!starts_with(raw, "ss://")) + return null; + + let body = substr(raw, 5); + let fragment = ""; + let hash_pos = index(body, "#"); + if (hash_pos >= 0) { + fragment = urldecode(substr(body, hash_pos + 1)); + body = substr(body, 0, hash_pos); + } + + let query = ""; + let question_pos = index(body, "?"); + if (question_pos >= 0) { + query = substr(body, question_pos + 1); + body = substr(body, 0, question_pos); + } + + let userinfo, hostport; + let at_pos = rindex(body, "@"); + if (at_pos >= 0) { + userinfo = substr(body, 0, at_pos); + hostport = substr(body, at_pos + 1); + } + else { + let decoded = base64_decode(body); + if (!decoded) + return null; + at_pos = rindex(decoded, "@"); + if (at_pos < 0) + return null; + userinfo = substr(decoded, 0, at_pos); + hostport = substr(decoded, at_pos + 1); + } + + userinfo = urldecode(userinfo); + if (!is_shadowsocks_userinfo_format(userinfo)) { + let decoded = base64_decode(userinfo); + if (!decoded) + return null; + userinfo = decoded; + } + + let cred_colon = index(userinfo, ":"); + let host_port = parse_host_port(hostport); + if (cred_colon <= 0) + return null; + let method = substr(userinfo, 0, cred_colon); + let password = substr(userinfo, cred_colon + 1); + if (method == "" || method == "ss" || password == "" || host_port[0] == "" || !valid_port(host_port[1])) + return null; + + let params = parse_query(query); + let plugin = params.plugin || ""; + let plugin_opts = params["plugin-opts"] || ""; + if (plugin != "" && plugin_opts == "") { + let parsed_plugin = match(plugin, /^([^;]+);(.*)$/); + if (parsed_plugin) { + plugin = parsed_plugin[1]; + plugin_opts = parsed_plugin[2]; + } + } + + let outbound = { + type: "shadowsocks", + tag: fragment != "" ? fragment : (host_port[0] + ":" + host_port[1]), + share_link: raw, + server: host_port[0], + server_port: host_port[1], + method: method, + password: password + }; + if (plugin != "") + outbound.plugin = plugin; + if (plugin_opts != "") + outbound.plugin_opts = plugin_opts; + return outbound; +} + +function process_hysteria2(raw, url) { + let mport = trim(as_string(url.query.mport || "")); + let port_value = mport != "" ? mport : url.port; + let server_ports = parse_hysteria2_server_ports(port_value); + let server_port = normalize_port_number(as_string(port_value)); + if (url.host == "" || (server_ports == null && server_port == "") || url.userinfo == "") + return null; + + let password = url.userinfo; + if (password == "") + return null; + + let tls = { enabled: true }; + if ((url.query.sni || "") != "") + tls.server_name = url.query.sni; + if (is_true(url.query.allowInsecure || url.query.insecure)) + tls.insecure = true; + if ((url.query.alpn || "") != "") + tls.alpn = split_csv(url.query.alpn); + + let outbound = { + type: "hysteria2", + tag: url.fragment != "" ? url.fragment : (url.host + ":" + as_string(port_value)), + share_link: raw, + server: url.host, + password: password, + tls: tls + }; + if (server_ports != null) + outbound.server_ports = server_ports; + else + outbound.server_port = int(server_port, 10); + + if ((url.query.network || "") != "") + outbound.network = url.query.network; + if (is_integer_string(url.query.upmbps || "")) + outbound.up_mbps = int(url.query.upmbps); + if (is_integer_string(url.query.downmbps || "")) + outbound.down_mbps = int(url.query.downmbps); + if ((url.query.obfs || "") != "" && url.query.obfs != "none") { + outbound.obfs = { type: url.query.obfs }; + if ((url.query["obfs-password"] || "") != "") + outbound.obfs.password = url.query["obfs-password"]; + } + return outbound; +} + +function string_value(value) { + return value == null ? "" : as_string(value); +} + +function process_vmess_json(raw, decoded) { + let vmess = json_decode_text(decoded); + if (type(vmess) != "object") + return null; + + let server = string_value(vmess.add); + let port = int(vmess.port || 0); + let uuid = string_value(vmess.id); + if (server == "" || !valid_port(port) || uuid == "") + return null; + + let outbound = { + type: "vmess", + tag: string_value(vmess.ps) != "" ? string_value(vmess.ps) : (server + ":" + port), + share_link: raw, + server: server, + server_port: port, + uuid: uuid, + security: string_value(vmess.scy) != "" ? string_value(vmess.scy) : "auto" + }; + + let alter_id = int(vmess.aid || 0); + if (as_string(vmess.aid) != "") + outbound.alter_id = alter_id; + + let network = string_value(vmess.net); + if (vmess.tls === true || vmess.tls == "tls" || vmess.tls == "true") { + let fingerprint = normalize_utls_fingerprint(string_value(vmess.fp)); + let tls = { enabled: true }; + if (string_value(vmess.sni) != "") + tls.server_name = string_value(vmess.sni); + let alpn = tls_alpn_for_transport(split_csv(string_value(vmess.alpn)), network); + if (length(alpn) > 0) + tls.alpn = alpn; + if (fingerprint != "") + tls.utls = { enabled: true, fingerprint: fingerprint }; + outbound.tls = tls; + } + + if (network == "ws") { + outbound.transport = { + type: "ws", + path: string_value(vmess.path) != "" ? string_value(vmess.path) : "/" + }; + if (string_value(vmess.host) != "") + outbound.transport.headers = { Host: string_value(vmess.host) }; + } + else if (network == "grpc") { + outbound.transport = { type: "grpc" }; + if (string_value(vmess.path) != "") + outbound.transport.service_name = string_value(vmess.path); + } + else if (network == "http" || network == "h2") { + outbound.transport = { type: "http" }; + if (string_value(vmess.path) != "") + outbound.transport.path = string_value(vmess.path); + if (string_value(vmess.host) != "") + outbound.transport.host = split_csv(string_value(vmess.host)); + } + + return outbound; +} + +function process_vmess(raw) { + if (!starts_with(raw, "vmess://")) + return null; + + let encoded = substr(raw, 8); + let hash_pos = index(encoded, "#"); + if (hash_pos >= 0) + encoded = substr(encoded, 0, hash_pos); + let decoded = base64_decode(encoded); + if (!decoded) + return null; + if (index(decoded, "\r") >= 0 || index(decoded, "\n") >= 0) + decoded = replace(decoded, /[\r\n]/g, ""); + let trimmed = trim(decoded); + if (substr(trimmed, 0, 1) != "{" || substr(trimmed, length(trimmed) - 1) != "}") + return null; + return process_vmess_json(raw, trimmed); +} + +function parse_share_link(line) { + if (starts_with(line, "vmess://")) + return process_vmess(line); + if (starts_with(line, "ss://")) + return process_shadowsocks(line); + + let url = parse_url(line); + if (!url) + return null; + + if (url.scheme == "vless") + return process_vless(line, url); + if (url.scheme == "trojan") + return process_trojan(line, url); + if (url.scheme == "hysteria2" || url.scheme == "hy2") + return process_hysteria2(line, url); + if (match(url.scheme, /^socks/)) + return process_socks(line, url); + return null; +} + +function share_link_outbound(raw, tag) { + let outbound = parse_share_link(raw); + if (type(outbound) != "object") + return false; + + outbound.tag = as_string(tag); + delete outbound.share_link; + delete outbound.remark; + write_json(outbound); + return true; +} + +function clean_scalar(value) { + value = trim(value); + let first = substr(value, 0, 1); + let last = substr(value, length(value) - 1); + if (length(value) >= 2 && ((first == "\"" && last == "\"") || (first == "'" && last == "'"))) + return substr(value, 1, length(value) - 2); + return value; +} + +function leading_indent(value) { + let m = match(as_string(value), /^[ \t]*/); + return m ? length(m[0]) : 0; +} + +function find_top_level_colon(value) { + let depth = 0, quote = "", escaped = false; + for (let i = 0; i < length(value); i++) { + let char = substr(value, i, 1); + if (quote != "") { + if (escaped) + escaped = false; + else if (char == "\\") + escaped = true; + else if (char == quote) + quote = ""; + } + else if (char == "\"" || char == "'") + quote = char; + else if (char == "{" || char == "[") + depth++; + else if (char == "}" || char == "]") + depth--; + else if (char == ":" && depth == 0) + return i; + } + return null; +} + +function split_top_level(value, separator) { + let result = [], depth = 0, quote = "", escaped = false, start = 0; + for (let i = 0; i < length(value); i++) { + let char = substr(value, i, 1); + if (quote != "") { + if (escaped) + escaped = false; + else if (char == "\\") + escaped = true; + else if (char == quote) + quote = ""; + } + else if (char == "\"" || char == "'") + quote = char; + else if (char == "{" || char == "[") + depth++; + else if (char == "}" || char == "]") + depth--; + else if (char == separator && depth == 0) { + push(result, substr(value, start, i - start)); + start = i + 1; + } + } + push(result, substr(value, start)); + return result; +} + +function set_record_field(record, key, value) { + key = clean_scalar(key); + value = clean_scalar(value); + if (key == "") + return; + if (record.fields[key] == null) + push(record.keys, key); + record.fields[key] = value; +} + +let parse_clash_map; + +function parse_clash_pair(record, part, prefix) { + part = trim(part); + let colon = find_top_level_colon(part); + if (colon == null) + return; + let key = clean_scalar(substr(part, 0, colon)); + let value = trim(substr(part, colon + 1)); + if (substr(value, 0, 1) == "{" && substr(value, length(value) - 1) == "}") + parse_clash_map(record, value, prefix + key + "."); + else + set_record_field(record, prefix + key, value); +} + +parse_clash_map = function(record, value, prefix) { + value = trim(value); + if (substr(value, 0, 1) == "{") + value = substr(value, 1); + if (substr(value, length(value) - 1) == "}") + value = substr(value, 0, length(value) - 1); + + for (let part in split_top_level(value, ",")) + parse_clash_pair(record, part, prefix); +}; + +function empty_clash_record() { + return { + fields: {}, + keys: [], + context: "", + context_indent: -1 + }; +} + +function clash_record_has_fields(record) { + return length(record.keys) > 0; +} + +function emit_clash_record(records, record) { + if (!clash_record_has_fields(record)) + return empty_clash_record(); + + let item = {}; + for (let key in record.keys) { + if (record.fields[key] != null) + item[key] = record.fields[key]; + } + push(records, item); + return empty_clash_record(); +} + +let clash_nested_keys = { + "ws-opts": true, + "grpc-opts": true, + "reality-opts": true, + "obfs-opts": true, + headers: true +}; + +function parse_clash_block_line(record, line) { + let indent = leading_indent(line); + let text = trim(line); + if (text == "") + return; + + let colon = index(text, ":"); + if (colon < 0) + return; + + let key = clean_scalar(substr(text, 0, colon)); + let value = trim(substr(text, colon + 1)); + + if (record.context != "" && indent <= record.context_indent) { + record.context = ""; + record.context_indent = -1; + } + + if (value == "") { + if (clash_nested_keys[key]) { + if (record.context != "" && indent > record.context_indent) + record.context = record.context + "." + key; + else + record.context = key; + record.context_indent = indent; + } + return; + } + + let full_key = key; + if (record.context != "" && indent > record.context_indent) + full_key = record.context + "." + key; + set_record_field(record, full_key, value); +} + +function is_clash_proxies_header(line) { + let text = trim(line); + return text == "proxies:" || match(text, /^proxies:\s*#/); +} + +function clash_yaml_records(input_file) { + let data = read_file(input_file); + if (data == null) + return []; + + let records = []; + let record = empty_clash_record(); + let in_proxies = false; + + for (let line in split(data, "\n")) { + line = replace(line, /\r$/g, ""); + let proxies_header = is_clash_proxies_header(line); + + if (in_proxies && match(line, /^[^ \t]/) && !proxies_header && !match(line, /^-[ \t]/)) { + record = emit_clash_record(records, record); + in_proxies = false; + } + + if (proxies_header) { + record = emit_clash_record(records, record); + in_proxies = true; + } + else if (in_proxies) { + if (match(line, /^[ \t]*-[ \t]*\{/)) { + record = emit_clash_record(records, record); + record = empty_clash_record(); + let map_value = replace(line, /^[ \t]*-[ \t]*/g, ""); + parse_clash_map(record, map_value, ""); + record = emit_clash_record(records, record); + } + else if (match(line, /^[ \t]*-[ \t]*/)) { + record = emit_clash_record(records, record); + record = empty_clash_record(); + let rest = replace(line, /^[ \t]*-[ \t]*/g, ""); + if (trim(rest) != "") + parse_clash_block_line(record, rest); + } + else if (clash_record_has_fields(record)) { + parse_clash_block_line(record, line); + } + } + } + + emit_clash_record(records, record); + return records; +} + +function normalize_packet_encoding(value) { + value = as_string(value); + return value == "xudp" || value == "packetaddr" ? value : ""; +} + +function clash_vless_flow_supported(flow) { + return flow == null || flow == "" || flow == "xtls-rprx-vision"; +} + +function normalized_clash_alpn(value) { + return replace(replace(replace(as_string(value), /^\[/g, ""), /\]$/g, ""), /\s+/g, ""); +} + +function add_clash_tls(outbound, options) { + let enabled = options.always || is_true(options.tls) || options.sni != "" || + length(tls_alpn_for_transport(split_csv(options.alpn), options.network)) > 0 || + options.fingerprint != "" || options.reality_public_key != ""; + if (!enabled) + return; + + let tls = { enabled: true }; + if (options.sni != "") + tls.server_name = options.sni; + if (is_true(options.skip_verify)) + tls.insecure = true; + let alpn = tls_alpn_for_transport(split_csv(options.alpn), options.network); + if (length(alpn) > 0) + tls.alpn = alpn; + + if (options.reality_public_key != "") { + tls.utls = { + enabled: true, + fingerprint: options.fingerprint != "" ? options.fingerprint : "chrome" + }; + tls.reality = { enabled: true, public_key: options.reality_public_key }; + if (options.reality_short_id != "") + tls.reality.short_id = options.reality_short_id; + } + else if (options.fingerprint != "") { + tls.utls = { enabled: true, fingerprint: options.fingerprint }; + } + + outbound.tls = tls; +} + +function add_clash_transport(outbound, options) { + if (options.network == "ws") { + outbound.transport = { + type: "ws", + path: options.ws_path != "" ? options.ws_path : "/" + }; + if (options.ws_host != "") + outbound.transport.headers = { Host: options.ws_host }; + } + else if (options.network == "grpc") { + outbound.transport = { type: "grpc" }; + if (options.grpc_service_name != "") + outbound.transport.service_name = options.grpc_service_name; + } +} + +function parse_clash_record(record) { + let proxy_type = lc(as_string(record.type)); + let name = as_string(record.name); + let server = as_string(record.server); + let port = int(record.port || 0); + if (name == "") + name = server + ":" + as_string(record.port); + if (proxy_type == "" || server == "" || !valid_port(port)) + return null; + + let options = { + tls: as_string(record.tls), + skip_verify: as_string(record["skip-cert-verify"]), + sni: as_string(record.sni || record.servername), + network: lc(as_string(record.network)), + ws_path: as_string(record["ws-opts.path"]), + ws_host: as_string(record["ws-opts.headers.Host"]), + grpc_service_name: as_string(record["grpc-opts.grpc-service-name"]), + reality_public_key: as_string(record["reality-opts.public-key"]), + reality_short_id: as_string(record["reality-opts.short-id"]), + alpn: normalized_clash_alpn(record.alpn || ""), + fingerprint: normalize_utls_fingerprint(as_string(record["client-fingerprint"] || record.fingerprint)) + }; + + if (proxy_type == "ss" || proxy_type == "shadowsocks") { + let method = as_string(record.cipher); + let password = as_string(record.password); + if (method == "" || method == "ss" || password == "") + return null; + return { type: "shadowsocks", tag: name, server: server, server_port: port, method: method, password: password }; + } + if (proxy_type == "vmess") { + let uuid = as_string(record.uuid); + if (uuid == "") + return null; + let outbound = { + type: "vmess", + tag: name, + server: server, + server_port: port, + uuid: uuid, + security: as_string(record.cipher) != "" ? as_string(record.cipher) : "auto" + }; + if (as_string(record.alterId || record["alter-id"]) != "") + outbound.alter_id = int(record.alterId || record["alter-id"]); + add_clash_tls(outbound, options); + add_clash_transport(outbound, options); + return outbound; + } + if (proxy_type == "vless") { + let uuid = as_string(record.uuid); + let flow = as_string(record.flow); + let packet_encoding = normalize_packet_encoding(record["packet-encoding"] || record.packetEncoding || ""); + let encryption = normalize_vless_encryption(record.encryption || ""); + if (uuid == "" || !clash_vless_flow_supported(flow)) + return null; + let outbound = { type: "vless", tag: name, server: server, server_port: port, uuid: uuid }; + if (encryption != "") + outbound.encryption = encryption; + if (flow != "") + outbound.flow = flow; + if (packet_encoding != "") + outbound.packet_encoding = packet_encoding; + add_clash_tls(outbound, options); + add_clash_transport(outbound, options); + return outbound; + } + if (proxy_type == "trojan") { + let password = as_string(record.password); + if (password == "") + return null; + let outbound = { type: "trojan", tag: name, server: server, server_port: port, password: password }; + options.always = true; + add_clash_tls(outbound, options); + add_clash_transport(outbound, options); + return outbound; + } + if (proxy_type == "hysteria2" || proxy_type == "hy2") { + let password = as_string(record.password); + if (password == "") + return null; + let outbound = { type: "hysteria2", tag: name, server: server, server_port: port, password: password, tls: { enabled: true } }; + if (options.sni != "") + outbound.tls.server_name = options.sni; + if (is_true(options.skip_verify)) + outbound.tls.insecure = true; + if (options.alpn != "") + outbound.tls.alpn = split_csv(options.alpn); + let obfs = as_string(record.obfs); + let obfs_password = as_string(record["obfs-password"] || record["obfs-opts.password"]); + if (obfs != "" && obfs != "none") { + outbound.obfs = { type: obfs }; + if (obfs_password != "") + outbound.obfs.password = obfs_password; + } + return outbound; + } + if (proxy_type == "socks5" || proxy_type == "socks") { + let outbound = { type: "socks", tag: name, server: server, server_port: port, version: "5" }; + if (as_string(record.username) != "") + outbound.username = as_string(record.username); + if (as_string(record.password) != "") + outbound.password = as_string(record.password); + return outbound; + } + if (proxy_type == "http" || proxy_type == "https") { + let outbound = { type: "http", tag: name, server: server, server_port: port }; + if (as_string(record.username) != "") + outbound.username = as_string(record.username); + if (as_string(record.password) != "") + outbound.password = as_string(record.password); + if (proxy_type == "https" || is_true(record.tls)) + outbound.tls = { enabled: true }; + return outbound; + } + + return null; +} + +function normalize_clash_yaml(input_file, output_file) { + let outbounds = []; + let skipped = 0; + + for (let record in clash_yaml_records(input_file)) { + let outbound = parse_clash_record(record); + if (outbound) + push(outbounds, outbound); + else + skipped++; + } + + if (length(outbounds) == 0) { + fs.unlink(output_file); + return false; + } + + return write_json_file(output_file, { + version: 1, + format: "clash-yaml", + skipped: skipped, + outbounds: outbounds + }); +} + +function normalize_uri_list_data(data, output_file) { + data = as_string(data); + if (index(data, "\r") >= 0) + data = replace(data, /\r/g, ""); + + let output = fs.open(output_file, "w"); + if (!output) + return false; + + let added = 0; + let skipped = 0; + output.write("{\"version\":1,\"format\":\"uri-list\",\"outbounds\":["); + + for (let line in split(data, "\n")) { + if (index(line, "\r") >= 0) + line = replace(line, /\r/g, ""); + line = trim(line); + if (line != "" && !starts_with(line, "#")) { + let outbound = parse_share_link(line); + if (outbound) { + if (added > 0) + output.write(","); + output.write(sprintf("%J", outbound)); + added++; + } + else { + skipped++; + } + } + } + + output.write("],\"skipped\":" + skipped + "}\n"); + output.close(); + + if (added == 0) { + fs.unlink(output_file); + return false; + } + return true; +} + +let metadata_allowed_keys = { + "profile-title": true, + "subscription-userinfo": true, + "profile-web-page-url": true, + "support-url": true, + "announce": true, + "announce-url": true, + "subscription-refill-date": true, + "content-disposition": true +}; + +function metadata_new_store() { + return { + values: {}, + seen: {}, + order: [] + }; +} + +function write_metadata_compact_json(store) { + print("{"); + for (let i = 0; i < length(store.order); i++) { + let key = store.order[i]; + if (i > 0) + print(","); + print(sprintf("%J", key), ":", sprintf("%J", store.values[key])); + } + print("}\n"); +} + +function add_metadata_entry(store, key, value) { + key = lc(trim(key)); + value = replace(trim(value), /\r/g, ""); + if (!metadata_allowed_keys[key] || value == "") + return; + + if (!store.seen[key]) { + push(store.order, key); + store.seen[key] = true; + } + store.values[key] = value; +} + +function parse_metadata_line(store, line) { + line = trim(line); + let colon = index(line, ":"); + if (colon <= 0) + return; + + add_metadata_entry(store, substr(line, 0, colon), substr(line, colon + 1)); +} + +function metadata_headers_store(input_file) { + let data = read_file(input_file); + let store = metadata_new_store(); + + if (data != null) { + for (let line in split(data, "\n")) + parse_metadata_line(store, line); + } + + return store; +} + +function metadata_headers_json(input_file) { + write_metadata_compact_json(metadata_headers_store(input_file)); + return true; +} + +function metadata_body_store_from_data(data) { + let store = metadata_new_store(); + let line_no = 0; + + if (data != null) { + for (let line in split(data, "\n")) { + line_no++; + if (line_no > 20) + break; + + line = trim(line); + if (starts_with(line, "#")) + line = replace(line, /^#[ \t]*/g, ""); + else if (starts_with(line, "//")) + line = replace(line, /^\/\/[ \t]*/g, ""); + else + continue; + + parse_metadata_line(store, line); + } + } + + return store; +} + +function metadata_body_store(input_file) { + return metadata_body_store_from_data(read_file(input_file)); +} + +function metadata_body_store_with_base64_fallback(input_file) { + let store = metadata_body_store(input_file); + if (length(store.order) > 0) + return store; + + let data = read_file(input_file); + if (data == null) + return store; + + let decoded = base64_decode(replace(as_string(data), /[\r\n\t ]/g, "")); + if (decoded == null || decoded == "") + return store; + + return metadata_body_store_from_data(decoded); +} + +function metadata_body_json(input_file) { + write_metadata_compact_json(metadata_body_store(input_file)); + return true; +} + +function metadata_trim_text(value) { + return trim(as_string(value)); +} + +function metadata_clean_text(value, max, decode_base64) { + value = as_string(value); + if (decode_base64 && lc(substr(value, 0, 7)) == "base64:") { + let decoded = base64_decode(substr(value, 7)); + value = decoded == null ? "" : decoded; + } + + let cleaned = ""; + let last_space = false; + for (let i = 0; i < length(value); i++) { + let char = substr(value, i, 1); + let code = ord(char); + if (code < 32 || code == 127) + char = " "; + + if (char == " ") { + if (!last_space) + cleaned += " "; + last_space = true; + } + else { + cleaned += char; + last_space = false; + } + } + + cleaned = trim(cleaned); + if (cleaned == "") + return null; + return length(cleaned) > max ? substr(cleaned, 0, max) : cleaned; +} + +function metadata_has_control_or_space(value) { + value = as_string(value); + for (let i = 0; i < length(value); i++) { + let code = ord(substr(value, i, 1)); + if (code <= 32 || code == 127) + return true; + } + return false; +} + +function metadata_clean_url(value) { + let cleaned = metadata_trim_text(value); + if (cleaned == "" || length(cleaned) > 2048) + return null; + if (!starts_with(cleaned, "http://") && !starts_with(cleaned, "https://")) + return null; + return metadata_has_control_or_space(cleaned) ? null : cleaned; +} + +function metadata_clean_number(value) { + value = metadata_trim_text(value); + if (value == "") + return null; + for (let i = 0; i < length(value); i++) { + let code = ord(substr(value, i, 1)); + if (code < 48 || code > 57) + return null; + } + return value; +} + +function metadata_replace_path_separators(value) { + let result = ""; + for (let i = 0; i < length(value); i++) { + let char = substr(value, i, 1); + result += (char == "/" || char == "\\") ? "_" : char; + } + return result; +} + +function metadata_content_disposition_filename(value) { + value = as_string(value); + let filename = null; + let quoted_marker = "filename=\""; + let quoted_pos = index(value, quoted_marker); + if (quoted_pos >= 0) { + let rest = substr(value, quoted_pos + length(quoted_marker)); + let quote_end = index(rest, "\""); + filename = quote_end >= 0 ? substr(rest, 0, quote_end) : rest; + } + else { + let marker = "filename="; + let pos = index(value, marker); + if (pos < 0) + return null; + + let rest = substr(value, pos + length(marker)); + let semicolon = index(rest, ";"); + filename = semicolon >= 0 ? substr(rest, 0, semicolon) : rest; + if (length(filename) > 0 && substr(filename, 0, 1) == "\"") + filename = substr(filename, 1); + if (length(filename) > 0 && substr(filename, length(filename) - 1) == "\"") + filename = substr(filename, 0, length(filename) - 1); + } + + filename = metadata_clean_text(filename, 120, false); + if (filename == null) + return null; + + filename = metadata_replace_path_separators(filename); + return filename == "" ? null : filename; +} + +function metadata_number_value(value) { + return value == null ? 0 : int(value, 10); +} + +function metadata_parse_userinfo(value) { + let result = {}; + + for (let item in split(as_string(value), ";")) { + item = trim(item); + let equals = index(item, "="); + if (equals < 0) + continue; + + let key = lc(trim(substr(item, 0, equals))); + let number = metadata_clean_number(substr(item, equals + 1)); + if (number == null) + continue; + + if (key == "upload" || key == "download" || key == "total" || key == "expire") + result[key] = number; + } + + return result; +} + +function metadata_ui_object(raw) { + raw = object_or_empty(raw); + + let title = metadata_clean_text(raw["profile-title"], 120, true); + let web_page_url = metadata_clean_url(raw["profile-web-page-url"]); + let support_url = metadata_clean_url(raw["support-url"]); + let announce = metadata_clean_text(raw["announce"], 500, true); + let announce_url = metadata_clean_url(raw["announce-url"]); + let refill_date = metadata_clean_number(raw["subscription-refill-date"]); + let file_name = metadata_content_disposition_filename(raw["content-disposition"]); + let userinfo = metadata_parse_userinfo(raw["subscription-userinfo"]); + + let upload = userinfo.upload; + let download = userinfo.download; + let total = userinfo.total; + let expire = userinfo.expire; + let has_traffic = upload != null || download != null || total != null || expire != null; + let used = metadata_number_value(upload) + metadata_number_value(download); + let total_value = metadata_number_value(total); + + let result = { version: 1 }; + if (title != null) + result.title = title; + if (has_traffic) { + let traffic = { + used: used, + isUnlimited: !(total != null && total_value > 0) + }; + if (upload != null) + traffic.upload = metadata_number_value(upload); + if (download != null) + traffic.download = metadata_number_value(download); + if (total != null && total_value > 0) { + traffic.total = total_value; + let remaining = total_value - used; + traffic.remaining = remaining < 0 ? 0 : remaining; + } + result.traffic = traffic; + } + if (expire != null && metadata_number_value(expire) > 0) + result.expire = metadata_number_value(expire); + if (refill_date != null && metadata_number_value(refill_date) > 0) + result.refillDate = metadata_number_value(refill_date); + if (web_page_url != null) + result.webPageUrl = web_page_url; + if (support_url != null) + result.supportUrl = support_url; + if (announce != null) + result.announce = announce; + if (announce_url != null) + result.announceUrl = announce_url; + if (file_name != null) + result.fileName = file_name; + + return result; +} + +function metadata_ui_json(raw_path) { + let result = metadata_ui_object(read_json_file(raw_path)); + if (length(keys(result)) > 1) + write_json(result); + return true; +} + +function metadata_extract_ui_json(headers_file, body_file) { + let raw = metadata_body_store_with_base64_fallback(body_file).values; + let headers = metadata_headers_store(headers_file).values; + for (let key, value in headers) + raw[key] = value; + + let result = metadata_ui_object(raw); + if (length(keys(result)) > 1) + write_json(result); + return true; +} + +function is_metadata_preamble_line(line) { + let trimmed = trim(line); + let m = match(trimmed, /^(#|\/\/)[ \t]*([A-Za-z0-9][A-Za-z0-9_-]*)[ \t]*:/); + return m && metadata_allowed_keys[lc(m[2])]; +} + +function normalize_uri_list_stream(input, output_file, strip_metadata) { + let output = fs.open(output_file, "w"); + if (!output) + return false; + + let added = 0; + let skipped = 0; + let line_no = 0; + output.write("{\"version\":1,\"format\":\"uri-list\",\"outbounds\":["); + + while (true) { + let line = input.read("line"); + if (line == null) + break; + line_no++; + line = trim(line); + if (strip_metadata && line_no <= 20 && is_metadata_preamble_line(line)) + continue; + if (line != "" && !starts_with(line, "#")) { + let outbound = parse_share_link(line); + if (outbound) { + if (added > 0) + output.write(","); + output.write(sprintf("%J", outbound)); + added++; + } + else { + skipped++; + } + } + } + + output.write("],\"skipped\":" + skipped + "}\n"); + output.close(); + + if (added == 0) { + fs.unlink(output_file); + return false; + } + return true; +} + +function file_looks_like_uri_list(input_file) { + let input = fs.open(input_file, "r"); + if (!input) + return false; + + let line_no = 0; + while (line_no < 200) { + let line = input.read("line"); + if (line == null) + break; + line_no++; + line = trim(line); + if (line == "" || starts_with(line, "#") || is_metadata_preamble_line(line)) + continue; + if (is_supported_share_link(line)) { + input.close(); + return true; + } + if (substr(line, 0, 1) == "{" || substr(line, 0, 1) == "[" || index(line, "proxies:") >= 0) { + input.close(); + return false; + } + } + + input.close(); + return false; +} + +function normalize_uri_list_file(input_file, output_file, strip_metadata) { + let input = fs.open(input_file, "r"); + if (!input) + return false; + let ok = normalize_uri_list_stream(input, output_file, strip_metadata); + input.close(); + return ok; +} + +function normalize_uri_list(input_file, output_file) { + return normalize_uri_list_file(input_file, output_file, false); +} + +function strip_metadata_preamble_data(data) { + data = as_string(data); + let offset = 0; + let changed = false; + + for (let line_no = 1; line_no <= 20 && offset <= length(data); line_no++) { + let rest = substr(data, offset); + let newline_pos = index(rest, "\n"); + let line = newline_pos >= 0 ? substr(rest, 0, newline_pos) : rest; + if (is_metadata_preamble_line(line)) { + changed = true; + break; + } + if (newline_pos < 0) + break; + offset += newline_pos + 1; + } + + if (!changed) + return data; + + let result = []; + let line_no = 0; + + for (let line in split(data, "\n")) { + line_no++; + let text = replace(line, /\r$/g, ""); + if (line_no <= 20) { + if (is_metadata_preamble_line(text)) + continue; + } + push(result, text); + } + + return join("\n", result); +} + +function content_is_clash_yaml(data) { + for (let line in split(data, "\n")) { + if (match(line, /^[ \t]*proxies:[ \t]*(#.*)?$/)) + return true; + } + return false; +} + +function xray_first_array_object(value) { + value = array_or_empty(value); + return length(value) > 0 && type(value[0]) == "object" ? value[0] : {}; +} + +function xray_first_string(values) { + for (let value in values) { + value = as_string(value); + if (value != "") + return value; + } + return ""; +} + +function xray_valid_port_value(value) { + let value_type = type(value); + if (value_type == "int" || value_type == "double") + return valid_port(value) ? int(value) : null; + + value = as_string(value); + if (!is_integer_string(value)) + return null; + + let port = int(value, 10); + return valid_port(port) ? port : null; +} + +function xray_alpn(value) { + if (type(value) == "array") + return value; + + let text = as_string(value); + return text != "" ? split_csv(text) : []; +} + +function xray_add_utls(tls, fingerprint) { + fingerprint = normalize_utls_fingerprint(fingerprint); + if (fingerprint != "") + tls.utls = { enabled: true, fingerprint: fingerprint }; +} + +function xray_tls_from_stream(stream, network) { + stream = object_or_empty(stream); + let security = lc(as_string(stream.security || "")); + let reality = object_or_empty(stream.realitySettings); + let tls_settings = object_or_empty(stream.tlsSettings); + let settings = security == "reality" ? reality : tls_settings; + + if (security != "tls" && security != "xtls" && security != "reality" && length(keys(settings)) == 0) + return null; + + let tls = { enabled: true }; + let server_name = xray_first_string([settings.serverName, settings.server_name, settings.sni]); + let alpn = tls_alpn_for_transport(xray_alpn(settings.alpn), network); + if (server_name != "") + tls.server_name = server_name; + if (is_true(settings.allowInsecure) || is_true(settings.insecure)) + tls.insecure = true; + if (length(alpn) > 0) + tls.alpn = alpn; + + xray_add_utls(tls, settings.fingerprint || settings.fp || ""); + + if (security == "reality" || length(keys(reality)) > 0) { + tls.reality = { enabled: true }; + let public_key = xray_first_string([reality.publicKey, reality.public_key, settings.publicKey, settings.public_key]); + let short_id = xray_first_string([reality.shortId, reality.short_id, settings.shortId, settings.short_id]); + if (public_key != "") + tls.reality.public_key = public_key; + if (short_id != "") + tls.reality.short_id = short_id; + if (!tls.utls) + tls.utls = { enabled: true, fingerprint: "chrome" }; + } + + return tls; +} + +function xray_transport_from_stream(stream) { + stream = object_or_empty(stream); + let network = lc(as_string(stream.network || "")); + if (network == "" || network == "tcp") + return null; + + if (network == "ws") { + let settings = object_or_empty(stream.wsSettings); + let headers = object_or_empty(settings.headers); + let result = { type: "ws", path: as_string(settings.path || "/") }; + let host = as_string(headers.Host || headers.host || ""); + if (host != "") + result.headers = { Host: host }; + return result; + } + + if (network == "grpc") { + let settings = object_or_empty(stream.grpcSettings); + let result = { type: "grpc" }; + if (as_string(settings.serviceName || settings.service_name || "") != "") + result.service_name = as_string(settings.serviceName || settings.service_name); + return result; + } + + if (network == "http" || network == "h2") { + let settings = object_or_empty(stream.httpSettings); + let result = { type: "http" }; + if (type(settings.path) == "array" && length(settings.path) > 0) + result.path = as_string(settings.path[0]); + else if (as_string(settings.path || "") != "") + result.path = as_string(settings.path); + if (type(settings.host) == "array") + result.host = settings.host; + else if (as_string(settings.host || "") != "") + result.host = split_csv(settings.host); + return result; + } + + if (network == "httpupgrade") { + let settings = object_or_empty(stream.httpupgradeSettings); + let result = { type: "httpupgrade" }; + if (as_string(settings.path || "") != "") + result.path = as_string(settings.path); + if (as_string(settings.host || "") != "") + result.host = as_string(settings.host); + return result; + } + + if (network == "xhttp") { + let settings = object_or_empty(stream.xhttpSettings); + let result = { + type: "xhttp", + mode: as_string(settings.mode || "auto"), + path: as_string(settings.path || "/"), + x_padding_bytes: "100-1000", + no_grpc_header: false, + sc_max_each_post_bytes: 1000000, + sc_min_posts_interval_ms: 30 + }; + if (result.mode != "auto" && result.mode != "packet-up" && result.mode != "stream-up" && result.mode != "stream-one") + result.mode = "auto"; + if (as_string(settings.host || "") != "") + result.host = as_string(settings.host); + xhttp_optional_positive_range(result, "x_padding_bytes", xhttp_setting_value(settings, settings, "xPaddingBytes", "x_padding_bytes")); + xhttp_optional_bool(result, "no_grpc_header", xhttp_setting_value(settings, settings, "noGRPCHeader", "no_grpc_header")); + xhttp_optional_positive_range(result, "sc_max_each_post_bytes", xhttp_setting_value(settings, settings, "scMaxEachPostBytes", "sc_max_each_post_bytes")); + xhttp_optional_range(result, "sc_min_posts_interval_ms", xhttp_setting_value(settings, settings, "scMinPostsIntervalMs", "sc_min_posts_interval_ms")); + xhttp_optional_range(result, "sc_stream_up_server_secs", xhttp_setting_value(settings, settings, "scStreamUpServerSecs", "sc_stream_up_server_secs")); + let xmux = xhttp_normalize_xmux(settings.xmux); + if (xmux) + result.xmux = xmux; + return result; + } + + return null; +} + +function xray_display_name(original_tag) { + original_tag = as_string(original_tag); + return original_tag; +} + +function xray_config_display_name(config, fallback) { + let name = xray_first_string([config.remarks, config.remark, config.name, config.ps]); + return name != "" ? name : fallback; +} + +function xray_source_tag(outbound, fallback) { + let tag = as_string(outbound.tag || ""); + return tag != "" ? tag : fallback; +} + +function xray_unique_tag(base, taken) { + base = as_string(base); + if (base == "") + base = "server"; + if (!taken[base]) + return base; + + for (let suffix = 1; suffix < 100000; suffix++) { + let candidate = base + "-" + suffix; + if (!taken[candidate]) + return candidate; + } + + return base + "-overflow"; +} + +function xray_tag_base(original_tag, config_index, config_count) { + original_tag = as_string(original_tag); + if (original_tag == "") + original_tag = "server"; + return original_tag; +} + +function xray_protocol_supported(protocol) { + protocol = lc(as_string(protocol)); + return protocol == "vless" || protocol == "socks" || protocol == "hysteria"; +} + +function convert_xray_vless(outbound, tag) { + let vnext = xray_first_array_object(object_or_empty(outbound.settings).vnext); + let user = xray_first_array_object(vnext.users); + let server = as_string(vnext.address || ""); + let port = xray_valid_port_value(vnext.port); + let uuid = as_string(user.id || ""); + if (server == "" || port == null || uuid == "") + return null; + + let result = { + type: "vless", + tag: tag, + server: server, + server_port: port, + uuid: uuid + }; + + let flow = as_string(user.flow || ""); + if (flow != "") + result.flow = flow; + let encryption = normalize_vless_encryption(user.encryption || ""); + if (encryption != "") + result.encryption = encryption; + + let stream = object_or_empty(outbound.streamSettings); + let transport = xray_transport_from_stream(stream); + let tls = xray_tls_from_stream(stream, lc(as_string(stream.network || ""))); + if (tls) + result.tls = tls; + if (transport) + result.transport = transport; + + return result; +} + +function convert_xray_socks(outbound, tag) { + let server_config = xray_first_array_object(object_or_empty(outbound.settings).servers); + let server = as_string(server_config.address || ""); + let port = xray_valid_port_value(server_config.port); + if (server == "" || port == null) + return null; + + let result = { + type: "socks", + tag: tag, + server: server, + server_port: port, + version: "5" + }; + + let user = xray_first_array_object(server_config.users); + if (as_string(user.user || "") != "") + result.username = as_string(user.user); + if (as_string(user.pass || user.password || "") != "") + result.password = as_string(user.pass || user.password); + + return result; +} + +function convert_xray_hysteria2(outbound, tag) { + let settings = object_or_empty(outbound.settings); + let stream = object_or_empty(outbound.streamSettings); + let hysteria_settings = object_or_empty(stream.hysteriaSettings); + let version = int(settings.version || hysteria_settings.version || 0); + if (version != 2) + return null; + + let server = as_string(settings.address || settings.server || ""); + let port = xray_valid_port_value(settings.port); + let password = as_string(hysteria_settings.auth || settings.auth || settings.password || ""); + if (server == "" || port == null || password == "") + return null; + + let result = { + type: "hysteria2", + tag: tag, + server: server, + server_port: port, + password: password, + tls: { enabled: true } + }; + + let tls_settings = object_or_empty(stream.tlsSettings); + let server_name = xray_first_string([tls_settings.serverName, tls_settings.server_name, tls_settings.sni]); + if (server_name != "") + result.tls.server_name = server_name; + if (is_true(tls_settings.allowInsecure) || is_true(tls_settings.insecure)) + result.tls.insecure = true; + let alpn = xray_alpn(tls_settings.alpn); + if (length(alpn) > 0) + result.tls.alpn = alpn; + + return result; +} + +function convert_xray_outbound(outbound, tag) { + let protocol = lc(as_string(outbound.protocol || "")); + if (protocol == "vless") + return convert_xray_vless(outbound, tag); + if (protocol == "socks") + return convert_xray_socks(outbound, tag); + if (protocol == "hysteria") + return convert_xray_hysteria2(outbound, tag); + return null; +} + +function xray_outbound_detour(outbound) { + let stream = object_or_empty(outbound.streamSettings); + let sockopt = object_or_empty(stream.sockopt); + if (as_string(sockopt.dialerProxy || "") != "") + return as_string(sockopt.dialerProxy); + + sockopt = object_or_empty(outbound.sockopt); + return as_string(sockopt.dialerProxy || ""); +} + +function xray_supported_source_tags(source_outbounds) { + let result = {}; + for (let i = 0; i < length(source_outbounds); i++) { + let outbound = source_outbounds[i]; + if (type(outbound) != "object" || !xray_protocol_supported(outbound.protocol)) + continue; + + let tag = xray_source_tag(outbound, lc(as_string(outbound.protocol || "server")) + "-" + (i + 1)); + result[tag] = true; + } + return result; +} + +function xray_primary_source_tag(config, source_outbounds) { + let supported_tags = xray_supported_source_tags(source_outbounds); + + for (let rule in array_or_empty(object_or_empty(config.routing).rules)) { + let outbound_tag = as_string(object_or_empty(rule).outboundTag || ""); + if (supported_tags[outbound_tag]) + return outbound_tag; + } + + if (supported_tags["proxy"]) + return "proxy"; + + for (let i = 0; i < length(source_outbounds); i++) { + let outbound = source_outbounds[i]; + if (type(outbound) != "object" || !xray_protocol_supported(outbound.protocol)) + continue; + + let tag = xray_source_tag(outbound, lc(as_string(outbound.protocol || "server")) + "-" + (i + 1)); + if (xray_outbound_detour(outbound) == "") + return tag; + } + + for (let tag in keys(supported_tags)) + return tag; + + return ""; +} + +function xray_balancer_selector_matches(selector, tag) { + selector = as_string(selector); + tag = as_string(tag); + if (selector == "" || tag == "") + return false; + return tag == selector || starts_with(tag, selector + "-") || starts_with(tag, selector); +} + +function xray_urltest_option_string(value) { + value = as_string(value); + return value != "" ? value : ""; +} + +function xray_config_urltest_options(config) { + let observatory = object_or_empty(config.observatory); + let burst_observatory = object_or_empty(config.burstObservatory || config.burst_observatory); + let ping_config = object_or_empty(burst_observatory.pingConfig || burst_observatory.ping_config); + let result = {}; + + let url = xray_first_string([ + ping_config.destination, + ping_config.url, + observatory.probeURL, + observatory.probeUrl, + observatory.probe_url + ]); + if (url != "") + result.url = url; + + let interval = xray_first_string([ + ping_config.interval, + observatory.probeInterval, + observatory.probe_interval + ]); + if (interval != "") + result.interval = interval; + + return result; +} + +function xray_apply_urltest_options(group, options) { + options = object_or_empty(options); + for (let key in [ "url", "interval" ]) { + let value = xray_urltest_option_string(options[key]); + if (value != "") + group[key] = value; + } +} + +function xray_balancer_plans(config, source_outbounds) { + let supported_tags = xray_supported_source_tags(source_outbounds); + let result = []; + let urltest_options = xray_config_urltest_options(config); + + for (let balancer in array_or_empty(object_or_empty(config.routing).balancers)) { + let selectors = array_or_empty(object_or_empty(balancer).selector); + let selected = {}; + let tags = []; + for (let tag in keys(supported_tags)) { + for (let selector in selectors) { + if (xray_balancer_selector_matches(selector, tag) && !selected[tag]) { + selected[tag] = true; + push(tags, tag); + break; + } + } + } + if (length(tags) > 0) { + push(result, { + tag: as_string(object_or_empty(balancer).tag || ""), + source_tags: tags, + urltest_options + }); + } + } + + return result; +} + +function xray_balancer_group_base(plan, display_name, config_index, config_count, plan_index, plan_count) { + let balancer_tag = as_string(object_or_empty(plan).tag || ""); + display_name = as_string(display_name); + if (plan_count == 1 && display_name != "") + return display_name; + if (balancer_tag != "") + return balancer_tag; + if (display_name != "") + return display_name + "-" + (plan_index + 1); + return xray_tag_base("urltest", config_index, config_count); +} + +function xray_plan_config_outbounds(config, config_index, config_count, taken) { + let source_outbounds = array_or_empty(config.outbounds); + let planned = []; + let tag_map = {}; + + for (let i = 0; i < length(source_outbounds); i++) { + let outbound = source_outbounds[i]; + if (type(outbound) != "object" || !xray_protocol_supported(outbound.protocol)) + continue; + + let original_tag = xray_source_tag(outbound, lc(as_string(outbound.protocol || "server")) + "-" + (i + 1)); + let tag = xray_unique_tag(xray_tag_base(original_tag, config_index, config_count), taken); + taken[tag] = true; + tag_map[original_tag] = tag; + push(planned, { outbound, original_tag, tag }); + } + + return { planned, tag_map }; +} + +function xray_retag_planned_outbound(planned, tag_map, source_tag, new_base, config_index, config_count, taken) { + source_tag = as_string(source_tag); + new_base = as_string(new_base); + if (source_tag == "" || new_base == "") + return; + + for (let item in planned) { + if (item.original_tag != source_tag) + continue; + + let old_tag = as_string(item.tag || ""); + let new_tag = xray_unique_tag(new_base, taken); + if (new_tag == old_tag) + return; + + if (old_tag != "") + delete taken[old_tag]; + taken[new_tag] = true; + item.tag = new_tag; + tag_map[source_tag] = new_tag; + return; + } +} + +function xray_add_converted_outbound(result, item, tag_map, visible, display_name) { + let converted = convert_xray_outbound(item.outbound, item.tag); + if (!converted) + return; + + if (visible && display_name != "") + converted.remark = display_name; + if (!visible) + converted.__tachyon_hidden = true; + + let detour = xray_outbound_detour(item.outbound); + if (detour != "" && tag_map[detour]) + converted.detour = tag_map[detour]; + + push(result, converted); +} + +function xray_config_outbounds(config, config_index, config_count, taken) { + let source_outbounds = array_or_empty(config.outbounds); + let plan = xray_plan_config_outbounds(config, config_index, config_count, taken); + let planned = array_or_empty(plan.planned); + let tag_map = object_or_empty(plan.tag_map); + let display_name = xray_config_display_name(config, ""); + let balancer_plans = xray_balancer_plans(config, source_outbounds); + let visible_source_tags = {}; + let dependency_tags = {}; + let result = []; + + if (length(balancer_plans) > 0) { + for (let plan_index = 0; plan_index < length(balancer_plans); plan_index++) { + let plan = balancer_plans[plan_index]; + let urltest_outbounds = []; + for (let source_tag in array_or_empty(plan.source_tags)) { + if (tag_map[source_tag]) { + visible_source_tags[source_tag] = false; + push(urltest_outbounds, tag_map[source_tag]); + } + } + + if (length(urltest_outbounds) > 0) { + let group_base = xray_balancer_group_base(plan, display_name, config_index, config_count, plan_index, length(balancer_plans)); + let group_tag = xray_unique_tag(group_base, taken); + taken[group_tag] = true; + let group = { + type: "urltest", + tag: group_tag, + outbounds: urltest_outbounds, + remark: group_base != "" ? group_base : group_tag, + __tachyon_allow_group: true + }; + xray_apply_urltest_options(group, plan.urltest_options); + push(result, group); + } + } + } else { + let primary_tag = xray_primary_source_tag(config, source_outbounds); + if (primary_tag != "") { + xray_retag_planned_outbound(planned, tag_map, primary_tag, display_name, config_index, config_count, taken); + visible_source_tags[primary_tag] = true; + } + } + + for (let item in planned) { + let detour = xray_outbound_detour(item.outbound); + if (detour != "" && tag_map[detour]) + dependency_tags[detour] = true; + } + + for (let item in planned) { + let visible = visible_source_tags[item.original_tag] === true; + let hidden = !visible; + if (visible || dependency_tags[item.original_tag] || visible_source_tags[item.original_tag] === false) { + let item_display_name = visible && display_name != "" + ? display_name + : xray_display_name(item.original_tag); + xray_add_converted_outbound(result, item, tag_map, !hidden, item_display_name); + } + } + + return result; +} + +function is_xray_config(value) { + if (type(value) != "object" || type(value.outbounds) != "array") + return false; + + for (let outbound in value.outbounds) { + if (type(outbound) == "object" && type(outbound.protocol) == "string") + return true; + } + + return false; +} + +function normalize_xray_configs(configs) { + configs = array_or_empty(configs); + let taken = {}; + let outbounds = []; + + for (let i = 0; i < length(configs); i++) { + let config = configs[i]; + if (!is_xray_config(config)) + continue; + + for (let outbound in xray_config_outbounds(config, i, length(configs), taken)) + push(outbounds, outbound); + } + + return outbounds; +} + +function normalize_sing_box_xhttp_transport(outbound) { + if (type(outbound) != "object" || type(outbound.transport) != "object") + return outbound; + if (outbound.transport.type != "xhttp") + return outbound; + + outbound.transport.x_padding_bytes = xhttp_positive_range_or_default(outbound.transport.x_padding_bytes, "100-1000"); + let sc_max_each_post_bytes = xhttp_present_positive_range_or_default(outbound.transport.sc_max_each_post_bytes, 1000000); + if (sc_max_each_post_bytes != null) + outbound.transport.sc_max_each_post_bytes = sc_max_each_post_bytes; + return outbound; +} + +function normalize_sing_box_hysteria2_outbound(outbound) { + if (type(outbound) == "object" && + lc(as_string(outbound.type || "")) == "hysteria2" && + type(outbound.tls) == "object" && + outbound.tls.utls != null) { + delete outbound.tls.utls; + } + return outbound; +} + +function sing_box_urltest_group(outbound) { + return type(outbound) == "object" && lc(as_string(outbound.type || "")) == "urltest"; +} + +function sing_box_outbound_tag(outbound) { + return type(outbound) == "object" ? as_string(outbound.tag || "") : ""; +} + +function normalize_sing_box_json_outbounds(candidates) { + let outbounds = []; + let urltest_refs = {}; + let detour_refs = {}; + + for (let outbound in candidates) { + if (type(outbound) != "object") + continue; + outbound = normalize_sing_box_hysteria2_outbound(outbound); + push(outbounds, normalize_sing_box_xhttp_transport(outbound)); + } + + for (let outbound in outbounds) { + if (sing_box_urltest_group(outbound)) { + for (let tag_name in array_or_empty(outbound.outbounds)) { + tag_name = as_string(tag_name); + if (tag_name != "") + urltest_refs[tag_name] = true; + } + } + + let detour = as_string(outbound.detour || ""); + if (detour != "") + detour_refs[detour] = true; + } + + for (let outbound in outbounds) { + let tag_name = sing_box_outbound_tag(outbound); + if (sing_box_urltest_group(outbound)) { + outbound.__tachyon_allow_group = true; + continue; + } + if (tag_name != "" && (urltest_refs[tag_name] || detour_refs[tag_name])) + outbound.__tachyon_hidden = true; + } + + return outbounds; +} + +function normalize_sing_box_json_value(value, output_file) { + let candidates = []; + let outbounds = []; + + if (is_xray_config(value)) + outbounds = normalize_xray_configs([value]); + else if (type(value) == "array" && length(value) > 0 && is_xray_config(value[0])) + outbounds = normalize_xray_configs(value); + + if (length(outbounds) > 0) { + return write_json_file(output_file, { + version: 1, + format: "sing-box-json", + outbounds: outbounds + }); + } + + if (type(value) == "object" && type(value.outbounds) == "array") + candidates = value.outbounds; + else if (type(value) == "array") + candidates = value; + else if (type(value) == "object" && type(value.type) == "string") + candidates = [value]; + + outbounds = normalize_sing_box_json_outbounds(candidates); + + if (length(outbounds) == 0) { + fs.unlink(output_file); + return false; + } + + return write_json_file(output_file, { + version: 1, + format: "sing-box-json", + outbounds: outbounds + }); +} + +function temp_path(prefix) { + let stamp = clock(); + return sprintf("/tmp/%s.%d.%d", prefix, stamp[0], stamp[1]); +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function gzip_decode_file(input_file, output_file) { + if (as_string(input_file) == "" || as_string(output_file) == "" || !fs.stat(input_file)) + return false; + + for (let command in [ "gzip -dc", "gunzip -c", "zcat" ]) { + let pipe = fs.popen(command + " " + shell_quote(input_file) + " 2>/dev/null", "r"); + if (!pipe) + continue; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status == 0 && data != null && data != "") { + if (fs.writefile(output_file, data)) + return true; + fs.unlink(output_file); + return false; + } + fs.unlink(output_file); + } + + return false; +} + +function try_decode_gzip_content_file(input_file) { + let tmp = temp_path("tachyon-subscription-gzip"); + if (!gzip_decode_file(input_file, tmp)) { + fs.unlink(tmp); + return false; + } + + if (!fs.rename(tmp, input_file)) { + fs.unlink(tmp); + return false; + } + + return true; +} + +function normalized_skipped_message(path) { + let value = read_json_file(path); + if (type(value) != "object") + return ""; + + let skipped = as_string(value.skipped != null ? value.skipped : "0"); + if (match(skipped, /^[0-9]+$/) == null || int(skipped) <= 0) + return ""; + + if (as_string(value.format != null ? value.format : "") == "clash-yaml") + return "Skipped " + skipped + " invalid or unsupported Clash proxy entries"; + + return "Skipped " + skipped + " invalid or unsupported subscription links"; +} + +function extract_ui_metadata_file(headers_file, body_file, output_file) { + let tmp = temp_path("tachyon-subscription-metadata"); + let raw = metadata_body_store_with_base64_fallback(body_file).values; + let headers = metadata_headers_store(headers_file).values; + for (let key, value in headers) + raw[key] = value; + + let result = metadata_ui_object(raw); + if (length(keys(result)) <= 1) { + fs.unlink(output_file); + return false; + } + + if (!fs.writefile(tmp, sprintf("%J\n", result))) { + fs.unlink(tmp); + return false; + } + if (!fs.rename(tmp, output_file)) { + fs.unlink(tmp); + return false; + } + return true; +} + +function normalize_content_data(data, output_file, depth) { + data = as_string(data); + if (index(data, "\r") >= 0) + data = replace(data, /\r/g, ""); + data = strip_metadata_preamble_data(data); + + let first = first_non_ws_char(data); + if (first == "{" || first == "[") { + let decoded_json = json_decode_text(data); + if (decoded_json != null) + return normalize_sing_box_json_value(decoded_json, output_file); + } + + if (index(data, "proxies:") >= 0 && content_is_clash_yaml(data)) { + let tmp = temp_path("tachyon-subscription-clash"); + if (!fs.writefile(tmp, data)) + return false; + let ok = normalize_clash_yaml(tmp, output_file); + fs.unlink(tmp); + return ok; + } + + if (index(data, "://") >= 0) { + if (normalize_uri_list_data(data, output_file)) + return true; + } + + if (depth < 1) { + let compact = replace(data, /[\r\n\t ]/g, ""); + let decoded = base64_decode(compact); + if (decoded != null && decoded != "") + return normalize_content_data(decoded, output_file, depth + 1); + } + + return false; +} + +function normalize_content_file(input_file, output_file) { + if (file_looks_like_uri_list(input_file)) + return normalize_uri_list_file(input_file, output_file, true); + + let data = read_file(input_file); + return data == null ? false : normalize_content_data(data, output_file, 0); +} + +function normalize_content_validated(input_file, output_file) { + let normalize_input = input_file; + let decoded_tmp = temp_path("tachyon-subscription-gzip"); + let decoded = gzip_decode_file(input_file, decoded_tmp); + if (decoded) + normalize_input = decoded_tmp; + else + fs.unlink(decoded_tmp); + + let ok = normalize_content_file(normalize_input, output_file); + if (decoded) + fs.unlink(decoded_tmp); + if (!ok) + return false; + + return validate_subscription(output_file); +} + +function normalized_skipped_warning(path) { + let message = normalized_skipped_message(path); + if (message != "") + print(message, "\n"); + + return true; +} + +function subscription_source_entry_result(valid, url, user_agent, error) { + return { + valid, + url: as_string(url), + user_agent: as_string(user_agent), + error: as_string(error) + }; +} + +function write_subscription_source_entry_tsv_result(result) { + result = type(result) == "object" ? result : subscription_source_entry_result(false, "", "", "Subscription URL cannot be empty"); + + if (!result.valid) { + print(result.error, "\n"); + return false; + } + + print(result.url, "\t", result.user_agent, "\n"); + return true; +} + +function parse_subscription_source_entry(entry) { + entry = trim(entry); + if (entry == "") + return subscription_source_entry_result(false, "", "", "Subscription URL cannot be empty"); + + if (index(entry, "|") >= 0) + return subscription_source_entry_result(false, "", "", "Configure User-Agent in the subscription item settings"); + + if (!(substr(entry, 0, 7) == "http://" || substr(entry, 0, 8) == "https://")) + return subscription_source_entry_result(false, "", "", "Subscription URL must start with http:// or https://"); + + return subscription_source_entry_result(true, entry, "", ""); +} + +function parse_subscription_source_entry_tsv(entry) { + return write_subscription_source_entry_tsv_result(parse_subscription_source_entry(entry)); +} + +function module_exports() { + return { + parse_subscription_source_entry, + validate_subscription, + normalize_content_validated, + normalized_skipped_message, + try_decode_gzip_content_file, + extract_ui_metadata_file, + runtime_outbounds_equal + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +let mode = ARGV[0]; +let ok = false; + +if (mode == "validate-subscription") + ok = validate_subscription(ARGV[1]); +else if (mode == "url-fragment") + ok = subscription_url_fragment(ARGV[1]); +else if (mode == "share-link-outbound") + ok = share_link_outbound(ARGV[1], ARGV[2]); +else if (mode == "normalize-uri-list") + ok = normalize_uri_list(ARGV[1], ARGV[2]); +else if (mode == "normalize-clash-yaml") + ok = normalize_clash_yaml(ARGV[1], ARGV[2]); +else if (mode == "normalize-content") + ok = normalize_content_file(ARGV[1], ARGV[2]); +else if (mode == "normalize-content-validated") + ok = normalize_content_validated(ARGV[1], ARGV[2]); +else if (mode == "try-decode-gzip-content") + ok = try_decode_gzip_content_file(ARGV[1]); +else if (mode == "runtime-outbounds-equal") + ok = runtime_outbounds_equal(ARGV[1], ARGV[2]); +else if (mode == "normalized-skipped-warning") + ok = normalized_skipped_warning(ARGV[1]); +else if (mode == "metadata-headers-json") + ok = metadata_headers_json(ARGV[1]); +else if (mode == "metadata-body-json") + ok = metadata_body_json(ARGV[1]); +else if (mode == "metadata-ui-json") + ok = metadata_ui_json(ARGV[1]); +else if (mode == "metadata-extract-ui-json") + ok = metadata_extract_ui_json(ARGV[1], ARGV[2]); +else if (mode == "metadata-extract-ui-file") + ok = extract_ui_metadata_file(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "parse-source-entry-tsv") + ok = parse_subscription_source_entry_tsv(ARGV[1]); +else if (ARGV[0] && ARGV[1]) + ok = normalize_uri_list(ARGV[0], ARGV[1]); +else { + warn("Usage: subscription/parser.uc validate-subscription \n"); + warn(" subscription/parser.uc url-fragment \n"); + warn(" subscription/parser.uc share-link-outbound \n"); + warn(" subscription/parser.uc normalize-uri-list \n"); + warn(" subscription/parser.uc normalize-clash-yaml \n"); + warn(" subscription/parser.uc normalize-content \n"); + warn(" subscription/parser.uc normalize-content-validated \n"); + warn(" subscription/parser.uc try-decode-gzip-content \n"); + warn(" subscription/parser.uc runtime-outbounds-equal \n"); + warn(" subscription/parser.uc normalized-skipped-warning \n"); + warn(" subscription/parser.uc metadata-headers-json \n"); + warn(" subscription/parser.uc metadata-body-json \n"); + warn(" subscription/parser.uc metadata-ui-json \n"); + warn(" subscription/parser.uc metadata-extract-ui-json \n"); + warn(" subscription/parser.uc metadata-extract-ui-file \n"); + warn(" subscription/parser.uc parse-source-entry-tsv \n"); + exit(2); +} + +if (!ok) + exit(1); diff --git a/tachyon/files/usr/lib/subscription/share_link.uc b/tachyon/files/usr/lib/subscription/share_link.uc new file mode 100644 index 00000000..5994d42a --- /dev/null +++ b/tachyon/files/usr/lib/subscription/share_link.uc @@ -0,0 +1,415 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); + +let as_string = common.as_string; +let array_or_empty = common.array_or_empty; + +function starts_with(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function uri_encode(value) { + value = as_string(value); + let result = ""; + for (let i = 0; i < length(value); i++) { + let char = substr(value, i, 1); + let code = ord(char); + if ((code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + char == "-" || char == "_" || char == "." || char == "~") + result += char; + else + result += sprintf("%%%02X", code); + } + return result; +} + +function base64_encode(value) { + let encoded = b64enc(as_string(value)); + while (length(encoded) > 0 && substr(encoded, length(encoded) - 1) == "=") + encoded = substr(encoded, 0, length(encoded) - 1); + return encoded; +} + +function host_port(server, port) { + server = as_string(server); + if (index(server, ":") >= 0 && !starts_with(server, "[")) + server = "[" + server + "]"; + return server + ":" + as_string(port); +} + +function hysteria2_server_port_entry(value) { + value = as_string(value); + let colon = index(value, ":"); + if (colon < 0) + return value; + + let start = substr(value, 0, colon); + let end = substr(value, colon + 1); + if (start == "" || end == "") + return ""; + + return start == end ? start : (start + "-" + end); +} + +function hysteria2_server_ports_uri(outbound) { + let server_ports = array_or_empty(outbound.server_ports); + if (length(server_ports) == 0) + return ""; + + let result = []; + for (let item in server_ports) { + let port = hysteria2_server_port_entry(item); + if (port != "") + push(result, port); + } + + return join(",", result); +} + +function add_query(params, key, value) { + value = as_string(value); + if (value != "") + push(params, uri_encode(key) + "=" + uri_encode(value)); +} + +function add_xhttp_extra_query(params, transport) { + let extra = {}; + for (let item in [ + ["xPaddingBytes", "x_padding_bytes"], + ["noGRPCHeader", "no_grpc_header"], + ["scMaxEachPostBytes", "sc_max_each_post_bytes"], + ["scMinPostsIntervalMs", "sc_min_posts_interval_ms"], + ["scStreamUpServerSecs", "sc_stream_up_server_secs"] + ]) { + if (transport[item[1]] != null) + extra[item[0]] = transport[item[1]]; + } + + if (type(transport.xmux) == "object") { + let xmux = {}; + for (let item in [ + ["maxConcurrency", "max_concurrency"], + ["maxConnections", "max_connections"], + ["cMaxReuseTimes", "c_max_reuse_times"], + ["hMaxRequestTimes", "h_max_request_times"], + ["hMaxReusableSecs", "h_max_reusable_secs"], + ["hKeepAlivePeriod", "h_keep_alive_period"] + ]) { + if (transport.xmux[item[1]] != null) + xmux[item[0]] = transport.xmux[item[1]]; + } + if (length(keys(xmux)) > 0) + extra.xmux = xmux; + } + + if (length(keys(extra)) > 0) + add_query(params, "extra", sprintf("%J", extra)); +} + +function add_tls_query(params, outbound, trojan_default_tls) { + let tls = type(outbound.tls) == "object" ? outbound.tls : null; + if (!tls || tls.enabled === false) { + if (trojan_default_tls) + add_query(params, "security", "tls"); + return; + } + + let reality = type(tls.reality) == "object" ? tls.reality : null; + if (reality && reality.enabled !== false) { + add_query(params, "security", "reality"); + add_query(params, "pbk", reality.public_key); + add_query(params, "sid", reality.short_id); + } + else { + add_query(params, "security", "tls"); + } + + add_query(params, "sni", tls.server_name); + if (tls.insecure === true) + add_query(params, "allowInsecure", "1"); + if (type(tls.utls) == "object" && tls.utls.enabled !== false) + add_query(params, "fp", tls.utls.fingerprint); + if (type(tls.alpn) == "array" && length(tls.alpn) > 0) + add_query(params, "alpn", join(",", tls.alpn)); +} + +function add_transport_query(params, outbound) { + let transport = type(outbound.transport) == "object" ? outbound.transport : null; + if (!transport) { + add_query(params, "type", "tcp"); + return; + } + + let transport_type = as_string(transport.type); + add_query(params, "type", transport_type != "" ? transport_type : "tcp"); + + if (transport_type == "ws") { + add_query(params, "path", transport.path); + if (type(transport.headers) == "object") + add_query(params, "host", transport.headers.Host || transport.headers.host); + } + else if (transport_type == "grpc") { + add_query(params, "serviceName", transport.service_name); + } + else if (transport_type == "http") { + add_query(params, "path", transport.path); + if (type(transport.host) == "array" && length(transport.host) > 0) + add_query(params, "host", join(",", transport.host)); + else + add_query(params, "host", transport.host); + } + else if (transport_type == "xhttp") { + add_query(params, "path", transport.path); + add_query(params, "host", transport.host); + add_query(params, "mode", transport.mode); + add_xhttp_extra_query(params, transport); + } +} + +function query_string(params) { + return length(params) == 0 ? "" : "?" + join("&", params); +} + +function fragment(outbound) { + let tag = as_string(outbound.tag); + return tag == "" ? "" : "#" + uri_encode(tag); +} + +function serialize_vless(outbound) { + if (as_string(outbound.uuid) == "" || as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + let params = []; + add_tls_query(params, outbound, false); + add_transport_query(params, outbound); + let encryption = as_string(outbound.encryption); + if (encryption != "" && encryption != "none") + add_query(params, "encryption", encryption); + add_query(params, "flow", outbound.flow); + add_query(params, "packetEncoding", outbound.packet_encoding); + return "vless://" + uri_encode(outbound.uuid) + "@" + + host_port(outbound.server, outbound.server_port) + query_string(params) + fragment(outbound); +} + +function serialize_trojan(outbound) { + if (as_string(outbound.password) == "" || as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + let params = []; + add_tls_query(params, outbound, true); + add_transport_query(params, outbound); + return "trojan://" + uri_encode(outbound.password) + "@" + + host_port(outbound.server, outbound.server_port) + query_string(params) + fragment(outbound); +} + +function serialize_shadowsocks(outbound) { + if (as_string(outbound.method) == "" || as_string(outbound.password) == "" || + as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + let userinfo = base64_encode(as_string(outbound.method) + ":" + as_string(outbound.password)); + return userinfo == "" ? "" : + "ss://" + userinfo + "@" + host_port(outbound.server, outbound.server_port) + fragment(outbound); +} + +function serialize_socks(outbound) { + if (as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + + let scheme = "socks" + as_string(outbound.version || "5"); + let auth = ""; + if (as_string(outbound.username) != "") { + auth = uri_encode(outbound.username); + if (as_string(outbound.password) != "") + auth += ":" + uri_encode(outbound.password); + auth += "@"; + } + + return scheme + "://" + auth + host_port(outbound.server, outbound.server_port) + fragment(outbound); +} + +function serialize_hysteria2(outbound) { + let port = hysteria2_server_ports_uri(outbound); + if (port == "" && outbound.server_port != null) + port = as_string(outbound.server_port); + + if (as_string(outbound.password) == "" || as_string(outbound.server) == "" || port == "") + return ""; + + let params = []; + let tls = type(outbound.tls) == "object" ? outbound.tls : null; + if (tls) { + add_query(params, "sni", tls.server_name); + if (tls.insecure === true) + add_query(params, "insecure", "1"); + if (type(tls.alpn) == "array" && length(tls.alpn) > 0) + add_query(params, "alpn", join(",", tls.alpn)); + } + if (type(outbound.obfs) == "object") { + add_query(params, "obfs", outbound.obfs.type); + add_query(params, "obfs-password", outbound.obfs.password); + } + + return "hysteria2://" + uri_encode(outbound.password) + "@" + + host_port(outbound.server, port) + query_string(params) + fragment(outbound); +} + +function serialize_vmess(outbound) { + if (as_string(outbound.uuid) == "" || as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + + let vmess = { + v: "2", + ps: as_string(outbound.tag), + add: as_string(outbound.server), + port: as_string(outbound.server_port), + id: as_string(outbound.uuid), + aid: as_string(outbound.alter_id || 0), + scy: as_string(outbound.security || "auto"), + net: "tcp", + type: "none", + host: "", + path: "", + tls: "", + sni: "" + }; + + if (type(outbound.tls) == "object" && outbound.tls.enabled !== false) { + vmess.tls = "tls"; + vmess.sni = as_string(outbound.tls.server_name); + if (type(outbound.tls.utls) == "object") + vmess.fp = as_string(outbound.tls.utls.fingerprint); + } + + if (type(outbound.transport) == "object") { + vmess.net = as_string(outbound.transport.type || "tcp"); + if (vmess.net == "ws") { + vmess.path = as_string(outbound.transport.path); + if (type(outbound.transport.headers) == "object") + vmess.host = as_string(outbound.transport.headers.Host || outbound.transport.headers.host); + } + else if (vmess.net == "grpc") { + vmess.path = as_string(outbound.transport.service_name); + } + else if (vmess.net == "http") { + vmess.path = as_string(outbound.transport.path); + if (type(outbound.transport.host) == "array" && length(outbound.transport.host) > 0) + vmess.host = join(",", outbound.transport.host); + else + vmess.host = as_string(outbound.transport.host); + } + } + + let encoded = base64_encode(sprintf("%J", vmess)); + return encoded == "" ? "" : "vmess://" + encoded; +} + +function serialize_outbound_link(outbound) { + if (type(outbound) != "object") + return ""; + + let outbound_type = as_string(outbound.type); + if (outbound_type == "vless") + return serialize_vless(outbound); + if (outbound_type == "trojan") + return serialize_trojan(outbound); + if (outbound_type == "shadowsocks") + return serialize_shadowsocks(outbound); + if (outbound_type == "socks") + return serialize_socks(outbound); + if (outbound_type == "hysteria2") + return serialize_hysteria2(outbound); + if (outbound_type == "vmess") + return serialize_vmess(outbound); + return ""; +} + +function is_copyable_link(value) { + value = lc(as_string(value)); + let prefixes = [ + "vless://", "vmess://", "trojan://", "ss://", "ssr://", + "hysteria2://", "hy2://", "tuic://", + "socks4://", "socks4a://", "socks5://" + ]; + for (let prefix in prefixes) { + if (starts_with(value, prefix)) + return true; + } + return false; +} + +function populate_subscription_links(subscription) { + if (type(subscription) != "object" || type(subscription.outbounds) != "array") + return 0; + + let changed = 0; + for (let outbound in subscription.outbounds) { + if (type(outbound) != "object" || is_copyable_link(outbound.share_link)) + continue; + + let link = serialize_outbound_link(outbound); + if (link == "") + continue; + + outbound.share_link = link; + changed++; + } + + return changed; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function populate_subscription_file(path) { + path = as_string(path); + let data = fs.readfile(path); + if (data == null) + return false; + + let subscription; + try { + subscription = json(data); + } + catch (e) { + return false; + } + + if (populate_subscription_links(subscription) == 0) + return true; + + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); + if (fs.writefile(tmp_path, sprintf("%J\n", subscription)) == null) + return false; + + system("chmod 600 " + shell_quote(tmp_path) + " >/dev/null 2>&1"); + if (!fs.rename(tmp_path, path)) { + fs.unlink(tmp_path); + return false; + } + + return true; +} + +function populate_subscription_dir(path) { + path = as_string(path); + let ok = true; + for (let file_path in fs.glob(path + "/*.json")) + if (!populate_subscription_file(file_path)) + ok = false; + + return ok; +} + +return { + serialize_outbound_link, + is_copyable_link, + populate_subscription_links, + populate_subscription_file, + populate_subscription_dir +}; diff --git a/tests/byedpi_runtime_owner.sh b/tests/byedpi_runtime_owner.sh index 9a66750b..ef21d626 100644 --- a/tests/byedpi_runtime_owner.sh +++ b/tests/byedpi_runtime_owner.sh @@ -1,13 +1,13 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -CLI_UC="$FORKOP_BIN" -BYEDPI_RUNTIME_SH="$FORKOP_LIB/byedpi.sh" -LIFECYCLE_UC="$FORKOP_LIB/service/lifecycle.uc" -BYEDPI_RUNTIME_UC="$FORKOP_LIB/providers/byedpi/runtime.uc" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +CLI_UC="$TACHYON_BIN" +BYEDPI_RUNTIME_SH="$TACHYON_LIB/byedpi.sh" +LIFECYCLE_UC="$TACHYON_LIB/service/lifecycle.uc" +BYEDPI_RUNTIME_UC="$TACHYON_LIB/providers/byedpi/runtime.uc" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -17,15 +17,15 @@ fail() { [ ! -e "$BYEDPI_RUNTIME_SH" ] || fail "byedpi.sh shell owner must be removed" -grep -Fq '#!/usr/bin/ucode' "$FORKOP_BIN" || - fail "forkop entrypoint must be a direct ucode executable" +grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" || + fail "tachyon entrypoint must be a direct ucode executable" grep -Fq 'service/lifecycle.uc' "$CLI_UC" || fail "service/cli.uc must dispatch lifecycle orchestration through service/lifecycle.uc" grep -Fq 'providers/byedpi/runtime.uc' "$LIFECYCLE_UC" || fail "service/lifecycle.uc must call providers/byedpi/runtime.uc for ByeDPI runtime operations" if grep -R -n -E 'start_byedpi_runtime|stop_byedpi_runtime|get_byedpi_status_json|check_byedpi_runtime_json|is_byedpi_installed|get_byedpi_package_version|get_byedpi_rule_|run_byedpi_supervisor' \ - "$FORKOP_BIN" "$FORKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + "$TACHYON_BIN" "$TACHYON_LIB" --include='*.sh' >/dev/null 2>&1; then fail "ByeDPI runtime shell symbols must not remain" fi if grep -n -E 'require\("uci"\)\.cursor|uci -q|"uci", "-q"' "$BYEDPI_RUNTIME_UC" >/dev/null 2>&1; then @@ -48,7 +48,7 @@ grep -Fq 'mode == "check"' "$BYEDPI_RUNTIME_UC" || fail "providers/byedpi/runtime.uc must own ByeDPI check" BYEDPI_BIN="$ROOT_DIR/tests/missing-ciadpi" \ - ucode -L "$FORKOP_LIB" "$BYEDPI_RUNTIME_UC" check | + ucode -L "$TACHYON_LIB" "$BYEDPI_RUNTIME_UC" check | node -e ' const fs = require("fs"); const value = JSON.parse(fs.readFileSync(0, "utf8")); diff --git a/tests/byedpi_validator.sh b/tests/byedpi_validator.sh index 87627157..ef043113 100644 --- a/tests/byedpi_validator.sh +++ b/tests/byedpi_validator.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -VALIDATOR="$ROOT_DIR/forkop/files/usr/lib/providers/byedpi/validator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +VALIDATOR="$ROOT_DIR/tachyon/files/usr/lib/providers/byedpi/validator.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -74,6 +74,6 @@ if (validator.strategy_or_default("", "--default\t1") != "--default 1") exit(1); UCODE -ucode -L "$FORKOP_LIB" "$WORK_DIR/require-byedpi-validator.uc" +ucode -L "$TACHYON_LIB" "$WORK_DIR/require-byedpi-validator.uc" printf 'ByeDPI validator checks passed\n' diff --git a/tests/cli_entrypoint.sh b/tests/cli_entrypoint.sh index ebcb8f89..0dbc6629 100644 --- a/tests/cli_entrypoint.sh +++ b/tests/cli_entrypoint.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" WORK_DIR="$(mktemp -d)" cleanup() { @@ -16,23 +16,23 @@ fail() { exit 1 } -[ ! -e "$FORKOP_LIB/service/cli.uc" ] || +[ ! -e "$TACHYON_LIB/service/cli.uc" ] || fail "service/cli.uc must be removed after /usr/bin ucode entrypoint takeover" -grep -Fq '#!/usr/bin/ucode' "$FORKOP_BIN" || - fail "forkop entrypoint must be a direct ucode executable" -if grep -Fq '#!/bin/sh' "$FORKOP_BIN" || - grep -Fq '#!/bin/ash' "$FORKOP_BIN" || - grep -Fq 'exec ucode' "$FORKOP_BIN"; then - fail "forkop entrypoint must not keep a shell loader" +grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" || + fail "tachyon entrypoint must be a direct ucode executable" +if grep -Fq '#!/bin/sh' "$TACHYON_BIN" || + grep -Fq '#!/bin/ash' "$TACHYON_BIN" || + grep -Fq 'exec ucode' "$TACHYON_BIN"; then + fail "tachyon entrypoint must not keep a shell loader" fi -if grep -Fq 'FORKOP_COMMAND' "$FORKOP_BIN" || - grep -Fq 'run_module()' "$FORKOP_BIN"; then - fail "forkop entrypoint must not keep legacy shell routing symbols" +if grep -Fq 'TACHYON_COMMAND' "$TACHYON_BIN" || + grep -Fq 'run_module()' "$TACHYON_BIN"; then + fail "tachyon entrypoint must not keep legacy shell routing symbols" fi -grep -Fq 'function command_spec(command)' "$FORKOP_BIN" || - fail "forkop ucode entrypoint must own command routing" -grep -Fq 'function show_help()' "$FORKOP_BIN" || - fail "forkop ucode entrypoint must own help text" +grep -Fq 'function command_spec(command)' "$TACHYON_BIN" || + fail "tachyon ucode entrypoint must own command routing" +grep -Fq 'function show_help()' "$TACHYON_BIN" || + fail "tachyon ucode entrypoint must own help text" fake_lib="$WORK_DIR/lib" mkdir -p "$fake_lib/service" "$fake_lib/diagnostics" "$fake_lib/components" "$fake_lib/dns" @@ -55,35 +55,35 @@ UCODE cat >"$fake_lib/dns/apply.uc" <<'UCODE' #!/usr/bin/env ucode let fs = require("fs"); -let marker = getenv("FORKOP_TEST_DNS_RESTORE_MARKER"); +let marker = getenv("TACHYON_TEST_DNS_RESTORE_MARKER"); if (marker != null && marker != "") fs.writefile(marker, ARGV[0] || ""); UCODE -show_version_out="$(FORKOP_LIB="$fake_lib" ucode "$FORKOP_BIN" show_version)" +show_version_out="$(TACHYON_LIB="$fake_lib" ucode "$TACHYON_BIN" show_version)" [ "$show_version_out" = $'diagnostics\tshow-version\t\t\t' ] || fail "show_version must dispatch through diagnostics/runtime.uc" -subscription_out="$(FORKOP_LIB="$fake_lib" ucode "$FORKOP_BIN" subscription_update proxy 2)" +subscription_out="$(TACHYON_LIB="$fake_lib" ucode "$TACHYON_BIN" subscription_update proxy 2)" [ "$subscription_out" = $'updates\tsubscription-update\tproxy\t2' ] || fail "subscription_update must dispatch through components/updates.uc with arguments" -component_out="$(FORKOP_LIB="$fake_lib" ucode "$FORKOP_BIN" component_action sing_box update)" +component_out="$(TACHYON_LIB="$fake_lib" ucode "$TACHYON_BIN" component_action sing_box update)" [ "$component_out" = $'action\tcomponent-action\tsing_box\tupdate' ] || fail "component_action must dispatch through components/action.uc with arguments" -component_check_cache_out="$(FORKOP_LIB="$fake_lib" ucode "$FORKOP_BIN" component_update_check_cache)" +component_check_cache_out="$(TACHYON_LIB="$fake_lib" ucode "$TACHYON_BIN" component_update_check_cache)" [ "$component_check_cache_out" = $'updates\tcomponent-update-check-cache\t\t' ] || fail "component_update_check_cache must dispatch through components/updates.uc" -component_updates_out="$(FORKOP_LIB="$fake_lib" ucode "$FORKOP_BIN" component_updates_if_due)" +component_updates_out="$(TACHYON_LIB="$fake_lib" ucode "$TACHYON_BIN" component_updates_if_due)" [ "$component_updates_out" = $'updates\tcomponent-updates-if-due\t\t' ] || fail "component_updates_if_due must dispatch through components/updates.uc" rm -f "$fake_lib/service/lifecycle.uc" set +e -FORKOP_TEST_DNS_RESTORE_MARKER="$WORK_DIR/dns-restore.marker" FORKOP_LIB="$fake_lib" \ - ucode "$FORKOP_BIN" start >/dev/null 2>"$WORK_DIR/missing.err" +TACHYON_TEST_DNS_RESTORE_MARKER="$WORK_DIR/dns-restore.marker" TACHYON_LIB="$fake_lib" \ + ucode "$TACHYON_BIN" start >/dev/null 2>"$WORK_DIR/missing.err" status="$?" set -e [ "$status" -ne 0 ] || diff --git a/tests/component_update_check_cache.sh b/tests/component_update_check_cache.sh index 855d038c..2221846f 100644 --- a/tests/component_update_check_cache.sh +++ b/tests/component_update_check_cache.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -UPDATES_UC="$FORKOP_LIB/components/updates.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +UPDATES_UC="$TACHYON_LIB/components/updates.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -59,7 +59,7 @@ UCODE cat >"$fake_lib/components/action.uc" <<'UCODE' #!/usr/bin/env ucode -let component = ARGV[1] || "forkop"; +let component = ARGV[1] || "tachyon"; let action = ARGV[2] || "check_update"; print(sprintf("%J\n", { success: true, @@ -89,11 +89,11 @@ UCODE updates_ucode() { TEST_COMPONENT_UPDATE_CHECK_ENABLED="${TEST_COMPONENT_UPDATE_CHECK_ENABLED:-1}" \ TEST_COMPONENT_UPDATE_CHECK_INTERVAL="${TEST_COMPONENT_UPDATE_CHECK_INTERVAL:-1d}" \ - FORKOP_LIB="$fake_lib" \ - FORKOP_COMPONENT_UPDATE_CHECK_CACHE_DIR="$cache_dir" \ - FORKOP_COMPONENT_UPDATE_CHECK_STATE_FILE="$timestamp_file" \ + TACHYON_LIB="$fake_lib" \ + TACHYON_COMPONENT_UPDATE_CHECK_CACHE_DIR="$cache_dir" \ + TACHYON_COMPONENT_UPDATE_CHECK_STATE_FILE="$timestamp_file" \ UPDATES_JOB_DIR="$job_dir" \ - ucode -L "$fake_lib" -L "$FORKOP_LIB" "$UPDATES_UC" "$@" + ucode -L "$fake_lib" -L "$TACHYON_LIB" "$UPDATES_UC" "$@" } updates_ucode component-action-worker "$state_file" "$output_file" zapret check_update @@ -117,8 +117,8 @@ TEST_COMPONENT_UPDATE_CHECK_ENABLED=0 updates_ucode \ rm -f "$state_file" "$timestamp_file" updates_ucode component-updates-if-due -[ -s "$cache_dir/forkop.json" ] || - fail "automatic checks must cache the Forkop result" +[ -s "$cache_dir/tachyon.json" ] || + fail "automatic checks must cache the Tachyon result" [ -s "$timestamp_file" ] || fail "automatic checks must record their last run" diff --git a/tests/components_updater_job.sh b/tests/components_updater_job.sh index 902ce55f..4199bb8a 100644 --- a/tests/components_updater_job.sh +++ b/tests/components_updater_job.sh @@ -1,13 +1,13 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -CLI_UC="$FORKOP_BIN" -UPDATER="$ROOT_DIR/forkop/files/usr/lib/components/updater.uc" -UPDATES_UC="$ROOT_DIR/forkop/files/usr/lib/components/updates.uc" -ACTION_UC="$ROOT_DIR/forkop/files/usr/lib/components/action.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +CLI_UC="$TACHYON_BIN" +UPDATER="$ROOT_DIR/tachyon/files/usr/lib/components/updater.uc" +UPDATES_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updates.uc" +ACTION_UC="$ROOT_DIR/tachyon/files/usr/lib/components/action.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -70,7 +70,7 @@ if ucode "$UPDATER" updates-check-result-row component 1.0 2.0 invalid >/dev/nul fail "invalid check result status should be rejected" fi -[ ! -e "$ROOT_DIR/forkop/files/usr/lib/updater.sh" ] || +[ ! -e "$ROOT_DIR/tachyon/files/usr/lib/updater.sh" ] || fail "updater.sh shell owner must be removed" grep -Fq 'component_action: [ "components/action.uc", "component-action", 2 ]' "$CLI_UC" || fail "service/cli.uc must dispatch direct component_action through components/action.uc" @@ -97,8 +97,8 @@ grep -Fq 'require("core.uci")' "$ACTION_UC" || if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"|command_exists\("uci"\)' "$ACTION_UC" >/dev/null 2>&1; then fail "components/action.uc must not own direct UCI cursor or CLI calls" fi -grep -Fq 'forkop_status_running_with_timeout()' "$ACTION_UC" || - fail "components/action.uc must use bounded Forkop status checks for component actions" +grep -Fq 'tachyon_status_running_with_timeout()' "$ACTION_UC" || + fail "components/action.uc must use bounded Tachyon status checks for component actions" grep -Fq 'restore_sing_box_after_failed_package_install' "$ACTION_UC" || fail "stable and tiny sing-box package installs must restore the previous variant after validation failures" grep -Fq 'let package_spec = package_name + "=" + package_version;' "$ACTION_UC" || @@ -109,15 +109,15 @@ grep -Fq '!file_exists("/etc/init.d/sing-box") && file_nonempty("/usr/bin/sing-b fail "binary rollback must restore a managed sing-box service when the package script is missing" grep -Fq 'fail_package_sing_box_install(action, tiny, "package was installed, but sing-box binary is not available"' "$ACTION_UC" || fail "stable and tiny sing-box binary validation failures must use transactional rollback" -grep -Fq 'fail_package_sing_box_install(action, tiny, "was installed, but Forkop did not start cleanly"' "$ACTION_UC" || +grep -Fq 'fail_package_sing_box_install(action, tiny, "was installed, but Tachyon did not start cleanly"' "$ACTION_UC" || fail "stable and tiny sing-box startup failures must use transactional rollback" if grep -Fq 'command_success_from_args([ SERVICE_INIT, "status" ])' "$ACTION_UC"; then fail "components/action.uc must not call init.d status without a timeout" fi -status_timeout_line="$(grep -n '^function forkop_status_running_with_timeout()' "$ACTION_UC" | cut -d: -f1)" -capture_state_line="$(grep -n '^function capture_forkop_running_state()' "$ACTION_UC" | cut -d: -f1)" +status_timeout_line="$(grep -n '^function tachyon_status_running_with_timeout()' "$ACTION_UC" | cut -d: -f1)" +capture_state_line="$(grep -n '^function capture_tachyon_running_state()' "$ACTION_UC" | cut -d: -f1)" [ -n "$status_timeout_line" ] && [ -n "$capture_state_line" ] && [ "$status_timeout_line" -lt "$capture_state_line" ] || - fail "components/action.uc must declare bounded status helper before capture_forkop_running_state for OpenWrt ucode" + fail "components/action.uc must declare bounded status helper before capture_tachyon_running_state for OpenWrt ucode" init_line="$(grep -n '^function init_tmp_dir()' "$ACTION_UC" | cut -d: -f1)" tmp_file_line="$(grep -n '^function make_tmp_file' "$ACTION_UC" | cut -d: -f1)" [ -n "$init_line" ] && [ -n "$tmp_file_line" ] && [ "$init_line" -lt "$tmp_file_line" ] || @@ -142,7 +142,7 @@ assert_eq "extracted payload" "$(cat "$command_success_output")" \ awk ' prev2 == " remove_file(archive_file);" && -prev1 == " stop_forkop_before_sing_box_change();" && +prev1 == " stop_tachyon_before_sing_box_change();" && $0 == " let new_version = validate_sing_box_extended_binary(tmp_binary, tmp_dir);" { safe_validation = 1 } @@ -159,7 +159,7 @@ if grep -Fq 'fs.rename(tmp_binary, "/usr/bin/sing-box")' "$ACTION_UC"; then fi grep -Fq 'let backup_on_tmpfs = previous_variant == "extended-compressed";' "$ACTION_UC" || fail "package sing-box installs must move compressed backups off overlay before opkg space checks" -grep -Fq 'backup_binary = backup_on_tmpfs ? tmp_dir + "/sing-box.forkop-backup"' "$ACTION_UC" || +grep -Fq 'backup_binary = backup_on_tmpfs ? tmp_dir + "/sing-box.tachyon-backup"' "$ACTION_UC" || fail "compressed sing-box backup must use tmpfs while installing a package variant" grep -Fq 'return move_file_portable(backup_binary, "/usr/bin/sing-box");' "$ACTION_UC" && fail "portable sing-box restore must preserve executable permissions explicitly" @@ -244,10 +244,10 @@ chmod +x "$package_runtime_bin/opkg" "$package_runtime_bin/logger" set +e PATH="$package_runtime_bin:$PATH" \ -FORKOP_LIB="$package_runtime_lib" \ -FORKOP_RUNTIME_STATE_DIR="$WORK_DIR/package-runtime" \ -FORKOP_BIN="$WORK_DIR/missing-forkop" \ -FORKOP_SERVICE_INIT="$WORK_DIR/missing-init" \ +TACHYON_LIB="$package_runtime_lib" \ +TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/package-runtime" \ +TACHYON_BIN="$WORK_DIR/missing-tachyon" \ +TACHYON_SERVICE_INIT="$WORK_DIR/missing-init" \ FAKE_OPKG_LOG="$WORK_DIR/opkg.log" \ FAKE_OPKG_UPDATED="$WORK_DIR/opkg.updated" \ ucode -L "$package_runtime_lib" "$ACTION_UC" component-action sing_box install_stable >/dev/null @@ -288,10 +288,10 @@ SH chmod +x "$package_runtime_bin/apk" set +e PATH="$package_runtime_bin:$PATH" \ -FORKOP_LIB="$package_runtime_lib" \ -FORKOP_RUNTIME_STATE_DIR="$WORK_DIR/apk-package-runtime" \ -FORKOP_BIN="$WORK_DIR/missing-forkop" \ -FORKOP_SERVICE_INIT="$WORK_DIR/missing-init" \ +TACHYON_LIB="$package_runtime_lib" \ +TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/apk-package-runtime" \ +TACHYON_BIN="$WORK_DIR/missing-tachyon" \ +TACHYON_SERVICE_INIT="$WORK_DIR/missing-init" \ FAKE_APK_LOG="$WORK_DIR/apk.log" \ ucode -L "$package_runtime_lib" "$ACTION_UC" component-action sing_box install_stable >/dev/null set -e @@ -306,47 +306,47 @@ release_json="$(cat <<'JSON' "tag_name": "1.2.3", "html_url": "https://example.com/release", "assets": [ - {"name": "forkop_1.2.3.ipk", "browser_download_url": "https://example.com/backend.ipk"}, - {"name": "luci-app-forkop_1.2.3.ipk", "browser_download_url": "https://example.com/app.ipk"}, - {"name": "luci-i18n-forkop-ru_1.2.3.ipk", "browser_download_url": "https://example.com/i18n.ipk"} + {"name": "tachyon_1.2.3.ipk", "browser_download_url": "https://example.com/backend.ipk"}, + {"name": "luci-app-tachyon_1.2.3.ipk", "browser_download_url": "https://example.com/app.ipk"}, + {"name": "luci-i18n-tachyon-ru_1.2.3.ipk", "browser_download_url": "https://example.com/i18n.ipk"} ] } JSON )" -assert_eq "$(printf 'https://example.com/release\tforkop_1.2.3.ipk\thttps://example.com/backend.ipk\tluci-app-forkop_1.2.3.ipk\thttps://example.com/app.ipk\tluci-i18n-forkop-ru_1.2.3.ipk\thttps://example.com/i18n.ipk')" \ - "$(printf '%s' "$release_json" | ucode "$UPDATER" forkop-release-plan 1.2.3 ipk 1)" \ - "forkop release plan with i18n" -assert_eq "$(printf 'https://example.com/release\tforkop_1.2.3.ipk\thttps://example.com/backend.ipk\tluci-app-forkop_1.2.3.ipk\thttps://example.com/app.ipk\t\t')" \ - "$(printf '%s' "$release_json" | ucode "$UPDATER" forkop-release-plan 1.2.3 ipk 0)" \ - "forkop release plan without i18n" -if printf '%s' "$release_json" | ucode "$UPDATER" forkop-release-plan 9.9.9 ipk 0 >/dev/null 2>&1; then - fail "forkop release plan should reject tag mismatch" +assert_eq "$(printf 'https://example.com/release\ttachyon_1.2.3.ipk\thttps://example.com/backend.ipk\tluci-app-tachyon_1.2.3.ipk\thttps://example.com/app.ipk\tluci-i18n-tachyon-ru_1.2.3.ipk\thttps://example.com/i18n.ipk')" \ + "$(printf '%s' "$release_json" | ucode "$UPDATER" tachyon-release-plan 1.2.3 ipk 1)" \ + "tachyon release plan with i18n" +assert_eq "$(printf 'https://example.com/release\ttachyon_1.2.3.ipk\thttps://example.com/backend.ipk\tluci-app-tachyon_1.2.3.ipk\thttps://example.com/app.ipk\t\t')" \ + "$(printf '%s' "$release_json" | ucode "$UPDATER" tachyon-release-plan 1.2.3 ipk 0)" \ + "tachyon release plan without i18n" +if printf '%s' "$release_json" | ucode "$UPDATER" tachyon-release-plan 9.9.9 ipk 0 >/dev/null 2>&1; then + fail "tachyon release plan should reject tag mismatch" fi missing_i18n_json="$(cat <<'JSON' { "tag_name": "1.2.3", "html_url": "https://example.com/release", "assets": [ - {"name": "forkop_1.2.3.ipk", "browser_download_url": "https://example.com/backend.ipk"}, - {"name": "luci-app-forkop_1.2.3.ipk", "browser_download_url": "https://example.com/app.ipk"} + {"name": "tachyon_1.2.3.ipk", "browser_download_url": "https://example.com/backend.ipk"}, + {"name": "luci-app-tachyon_1.2.3.ipk", "browser_download_url": "https://example.com/app.ipk"} ] } JSON )" -if printf '%s' "$missing_i18n_json" | ucode "$UPDATER" forkop-release-plan 1.2.3 ipk 1 >/dev/null 2>&1; then - fail "forkop release plan should require i18n asset when requested" +if printf '%s' "$missing_i18n_json" | ucode "$UPDATER" tachyon-release-plan 1.2.3 ipk 1 >/dev/null 2>&1; then + fail "tachyon release plan should require i18n asset when requested" fi -ucode "$UPDATER" forkop-release-version-valid 1.2.3 || - fail "three-part Forkop release version should be valid" +ucode "$UPDATER" tachyon-release-version-valid 1.2.3 || + fail "three-part Tachyon release version should be valid" for invalid_version in v1.2.3 1.2 1.2.3.4 1.2.3-1 1.2.3-r1; do - if ucode "$UPDATER" forkop-release-version-valid "$invalid_version" >/dev/null 2>&1; then - fail "invalid Forkop release version was accepted: $invalid_version" + if ucode "$UPDATER" tachyon-release-version-valid "$invalid_version" >/dev/null 2>&1; then + fail "invalid Tachyon release version was accepted: $invalid_version" fi done -assert_eq "-1" "$(ucode "$UPDATER" forkop-release-version-compare 1.2.3 1.2.4)" \ - "three-part Forkop release version comparison" +assert_eq "-1" "$(ucode "$UPDATER" tachyon-release-version-compare 1.2.3 1.2.4)" \ + "three-part Tachyon release version comparison" assert_eq "1.13.14-extended-2.5.0" \ "$(printf 'sing-box version 1.13.14-extended-2.5.0\n\nEnvironment: go1.26.4 linux/amd64\n' | ucode "$UPDATER" stdin-first-line-last-field)" \ @@ -356,9 +356,9 @@ component_actions_dir="$WORK_DIR/component-actions" fake_lib="$WORK_DIR/lib" mkdir -p "$fake_lib/components" "$fake_lib/config" "$fake_lib/core" cp "$UPDATES_UC" "$fake_lib/components/updates.uc" -cp "$FORKOP_LIB/config/connections.uc" "$fake_lib/config/connections.uc" -cp "$FORKOP_LIB/core/uci.uc" "$fake_lib/core/uci.uc" -cp "$FORKOP_LIB/core/common.uc" "$fake_lib/core/common.uc" +cp "$TACHYON_LIB/config/connections.uc" "$fake_lib/config/connections.uc" +cp "$TACHYON_LIB/core/uci.uc" "$fake_lib/core/uci.uc" +cp "$TACHYON_LIB/core/common.uc" "$fake_lib/core/common.uc" cat >"$fake_lib/components/action.uc" <<'UCODE' #!/usr/bin/env ucode if ((ARGV[0] || "") == "component-action") { @@ -368,8 +368,8 @@ if ((ARGV[0] || "") == "component-action") { exit(1); UCODE -start_json="$(FORKOP_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ - ucode -L "$FORKOP_LIB" "$UPDATES_UC" component-action-async sing_box check_update)" +start_json="$(TACHYON_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ + ucode -L "$TACHYON_LIB" "$UPDATES_UC" component-action-async sing_box check_update)" job_id="$(JSON_VALUE="$start_json" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (!value.success || !value.job_id) { @@ -382,8 +382,8 @@ NODE status_json="" for _ in 1 2 3 4 5; do - status_json="$(FORKOP_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ - ucode -L "$FORKOP_LIB" "$UPDATES_UC" component-action-status "$job_id")" + status_json="$(TACHYON_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ + ucode -L "$TACHYON_LIB" "$UPDATES_UC" component-action-status "$job_id")" JSON_VALUE="$status_json" node - <<'NODE' && break || true const value = JSON.parse(process.env.JSON_VALUE); process.exit(value.running === false ? 0 : 1); @@ -400,8 +400,8 @@ if (value.running !== false || value.success !== true || value.component !== "si } NODE -start_json="$(FORKOP_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ - ucode -L "$FORKOP_LIB" "$UPDATES_UC" component-action-async sing-box check_update)" +start_json="$(TACHYON_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ + ucode -L "$TACHYON_LIB" "$UPDATES_UC" component-action-async sing-box check_update)" job_id="$(JSON_VALUE="$start_json" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (!value.success || !value.job_id) { @@ -414,8 +414,8 @@ NODE status_json="" for _ in 1 2 3 4 5; do - status_json="$(FORKOP_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ - ucode -L "$FORKOP_LIB" "$UPDATES_UC" component-action-status "$job_id")" + status_json="$(TACHYON_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ + ucode -L "$TACHYON_LIB" "$UPDATES_UC" component-action-status "$job_id")" JSON_VALUE="$status_json" node - <<'NODE' && break || true const value = JSON.parse(process.env.JSON_VALUE); process.exit(value.running === false ? 0 : 1); diff --git a/tests/config_contract_matrix.sh b/tests/config_contract_matrix.sh index da00df4d..285cc00e 100644 --- a/tests/config_contract_matrix.sh +++ b/tests/config_contract_matrix.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -STABLE_REF="${FORKOP_STABLE_REF:-0.7.19.9}" -STABLE_REPO="${FORKOP_STABLE_REPO:-}" +STABLE_REF="${TACHYON_STABLE_REF:-0.7.19.9}" +STABLE_REPO="${TACHYON_STABLE_REPO:-}" MATRIX_SCRIPT="$ROOT_DIR/tests/helpers/config_contract_matrix.js" WORK_DIR="$(mktemp -d)" LEGACY_STEM="$(printf '\160\157\144\153\157\160')" @@ -29,7 +29,7 @@ ensure_stable_ref() { prepare_stable_repo() { if [ -n "$STABLE_REPO" ]; then - if [ ! -r "$STABLE_REPO/forkop/files/etc/config/forkop" ] && + if [ ! -r "$STABLE_REPO/tachyon/files/etc/config/tachyon" ] && [ ! -r "$STABLE_REPO/$LEGACY_STEM/files/etc/config/$LEGACY_STEM" ]; then fail "stable repo is missing the expected config template: $STABLE_REPO" fi diff --git a/tests/config_migration.sh b/tests/config_migration.sh index e63e5ece..f751bcee 100644 --- a/tests/config_migration.sh +++ b/tests/config_migration.sh @@ -1,13 +1,13 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -RUNTIME_MIGRATION="$FORKOP_LIB/config/migration.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +RUNTIME_MIGRATION="$TACHYON_LIB/config/migration.uc" INSTALLER="$ROOT_DIR/install.sh" WORK_DIR="$(mktemp -d)" MIGRATION="$RUNTIME_MIGRATION" -MIGRATIONS_DIR="$FORKOP_LIB/config/migrations" +MIGRATIONS_DIR="$TACHYON_LIB/config/migrations" cleanup() { rm -rf "$WORK_DIR" @@ -19,7 +19,7 @@ fail() { exit 1 } -if grep -n -E 'FORKOP_CONFIG_MIGRATION_EOF|installer_config_migration_path' "$INSTALLER" >/dev/null 2>&1; then +if grep -n -E 'TACHYON_CONFIG_MIGRATION_EOF|installer_config_migration_path' "$INSTALLER" >/dev/null 2>&1; then fail "install.sh must not embed configuration migration logic" fi [ -s "$MIGRATION" ] || fail "runtime configuration migration module is missing" @@ -37,7 +37,7 @@ grep -Fq '{ id: "enable_component_checks", run: migrate_enable_component_checks grep -Fq 'release_at_most(ctx, "1.0.1")' "$MIGRATION" || fail "release-specific migrations must use the source release only as a condition" -eval "$(ucode -L "$FORKOP_LIB" "$FORKOP_LIB/core/constants.uc" shell-env)" +eval "$(ucode -L "$TACHYON_LIB" "$TACHYON_LIB/core/constants.uc" shell-env)" export ZAPRET_LEGACY_DEFAULT_NFQWS_OPT ZAPRET_DEFAULT_NFQWS_OPT node >"$WORK_DIR/fixture.json" <<'NODE' @@ -155,7 +155,7 @@ const fixture = { process.stdout.write(`${JSON.stringify(fixture, null, 2)}\n`); NODE -FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/fixture.json" podkop >"$WORK_DIR/output.json" +TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/fixture.json" podkop >"$WORK_DIR/output.json" node - "$WORK_DIR/output.json" <<'NODE' const fs = require('fs'); @@ -350,10 +350,10 @@ assert(legacyOutboundCollision.outbound_jsons.length === 2, 'legacy JSON outboun assert(JSON.parse(legacyOutboundCollision.outbound_jsons[1]).tag === 'legacy-outbound-collision-json-2-out-1', 'legacy JSON outbound tag avoids existing list tags'); assert(out.removed_caches.includes('/tmp/sing-box/subscriptions/legacy-sub.json'), 'subscription runtime cache removal'); -assert(out.removed_caches.includes('/var/run/forkop/section-cache/legacy-sub.json'), 'section cache removal'); +assert(out.removed_caches.includes('/var/run/tachyon/section-cache/legacy-sub.json'), 'section cache removal'); NODE -cat >"$WORK_DIR/forkop-1.0.1.json" <<'JSON' +cat >"$WORK_DIR/tachyon-1.0.1.json" <<'JSON' { "settings": { ".name": "settings", @@ -372,9 +372,9 @@ cat >"$WORK_DIR/forkop-1.0.1.json" <<'JSON' } JSON -FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/forkop-1.0.1.json" >"$WORK_DIR/forkop-1.0.2.json" +TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/tachyon-1.0.1.json" >"$WORK_DIR/tachyon-1.0.2.json" -node - "$WORK_DIR/forkop-1.0.2.json" <<'NODE' +node - "$WORK_DIR/tachyon-1.0.2.json" <<'NODE' const fs = require('fs'); const out = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); const section = out.config.section[0]; @@ -401,7 +401,7 @@ for (const item of interfaces) { } NODE -cat >"$WORK_DIR/forkop-1.0.2-disabled.json" <<'JSON' +cat >"$WORK_DIR/tachyon-1.0.2-disabled.json" <<'JSON' { "settings": { ".name": "settings", @@ -413,9 +413,9 @@ cat >"$WORK_DIR/forkop-1.0.2-disabled.json" <<'JSON' } JSON -FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/forkop-1.0.2-disabled.json" >"$WORK_DIR/forkop-1.0.2-disabled-output.json" +TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/tachyon-1.0.2-disabled.json" >"$WORK_DIR/tachyon-1.0.2-disabled-output.json" -node - "$WORK_DIR/forkop-1.0.2-disabled-output.json" <<'NODE' +node - "$WORK_DIR/tachyon-1.0.2-disabled-output.json" <<'NODE' const fs = require('fs'); const out = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); @@ -431,100 +431,100 @@ assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify NODE cat >"$WORK_DIR/runtime-migrate.state" <<'EOF_UCI' -forkop.settings=settings -forkop.settings.routing_excluded_ips=192.0.2.0/24 -forkop.legacy=rule -forkop.legacy.enabled=1 -forkop.legacy.connection_type=proxy -forkop.legacy.proxy_config_type=url -forkop.legacy.proxy_string=vless://one -forkop.old_direct=section -forkop.old_direct.enabled=1 -forkop.old_direct.action=direct -forkop.vpn=section -forkop.vpn.enabled=1 -forkop.vpn.connection_type=vpn -forkop.vpn.interface=awg0 -forkop.vpn.domain_resolver_enabled=1 -forkop.vpn.domain_resolver_dns_type=doh -forkop.vpn.domain_resolver_dns_server=https://dns.example/dns-query +tachyon.settings=settings +tachyon.settings.routing_excluded_ips=192.0.2.0/24 +tachyon.legacy=rule +tachyon.legacy.enabled=1 +tachyon.legacy.connection_type=proxy +tachyon.legacy.proxy_config_type=url +tachyon.legacy.proxy_string=vless://one +tachyon.old_direct=section +tachyon.old_direct.enabled=1 +tachyon.old_direct.action=direct +tachyon.vpn=section +tachyon.vpn.enabled=1 +tachyon.vpn.connection_type=vpn +tachyon.vpn.interface=awg0 +tachyon.vpn.domain_resolver_enabled=1 +tachyon.vpn.domain_resolver_dns_type=doh +tachyon.vpn.domain_resolver_dns_server=https://dns.example/dns-query EOF_UCI mkdir -p "$WORK_DIR/runtime" "$WORK_DIR/persistent-cache" : >"$WORK_DIR/runtime-migrate.log" -FORKOP_UCI_STATE_FILE="$WORK_DIR/runtime-migrate.state" \ -FORKOP_UCI_LOG_FILE="$WORK_DIR/runtime-migrate.log" \ -FORKOP_CONFIG_NAME="forkop" \ +TACHYON_UCI_STATE_FILE="$WORK_DIR/runtime-migrate.state" \ +TACHYON_UCI_LOG_FILE="$WORK_DIR/runtime-migrate.log" \ +TACHYON_CONFIG_NAME="tachyon" \ TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/tmp-subscriptions" \ -FORKOP_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \ -FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent-cache" \ -FORKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ -ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-podkop +TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \ +TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent-cache" \ +TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ +ucode -L "$TACHYON_LIB" "$MIGRATION" migrate-podkop -grep -Fxq 'forkop.legacy=section' "$WORK_DIR/runtime-migrate.state" || +grep -Fxq 'tachyon.legacy=section' "$WORK_DIR/runtime-migrate.state" || fail "runtime migration must convert legacy rule type through core.uci" -grep -Fxq 'forkop.legacy.action=connection' "$WORK_DIR/runtime-migrate.state" || +grep -Fxq 'tachyon.legacy.action=connection' "$WORK_DIR/runtime-migrate.state" || fail "runtime migration must write migrated action through core.uci" -grep -Fxq 'forkop.old_direct.action=bypass' "$WORK_DIR/runtime-migrate.state" || +grep -Fxq 'tachyon.old_direct.action=bypass' "$WORK_DIR/runtime-migrate.state" || fail "runtime migration must convert direct action to bypass through core.uci" -grep -Eq '^forkop\\.cfg[0-9a-f]+=$' "$WORK_DIR/runtime-migrate.state" && fail "anonymous fixture section should include a type" -grep -Fxq 'forkop.legacy.selector_proxy_links=vless://one' "$WORK_DIR/runtime-migrate.state" || +grep -Eq '^tachyon\\.cfg[0-9a-f]+=$' "$WORK_DIR/runtime-migrate.state" && fail "anonymous fixture section should include a type" +grep -Fxq 'tachyon.legacy.selector_proxy_links=vless://one' "$WORK_DIR/runtime-migrate.state" || fail "runtime migration must keep connection URLs in the parent section" if grep -Fq '=connection_url' "$WORK_DIR/runtime-migrate.state"; then fail "runtime migration must not create connection_url child sections" fi -grep -Eq '^forkop\.cfg[0-9a-f]+=section_interface$' "$WORK_DIR/runtime-migrate.state" || +grep -Eq '^tachyon\.cfg[0-9a-f]+=section_interface$' "$WORK_DIR/runtime-migrate.state" || fail "runtime migration must create an anonymous interface settings section" -grep -Eq '^forkop\.cfg[0-9a-f]+\.section=vpn$' "$WORK_DIR/runtime-migrate.state" || +grep -Eq '^tachyon\.cfg[0-9a-f]+\.section=vpn$' "$WORK_DIR/runtime-migrate.state" || fail "runtime interface settings section must reference its parent" -grep -Eq '^forkop\.cfg[0-9a-f]+\.name=' "$WORK_DIR/runtime-migrate.state" || +grep -Eq '^tachyon\.cfg[0-9a-f]+\.name=' "$WORK_DIR/runtime-migrate.state" || fail "runtime interface settings section must store the interface name" -if grep -Fq 'forkop.legacy.urltest_enabled=' "$WORK_DIR/runtime-migrate.state"; then +if grep -Fq 'tachyon.legacy.urltest_enabled=' "$WORK_DIR/runtime-migrate.state"; then fail "runtime migration must delete migrated urltest_enabled through core.uci" fi -if grep -Fq 'forkop.settings.routing_excluded_ips=' "$WORK_DIR/runtime-migrate.state"; then +if grep -Fq 'tachyon.settings.routing_excluded_ips=' "$WORK_DIR/runtime-migrate.state"; then fail "runtime migration must delete removed routing_excluded_ips through core.uci" fi -if grep -Fq 'forkop.legacy.proxy_string=' "$WORK_DIR/runtime-migrate.state"; then +if grep -Fq 'tachyon.legacy.proxy_string=' "$WORK_DIR/runtime-migrate.state"; then fail "runtime migration must delete legacy proxy_string through core.uci" fi -grep -Fxq 'commit forkop' "$WORK_DIR/runtime-migrate.log" || +grep -Fxq 'commit tachyon' "$WORK_DIR/runtime-migrate.log" || fail "runtime migration must commit through core.uci" cat >"$WORK_DIR/runtime-version.state" <<'EOF_UCI' -forkop.settings=settings -forkop.settings.config_version=1.0.1 -forkop.settings.component_update_check_enabled=0 -forkop.main=section -forkop.main.enabled=1 -forkop.main.action=connection -forkop.main.interfaces=awg0 +tachyon.settings=settings +tachyon.settings.config_version=1.0.1 +tachyon.settings.component_update_check_enabled=0 +tachyon.main=section +tachyon.main.enabled=1 +tachyon.main.action=connection +tachyon.main.interfaces=awg0 EOF_UCI : >"$WORK_DIR/runtime-version.log" -FORKOP_UCI_STATE_FILE="$WORK_DIR/runtime-version.state" \ -FORKOP_UCI_LOG_FILE="$WORK_DIR/runtime-version.log" \ -FORKOP_CONFIG_NAME="forkop" \ -FORKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ -ucode -L "$FORKOP_LIB" "$MIGRATION" migrate +TACHYON_UCI_STATE_FILE="$WORK_DIR/runtime-version.state" \ +TACHYON_UCI_LOG_FILE="$WORK_DIR/runtime-version.log" \ +TACHYON_CONFIG_NAME="tachyon" \ +TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ +ucode -L "$TACHYON_LIB" "$MIGRATION" migrate -grep -Fxq 'forkop.settings.config_version=1.0.2' "$WORK_DIR/runtime-version.state" || +grep -Fxq 'tachyon.settings.config_version=1.0.2' "$WORK_DIR/runtime-version.state" || fail "runtime version migration must advance config_version" -grep -Fxq 'forkop.settings.component_update_check_enabled=1' "$WORK_DIR/runtime-version.state" || +grep -Fxq 'tachyon.settings.component_update_check_enabled=1' "$WORK_DIR/runtime-version.state" || fail "runtime version migration must enable component update checks" -grep -Fq 'forkop.settings.applied_migrations=interface_sections enable_component_checks' "$WORK_DIR/runtime-version.state" || +grep -Fq 'tachyon.settings.applied_migrations=interface_sections enable_component_checks' "$WORK_DIR/runtime-version.state" || fail "runtime migration must record stable migration names" -grep -Eq '^forkop\.cfg[0-9a-f]+=section_interface$' "$WORK_DIR/runtime-version.state" || +grep -Eq '^tachyon\.cfg[0-9a-f]+=section_interface$' "$WORK_DIR/runtime-version.state" || fail "runtime version migration must create interface child settings" -if grep -Fq 'forkop.main.interfaces=' "$WORK_DIR/runtime-version.state"; then +if grep -Fq 'tachyon.main.interfaces=' "$WORK_DIR/runtime-version.state"; then fail "runtime version migration must remove the parent interface list" fi : >"$WORK_DIR/runtime-version.log" -FORKOP_UCI_STATE_FILE="$WORK_DIR/runtime-version.state" \ -FORKOP_UCI_LOG_FILE="$WORK_DIR/runtime-version.log" \ -FORKOP_CONFIG_NAME="forkop" \ -FORKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ -ucode -L "$FORKOP_LIB" "$MIGRATION" migrate -if grep -Fq 'commit forkop' "$WORK_DIR/runtime-version.log"; then +TACHYON_UCI_STATE_FILE="$WORK_DIR/runtime-version.state" \ +TACHYON_UCI_LOG_FILE="$WORK_DIR/runtime-version.log" \ +TACHYON_CONFIG_NAME="tachyon" \ +TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ +ucode -L "$TACHYON_LIB" "$MIGRATION" migrate +if grep -Fq 'commit tachyon' "$WORK_DIR/runtime-version.log"; then fail "completed named migrations must be idempotent" fi @@ -552,14 +552,14 @@ cat >"$WORK_DIR/cache-migration/persistent/proxy-subscription-1.json" <<'JSON' } JSON -FORKOP_UCI_STATE_FILE="$WORK_DIR/runtime-version.state" \ -FORKOP_UCI_LOG_FILE="$WORK_DIR/runtime-version.log" \ -FORKOP_CONFIG_NAME="forkop" \ +TACHYON_UCI_STATE_FILE="$WORK_DIR/runtime-version.state" \ +TACHYON_UCI_LOG_FILE="$WORK_DIR/runtime-version.log" \ +TACHYON_CONFIG_NAME="tachyon" \ TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/cache-migration/tmp-subscriptions" \ -FORKOP_RUNTIME_STATE_DIR="$WORK_DIR/cache-migration/runtime" \ -FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/cache-migration/persistent" \ -FORKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ -ucode -L "$FORKOP_LIB" "$MIGRATION" migrate +TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/cache-migration/runtime" \ +TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/cache-migration/persistent" \ +TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ +ucode -L "$TACHYON_LIB" "$MIGRATION" migrate [ "$(sed -n '1p' "$WORK_DIR/cache-migration/runtime/cache-format")" = "8" ] || fail "package migration must advance the runtime cache format" @@ -579,13 +579,13 @@ NODE : >"$WORK_DIR/uci-commit.log" : >"$WORK_DIR/uci-commit.state" -FORKOP_UCI_STATE_FILE="$WORK_DIR/uci-commit.state" \ -FORKOP_UCI_LOG_FILE="$WORK_DIR/uci-commit.log" \ -FORKOP_CONFIG_NAME="forkop" \ -FORKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ -ucode -L "$FORKOP_LIB" "$MIGRATION" commit +TACHYON_UCI_STATE_FILE="$WORK_DIR/uci-commit.state" \ +TACHYON_UCI_LOG_FILE="$WORK_DIR/uci-commit.log" \ +TACHYON_CONFIG_NAME="tachyon" \ +TACHYON_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ +ucode -L "$TACHYON_LIB" "$MIGRATION" commit -grep -Fxq 'commit forkop' "$WORK_DIR/uci-commit.log" || - fail "commit mode must commit forkop through core.uci" +grep -Fxq 'commit tachyon' "$WORK_DIR/uci-commit.log" || + fail "commit mode must commit tachyon through core.uci" printf 'installer config migration checks passed\n' diff --git a/tests/config_validation_owner.sh b/tests/config_validation_owner.sh index 75a0d243..9058554d 100644 --- a/tests/config_validation_owner.sh +++ b/tests/config_validation_owner.sh @@ -1,31 +1,31 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_FILES="$ROOT_DIR/forkop/files" -FORKOP_BIN="$FORKOP_FILES/usr/bin/forkop" -FORKOP_LIB="$FORKOP_FILES/usr/lib" -CLI_UC="$FORKOP_BIN" -VALIDATOR="$FORKOP_LIB/config/validator.uc" -RULE_CONFIG="$FORKOP_LIB/config/rule.uc" -GENERATOR="$FORKOP_LIB/singbox/generator.uc" -SECTION_JS="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js" -LIFECYCLE="$FORKOP_LIB/service/lifecycle.uc" +TACHYON_FILES="$ROOT_DIR/tachyon/files" +TACHYON_BIN="$TACHYON_FILES/usr/bin/tachyon" +TACHYON_LIB="$TACHYON_FILES/usr/lib" +CLI_UC="$TACHYON_BIN" +VALIDATOR="$TACHYON_LIB/config/validator.uc" +RULE_CONFIG="$TACHYON_LIB/config/rule.uc" +GENERATOR="$TACHYON_LIB/singbox/generator.uc" +SECTION_JS="$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" +LIFECYCLE="$TACHYON_LIB/service/lifecycle.uc" fail() { printf 'FAIL: %s\n' "$1" >&2 exit 1 } -[ ! -e "$FORKOP_LIB/config_validation.sh" ] || +[ ! -e "$TACHYON_LIB/config_validation.sh" ] || fail "config_validation.sh shell owner must be removed" -if grep -R -n "config_validation.sh" "$FORKOP_FILES" >/dev/null 2>&1; then +if grep -R -n "config_validation.sh" "$TACHYON_FILES" >/dev/null 2>&1; then fail "runtime files must not reference config_validation.sh" fi -legacy_symbols='(^|[^A-Za-z0-9_])(config_validate_runtime|check_requirements|commit_forkop_config|mwan3_is_active|get_inline_remote_ruleset_format|detect_inline_ruleset_reference_kind)([^A-Za-z0-9_]|$)' -if grep -R -n -E "$legacy_symbols" "$FORKOP_BIN" "$FORKOP_LIB" --include='*.sh' >/dev/null 2>&1; then +legacy_symbols='(^|[^A-Za-z0-9_])(config_validate_runtime|check_requirements|commit_tachyon_config|mwan3_is_active|get_inline_remote_ruleset_format|detect_inline_ruleset_reference_kind)([^A-Za-z0-9_]|$)' +if grep -R -n -E "$legacy_symbols" "$TACHYON_BIN" "$TACHYON_LIB" --include='*.sh' >/dev/null 2>&1; then fail "runtime shell must not keep config_validation.sh symbols" fi @@ -46,8 +46,8 @@ grep -Fq 'return appendUniqueDomainTextValues(textValue, values);' "$SECTION_JS" if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"|command_output\("uci' "$VALIDATOR" >/dev/null 2>&1; then fail "config validator must not own direct UCI cursor or CLI access" fi -grep -Fq '#!/usr/bin/ucode' "$FORKOP_BIN" || - fail "forkop entrypoint must be a direct ucode executable" +grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" || + fail "tachyon entrypoint must be a direct ucode executable" grep -Fq 'service/lifecycle.uc' "$CLI_UC" || fail "service/cli.uc must dispatch service lifecycle through service/lifecycle.uc" if grep -n -E 'MIGRATION_UC|config[./]migration|"migrate"' "$LIFECYCLE" >/dev/null 2>&1; then diff --git a/tests/config_validator_detour.sh b/tests/config_validator_detour.sh index 15eab1b6..2df60a56 100644 --- a/tests/config_validator_detour.sh +++ b/tests/config_validator_detour.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -VALIDATOR="$ROOT_DIR/forkop/files/usr/lib/config/validator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +VALIDATOR="$ROOT_DIR/tachyon/files/usr/lib/config/validator.uc" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -20,7 +20,7 @@ assert_rejects() { shift 2 local output - if output="$(rows "$@" | ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-outbound-detours 2>/dev/null)"; then + if output="$(rows "$@" | ucode -L "$TACHYON_LIB" "$VALIDATOR" validate-outbound-detours 2>/dev/null)"; then fail "$label should be rejected" fi @@ -31,7 +31,7 @@ assert_rejects() { rows \ source 1 connection 1 target \ target 1 connection 0 '' | - ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-outbound-detours + ucode -L "$TACHYON_LIB" "$VALIDATOR" validate-outbound-detours assert_rejects "unsupported source action" "supported only for Connection rules" \ source 1 zapret 1 target \ diff --git a/tests/config_validator_download_section.sh b/tests/config_validator_download_section.sh index eb462b88..2ec117e1 100644 --- a/tests/config_validator_download_section.sh +++ b/tests/config_validator_download_section.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -VALIDATOR="$ROOT_DIR/forkop/files/usr/lib/config/validator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +VALIDATOR="$ROOT_DIR/tachyon/files/usr/lib/config/validator.uc" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -21,7 +21,7 @@ assert_accepts() { local zapret2="$4" shift 4 - rows "$@" | ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-download-section "$target" "$byedpi" "$zapret" "$zapret2" + rows "$@" | ucode -L "$TACHYON_LIB" "$VALIDATOR" validate-download-section "$target" "$byedpi" "$zapret" "$zapret2" } assert_rejects() { @@ -34,7 +34,7 @@ assert_rejects() { shift 6 local output - if output="$(rows "$@" | ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-download-section "$target" "$byedpi" "$zapret" "$zapret2" 2>/dev/null)"; then + if output="$(rows "$@" | ucode -L "$TACHYON_LIB" "$VALIDATOR" validate-download-section "$target" "$byedpi" "$zapret" "$zapret2" 2>/dev/null)"; then fail "$label should be rejected" fi diff --git a/tests/config_validator_reference.sh b/tests/config_validator_reference.sh index 5681e48a..9af92e33 100644 --- a/tests/config_validator_reference.sh +++ b/tests/config_validator_reference.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -VALIDATOR="$ROOT_DIR/forkop/files/usr/lib/config/validator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +VALIDATOR="$ROOT_DIR/tachyon/files/usr/lib/config/validator.uc" COMMUNITY_SERVICES="youtube twitter telegram" fail() { @@ -15,7 +15,7 @@ assert_accepts() { local label="$1" shift - ucode -L "$FORKOP_LIB" "$VALIDATOR" "$@" >/dev/null || + ucode -L "$TACHYON_LIB" "$VALIDATOR" "$@" >/dev/null || fail "$label should be accepted" } @@ -23,7 +23,7 @@ assert_rejects() { local label="$1" shift - if ucode -L "$FORKOP_LIB" "$VALIDATOR" "$@" >/dev/null 2>&1; then + if ucode -L "$TACHYON_LIB" "$VALIDATOR" "$@" >/dev/null 2>&1; then fail "$label should be rejected" fi } diff --git a/tests/config_validator_runtime.sh b/tests/config_validator_runtime.sh index 20edc07e..a05944f6 100644 --- a/tests/config_validator_runtime.sh +++ b/tests/config_validator_runtime.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -VALIDATOR="$FORKOP_LIB/config/validator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +VALIDATOR="$TACHYON_LIB/config/validator.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -33,7 +33,7 @@ if (input.settings.dns_server === undefined) input.settings.dns_server = ['77.88 if (input.settings.bootstrap_dns_server === undefined) input.settings.bootstrap_dns_server = ['77.88.8.8']; fs.writeFileSync(process.argv[3], JSON.stringify(input)); JS - FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$normalized" "$context" + TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$VALIDATOR" validate-runtime-fixture "$normalized" "$context" } assert_rejects() { @@ -432,7 +432,7 @@ cat >"$WORK_DIR/bad-byedpi.json" <<'JSON' ] } JSON -if output="$(FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/bad-byedpi.json" "$provider_context" 2>/dev/null)"; then +if output="$(TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/bad-byedpi.json" "$provider_context" 2>/dev/null)"; then fail "bad byedpi should be rejected" fi printf '%s\n' "$output" | grep -Fq "ByeDPI listen address and port are assigned" || @@ -440,10 +440,10 @@ printf '%s\n' "$output" | grep -Fq "ByeDPI listen address and port are assigned" runtime_lib="$WORK_DIR/runtime-lib" mkdir -p "$runtime_lib" -ln -s "$FORKOP_LIB/core" "$runtime_lib/core" -ln -s "$FORKOP_LIB/config" "$runtime_lib/config" -ln -s "$FORKOP_LIB/subscription" "$runtime_lib/subscription" -ln -s "$FORKOP_LIB/providers" "$runtime_lib/providers" +ln -s "$TACHYON_LIB/core" "$runtime_lib/core" +ln -s "$TACHYON_LIB/config" "$runtime_lib/config" +ln -s "$TACHYON_LIB/subscription" "$runtime_lib/subscription" +ln -s "$TACHYON_LIB/providers" "$runtime_lib/providers" touch "$WORK_DIR/ciadpi-provider" cat >"$WORK_DIR/bad-byedpi-runtime-state.json" <<'JSON' { @@ -469,7 +469,7 @@ env \ ZAPRET2_QUEUE_RANGE_SIZE="16" \ NFT_FAKEIP_MARK="0x00000800" \ NFT_OUTBOUND_MARK="0x00000400" \ - FORKOP_LIB="$runtime_lib" \ + TACHYON_LIB="$runtime_lib" \ ucode -L "$runtime_lib" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/bad-byedpi-runtime-state.json" "{}" chmod 755 "$WORK_DIR/ciadpi-provider" if output="$(env \ @@ -487,7 +487,7 @@ if output="$(env \ ZAPRET2_QUEUE_RANGE_SIZE="16" \ NFT_FAKEIP_MARK="0x00000800" \ NFT_OUTBOUND_MARK="0x00000400" \ - FORKOP_LIB="$runtime_lib" \ + TACHYON_LIB="$runtime_lib" \ ucode -L "$runtime_lib" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/bad-byedpi-runtime-state.json" "{}" 2>/dev/null)"; then fail "executable byedpi provider should enable strategy validation" fi diff --git a/tests/connection_cascade.sh b/tests/connection_cascade.sh index 4b292768..63ef6038 100644 --- a/tests/connection_cascade.sh +++ b/tests/connection_cascade.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -GENERATOR="$FORKOP_LIB/singbox/generator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +GENERATOR="$TACHYON_LIB/singbox/generator.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -49,7 +49,7 @@ cat >"$WORK_DIR/subscriptions/source-subscription-1.json" <<'JSON' "outbounds": [ "nested-entry", "independent" ], "url": "https://www.gstatic.com/generate_204", "interval": "3m", - "__forkop_allow_group": true + "__tachyon_allow_group": true } ] } @@ -100,7 +100,7 @@ cat >"$WORK_DIR/fixture.json" <<'JSON' JSON TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/subscriptions" \ - ucode -L "$FORKOP_LIB" "$GENERATOR" generate-config-fixture \ + ucode -L "$TACHYON_LIB" "$GENERATOR" generate-config-fixture \ "$WORK_DIR/fixture.json" "$WORK_DIR/output.json" "127.0.0.1" ucode -e ' diff --git a/tests/constants_owner.sh b/tests/constants_owner.sh index bc8a593c..4e21508f 100644 --- a/tests/constants_owner.sh +++ b/tests/constants_owner.sh @@ -1,17 +1,17 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -CLI_UC="$FORKOP_BIN" -FORKOP_MAKEFILE="$ROOT_DIR/forkop/Makefile" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +CLI_UC="$TACHYON_BIN" +TACHYON_MAKEFILE="$ROOT_DIR/tachyon/Makefile" BUILD_SCRIPT="$ROOT_DIR/build.sh" -CONSTANTS_SH="$FORKOP_LIB/constants.sh" -LIFECYCLE_UC="$FORKOP_LIB/service/lifecycle.uc" -CONSTANTS_UC="$FORKOP_LIB/core/constants.uc" -SINGBOX_CONSTANTS_UC="$FORKOP_LIB/singbox/constants.uc" -FRONTEND_CONSTANTS="$ROOT_DIR/fe-app-forkop/src/constants.ts" +CONSTANTS_SH="$TACHYON_LIB/constants.sh" +LIFECYCLE_UC="$TACHYON_LIB/service/lifecycle.uc" +CONSTANTS_UC="$TACHYON_LIB/core/constants.uc" +SINGBOX_CONSTANTS_UC="$TACHYON_LIB/singbox/constants.uc" +FRONTEND_CONSTANTS="$ROOT_DIR/fe-app-tachyon/src/constants.ts" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -21,41 +21,41 @@ fail() { [ ! -e "$CONSTANTS_SH" ] || fail "constants.sh shell owner must be removed" -grep -Fq '#!/usr/bin/ucode' "$FORKOP_BIN" || - fail "forkop entrypoint must be a direct ucode executable" +grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" || + fail "tachyon entrypoint must be a direct ucode executable" grep -Fq 'service/lifecycle.uc' "$CLI_UC" || fail "service/cli.uc must dispatch lifecycle orchestration through service/lifecycle.uc" grep -Fq 'core.constants' "$LIFECYCLE_UC" || fail "service/lifecycle.uc must load constants from core/constants.uc" if grep -R -n -E 'constants\.sh|read_shell_constants|expand_shell_constants|unquote_shell_value' \ - "$FORKOP_BIN" "$FORKOP_LIB" --include='*.sh' --include='*.uc' >/dev/null 2>&1; then + "$TACHYON_BIN" "$TACHYON_LIB" --include='*.sh' --include='*.uc' >/dev/null 2>&1; then fail "shell constants owner or parser references must not remain" fi -if grep -n 'constants\.sh' "$FORKOP_MAKEFILE" "$BUILD_SCRIPT" >/dev/null 2>&1; then +if grep -n 'constants\.sh' "$TACHYON_MAKEFILE" "$BUILD_SCRIPT" >/dev/null 2>&1; then fail "package build must not patch removed constants.sh" fi -grep -Fq 'core/constants.uc' "$FORKOP_MAKEFILE" || - fail "forkop/Makefile must patch core/constants.uc" +grep -Fq 'core/constants.uc' "$TACHYON_MAKEFILE" || + fail "tachyon/Makefile must patch core/constants.uc" grep -Fq 'core/constants.uc' "$BUILD_SCRIPT" || fail "manual WSL build must patch core/constants.uc" -config_name="$(ucode -L "$FORKOP_LIB" "$CONSTANTS_UC" get FORKOP_CONFIG_NAME)" -[ "$config_name" = "forkop" ] || - fail "core/constants.uc get returned unexpected FORKOP_CONFIG_NAME" +config_name="$(ucode -L "$TACHYON_LIB" "$CONSTANTS_UC" get TACHYON_CONFIG_NAME)" +[ "$config_name" = "tachyon" ] || + fail "core/constants.uc get returned unexpected TACHYON_CONFIG_NAME" -eval "$(ucode -L "$FORKOP_LIB" "$CONSTANTS_UC" shell-env)" -[ "$FORKOP_CONFIG" = "/etc/config/forkop" ] || - fail "core/constants.uc shell-env did not derive FORKOP_CONFIG" +eval "$(ucode -L "$TACHYON_LIB" "$CONSTANTS_UC" shell-env)" +[ "$TACHYON_CONFIG" = "/etc/config/tachyon" ] || + fail "core/constants.uc shell-env did not derive TACHYON_CONFIG" [ "$TMP_RULESET_FOLDER" = "/tmp/sing-box/rulesets" ] || fail "core/constants.uc shell-env did not derive TMP_RULESET_FOLDER" -[ "$BYEDPI_PID_DIR" = "/var/run/forkop/byedpi/pid" ] || +[ "$BYEDPI_PID_DIR" = "/var/run/tachyon/byedpi/pid" ] || fail "core/constants.uc shell-env did not derive BYEDPI_PID_DIR" -[ "$(ucode -L "$FORKOP_LIB" "$CONSTANTS_UC" get FAKEIP_TEST_DOMAIN)" = "fakeip.podkop.fyi" ] || +[ "$(ucode -L "$TACHYON_LIB" "$CONSTANTS_UC" get FAKEIP_TEST_DOMAIN)" = "fakeip.podkop.fyi" ] || fail "FakeIP diagnostics must use the deployed public endpoint" -[ "$(ucode -L "$FORKOP_LIB" "$CONSTANTS_UC" get CHECK_PROXY_IP_DOMAIN)" = "ip.podkop.fyi" ] || +[ "$(ucode -L "$TACHYON_LIB" "$CONSTANTS_UC" get CHECK_PROXY_IP_DOMAIN)" = "ip.podkop.fyi" ] || fail "public IP diagnostics must use the deployed public endpoint" grep -Fq 'const FAKEIP_TEST_DOMAIN = "fakeip.podkop.fyi";' "$SINGBOX_CONSTANTS_UC" || fail "sing-box constants must match the deployed FakeIP endpoint" diff --git a/tests/core_helpers.sh b/tests/core_helpers.sh index 7db9fabb..401e4770 100644 --- a/tests/core_helpers.sh +++ b/tests/core_helpers.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -HELPERS_UC="$ROOT_DIR/forkop/files/usr/lib/core/helpers.uc" +HELPERS_UC="$ROOT_DIR/tachyon/files/usr/lib/core/helpers.uc" fail() { printf 'FAIL: %s\n' "$1" >&2 diff --git a/tests/core_uci_runtime.sh b/tests/core_uci_runtime.sh index 98df3fb9..08e4aede 100644 --- a/tests/core_uci_runtime.sh +++ b/tests/core_uci_runtime.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -UCODE_LIB="$ROOT_DIR/forkop/files/usr/lib" +UCODE_LIB="$ROOT_DIR/tachyon/files/usr/lib" WORK_DIR="$(mktemp -d)" cleanup() { diff --git a/tests/country_detection.sh b/tests/country_detection.sh index 287bf19a..df32fc6d 100644 --- a/tests/country_detection.sh +++ b/tests/country_detection.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" WORK_DIR="$(mktemp -d)" cleanup() { @@ -95,9 +95,9 @@ UCODE FAKE_COUNTRY_PAYLOAD="$WORK_DIR/payload.json" \ FAKE_COUNTRY_RESOLVE="$WORK_DIR/resolve.txt" \ -FORKOP_COUNTRY_IS_URL="https://country.invalid/" \ +TACHYON_COUNTRY_IS_URL="https://country.invalid/" \ PATH="$WORK_DIR/bin:$PATH" \ - ucode -L "$FORKOP_LIB" "$WORK_DIR/runner.uc" + ucode -L "$TACHYON_LIB" "$WORK_DIR/runner.uc" grep -Fq '8.8.8.8' "$WORK_DIR/payload.json" || fail "country.is request did not contain the first resolved IP" diff --git a/tests/dashboard_server_filter.sh b/tests/dashboard_server_filter.sh index 80c7312f..eb12b53c 100644 --- a/tests/dashboard_server_filter.sh +++ b/tests/dashboard_server_filter.sh @@ -1,10 +1,10 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -GENERATOR_UC="$FORKOP_LIB/singbox/generator.uc" -VALIDATOR_UC="$FORKOP_LIB/config/validator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +GENERATOR_UC="$TACHYON_LIB/singbox/generator.uc" +VALIDATOR_UC="$TACHYON_LIB/config/validator.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -21,13 +21,13 @@ generate_config() { local fixture="$1" local output="$2" mkdir -p "${output}.section-cache" - ucode -L "$FORKOP_LIB" "$GENERATOR_UC" generate-config-fixture \ + ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ "$fixture" "$output" "127.0.0.1" } validate_fixture() { local fixture="$1" - FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR_UC" \ + TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$VALIDATOR_UC" \ validate-runtime-fixture "$fixture" "{}" } diff --git a/tests/diagnostics_status.sh b/tests/diagnostics_status.sh index bb51dcd6..8dd7b3be 100644 --- a/tests/diagnostics_status.sh +++ b/tests/diagnostics_status.sh @@ -1,16 +1,16 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -DIAGNOSTICS="$ROOT_DIR/forkop/files/usr/lib/diagnostics/status.uc" -DIAGNOSTICS_RUNTIME="$ROOT_DIR/forkop/files/usr/lib/diagnostics/runtime.uc" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -CLI_UC="$FORKOP_BIN" +DIAGNOSTICS="$ROOT_DIR/tachyon/files/usr/lib/diagnostics/status.uc" +DIAGNOSTICS_RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/diagnostics/runtime.uc" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +CLI_UC="$TACHYON_BIN" WORK_DIR="$(mktemp -d)" status_ucode() { - ucode -L "$FORKOP_LIB" "$DIAGNOSTICS" "$@" + ucode -L "$TACHYON_LIB" "$DIAGNOSTICS" "$@" } cleanup() { @@ -47,24 +47,24 @@ assert_status 1 0 0 "running but disabled" assert_status 0 1 1 "stopped but enabled" assert_status 0 0 0 "stopped & disabled" -[ ! -e "$FORKOP_LIB/status_diagnostics.sh" ] || +[ ! -e "$TACHYON_LIB/status_diagnostics.sh" ] || fail "status_diagnostics.sh shell owner must be removed" grep -Fq 'get_system_info: [ "diagnostics/runtime.uc", "get-system-info", 0 ]' "$CLI_UC" || fail "service/cli.uc must dispatch get_system_info through diagnostics/runtime.uc" -[ "$(FORKOP_VERSION=runtime-test ucode -L "$FORKOP_LIB" "$DIAGNOSTICS_RUNTIME" show-version)" = "runtime-test" ] || +[ "$(TACHYON_VERSION=runtime-test ucode -L "$TACHYON_LIB" "$DIAGNOSTICS_RUNTIME" show-version)" = "runtime-test" ] || fail "diagnostics/runtime.uc show-version mode failed" if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "show"|uci", "-q"' "$DIAGNOSTICS_RUNTIME" >/dev/null 2>&1; then fail "diagnostics/runtime.uc must use core.uci instead of owning direct UCI cursor or CLI calls" fi -grep -Fq '"forkop-stably-running", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MARK, RUNTIME_STABLE_MIN_AGE' "$DIAGNOSTICS_RUNTIME" || - fail "diagnostics Forkop status must use stable runtime state to avoid crash-loop flicker" +grep -Fq '"tachyon-stably-running", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MARK, RUNTIME_STABLE_MIN_AGE' "$DIAGNOSTICS_RUNTIME" || + fail "diagnostics Tachyon status must use stable runtime state to avoid crash-loop flicker" grep -Fq '"sing-box-service-stable",' "$DIAGNOSTICS_RUNTIME" || fail "diagnostics sing-box status must use stable runtime state to avoid crash-loop flicker" capabilities="$( - FORKOP_DIAGNOSTICS_SING_BOX_BIN_PATH="$WORK_DIR/missing-sing-box" \ - FORKOP_LIB="$FORKOP_LIB" \ - ucode -L "$FORKOP_LIB" "$DIAGNOSTICS_RUNTIME" get-server-capabilities + TACHYON_DIAGNOSTICS_SING_BOX_BIN_PATH="$WORK_DIR/missing-sing-box" \ + TACHYON_LIB="$TACHYON_LIB" \ + ucode -L "$TACHYON_LIB" "$DIAGNOSTICS_RUNTIME" get-server-capabilities )" JSON_VALUE="$capabilities" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); @@ -74,19 +74,19 @@ if (value.sing_box_extended !== 0 || value.sing_box_tiny !== 0 || value.sing_box } NODE -masked_config="$WORK_DIR/forkop-masked" +masked_config="$WORK_DIR/tachyon-masked" cat >"$masked_config" <<'EOF' config settings 'main' option hwid 'device-secret' option proxy_string 'vless://secret@example.com:443' EOF -masked_output="$(status_ucode forkop-config-masked "$masked_config")" +masked_output="$(status_ucode tachyon-config-masked "$masked_config")" case "$masked_output" in - *device-secret*|*vless://secret*) fail "masked Forkop config leaked a secret" ;; + *device-secret*|*vless://secret*) fail "masked Tachyon config leaked a secret" ;; esac case "$masked_output" in *"option hwid 'MASKED'"*) ;; - *) fail "masked Forkop config must preserve the HWID option shape" ;; + *) fail "masked Tachyon config must preserve the HWID option shape" ;; esac legacy_json="$(status_ucode service-status-json 1 0 ignored 1)" @@ -99,13 +99,13 @@ if (value.status !== "running but disabled" || value.dns_configured !== 1) { NODE { - printf 'Tue Jun 30 11:00:00 2026 user.notice forkop: [info] Starting Forkop\n' + printf 'Tue Jun 30 11:00:00 2026 user.notice tachyon: [info] Starting Tachyon\n' for i in $(seq 1 4500); do printf 'Tue Jun 30 11:00:%02d 2026 daemon.info unrelated[%04d]: filler filler filler filler filler filler filler filler filler filler\n' "$((i % 60))" "$i" done - printf 'Tue Jun 30 11:01:00 2026 user.notice forkop: [info] large logread marker survived stdin transport\n' + printf 'Tue Jun 30 11:01:00 2026 user.notice tachyon: [info] large logread marker survived stdin transport\n' } >"$WORK_DIR/large-logread.txt" -large_logs="$(FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$DIAGNOSTICS_RUNTIME" forkop-logs-fixture <"$WORK_DIR/large-logread.txt")" || +large_logs="$(TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$DIAGNOSTICS_RUNTIME" tachyon-logs-fixture <"$WORK_DIR/large-logread.txt")" || fail "diagnostics/runtime.uc must process large logread payloads through stdin without shell argument limits" case "$large_logs" in *"large logread marker survived stdin transport"*) ;; @@ -122,14 +122,14 @@ SH chmod +x "$fake_bin/curl" uci_state="$WORK_DIR/uci-state.txt" cat >"$uci_state" <<'EOF' -forkop.settings=settings -forkop.settings.latency_test_url=https://latency.example/generate_204 +tachyon.settings=settings +tachyon.settings.latency_test_url=https://latency.example/generate_204 EOF FAKE_CURL_LOG="$WORK_DIR/fake-curl.log" \ -FORKOP_UCI_STATE_FILE="$uci_state" \ -FORKOP_LIB="$FORKOP_LIB" \ +TACHYON_UCI_STATE_FILE="$uci_state" \ +TACHYON_LIB="$TACHYON_LIB" \ PATH="$fake_bin:$PATH" \ - ucode -L "$FORKOP_LIB" "$DIAGNOSTICS_RUNTIME" clash-api get_proxy_latency proxy-out 5000 >/dev/null || + ucode -L "$TACHYON_LIB" "$DIAGNOSTICS_RUNTIME" clash-api get_proxy_latency proxy-out 5000 >/dev/null || fail "clash-api get_proxy_latency should use fake curl successfully" grep -Fq "url=https://latency.example/generate_204" "$WORK_DIR/fake-curl.log" || fail "clash-api latency check must use settings.latency_test_url" @@ -139,11 +139,11 @@ mkdir -p "$latency_action_dir" latency_state="$latency_action_dir/latency-1.json" printf '%s\n' '{"success":true,"running":true,"kind":"latency","latency_type":"proxy_list","section":"main","tag":"[]","started_at":100}' >"$latency_state" FAKE_CURL_LOG="$WORK_DIR/fake-curl-latencies.log" \ -FORKOP_UCI_STATE_FILE="$uci_state" \ -FORKOP_LIB="$FORKOP_LIB" \ -FORKOP_UI_LATENCY_ACTION_DIR="$latency_action_dir" \ +TACHYON_UCI_STATE_FILE="$uci_state" \ +TACHYON_LIB="$TACHYON_LIB" \ +TACHYON_UI_LATENCY_ACTION_DIR="$latency_action_dir" \ PATH="$fake_bin:$PATH" \ - ucode -L "$FORKOP_LIB" "$DIAGNOSTICS_RUNTIME" clash-api get_proxy_latencies '["proxy-a","proxy-b"]' 5000 "$latency_state" >/dev/null || + ucode -L "$TACHYON_LIB" "$DIAGNOSTICS_RUNTIME" clash-api get_proxy_latencies '["proxy-a","proxy-b"]' 5000 "$latency_state" >/dev/null || fail "clash-api get_proxy_latencies should update latency progress" JOB_STATE="$latency_state" node - <<'NODE' const fs = require("fs"); @@ -254,10 +254,10 @@ EOF )" printf '%s\n' "$sing_box_netstat" | - FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$DIAGNOSTICS_RUNTIME" sing-box-standard-ports-listening-fixture >/dev/null || + TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$DIAGNOSTICS_RUNTIME" sing-box-standard-ports-listening-fixture >/dev/null || fail "sing-box standard listeners should satisfy diagnostics" if printf '%s\n' "$sing_box_netstat" | sed '/0.0.0.0:1602/d' | - FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$DIAGNOSTICS_RUNTIME" sing-box-standard-ports-listening-fixture >/dev/null 2>&1; then + TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$DIAGNOSTICS_RUNTIME" sing-box-standard-ports-listening-fixture >/dev/null 2>&1; then fail "missing sing-box tproxy listener should fail diagnostics" fi diff --git a/tests/dns_action_ui.sh b/tests/dns_action_ui.sh index f95ebf44..37a6c00e 100644 --- a/tests/dns_action_ui.sh +++ b/tests/dns_action_ui.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SECTION_JS="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js" +SECTION_JS="$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" node - "$SECTION_JS" <<'NODE' const fs = require("fs"); diff --git a/tests/dns_apply.sh b/tests/dns_apply.sh index 109374b5..ad2fc4f7 100644 --- a/tests/dns_apply.sh +++ b/tests/dns_apply.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -APPLY="$ROOT_DIR/forkop/files/usr/lib/dns/apply.uc" -UCODE_LIB="$ROOT_DIR/forkop/files/usr/lib" +APPLY="$ROOT_DIR/tachyon/files/usr/lib/dns/apply.uc" +UCODE_LIB="$ROOT_DIR/tachyon/files/usr/lib" WORK_DIR="$(mktemp -d)" STATE="$WORK_DIR/uci.state" LOG="$WORK_DIR/uci.log" @@ -131,7 +131,7 @@ export UCI_STATE="$STATE" export UCI_LOG="$LOG" export DNSMASQ_LOG export DNSMASQ_INIT="$WORK_DIR/dnsmasq-init" -export FORKOP_CONFIG_NAME="forkop" +export TACHYON_CONFIG_NAME="tachyon" export SB_DNS_INBOUND_ADDRESS="127.0.0.42" if grep -E 'uci -q|command -v uci' "$APPLY" >/dev/null; then @@ -182,18 +182,18 @@ cat >"$STATE" <<'EOF_STATE' dhcp.@dnsmasq[0].server=1.1.1.1 8.8.8.8 dhcp.@dnsmasq[0].noresolv=0 dhcp.@dnsmasq[0].cachesize=150 -forkop.settings.shutdown_correctly=1 +tachyon.settings.shutdown_correctly=1 EOF_STATE : > "$DNSMASQ_LOG" : > "$LOG" ucode -L "$UCODE_LIB" "$APPLY" configure force assert_value 'dhcp.@dnsmasq[0].server' '127.0.0.42' -assert_value 'dhcp.@dnsmasq[0].forkop_server' '1.1.1.1 8.8.8.8' +assert_value 'dhcp.@dnsmasq[0].tachyon_server' '1.1.1.1 8.8.8.8' assert_value 'dhcp.@dnsmasq[0].noresolv' '1' -assert_value 'dhcp.@dnsmasq[0].forkop_noresolv' '0' +assert_value 'dhcp.@dnsmasq[0].tachyon_noresolv' '0' assert_value 'dhcp.@dnsmasq[0].cachesize' '0' -assert_value 'dhcp.@dnsmasq[0].forkop_cachesize' '150' +assert_value 'dhcp.@dnsmasq[0].tachyon_cachesize' '150' assert_log_contains 'commit dhcp' assert_dnsmasq_restarted @@ -203,21 +203,21 @@ ucode -L "$UCODE_LIB" "$APPLY" restore force assert_value 'dhcp.@dnsmasq[0].server' '1.1.1.1 8.8.8.8' assert_value 'dhcp.@dnsmasq[0].noresolv' '0' assert_value 'dhcp.@dnsmasq[0].cachesize' '150' -assert_absent 'dhcp.@dnsmasq[0].forkop_server' -assert_absent 'dhcp.@dnsmasq[0].forkop_noresolv' -assert_absent 'dhcp.@dnsmasq[0].forkop_cachesize' +assert_absent 'dhcp.@dnsmasq[0].tachyon_server' +assert_absent 'dhcp.@dnsmasq[0].tachyon_noresolv' +assert_absent 'dhcp.@dnsmasq[0].tachyon_cachesize' assert_log_contains 'commit dhcp' assert_dnsmasq_restarted cat >"$STATE" <<'EOF_STATE' dhcp.@dnsmasq[0].server=127.0.0.42 dhcp.@dnsmasq[0].notinterface=br-lan guest -dhcp.@dnsmasq[0].forkop_server=1.1.1.1 8.8.8.8 -dhcp.@dnsmasq[0].forkop_notinterface=wan docker -dhcp.@dnsmasq[0].forkop_noresolv=1 -dhcp.@dnsmasq[0].forkop_cachesize=0 -dhcp.forkop.interface=br-lan guest -forkop.settings.dont_touch_dhcp=1 +dhcp.@dnsmasq[0].tachyon_server=1.1.1.1 8.8.8.8 +dhcp.@dnsmasq[0].tachyon_notinterface=wan docker +dhcp.@dnsmasq[0].tachyon_noresolv=1 +dhcp.@dnsmasq[0].tachyon_cachesize=0 +dhcp.tachyon.interface=br-lan guest +tachyon.settings.dont_touch_dhcp=1 EOF_STATE run_restore @@ -225,16 +225,16 @@ assert_value 'dhcp.@dnsmasq[0].server' '1.1.1.1 8.8.8.8' assert_value 'dhcp.@dnsmasq[0].notinterface' 'wan docker' assert_value 'dhcp.@dnsmasq[0].noresolv' '1' assert_value 'dhcp.@dnsmasq[0].cachesize' '0' -assert_absent 'dhcp.@dnsmasq[0].forkop_server' -assert_absent 'dhcp.@dnsmasq[0].forkop_notinterface' -assert_absent 'dhcp.@dnsmasq[0].forkop_noresolv' -assert_absent 'dhcp.@dnsmasq[0].forkop_cachesize' -assert_absent 'dhcp.forkop.interface' +assert_absent 'dhcp.@dnsmasq[0].tachyon_server' +assert_absent 'dhcp.@dnsmasq[0].tachyon_notinterface' +assert_absent 'dhcp.@dnsmasq[0].tachyon_noresolv' +assert_absent 'dhcp.@dnsmasq[0].tachyon_cachesize' +assert_absent 'dhcp.tachyon.interface' assert_log_contains 'commit dhcp' cat >"$STATE" <<'EOF_STATE' dhcp.@dnsmasq[0].server=9.9.9.9 -forkop.settings.dont_touch_dhcp=1 +tachyon.settings.dont_touch_dhcp=1 EOF_STATE run_restore @@ -243,17 +243,17 @@ assert_log_empty cat >"$STATE" <<'EOF_STATE' dhcp.@dnsmasq[0].server=127.0.0.42 -dhcp.@dnsmasq[0].forkop_noresolv=1 -dhcp.@dnsmasq[0].forkop_cachesize=0 -forkop.settings.dont_touch_dhcp=0 +dhcp.@dnsmasq[0].tachyon_noresolv=1 +dhcp.@dnsmasq[0].tachyon_cachesize=0 +tachyon.settings.dont_touch_dhcp=0 EOF_STATE run_restore assert_absent 'dhcp.@dnsmasq[0].server' assert_value 'dhcp.@dnsmasq[0].noresolv' '1' assert_value 'dhcp.@dnsmasq[0].cachesize' '0' -assert_absent 'dhcp.@dnsmasq[0].forkop_noresolv' -assert_absent 'dhcp.@dnsmasq[0].forkop_cachesize' +assert_absent 'dhcp.@dnsmasq[0].tachyon_noresolv' +assert_absent 'dhcp.@dnsmasq[0].tachyon_cachesize' assert_log_contains 'commit dhcp' printf 'DNS apply checks passed\n' diff --git a/tests/dns_failover.sh b/tests/dns_failover.sh index 895353a2..93417bad 100644 --- a/tests/dns_failover.sh +++ b/tests/dns_failover.sh @@ -1,10 +1,10 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -GENERATOR="$FORKOP_LIB/singbox/generator.uc" -FAILOVER="$FORKOP_LIB/singbox/dns_failover.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +GENERATOR="$TACHYON_LIB/singbox/generator.uc" +FAILOVER="$TACHYON_LIB/singbox/dns_failover.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -21,9 +21,9 @@ generate() { local fixture="$1" local output="$2" local state="${3:-$WORK_DIR/missing-state.json}" - FORKOP_LIB="$FORKOP_LIB" \ - FORKOP_DNS_FAILOVER_STATE_FILE="$state" \ - ucode -L "$FORKOP_LIB" "$GENERATOR" generate-config-fixture "$fixture" "$output" 192.168.1.1 0 + TACHYON_LIB="$TACHYON_LIB" \ + TACHYON_DNS_FAILOVER_STATE_FILE="$state" \ + ucode -L "$TACHYON_LIB" "$GENERATOR" generate-config-fixture "$fixture" "$output" 192.168.1.1 0 } cat >"$WORK_DIR/single.json" <<'JSON' @@ -141,11 +141,11 @@ cat >"$WORK_DIR/alive-recovery.json" <<'JSON' { "0": true, "1": true } JSON -selected="$(ucode -L "$FORKOP_LIB" "$FAILOVER" select-fixture "$WORK_DIR/select-state.json" "$WORK_DIR/alive-failover.json" main 0)" +selected="$(ucode -L "$TACHYON_LIB" "$FAILOVER" select-fixture "$WORK_DIR/select-state.json" "$WORK_DIR/alive-failover.json" main 0)" printf '%s' "$selected" | grep -Eq '"index"[[:space:]]*:[[:space:]]*1' || fail "dead active DNS must select the first live server below" printf '%s' "$selected" | grep -Eq '"reason"[[:space:]]*:[[:space:]]*"active_dead"' || fail "failover reason" -selected="$(ucode -L "$FORKOP_LIB" "$FAILOVER" select-fixture "$WORK_DIR/select-state.json" "$WORK_DIR/alive-recovery.json" bootstrap 1)" +selected="$(ucode -L "$TACHYON_LIB" "$FAILOVER" select-fixture "$WORK_DIR/select-state.json" "$WORK_DIR/alive-recovery.json" bootstrap 1)" printf '%s' "$selected" | grep -Eq '"index"[[:space:]]*:[[:space:]]*0' || fail "recovery must return to the highest live priority" printf '%s' "$selected" | grep -Eq '"reason"[[:space:]]*:[[:space:]]*"recovery"' || fail "recovery reason" @@ -156,7 +156,7 @@ cat >"$WORK_DIR/verify-bootstrap.json" <<'JSON' { "main_index": 0, "bootstrap_index": 1, "bootstrap_servers": [ "a", "b" ] } JSON -verification="$(ucode -L "$FORKOP_LIB" "$FAILOVER" verification-plan-fixture "$WORK_DIR/verify-previous.json" "$WORK_DIR/verify-bootstrap.json")" +verification="$(ucode -L "$TACHYON_LIB" "$FAILOVER" verification-plan-fixture "$WORK_DIR/verify-previous.json" "$WORK_DIR/verify-bootstrap.json")" printf '%s' "$verification" | grep -Eq '"main"[[:space:]]*:[[:space:]]*false' || fail "bootstrap-only switch must not require a dead main DNS to recover" printf '%s' "$verification" | grep -Eq '"bootstrap"[[:space:]]*:[[:space:]]*true' || fail "bootstrap-only switch must verify the selected bootstrap DNS" diff --git a/tests/helpers/config_contract_matrix.js b/tests/helpers/config_contract_matrix.js index 75df4971..c1835a1a 100644 --- a/tests/helpers/config_contract_matrix.js +++ b/tests/helpers/config_contract_matrix.js @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env node "use strict"; const fs = require("fs"); @@ -150,12 +150,12 @@ function enrichUiValues(fileData, option, block, values) { function extractUi(repo, fields) { const currentViewDir = path.join( repo, - "luci-app-forkop", + "luci-app-tachyon", "htdocs", "luci-static", "resources", "view", - "forkop", + "tachyon", ); const legacyViewDir = path.join(repo, legacyAppDir, "htdocs", "luci-static", "resources", "view", legacyStem); const viewDir = fs.existsSync(currentViewDir) ? currentViewDir : legacyViewDir; @@ -205,7 +205,7 @@ function extractUi(repo, fields) { } function extractConfigDefaults(repo, fields) { - const currentFile = path.join(repo, "forkop", "files", "etc", "config", "forkop"); + const currentFile = path.join(repo, "tachyon", "files", "etc", "config", "tachyon"); const legacyFile = path.join(repo, legacyStem, "files", "etc", "config", legacyStem); const file = fs.existsSync(currentFile) ? currentFile : legacyFile; const data = readFileIfExists(file); @@ -219,7 +219,7 @@ function extractConfigDefaults(repo, fields) { } function extractBackend(repo, fields) { - const backendRoot = fs.existsSync(path.join(repo, "forkop")) ? "forkop" : legacyStem; + const backendRoot = fs.existsSync(path.join(repo, "tachyon")) ? "tachyon" : legacyStem; const roots = [ path.join(repo, backendRoot, "files", "usr", "lib"), path.join(repo, backendRoot, "files", "usr", "bin"), @@ -227,14 +227,14 @@ function extractBackend(repo, fields) { ]; const files = roots .flatMap((root) => walkFiles(root)) - .filter((file) => /\.(sh|uc)$/.test(file) || ["forkop", legacyStem].includes(path.basename(file))); + .filter((file) => /\.(sh|uc)$/.test(file) || ["tachyon", legacyStem].includes(path.basename(file))); const installer = path.join(repo, "install.sh"); if (fs.existsSync(installer)) files.push(installer); const shellOptionRe = /\bconfig_(?:get|get_bool|list_foreach)\s+\S+\s+(?:"[^"]+"|'[^']+'|\$[A-Za-z_][A-Za-z0-9_]*|\$\{[^}]+\})\s+["']([A-Za-z0-9_]+)["']/g; const ucodeOptionRe = /\b(?:option|list_option|bool_option|int_option)\(\s*[^,\n]+,\s*["']([A-Za-z0-9_]+)["']/g; const ucodeStaticOptionKeyArrayRe = /^\s*\[\s*["']([A-Za-z0-9_]+)["']\s*,/gm; - const migrationRe = new RegExp(`\\b(?:forkop|${legacyStem})_uci_(?:set_option|set_option_if_missing|delete_option|add_list_unique)\\s+["'$A-Za-z0-9_{}.-]+\\s+["']([A-Za-z0-9_]+)["']`, "g"); + const migrationRe = new RegExp(`\\b(?:tachyon|${legacyStem})_uci_(?:set_option|set_option_if_missing|delete_option|add_list_unique)\\s+["'$A-Za-z0-9_{}.-]+\\s+["']([A-Za-z0-9_]+)["']`, "g"); const ucodeMigrationRe = /\b(?:set_option|set_option_if_missing|delete_option|add_list_unique)\(\s*[^,\n]+,\s*[^,\n]+,\s*["']([A-Za-z0-9_]+)["']/g; for (const file of files) { diff --git a/tests/helpers_owner.sh b/tests/helpers_owner.sh index e358299c..03a3cbaa 100644 --- a/tests/helpers_owner.sh +++ b/tests/helpers_owner.sh @@ -1,17 +1,17 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -CLI_UC="$FORKOP_BIN" -HELPERS_SH="$FORKOP_LIB/helpers.sh" -LIFECYCLE_UC="$FORKOP_LIB/service/lifecycle.uc" -PACKAGES_UC="$FORKOP_LIB/core/packages.uc" -RULES_UC="$FORKOP_LIB/providers/rules.uc" -SINGBOX_RUNTIME_UC="$FORKOP_LIB/singbox/runtime.uc" -COMPONENT_ACTION_UC="$FORKOP_LIB/components/action.uc" -DIAGNOSTICS_RUNTIME_UC="$FORKOP_LIB/diagnostics/runtime.uc" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +CLI_UC="$TACHYON_BIN" +HELPERS_SH="$TACHYON_LIB/helpers.sh" +LIFECYCLE_UC="$TACHYON_LIB/service/lifecycle.uc" +PACKAGES_UC="$TACHYON_LIB/core/packages.uc" +RULES_UC="$TACHYON_LIB/providers/rules.uc" +SINGBOX_RUNTIME_UC="$TACHYON_LIB/singbox/runtime.uc" +COMPONENT_ACTION_UC="$TACHYON_LIB/components/action.uc" +DIAGNOSTICS_RUNTIME_UC="$TACHYON_LIB/diagnostics/runtime.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -26,16 +26,16 @@ fail() { [ ! -e "$HELPERS_SH" ] || fail "helpers.sh shell owner must be removed" -if grep -R -n -F 'helpers.sh' "$FORKOP_BIN" "$FORKOP_LIB" --include='*.sh' >/dev/null 2>&1; then +if grep -R -n -F 'helpers.sh' "$TACHYON_BIN" "$TACHYON_LIB" --include='*.sh' >/dev/null 2>&1; then fail "runtime shell must not reference helpers.sh" fi if grep -R -n -E 'helpers_ucode\(|get_(inbound|server_inbound|tailscale_dns_server|outbound)_tag_by_section\(|get_domain_resolver_tag\(|provider_status_ucode\(|is_ipv4\(|is_min_package_version\(|url_get_(scheme|userinfo|host|port|path|query_param)\(' \ - "$FORKOP_BIN" "$FORKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + "$TACHYON_BIN" "$TACHYON_LIB" --include='*.sh' >/dev/null 2>&1; then fail "helpers.sh wrapper symbols must not remain in runtime shell" fi -grep -Fq '#!/usr/bin/ucode' "$FORKOP_BIN" || - fail "forkop entrypoint must be a direct ucode executable" +grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" || + fail "tachyon entrypoint must be a direct ucode executable" grep -Fq 'service/lifecycle.uc' "$CLI_UC" || fail "service/cli.uc must dispatch lifecycle through service/lifecycle.uc" grep -Fq 'core/packages.uc' "$LIFECYCLE_UC" || @@ -45,19 +45,19 @@ for shell_owner_pattern in \ 'config_get' \ 'uci_set' do - if grep -Fq "$shell_owner_pattern" "$FORKOP_BIN"; then - fail "forkop shell entrypoint must not own $shell_owner_pattern logic" + if grep -Fq "$shell_owner_pattern" "$TACHYON_BIN"; then + fail "tachyon shell entrypoint must not own $shell_owner_pattern logic" fi done -if grep -E -n '(^|[;&|[:space:]])nft[[:space:]]+(list|add|delete|flush)' "$FORKOP_BIN" >/dev/null 2>&1; then - fail "forkop shell entrypoint must not own nft command logic" +if grep -E -n '(^|[;&|[:space:]])nft[[:space:]]+(list|add|delete|flush)' "$TACHYON_BIN" >/dev/null 2>&1; then + fail "tachyon shell entrypoint must not own nft command logic" fi -if grep -E -n '(^|[;&|[:space:]])ip[[:space:]]+(rule|route)' "$FORKOP_BIN" >/dev/null 2>&1; then - fail "forkop shell entrypoint must not own ip rule/route logic" +if grep -E -n '(^|[;&|[:space:]])ip[[:space:]]+(rule|route)' "$TACHYON_BIN" >/dev/null 2>&1; then + fail "tachyon shell entrypoint must not own ip rule/route logic" fi -[ ! -e "$FORKOP_LIB/updater.sh" ] || +[ ! -e "$TACHYON_LIB/updater.sh" ] || fail "updater.sh shell owner must be removed" -[ ! -e "$FORKOP_LIB/status_diagnostics.sh" ] || +[ ! -e "$TACHYON_LIB/status_diagnostics.sh" ] || fail "status_diagnostics.sh shell owner must be removed" grep -Fq 'diagnostics/runtime.uc' "$CLI_UC" || fail "service/cli.uc must dispatch diagnostics through diagnostics/runtime.uc" @@ -86,52 +86,52 @@ do fail "singbox/runtime.uc missing $mode" done if grep -R -n -E 'get_sing_box_version\(|sing_box_version_from_output\(|sing_box_version_output\(|sing_box_output_has_build_tag\(|sing_box_has_build_tag\(|is_sing_box_extended\(|is_sing_box_tiny_package_installed\(|is_sing_box_full_package_installed\(|is_sing_box_compressed_marker_set\(|is_sing_box_extended_marker_set\(|read_sing_box_version_state\(|is_sing_box_tiny_marker_set\(|is_sing_box_tiny\(|sing_box_supports_tailscale\(|get_sing_box_variant\(|updates_(write|read|clear|restore)_sing_box_(variant_marker|version_state)\(' \ - "$FORKOP_BIN" "$FORKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + "$TACHYON_BIN" "$TACHYON_LIB" --include='*.sh' >/dev/null 2>&1; then fail "sing-box helper/state shell symbols must not remain" fi -if ucode -L "$FORKOP_LIB" "$PACKAGES_UC" installed forkop-definitely-missing >/dev/null 2>&1; then +if ucode -L "$TACHYON_LIB" "$PACKAGES_UC" installed tachyon-definitely-missing >/dev/null 2>&1; then fail "missing package must not be reported installed" fi -mark_hex="$(ucode -L "$FORKOP_LIB" "$RULES_UC" mark-hex 0x01000000 2)" +mark_hex="$(ucode -L "$TACHYON_LIB" "$RULES_UC" mark-hex 0x01000000 2)" [ "$mark_hex" = "0x01000002" ] || fail "providers/rules.uc mark math changed" version="$(printf 'sing-box version 1.12.4-extended\nEnvironment: test\n' | - ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" version-from-output)" + ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" version-from-output)" [ "$version" = "1.12.4-extended" ] || fail "singbox/runtime.uc version-from-output changed" -ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" is-extended "1.12.4-extended" >/dev/null || +ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" is-extended "1.12.4-extended" >/dev/null || fail "extended sing-box version should be detected" -if ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" is-extended "1.12.4" >/dev/null 2>&1; then +if ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" is-extended "1.12.4" >/dev/null 2>&1; then fail "stable sing-box version must not be detected as extended" fi -ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" supports-tailscale "" "$(printf 'Tags: with_quic,with_tailscale\n')" >/dev/null || +ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" supports-tailscale "" "$(printf 'Tags: with_quic,with_tailscale\n')" >/dev/null || fail "with_tailscale build tag should be detected" SB_VERSION_STATE_FILE="$WORK_DIR/version" \ - ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" write-version-state 1.2.3 >/dev/null || + ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" write-version-state 1.2.3 >/dev/null || fail "version state write failed" -[ "$(SB_VERSION_STATE_FILE="$WORK_DIR/version" ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" read-version-state)" = "1.2.3" ] || +[ "$(SB_VERSION_STATE_FILE="$WORK_DIR/version" ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" read-version-state)" = "1.2.3" ] || fail "version state read failed" SB_VERSION_STATE_FILE="$WORK_DIR/version" \ - ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" restore-version-state "" >/dev/null || + ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" restore-version-state "" >/dev/null || fail "version state restore-clear failed" [ ! -e "$WORK_DIR/version" ] || fail "empty version state restore must clear the state file" SB_VARIANT_STATE_FILE="$WORK_DIR/variant" \ - ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" write-variant-marker extended-compressed >/dev/null || + ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" write-variant-marker extended-compressed >/dev/null || fail "variant marker write failed" SB_VARIANT_STATE_FILE="$WORK_DIR/variant" \ - ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" marker-is extended-compressed >/dev/null || + ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" marker-is extended-compressed >/dev/null || fail "variant marker check failed" -[ "$(SB_VARIANT_STATE_FILE="$WORK_DIR/variant" ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" read-variant-marker)" = "extended-compressed" ] || +[ "$(SB_VARIANT_STATE_FILE="$WORK_DIR/variant" ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" read-variant-marker)" = "extended-compressed" ] || fail "variant marker read failed" SB_VARIANT_STATE_FILE="$WORK_DIR/variant" \ - ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" restore-variant-marker "" >/dev/null || + ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" restore-variant-marker "" >/dev/null || fail "variant marker restore-clear failed" [ ! -e "$WORK_DIR/variant" ] || fail "empty variant marker restore must clear the marker file" diff --git a/tests/initd_state.sh b/tests/initd_state.sh index f6100cc5..b3a6c4c3 100755 --- a/tests/initd_state.sh +++ b/tests/initd_state.sh @@ -1,11 +1,11 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -INITD_UC="$FORKOP_LIB/service/initd.uc" -STATE_UC="$FORKOP_LIB/service/state.uc" -INITD="$ROOT_DIR/forkop/files/etc/init.d/forkop" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +INITD_UC="$TACHYON_LIB/service/initd.uc" +STATE_UC="$TACHYON_LIB/service/state.uc" +INITD="$ROOT_DIR/tachyon/files/etc/init.d/tachyon" WORK_DIR="$(mktemp -d)" cleanup() { @@ -19,10 +19,10 @@ fail() { } initd_ucode() { - ucode -L "$FORKOP_LIB" "$INITD_UC" "$@" + ucode -L "$TACHYON_LIB" "$INITD_UC" "$@" } -config_file="$WORK_DIR/forkop" +config_file="$WORK_DIR/tachyon" guard_file="$WORK_DIR/internal-config-change" sync_file="$WORK_DIR/service-triggers.sync" @@ -89,7 +89,7 @@ if initd_ucode initd-should-queue-config-change-reload on_config_change on_confi fi pending_file="$WORK_DIR/reload.pending" -if FORKOP_PENDING_RELOAD_FILE="$pending_file" \ +if TACHYON_PENDING_RELOAD_FILE="$pending_file" \ initd_ucode reload-begin-fixture on_config_change 123 0 1 start >/dev/null 2>&1; then fail "queued config-change reload should not run immediately while service is starting" fi @@ -99,7 +99,7 @@ grep -Fq 'reason=on_config_change' "$pending_file" || fail "pending reload should preserve config-change reason" rm -f "$pending_file" -if FORKOP_PENDING_RELOAD_FILE="$pending_file" \ +if TACHYON_PENDING_RELOAD_FILE="$pending_file" \ initd_ucode reload-begin-fixture pending 123 1 1 start >/dev/null 2>&1; then fail "pending reload should not run while another service action is active" fi @@ -125,15 +125,15 @@ fi retry_pid_file="$WORK_DIR/start-retry.pid" retry_call_file="$WORK_DIR/start-retry.called" -retry_service="$WORK_DIR/forkop-init" +retry_service="$WORK_DIR/tachyon-init" cat >"$retry_service" <>"$retry_call_file" EOF chmod +x "$retry_service" -FORKOP_SERVICE_INIT="$retry_service" \ - FORKOP_START_RETRY_PID_FILE="$retry_pid_file" \ - FORKOP_START_RETRY_DELAY_SECONDS=1 \ +TACHYON_SERVICE_INIT="$retry_service" \ + TACHYON_START_RETRY_PID_FILE="$retry_pid_file" \ + TACHYON_START_RETRY_DELAY_SECONDS=1 \ initd_ucode schedule-start-retry >/dev/null || fail "failed start should schedule an automatic retry" [ -s "$retry_pid_file" ] || @@ -255,7 +255,7 @@ fi if grep -Fq 'runtime_is_running' "$INITD"; then fail "init.d must not keep shell runtime status decisions" fi -if grep -E -n 'FORKOP_(CONFIG_FILE|RELOAD_LOCK_DIR|RUNTIME_STATE_DIR|PENDING_RELOAD_FILE|SERVICE_TRIGGER_SYNC_FILE|INTERNAL_CONFIG_TRIGGER_GUARD|CONFIG_CHANGE_REASON|BIN|SERVICE_INIT)=' "$INITD" >/dev/null 2>&1; then +if grep -E -n 'TACHYON_(CONFIG_FILE|RELOAD_LOCK_DIR|RUNTIME_STATE_DIR|PENDING_RELOAD_FILE|SERVICE_TRIGGER_SYNC_FILE|INTERNAL_CONFIG_TRIGGER_GUARD|CONFIG_CHANGE_REASON|BIN|SERVICE_INIT)=' "$INITD" >/dev/null 2>&1; then fail "init.d must not pass internal orchestration paths through shell env" fi if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$INITD_UC" >/dev/null 2>&1; then @@ -264,10 +264,10 @@ fi if grep -E -n '(^|[[:space:]])config_(load|get|get_bool|set)[[:space:]]' "$INITD" >/dev/null 2>&1; then fail "init.d must not read UCI directly" fi -if grep -Fq 'FORKOP_STATE_UC' "$INITD" || grep -Fq 'FORKOP_UI_UC' "$INITD" || grep -Fq 'FORKOP_STATUS_UC' "$INITD"; then +if grep -Fq 'TACHYON_STATE_UC' "$INITD" || grep -Fq 'TACHYON_UI_UC' "$INITD" || grep -Fq 'TACHYON_STATUS_UC' "$INITD"; then fail "init.d must not orchestrate state/UI/status modules directly" fi -if grep -Fq 'procd_set_param command "$FORKOP_BIN" start' "$INITD"; then +if grep -Fq 'procd_set_param command "$TACHYON_BIN" start' "$INITD"; then fail "init.d must not hand the one-shot start operation to procd as a daemon" fi if grep -Fq 'initd-should-' "$STATE_UC"; then diff --git a/tests/installer_owner.sh b/tests/installer_owner.sh index f18831b2..d018bdde 100644 --- a/tests/installer_owner.sh +++ b/tests/installer_owner.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -51,9 +51,9 @@ awk ' ' "$INSTALLER" > "$deadline_helper" [ -s "$deadline_helper" ] || fail "failed to extract installer deadline helper" chmod 0700 "$deadline_helper" -export FORKOP_DEADLINE_HELPER_PATH="$deadline_helper" -export FORKOP_INSTALLER_DEADLINE_HELPER="$deadline_helper" -export FORKOP_INSTALLER_COMMAND_RESULT="$WORK_DIR/installer-command" +export TACHYON_DEADLINE_HELPER_PATH="$deadline_helper" +export TACHYON_INSTALLER_DEADLINE_HELPER="$deadline_helper" +export TACHYON_INSTALLER_COMMAND_RESULT="$WORK_DIR/installer-command" export TMP_DIR="$WORK_DIR" grep -Fq 'run_with_deadline "$METADATA_TIMEOUT_SECONDS" wget -T "$CONNECT_TIMEOUT_SECONDS" -qO-' "$INSTALLER" || @@ -96,7 +96,7 @@ if grep -n -E '(^|[;&|[:space:]])uci[[:space:]]+-q|command_exists[[:space:]]+uci fail "install.sh must not own UCI reads/writes through shell" fi -shell_sing_box_owner_symbols='REQUIRED_SING_BOX_VERSION|SING_BOX_EXTENDED_|SING_BOX_MANAGED_SERVICE_MARKER|remove_old_sing_box_if_needed|install_managed_sing_box_service_script|remove_managed_sing_box_service_script|disable_sing_box_service_config|prepare_sing_box_service_disabled|prepare_sing_box_package_service_install|stop_forkop_for_sing_box_install|pkg_install_sing_box_variant|pkg_install_name_downgrade|pkg_remove_sing_box_conflict|resolve_sing_box_extended_release|install_sing_box_extended_(package|binary)|restore_sing_box_after_failed|move_file_to_backup|validate_extended_sing_box_binary|archive-member-path|sing-box-extended-arch-suffix|sing-box-extended-asset-url|sing-box-extended-package-asset-url' +shell_sing_box_owner_symbols='REQUIRED_SING_BOX_VERSION|SING_BOX_EXTENDED_|SING_BOX_MANAGED_SERVICE_MARKER|remove_old_sing_box_if_needed|install_managed_sing_box_service_script|remove_managed_sing_box_service_script|disable_sing_box_service_config|prepare_sing_box_service_disabled|prepare_sing_box_package_service_install|stop_tachyon_for_sing_box_install|pkg_install_sing_box_variant|pkg_install_name_downgrade|pkg_remove_sing_box_conflict|resolve_sing_box_extended_release|install_sing_box_extended_(package|binary)|restore_sing_box_after_failed|move_file_to_backup|validate_extended_sing_box_binary|archive-member-path|sing-box-extended-arch-suffix|sing-box-extended-asset-url|sing-box-extended-package-asset-url' if grep -n -E "$shell_sing_box_owner_symbols" "$INSTALLER" >/dev/null; then fail "install.sh must not contain shell sing-box install/runtime ownership" fi @@ -106,8 +106,8 @@ grep -Fq 'ensure_bootstrap_package "ucode-mod-fs"' "$INSTALLER" || grep -Fq 'ensure_bootstrap_package "ucode-mod-uci"' "$INSTALLER" || fail "install.sh must bootstrap ucode-mod-uci before embedded UCI helper use" -grep -Fq '/usr/bin/forkop component_action sing_box "$action"' "$INSTALLER" || - fail "selected sing-box install must delegate to forkop component_action" +grep -Fq '/usr/bin/tachyon component_action sing_box "$action"' "$INSTALLER" || + fail "selected sing-box install must delegate to tachyon component_action" for action in install_stable install_extended install_extended_compressed; do grep -Fq "action=\"$action\"" "$INSTALLER" || fail "selected sing-box installer is missing action mapping: $action" @@ -139,13 +139,13 @@ grep -Fq 'install_json_ucode installer-post-install' "$INSTALLER" || if grep -Fq 'installer_config_migration_path()' "$INSTALLER"; then fail "install.sh must not embed configuration migration logic" fi -grep -Fq 'ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate-podkop' "$INSTALLER" || +grep -Fq 'ucode -L /usr/lib/tachyon /usr/lib/tachyon/config/migration.uc migrate-podkop' "$INSTALLER" || fail "install.sh must delegate the legacy transition to the installed migration module" -if grep -n -E 'restore_forkop_dnsmasq_failsafe|remember_service_state|stop_conflicting_services|deactivate_original_forkop_if_present|remove_conflicting_dns_proxy|pkg_remove_if_installed|pkg_remove_matching_prefix|pkg_list_installed_names' "$INSTALLER" >/dev/null 2>&1; then +if grep -n -E 'restore_tachyon_dnsmasq_failsafe|remember_service_state|stop_conflicting_services|deactivate_original_tachyon_if_present|remove_conflicting_dns_proxy|pkg_remove_if_installed|pkg_remove_matching_prefix|pkg_list_installed_names' "$INSTALLER" >/dev/null 2>&1; then fail "install.sh must not keep shell cleanup/remove service owners" fi -if grep -n -E 'rm -f /var/luci-indexcache|rm -f /tmp/luci-indexcache|/etc/init\.d/rpcd[[:space:]]+reload|/etc/init\.d/forkop[[:space:]]+(start|stop|disable|enable|restart)|/etc/init\.d/forkop[[:space:]]+(stop|disable)' "$INSTALLER" >/dev/null 2>&1; then +if grep -n -E 'rm -f /var/luci-indexcache|rm -f /tmp/luci-indexcache|/etc/init\.d/rpcd[[:space:]]+reload|/etc/init\.d/tachyon[[:space:]]+(start|stop|disable|enable|restart)|/etc/init\.d/tachyon[[:space:]]+(stop|disable)' "$INSTALLER" >/dev/null 2>&1; then fail "install.sh shell must not own service/cache lifecycle actions" fi @@ -181,12 +181,12 @@ awk ' printf '%s\n' '{"tag_name":"0.0.1"}' | ucode "$helper" release-tag | grep -Fxq '0.0.1' || fail "embedded helper release-tag mode must parse release JSON" -release_json='{"tag_name":"0.0.1","assets":[{"name":"forkop_0.0.1.ipk","browser_download_url":"https://example.com/forkop.ipk"}]}' -printf '%s' "$release_json" | ucode "$helper" release-asset-url backend ipk | grep -Fxq 'https://example.com/forkop.ipk' || - fail "embedded helper must resolve the exact three-part Forkop package name" -if printf '%s' '{"tag_name":"0.0.1","assets":[{"name":"forkop_0.0.1_all.ipk","browser_download_url":"https://example.com/old.ipk"}]}' | +release_json='{"tag_name":"0.0.1","assets":[{"name":"tachyon_0.0.1.ipk","browser_download_url":"https://example.com/tachyon.ipk"}]}' +printf '%s' "$release_json" | ucode "$helper" release-asset-url backend ipk | grep -Fxq 'https://example.com/tachyon.ipk' || + fail "embedded helper must resolve the exact three-part Tachyon package name" +if printf '%s' '{"tag_name":"0.0.1","assets":[{"name":"tachyon_0.0.1_all.ipk","browser_download_url":"https://example.com/old.ipk"}]}' | ucode "$helper" release-asset-url backend ipk | grep -q .; then - fail "embedded helper must reject package names outside the Forkop release format" + fail "embedded helper must reject package names outside the Tachyon release format" fi cat >"$WORK_DIR/opkg" <<'SH' @@ -197,18 +197,18 @@ case "$1" in 'https-dns-proxy - 1.0' \ 'luci-app-https-dns-proxy - 1.0' \ 'luci-i18n-https-dns-proxy-ru - 1.0' - if [ -n "${FORKOP_INSTALLER_FAKE_LEGACY_BACKEND:-}" ]; then + if [ -n "${TACHYON_INSTALLER_FAKE_LEGACY_BACKEND:-}" ]; then printf '%s\n' \ - "$FORKOP_INSTALLER_FAKE_LEGACY_BACKEND - 1.0" \ - "luci-app-$FORKOP_INSTALLER_FAKE_LEGACY_BACKEND - 1.0" \ - "luci-i18n-$FORKOP_INSTALLER_FAKE_LEGACY_BACKEND-ru - 1.0" + "$TACHYON_INSTALLER_FAKE_LEGACY_BACKEND - 1.0" \ + "luci-app-$TACHYON_INSTALLER_FAKE_LEGACY_BACKEND - 1.0" \ + "luci-i18n-$TACHYON_INSTALLER_FAKE_LEGACY_BACKEND-ru - 1.0" fi ;; remove) shift [ "$1" = "--force-depends" ] && shift - printf '%s\n' "$1" >> "$FORKOP_INSTALLER_OPKG_LOG" - [ "${FORKOP_INSTALLER_FAIL_REMOVE:-}" != "$1" ] || exit 1 + printf '%s\n' "$1" >> "$TACHYON_INSTALLER_OPKG_LOG" + [ "${TACHYON_INSTALLER_FAIL_REMOVE:-}" != "$1" ] || exit 1 ;; esac exit 0 @@ -217,14 +217,14 @@ chmod 0755 "$WORK_DIR/opkg" cat >"$WORK_DIR/hanging-init" <<'SH' #!/usr/bin/env sh -printf '%s\n' "$1" >> "$FORKOP_INSTALLER_INIT_LOG" +printf '%s\n' "$1" >> "$TACHYON_INSTALLER_INIT_LOG" case "$1" in enabled|status|running) - printf '%s\n' "$$" >> "$FORKOP_INSTALLER_HANG_PID_LOG" + printf '%s\n' "$$" >> "$TACHYON_INSTALLER_HANG_PID_LOG" trap '' TERM sh -c ' trap "" TERM - printf "%s\n" "$$" >> "$FORKOP_INSTALLER_HANG_PID_LOG" + printf "%s\n" "$$" >> "$TACHYON_INSTALLER_HANG_PID_LOG" while :; do sleep 30; done ' & wait "$!" @@ -252,8 +252,8 @@ HANG_PID_LOG="$WORK_DIR/hanging-pids.log" : > "$WORK_DIR/hanging-init.log" ORPHAN_PROBE_PID="$( - FORKOP_INSTALLER_INIT_LOG="$WORK_DIR/hanging-init.log" \ - FORKOP_INSTALLER_HANG_PID_LOG="$HANG_PID_LOG" \ + TACHYON_INSTALLER_INIT_LOG="$WORK_DIR/hanging-init.log" \ + TACHYON_INSTALLER_HANG_PID_LOG="$HANG_PID_LOG" \ sh -c '"$1" running >/dev/null 2>&1 & printf "%s\n" "$!"' sh "$WORK_DIR/hanging-init" )" ORPHAN_PROBE_PPID="" @@ -273,35 +273,35 @@ printf '%s\n' pending > "$WORK_DIR/start.retry" hanging_started="$(date +%s)" PATH="$WORK_DIR:$PATH" \ -FORKOP_INSTALLER_OPKG_LOG="$WORK_DIR/opkg.log" \ -FORKOP_INSTALLER_INIT="$WORK_DIR/hanging-init" \ -FORKOP_INSTALLER_BIN="$WORK_DIR/hanging-bin" \ -FORKOP_INSTALLER_LIB="$WORK_DIR/missing-forkop-lib" \ -FORKOP_INSTALLER_UCI_DEFAULTS="$WORK_DIR/missing-uci-defaults" \ -FORKOP_INSTALLER_LUCI_VIEW="$WORK_DIR/missing-luci-view" \ -FORKOP_INSTALLER_MENU_JSON="$WORK_DIR/missing-menu.json" \ -FORKOP_INSTALLER_ACL_JSON="$WORK_DIR/missing-acl.json" \ -FORKOP_INSTALLER_RU_LMO="$WORK_DIR/missing-ru.lmo" \ -FORKOP_INSTALLER_EN_LMO="$WORK_DIR/missing-en.lmo" \ -FORKOP_INSTALLER_RU_LUA="$WORK_DIR/missing-ru.lua" \ -FORKOP_INSTALLER_EN_LUA="$WORK_DIR/missing-en.lua" \ -FORKOP_INSTALLER_LEGACY_BASE_INIT="$WORK_DIR/missing-original-init" \ -FORKOP_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ -FORKOP_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ -FORKOP_INSTALLER_RC_DIR="$WORK_DIR/rc.d" \ -FORKOP_INSTALLER_START_RETRY_FILE="$WORK_DIR/start.retry" \ -FORKOP_INSTALLER_START_RETRY_PID_FILE="$WORK_DIR/start-retry.pid" \ -FORKOP_INSTALLER_ORPHAN_PPID="$ORPHAN_PROBE_PPID" \ -FORKOP_INSTALLER_SERVICE_PROBE_TIMEOUT=1 \ -FORKOP_INSTALLER_SERVICE_ACTION_TIMEOUT=3 \ -FORKOP_INSTALLER_INIT_LOG="$WORK_DIR/hanging-init.log" \ -FORKOP_INSTALLER_HANG_PID_LOG="$HANG_PID_LOG" \ -FORKOP_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ +TACHYON_INSTALLER_OPKG_LOG="$WORK_DIR/opkg.log" \ +TACHYON_INSTALLER_INIT="$WORK_DIR/hanging-init" \ +TACHYON_INSTALLER_BIN="$WORK_DIR/hanging-bin" \ +TACHYON_INSTALLER_LIB="$WORK_DIR/missing-tachyon-lib" \ +TACHYON_INSTALLER_UCI_DEFAULTS="$WORK_DIR/missing-uci-defaults" \ +TACHYON_INSTALLER_LUCI_VIEW="$WORK_DIR/missing-luci-view" \ +TACHYON_INSTALLER_MENU_JSON="$WORK_DIR/missing-menu.json" \ +TACHYON_INSTALLER_ACL_JSON="$WORK_DIR/missing-acl.json" \ +TACHYON_INSTALLER_RU_LMO="$WORK_DIR/missing-ru.lmo" \ +TACHYON_INSTALLER_EN_LMO="$WORK_DIR/missing-en.lmo" \ +TACHYON_INSTALLER_RU_LUA="$WORK_DIR/missing-ru.lua" \ +TACHYON_INSTALLER_EN_LUA="$WORK_DIR/missing-en.lua" \ +TACHYON_INSTALLER_LEGACY_BASE_INIT="$WORK_DIR/missing-original-init" \ +TACHYON_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ +TACHYON_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ +TACHYON_INSTALLER_RC_DIR="$WORK_DIR/rc.d" \ +TACHYON_INSTALLER_START_RETRY_FILE="$WORK_DIR/start.retry" \ +TACHYON_INSTALLER_START_RETRY_PID_FILE="$WORK_DIR/start-retry.pid" \ +TACHYON_INSTALLER_ORPHAN_PPID="$ORPHAN_PROBE_PPID" \ +TACHYON_INSTALLER_SERVICE_PROBE_TIMEOUT=1 \ +TACHYON_INSTALLER_SERVICE_ACTION_TIMEOUT=3 \ +TACHYON_INSTALLER_INIT_LOG="$WORK_DIR/hanging-init.log" \ +TACHYON_INSTALLER_HANG_PID_LOG="$HANG_PID_LOG" \ +TACHYON_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ ucode "$helper" installer-cleanup-legacy > "$WORK_DIR/hanging-state.env" hanging_elapsed="$(($(date +%s) - hanging_started))" [ "$hanging_elapsed" -lt 15 ] || fail "installer cleanup did not bound hanging init.d probes (${hanging_elapsed}s)" -grep -Fxq 'FORKOP_WAS_ENABLED=1' "$WORK_DIR/hanging-state.env" || +grep -Fxq 'TACHYON_WAS_ENABLED=1' "$WORK_DIR/hanging-state.env" || fail "installer cleanup must recover enabled state from rc.d after a probe timeout" for action in enabled status running stop disable; do grep -Fxq "$action" "$WORK_DIR/hanging-init.log" || @@ -325,22 +325,22 @@ done < "$HANG_PID_LOG" : >"$WORK_DIR/opkg.log" printf '%s\n' '1' | PATH="$WORK_DIR:$PATH" \ - FORKOP_INSTALLER_OPKG_LOG="$WORK_DIR/opkg.log" \ - FORKOP_INSTALLER_INIT="$WORK_DIR/missing-forkop-init" \ - FORKOP_INSTALLER_BIN="$WORK_DIR/missing-forkop-bin" \ - FORKOP_INSTALLER_LIB="$WORK_DIR/missing-forkop-lib" \ - FORKOP_INSTALLER_UCI_DEFAULTS="$WORK_DIR/missing-uci-defaults" \ - FORKOP_INSTALLER_LUCI_VIEW="$WORK_DIR/missing-luci-view" \ - FORKOP_INSTALLER_MENU_JSON="$WORK_DIR/missing-menu.json" \ - FORKOP_INSTALLER_ACL_JSON="$WORK_DIR/missing-acl.json" \ - FORKOP_INSTALLER_RU_LMO="$WORK_DIR/missing-ru.lmo" \ - FORKOP_INSTALLER_EN_LMO="$WORK_DIR/missing-en.lmo" \ - FORKOP_INSTALLER_RU_LUA="$WORK_DIR/missing-ru.lua" \ - FORKOP_INSTALLER_EN_LUA="$WORK_DIR/missing-en.lua" \ - FORKOP_INSTALLER_LEGACY_BASE_INIT="$WORK_DIR/missing-original-init" \ - FORKOP_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ - FORKOP_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ - FORKOP_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ + TACHYON_INSTALLER_OPKG_LOG="$WORK_DIR/opkg.log" \ + TACHYON_INSTALLER_INIT="$WORK_DIR/missing-tachyon-init" \ + TACHYON_INSTALLER_BIN="$WORK_DIR/missing-tachyon-bin" \ + TACHYON_INSTALLER_LIB="$WORK_DIR/missing-tachyon-lib" \ + TACHYON_INSTALLER_UCI_DEFAULTS="$WORK_DIR/missing-uci-defaults" \ + TACHYON_INSTALLER_LUCI_VIEW="$WORK_DIR/missing-luci-view" \ + TACHYON_INSTALLER_MENU_JSON="$WORK_DIR/missing-menu.json" \ + TACHYON_INSTALLER_ACL_JSON="$WORK_DIR/missing-acl.json" \ + TACHYON_INSTALLER_RU_LMO="$WORK_DIR/missing-ru.lmo" \ + TACHYON_INSTALLER_EN_LMO="$WORK_DIR/missing-en.lmo" \ + TACHYON_INSTALLER_RU_LUA="$WORK_DIR/missing-ru.lua" \ + TACHYON_INSTALLER_EN_LUA="$WORK_DIR/missing-en.lua" \ + TACHYON_INSTALLER_LEGACY_BASE_INIT="$WORK_DIR/missing-original-init" \ + TACHYON_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ + TACHYON_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ + TACHYON_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ ucode "$helper" installer-cleanup-legacy >"$WORK_DIR/conflict-state.env" 2>"$WORK_DIR/conflict.err" grep -Fxq 'https-dns-proxy' "$WORK_DIR/opkg.log" || @@ -357,7 +357,7 @@ write_fake_service_init() { case "$1" in enabled) exit 0 ;; status) printf '%s\n' running; exit 0 ;; - stop|disable|enable|start|restart) printf '%s\n' "$1" >> "$FORKOP_INSTALLER_INIT_LOG"; exit 0 ;; + stop|disable|enable|start|restart) printf '%s\n' "$1" >> "$TACHYON_INSTALLER_INIT_LOG"; exit 0 ;; esac exit 0 SH @@ -368,7 +368,7 @@ write_fake_service_init "$WORK_DIR/legacy-init" cat >"$WORK_DIR/legacy-bin" <<'SH' #!/usr/bin/env sh -printf '%s\n' "$*" >> "$FORKOP_INSTALLER_BIN_LOG" +printf '%s\n' "$*" >> "$TACHYON_INSTALLER_BIN_LOG" case "$1" in get_status) printf '%s\n' '{"running":1}' ;; esac @@ -380,36 +380,36 @@ chmod 0755 "$WORK_DIR/legacy-bin" : >"$WORK_DIR/bin.log" state="$WORK_DIR/state.env" PATH="$WORK_DIR:$PATH" \ -FORKOP_INSTALLER_INIT="$WORK_DIR/missing-forkop-init" \ -FORKOP_INSTALLER_BIN="$WORK_DIR/missing-forkop-bin" \ -FORKOP_INSTALLER_LIB="$WORK_DIR/missing-forkop-lib" \ -FORKOP_INSTALLER_UCI_DEFAULTS="$WORK_DIR/uci-defaults" \ -FORKOP_INSTALLER_LUCI_VIEW="$WORK_DIR/luci-view" \ -FORKOP_INSTALLER_MENU_JSON="$WORK_DIR/menu.json" \ -FORKOP_INSTALLER_ACL_JSON="$WORK_DIR/acl.json" \ -FORKOP_INSTALLER_RU_LMO="$WORK_DIR/ru.lmo" \ -FORKOP_INSTALLER_EN_LMO="$WORK_DIR/en.lmo" \ -FORKOP_INSTALLER_RU_LUA="$WORK_DIR/ru.lua" \ -FORKOP_INSTALLER_EN_LUA="$WORK_DIR/en.lua" \ -FORKOP_INSTALLER_LEGACY_BASE_INIT="$WORK_DIR/missing-original-init" \ -FORKOP_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ -FORKOP_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ -FORKOP_INSTALLER_LEGACY_CONFIG_ALT="$LEGACY_CONFIG_ALT" \ -FORKOP_INSTALLER_LEGACY_INIT="$WORK_DIR/legacy-init" \ -FORKOP_INSTALLER_LEGACY_BIN="$WORK_DIR/legacy-bin" \ -FORKOP_INSTALLER_LEGACY_LIB="$WORK_DIR/legacy-lib" \ -FORKOP_INSTALLER_FAKE_LEGACY_BACKEND="$LEGACY_BACKEND" \ -FORKOP_INSTALLER_OPKG_LOG="$WORK_DIR/opkg.log" \ -FORKOP_INSTALLER_INIT_LOG="$WORK_DIR/init.log" \ -FORKOP_INSTALLER_BIN_LOG="$WORK_DIR/bin.log" \ -FORKOP_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ +TACHYON_INSTALLER_INIT="$WORK_DIR/missing-tachyon-init" \ +TACHYON_INSTALLER_BIN="$WORK_DIR/missing-tachyon-bin" \ +TACHYON_INSTALLER_LIB="$WORK_DIR/missing-tachyon-lib" \ +TACHYON_INSTALLER_UCI_DEFAULTS="$WORK_DIR/uci-defaults" \ +TACHYON_INSTALLER_LUCI_VIEW="$WORK_DIR/luci-view" \ +TACHYON_INSTALLER_MENU_JSON="$WORK_DIR/menu.json" \ +TACHYON_INSTALLER_ACL_JSON="$WORK_DIR/acl.json" \ +TACHYON_INSTALLER_RU_LMO="$WORK_DIR/ru.lmo" \ +TACHYON_INSTALLER_EN_LMO="$WORK_DIR/en.lmo" \ +TACHYON_INSTALLER_RU_LUA="$WORK_DIR/ru.lua" \ +TACHYON_INSTALLER_EN_LUA="$WORK_DIR/en.lua" \ +TACHYON_INSTALLER_LEGACY_BASE_INIT="$WORK_DIR/missing-original-init" \ +TACHYON_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ +TACHYON_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ +TACHYON_INSTALLER_LEGACY_CONFIG_ALT="$LEGACY_CONFIG_ALT" \ +TACHYON_INSTALLER_LEGACY_INIT="$WORK_DIR/legacy-init" \ +TACHYON_INSTALLER_LEGACY_BIN="$WORK_DIR/legacy-bin" \ +TACHYON_INSTALLER_LEGACY_LIB="$WORK_DIR/legacy-lib" \ +TACHYON_INSTALLER_FAKE_LEGACY_BACKEND="$LEGACY_BACKEND" \ +TACHYON_INSTALLER_OPKG_LOG="$WORK_DIR/opkg.log" \ +TACHYON_INSTALLER_INIT_LOG="$WORK_DIR/init.log" \ +TACHYON_INSTALLER_BIN_LOG="$WORK_DIR/bin.log" \ +TACHYON_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ ucode "$helper" installer-cleanup-legacy >"$state" -grep -Fxq 'FORKOP_WAS_ENABLED=1' "$state" || +grep -Fxq 'TACHYON_WAS_ENABLED=1' "$state" || fail "installer cleanup must export previous enabled state" -grep -Fxq 'FORKOP_WAS_RUNNING=1' "$state" || +grep -Fxq 'TACHYON_WAS_RUNNING=1' "$state" || fail "installer cleanup must export previous running state" -grep -Fxq 'FORKOP_LEGACY_DETECTED=1' "$state" || +grep -Fxq 'TACHYON_LEGACY_DETECTED=1' "$state" || fail "installer cleanup must report the legacy package transition" grep -Fxq 'stop' "$WORK_DIR/init.log" || fail "installer cleanup must stop the legacy service through ucode owner" @@ -424,12 +424,12 @@ grep -Fxq "luci-app-$LEGACY_BACKEND" "$WORK_DIR/opkg.log" || if printf '%s\n' '1' | PATH="$WORK_DIR:$PATH" \ - FORKOP_INSTALLER_OPKG_LOG="$WORK_DIR/opkg.log" \ - FORKOP_INSTALLER_FAIL_REMOVE="$LEGACY_BACKEND" \ - FORKOP_INSTALLER_FAKE_LEGACY_BACKEND="$LEGACY_BACKEND" \ - FORKOP_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ - FORKOP_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ - FORKOP_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ + TACHYON_INSTALLER_OPKG_LOG="$WORK_DIR/opkg.log" \ + TACHYON_INSTALLER_FAIL_REMOVE="$LEGACY_BACKEND" \ + TACHYON_INSTALLER_FAKE_LEGACY_BACKEND="$LEGACY_BACKEND" \ + TACHYON_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ + TACHYON_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ + TACHYON_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ ucode "$helper" installer-cleanup-legacy >/dev/null 2>&1; then fail "installer cleanup must stop when the package manager cannot remove the legacy backend" fi @@ -437,7 +437,7 @@ fi legacy_config="$WORK_DIR/legacy-config" legacy_config_alt="$WORK_DIR/legacy-config-alt" legacy_persistent="$WORK_DIR/legacy-persistent" -forkop_persistent="$WORK_DIR/forkop-persistent" +tachyon_persistent="$WORK_DIR/tachyon-persistent" legacy_runtime="$WORK_DIR/legacy-runtime" legacy_tmp="$WORK_DIR/legacy-tmp" legacy_tmp_alt="$WORK_DIR/legacy-tmp-alt" @@ -461,11 +461,11 @@ legacy_nested_backup="$legacy_scan_root/audit/${LEGACY_BACKEND}.config" mkdir -p \ "$legacy_persistent/tailscale/server-new" \ "$legacy_persistent/tailscale/server-existing" \ - "$forkop_persistent/tailscale/server-existing" \ + "$tachyon_persistent/tailscale/server-existing" \ "$legacy_runtime" "$legacy_tmp" "$legacy_tmp_alt" printf '%s\n' 'legacy-node-identity' >"$legacy_persistent/tailscale/server-new/node.key" printf '%s\n' 'stale-legacy-identity' >"$legacy_persistent/tailscale/server-existing/node.key" -printf '%s\n' 'current-forkop-identity' >"$forkop_persistent/tailscale/server-existing/node.key" +printf '%s\n' 'current-tachyon-identity' >"$tachyon_persistent/tailscale/server-existing/node.key" mkdir -p "$(dirname "$legacy_nested_uci")" "$(dirname "$legacy_nested_lock")" "$(dirname "$legacy_nested_backup")" touch "$legacy_config" "$legacy_config_alt" "$legacy_config-opkg" touch \ @@ -485,35 +485,35 @@ touch \ "$legacy_nested_uci" \ "$legacy_nested_lock" \ "$legacy_nested_backup" -FORKOP_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ -FORKOP_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ -FORKOP_INSTALLER_LEGACY_CONFIG_ALT="$LEGACY_CONFIG_ALT" \ -FORKOP_INSTALLER_LEGACY_CONFIG="$legacy_config" \ -FORKOP_INSTALLER_LEGACY_CONFIG_FILE_ALT="$legacy_config_alt" \ -FORKOP_INSTALLER_LEGACY_PERSISTENT_DIR="$legacy_persistent" \ -FORKOP_INSTALLER_PERSISTENT_DIR="$forkop_persistent" \ -FORKOP_INSTALLER_LEGACY_RUNTIME_DIR="$legacy_runtime" \ -FORKOP_INSTALLER_LEGACY_TMP_DIR="$legacy_tmp" \ -FORKOP_INSTALLER_LEGACY_TMP_ALT_DIR="$legacy_tmp_alt" \ -FORKOP_INSTALLER_LEGACY_BASE_CONFIG="$legacy_base_config" \ -FORKOP_INSTALLER_LEGACY_BASE_PERSISTENT_DIR="$legacy_base_persistent" \ -FORKOP_INSTALLER_LEGACY_BASE_RUNTIME_DIR="$legacy_base_runtime" \ -FORKOP_INSTALLER_LEGACY_BASE_TMP_DIR="$legacy_base_tmp" \ -FORKOP_INSTALLER_LEGACY_BASE_INIT="$legacy_base_init" \ -FORKOP_INSTALLER_LEGACY_BASE_BIN="$legacy_base_bin" \ -FORKOP_INSTALLER_LEGACY_BASE_LIB="$legacy_base_lib" \ -FORKOP_INSTALLER_LEGACY_BASE_UCI_DEFAULTS="$legacy_base_uci_defaults" \ -FORKOP_INSTALLER_LEGACY_BASE_LUCI_VIEW="$legacy_base_luci_view" \ -FORKOP_INSTALLER_LEGACY_BASE_MENU_JSON="$legacy_base_menu" \ -FORKOP_INSTALLER_LEGACY_BASE_ACL_JSON="$legacy_base_acl" \ -FORKOP_INSTALLER_LEGACY_BASE_I18N="$legacy_base_i18n" \ -FORKOP_INSTALLER_LEGACY_TMP_PACKAGE_GLOB="$WORK_DIR/*${LEGACY_BRAND}*" \ -FORKOP_INSTALLER_LEGACY_SCAN_ROOTS="$legacy_scan_root" \ +TACHYON_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ +TACHYON_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ +TACHYON_INSTALLER_LEGACY_CONFIG_ALT="$LEGACY_CONFIG_ALT" \ +TACHYON_INSTALLER_LEGACY_CONFIG="$legacy_config" \ +TACHYON_INSTALLER_LEGACY_CONFIG_FILE_ALT="$legacy_config_alt" \ +TACHYON_INSTALLER_LEGACY_PERSISTENT_DIR="$legacy_persistent" \ +TACHYON_INSTALLER_PERSISTENT_DIR="$tachyon_persistent" \ +TACHYON_INSTALLER_LEGACY_RUNTIME_DIR="$legacy_runtime" \ +TACHYON_INSTALLER_LEGACY_TMP_DIR="$legacy_tmp" \ +TACHYON_INSTALLER_LEGACY_TMP_ALT_DIR="$legacy_tmp_alt" \ +TACHYON_INSTALLER_LEGACY_BASE_CONFIG="$legacy_base_config" \ +TACHYON_INSTALLER_LEGACY_BASE_PERSISTENT_DIR="$legacy_base_persistent" \ +TACHYON_INSTALLER_LEGACY_BASE_RUNTIME_DIR="$legacy_base_runtime" \ +TACHYON_INSTALLER_LEGACY_BASE_TMP_DIR="$legacy_base_tmp" \ +TACHYON_INSTALLER_LEGACY_BASE_INIT="$legacy_base_init" \ +TACHYON_INSTALLER_LEGACY_BASE_BIN="$legacy_base_bin" \ +TACHYON_INSTALLER_LEGACY_BASE_LIB="$legacy_base_lib" \ +TACHYON_INSTALLER_LEGACY_BASE_UCI_DEFAULTS="$legacy_base_uci_defaults" \ +TACHYON_INSTALLER_LEGACY_BASE_LUCI_VIEW="$legacy_base_luci_view" \ +TACHYON_INSTALLER_LEGACY_BASE_MENU_JSON="$legacy_base_menu" \ +TACHYON_INSTALLER_LEGACY_BASE_ACL_JSON="$legacy_base_acl" \ +TACHYON_INSTALLER_LEGACY_BASE_I18N="$legacy_base_i18n" \ +TACHYON_INSTALLER_LEGACY_TMP_PACKAGE_GLOB="$WORK_DIR/*${LEGACY_BRAND}*" \ +TACHYON_INSTALLER_LEGACY_SCAN_ROOTS="$legacy_scan_root" \ ucode "$helper" installer-finalize-legacy -[ "$(cat "$forkop_persistent/tailscale/server-new/node.key")" = 'legacy-node-identity' ] || +[ "$(cat "$tachyon_persistent/tailscale/server-new/node.key")" = 'legacy-node-identity' ] || fail "installer finalization must migrate legacy Tailscale node identity" -[ "$(cat "$forkop_persistent/tailscale/server-existing/node.key")" = 'current-forkop-identity' ] || - fail "installer finalization must not overwrite existing Forkop Tailscale state" +[ "$(cat "$tachyon_persistent/tailscale/server-existing/node.key")" = 'current-tachyon-identity' ] || + fail "installer finalization must not overwrite existing Tachyon Tailscale state" for path in "$legacy_config" "$legacy_config_alt" "$legacy_config-opkg" \ "$legacy_persistent" "$legacy_runtime" "$legacy_tmp" "$legacy_tmp_alt" \ "$legacy_base_config.backup" "$legacy_base_persistent.cache" \ @@ -528,52 +528,52 @@ done mkdir -p "$legacy_persistent/tailscale/server-failed" printf '%s\n' 'preserve-on-failure' >"$legacy_persistent/tailscale/server-failed/node.key" -forkop_persistent_file="$WORK_DIR/forkop-persistent-file" -touch "$forkop_persistent_file" -if FORKOP_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ - FORKOP_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ - FORKOP_INSTALLER_LEGACY_CONFIG_ALT="$LEGACY_CONFIG_ALT" \ - FORKOP_INSTALLER_LEGACY_CONFIG="$legacy_config" \ - FORKOP_INSTALLER_LEGACY_CONFIG_FILE_ALT="$legacy_config_alt" \ - FORKOP_INSTALLER_LEGACY_PERSISTENT_DIR="$legacy_persistent" \ - FORKOP_INSTALLER_PERSISTENT_DIR="$forkop_persistent_file" \ - FORKOP_INSTALLER_LEGACY_RUNTIME_DIR="$legacy_runtime" \ - FORKOP_INSTALLER_LEGACY_TMP_DIR="$legacy_tmp" \ - FORKOP_INSTALLER_LEGACY_TMP_ALT_DIR="$legacy_tmp_alt" \ - FORKOP_INSTALLER_LEGACY_BASE_CONFIG="$legacy_base_config" \ - FORKOP_INSTALLER_LEGACY_BASE_PERSISTENT_DIR="$legacy_base_persistent" \ - FORKOP_INSTALLER_LEGACY_BASE_RUNTIME_DIR="$legacy_base_runtime" \ - FORKOP_INSTALLER_LEGACY_BASE_TMP_DIR="$legacy_base_tmp" \ - FORKOP_INSTALLER_LEGACY_BASE_INIT="$legacy_base_init" \ - FORKOP_INSTALLER_LEGACY_BASE_BIN="$legacy_base_bin" \ - FORKOP_INSTALLER_LEGACY_BASE_LIB="$legacy_base_lib" \ - FORKOP_INSTALLER_LEGACY_BASE_UCI_DEFAULTS="$legacy_base_uci_defaults" \ - FORKOP_INSTALLER_LEGACY_BASE_LUCI_VIEW="$legacy_base_luci_view" \ - FORKOP_INSTALLER_LEGACY_BASE_MENU_JSON="$legacy_base_menu" \ - FORKOP_INSTALLER_LEGACY_BASE_ACL_JSON="$legacy_base_acl" \ - FORKOP_INSTALLER_LEGACY_BASE_I18N="$legacy_base_i18n" \ - FORKOP_INSTALLER_LEGACY_TMP_PACKAGE_GLOB="$WORK_DIR/missing-${LEGACY_BRAND}*" \ - FORKOP_INSTALLER_LEGACY_SCAN_ROOTS="$legacy_scan_root" \ +tachyon_persistent_file="$WORK_DIR/tachyon-persistent-file" +touch "$tachyon_persistent_file" +if TACHYON_INSTALLER_LEGACY_BRAND="$LEGACY_BRAND" \ + TACHYON_INSTALLER_LEGACY_BACKEND="$LEGACY_BACKEND" \ + TACHYON_INSTALLER_LEGACY_CONFIG_ALT="$LEGACY_CONFIG_ALT" \ + TACHYON_INSTALLER_LEGACY_CONFIG="$legacy_config" \ + TACHYON_INSTALLER_LEGACY_CONFIG_FILE_ALT="$legacy_config_alt" \ + TACHYON_INSTALLER_LEGACY_PERSISTENT_DIR="$legacy_persistent" \ + TACHYON_INSTALLER_PERSISTENT_DIR="$tachyon_persistent_file" \ + TACHYON_INSTALLER_LEGACY_RUNTIME_DIR="$legacy_runtime" \ + TACHYON_INSTALLER_LEGACY_TMP_DIR="$legacy_tmp" \ + TACHYON_INSTALLER_LEGACY_TMP_ALT_DIR="$legacy_tmp_alt" \ + TACHYON_INSTALLER_LEGACY_BASE_CONFIG="$legacy_base_config" \ + TACHYON_INSTALLER_LEGACY_BASE_PERSISTENT_DIR="$legacy_base_persistent" \ + TACHYON_INSTALLER_LEGACY_BASE_RUNTIME_DIR="$legacy_base_runtime" \ + TACHYON_INSTALLER_LEGACY_BASE_TMP_DIR="$legacy_base_tmp" \ + TACHYON_INSTALLER_LEGACY_BASE_INIT="$legacy_base_init" \ + TACHYON_INSTALLER_LEGACY_BASE_BIN="$legacy_base_bin" \ + TACHYON_INSTALLER_LEGACY_BASE_LIB="$legacy_base_lib" \ + TACHYON_INSTALLER_LEGACY_BASE_UCI_DEFAULTS="$legacy_base_uci_defaults" \ + TACHYON_INSTALLER_LEGACY_BASE_LUCI_VIEW="$legacy_base_luci_view" \ + TACHYON_INSTALLER_LEGACY_BASE_MENU_JSON="$legacy_base_menu" \ + TACHYON_INSTALLER_LEGACY_BASE_ACL_JSON="$legacy_base_acl" \ + TACHYON_INSTALLER_LEGACY_BASE_I18N="$legacy_base_i18n" \ + TACHYON_INSTALLER_LEGACY_TMP_PACKAGE_GLOB="$WORK_DIR/missing-${LEGACY_BRAND}*" \ + TACHYON_INSTALLER_LEGACY_SCAN_ROOTS="$legacy_scan_root" \ ucode "$helper" installer-finalize-legacy >/dev/null 2>&1; then fail "installer finalization must fail when legacy Tailscale state cannot be migrated" fi [ "$(cat "$legacy_persistent/tailscale/server-failed/node.key")" = 'preserve-on-failure' ] || fail "failed Tailscale migration must preserve the legacy identity" -write_fake_service_init "$WORK_DIR/forkop-init" +write_fake_service_init "$WORK_DIR/tachyon-init" touch "$WORK_DIR/luci-indexcache.one" "$WORK_DIR/luci-indexcache.two" : >"$WORK_DIR/init.log" -FORKOP_INSTALLER_INIT="$WORK_DIR/forkop-init" \ -FORKOP_INSTALLER_RPCD_INIT="$WORK_DIR/missing-rpcd" \ -FORKOP_INSTALLER_LUCI_CACHE_GLOBS="$WORK_DIR/luci-indexcache*" \ -FORKOP_INSTALLER_LATEST_VERSION_CACHE="$WORK_DIR/latest.cache" \ -FORKOP_INSTALLER_SYSTEM_INFO_CACHE="$WORK_DIR/system-info.json" \ -FORKOP_INSTALLER_SERVER_COUNTRY_CACHE="$WORK_DIR/server-country.json" \ -FORKOP_INSTALLER_SING_BOX_VERSION_CACHE="$WORK_DIR/sing-box-version" \ -FORKOP_INSTALLER_TMP_SYSTEM_INFO_CACHE="$WORK_DIR/tmp-system-info.json" \ -FORKOP_WAS_ENABLED=1 \ -FORKOP_WAS_RUNNING=1 \ -FORKOP_INSTALLER_INIT_LOG="$WORK_DIR/init.log" \ +TACHYON_INSTALLER_INIT="$WORK_DIR/tachyon-init" \ +TACHYON_INSTALLER_RPCD_INIT="$WORK_DIR/missing-rpcd" \ +TACHYON_INSTALLER_LUCI_CACHE_GLOBS="$WORK_DIR/luci-indexcache*" \ +TACHYON_INSTALLER_LATEST_VERSION_CACHE="$WORK_DIR/latest.cache" \ +TACHYON_INSTALLER_SYSTEM_INFO_CACHE="$WORK_DIR/system-info.json" \ +TACHYON_INSTALLER_SERVER_COUNTRY_CACHE="$WORK_DIR/server-country.json" \ +TACHYON_INSTALLER_SING_BOX_VERSION_CACHE="$WORK_DIR/sing-box-version" \ +TACHYON_INSTALLER_TMP_SYSTEM_INFO_CACHE="$WORK_DIR/tmp-system-info.json" \ +TACHYON_WAS_ENABLED=1 \ +TACHYON_WAS_RUNNING=1 \ +TACHYON_INSTALLER_INIT_LOG="$WORK_DIR/init.log" \ ucode "$helper" installer-post-install if compgen -G "$WORK_DIR/luci-indexcache*" >/dev/null; then diff --git a/tests/log_hygiene.sh b/tests/log_hygiene.sh index 90512951..b4ba6328 100644 --- a/tests/log_hygiene.sh +++ b/tests/log_hygiene.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -12,8 +12,8 @@ reject_runtime_pattern() { local pattern="$1" local label="$2" - if rg -n --fixed-strings "$pattern" "$ROOT_DIR/forkop/files" >/dev/null 2>&1; then - rg -n --fixed-strings "$pattern" "$ROOT_DIR/forkop/files" >&2 + if rg -n --fixed-strings "$pattern" "$ROOT_DIR/tachyon/files" >/dev/null 2>&1; then + rg -n --fixed-strings "$pattern" "$ROOT_DIR/tachyon/files" >&2 fail "$label" fi } @@ -22,8 +22,8 @@ reject_runtime_regex() { local pattern="$1" local label="$2" - if rg -n "$pattern" "$ROOT_DIR/forkop/files" >/dev/null 2>&1; then - rg -n "$pattern" "$ROOT_DIR/forkop/files" >&2 + if rg -n "$pattern" "$ROOT_DIR/tachyon/files" >/dev/null 2>&1; then + rg -n "$pattern" "$ROOT_DIR/tachyon/files" >&2 fail "$label" fi } @@ -44,7 +44,7 @@ reject_runtime_pattern "Current sing-box config hash:" \ "sing-box config logs must not emit duplicate hash lines" reject_runtime_pattern "Temporary sing-box config hash:" \ "sing-box config logs must not emit duplicate hash lines" -reject_runtime_pattern "Forkop did not reach a stable running state after start" \ +reject_runtime_pattern "Tachyon did not reach a stable running state after start" \ "startup rollback logs must not be fatal secondary errors" reject_runtime_pattern "Flush nft" \ "stop logs must not expose routine nft cleanup as user-facing info" diff --git a/tests/luci_duration_validation.sh b/tests/luci_duration_validation.sh index 09ad8435..c03f060e 100755 --- a/tests/luci_duration_validation.sh +++ b/tests/luci_duration_validation.sh @@ -1,11 +1,11 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" for file in settings.js section.js; do source="$(sed -n '/^function isSingBoxDuration(/,/^}/p' \ - "$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/$file")" + "$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/$file")" DURATION_SOURCE="$source" node <<'NODE' const validate = Function(`${process.env.DURATION_SOURCE}; return isSingBoxDuration`)(); diff --git a/tests/luci_dynamiclist_layout.sh b/tests/luci_dynamiclist_layout.sh index d4554ffd..577aa046 100644 --- a/tests/luci_dynamiclist_layout.sh +++ b/tests/luci_dynamiclist_layout.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SECTION_JS="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js" +SECTION_JS="$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -124,7 +124,7 @@ ports_line="$(grep -n 'dependsOnRoutingAction(portsOption);' <<<"$create_section dashboard_line="$(grep -n 'addDashboardServerFilterOptions(section);' <<<"$create_section" | cut -d: -f1)" [[ "$dashboard_line" -gt "$ports_line" ]] || fail "dashboard server filter must be the last section option block" -if grep -Fq '__forkop_no_group__' "$SECTION_JS"; then +if grep -Fq '__tachyon_no_group__' "$SECTION_JS"; then fail "dashboard group selectors must not duplicate the placeholder with a fake choice" fi diff --git a/tests/luci_interface_settings.sh b/tests/luci_interface_settings.sh index 156b73ab..e63b7821 100644 --- a/tests/luci_interface_settings.sh +++ b/tests/luci_interface_settings.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SECTION_JS="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js" +SECTION_JS="$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" fail() { printf 'FAIL: %s\n' "$1" >&2 diff --git a/tests/luci_localization.sh b/tests/luci_localization.sh index 848953ba..70e41239 100755 --- a/tests/luci_localization.sh +++ b/tests/luci_localization.sh @@ -1,10 +1,10 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SECTION_JS="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js" -SOURCE_PO="$ROOT_DIR/fe-app-forkop/locales/forkop.ru.po" -PACKAGE_PO="$ROOT_DIR/luci-app-forkop/po/ru/forkop.po" +SECTION_JS="$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" +SOURCE_PO="$ROOT_DIR/fe-app-tachyon/locales/tachyon.ru.po" +PACKAGE_PO="$ROOT_DIR/luci-app-tachyon/po/ru/tachyon.po" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -12,10 +12,10 @@ fail() { } if grep -Fq '_("Dismiss")' "$SECTION_JS"; then - fail "Forkop modals must use Close instead of the shared LuCI Dismiss key" + fail "Tachyon modals must use Close instead of the shared LuCI Dismiss key" fi grep -Fq '_("Close")' "$SECTION_JS" || - fail "Forkop section settings modal must expose a Close action" + fail "Tachyon section settings modal must expose a Close action" for po in "$SOURCE_PO" "$PACKAGE_PO"; do awk ' diff --git a/tests/luci_server_auth.sh b/tests/luci_server_auth.sh index 23065ea3..3d1ae5b8 100755 --- a/tests/luci_server_auth.sh +++ b/tests/luci_server_auth.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SERVER_JS="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/server.js" +SERVER_JS="$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js" node - "$SERVER_JS" <<'NODE' const fs = require("fs"); diff --git a/tests/luci_stacked_settings_validation.sh b/tests/luci_stacked_settings_validation.sh index 32a05254..75427994 100755 --- a/tests/luci_stacked_settings_validation.sh +++ b/tests/luci_stacked_settings_validation.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SECTION_JS="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js" +SECTION_JS="$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" fail() { printf 'FAIL: %s\n' "$1" >&2 diff --git a/tests/luci_updates_theme.sh b/tests/luci_updates_theme.sh index 4bcdfab2..2f4cc494 100755 --- a/tests/luci_updates_theme.sh +++ b/tests/luci_updates_theme.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SOURCE_STYLES="$ROOT_DIR/fe-app-forkop/src/forkop/tabs/updates/styles.ts" -BUNDLE="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/main.js" +SOURCE_STYLES="$ROOT_DIR/fe-app-tachyon/src/tachyon/tabs/updates/styles.ts" +BUNDLE="$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js" fail() { printf 'FAIL: %s\n' "$1" >&2 diff --git a/tests/mark_ranges.sh b/tests/mark_ranges.sh index b5a6fad4..f5d7ed68 100644 --- a/tests/mark_ranges.sh +++ b/tests/mark_ranges.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -VALIDATOR="$FORKOP_LIB/config/validator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +VALIDATOR="$TACHYON_LIB/config/validator.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -42,7 +42,7 @@ assert_mark_range_no_overlap() { done } -eval "$(ucode -L "$FORKOP_LIB" "$FORKOP_LIB/core/constants.uc" shell-env)" +eval "$(ucode -L "$TACHYON_LIB" "$TACHYON_LIB/core/constants.uc" shell-env)" assert_mark_range_no_overlap "Zapret" "$((ZAPRET_ROUTE_MARK_BASE))" "$ZAPRET_QUEUE_RANGE_SIZE" "$((NFT_FAKEIP_MARK))" "FakeIP" assert_mark_range_no_overlap "Zapret" "$((ZAPRET_ROUTE_MARK_BASE))" "$ZAPRET_QUEUE_RANGE_SIZE" "$((NFT_OUTBOUND_MARK))" "outbound" @@ -78,9 +78,9 @@ context_json() { JSON } -FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/fixture.json" "$(context_json)" +TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/fixture.json" "$(context_json)" -if FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/fixture.json" "$(context_json "0x01100000")" >/dev/null 2>&1; then +if TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/fixture.json" "$(context_json "0x01100000")" >/dev/null 2>&1; then fail "legacy Zapret2 route mark base should overlap FakeIP mark" fi diff --git a/tests/nft_apply.sh b/tests/nft_apply.sh index 8a271960..2251838e 100644 --- a/tests/nft_apply.sh +++ b/tests/nft_apply.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -NFT_RUNTIME="$ROOT_DIR/forkop/files/usr/lib/nft/apply.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +NFT_RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/nft/apply.uc" WORK_DIR="$(mktemp -d)" NFT_LOG="$WORK_DIR/nft.log" LOGGER_LOG="$WORK_DIR/logger.log" @@ -11,7 +11,7 @@ IP_LOG="$WORK_DIR/ip.log" SYSCTL_LOG="$WORK_DIR/sysctl.log" nft_ucode() { - ucode -L "$FORKOP_LIB" "$NFT_RUNTIME" "$@" + ucode -L "$TACHYON_LIB" "$NFT_RUNTIME" "$@" } cleanup() { @@ -179,10 +179,10 @@ export LOGGER_LOG export IP_LOG export SYSCTL_LOG -assert_eq "/tmp/forkop-cache/condition_rule_1_domain_domains" \ - "$(nft_ucode cache-path 1 /tmp/forkop-cache condition rule_1 domain domains)" \ +assert_eq "/tmp/tachyon-cache/condition_rule_1_domain_domains" \ + "$(nft_ucode cache-path 1 /tmp/tachyon-cache condition rule_1 domain domains)" \ "cache path" -if nft_ucode cache-path 1 /tmp/forkop-cache condition 'bad-name' domain domains >/dev/null 2>&1; then +if nft_ucode cache-path 1 /tmp/tachyon-cache condition 'bad-name' domain domains >/dev/null 2>&1; then fail "unsafe cache key should fail" fi @@ -214,55 +214,55 @@ assert_eq "^xn--80aswg[.]xn--p1ai$" \ "$(nft_ucode rule-condition-csv domain_regex generic 0 0 '' '' 'сайт.рф full:пример.испытание keyword:пример regex:^сайт[.]рф$' '')" \ "combined IDN regex is punycoded" -nft_ucode nft-create-runtime-base ForkopTable localv4 forkop_subnets forkop_ports forkop_ip_ports forkop_interfaces "br-lan tun0" 0x00100000 0x00200000 198.18.0.0/15 1602 1 -assert_contains "$NFT_LOG" $'nft\tadd\ttable\tinet\tForkopTable' "runtime table" -assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tForkopTable\tlocalv4\t{ type ipv4_addr; flags interval; auto-merge; }' "runtime localv4 set" -assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tForkopTable\tlocalv6\t{ type ipv6_addr; flags interval; auto-merge; }' "runtime localv6 set" +nft_ucode nft-create-runtime-base TachyonTable localv4 tachyon_subnets tachyon_ports tachyon_ip_ports tachyon_interfaces "br-lan tun0" 0x00100000 0x00200000 198.18.0.0/15 1602 1 +assert_contains "$NFT_LOG" $'nft\tadd\ttable\tinet\tTachyonTable' "runtime table" +assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tTachyonTable\tlocalv4\t{ type ipv4_addr; flags interval; auto-merge; }' "runtime localv4 set" +assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tTachyonTable\tlocalv6\t{ type ipv6_addr; flags interval; auto-merge; }' "runtime localv6 set" assert_contains "$NFT_LOG" '0.0.0.0/8,10.0.0.0/8,127.0.0.0/8' "runtime localv4 elements" assert_contains "$NFT_LOG" '::/128,::1/128,64:ff9b::/96' "runtime localv6 elements" -assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tForkopTable\tforkop_interfaces\t{ type ifname; flags interval; }' "runtime interface set" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_interfaces\t{ br-lan }' "runtime br-lan interface" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_interfaces\t{ tun0 }' "runtime tun0 interface" -assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tForkopTable\tmangle\t{ type filter hook prerouting priority -150; policy accept; }' "runtime mangle chain" -assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tForkopTable\tpriority_rules\t{ }' "runtime priority chain" -assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tForkopTable\tpriority_output_rules\t{ }' "runtime priority output chain" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle\tjump\tpriority_rules' "runtime priority jump" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle\tiifname\t@forkop_interfaces\tip\tdaddr\t@forkop_subnets\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime common tcp rule" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle\tiifname\t@forkop_interfaces\tip6\tdaddr\t@forkop_subnets6\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime common6 tcp rule" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tproxy\tmeta\tmark\t&\t0x00100000\t==\t0x00100000\tmeta\tl4proto\ttcp\ttproxy\tip\tto\t:1602\tcounter' "runtime proxy tcp rule" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tproxy\tmeta\tmark\t&\t0x00100000\t==\t0x00100000\tmeta\tl4proto\ttcp\ttproxy\tip6\tto\t[::1]:1602\tcounter' "runtime proxy6 tcp rule" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\tmeta\tmark\t0x00200000\tcounter\treturn' "runtime outbound return" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\tjump\tpriority_output_rules' "runtime priority output jump" -assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tForkopTable\tmangle\tudp\tdport\t123\treturn' "runtime ntp exclusion" +assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tTachyonTable\ttachyon_interfaces\t{ type ifname; flags interval; }' "runtime interface set" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_interfaces\t{ br-lan }' "runtime br-lan interface" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_interfaces\t{ tun0 }' "runtime tun0 interface" +assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tTachyonTable\tmangle\t{ type filter hook prerouting priority -150; policy accept; }' "runtime mangle chain" +assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tTachyonTable\tpriority_rules\t{ }' "runtime priority chain" +assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tTachyonTable\tpriority_output_rules\t{ }' "runtime priority output chain" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle\tjump\tpriority_rules' "runtime priority jump" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle\tiifname\t@tachyon_interfaces\tip\tdaddr\t@tachyon_subnets\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime common tcp rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle\tiifname\t@tachyon_interfaces\tip6\tdaddr\t@tachyon_subnets6\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime common6 tcp rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tproxy\tmeta\tmark\t&\t0x00100000\t==\t0x00100000\tmeta\tl4proto\ttcp\ttproxy\tip\tto\t:1602\tcounter' "runtime proxy tcp rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tproxy\tmeta\tmark\t&\t0x00100000\t==\t0x00100000\tmeta\tl4proto\ttcp\ttproxy\tip6\tto\t[::1]:1602\tcounter' "runtime proxy6 tcp rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\tmeta\tmark\t0x00200000\tcounter\treturn' "runtime outbound return" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\tjump\tpriority_output_rules' "runtime priority output jump" +assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tTachyonTable\tmangle\tudp\tdport\t123\treturn' "runtime ntp exclusion" cat >"$WORK_DIR/runtime-base-uci.state" <<'EOF_UCI' -forkop.settings=settings -forkop.settings.source_network_interfaces=br-lan tun0 -forkop.settings.exclude_ntp=1 +tachyon.settings=settings +tachyon.settings.source_network_interfaces=br-lan tun0 +tachyon.settings.exclude_ntp=1 EOF_UCI : > "$NFT_LOG" -FORKOP_UCI_STATE_FILE="$WORK_DIR/runtime-base-uci.state" \ - nft_ucode nft-create-runtime-base-from-uci ForkopTable localv4 forkop_subnets forkop_ports forkop_ip_ports forkop_interfaces 0x00100000 0x00200000 198.18.0.0/15 1602 -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_interfaces\t{ br-lan }' "runtime base from UCI br-lan interface" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_interfaces\t{ tun0 }' "runtime base from UCI tun0 interface" -assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tForkopTable\tmangle\tudp\tdport\t123\treturn' "runtime base from UCI ntp exclusion" +TACHYON_UCI_STATE_FILE="$WORK_DIR/runtime-base-uci.state" \ + nft_ucode nft-create-runtime-base-from-uci TachyonTable localv4 tachyon_subnets tachyon_ports tachyon_ip_ports tachyon_interfaces 0x00100000 0x00200000 198.18.0.0/15 1602 +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_interfaces\t{ br-lan }' "runtime base from UCI br-lan interface" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_interfaces\t{ tun0 }' "runtime base from UCI tun0 interface" +assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tTachyonTable\tmangle\tudp\tdport\t123\treturn' "runtime base from UCI ntp exclusion" : > "$NFT_LOG" -nft_ucode nft-create-runtime-output-rules ForkopTable localv4 forkop_subnets forkop_ports forkop_ip_ports 0x00100000 198.18.0.0/15 -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\tip\tdaddr\t@forkop_subnets\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output common tcp" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\tip6\tdaddr\t@forkop_subnets6\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output common6 tcp" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\tip\tdaddr\t.\ttcp\tdport\t@forkop_ip_ports\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output ip-port tcp" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\tip6\tdaddr\t.\ttcp\tdport\t@forkop_ip6_ports\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output ip6-port tcp" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\ttcp\tdport\t@forkop_ports\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output port tcp" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\tip\tdaddr\t198.18.0.0/15\tmeta\tl4proto\tudp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output fakeip udp" +nft_ucode nft-create-runtime-output-rules TachyonTable localv4 tachyon_subnets tachyon_ports tachyon_ip_ports 0x00100000 198.18.0.0/15 +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\tip\tdaddr\t@tachyon_subnets\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output common tcp" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\tip6\tdaddr\t@tachyon_subnets6\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output common6 tcp" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\tip\tdaddr\t.\ttcp\tdport\t@tachyon_ip_ports\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output ip-port tcp" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\tip6\tdaddr\t.\ttcp\tdport\t@tachyon_ip6_ports\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output ip6-port tcp" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\ttcp\tdport\t@tachyon_ports\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output port tcp" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\tip\tdaddr\t198.18.0.0/15\tmeta\tl4proto\tudp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output fakeip udp" : > "$NFT_LOG" -if NFT_LIST_TABLE_FAIL=1 nft_ucode nft-table-present-fixture ForkopTable 2>"$WORK_DIR/nft-table-present.err"; then +if NFT_LIST_TABLE_FAIL=1 nft_ucode nft-table-present-fixture TachyonTable 2>"$WORK_DIR/nft-table-present.err"; then fail "missing nft table should return false" fi [ ! -s "$WORK_DIR/nft-table-present.err" ] || fail "missing nft table predicate must suppress nft stderr" -assert_contains "$NFT_LOG" $'nft\tlist\ttable\tinet\tForkopTable' "nft table presence check" +assert_contains "$NFT_LOG" $'nft\tlist\ttable\tinet\tTachyonTable' "nft table presence check" cat >"$WORK_DIR/provider-rules.json" <<'JSON' { @@ -280,16 +280,16 @@ printf '#!/usr/bin/env sh\nexit 0\n' >"$provider_bin" chmod 0755 "$provider_bin" : > "$NFT_LOG" -nft_ucode nft-create-provider-output-rules-fixture "$WORK_DIR/provider-rules.json" ForkopTable zapret "$provider_bin" 0x01000000 4000 0x40000000 0x20000000 -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\tmeta\tmark\t&\t0x40000000\t==\t0x40000000\treturn' "zapret desync return" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\tmeta\tmark\t0x01000001\tmeta\tl4proto\ttcp\tcounter\tqueue\tnum\t4000\tbypass' "zapret first tcp queue rule" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle_output\tmeta\tmark\t0x01000002\tmeta\tl4proto\tudp\tcounter\tqueue\tnum\t4001\tbypass' "zapret second udp queue rule" +nft_ucode nft-create-provider-output-rules-fixture "$WORK_DIR/provider-rules.json" TachyonTable zapret "$provider_bin" 0x01000000 4000 0x40000000 0x20000000 +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\tmeta\tmark\t&\t0x40000000\t==\t0x40000000\treturn' "zapret desync return" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\tmeta\tmark\t0x01000001\tmeta\tl4proto\ttcp\tcounter\tqueue\tnum\t4000\tbypass' "zapret first tcp queue rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tmangle_output\tmeta\tmark\t0x01000002\tmeta\tl4proto\tudp\tcounter\tqueue\tnum\t4001\tbypass' "zapret second udp queue rule" if grep -Fq 'zapret_disabled' "$NFT_LOG"; then fail "disabled provider section should not create nft rule" fi : > "$NFT_LOG" -nft_ucode nft-create-provider-output-rules-fixture "$WORK_DIR/provider-rules.json" ForkopTable zapret "$WORK_DIR/bin/missing-provider" 0x01000000 4000 0x40000000 0x20000000 +nft_ucode nft-create-provider-output-rules-fixture "$WORK_DIR/provider-rules.json" TachyonTable zapret "$WORK_DIR/bin/missing-provider" 0x01000000 4000 0x40000000 0x20000000 if [ -s "$NFT_LOG" ]; then fail "missing provider binary should skip provider nft output rules" fi @@ -323,29 +323,29 @@ cat >"$WORK_DIR/priority-rules.json" <<'JSON' JSON : > "$NFT_LOG" -nft_ucode nft-add-section-priority-rules-fixture "$WORK_DIR/priority-rules.json" ForkopTable forkop_interfaces localv4 localv6 0x00100000 -assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tForkopTable\tforkop_rule_bypass_first_subnets\t{ type ipv4_addr; flags interval; auto-merge; }' "bypass priority subnet set" -assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tForkopTable\tforkop_rule_wide_proxy_subnets\t{ type ipv4_addr; flags interval; auto-merge; }' "proxy priority subnet set" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tpriority_rules\tiifname\t@forkop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@forkop_rule_bypass_first_subnets\tcounter\taccept' "bypass priority accept rule" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tpriority_rules\tiifname\t@forkop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@forkop_rule_wide_proxy_subnets\tmeta\tmark\tset\t0x00100000\tcounter\taccept' "proxy priority capture rule" -assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tpriority_rules\tiifname\t@forkop_interfaces\tip\tdaddr\t!=\t@localv4\ttcp\tdport\t@forkop_rule_port_bypass_ports\tcounter\taccept' "port-only bypass priority rule" +nft_ucode nft-add-section-priority-rules-fixture "$WORK_DIR/priority-rules.json" TachyonTable tachyon_interfaces localv4 localv6 0x00100000 +assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tTachyonTable\ttachyon_rule_bypass_first_subnets\t{ type ipv4_addr; flags interval; auto-merge; }' "bypass priority subnet set" +assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tTachyonTable\ttachyon_rule_wide_proxy_subnets\t{ type ipv4_addr; flags interval; auto-merge; }' "proxy priority subnet set" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tpriority_rules\tiifname\t@tachyon_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@tachyon_rule_bypass_first_subnets\tcounter\taccept' "bypass priority accept rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tpriority_rules\tiifname\t@tachyon_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@tachyon_rule_wide_proxy_subnets\tmeta\tmark\tset\t0x00100000\tcounter\taccept' "proxy priority capture rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tTachyonTable\tpriority_rules\tiifname\t@tachyon_interfaces\tip\tdaddr\t!=\t@localv4\ttcp\tdport\t@tachyon_rule_port_bypass_ports\tcounter\taccept' "port-only bypass priority rule" assert_line_before "$NFT_LOG" \ - $'nft\tadd\trule\tinet\tForkopTable\tpriority_rules\tiifname\t@forkop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@forkop_rule_bypass_first_subnets\tcounter\taccept' \ - $'nft\tadd\trule\tinet\tForkopTable\tpriority_rules\tiifname\t@forkop_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@forkop_rule_wide_proxy_subnets\tmeta\tmark\tset\t0x00100000\tcounter\taccept' \ + $'nft\tadd\trule\tinet\tTachyonTable\tpriority_rules\tiifname\t@tachyon_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@tachyon_rule_bypass_first_subnets\tcounter\taccept' \ + $'nft\tadd\trule\tinet\tTachyonTable\tpriority_rules\tiifname\t@tachyon_interfaces\tip\tdaddr\t!=\t@localv4\tip\tdaddr\t@tachyon_rule_wide_proxy_subnets\tmeta\tmark\tset\t0x00100000\tcounter\taccept' \ "bypass priority order" : > "$IP_LOG" : > "$LOGGER_LOG" rt_tables="$WORK_DIR/rt_tables" IP_ROUTE_LIST_FAIL=1 IP_ROUTE6_LIST_FAIL=1 IP_RULE_OUTPUT='' IP_RULE6_OUTPUT='' \ - nft_ucode ensure-tproxy-route-rule forkop 0x00100000 "$rt_tables" 2>"$WORK_DIR/tproxy-route-check.err" + nft_ucode ensure-tproxy-route-rule tachyon 0x00100000 "$rt_tables" 2>"$WORK_DIR/tproxy-route-check.err" [ ! -s "$WORK_DIR/tproxy-route-check.err" ] || fail "missing tproxy route table predicates must suppress ip stderr" -assert_contains "$rt_tables" "105 forkop" "tproxy route table registry" -assert_contains "$IP_LOG" $'ip\troute\tadd\tlocal\t0.0.0.0/0\tdev\tlo\ttable\tforkop' "tproxy route add" -assert_contains "$IP_LOG" $'ip\t-6\troute\tadd\tlocal\t::/0\tdev\tlo\ttable\tforkop' "tproxy route6 add" -assert_contains "$IP_LOG" $'ip\t-4\trule\tadd\tfwmark\t0x00100000/0x00100000\ttable\tforkop\tpriority\t105' "tproxy marking rule add" -assert_contains "$IP_LOG" $'ip\t-6\trule\tadd\tfwmark\t0x00100000/0x00100000\ttable\tforkop\tpriority\t105' "tproxy marking rule6 add" +assert_contains "$rt_tables" "105 tachyon" "tproxy route table registry" +assert_contains "$IP_LOG" $'ip\troute\tadd\tlocal\t0.0.0.0/0\tdev\tlo\ttable\ttachyon' "tproxy route add" +assert_contains "$IP_LOG" $'ip\t-6\troute\tadd\tlocal\t::/0\tdev\tlo\ttable\ttachyon' "tproxy route6 add" +assert_contains "$IP_LOG" $'ip\t-4\trule\tadd\tfwmark\t0x00100000/0x00100000\ttable\ttachyon\tpriority\t105' "tproxy marking rule add" +assert_contains "$IP_LOG" $'ip\t-6\trule\tadd\tfwmark\t0x00100000/0x00100000\ttable\ttachyon\tpriority\t105' "tproxy marking rule6 add" assert_contains "$LOGGER_LOG" "[debug] Added IPv4 TPROXY route" "tproxy route creation log" assert_contains "$LOGGER_LOG" "[debug] Added IPv6 TPROXY route" "tproxy route6 creation log" assert_contains "$LOGGER_LOG" "[debug] Creating IPv4 TPROXY marking rule" "tproxy marking rule creation log" @@ -353,12 +353,12 @@ assert_contains "$LOGGER_LOG" "[debug] Creating IPv6 TPROXY marking rule" "tprox : > "$IP_LOG" : > "$LOGGER_LOG" -printf '%s\n' '105 forkop' >"$rt_tables" +printf '%s\n' '105 tachyon' >"$rt_tables" IP_ROUTE_OUTPUT='local default dev lo scope host' \ IP_ROUTE6_OUTPUT='local default dev lo metric 1024 pref medium' \ - IP_RULE_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup forkop' \ - IP_RULE6_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup forkop' \ - nft_ucode ensure-tproxy-route-rule forkop 0x00100000 "$rt_tables" + IP_RULE_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup tachyon' \ + IP_RULE6_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup tachyon' \ + nft_ucode ensure-tproxy-route-rule tachyon 0x00100000 "$rt_tables" if grep -Fq $'\tadd\t' "$IP_LOG"; then fail "existing tproxy route/rule should not be added again" fi @@ -368,14 +368,14 @@ assert_contains "$LOGGER_LOG" "[debug] IPv4 TPROXY marking rule already exists" assert_contains "$LOGGER_LOG" "[debug] IPv6 TPROXY marking rule already exists" "existing tproxy marking rule6 log" IP_ROUTE_OUTPUT='local default dev lo scope host' \ IP_ROUTE6_OUTPUT='local default dev lo metric 1024 pref medium' \ - IP_RULE_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup forkop' \ - IP_RULE6_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup forkop' \ - nft_ucode tproxy-route-rule-present forkop 0x00100000 + IP_RULE_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup tachyon' \ + IP_RULE6_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup tachyon' \ + nft_ucode tproxy-route-rule-present tachyon 0x00100000 if IP_ROUTE_OUTPUT='local default dev lo scope host' \ IP_ROUTE6_OUTPUT='local default dev lo metric 1024 pref medium' \ IP_RULE_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup other' \ - IP_RULE6_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup forkop' \ - nft_ucode tproxy-route-rule-present forkop 0x00100000 >/dev/null 2>&1; then + IP_RULE6_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup tachyon' \ + nft_ucode tproxy-route-rule-present tachyon 0x00100000 >/dev/null 2>&1; then fail "tproxy route/rule presence should require matching lookup table" fi @@ -400,10 +400,10 @@ bad-value 203.0.113.0/24 EOF_INPUT -nft_ucode nft-add-file-chunks-to-set "$input" ForkopTable forkop_subnets ips "" 2 -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_subnets\t{ 198.51.100.1,203.0.113.0/24 }' "nft chunked ips" +nft_ucode nft-add-file-chunks-to-set "$input" TachyonTable tachyon_subnets ips "" 2 +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_subnets\t{ 198.51.100.1,203.0.113.0/24 }' "nft chunked ips" assert_contains "$LOGGER_LOG" "[debug] 'bad-value' is not IP or CIDR" "invalid element log" -assert_contains "$LOGGER_LOG" "[debug] Adding 2 elements to nft set forkop_subnets" "chunk count log" +assert_contains "$LOGGER_LOG" "[debug] Adding 2 elements to nft set tachyon_subnets" "chunk count log" : > "$NFT_LOG" ports_input="$WORK_DIR/ip-ports.txt" @@ -412,7 +412,7 @@ cat >"$ports_input" <<'EOF_INPUT' 203.0.113.0/24 EOF_INPUT -nft_ucode nft-add-file-chunks-to-set "$ports_input" ForkopTable forkop_ip_ports ip-port-from-ip "80,443-444" 3 +nft_ucode nft-add-file-chunks-to-set "$ports_input" TachyonTable tachyon_ip_ports ip-port-from-ip "80,443-444" 3 assert_contains "$NFT_LOG" $'198.51.100.1 . 80,198.51.100.1 . 443-444,203.0.113.0/24 . 80' "ip-port chunk" assert_contains "$NFT_LOG" $'203.0.113.0/24 . 443-444' "ip-port second chunk" @@ -491,12 +491,12 @@ cat >"$plain_subnets" <<'EOF_INPUT' EOF_INPUT : > "$NFT_LOG" -nft_ucode nft-add-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports "$plain_subnets" ForkopTable forkop_subnets forkop_ip_ports 5000 -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_inline_no_ports_subnets\t{ 198.51.100.210,203.0.113.0/24 }' "plain subnet import without ports" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_inline_no_ports_subnets6\t{ 2001:db8::210 }' "plain subnet6 import without ports" +nft_ucode nft-add-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports "$plain_subnets" TachyonTable tachyon_subnets tachyon_ip_ports 5000 +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_inline_no_ports_subnets\t{ 198.51.100.210,203.0.113.0/24 }' "plain subnet import without ports" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_inline_no_ports_subnets6\t{ 2001:db8::210 }' "plain subnet6 import without ports" : > "$NFT_LOG" -nft_ucode nft-add-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" ports_only "$plain_subnets" ForkopTable forkop_subnets forkop_ip_ports 3 +nft_ucode nft-add-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" ports_only "$plain_subnets" TachyonTable tachyon_subnets tachyon_ip_ports 3 assert_contains "$NFT_LOG" $'198.51.100.210 . 53,198.51.100.210 . 853,198.51.100.210 . 5353' "plain subnet import scoped first chunk" assert_contains "$NFT_LOG" $'203.0.113.0/24 . 53,203.0.113.0/24 . 853,203.0.113.0/24 . 5353' "plain subnet import scoped second chunk" assert_contains "$NFT_LOG" $'2001:db8::210 . 53,2001:db8::210 . 853,2001:db8::210 . 5353' "plain subnet6 import scoped chunk" @@ -523,59 +523,59 @@ JSON : > "$NFT_LOG" unscoped_json="$WORK_DIR/unscoped-json.txt" scoped_json="$WORK_DIR/scoped-json.txt" -nft_ucode nft-add-json-ruleset-subnets-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports "$json_ruleset" "fixture json" ForkopTable forkop_subnets forkop_ip_ports "$unscoped_json" "$scoped_json" 5000 -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_inline_no_ports_subnets\t{ 198.51.100.220 }' "json ruleset unscoped import" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_inline_no_ports_subnets6\t{ 2001:db8::220 }' "json ruleset unscoped6 import" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_inline_no_ports_ip_ports\t{ 198.51.100.221 . 443,198.51.100.222 . 1000-1002 }' "json ruleset own port filters" +nft_ucode nft-add-json-ruleset-subnets-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports "$json_ruleset" "fixture json" TachyonTable tachyon_subnets tachyon_ip_ports "$unscoped_json" "$scoped_json" 5000 +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_inline_no_ports_subnets\t{ 198.51.100.220 }' "json ruleset unscoped import" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_inline_no_ports_subnets6\t{ 2001:db8::220 }' "json ruleset unscoped6 import" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_inline_no_ports_ip_ports\t{ 198.51.100.221 . 443,198.51.100.222 . 1000-1002 }' "json ruleset own port filters" : > "$NFT_LOG" unscoped_json="$WORK_DIR/unscoped-json-ports.txt" scoped_json="$WORK_DIR/scoped-json-ports.txt" -nft_ucode nft-add-json-ruleset-subnets-for-section-fixture "$WORK_DIR/populate-fixture.json" ports_only "$json_ruleset" "fixture json ports" ForkopTable forkop_subnets forkop_ip_ports "$unscoped_json" "$scoped_json" 4 +nft_ucode nft-add-json-ruleset-subnets-for-section-fixture "$WORK_DIR/populate-fixture.json" ports_only "$json_ruleset" "fixture json ports" TachyonTable tachyon_subnets tachyon_ip_ports "$unscoped_json" "$scoped_json" 4 assert_contains "$NFT_LOG" $'198.51.100.220 . 53,198.51.100.220 . 5353,198.51.100.220 . 853' "json ruleset scoped import" if grep -Fq '198.51.100.221' "$NFT_LOG" || grep -Fq '198.51.100.222' "$NFT_LOG"; then fail "json ruleset section ports should intersect rule-owned port filters" fi : > "$NFT_LOG" -nft_ucode nft-add-community-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports discord "$plain_subnets" ForkopTable forkop_subnets forkop_ip_ports forkop_interfaces forkop_discord_subnets 0x00100000 5000 -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_inline_no_ports_subnets\t{ 198.51.100.210,203.0.113.0/24 }' "discord community subnet import" +nft_ucode nft-add-community-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports discord "$plain_subnets" TachyonTable tachyon_subnets tachyon_ip_ports tachyon_interfaces tachyon_discord_subnets 0x00100000 5000 +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_inline_no_ports_subnets\t{ 198.51.100.210,203.0.113.0/24 }' "discord community subnet import" : > "$NFT_LOG" -NFT_MANGLE_CHAIN_OUTPUT=$'iifname @forkop_interfaces ip daddr @forkop_discord_subnets udp dport { 19000-20000, 50000-65535 } meta mark set 0x00100000 counter\niifname @forkop_interfaces ip6 daddr @forkop_discord_subnets6 udp dport { 19000-20000, 50000-65535 } meta mark set 0x00100000 counter' \ - nft_ucode nft-add-community-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports discord "$plain_subnets" ForkopTable forkop_subnets forkop_ip_ports forkop_interfaces forkop_discord_subnets 0x00100000 5000 +NFT_MANGLE_CHAIN_OUTPUT=$'iifname @tachyon_interfaces ip daddr @tachyon_discord_subnets udp dport { 19000-20000, 50000-65535 } meta mark set 0x00100000 counter\niifname @tachyon_interfaces ip6 daddr @tachyon_discord_subnets6 udp dport { 19000-20000, 50000-65535 } meta mark set 0x00100000 counter' \ + nft_ucode nft-add-community-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports discord "$plain_subnets" TachyonTable tachyon_subnets tachyon_ip_ports tachyon_interfaces tachyon_discord_subnets 0x00100000 5000 if grep -Fq $'nft\tadd\trule' "$NFT_LOG"; then fail "discord community existing rule should not insert" fi : > "$NFT_LOG" -nft_ucode nft-add-community-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" ports_only discord "$plain_subnets" ForkopTable forkop_subnets forkop_ip_ports forkop_interfaces forkop_discord_subnets 0x00100000 3 -if grep -Fq 'forkop_discord_subnets' "$NFT_LOG"; then +nft_ucode nft-add-community-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" ports_only discord "$plain_subnets" TachyonTable tachyon_subnets tachyon_ip_ports tachyon_interfaces tachyon_discord_subnets 0x00100000 3 +if grep -Fq 'tachyon_discord_subnets' "$NFT_LOG"; then fail "discord community with section ports should not use the discord nft set" fi assert_contains "$NFT_LOG" $'198.51.100.210 . 53,198.51.100.210 . 853,198.51.100.210 . 5353' "discord community with ports uses scoped import" : > "$NFT_LOG" -nft_ucode nft-populate-runtime-sets-fixture "$WORK_DIR/populate-fixture.json" 1 "deferred" ForkopTable forkop_subnets forkop_ports forkop_ip_ports forkop_interfaces localv4 0x00100000 +nft_ucode nft-populate-runtime-sets-fixture "$WORK_DIR/populate-fixture.json" 1 "deferred" TachyonTable tachyon_subnets tachyon_ports tachyon_ip_ports tachyon_interfaces localv4 0x00100000 assert_contains "$NFT_LOG" $'198.51.100.1 . 80,198.51.100.1 . 443-444,203.0.113.0/24 . 80' "populate inline ip-port first chunk" assert_contains "$NFT_LOG" $'203.0.113.0/24 . 443-444' "populate inline ip-port second chunk" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_inline_ip_ports\t{ 198.51.100.1 . 80,198.51.100.1 . 443-444,203.0.113.0/24 . 80,203.0.113.0/24 . 443-444 }' "populate inline priority ip-port set" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_inline_ip6_ports\t{ 2001:db8::1 . 80,2001:db8::1 . 443-444 }' "populate inline priority ip6-port set" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_inline_no_ports_subnets\t{ 198.51.100.200 }' "populate inline ip without ports" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_inline_no_ports_subnets6\t{ 2001:db8::200 }' "populate inline ip6 without ports" -assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_ports_only_ports\t{ 53,853,5353 }' "populate ports-only set" -assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tForkopTable\tmangle\tiifname\t@forkop_interfaces\tip\tsaddr\t192.168.1.20/32\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "populate fully routed tcp" -assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tForkopTable\tmangle\tiifname\t@forkop_interfaces\tip6\tsaddr\t2001:db8::20/128\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "populate fully routed6 tcp" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_inline_ip_ports\t{ 198.51.100.1 . 80,198.51.100.1 . 443-444,203.0.113.0/24 . 80,203.0.113.0/24 . 443-444 }' "populate inline priority ip-port set" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_inline_ip6_ports\t{ 2001:db8::1 . 80,2001:db8::1 . 443-444 }' "populate inline priority ip6-port set" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_inline_no_ports_subnets\t{ 198.51.100.200 }' "populate inline ip without ports" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_inline_no_ports_subnets6\t{ 2001:db8::200 }' "populate inline ip6 without ports" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_ports_only_ports\t{ 53,853,5353 }' "populate ports-only set" +assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tTachyonTable\tmangle\tiifname\t@tachyon_interfaces\tip\tsaddr\t192.168.1.20/32\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "populate fully routed tcp" +assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tTachyonTable\tmangle\tiifname\t@tachyon_interfaces\tip6\tsaddr\t2001:db8::20/128\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "populate fully routed6 tcp" if grep -Fq '192.0.2.1' "$NFT_LOG"; then fail "disabled section should not populate nft" fi if grep -Fq '192.0.2.2' "$NFT_LOG"; then fail "deferred section should not populate nft" fi -if grep -Fq $'nft\tadd\telement\tinet\tForkopTable\tforkop_rule_ports_with_domain_ports\t{ 8443 }' "$NFT_LOG"; then +if grep -Fq $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_ports_with_domain_ports\t{ 8443 }' "$NFT_LOG"; then fail "ports with destination matchers should not populate global port set" fi -if grep -Fq '192.0.2.53' "$NFT_LOG" || grep -Fq '192.168.1.53' "$NFT_LOG" || grep -Fq 'forkop_rule_dns_only' "$NFT_LOG"; then +if grep -Fq '192.0.2.53' "$NFT_LOG" || grep -Fq '192.168.1.53' "$NFT_LOG" || grep -Fq 'tachyon_rule_dns_only' "$NFT_LOG"; then fail "DNS action should not populate nft sets or fully routed rules" fi if [ "$(grep -F $'ip\tsaddr\t192.168.1.20/32' "$NFT_LOG" | wc -l | tr -d ' ')" != "3" ]; then @@ -674,23 +674,23 @@ assert_eq "$expected_signature" \ "nft runtime signature fixture" cat >"$WORK_DIR/signature-uci.state" <<'EOF_UCI' -forkop.settings=settings -forkop.settings.source_network_interfaces=br-lan tun0 -forkop.settings.exclude_ntp=1 -forkop.disabled=section -forkop.disabled.enabled=0 -forkop.disabled.ip_cidr=192.0.2.10 -forkop.enabled=section -forkop.enabled.enabled=1 -forkop.enabled.action=bypass -forkop.enabled.ip_cidr=10.0.0.0/8 192.0.2.1 -forkop.enabled.ports=443 bad-value -forkop.enabled.ports_text=80 443-444 65536 -forkop.enabled.fully_routed_ips=192.168.1.10/32 -forkop.enabled.community_lists=geoblock meta telegram youtube discord -forkop.enabled.remote_subnet_lists=https://example.com/subnets.lst -forkop.enabled.rule_set_with_subnets=/tmp/local.json -forkop.enabled.domain_ip_lists=https://example.com/mixed.lst +tachyon.settings=settings +tachyon.settings.source_network_interfaces=br-lan tun0 +tachyon.settings.exclude_ntp=1 +tachyon.disabled=section +tachyon.disabled.enabled=0 +tachyon.disabled.ip_cidr=192.0.2.10 +tachyon.enabled=section +tachyon.enabled.enabled=1 +tachyon.enabled.action=bypass +tachyon.enabled.ip_cidr=10.0.0.0/8 192.0.2.1 +tachyon.enabled.ports=443 bad-value +tachyon.enabled.ports_text=80 443-444 65536 +tachyon.enabled.fully_routed_ips=192.168.1.10/32 +tachyon.enabled.community_lists=geoblock meta telegram youtube discord +tachyon.enabled.remote_subnet_lists=https://example.com/subnets.lst +tachyon.enabled.rule_set_with_subnets=/tmp/local.json +tachyon.enabled.domain_ip_lists=https://example.com/mixed.lst EOF_UCI cat >"$WORK_DIR/signature-uci-expected.txt" <<'EOF_EXPECTED' [settings.source_network_interfaces] @@ -718,7 +718,7 @@ https://example.com/mixed.lst EOF_EXPECTED expected_signature="$(md5sum "$WORK_DIR/signature-uci-expected.txt" | awk '{print $1}')" assert_eq "$expected_signature" \ - "$(FORKOP_UCI_STATE_FILE="$WORK_DIR/signature-uci.state" nft_ucode nft-runtime-signature)" \ + "$(TACHYON_UCI_STATE_FILE="$WORK_DIR/signature-uci.state" nft_ucode nft-runtime-signature)" \ "nft runtime signature from UCI state" cat >"$WORK_DIR/signature-defaults-fixture.json" <<'JSON' @@ -736,12 +736,12 @@ assert_eq "$expected_signature" \ "nft runtime signature defaults" : > "$NFT_LOG" -nft_ucode nft-populate-runtime-sets-fixture "$WORK_DIR/populate-fixture.json" 0 "" ForkopTable forkop_subnets forkop_ports forkop_ip_ports forkop_interfaces localv4 0x00100000 +nft_ucode nft-populate-runtime-sets-fixture "$WORK_DIR/populate-fixture.json" 0 "" TachyonTable tachyon_subnets tachyon_ports tachyon_ip_ports tachyon_interfaces localv4 0x00100000 [ ! -s "$NFT_LOG" ] || fail "disabled nft population should not call nft" : > "$NFT_LOG" NFT_MANGLE_CHAIN_OUTPUT='ip saddr 192.168.1.20 meta l4proto tcp meta mark set 0x00100000 counter' \ - nft_ucode nft-populate-runtime-sets-fixture "$WORK_DIR/populate-fixture.json" 1 "disabled deferred inline inline_no_ports ports_only ports_with_domain dns_only" ForkopTable forkop_subnets forkop_ports forkop_ip_ports forkop_interfaces localv4 0x00100000 + nft_ucode nft-populate-runtime-sets-fixture "$WORK_DIR/populate-fixture.json" 1 "disabled deferred inline inline_no_ports ports_only ports_with_domain dns_only" TachyonTable tachyon_subnets tachyon_ports tachyon_ip_ports tachyon_interfaces localv4 0x00100000 if grep -Fq $'nft\tinsert\trule' "$NFT_LOG"; then fail "existing nft-normalized /32 fully routed source should not insert" fi diff --git a/tests/outbound_tags.sh b/tests/outbound_tags.sh index 2d1a0d29..85b1d382 100644 --- a/tests/outbound_tags.sh +++ b/tests/outbound_tags.sh @@ -1,11 +1,11 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -GENERATOR_UC="$FORKOP_LIB/singbox/generator.uc" -PARSER_UC="$FORKOP_LIB/subscription/parser.uc" -VALIDATOR_UC="$FORKOP_LIB/config/validator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +GENERATOR_UC="$TACHYON_LIB/singbox/generator.uc" +PARSER_UC="$TACHYON_LIB/subscription/parser.uc" +VALIDATOR_UC="$TACHYON_LIB/config/validator.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -109,7 +109,7 @@ JSON runtime_config="$WORK_DIR/runtime-config.json" mkdir -p "$runtime_config.section-cache" TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/subscriptions" \ - ucode -L "$FORKOP_LIB" "$GENERATOR_UC" generate-config-fixture \ + ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ "$WORK_DIR/runtime-tags.json" "$runtime_config" "127.0.0.1" "0" ucode -e ' @@ -225,7 +225,7 @@ input.settings.dns_server = ['77.88.8.8']; input.settings.bootstrap_dns_server = ['77.88.8.8']; fs.writeFileSync(process.argv[3], JSON.stringify(input)); JS - if output="$(FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR_UC" \ + if output="$(TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$VALIDATOR_UC" \ validate-runtime-fixture "$normalized" '{}' 2>/dev/null)"; then fail "validator accepted $fixture" fi @@ -259,7 +259,7 @@ cat >"$WORK_DIR/xray-duplicates.json" <<'JSON' ] JSON -ucode -L "$FORKOP_LIB" "$PARSER_UC" normalize-content \ +ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content \ "$WORK_DIR/xray-duplicates.json" "$WORK_DIR/xray-normalized.json" ucode -e ' let fs = require("fs"); diff --git a/tests/package_contract.sh b/tests/package_contract.sh index c6e1adb4..ad422ab1 100644 --- a/tests/package_contract.sh +++ b/tests/package_contract.sh @@ -1,11 +1,11 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_MAKEFILE="$ROOT_DIR/forkop/Makefile" -FORKOP_CONFIG="$ROOT_DIR/forkop/files/etc/config/forkop" +TACHYON_MAKEFILE="$ROOT_DIR/tachyon/Makefile" +TACHYON_CONFIG="$ROOT_DIR/tachyon/files/etc/config/tachyon" BUILD_SCRIPT="$ROOT_DIR/build.sh" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -21,8 +21,8 @@ require_file() { require_make_dep() { local package="$1" - grep -Eq "DEPENDS:=.*(^|[[:space:]])\\+$package([[:space:]]|$)" "$FORKOP_MAKEFILE" || - fail "forkop/Makefile DEPENDS is missing +$package" + grep -Eq "DEPENDS:=.*(^|[[:space:]])\\+$package([[:space:]]|$)" "$TACHYON_MAKEFILE" || + fail "tachyon/Makefile DEPENDS is missing +$package" } require_build_dep() { @@ -41,53 +41,53 @@ require_package_dependency() { require_build_dep "BACKEND_DEPENDS_APK" "$package" } -require_file "$FORKOP_MAKEFILE" -require_file "$FORKOP_CONFIG" +require_file "$TACHYON_MAKEFILE" +require_file "$TACHYON_CONFIG" require_file "$BUILD_SCRIPT" -require_file "$FORKOP_LIB" +require_file "$TACHYON_LIB" for conflict in https-dns-proxy nextdns luci-app-passwall luci-app-passwall2; do - grep -E 'CONFLICTS:=' "$FORKOP_MAKEFILE" | grep -Fq "$conflict" || - fail "forkop/Makefile conflicts are missing $conflict" + grep -E 'CONFLICTS:=' "$TACHYON_MAKEFILE" | grep -Fq "$conflict" || + fail "tachyon/Makefile conflicts are missing $conflict" grep -E '^BACKEND_CONFLICTS_IPK=' "$BUILD_SCRIPT" | grep -Fq "$conflict" || fail "manual IPK conflicts are missing $conflict" grep -E '^BACKEND_DEPENDS_APK=' "$BUILD_SCRIPT" | grep -Fq "!$conflict" || fail "manual APK conflicts are missing $conflict" done -if grep -Fq 'coreutils-sort' "$FORKOP_MAKEFILE" "$BUILD_SCRIPT"; then +if grep -Fq 'coreutils-sort' "$TACHYON_MAKEFILE" "$BUILD_SCRIPT"; then fail "unused coreutils-sort runtime dependency must not be packaged" fi -grep -Fq "must use x.y.z format" "$FORKOP_MAKEFILE" || - fail "forkop/Makefile must enforce the three-part release version contract" +grep -Fq "must use x.y.z format" "$TACHYON_MAKEFILE" || + fail "tachyon/Makefile must enforce the three-part release version contract" grep -Fq 'APK_INTERNAL_VERSION="$RELEASE_VERSION"' "$BUILD_SCRIPT" || fail "build.sh must use the exact three-part release version for APK metadata" -grep -Fq "option component_update_check_enabled '1'" "$FORKOP_CONFIG" || +grep -Fq "option component_update_check_enabled '1'" "$TACHYON_CONFIG" || fail "new installations must enable component update checks by default" -grep -Fq "option config_version '1.0.2'" "$FORKOP_CONFIG" || +grep -Fq "option config_version '1.0.2'" "$TACHYON_CONFIG" || fail "new installations must start at the current configuration schema version" -grep -Fq "list applied_migrations 'interface_sections'" "$FORKOP_CONFIG" || +grep -Fq "list applied_migrations 'interface_sections'" "$TACHYON_CONFIG" || fail "new installations must mark the interface section migration as applied" -grep -Fq "list applied_migrations 'enable_component_checks'" "$FORKOP_CONFIG" || +grep -Fq "list applied_migrations 'enable_component_checks'" "$TACHYON_CONFIG" || fail "new installations must mark the component check migration as applied" -grep -Fq '/usr/lib/forkop/config/migration.uc migrate' "$FORKOP_MAKEFILE" || +grep -Fq '/usr/lib/tachyon/config/migration.uc migrate' "$TACHYON_MAKEFILE" || fail "OpenWrt package postinst must run configuration migrations" -[ "$(grep -Fc '/usr/lib/forkop/config/migration.uc migrate' "$BUILD_SCRIPT")" -ge 3 ] || +[ "$(grep -Fc '/usr/lib/tachyon/config/migration.uc migrate' "$BUILD_SCRIPT")" -ge 3 ] || fail "manual IPK/APK package scripts must run configuration migrations after install and upgrade" -if grep -Rqs 'require("uci")' "$FORKOP_LIB"; then +if grep -Rqs 'require("uci")' "$TACHYON_LIB"; then require_package_dependency "ucode-mod-uci" fi -if grep -Rqs 'require("fs")' "$FORKOP_LIB"; then +if grep -Rqs 'require("fs")' "$TACHYON_LIB"; then require_package_dependency "ucode-mod-fs" fi -if grep -Rqs 'forkop_dnsmasq_failsafe_restore_raw' \ - "$ROOT_DIR/forkop/files/usr/bin" \ - "$ROOT_DIR/forkop/files/usr/lib" \ - "$ROOT_DIR/forkop/files/etc/init.d"; then +if grep -Rqs 'tachyon_dnsmasq_failsafe_restore_raw' \ + "$ROOT_DIR/tachyon/files/usr/bin" \ + "$ROOT_DIR/tachyon/files/usr/lib" \ + "$ROOT_DIR/tachyon/files/etc/init.d"; then fail "duplicated raw dnsmasq failsafe restore shell owner is present" fi diff --git a/tests/package_lifecycle.sh b/tests/package_lifecycle.sh index 18b092da..5e081363 100644 --- a/tests/package_lifecycle.sh +++ b/tests/package_lifecycle.sh @@ -1,15 +1,15 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -PACKAGE_UC="$FORKOP_LIB/service/package.uc" -FORKOP_MAKEFILE="$ROOT_DIR/forkop/Makefile" -LUCI_UCI_DEFAULTS="$ROOT_DIR/luci-app-forkop/root/etc/uci-defaults/50_luci-forkop" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +PACKAGE_UC="$TACHYON_LIB/service/package.uc" +TACHYON_MAKEFILE="$ROOT_DIR/tachyon/Makefile" +LUCI_UCI_DEFAULTS="$ROOT_DIR/luci-app-tachyon/root/etc/uci-defaults/50_luci-tachyon" BUILD_SCRIPT="$ROOT_DIR/build.sh" WORK_DIR="$(mktemp -d)" -export FORKOP_PACKAGE_UPGRADE_STATE="$WORK_DIR/package-was-running" +export TACHYON_PACKAGE_UPGRADE_STATE="$WORK_DIR/package-was-running" cleanup() { rm -rf "$WORK_DIR" @@ -28,162 +28,162 @@ if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$PACKAGE_UC" >/dev/n fi grep -Fq 'require("core.uci")' "$PACKAGE_UC" || fail "service/package.uc must import core.uci" -grep -Fq 'package_prerm: [ "service/package.uc", "prerm", 1 ]' "$FORKOP_BIN" || - fail "forkop entrypoint must dispatch package prerm cleanup through service/package.uc" -grep -Fq 'package_postinst: [ "service/package.uc", "postinst", 0 ]' "$FORKOP_BIN" || - fail "forkop entrypoint must dispatch package postinst recovery through service/package.uc" -grep -Fq 'luci_postinst: [ "service/package.uc", "luci-postinst", 0 ]' "$FORKOP_BIN" || - fail "forkop entrypoint must dispatch LuCI postinstall cleanup through service/package.uc" +grep -Fq 'package_prerm: [ "service/package.uc", "prerm", 1 ]' "$TACHYON_BIN" || + fail "tachyon entrypoint must dispatch package prerm cleanup through service/package.uc" +grep -Fq 'package_postinst: [ "service/package.uc", "postinst", 0 ]' "$TACHYON_BIN" || + fail "tachyon entrypoint must dispatch package postinst recovery through service/package.uc" +grep -Fq 'luci_postinst: [ "service/package.uc", "luci-postinst", 0 ]' "$TACHYON_BIN" || + fail "tachyon entrypoint must dispatch LuCI postinstall cleanup through service/package.uc" grep -Fq '#!/bin/sh' "$LUCI_UCI_DEFAULTS" || fail "LuCI uci-defaults must remain a shell script because OpenWrt default_postinst runs it through shell" -grep -Fq '/usr/bin/forkop luci_postinst' "$LUCI_UCI_DEFAULTS" || +grep -Fq '/usr/bin/tachyon luci_postinst' "$LUCI_UCI_DEFAULTS" || fail "LuCI uci-defaults must delegate cache/rpcd handling to ucode" -if grep -E 'rm -f /var/luci-indexcache|rm -f /tmp/luci-indexcache|logger -t "forkop"' "$LUCI_UCI_DEFAULTS" >/dev/null; then +if grep -E 'rm -f /var/luci-indexcache|rm -f /tmp/luci-indexcache|logger -t "tachyon"' "$LUCI_UCI_DEFAULTS" >/dev/null; then fail "LuCI uci-defaults must not own cache/logger shell logic" fi -if grep -n -E 'grep -q "105 forkop"|sed -i "/105 forkop|forkop_dont_touch_dhcp=.*uci|cp /etc/config/forkop|rm -f /tmp/luci-indexcache|killall -HUP rpcd' "$FORKOP_MAKEFILE" "$BUILD_SCRIPT" >/dev/null; then +if grep -n -E 'grep -q "105 tachyon"|sed -i "/105 tachyon|tachyon_dont_touch_dhcp=.*uci|cp /etc/config/tachyon|rm -f /tmp/luci-indexcache|killall -HUP rpcd' "$TACHYON_MAKEFILE" "$BUILD_SCRIPT" >/dev/null; then fail "package scripts must not keep backend/LuCI lifecycle business logic in shell" fi -grep -Fq '#!/usr/bin/ucode' "$FORKOP_MAKEFILE" || - fail "forkop Makefile package hooks must use ucode entrypoints" -grep -Fq '/usr/bin/forkop package_prerm' "$FORKOP_MAKEFILE" || - fail "forkop Makefile prerm must delegate cleanup to package_prerm" -grep -Fq '/usr/bin/forkop package_postinst' "$FORKOP_MAKEFILE" || - fail "forkop Makefile postinst must restore a service that was running before upgrade" -grep -Fq '/usr/bin/forkop package_prerm upgrade' "$BUILD_SCRIPT" || +grep -Fq '#!/usr/bin/ucode' "$TACHYON_MAKEFILE" || + fail "tachyon Makefile package hooks must use ucode entrypoints" +grep -Fq '/usr/bin/tachyon package_prerm' "$TACHYON_MAKEFILE" || + fail "tachyon Makefile prerm must delegate cleanup to package_prerm" +grep -Fq '/usr/bin/tachyon package_postinst' "$TACHYON_MAKEFILE" || + fail "tachyon Makefile postinst must restore a service that was running before upgrade" +grep -Fq '/usr/bin/tachyon package_prerm upgrade' "$BUILD_SCRIPT" || fail "manual APK pre-upgrade must record and stop the running service" -grep -Fq '/usr/bin/forkop package_postinst' "$BUILD_SCRIPT" || +grep -Fq '/usr/bin/tachyon package_postinst' "$BUILD_SCRIPT" || fail "manual packages must restore a service that was running before upgrade" -grep -Fq '/usr/bin/forkop luci_postinst' "$BUILD_SCRIPT" || +grep -Fq '/usr/bin/tachyon luci_postinst' "$BUILD_SCRIPT" || fail "manual package builder must delegate LuCI cache/rpcd handling to luci_postinst" -if grep -n -E 'Package/forkop/preinst|copy_legacy_config|FORKOP_LEGACY_CONFIG|mode == "preinst"' \ - "$FORKOP_MAKEFILE" "$BUILD_SCRIPT" "$PACKAGE_UC" >/dev/null 2>&1; then +if grep -n -E 'Package/tachyon/preinst|copy_legacy_config|TACHYON_LEGACY_CONFIG|mode == "preinst"' \ + "$TACHYON_MAKEFILE" "$BUILD_SCRIPT" "$PACKAGE_UC" >/dev/null 2>&1; then fail "package hooks and runtime service must not own configuration migration" fi rt_tables="$WORK_DIR/rt_tables" cat >"$rt_tables" <<'EOF' 100 main -105 forkop +105 tachyon 200 custom EOF -FORKOP_PACKAGE_TEST_MODE=1 FORKOP_RT_TABLES="$rt_tables" \ - ucode -L "$FORKOP_LIB" "$PACKAGE_UC" prerm -if grep -Fq '105 forkop' "$rt_tables"; then - fail "package prerm must remove the Forkop routing table entry" +TACHYON_PACKAGE_TEST_MODE=1 TACHYON_RT_TABLES="$rt_tables" \ + ucode -L "$TACHYON_LIB" "$PACKAGE_UC" prerm +if grep -Fq '105 tachyon' "$rt_tables"; then + fail "package prerm must remove the Tachyon routing table entry" fi grep -Fq '200 custom' "$rt_tables" || fail "package prerm must preserve unrelated rt_tables entries" -cat >"$WORK_DIR/forkop-init" <<'SH' +cat >"$WORK_DIR/tachyon-init" <<'SH' #!/usr/bin/env bash -grep -Fq '105 forkop' "${FORKOP_RT_TABLES:?}" || exit 1 -printf '%s\n' 'stop-with-route-table' >>"${FORKOP_STOP_LOG:?}" +grep -Fq '105 tachyon' "${TACHYON_RT_TABLES:?}" || exit 1 +printf '%s\n' 'stop-with-route-table' >>"${TACHYON_STOP_LOG:?}" SH -chmod 0755 "$WORK_DIR/forkop-init" +chmod 0755 "$WORK_DIR/tachyon-init" cat >"$WORK_DIR/stop-order.state" <<'EOF_UCI' -forkop.settings=settings -forkop.settings.dont_touch_dhcp=1 +tachyon.settings=settings +tachyon.settings.dont_touch_dhcp=1 EOF_UCI -printf '105 forkop\n' >"$WORK_DIR/rt_tables_stop_order" +printf '105 tachyon\n' >"$WORK_DIR/rt_tables_stop_order" : >"$WORK_DIR/stop-order.log" -FORKOP_UCI_STATE_FILE="$WORK_DIR/stop-order.state" \ -FORKOP_INIT="$WORK_DIR/forkop-init" \ -FORKOP_STOP_LOG="$WORK_DIR/stop-order.log" \ -FORKOP_BIN="$WORK_DIR/missing-forkop-bin" \ -FORKOP_DNS_APPLY_UC="$WORK_DIR/missing-dns-apply.uc" \ -FORKOP_SING_BOX_INIT="$WORK_DIR/missing-sing-box-init" \ -FORKOP_SING_BOX_BIN="$WORK_DIR/missing-sing-box-bin" \ -FORKOP_SING_BOX_CRONET="$WORK_DIR/missing-cronet" \ -FORKOP_RT_TABLES="$WORK_DIR/rt_tables_stop_order" \ - ucode -L "$FORKOP_LIB" "$PACKAGE_UC" prerm +TACHYON_UCI_STATE_FILE="$WORK_DIR/stop-order.state" \ +TACHYON_INIT="$WORK_DIR/tachyon-init" \ +TACHYON_STOP_LOG="$WORK_DIR/stop-order.log" \ +TACHYON_BIN="$WORK_DIR/missing-tachyon-bin" \ +TACHYON_DNS_APPLY_UC="$WORK_DIR/missing-dns-apply.uc" \ +TACHYON_SING_BOX_INIT="$WORK_DIR/missing-sing-box-init" \ +TACHYON_SING_BOX_BIN="$WORK_DIR/missing-sing-box-bin" \ +TACHYON_SING_BOX_CRONET="$WORK_DIR/missing-cronet" \ +TACHYON_RT_TABLES="$WORK_DIR/rt_tables_stop_order" \ + ucode -L "$TACHYON_LIB" "$PACKAGE_UC" prerm grep -Fxq 'stop-with-route-table' "$WORK_DIR/stop-order.log" || - fail "package prerm must stop Forkop before removing its routing table name" + fail "package prerm must stop Tachyon before removing its routing table name" [ ! -s "$WORK_DIR/rt_tables_stop_order" ] || - fail "package prerm must remove the routing table name after Forkop stops" + fail "package prerm must remove the routing table name after Tachyon stops" touch "$WORK_DIR/luci-indexcache.one" "$WORK_DIR/luci-indexcache.two" -FORKOP_PACKAGE_TEST_MODE=1 FORKOP_LUCI_CACHE_GLOBS="$WORK_DIR/luci-indexcache*" \ - ucode -L "$FORKOP_LIB" "$PACKAGE_UC" luci-postinst +TACHYON_PACKAGE_TEST_MODE=1 TACHYON_LUCI_CACHE_GLOBS="$WORK_DIR/luci-indexcache*" \ + ucode -L "$TACHYON_LIB" "$PACKAGE_UC" luci-postinst if compgen -G "$WORK_DIR/luci-indexcache*" >/dev/null; then fail "luci-postinst must remove LuCI index cache files" fi -cat >"$WORK_DIR/forkop-bin" <<'SH' +cat >"$WORK_DIR/tachyon-bin" <<'SH' #!/usr/bin/env bash -printf '%s\n' "$*" >> "${FORKOP_RESTORE_LOG:?}" +printf '%s\n' "$*" >> "${TACHYON_RESTORE_LOG:?}" SH -chmod 0755 "$WORK_DIR/forkop-bin" +chmod 0755 "$WORK_DIR/tachyon-bin" cat >"$WORK_DIR/dont-touch.state" <<'EOF_UCI' -forkop.settings=settings -forkop.settings.dont_touch_dhcp=1 +tachyon.settings=settings +tachyon.settings.dont_touch_dhcp=1 EOF_UCI -printf '105 forkop\n' >"$WORK_DIR/rt_tables_dont_touch" +printf '105 tachyon\n' >"$WORK_DIR/rt_tables_dont_touch" : >"$WORK_DIR/restore-dont-touch.log" -FORKOP_UCI_STATE_FILE="$WORK_DIR/dont-touch.state" \ -FORKOP_RESTORE_LOG="$WORK_DIR/restore-dont-touch.log" \ -FORKOP_BIN="$WORK_DIR/forkop-bin" \ -FORKOP_DNS_APPLY_UC="$WORK_DIR/missing-dns-apply.uc" \ -FORKOP_SING_BOX_INIT="$WORK_DIR/missing-sing-box-init" \ -FORKOP_SING_BOX_BIN="$WORK_DIR/missing-sing-box-bin" \ -FORKOP_SING_BOX_CRONET="$WORK_DIR/missing-cronet" \ -FORKOP_RT_TABLES="$WORK_DIR/rt_tables_dont_touch" \ - ucode -L "$FORKOP_LIB" "$PACKAGE_UC" prerm +TACHYON_UCI_STATE_FILE="$WORK_DIR/dont-touch.state" \ +TACHYON_RESTORE_LOG="$WORK_DIR/restore-dont-touch.log" \ +TACHYON_BIN="$WORK_DIR/tachyon-bin" \ +TACHYON_DNS_APPLY_UC="$WORK_DIR/missing-dns-apply.uc" \ +TACHYON_SING_BOX_INIT="$WORK_DIR/missing-sing-box-init" \ +TACHYON_SING_BOX_BIN="$WORK_DIR/missing-sing-box-bin" \ +TACHYON_SING_BOX_CRONET="$WORK_DIR/missing-cronet" \ +TACHYON_RT_TABLES="$WORK_DIR/rt_tables_dont_touch" \ + ucode -L "$TACHYON_LIB" "$PACKAGE_UC" prerm [ ! -s "$WORK_DIR/restore-dont-touch.log" ] || fail "package prerm must skip dnsmasq restore when dont_touch_dhcp is enabled" cat >"$WORK_DIR/restore.state" <<'EOF_UCI' -forkop.settings=settings -forkop.settings.dont_touch_dhcp=0 +tachyon.settings=settings +tachyon.settings.dont_touch_dhcp=0 EOF_UCI -printf '105 forkop\n' >"$WORK_DIR/rt_tables_restore" +printf '105 tachyon\n' >"$WORK_DIR/rt_tables_restore" : >"$WORK_DIR/restore.log" -FORKOP_UCI_STATE_FILE="$WORK_DIR/restore.state" \ -FORKOP_RESTORE_LOG="$WORK_DIR/restore.log" \ -FORKOP_BIN="$WORK_DIR/forkop-bin" \ -FORKOP_DNS_APPLY_UC="$WORK_DIR/missing-dns-apply.uc" \ -FORKOP_SING_BOX_INIT="$WORK_DIR/missing-sing-box-init" \ -FORKOP_SING_BOX_BIN="$WORK_DIR/missing-sing-box-bin" \ -FORKOP_SING_BOX_CRONET="$WORK_DIR/missing-cronet" \ -FORKOP_RT_TABLES="$WORK_DIR/rt_tables_restore" \ - ucode -L "$FORKOP_LIB" "$PACKAGE_UC" prerm +TACHYON_UCI_STATE_FILE="$WORK_DIR/restore.state" \ +TACHYON_RESTORE_LOG="$WORK_DIR/restore.log" \ +TACHYON_BIN="$WORK_DIR/tachyon-bin" \ +TACHYON_DNS_APPLY_UC="$WORK_DIR/missing-dns-apply.uc" \ +TACHYON_SING_BOX_INIT="$WORK_DIR/missing-sing-box-init" \ +TACHYON_SING_BOX_BIN="$WORK_DIR/missing-sing-box-bin" \ +TACHYON_SING_BOX_CRONET="$WORK_DIR/missing-cronet" \ +TACHYON_RT_TABLES="$WORK_DIR/rt_tables_restore" \ + ucode -L "$TACHYON_LIB" "$PACKAGE_UC" prerm grep -Fxq 'restore_dnsmasq' "$WORK_DIR/restore.log" || fail "package prerm must restore dnsmasq when dont_touch_dhcp is disabled" cat >"$WORK_DIR/upgrade-init" <<'SH' #!/usr/bin/env bash case "$1" in - status) exit "${FORKOP_FAKE_STATUS:-0}" ;; - start) printf '%s\n' start >>"${FORKOP_START_LOG:?}" ;; + status) exit "${TACHYON_FAKE_STATUS:-0}" ;; + start) printf '%s\n' start >>"${TACHYON_START_LOG:?}" ;; esac SH chmod 0755 "$WORK_DIR/upgrade-init" : >"$WORK_DIR/upgrade-start.log" : >"$WORK_DIR/rt_tables_upgrade" -FORKOP_PACKAGE_TEST_MODE=1 \ -FORKOP_INIT="$WORK_DIR/upgrade-init" \ -FORKOP_START_LOG="$WORK_DIR/upgrade-start.log" \ -FORKOP_RT_TABLES="$WORK_DIR/rt_tables_upgrade" \ - ucode -L "$FORKOP_LIB" "$PACKAGE_UC" prerm upgrade -[ -f "$FORKOP_PACKAGE_UPGRADE_STATE" ] || +TACHYON_PACKAGE_TEST_MODE=1 \ +TACHYON_INIT="$WORK_DIR/upgrade-init" \ +TACHYON_START_LOG="$WORK_DIR/upgrade-start.log" \ +TACHYON_RT_TABLES="$WORK_DIR/rt_tables_upgrade" \ + ucode -L "$TACHYON_LIB" "$PACKAGE_UC" prerm upgrade +[ -f "$TACHYON_PACKAGE_UPGRADE_STATE" ] || fail "package pre-upgrade must remember a running service" -FORKOP_PACKAGE_TEST_MODE=1 \ -FORKOP_INIT="$WORK_DIR/upgrade-init" \ -FORKOP_START_LOG="$WORK_DIR/upgrade-start.log" \ - ucode -L "$FORKOP_LIB" "$PACKAGE_UC" postinst +TACHYON_PACKAGE_TEST_MODE=1 \ +TACHYON_INIT="$WORK_DIR/upgrade-init" \ +TACHYON_START_LOG="$WORK_DIR/upgrade-start.log" \ + ucode -L "$TACHYON_LIB" "$PACKAGE_UC" postinst grep -Fxq start "$WORK_DIR/upgrade-start.log" || fail "package postinst must restart a service that was running before upgrade" -[ ! -e "$FORKOP_PACKAGE_UPGRADE_STATE" ] || +[ ! -e "$TACHYON_PACKAGE_UPGRADE_STATE" ] || fail "package postinst must clear the consumed upgrade state" -FORKOP_PACKAGE_TEST_MODE=1 \ -FORKOP_FAKE_STATUS=1 \ -FORKOP_INIT="$WORK_DIR/upgrade-init" \ -FORKOP_RT_TABLES="$WORK_DIR/rt_tables_upgrade" \ - ucode -L "$FORKOP_LIB" "$PACKAGE_UC" prerm upgrade -[ ! -e "$FORKOP_PACKAGE_UPGRADE_STATE" ] || +TACHYON_PACKAGE_TEST_MODE=1 \ +TACHYON_FAKE_STATUS=1 \ +TACHYON_INIT="$WORK_DIR/upgrade-init" \ +TACHYON_RT_TABLES="$WORK_DIR/rt_tables_upgrade" \ + ucode -L "$TACHYON_LIB" "$PACKAGE_UC" prerm upgrade +[ ! -e "$TACHYON_PACKAGE_UPGRADE_STATE" ] || fail "package pre-upgrade must not mark an already stopped service" printf 'package lifecycle checks passed\n' diff --git a/tests/package_versions.sh b/tests/package_versions.sh index ec058e21..39f5cbf0 100755 --- a/tests/package_versions.sh +++ b/tests/package_versions.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -PACKAGES_UC="${PACKAGES_UC:-$ROOT_DIR/forkop/files/usr/lib/core/packages.uc}" +PACKAGES_UC="${PACKAGES_UC:-$ROOT_DIR/tachyon/files/usr/lib/core/packages.uc}" WORK_DIR="$(mktemp -d)" cleanup() { diff --git a/tests/priority_failover.sh b/tests/priority_failover.sh index a0429571..8239d424 100644 --- a/tests/priority_failover.sh +++ b/tests/priority_failover.sh @@ -1,11 +1,11 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -GENERATOR_UC="$FORKOP_LIB/singbox/generator.uc" -VALIDATOR_UC="$FORKOP_LIB/config/validator.uc" -PRIORITY_UC="$FORKOP_LIB/singbox/priority.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +GENERATOR_UC="$TACHYON_LIB/singbox/generator.uc" +VALIDATOR_UC="$TACHYON_LIB/config/validator.uc" +PRIORITY_UC="$TACHYON_LIB/singbox/priority.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -23,7 +23,7 @@ generate_config() { local output="$2" mkdir -p "${output}.section-cache" - ucode -L "$FORKOP_LIB" "$GENERATOR_UC" generate-config-fixture \ + ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ "$fixture" "$output" "127.0.0.1" } @@ -38,7 +38,7 @@ input.settings.dns_server ??= ['77.88.8.8']; input.settings.bootstrap_dns_server ??= ['77.88.8.8']; fs.writeFileSync(process.argv[3], JSON.stringify(input)); JS - FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR_UC" \ + TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$VALIDATOR_UC" \ validate-runtime-fixture "$normalized" "{}" } @@ -365,7 +365,7 @@ JSON cat >"$WORK_DIR/select-first-live-latency.json" <<'JSON' { "a": -1, "b": 200, "c": 50 } JSON -first_live="$(ucode -L "$FORKOP_LIB" "$PRIORITY_UC" select-fixture \ +first_live="$(ucode -L "$TACHYON_LIB" "$PRIORITY_UC" select-fixture \ "$WORK_DIR/select-first-live-group.json" "$WORK_DIR/select-first-live-latency.json" 0 1)" printf '%s\n' "$first_live" | grep -Fq '"tag": "b"' || fail "priority selection should choose the first live outbound when pick_fastest=0" @@ -382,7 +382,7 @@ JSON cat >"$WORK_DIR/select-fastest-latency.json" <<'JSON' { "a": 80, "b": 20, "c": 50 } JSON -fastest="$(ucode -L "$FORKOP_LIB" "$PRIORITY_UC" select-fixture \ +fastest="$(ucode -L "$TACHYON_LIB" "$PRIORITY_UC" select-fixture \ "$WORK_DIR/select-fastest-group.json" "$WORK_DIR/select-fastest-latency.json" 0 0)" printf '%s\n' "$fastest" | grep -Fq '"tag": "b"' || fail "priority selection should choose the fastest live outbound when pick_fastest=1" @@ -651,7 +651,7 @@ cat >"$WORK_DIR/bad-filter-mode.json" <<'JSON' JSON assert_rejects "priority level bad filter mode" "$WORK_DIR/bad-filter-mode.json" "Invalid Priority filter mode" -skip_dead="$(ucode -L "$FORKOP_LIB" "$PRIORITY_UC" select-fixture \ +skip_dead="$(ucode -L "$TACHYON_LIB" "$PRIORITY_UC" select-fixture \ "$WORK_DIR/select-first-live-group.json" "$WORK_DIR/select-fastest-latency.json" 0 1 a)" printf '%s\n' "$skip_dead" | grep -Fq '"tag": "b"' || fail "replacement selection should skip the just-failed active outbound" @@ -659,7 +659,7 @@ printf '%s\n' "$skip_dead" | grep -Fq '"tag": "b"' || cat >"$WORK_DIR/select-faster-current-latency.json" <<'JSON' { "a": 100, "b": 50, "c": 80 } JSON -same_level="$(ucode -L "$FORKOP_LIB" "$PRIORITY_UC" select-faster-fixture \ +same_level="$(ucode -L "$TACHYON_LIB" "$PRIORITY_UC" select-faster-fixture \ "$WORK_DIR/select-fastest-group.json" "$WORK_DIR/select-faster-current-latency.json" 0 a)" printf '%s\n' "$same_level" | grep -Fq '"tag": "b"' || fail "same-level switching should compare candidates with the active delay from the same pass" diff --git a/tests/provider_rules.sh b/tests/provider_rules.sh index 085eefc7..e2915174 100644 --- a/tests/provider_rules.sh +++ b/tests/provider_rules.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -RULES_UC="$ROOT_DIR/forkop/files/usr/lib/providers/rules.uc" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" +RULES_UC="$ROOT_DIR/tachyon/files/usr/lib/providers/rules.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -28,7 +28,7 @@ rows() { } rules_ucode() { - ucode -L "$FORKOP_LIB" "$RULES_UC" "$@" + ucode -L "$TACHYON_LIB" "$RULES_UC" "$@" } if grep -n -E 'require\("uci"\)\.cursor|cursor\.foreach' "$RULES_UC" >/dev/null 2>&1; then diff --git a/tests/proxy_parameter_filters.sh b/tests/proxy_parameter_filters.sh index 8086a8cd..21f6c61c 100755 --- a/tests/proxy_parameter_filters.sh +++ b/tests/proxy_parameter_filters.sh @@ -1,10 +1,10 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -GENERATOR_UC="$FORKOP_LIB/singbox/generator.uc" -VALIDATOR_UC="$FORKOP_LIB/config/validator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +GENERATOR_UC="$TACHYON_LIB/singbox/generator.uc" +VALIDATOR_UC="$TACHYON_LIB/config/validator.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -21,13 +21,13 @@ generate_config() { local fixture="$1" local output="$2" mkdir -p "${output}.section-cache" - ucode -L "$FORKOP_LIB" "$GENERATOR_UC" generate-config-fixture \ + ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ "$fixture" "$output" "127.0.0.1" } validate_fixture() { local fixture="$1" - FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR_UC" \ + TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$VALIDATOR_UC" \ validate-runtime-fixture "$fixture" "{}" } diff --git a/tests/release_workflow.sh b/tests/release_workflow.sh index aac39ff1..c69ba66e 100644 --- a/tests/release_workflow.sh +++ b/tests/release_workflow.sh @@ -1,11 +1,11 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail workflow="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.github/workflows/build.yml" gate="if: \${{ steps.sourceforge.outputs.available == 'true' }}" grep -Fq 'id: sourceforge' "$workflow" -grep -Fq 'ls /home/frs/project/forkop' "$workflow" +grep -Fq 'ls /home/frs/project/tachyon' "$workflow" [ "$(grep -Fc "$gate" "$workflow")" -eq 4 ] grep -Fq 'SourceForge project is unavailable; skipping mirror publication' "$workflow" diff --git a/tests/runtime_state_owner.sh b/tests/runtime_state_owner.sh index 5c6960d9..6bd8be6c 100644 --- a/tests/runtime_state_owner.sh +++ b/tests/runtime_state_owner.sh @@ -1,16 +1,16 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -FORKOP_INIT="$ROOT_DIR/forkop/files/etc/init.d/forkop" -CLI_UC="$FORKOP_BIN" -RUNTIME_STATE="$ROOT_DIR/forkop/files/usr/lib/runtime_state.sh" -UPDATES_RUNTIME="$ROOT_DIR/forkop/files/usr/lib/updates_runtime.sh" -LIFECYCLE_UC="$ROOT_DIR/forkop/files/usr/lib/service/lifecycle.uc" -UPDATES_UC="$ROOT_DIR/forkop/files/usr/lib/components/updates.uc" -STATE_UC="$ROOT_DIR/forkop/files/usr/lib/service/state.uc" -NFT_UC="$ROOT_DIR/forkop/files/usr/lib/nft/apply.uc" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +TACHYON_INIT="$ROOT_DIR/tachyon/files/etc/init.d/tachyon" +CLI_UC="$TACHYON_BIN" +RUNTIME_STATE="$ROOT_DIR/tachyon/files/usr/lib/runtime_state.sh" +UPDATES_RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/updates_runtime.sh" +LIFECYCLE_UC="$ROOT_DIR/tachyon/files/usr/lib/service/lifecycle.uc" +UPDATES_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updates.uc" +STATE_UC="$ROOT_DIR/tachyon/files/usr/lib/service/state.uc" +NFT_UC="$ROOT_DIR/tachyon/files/usr/lib/nft/apply.uc" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -22,30 +22,30 @@ fail() { [ ! -e "$UPDATES_RUNTIME" ] || fail "updates_runtime.sh shell owner must be removed" -grep -Fq 'runtime_state.sh' "$FORKOP_BIN" && - fail "forkop must not source runtime_state.sh" +grep -Fq 'runtime_state.sh' "$TACHYON_BIN" && + fail "tachyon must not source runtime_state.sh" -service_predicates='process_age_seconds|sing_box_service_pid|pid_is_sing_box|sing_box_service_is_running|sing_box_service_is_stable|forkop_runtime_network_is_configured|forkop_is_running|forkop_is_stably_running|wait_for_forkop_stable_start' -if grep -R -n -E "$service_predicates" "$FORKOP_BIN" "$ROOT_DIR/forkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then +service_predicates='process_age_seconds|sing_box_service_pid|pid_is_sing_box|sing_box_service_is_running|sing_box_service_is_stable|tachyon_runtime_network_is_configured|tachyon_is_running|tachyon_is_stably_running|wait_for_tachyon_stable_start' +if grep -R -n -E "$service_predicates" "$TACHYON_BIN" "$ROOT_DIR/tachyon/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then fail "service running/stability predicates must not remain in shell" fi pending_state='mark_pending_reload|consume_pending_reload|run_pending_reload_if_requested|sync_time_if_needed' -if grep -R -n -E "$pending_state" "$FORKOP_BIN" "$FORKOP_INIT" "$ROOT_DIR/forkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then +if grep -R -n -E "$pending_state" "$TACHYON_BIN" "$TACHYON_INIT" "$ROOT_DIR/tachyon/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then fail "pending reload/time-sync ownership must not remain in shell" fi -runtime_state_shell_symbols='runtime_state_ucode|acquire_runtime_dir_lock|release_runtime_dir_lock|capture_reload_state|write_reload_state_file|write_reload_state|clear_reload_state|populate_nft_runtime_sets|rebuild_nft_runtime|reload_sing_box_runtime|apply_pending_urltest_selector_switches|close_inherited_service_lock_fd|FORKOP_URLTEST_SELECTOR_SWITCHES' -if grep -R -n -E "$runtime_state_shell_symbols" "$FORKOP_BIN" "$FORKOP_INIT" "$ROOT_DIR/forkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then +runtime_state_shell_symbols='runtime_state_ucode|acquire_runtime_dir_lock|release_runtime_dir_lock|capture_reload_state|write_reload_state_file|write_reload_state|clear_reload_state|populate_nft_runtime_sets|rebuild_nft_runtime|reload_sing_box_runtime|apply_pending_urltest_selector_switches|close_inherited_service_lock_fd|TACHYON_URLTEST_SELECTOR_SWITCHES' +if grep -R -n -E "$runtime_state_shell_symbols" "$TACHYON_BIN" "$TACHYON_INIT" "$ROOT_DIR/tachyon/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then fail "runtime_state.sh symbols must not remain in shell" fi -if grep -Fq 'acquire_reload_lock' "$FORKOP_INIT" || grep -Fq 'release_reload_lock' "$FORKOP_INIT"; then +if grep -Fq 'acquire_reload_lock' "$TACHYON_INIT" || grep -Fq 'release_reload_lock' "$TACHYON_INIT"; then fail "init.d must not own reload lock decisions" fi -grep -Fq '#!/usr/bin/ucode' "$FORKOP_BIN" || - fail "forkop entrypoint must be a direct ucode executable" +grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" || + fail "tachyon entrypoint must be a direct ucode executable" grep -Fq 'service/lifecycle.uc' "$CLI_UC" || fail "service/cli.uc must dispatch lifecycle orchestration through service/lifecycle.uc" grep -Fq 'refresh-cron-from-uci' "$LIFECYCLE_UC" || @@ -56,7 +56,7 @@ grep -Fq '"list-update"' "$LIFECYCLE_UC" || fail "service/lifecycle.uc start/reload must run list updates through components/updates.uc" grep -Fq 'subscription_update: [ "components/updates.uc", "subscription-update", 2 ]' "$CLI_UC" || fail "service/cli.uc must run subscription updates through components/updates.uc" -if grep -R -n -F 'updates_runtime.sh' "$FORKOP_BIN" "$ROOT_DIR/forkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then +if grep -R -n -F 'updates_runtime.sh' "$TACHYON_BIN" "$ROOT_DIR/tachyon/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then fail "runtime shell must not reference updates_runtime.sh" fi grep -Fq 'mode == "refresh-cron-from-uci"' "$UPDATES_UC" || @@ -67,11 +67,11 @@ grep -Fq 'mode == "list-update"' "$UPDATES_UC" || fail "components/updates.uc must own list update" grep -Fq 'mode == "subscription-update"' "$UPDATES_UC" || fail "components/updates.uc must own subscription update" -grep -Fq 'mode == "forkop-running"' "$STATE_UC" || - fail "service/state.uc must own Forkop running predicate" +grep -Fq 'mode == "tachyon-running"' "$STATE_UC" || + fail "service/state.uc must own Tachyon running predicate" grep -Fq 'mode == "sing-box-service-stable"' "$STATE_UC" || fail "service/state.uc must expose stable sing-box predicate" -grep -Fq 'mode == "wait-forkop-stable-start"' "$STATE_UC" || +grep -Fq 'mode == "wait-tachyon-stable-start"' "$STATE_UC" || fail "service/state.uc must own stable-start waiting" grep -Fq 'mode == "mark-pending-reload"' "$STATE_UC" || fail "service/state.uc must own pending reload writes" @@ -83,12 +83,12 @@ grep -Fq 'mode == "write-captured-reload-state"' "$STATE_UC" || fail "service/state.uc must own captured reload state writes" grep -Fq 'mode == "reload-sing-box-runtime"' "$STATE_UC" || fail "service/state.uc must own sing-box runtime reload" -grep -Fq '"wait-forkop-stable-start"' "$LIFECYCLE_UC" || +grep -Fq '"wait-tachyon-stable-start"' "$LIFECYCLE_UC" || fail "service/lifecycle.uc must verify stable runtime after sing-box start/reload" grep -Fq 'SING_BOX_START_STABLE_MIN_AGE' "$LIFECYCLE_UC" || fail "service/lifecycle.uc must use a dedicated sing-box start stability window" sing_box_start_line="$(grep -nF 'command_success_from_args([ "/etc/init.d/sing-box", "start" ])' "$LIFECYCLE_UC" | head -n1 | cut -d: -f1)" -early_stable_line="$(awk -v start="$sing_box_start_line" 'NR > start && /"wait-forkop-stable-start"/ { print NR; exit }' "$LIFECYCLE_UC")" +early_stable_line="$(awk -v start="$sing_box_start_line" 'NR > start && /"wait-tachyon-stable-start"/ { print NR; exit }' "$LIFECYCLE_UC")" start_stable_min_age_line="$(awk -v start="$sing_box_start_line" 'NR > start && /SING_BOX_START_STABLE_MIN_AGE/ { print NR; exit }' "$LIFECYCLE_UC")" deferred_bootstrap_line="$(grep -nF '"run-deferred-bootstrap"' "$LIFECYCLE_UC" | head -n1 | cut -d: -f1)" [ -n "$sing_box_start_line" ] || @@ -105,9 +105,9 @@ reload_stable_min_age_line="$(awk -v start="$sing_box_reload_line" 'NR > start & fail "service/lifecycle.uc must reload sing-box through service/state.uc" [ -n "$reload_stable_min_age_line" ] || fail "service/lifecycle.uc must use the dedicated sing-box start stability window after sing-box reload" -grep -Fq 'Reload verification failed after sing-box was reloaded; stopping Forkop runtime' "$LIFECYCLE_UC" || +grep -Fq 'Reload verification failed after sing-box was reloaded; stopping Tachyon runtime' "$LIFECYCLE_UC" || fail "service/lifecycle.uc must fail reload when sing-box does not stay stable" -grep -Fq 'Reload runtime restart verification failed after Forkop was started; rolling back DNS changes' "$LIFECYCLE_UC" || +grep -Fq 'Reload runtime restart verification failed after Tachyon was started; rolling back DNS changes' "$LIFECYCLE_UC" || fail "service/lifecycle.uc must fail full runtime restart when sing-box does not stay stable" if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"|uci_get_cli|uci_exists_cli|mwan3_has_enabled_interface_from_uci_show' "$STATE_UC" >/dev/null 2>&1; then fail "service/state.uc must not own direct UCI CLI/cursor access" @@ -115,7 +115,7 @@ fi grep -Fq 'nft-rebuild-runtime-from-uci' "$LIFECYCLE_UC" || fail "service/lifecycle.uc reload must call nft rebuild through ucode" if grep -Fq 'nft-create-full-runtime-from-uci' "$LIFECYCLE_UC"; then - fail "forkop start/reload must rebuild nft runtime instead of appending to stale state" + fail "tachyon start/reload must rebuild nft runtime instead of appending to stale state" fi grep -Fq 'nft-populate-runtime-sets-from-uci' "$LIFECYCLE_UC" || fail "service/lifecycle.uc reload must call nft populate through ucode" diff --git a/tests/runtime_state_predicates.sh b/tests/runtime_state_predicates.sh index 1004da0a..dbcdc0a1 100644 --- a/tests/runtime_state_predicates.sh +++ b/tests/runtime_state_predicates.sh @@ -1,16 +1,16 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -STATE_UC="$ROOT_DIR/forkop/files/usr/lib/service/state.uc" -NFT_UC="$ROOT_DIR/forkop/files/usr/lib/nft/apply.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +STATE_UC="$ROOT_DIR/tachyon/files/usr/lib/service/state.uc" +NFT_UC="$ROOT_DIR/tachyon/files/usr/lib/nft/apply.uc" UCODE_BIN="$(command -v ucode)" WORK_DIR="$(mktemp -d)" export ZAPRET_DEFAULT_NFQWS_OPT="--default-zapret" export ZAPRET2_DEFAULT_NFQWS2_OPT="--default-zapret2" export BYEDPI_DEFAULT_CMD_OPTS="--default-bye" -export FORKOP_FAKE_INIT_CAPTURE="$WORK_DIR/pending-reload-init.args" +export TACHYON_FAKE_INIT_CAPTURE="$WORK_DIR/pending-reload-init.args" cleanup() { rm -rf "$WORK_DIR" @@ -35,11 +35,11 @@ md5_file() { } state_ucode() { - ucode -L "$FORKOP_LIB" "$STATE_UC" "$@" + ucode -L "$TACHYON_LIB" "$STATE_UC" "$@" } nft_ucode() { - ucode -L "$FORKOP_LIB" "$NFT_UC" "$@" + ucode -L "$TACHYON_LIB" "$NFT_UC" "$@" } state_ucode list-has-remote-references "local.lst https://example.com/list.txt" >/dev/null || @@ -183,15 +183,15 @@ fi if ! PATH="$WORK_DIR/stable-start-bin:$PATH" \ REAL_UCODE="$UCODE_BIN" \ SING_BOX_TEST_PID_FILE="$WORK_DIR/sing-box.pid" \ - state_ucode forkop-running forkop ForkopTable 0x00100000; then + state_ucode tachyon-running tachyon TachyonTable 0x00100000; then kill "$sing_box_pid" >/dev/null 2>&1 || true wait "$sing_box_pid" 2>/dev/null || true - fail "stable-start fixture must expose configured Forkop networking" + fail "stable-start fixture must expose configured Tachyon networking" fi if ! PATH="$WORK_DIR/stable-start-bin:$PATH" \ REAL_UCODE="$UCODE_BIN" \ SING_BOX_TEST_PID_FILE="$WORK_DIR/sing-box.pid" \ - state_ucode wait-forkop-stable-start forkop ForkopTable 0x00100000 2 2; then + state_ucode wait-tachyon-stable-start tachyon TachyonTable 0x00100000 2 2; then kill "$sing_box_pid" >/dev/null 2>&1 || true wait "$sing_box_pid" 2>/dev/null || true fail "stable-start wait must check runtime state after its final sleep" @@ -217,17 +217,17 @@ fi cat >"$WORK_DIR/fake-init" <<'SH' #!/bin/sh -printf '%s\n' "$1" >"$FORKOP_FAKE_INIT_CAPTURE" +printf '%s\n' "$1" >"$TACHYON_FAKE_INIT_CAPTURE" SH chmod +x "$WORK_DIR/fake-init" state_ucode mark-pending-reload "$PENDING_RELOAD_FILE" "reload_busy" state_ucode run-pending-reload-if-requested "$PENDING_RELOAD_FILE" "$WORK_DIR/fake-init" for _ in $(seq 1 20); do - [ -s "$FORKOP_FAKE_INIT_CAPTURE" ] && break + [ -s "$TACHYON_FAKE_INIT_CAPTURE" ] && break sleep 0.1 done assert_eq "reload" \ - "$(cat "$FORKOP_FAKE_INIT_CAPTURE")" \ + "$(cat "$TACHYON_FAKE_INIT_CAPTURE")" \ "pending reload should invoke init.d reload" [ ! -e "$PENDING_RELOAD_FILE" ] || fail "pending reload should be consumed when worker is started" @@ -294,9 +294,9 @@ cat >"$WORK_DIR/service-dns-state.json" <<'JSON' "server": [ "1.1.1.1#53", "8.8.8.8" ], "noresolv": "1", "cachesize": "0", - "forkop_server": [ "127.0.0.42#53" ], - "forkop_noresolv": "0", - "forkop_cachesize": "1500" + "tachyon_server": [ "127.0.0.42#53" ], + "tachyon_noresolv": "0", + "tachyon_cachesize": "1500" }, "legacy_dnsmasq_present": true } @@ -320,13 +320,13 @@ cat >"$WORK_DIR/dnsmasq-signature.expected" <<'EOF_DNSMASQ_SIG' 1 [dhcp.@dnsmasq[0].cachesize] 0 -[dhcp.@dnsmasq[0].forkop_server] +[dhcp.@dnsmasq[0].tachyon_server] 127.0.0.42#53 -[dhcp.@dnsmasq[0].forkop_noresolv] +[dhcp.@dnsmasq[0].tachyon_noresolv] 0 -[dhcp.@dnsmasq[0].forkop_cachesize] +[dhcp.@dnsmasq[0].tachyon_cachesize] 1500 -[dhcp.forkop.present] +[dhcp.tachyon.present] 1 EOF_DNSMASQ_SIG diff --git a/tests/selector_state.sh b/tests/selector_state.sh index 05806549..b2340e7b 100644 --- a/tests/selector_state.sh +++ b/tests/selector_state.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -LIFECYCLE_UC="$FORKOP_LIB/service/lifecycle.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +LIFECYCLE_UC="$TACHYON_LIB/service/lifecycle.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -17,8 +17,8 @@ fail() { } lifecycle_ucode() { - FORKOP_RUNTIME_STATE_DIR="$WORK_DIR/run" \ - ucode -L "$FORKOP_LIB" "$LIFECYCLE_UC" "$@" + TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/run" \ + ucode -L "$TACHYON_LIB" "$LIFECYCLE_UC" "$@" } json_flatten() { diff --git a/tests/server_runtime.sh b/tests/server_runtime.sh index b653b10d..36eab462 100644 --- a/tests/server_runtime.sh +++ b/tests/server_runtime.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SERVER_RUNTIME="$ROOT_DIR/forkop/files/usr/lib/server/service.uc" -UCODE_LIB="$ROOT_DIR/forkop/files/usr/lib" +SERVER_RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/server/service.uc" +UCODE_LIB="$ROOT_DIR/tachyon/files/usr/lib" WORK_DIR="$(mktemp -d)" STATE="$WORK_DIR/uci.state" LOG="$WORK_DIR/uci.log" @@ -59,39 +59,39 @@ LOGGER chmod 0755 "$WORK_DIR/bin/logger" export PATH="$WORK_DIR/bin:$PATH" -export FORKOP_UCI_STATE_FILE="$STATE" -export FORKOP_UCI_LOG_FILE="$LOG" -export FORKOP_CONFIG_NAME="forkop" -export FORKOP_SERVER_RUNTIME_UC="$SERVER_RUNTIME" +export TACHYON_UCI_STATE_FILE="$STATE" +export TACHYON_UCI_LOG_FILE="$LOG" +export TACHYON_CONFIG_NAME="tachyon" +export TACHYON_SERVER_RUNTIME_UC="$SERVER_RUNTIME" if grep -E 'uci -q|command -v uci' "$SERVER_RUNTIME" >/dev/null; then fail "server/service.uc must use ucode UCI access instead of shelling out to uci" fi if grep -F 'output("ucode "' "$SERVER_RUNTIME" >/dev/null; then - fail "server defaults must not spawn service.uc without the Forkop module path" + fail "server defaults must not spawn service.uc without the Tachyon module path" fi cat >"$STATE" <<'EOF_STATE' -forkop.vless=server -forkop.vless.protocol=vless -forkop.vless.server_users=client|22222222-2222-4222-8222-222222222222|xtls-rprx-vision -forkop.socks=server -forkop.socks.protocol=socks -forkop.socks.label=desk -forkop.socks_open=server -forkop.socks_open.protocol=socks -forkop.socks_open.label=guest -forkop.socks_open.socks_auth_enabled=0 -forkop.tailscale=server -forkop.tailscale.protocol=tailscale -forkop.json=server -forkop.json.protocol=json_inbound -forkop.mtproto=server -forkop.mtproto.protocol=mtproto -forkop.mtproto.mtproto_secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -forkop.mtproto_legacy=server -forkop.mtproto_legacy.protocol=mtproto -forkop.mtproto_legacy.server_users=client|eebbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb676f6f676c652e636f6d +tachyon.vless=server +tachyon.vless.protocol=vless +tachyon.vless.server_users=client|22222222-2222-4222-8222-222222222222|xtls-rprx-vision +tachyon.socks=server +tachyon.socks.protocol=socks +tachyon.socks.label=desk +tachyon.socks_open=server +tachyon.socks_open.protocol=socks +tachyon.socks_open.label=guest +tachyon.socks_open.socks_auth_enabled=0 +tachyon.tailscale=server +tachyon.tailscale.protocol=tailscale +tachyon.json=server +tachyon.json.protocol=json_inbound +tachyon.mtproto=server +tachyon.mtproto.protocol=mtproto +tachyon.mtproto.mtproto_secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +tachyon.mtproto_legacy=server +tachyon.mtproto_legacy.protocol=mtproto +tachyon.mtproto_legacy.server_users=client|eebbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb676f6f676c652e636f6d EOF_STATE ucode -L "$UCODE_LIB" "$SERVER_RUNTIME" prepare-all-defaults @@ -106,30 +106,30 @@ assert_value() { [ "$actual" = "$expected" ] || fail "$path: expected '$expected', got '$actual'" } -assert_value forkop.vless.security reality -assert_value forkop.vless.listen 0.0.0.0 -assert_value forkop.vless.listen_port 443 -assert_value forkop.vless.server_uuid 22222222-2222-4222-8222-222222222222 -assert_value forkop.vless.vless_flow xtls-rprx-vision -assert_value forkop.vless.reality_short_id abcd1234 -assert_value forkop.vless.reality_private_key private-key -assert_value forkop.vless.reality_public_key public-key -assert_value forkop.socks.security none -assert_value forkop.socks.socks_auth_enabled 1 -assert_value forkop.socks.server_username desk -assert_value forkop.socks.server_password generated-password -assert_value forkop.socks_open.socks_auth_enabled 0 -assert_value forkop.socks_open.server_username guest -assert_value forkop.socks_open.server_password generated-password -assert_value forkop.tailscale.security none -assert_value forkop.tailscale.tailscale_control_url https://controlplane.tailscale.com -assert_value forkop.tailscale.tailscale_hostname forkop-tailscale -assert_value forkop.tailscale.tailscale_advertise_exit_node 1 -assert_value forkop.json.security none -assert_value forkop.mtproto.mtproto_secret aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -assert_value forkop.mtproto_legacy.mtproto_secret bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb -assert_value forkop.mtproto_legacy.mtproto_faketls google.com -grep -Fxq 'commit forkop' "$LOG" || fail 'expected config commit' +assert_value tachyon.vless.security reality +assert_value tachyon.vless.listen 0.0.0.0 +assert_value tachyon.vless.listen_port 443 +assert_value tachyon.vless.server_uuid 22222222-2222-4222-8222-222222222222 +assert_value tachyon.vless.vless_flow xtls-rprx-vision +assert_value tachyon.vless.reality_short_id abcd1234 +assert_value tachyon.vless.reality_private_key private-key +assert_value tachyon.vless.reality_public_key public-key +assert_value tachyon.socks.security none +assert_value tachyon.socks.socks_auth_enabled 1 +assert_value tachyon.socks.server_username desk +assert_value tachyon.socks.server_password generated-password +assert_value tachyon.socks_open.socks_auth_enabled 0 +assert_value tachyon.socks_open.server_username guest +assert_value tachyon.socks_open.server_password generated-password +assert_value tachyon.tailscale.security none +assert_value tachyon.tailscale.tailscale_control_url https://controlplane.tailscale.com +assert_value tachyon.tailscale.tailscale_hostname tachyon-tailscale +assert_value tachyon.tailscale.tailscale_advertise_exit_node 1 +assert_value tachyon.json.security none +assert_value tachyon.mtproto.mtproto_secret aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +assert_value tachyon.mtproto_legacy.mtproto_secret bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +assert_value tachyon.mtproto_legacy.mtproto_faketls google.com +grep -Fxq 'commit tachyon' "$LOG" || fail 'expected config commit' : >"$LOG" ucode -L "$UCODE_LIB" "$SERVER_RUNTIME" prepare-all-defaults diff --git a/tests/service_reload_plan.sh b/tests/service_reload_plan.sh index b6e53c86..c6c35cdb 100644 --- a/tests/service_reload_plan.sh +++ b/tests/service_reload_plan.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -RELOAD_UC="$ROOT_DIR/forkop/files/usr/lib/service/reload.uc" +RELOAD_UC="$ROOT_DIR/tachyon/files/usr/lib/service/reload.uc" WORK_DIR="$(mktemp -d)" cleanup() { diff --git a/tests/service_start_trap.sh b/tests/service_start_trap.sh index f4d1265e..70d18576 100644 --- a/tests/service_start_trap.sh +++ b/tests/service_start_trap.sh @@ -1,19 +1,19 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -FORKOP_INIT="$ROOT_DIR/forkop/files/etc/init.d/forkop" -CLI_UC="$FORKOP_BIN" -LIFECYCLE_UC="$ROOT_DIR/forkop/files/usr/lib/service/lifecycle.uc" -INITD_UC="$ROOT_DIR/forkop/files/usr/lib/service/initd.uc" -STATE_UC="$ROOT_DIR/forkop/files/usr/lib/service/state.uc" -UI_UC="$ROOT_DIR/forkop/files/usr/lib/service/ui.uc" -UPDATES_UC="$ROOT_DIR/forkop/files/usr/lib/components/updates.uc" -SUBSCRIPTION_CACHE_UC="$ROOT_DIR/forkop/files/usr/lib/subscription/cache.uc" -NFQUEUE_RUNTIME_UC="$ROOT_DIR/forkop/files/usr/lib/providers/nfqueue/runtime.uc" -BYEDPI_RUNTIME_UC="$ROOT_DIR/forkop/files/usr/lib/providers/byedpi/runtime.uc" -PRIORITY_UC="$ROOT_DIR/forkop/files/usr/lib/singbox/priority.uc" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +TACHYON_INIT="$ROOT_DIR/tachyon/files/etc/init.d/tachyon" +CLI_UC="$TACHYON_BIN" +LIFECYCLE_UC="$ROOT_DIR/tachyon/files/usr/lib/service/lifecycle.uc" +INITD_UC="$ROOT_DIR/tachyon/files/usr/lib/service/initd.uc" +STATE_UC="$ROOT_DIR/tachyon/files/usr/lib/service/state.uc" +UI_UC="$ROOT_DIR/tachyon/files/usr/lib/service/ui.uc" +UPDATES_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updates.uc" +SUBSCRIPTION_CACHE_UC="$ROOT_DIR/tachyon/files/usr/lib/subscription/cache.uc" +NFQUEUE_RUNTIME_UC="$ROOT_DIR/tachyon/files/usr/lib/providers/nfqueue/runtime.uc" +BYEDPI_RUNTIME_UC="$ROOT_DIR/tachyon/files/usr/lib/providers/byedpi/runtime.uc" +PRIORITY_UC="$ROOT_DIR/tachyon/files/usr/lib/singbox/priority.uc" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -31,7 +31,7 @@ reject_pattern() { local pattern="$1" local label="$2" - ! grep -Fq "$pattern" "$FORKOP_BIN" || fail "$label" + ! grep -Fq "$pattern" "$TACHYON_BIN" || fail "$label" } require_file_pattern() { @@ -42,18 +42,18 @@ require_file_pattern() { grep -Fq "$pattern" "$file" || fail "$label" } -[ -r "$FORKOP_BIN" ] || fail "forkop binary source is missing" -[ -r "$FORKOP_BIN" ] || fail "forkop entrypoint is missing" +[ -r "$TACHYON_BIN" ] || fail "tachyon binary source is missing" +[ -r "$TACHYON_BIN" ] || fail "tachyon entrypoint is missing" [ -r "$LIFECYCLE_UC" ] || fail "service/lifecycle.uc is missing" -grep -Fq '#!/usr/bin/ucode' "$FORKOP_BIN" || - fail "forkop entrypoint must be a direct ucode executable" +grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" || + fail "tachyon entrypoint must be a direct ucode executable" grep -Fq 'service/lifecycle.uc' "$CLI_UC" || fail "service/cli.uc must dispatch startup through service/lifecycle.uc" reject_pattern "trap " \ - "forkop shell entrypoint must not own startup fail-safe traps" + "tachyon shell entrypoint must not own startup fail-safe traps" reject_pattern "clear_startup_failsafe_trap" \ - "forkop shell entrypoint must not keep old trap helper" + "tachyon shell entrypoint must not keep old trap helper" require_pattern 'module_background(UPDATES_UC, [ "list-update" ])' \ "startup list_update background job must be owned by service/lifecycle.uc" require_pattern 'module_background(DIAGNOSTICS_UC, [ "get-system-info" ])' \ @@ -91,9 +91,9 @@ require_file_pattern "$INITD_UC" 'reload pending >/dev/null 2>&1 1000>&- &' \ "service/initd.uc pending reload worker must close inherited procd lock fd" require_file_pattern "$STATE_UC" 'reload pending >/dev/null 2>&1 1000>&- &' \ "service/state.uc pending reload worker must close inherited procd lock fd" -require_file_pattern "$FORKOP_INIT" 'FORKOP_LAST_START_STATUS="$?"' \ +require_file_pattern "$TACHYON_INIT" 'TACHYON_LAST_START_STATUS="$?"' \ "init.d start_service must preserve backend start status for rc.common" -require_file_pattern "$FORKOP_INIT" 'service_started()' \ +require_file_pattern "$TACHYON_INIT" 'service_started()' \ "init.d must return preserved start status through rc.common service_started hook" require_file_pattern "$UI_UC" '>/dev/null 2>&1 1000>&- & echo $!' \ "service/ui.uc service action workers must close inherited procd lock fd" diff --git a/tests/shell_inventory.sh b/tests/shell_inventory.sh index cf12776c..dc38eedb 100644 --- a/tests/shell_inventory.sh +++ b/tests/shell_inventory.sh @@ -1,35 +1,35 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_FILES="$ROOT_DIR/forkop/files" -FORKOP_BIN="$FORKOP_FILES/usr/bin/forkop" -FORKOP_LIB="$FORKOP_FILES/usr/lib" -FORKOP_INIT="$FORKOP_FILES/etc/init.d/forkop" -LUCI_ROOT="$ROOT_DIR/luci-app-forkop/root" -LUCI_UCI_DEFAULTS="$LUCI_ROOT/etc/uci-defaults/50_luci-forkop" +TACHYON_FILES="$ROOT_DIR/tachyon/files" +TACHYON_BIN="$TACHYON_FILES/usr/bin/tachyon" +TACHYON_LIB="$TACHYON_FILES/usr/lib" +TACHYON_INIT="$TACHYON_FILES/etc/init.d/tachyon" +LUCI_ROOT="$ROOT_DIR/luci-app-tachyon/root" +LUCI_UCI_DEFAULTS="$LUCI_ROOT/etc/uci-defaults/50_luci-tachyon" fail() { printf 'FAIL: %s\n' "$1" >&2 exit 1 } -[ -d "$FORKOP_LIB" ] || fail "runtime library directory is missing" -[ -r "$FORKOP_BIN" ] || fail "forkop ucode entrypoint is missing" -[ -r "$FORKOP_INIT" ] || fail "forkop init.d entrypoint is missing" +[ -d "$TACHYON_LIB" ] || fail "runtime library directory is missing" +[ -r "$TACHYON_BIN" ] || fail "tachyon ucode entrypoint is missing" +[ -r "$TACHYON_INIT" ] || fail "tachyon init.d entrypoint is missing" [ -r "$LUCI_UCI_DEFAULTS" ] || fail "LuCI uci-defaults entrypoint is missing" -runtime_shell_files="$(find "$FORKOP_LIB" -type f -name '*.sh' -print)" +runtime_shell_files="$(find "$TACHYON_LIB" -type f -name '*.sh' -print)" [ -z "$runtime_shell_files" ] || fail "runtime library must not contain shell owners: $runtime_shell_files" legacy_shell_owners='runtime_state\.sh|rules_nft_runtime\.sh|config_validation\.sh|sing_box_runtime\.sh|updates_runtime\.sh|updater\.sh|status_diagnostics\.sh|helpers\.sh|constants\.sh|subscription_runtime\.sh|byedpi\.sh|zapret\.sh|zapret2\.sh' -if find "$FORKOP_FILES" -type f -print | grep -E "$legacy_shell_owners" >/dev/null 2>&1; then - fail "legacy runtime shell owner file returned under forkop/files" +if find "$TACHYON_FILES" -type f -print | grep -E "$legacy_shell_owners" >/dev/null 2>&1; then + fail "legacy runtime shell owner file returned under tachyon/files" fi shell_scripts="$( - find "$FORKOP_FILES" "$LUCI_ROOT" -type f -print | + find "$TACHYON_FILES" "$LUCI_ROOT" -type f -print | while IFS= read -r file; do first_line="$(sed -n '1p' "$file")" case "$first_line" in @@ -43,8 +43,8 @@ shell_scripts="$( expected_shell_scripts="$( printf '%s\n' \ - 'luci-app-forkop/root/etc/uci-defaults/50_luci-forkop' \ - 'forkop/files/etc/init.d/forkop' | + 'luci-app-tachyon/root/etc/uci-defaults/50_luci-tachyon' \ + 'tachyon/files/etc/init.d/tachyon' | LC_ALL=C sort )" @@ -55,33 +55,33 @@ $expected_shell_scripts actual: $shell_scripts" -grep -Fq '#!/usr/bin/ucode' "$FORKOP_BIN" || - fail "/usr/bin/forkop must remain a direct ucode executable" -grep -Fq 'function command_spec(command)' "$FORKOP_BIN" || - fail "/usr/bin/forkop must own command routing in ucode" -if grep -n -E '#!/bin/(ba)?sh|exec[[:space:]]+ucode|run_module\(|FORKOP_COMMAND' "$FORKOP_BIN" >/dev/null 2>&1; then - fail "/usr/bin/forkop must not regress to a shell loader or shell router" +grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" || + fail "/usr/bin/tachyon must remain a direct ucode executable" +grep -Fq 'function command_spec(command)' "$TACHYON_BIN" || + fail "/usr/bin/tachyon must own command routing in ucode" +if grep -n -E '#!/bin/(ba)?sh|exec[[:space:]]+ucode|run_module\(|TACHYON_COMMAND' "$TACHYON_BIN" >/dev/null 2>&1; then + fail "/usr/bin/tachyon must not regress to a shell loader or shell router" fi -grep -Fq 'FORKOP_INITD_UC="$FORKOP_LIB/service/initd.uc"' "$FORKOP_INIT" || +grep -Fq 'TACHYON_INITD_UC="$TACHYON_LIB/service/initd.uc"' "$TACHYON_INIT" || fail "init.d must delegate service orchestration to service/initd.uc" -grep -Fq 'initd_ucode start-service' "$FORKOP_INIT" || +grep -Fq 'initd_ucode start-service' "$TACHYON_INIT" || fail "init.d start path must delegate to ucode" -grep -Fq 'initd_ucode stop-service' "$FORKOP_INIT" || +grep -Fq 'initd_ucode stop-service' "$TACHYON_INIT" || fail "init.d stop path must delegate to ucode" -grep -Fq 'initd_ucode reload-service' "$FORKOP_INIT" || +grep -Fq 'initd_ucode reload-service' "$TACHYON_INIT" || fail "init.d reload path must delegate to ucode" -grep -Fq 'initd_ucode trigger-plan' "$FORKOP_INIT" || +grep -Fq 'initd_ucode trigger-plan' "$TACHYON_INIT" || fail "init.d trigger decisions must be produced by ucode" -if grep -n -E '(^|[^[:alnum:]_])(uci|config_load|config_get|config_foreach|jsonfilter|nft|iptables|ip6?tables|sing-box|dnsmasq|curl|wget|opkg|apk)([[:space:]]|$)' "$FORKOP_INIT" >/dev/null 2>&1; then +if grep -n -E '(^|[^[:alnum:]_])(uci|config_load|config_get|config_foreach|jsonfilter|nft|iptables|ip6?tables|sing-box|dnsmasq|curl|wget|opkg|apk)([[:space:]]|$)' "$TACHYON_INIT" >/dev/null 2>&1; then fail "init.d must not own UCI, routing, download, package, dnsmasq, nft, or sing-box decisions" fi -if grep -n -E 'FORKOP_RELOAD_LOCK|FORKOP_URLTEST_SELECTOR_SWITCHES|capture_reload_state|populate_nft_runtime_sets|rebuild_nft_runtime|apply_pending_urltest_selector_switches' "$FORKOP_INIT" >/dev/null 2>&1; then +if grep -n -E 'TACHYON_RELOAD_LOCK|TACHYON_URLTEST_SELECTOR_SWITCHES|capture_reload_state|populate_nft_runtime_sets|rebuild_nft_runtime|apply_pending_urltest_selector_switches' "$TACHYON_INIT" >/dev/null 2>&1; then fail "init.d must not own runtime state or reload decisions" fi -grep -Fq '/usr/bin/forkop luci_postinst' "$LUCI_UCI_DEFAULTS" || +grep -Fq '/usr/bin/tachyon luci_postinst' "$LUCI_UCI_DEFAULTS" || fail "LuCI uci-defaults must delegate postinstall work to ucode" if grep -n -E '(^|[^[:alnum:]_])(uci|rm|logger|rpcd|killall|jsonfilter|config_load|config_get)([[:space:]]|$)' "$LUCI_UCI_DEFAULTS" >/dev/null 2>&1; then fail "LuCI uci-defaults must not own cache, rpcd, logging, or UCI logic" diff --git a/tests/sing_box_runtime.sh b/tests/sing_box_runtime.sh index f57285d1..8c69847f 100644 --- a/tests/sing_box_runtime.sh +++ b/tests/sing_box_runtime.sh @@ -1,14 +1,14 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -CLI_UC="$FORKOP_BIN" -SING_BOX_RUNTIME_SH="$FORKOP_LIB/sing_box_runtime.sh" -LIFECYCLE_UC="$FORKOP_LIB/service/lifecycle.uc" -SINGBOX_RUNTIME_UC="$FORKOP_LIB/singbox/runtime.uc" -SINGBOX_GENERATOR_UC="$FORKOP_LIB/singbox/generator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +CLI_UC="$TACHYON_BIN" +SING_BOX_RUNTIME_SH="$TACHYON_LIB/sing_box_runtime.sh" +LIFECYCLE_UC="$TACHYON_LIB/service/lifecycle.uc" +SINGBOX_RUNTIME_UC="$TACHYON_LIB/singbox/runtime.uc" +SINGBOX_GENERATOR_UC="$TACHYON_LIB/singbox/generator.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -23,13 +23,13 @@ fail() { [ ! -e "$SING_BOX_RUNTIME_SH" ] || fail "sing_box_runtime.sh shell owner must be removed" -grep -Fq '#!/usr/bin/ucode' "$FORKOP_BIN" || - fail "forkop entrypoint must be a direct ucode executable" +grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" || + fail "tachyon entrypoint must be a direct ucode executable" grep -Fq 'service/lifecycle.uc' "$CLI_UC" || fail "service/cli.uc must dispatch lifecycle orchestration through service/lifecycle.uc" grep -Fq 'singbox/runtime.uc' "$LIFECYCLE_UC" || fail "service/lifecycle.uc must call singbox/runtime.uc for sing-box runtime operations" -if grep -R -n -E 'sing_box_runtime_ucode|rulesets_ucode|sing_box_configure_service|sing_box_init_config|get_service_listen_address|get_device_ipv4_address|get_download_detour_tag' "$FORKOP_BIN" "$FORKOP_LIB" --include='*.sh' >/dev/null 2>&1; then +if grep -R -n -E 'sing_box_runtime_ucode|rulesets_ucode|sing_box_configure_service|sing_box_init_config|get_service_listen_address|get_device_ipv4_address|get_download_detour_tag' "$TACHYON_BIN" "$TACHYON_LIB" --include='*.sh' >/dev/null 2>&1; then fail "sing-box runtime shell symbols must not remain" fi grep -Fq 'mode == "configure-service"' "$SINGBOX_RUNTIME_UC" || @@ -54,7 +54,7 @@ if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$SINGBOX_GENERATOR_U fi grep -Fq 'require("core.uci")' "$SINGBOX_GENERATOR_UC" || fail "singbox/generator.uc must import core.uci" -grep -Fq 'FORKOP_RULE_CONDITION_CACHE_DIR' "$LIFECYCLE_UC" || +grep -Fq 'TACHYON_RULE_CONDITION_CACHE_DIR' "$LIFECYCLE_UC" || fail "service/lifecycle.uc must pass rule-condition cache dir through module environment" if awk ' /"write-current-reload-state-clean"|"write-captured-reload-state"/ { @@ -84,9 +84,9 @@ EOF_SING_BOX chmod +x "$WORK_DIR/bin/sing-box" stable_variant="$({ PATH="$WORK_DIR/bin:$PATH" \ - FORKOP_LIB="$FORKOP_LIB" \ + TACHYON_LIB="$TACHYON_LIB" \ SB_VARIANT_STATE_FILE="$WORK_DIR/sing-box-variant" \ - ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" variant + ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" variant } 2>"$WORK_DIR/stable-variant.stderr")" || fail "singbox/runtime.uc must detect a stable binary without a ucode call-order failure" [ "$stable_variant" = "stable" ] || @@ -104,7 +104,7 @@ EOF_SING_BOX_CHECK chmod +x "$WORK_DIR/check-bin/sing-box" : >"$WORK_DIR/invalid-config.json" if PATH="$WORK_DIR/check-bin:$PATH" \ - ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" check-config-fixture \ + ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" check-config-fixture \ "$WORK_DIR/invalid-config.json" "$WORK_DIR/sing-box-check.log" \ >"$WORK_DIR/sing-box-check.reason"; then fail "singbox/runtime.uc check fixture should preserve a failed sing-box status" @@ -118,7 +118,7 @@ cat >"$WORK_DIR/generator-failure.log" <<'EOF_GENERATOR_FAILURE' skipped incompatible subscription outbound for rule 'only_xhttp': Only XHTTP node (XHTTP requires sing-box-extended) connection section has no usable outbounds EOF_GENERATOR_FAILURE -generator_reason="$(ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" generator-failure-reason-fixture \ +generator_reason="$(ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" generator-failure-reason-fixture \ "$WORK_DIR/generator-failure.log" 2)" [ "$generator_reason" = 'connection section has no usable outbounds' ] || fail "singbox/runtime.uc must report the terminal generator error instead of an earlier warning" @@ -126,7 +126,7 @@ generator_reason="$(ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" generator-failu mkdir -p "$WORK_DIR/etc" "$WORK_DIR/tmp" printf 'old config\n' >"$WORK_DIR/etc/config.json" printf 'new config\n' >"$WORK_DIR/tmp/config.json" -ucode -L "$FORKOP_LIB" "$SINGBOX_RUNTIME_UC" save-config-file-fixture \ +ucode -L "$TACHYON_LIB" "$SINGBOX_RUNTIME_UC" save-config-file-fixture \ "$WORK_DIR/tmp/config.json" "$WORK_DIR/etc/config.json" [ "$(cat "$WORK_DIR/etc/config.json")" = "new config" ] || fail "singbox/runtime.uc must replace an existing config from a temp file" @@ -139,7 +139,7 @@ generate_config() { local mwan3_active="${3:-0}" mkdir -p "$output.section-cache" "$output.rulesets" - ucode -L "$FORKOP_LIB" "$FORKOP_LIB/singbox/generator.uc" generate-config-fixture \ + ucode -L "$TACHYON_LIB" "$TACHYON_LIB/singbox/generator.uc" generate-config-fixture \ "$fixture" "$output" "127.0.0.1" "$mwan3_active" } @@ -150,8 +150,8 @@ generate_config_with_subscription_cache() { mkdir -p "$output.section-cache" "$output.rulesets" TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/subscriptions" \ - FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent-subscription-cache" \ - ucode -L "$FORKOP_LIB" "$FORKOP_LIB/singbox/generator.uc" generate-config-fixture \ + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent-subscription-cache" \ + ucode -L "$TACHYON_LIB" "$TACHYON_LIB/singbox/generator.uc" generate-config-fixture \ "$fixture" "$output" "127.0.0.1" "0" "$supports_xhttp" } @@ -168,7 +168,7 @@ cat >"$WORK_DIR/no-enabled-fixture.json" <<'JSON' } JSON -if ucode -L "$FORKOP_LIB" "$SINGBOX_GENERATOR_UC" generate-config-fixture \ +if ucode -L "$TACHYON_LIB" "$SINGBOX_GENERATOR_UC" generate-config-fixture \ "$WORK_DIR/no-enabled-fixture.json" "$WORK_DIR/no-enabled.json" "127.0.0.1" "0" \ >"$WORK_DIR/no-enabled.stdout" 2>"$WORK_DIR/no-enabled.stderr"; then fail "generator should reject a config without enabled sections" @@ -180,21 +180,21 @@ grep -Fxq 'no enabled sections' "$WORK_DIR/no-enabled.stderr" || fail "generator failure reason should be concise" cat >"$WORK_DIR/generator-uci.state" <<'EOF_UCI' -forkop.settings=settings -forkop.settings.dns_server=1.1.1.1 -forkop.settings.bootstrap_dns_server=1.1.1.1 -forkop.settings.config_path=/tmp/sing-box/config.json -forkop.settings.cache_path=/tmp/sing-box/cache.db -forkop.settings.log_level=warn -forkop.uci_proxy=section -forkop.uci_proxy.enabled=1 -forkop.uci_proxy.action=connection -forkop.uci_proxy.outbound_jsons={"type":"direct"} -forkop.uci_proxy.domain_suffix=example.org +tachyon.settings=settings +tachyon.settings.dns_server=1.1.1.1 +tachyon.settings.bootstrap_dns_server=1.1.1.1 +tachyon.settings.config_path=/tmp/sing-box/config.json +tachyon.settings.cache_path=/tmp/sing-box/cache.db +tachyon.settings.log_level=warn +tachyon.uci_proxy=section +tachyon.uci_proxy.enabled=1 +tachyon.uci_proxy.action=connection +tachyon.uci_proxy.outbound_jsons={"type":"direct"} +tachyon.uci_proxy.domain_suffix=example.org EOF_UCI -FORKOP_UCI_STATE_FILE="$WORK_DIR/generator-uci.state" \ - FORKOP_SECTION_CACHE_DIR="$WORK_DIR/generated-from-uci.section-cache" \ - ucode -L "$FORKOP_LIB" "$SINGBOX_GENERATOR_UC" generate-config "$WORK_DIR/generated-from-uci.json" "127.0.0.1" "0" +TACHYON_UCI_STATE_FILE="$WORK_DIR/generator-uci.state" \ + TACHYON_SECTION_CACHE_DIR="$WORK_DIR/generated-from-uci.section-cache" \ + ucode -L "$TACHYON_LIB" "$SINGBOX_GENERATOR_UC" generate-config "$WORK_DIR/generated-from-uci.json" "127.0.0.1" "0" grep -Fq '"example.org"' "$WORK_DIR/generated-from-uci.json" || fail "singbox/generator.uc must read section matchers from core.uci" grep -Fq '"uci_proxy-out"' "$WORK_DIR/generated-from-uci.json" || @@ -885,7 +885,7 @@ cat >"$WORK_DIR/subscriptions/grouped-subscription-1.json" <<'JSON' "interval": "10m", "tolerance": 50, "remark": "Provider Group", - "__forkop_allow_group": true + "__tachyon_allow_group": true }, { "type": "vless", @@ -894,7 +894,7 @@ cat >"$WORK_DIR/subscriptions/grouped-subscription-1.json" <<'JSON' "server_port": 443, "uuid": "00000000-0000-4000-8000-000000000001", "tls": { "enabled": true, "server_name": "example.com" }, - "__forkop_hidden": true + "__tachyon_hidden": true }, { "type": "vless", @@ -903,7 +903,7 @@ cat >"$WORK_DIR/subscriptions/grouped-subscription-1.json" <<'JSON' "server_port": 443, "uuid": "00000000-0000-4000-8000-000000000002", "tls": { "enabled": true, "server_name": "example.org" }, - "__forkop_hidden": true + "__tachyon_hidden": true }, { "type": "direct", @@ -924,7 +924,7 @@ cat >"$WORK_DIR/subscriptions/xhttp-subscription-1.json" <<'JSON' "default": "xhttp-node", "url": "https://www.gstatic.com/generate_204", "remark": "Provider XHTTP Group", - "__forkop_allow_group": true + "__tachyon_allow_group": true }, { "type": "vless", @@ -934,7 +934,7 @@ cat >"$WORK_DIR/subscriptions/xhttp-subscription-1.json" <<'JSON' "server_port": 443, "uuid": "00000000-0000-4000-8000-000000000010", "transport": { "type": "xhttp" }, - "__forkop_hidden": true + "__tachyon_hidden": true }, { "type": "socks", @@ -942,7 +942,7 @@ cat >"$WORK_DIR/subscriptions/xhttp-subscription-1.json" <<'JSON' "remark": "Plain node", "server": "127.0.0.11", "server_port": 1080, - "__forkop_hidden": true + "__tachyon_hidden": true }, { "type": "socks", @@ -1018,7 +1018,7 @@ grep -Fq "Only XHTTP node (XHTTP requires sing-box-extended)" "$WORK_DIR/subscri fail "all-XHTTP subscription failure should end with the actionable generator error" for fixture in manual-xhttp json-xhttp; do - if ucode -L "$FORKOP_LIB" "$SINGBOX_GENERATOR_UC" generate-config-fixture \ + if ucode -L "$TACHYON_LIB" "$SINGBOX_GENERATOR_UC" generate-config-fixture \ "$WORK_DIR/$fixture-fixture.json" "$WORK_DIR/$fixture-stable.json" "127.0.0.1" "0" "0" \ >"$WORK_DIR/$fixture-stable.stdout" 2>"$WORK_DIR/$fixture-stable.stderr"; then fail "stable sing-box should reject explicit $fixture configuration" diff --git a/tests/singbox_rulesets.sh b/tests/singbox_rulesets.sh index 0398471b..bf348a04 100644 --- a/tests/singbox_rulesets.sh +++ b/tests/singbox_rulesets.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -RULESETS_UC="$ROOT_DIR/forkop/files/usr/lib/singbox/rulesets.uc" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" +RULESETS_UC="$ROOT_DIR/tachyon/files/usr/lib/singbox/rulesets.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -19,32 +19,32 @@ assert_eq() { } assert_eq srs \ - "$(ucode -L "$FORKOP_LIB" "$RULESETS_UC" file-extension 'https://example.com/path/rule.srs?token=1#x')" \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" file-extension 'https://example.com/path/rule.srs?token=1#x')" \ "ruleset file extension" -ucode -L "$FORKOP_LIB" "$RULESETS_UC" is-community youtube >/dev/null || +ucode -L "$TACHYON_LIB" "$RULESETS_UC" is-community youtube >/dev/null || fail "youtube should be a community ruleset" -if ucode -L "$FORKOP_LIB" "$RULESETS_UC" is-community unknown_service >/dev/null 2>&1; then +if ucode -L "$TACHYON_LIB" "$RULESETS_UC" is-community unknown_service >/dev/null 2>&1; then fail "unknown service should not be a community ruleset" fi assert_eq domains \ - "$(ucode -L "$FORKOP_LIB" "$RULESETS_UC" kind-from-reference-hint 'https://example.com/geosite-custom.srs')" \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" kind-from-reference-hint 'https://example.com/geosite-custom.srs')" \ "domain ruleset hint" assert_eq subnets \ - "$(ucode -L "$FORKOP_LIB" "$RULESETS_UC" kind-from-reference-hint '/tmp/geoip-cidr.json')" \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" kind-from-reference-hint '/tmp/geoip-cidr.json')" \ "subnet ruleset hint" assert_eq unknown \ - "$(ucode -L "$FORKOP_LIB" "$RULESETS_UC" kind-from-reference-hint '/tmp/custom.srs')" \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" kind-from-reference-hint '/tmp/custom.srs')" \ "unknown ruleset hint" assert_eq source \ - "$(ucode -L "$FORKOP_LIB" "$RULESETS_UC" remote-format 'https://example.com/rules.json')" \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" remote-format 'https://example.com/rules.json')" \ "json remote ruleset format" assert_eq binary \ - "$(ucode -L "$FORKOP_LIB" "$RULESETS_UC" remote-format 'https://example.com/rules.srs')" \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" remote-format 'https://example.com/rules.srs')" \ "srs remote ruleset format" assert_eq binary \ - "$(ucode -L "$FORKOP_LIB" "$RULESETS_UC" remote-format 'https://example.com/rules.unknown')" \ + "$(ucode -L "$TACHYON_LIB" "$RULESETS_UC" remote-format 'https://example.com/rules.unknown')" \ "unknown remote ruleset format" -ucode -L "$FORKOP_LIB" -e 'let rulesets = require("singbox.rulesets"); if (rulesets.kind_from_reference_hint("geoip") != "subnets") exit(1);' +ucode -L "$TACHYON_LIB" -e 'let rulesets = require("singbox.rulesets"); if (rulesets.kind_from_reference_hint("geoip") != "subnets") exit(1);' printf 'singbox rulesets checks passed\n' diff --git a/tests/subscription_alpn.sh b/tests/subscription_alpn.sh index ccab437f..3314a010 100644 --- a/tests/subscription_alpn.sh +++ b/tests/subscription_alpn.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PARSER="$ROOT_DIR/forkop/files/usr/lib/subscription/parser.uc" +PARSER="$ROOT_DIR/tachyon/files/usr/lib/subscription/parser.uc" WORK_DIR="$(mktemp -d)" cleanup() { diff --git a/tests/subscription_cache_state.sh b/tests/subscription_cache_state.sh index c7a94352..71c4c687 100644 --- a/tests/subscription_cache_state.sh +++ b/tests/subscription_cache_state.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CACHE_UC="$ROOT_DIR/forkop/files/usr/lib/subscription/cache.uc" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" +CACHE_UC="$ROOT_DIR/tachyon/files/usr/lib/subscription/cache.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" WORK_DIR="$(mktemp -d)" cleanup() { @@ -25,7 +25,7 @@ assert_eq() { } cache_ucode() { - ucode -L "$FORKOP_LIB" "$CACHE_UC" "$@" + ucode -L "$TACHYON_LIB" "$CACHE_UC" "$@" } assert_eq "first second" \ @@ -44,24 +44,24 @@ if cache_ucode state-list-contains "first second" third >/dev/null 2>&1; then fail "state list should not contain third" fi -SUBSCRIPTION_RUNTIME_SH="$ROOT_DIR/forkop/files/usr/lib/subscription_runtime.sh" -UPDATES_RUNTIME_SH="$ROOT_DIR/forkop/files/usr/lib/updates_runtime.sh" +SUBSCRIPTION_RUNTIME_SH="$ROOT_DIR/tachyon/files/usr/lib/subscription_runtime.sh" +UPDATES_RUNTIME_SH="$ROOT_DIR/tachyon/files/usr/lib/updates_runtime.sh" [ ! -e "$SUBSCRIPTION_RUNTIME_SH" ] || fail "subscription_runtime.sh shell owner must be removed" [ ! -e "$UPDATES_RUNTIME_SH" ] || fail "updates_runtime.sh shell owner must be removed" -if grep -R -n "subscription_runtime.sh" "$ROOT_DIR/forkop/files" >/dev/null 2>&1; then +if grep -R -n "subscription_runtime.sh" "$ROOT_DIR/tachyon/files" >/dev/null 2>&1; then fail "runtime files must not reference subscription_runtime.sh" fi subscription_runtime_shell_symbols='subscription_runtime_|prepare_subscription_caches_for_startup|prepare_subscription_caches_for_runtime_generation|run_deferred_subscription_bootstrap|stop_deferred_subscription_bootstrap_retry_worker' -if grep -R -n -E "$subscription_runtime_shell_symbols" "$ROOT_DIR/forkop/files/usr/bin/forkop" "$ROOT_DIR/forkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then +if grep -R -n -E "$subscription_runtime_shell_symbols" "$ROOT_DIR/tachyon/files/usr/bin/tachyon" "$ROOT_DIR/tachyon/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then fail "subscription_runtime shell symbols must not remain in runtime shell" fi -if grep -R -n -E 'get_subscription_metadata_path|get_outbound_metadata_path' "$ROOT_DIR/forkop/files/usr/bin/forkop" "$ROOT_DIR/forkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then +if grep -R -n -E 'get_subscription_metadata_path|get_outbound_metadata_path' "$ROOT_DIR/tachyon/files/usr/bin/tachyon" "$ROOT_DIR/tachyon/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then fail "subscription metadata path helpers must be owned by subscription/cache.uc" fi @@ -102,10 +102,10 @@ grep -q 'mode == "stop-deferred-bootstrap-worker"' "$CACHE_UC" || assert_eq "proxy-subscription-2" \ "$(cache_ucode source-id proxy 2)" \ "source id mode" -assert_eq "/var/run/forkop/subscription-metadata/proxy.json" \ +assert_eq "/var/run/tachyon/subscription-metadata/proxy.json" \ "$(cache_ucode subscription-metadata-path proxy)" \ "subscription metadata path mode" -assert_eq "/var/run/forkop/outbound-metadata/proxy.json" \ +assert_eq "/var/run/tachyon/outbound-metadata/proxy.json" \ "$(cache_ucode outbound-metadata-path proxy)" \ "outbound metadata path mode" if cache_ucode subscription-metadata-path '../bad' >/dev/null 2>&1; then @@ -137,17 +137,17 @@ runtime_env() { TMP_SING_BOX_FOLDER="$WORK_DIR/runtime/tmp-sing-box" \ TMP_RULESET_FOLDER="$WORK_DIR/runtime/tmp-sing-box/rulesets" \ TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/runtime/tmp-sing-box/subscriptions" \ - FORKOP_RUNTIME_STATE_DIR="$WORK_DIR/runtime/run" \ - FORKOP_SUBSCRIPTION_UPDATE_STATE_DIR="$WORK_DIR/runtime/run/subscription-update" \ - FORKOP_SUBSCRIPTION_LINKS_DIR="$WORK_DIR/runtime/run/subscription-links" \ - FORKOP_SUBSCRIPTION_METADATA_DIR="$WORK_DIR/runtime/run/subscription-metadata" \ - FORKOP_OUTBOUND_METADATA_DIR="$WORK_DIR/runtime/run/outbound-metadata" \ - FORKOP_SECTION_CACHE_DIR="$WORK_DIR/runtime/run/section-cache" \ - FORKOP_RUNTIME_CACHE_FORMAT_FILE="$WORK_DIR/runtime/run/cache-format" \ - FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/runtime/persistent" \ - FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE="$WORK_DIR/runtime/persistent/cache-format" \ - FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT="7" \ - FORKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE="$WORK_DIR/runtime/run/bootstrap.pid" \ + TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime/run" \ + TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR="$WORK_DIR/runtime/run/subscription-update" \ + TACHYON_SUBSCRIPTION_LINKS_DIR="$WORK_DIR/runtime/run/subscription-links" \ + TACHYON_SUBSCRIPTION_METADATA_DIR="$WORK_DIR/runtime/run/subscription-metadata" \ + TACHYON_OUTBOUND_METADATA_DIR="$WORK_DIR/runtime/run/outbound-metadata" \ + TACHYON_SECTION_CACHE_DIR="$WORK_DIR/runtime/run/section-cache" \ + TACHYON_RUNTIME_CACHE_FORMAT_FILE="$WORK_DIR/runtime/run/cache-format" \ + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/runtime/persistent" \ + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE="$WORK_DIR/runtime/persistent/cache-format" \ + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT="7" \ + TACHYON_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE="$WORK_DIR/runtime/run/bootstrap.pid" \ "$@" } @@ -416,7 +416,7 @@ cat >"$WORK_DIR/link-subscriptions/proxy-subscription-1.json" <<'JSON' } JSON serialize_outbound_link() { - ucode -L "$FORKOP_LIB" -e ' + ucode -L "$TACHYON_LIB" -e ' let fs = require("fs"); let share_link = require("subscription.share_link"); let source = json(fs.readfile(ARGV[0])); diff --git a/tests/subscription_hysteria2.sh b/tests/subscription_hysteria2.sh index ffbd0f44..854d1972 100644 --- a/tests/subscription_hysteria2.sh +++ b/tests/subscription_hysteria2.sh @@ -1,10 +1,10 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -PARSER="$ROOT_DIR/forkop/files/usr/lib/subscription/parser.uc" -GENERATOR="$ROOT_DIR/forkop/files/usr/lib/singbox/generator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +PARSER="$ROOT_DIR/tachyon/files/usr/lib/subscription/parser.uc" +GENERATOR="$ROOT_DIR/tachyon/files/usr/lib/singbox/generator.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -119,7 +119,7 @@ cat >"$generator_fixture" <<'JSON' } JSON TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/subscriptions" \ - ucode -L "$FORKOP_LIB" "$GENERATOR" generate-config-fixture \ + ucode -L "$TACHYON_LIB" "$GENERATOR" generate-config-fixture \ "$generator_fixture" "$generator_output" "127.0.0.1" "0" assert_contains "$generator_output" '"type": "hysteria2"' "generated stale HY2 type" if grep -Fq '"utls"' "$generator_output"; then diff --git a/tests/subscription_source_entry.sh b/tests/subscription_source_entry.sh index f679440e..298458b6 100644 --- a/tests/subscription_source_entry.sh +++ b/tests/subscription_source_entry.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -PARSER="$ROOT_DIR/forkop/files/usr/lib/subscription/parser.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +PARSER="$ROOT_DIR/tachyon/files/usr/lib/subscription/parser.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -69,6 +69,6 @@ if (invalid.valid || invalid.error != "Subscription URL must start with http:// exit(1); UCODE -ucode -L "$FORKOP_LIB" "$WORK_DIR/require-subscription-parser.uc" +ucode -L "$TACHYON_LIB" "$WORK_DIR/require-subscription-parser.uc" printf 'subscription source entry checks passed\n' diff --git a/tests/subscription_update_job.sh b/tests/subscription_update_job.sh index 2c817780..550e64da 100644 --- a/tests/subscription_update_job.sh +++ b/tests/subscription_update_job.sh @@ -1,10 +1,10 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -UPDATES_UC="$ROOT_DIR/forkop/files/usr/lib/components/updates.uc" -UPDATES_RUNTIME="$ROOT_DIR/forkop/files/usr/lib/updates_runtime.sh" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" +UPDATES_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updates.uc" +UPDATES_RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/updates_runtime.sh" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" WORK_DIR="$(mktemp -d)" cleanup() { @@ -34,7 +34,7 @@ write_state() { } updates_ucode() { - ucode -L "$FORKOP_LIB" "$UPDATES_UC" "$@" + ucode -L "$TACHYON_LIB" "$UPDATES_UC" "$@" } [ ! -e "$UPDATES_RUNTIME" ] || @@ -79,7 +79,7 @@ if (value.running !== false || value.success !== false || value.section !== "pro NODE mkdir -p "$WORK_DIR/bin" -cat >"$WORK_DIR/bin/forkop" <<'SH' +cat >"$WORK_DIR/bin/tachyon" <<'SH' #!/usr/bin/env sh if [ "$1" != "subscription_update" ]; then exit 64 @@ -87,27 +87,27 @@ fi printf 'fake update for %s/%s\n' "$2" "$3" printf 'Subscription update completed by fake worker\n' SH -chmod +x "$WORK_DIR/bin/forkop" +chmod +x "$WORK_DIR/bin/tachyon" ASYNC_ENV=( - "FORKOP_LIB=$ROOT_DIR/forkop/files/usr/lib" - "FORKOP_BIN=$WORK_DIR/bin/forkop" + "TACHYON_LIB=$ROOT_DIR/tachyon/files/usr/lib" + "TACHYON_BIN=$WORK_DIR/bin/tachyon" "TMP_SING_BOX_FOLDER=$WORK_DIR/tmp/sing-box" "TMP_RULESET_FOLDER=$WORK_DIR/tmp/sing-box/rulesets" "TMP_SUBSCRIPTION_FOLDER=$WORK_DIR/tmp/sing-box/subscriptions" - "FORKOP_RUNTIME_STATE_DIR=$WORK_DIR/run" - "FORKOP_SUBSCRIPTION_UPDATE_STATE_DIR=$WORK_DIR/run/subscription-update" - "FORKOP_SUBSCRIPTION_UPDATE_JOB_DIR=$WORK_DIR/run/subscription-update-jobs" - "FORKOP_SUBSCRIPTION_LINKS_DIR=$WORK_DIR/run/subscription-links" - "FORKOP_SUBSCRIPTION_METADATA_DIR=$WORK_DIR/run/subscription-metadata" - "FORKOP_OUTBOUND_METADATA_DIR=$WORK_DIR/run/outbound-metadata" - "FORKOP_SECTION_CACHE_DIR=$WORK_DIR/run/section-cache" - "FORKOP_RUNTIME_CACHE_FORMAT_FILE=$WORK_DIR/run/cache-format" - "FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR=$WORK_DIR/persistent/subscription-cache" - "FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE=$WORK_DIR/persistent/subscription-cache/cache-format" + "TACHYON_RUNTIME_STATE_DIR=$WORK_DIR/run" + "TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR=$WORK_DIR/run/subscription-update" + "TACHYON_SUBSCRIPTION_UPDATE_JOB_DIR=$WORK_DIR/run/subscription-update-jobs" + "TACHYON_SUBSCRIPTION_LINKS_DIR=$WORK_DIR/run/subscription-links" + "TACHYON_SUBSCRIPTION_METADATA_DIR=$WORK_DIR/run/subscription-metadata" + "TACHYON_OUTBOUND_METADATA_DIR=$WORK_DIR/run/outbound-metadata" + "TACHYON_SECTION_CACHE_DIR=$WORK_DIR/run/section-cache" + "TACHYON_RUNTIME_CACHE_FORMAT_FILE=$WORK_DIR/run/cache-format" + "TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR=$WORK_DIR/persistent/subscription-cache" + "TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE=$WORK_DIR/persistent/subscription-cache/cache-format" ) -async_response="$(env "${ASYNC_ENV[@]}" ucode -L "$ROOT_DIR/forkop/files/usr/lib" "$UPDATES_UC" subscription-update-async proxy 1)" +async_response="$(env "${ASYNC_ENV[@]}" ucode -L "$ROOT_DIR/tachyon/files/usr/lib" "$UPDATES_UC" subscription-update-async proxy 1)" job_id="$(JSON_VALUE="$async_response" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (value.success !== true || !value.job_id) { @@ -120,7 +120,7 @@ NODE final_status="" for _ in $(seq 1 30); do - final_status="$(env "${ASYNC_ENV[@]}" ucode -L "$ROOT_DIR/forkop/files/usr/lib" "$UPDATES_UC" subscription-update-status "$job_id")" + final_status="$(env "${ASYNC_ENV[@]}" ucode -L "$ROOT_DIR/tachyon/files/usr/lib" "$UPDATES_UC" subscription-update-status "$job_id")" if JSON_VALUE="$final_status" node -e 'process.exit(JSON.parse(process.env.JSON_VALUE).running ? 1 : 0)' >/dev/null 2>&1; then break fi diff --git a/tests/subscription_update_reload.sh b/tests/subscription_update_reload.sh index b97be06b..efa2ec72 100644 --- a/tests/subscription_update_reload.sh +++ b/tests/subscription_update_reload.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -UPDATES_UC="$ROOT_DIR/forkop/files/usr/lib/components/updates.uc" -REAL_LIB="$ROOT_DIR/forkop/files/usr/lib" +UPDATES_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updates.uc" +REAL_LIB="$ROOT_DIR/tachyon/files/usr/lib" WORK_DIR="$(mktemp -d)" cleanup() { @@ -116,22 +116,22 @@ run_update() { : >"$log" env \ - FORKOP_LIB="$FAKE_LIB" \ - FORKOP_RUNTIME_STATE_DIR="$WORK_DIR/run" \ - FORKOP_SUBSCRIPTION_UPDATE_LOCK_DIR="$WORK_DIR/run/subscription-update.lock" \ - FORKOP_RELOAD_LOCK_DIR="$WORK_DIR/run/reload.lock" \ - FORKOP_SUBSCRIPTION_UPDATE_STATE_DIR="$WORK_DIR/run/subscription-update" \ - FORKOP_SUBSCRIPTION_UPDATE_JOB_DIR="$WORK_DIR/run/subscription-update-jobs" \ - FORKOP_SUBSCRIPTION_LINKS_DIR="$WORK_DIR/run/subscription-links" \ - FORKOP_SUBSCRIPTION_METADATA_DIR="$WORK_DIR/run/subscription-metadata" \ - FORKOP_OUTBOUND_METADATA_DIR="$WORK_DIR/run/outbound-metadata" \ - FORKOP_SECTION_CACHE_DIR="$WORK_DIR/run/section-cache" \ - FORKOP_RUNTIME_CACHE_FORMAT_FILE="$WORK_DIR/run/cache-format" \ - FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent/subscription-cache" \ - FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE="$WORK_DIR/persistent/subscription-cache/cache-format" \ - FORKOP_PENDING_RELOAD_FILE="$WORK_DIR/run/reload.pending" \ - FORKOP_RELOAD_STATE_FILE="$WORK_DIR/run/reload-state" \ - FORKOP_RULE_CONDITION_CACHE_DIR="$WORK_DIR/run/rule-condition-cache" \ + TACHYON_LIB="$FAKE_LIB" \ + TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/run" \ + TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR="$WORK_DIR/run/subscription-update.lock" \ + TACHYON_RELOAD_LOCK_DIR="$WORK_DIR/run/reload.lock" \ + TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR="$WORK_DIR/run/subscription-update" \ + TACHYON_SUBSCRIPTION_UPDATE_JOB_DIR="$WORK_DIR/run/subscription-update-jobs" \ + TACHYON_SUBSCRIPTION_LINKS_DIR="$WORK_DIR/run/subscription-links" \ + TACHYON_SUBSCRIPTION_METADATA_DIR="$WORK_DIR/run/subscription-metadata" \ + TACHYON_OUTBOUND_METADATA_DIR="$WORK_DIR/run/outbound-metadata" \ + TACHYON_SECTION_CACHE_DIR="$WORK_DIR/run/section-cache" \ + TACHYON_RUNTIME_CACHE_FORMAT_FILE="$WORK_DIR/run/cache-format" \ + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent/subscription-cache" \ + TACHYON_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE="$WORK_DIR/persistent/subscription-cache/cache-format" \ + TACHYON_PENDING_RELOAD_FILE="$WORK_DIR/run/reload.pending" \ + TACHYON_RELOAD_STATE_FILE="$WORK_DIR/run/reload-state" \ + TACHYON_RULE_CONDITION_CACHE_DIR="$WORK_DIR/run/rule-condition-cache" \ FAKE_CALL_LOG="$log" \ FAKE_SUBSCRIPTION_UPDATE_SUMMARY="$summary" \ ucode -L "$REAL_LIB" "$UPDATES_UC" subscription-update-if-due diff --git a/tests/subscription_user_agent.sh b/tests/subscription_user_agent.sh index 4c3c7899..d2b7ec18 100644 --- a/tests/subscription_user_agent.sh +++ b/tests/subscription_user_agent.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CACHE_UC="$ROOT_DIR/forkop/files/usr/lib/subscription/cache.uc" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" +CACHE_UC="$ROOT_DIR/tachyon/files/usr/lib/subscription/cache.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" WORK_DIR="$(mktemp -d)" cleanup() { @@ -42,7 +42,7 @@ preferred_candidates="$(printf '%s\n%s\n%s\n%s\n%s\n%s' \ Clash.Meta)" cache_ucode() { - ucode -L "$FORKOP_LIB" "$CACHE_UC" "$@" + ucode -L "$TACHYON_LIB" "$CACHE_UC" "$@" } cache_ucode write-user-agent-candidates "$WORK_DIR/default.txt" "" "" "$default_ua" diff --git a/tests/ui_runtime_job.sh b/tests/ui_runtime_job.sh index 165d8e89..670a57d1 100644 --- a/tests/ui_runtime_job.sh +++ b/tests/ui_runtime_job.sh @@ -1,12 +1,12 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -UI_UC="$ROOT_DIR/forkop/files/usr/lib/service/ui.uc" -FORKOP_FILES="$ROOT_DIR/forkop/files" -FORKOP_BIN="$FORKOP_FILES/usr/bin/forkop" -FORKOP_INIT="$FORKOP_FILES/etc/init.d/forkop" -UI_RUNTIME_SH="$FORKOP_FILES/usr/lib/ui_runtime.sh" +UI_UC="$ROOT_DIR/tachyon/files/usr/lib/service/ui.uc" +TACHYON_FILES="$ROOT_DIR/tachyon/files" +TACHYON_BIN="$TACHYON_FILES/usr/bin/tachyon" +TACHYON_INIT="$TACHYON_FILES/etc/init.d/tachyon" +UI_RUNTIME_SH="$TACHYON_FILES/usr/lib/ui_runtime.sh" WORK_DIR="$(mktemp -d)" cleanup() { @@ -20,7 +20,7 @@ fail() { } ui_ucode() { - ucode -L "$FORKOP_FILES/usr/lib" "$UI_UC" "$@" + ucode -L "$TACHYON_FILES/usr/lib" "$UI_UC" "$@" } assert_eq() { @@ -42,16 +42,16 @@ write_state() { [ ! -e "$UI_RUNTIME_SH" ] || fail "ui_runtime.sh shell owner must be removed" -if grep -R -n "ui_runtime.sh" "$FORKOP_FILES" >/dev/null 2>&1; then +if grep -R -n "ui_runtime.sh" "$TACHYON_FILES" >/dev/null 2>&1; then fail "runtime files must not reference ui_runtime.sh" fi -ui_runtime_shell_symbols='ui_runtime_|forkop_fast_get_ui_state|FORKOP_UI_RUNTIME|load_ui_runtime' -if grep -R -n -E "$ui_runtime_shell_symbols" "$FORKOP_BIN" "$FORKOP_INIT" "$FORKOP_FILES/usr/lib" --include='*.sh' >/dev/null 2>&1; then +ui_runtime_shell_symbols='ui_runtime_|tachyon_fast_get_ui_state|TACHYON_UI_RUNTIME|load_ui_runtime' +if grep -R -n -E "$ui_runtime_shell_symbols" "$TACHYON_BIN" "$TACHYON_INIT" "$TACHYON_FILES/usr/lib" --include='*.sh' >/dev/null 2>&1; then fail "ui_runtime shell symbols must not remain in runtime shell" fi -if grep -R -n '^get_ui_capabilities()' "$FORKOP_FILES/usr/lib" --include='*.sh' >/dev/null 2>&1; then +if grep -R -n '^get_ui_capabilities()' "$TACHYON_FILES/usr/lib" --include='*.sh' >/dev/null 2>&1; then fail "UI capabilities must be owned by service/ui.uc, not shell" fi grep -Fq 'require("core.uci")' "$UI_UC" || @@ -59,8 +59,8 @@ grep -Fq 'require("core.uci")' "$UI_UC" || if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$UI_UC" >/dev/null 2>&1; then fail "service/ui.uc must not own direct UCI cursor or CLI reads" fi -grep -Fq '"forkop-stably-running"' "$UI_UC" || - fail "UI Forkop status must use stable runtime state to avoid crash-loop flicker" +grep -Fq '"tachyon-stably-running"' "$UI_UC" || + fail "UI Tachyon status must use stable runtime state to avoid crash-loop flicker" grep -Fq '"sing-box-service-stable"' "$UI_UC" || fail "UI sing-box status must use stable runtime state to avoid crash-loop flicker" if sed -n '/^function ensure_dir(/,/^}/p' "$UI_UC" | grep -Fq 'mkdir", "-p'; then @@ -133,7 +133,7 @@ latency_action_dir="$WORK_DIR/latency-actions" mkdir -p "$latency_action_dir" latency_state="$latency_action_dir/latency-1.json" printf '%s\n' '{"success":true,"running":true,"kind":"latency","latency_type":"proxy_list","section":"main","tag":"[]","started_at":100}' >"$latency_state" -FORKOP_UI_LATENCY_ACTION_DIR="$latency_action_dir" \ +TACHYON_UI_LATENCY_ACTION_DIR="$latency_action_dir" \ ui_ucode latency-progress-state "$latency_state" 2 5 1 >/dev/null || fail "latency-progress-state should update running latency jobs" JOB_STATE="$latency_state" node - <<'NODE' @@ -216,25 +216,25 @@ ui_ucode cleanup-action-dir-fixture "$cleanup_dir" old_state="$cleanup_dir/old.json" printf '%s\n' '{"running":false}' >"$old_state" touch -d '2 hours ago' "$old_state" -FORKOP_UI_ACTION_FINISHED_TTL_MINUTES=60 ui_ucode cleanup-action-dir-fixture "$cleanup_dir" +TACHYON_UI_ACTION_FINISHED_TTL_MINUTES=60 ui_ucode cleanup-action-dir-fixture "$cleanup_dir" [ ! -e "$old_state" ] || fail "old finished action state should be cleaned without find" -export FORKOP_UI_STATE_DIR="$WORK_DIR/ui-state" -export FORKOP_UI_SERVICE_ACTION_DIR="$FORKOP_UI_STATE_DIR/service-actions" -export FORKOP_UI_SERVICE_ACTION_LOCK_DIR="$FORKOP_UI_STATE_DIR/service-actions.lock" -export FORKOP_UI_LATENCY_ACTION_DIR="$FORKOP_UI_STATE_DIR/latency-actions" -export FORKOP_UI_COMPONENT_ACTION_DIR="$FORKOP_UI_STATE_DIR/component-actions" -export FORKOP_UI_SUBSCRIPTION_ACTION_DIR="$FORKOP_UI_STATE_DIR/subscription-actions" +export TACHYON_UI_STATE_DIR="$WORK_DIR/ui-state" +export TACHYON_UI_SERVICE_ACTION_DIR="$TACHYON_UI_STATE_DIR/service-actions" +export TACHYON_UI_SERVICE_ACTION_LOCK_DIR="$TACHYON_UI_STATE_DIR/service-actions.lock" +export TACHYON_UI_LATENCY_ACTION_DIR="$TACHYON_UI_STATE_DIR/latency-actions" +export TACHYON_UI_COMPONENT_ACTION_DIR="$TACHYON_UI_STATE_DIR/component-actions" +export TACHYON_UI_SUBSCRIPTION_ACTION_DIR="$TACHYON_UI_STATE_DIR/subscription-actions" latency_start="$( - FORKOP_BIN=/bin/true \ - FORKOP_LIB="$FORKOP_FILES/usr/lib" \ + TACHYON_BIN=/bin/true \ + TACHYON_LIB="$TACHYON_FILES/usr/lib" \ ui_ucode latency-test-async proxy_list main '["proxy-a","proxy-b"]' 5000 )" latency_job_id="$(printf '%s' "$latency_start" | sed -n 's/.*"job_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" [ -n "$latency_job_id" ] || fail "latency-test-async should return a job id" -latency_async_state="$FORKOP_UI_LATENCY_ACTION_DIR/$latency_job_id.json" +latency_async_state="$TACHYON_UI_LATENCY_ACTION_DIR/$latency_job_id.json" [ -f "$latency_async_state" ] || fail "latency-test-async should create a state file" JOB_STATE="$latency_async_state" node - <<'NODE' const fs = require("fs"); @@ -247,7 +247,7 @@ NODE job_id="$(ui_ucode service-action-begin-if-idle reload test)" [ -n "$job_id" ] || fail "service-action-begin-if-idle should create a job" -service_state="$FORKOP_UI_SERVICE_ACTION_DIR/$job_id.json" +service_state="$TACHYON_UI_SERVICE_ACTION_DIR/$job_id.json" [ -f "$service_state" ] || fail "service action state file should be created" assert_eq "reload" \ "$(ui_ucode active-service-action)" \ @@ -260,11 +260,11 @@ fi ui_ucode service-action-update-pid "$job_id" "$$" >/dev/null || fail "service-action-update-pid should update running job" -FORKOP_UI_SERVICE_ACTION_TIMEOUT_SECONDS=1 \ -FORKOP_UI_SERVICE_ACTION_SETTLE_SECONDS=0 \ -FORKOP_SERVICE_INIT=/bin/true \ -FORKOP_BIN=/bin/true \ -FORKOP_LIB="$FORKOP_FILES/usr/lib" \ +TACHYON_UI_SERVICE_ACTION_TIMEOUT_SECONDS=1 \ +TACHYON_UI_SERVICE_ACTION_SETTLE_SECONDS=0 \ +TACHYON_SERVICE_INIT=/bin/true \ +TACHYON_BIN=/bin/true \ +TACHYON_LIB="$TACHYON_FILES/usr/lib" \ ui_ucode service-action-finish-after-command reload "$job_id" 0 >/dev/null || fail "service-action-finish-after-command should spawn waiter without ucode declaration-order failure" @@ -285,8 +285,8 @@ if (value.running !== false || value.success !== true || value.message !== "done } NODE -component_job="$FORKOP_UI_COMPONENT_ACTION_DIR/component-1.json" -mkdir -p "$FORKOP_UI_COMPONENT_ACTION_DIR" +component_job="$TACHYON_UI_COMPONENT_ACTION_DIR/component-1.json" +mkdir -p "$TACHYON_UI_COMPONENT_ACTION_DIR" printf '%s\n' '{"running":true,"component":"sing_box","started_at":100}' >"$component_job" ui_ucode component-action-running-for sing_box >/dev/null || fail "component-action-running-for should detect running sing-box component action" diff --git a/tests/ui_sing_box_probe.sh b/tests/ui_sing_box_probe.sh index d6ca2eaf..ca0e6804 100755 --- a/tests/ui_sing_box_probe.sh +++ b/tests/ui_sing_box_probe.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -UI_UC="$FORKOP_LIB/service/ui.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +UI_UC="$TACHYON_LIB/service/ui.uc" WORK_DIR="$(mktemp -d)" PROBE_BIN="$WORK_DIR/sing-box" PROBE_COUNT="$WORK_DIR/probe-count" @@ -27,9 +27,9 @@ fail() { cat >"$PROBE_BIN" <<'SH' #!/bin/sh -printf '%s\n' "$$" >>"$FORKOP_TEST_SING_BOX_PROBE_PIDS" -printf 'probe\n' >>"$FORKOP_TEST_SING_BOX_PROBE_COUNT" -if [ "${FORKOP_TEST_SING_BOX_PROBE_MODE:-fast}" = "slow" ]; then +printf '%s\n' "$$" >>"$TACHYON_TEST_SING_BOX_PROBE_PIDS" +printf 'probe\n' >>"$TACHYON_TEST_SING_BOX_PROBE_COUNT" +if [ "${TACHYON_TEST_SING_BOX_PROBE_MODE:-fast}" = "slow" ]; then exec sleep 30 fi printf 'sing-box version 1.13.14\n\n' @@ -44,24 +44,24 @@ chmod 755 "$WORK_DIR/opkg" ui_capabilities() { PATH="$WORK_DIR:$PATH" \ - FORKOP_CONFIG_NAME=forkop-ui-probe-test \ - FORKOP_UI_STATE_DIR="$WORK_DIR/state" \ - FORKOP_UI_COMPONENT_ACTION_DIR="$WORK_DIR/components" \ - FORKOP_UI_SING_BOX_VERSION_CACHE_FILE="$CACHE_FILE" \ - FORKOP_UI_SING_BOX_VARIANT_STATE_FILE="$WORK_DIR/missing-variant" \ - FORKOP_UI_SING_BOX_BIN_PATH="$PROBE_BIN" \ - FORKOP_UI_SING_BOX_VERSION_PROBE_TIMEOUT_SECONDS=1 \ - FORKOP_UI_SING_BOX_VERSION_PROBE_FAILURE_TTL_SECONDS=30 \ + TACHYON_CONFIG_NAME=tachyon-ui-probe-test \ + TACHYON_UI_STATE_DIR="$WORK_DIR/state" \ + TACHYON_UI_COMPONENT_ACTION_DIR="$WORK_DIR/components" \ + TACHYON_UI_SING_BOX_VERSION_CACHE_FILE="$CACHE_FILE" \ + TACHYON_UI_SING_BOX_VARIANT_STATE_FILE="$WORK_DIR/missing-variant" \ + TACHYON_UI_SING_BOX_BIN_PATH="$PROBE_BIN" \ + TACHYON_UI_SING_BOX_VERSION_PROBE_TIMEOUT_SECONDS=1 \ + TACHYON_UI_SING_BOX_VERSION_PROBE_FAILURE_TTL_SECONDS=30 \ ZAPRET_PROVIDER_NFQWS_BIN="$WORK_DIR/missing-nfqws" \ ZAPRET2_PROVIDER_NFQWS2_BIN="$WORK_DIR/missing-nfqws2" \ BYEDPI_BIN="$WORK_DIR/missing-ciadpi" \ - FORKOP_TEST_SING_BOX_PROBE_COUNT="$PROBE_COUNT" \ - FORKOP_TEST_SING_BOX_PROBE_PIDS="$PROBE_PIDS" \ - ucode -L "$FORKOP_LIB" "$UI_UC" get-ui-capabilities + TACHYON_TEST_SING_BOX_PROBE_COUNT="$PROBE_COUNT" \ + TACHYON_TEST_SING_BOX_PROBE_PIDS="$PROBE_PIDS" \ + ucode -L "$TACHYON_LIB" "$UI_UC" get-ui-capabilities } -fast_first="$(FORKOP_TEST_SING_BOX_PROBE_MODE=fast ui_capabilities)" -fast_second="$(FORKOP_TEST_SING_BOX_PROBE_MODE=fast ui_capabilities)" +fast_first="$(TACHYON_TEST_SING_BOX_PROBE_MODE=fast ui_capabilities)" +fast_second="$(TACHYON_TEST_SING_BOX_PROBE_MODE=fast ui_capabilities)" [ "$(wc -l <"$PROBE_COUNT")" -eq 1 ] || fail "successful sing-box capability detection must be cached by binary signature" @@ -82,7 +82,7 @@ rm -rf "$CACHE_FILE" "$CACHE_FILE.lock" start_seconds=$SECONDS workers="" for index in 1 2 3 4 5; do - FORKOP_TEST_SING_BOX_PROBE_MODE=slow ui_capabilities >"$WORK_DIR/slow-$index.json" & + TACHYON_TEST_SING_BOX_PROBE_MODE=slow ui_capabilities >"$WORK_DIR/slow-$index.json" & workers="$workers $!" done for worker in $workers; do @@ -106,7 +106,7 @@ if (value.sing_box_extended !== 0 || value.sing_box_tiny !== 0 || value.sing_box NODE done -FORKOP_TEST_SING_BOX_PROBE_MODE=slow ui_capabilities >/dev/null +TACHYON_TEST_SING_BOX_PROBE_MODE=slow ui_capabilities >/dev/null [ "$(wc -l <"$PROBE_COUNT")" -eq 1 ] || fail "failed sing-box probe must be cached during the retry cooldown" diff --git a/tests/updates_due.sh b/tests/updates_due.sh index 931cb6c7..c47bfd1d 100644 --- a/tests/updates_due.sh +++ b/tests/updates_due.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -UPDATES_UC="$ROOT_DIR/forkop/files/usr/lib/components/updates.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +UPDATES_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updates.uc" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -22,7 +22,7 @@ assert_status() { } updates_ucode() { - ucode -L "$FORKOP_LIB" "$UPDATES_UC" "$@" + ucode -L "$TACHYON_LIB" "$UPDATES_UC" "$@" } if grep -n -E 'require\("uci"\)\.cursor|uci -q' "$UPDATES_UC" >/dev/null 2>&1; then @@ -83,11 +83,11 @@ assert_eq "0 0 * * *" \ "$(updates_ucode due-check-cron-schedule 86400)" \ "daily cron schedule" -assert_eq "*/30 * * * * /usr/bin/forkop list_update_if_due # list" \ - "$(updates_ucode list-update-cron-job 30m /usr/bin/forkop '# list')" \ +assert_eq "*/30 * * * * /usr/bin/tachyon list_update_if_due # list" \ + "$(updates_ucode list-update-cron-job 30m /usr/bin/tachyon '# list')" \ "list update cron job" -assert_eq "0 */2 * * * /usr/bin/forkop subscription_update_if_due # subscription" \ - "$(updates_ucode subscription-update-cron-job 7200 /usr/bin/forkop '# subscription')" \ +assert_eq "0 */2 * * * /usr/bin/tachyon subscription_update_if_due # subscription" \ + "$(updates_ucode subscription-update-cron-job 7200 /usr/bin/tachyon '# subscription')" \ "subscription update cron job" assert_eq $'error\trule3\tinvalid\nmin\t1800' \ "$(printf 'rule1\t2h\nrule2\t30m\nrule3\tinvalid\nrule4\t\n' | updates_ucode subscription-update-interval-plan)" \ @@ -95,10 +95,10 @@ assert_eq $'error\trule3\tinvalid\nmin\t1800' \ assert_eq $'min\t0' \ "$(printf 'rule1\t\n' | updates_ucode subscription-update-interval-plan)" \ "empty subscription interval plan" -if updates_ucode list-update-cron-job invalid /usr/bin/forkop '# list' >/dev/null 2>&1; then +if updates_ucode list-update-cron-job invalid /usr/bin/tachyon '# list' >/dev/null 2>&1; then fail "invalid list update interval should fail" fi -if updates_ucode subscription-update-cron-job 0 /usr/bin/forkop '# subscription' >/dev/null 2>&1; then +if updates_ucode subscription-update-cron-job 0 /usr/bin/tachyon '# subscription' >/dev/null 2>&1; then fail "invalid subscription update interval should fail" fi @@ -150,8 +150,8 @@ cat >"$WORK_DIR/cron-plan.json" <<'JSON' } JSON -assert_eq $'list\t*/30 * * * * /usr/bin/forkop list_update_if_due # list\nsubscription-error\tbad_sub\tbad\nsubscription\t*/45 * * * * /usr/bin/forkop subscription_update_if_due # subscription\ncomponent\t0 */2 * * * /usr/bin/forkop component_updates_if_due # component' \ - "$(updates_ucode cron-refresh-plan-fixture "$WORK_DIR/cron-plan.json" /usr/bin/forkop '# list' '# subscription' '# component')" \ +assert_eq $'list\t*/30 * * * * /usr/bin/tachyon list_update_if_due # list\nsubscription-error\tbad_sub\tbad\nsubscription\t*/45 * * * * /usr/bin/tachyon subscription_update_if_due # subscription\ncomponent\t0 */2 * * * /usr/bin/tachyon component_updates_if_due # component' \ + "$(updates_ucode cron-refresh-plan-fixture "$WORK_DIR/cron-plan.json" /usr/bin/tachyon '# list' '# subscription' '# component')" \ "cron refresh plan" grep -Fq 'fs.writefile(tmp, as_string(text)) == null' "$UPDATES_UC" || @@ -175,7 +175,7 @@ cat >"$WORK_DIR/cron-plan-list-disabled.json" <<'JSON' JSON assert_eq "list-disabled" \ - "$(updates_ucode cron-refresh-plan-fixture "$WORK_DIR/cron-plan-list-disabled.json" /usr/bin/forkop '# list' '# subscription' '# component')" \ + "$(updates_ucode cron-refresh-plan-fixture "$WORK_DIR/cron-plan-list-disabled.json" /usr/bin/tachyon '# list' '# subscription' '# component')" \ "disabled list cron plan" cat >"$WORK_DIR/cron-plan-invalid-list.json" <<'JSON' @@ -196,26 +196,26 @@ cat >"$WORK_DIR/cron-plan-invalid-list.json" <<'JSON' JSON status=0 -invalid_plan="$(updates_ucode cron-refresh-plan-fixture "$WORK_DIR/cron-plan-invalid-list.json" /usr/bin/forkop '# list' '# subscription' '# component')" || status="$?" +invalid_plan="$(updates_ucode cron-refresh-plan-fixture "$WORK_DIR/cron-plan-invalid-list.json" /usr/bin/tachyon '# list' '# subscription' '# component')" || status="$?" assert_eq 1 "$status" "invalid list cron plan status" assert_eq $'list-error\tbad' "$invalid_plan" "invalid list cron plan output" cat >"$WORK_DIR/existing.cron" <<'CRON' 0 1 * * * /bin/true # keep -* * * * * /usr/bin/forkop list_update_if_due # list -* * * * * /usr/bin/forkop subscription_update_if_due # subscription -* * * * * /usr/bin/forkop component_updates_if_due # component +* * * * * /usr/bin/tachyon list_update_if_due # list +* * * * * /usr/bin/tachyon subscription_update_if_due # subscription +* * * * * /usr/bin/tachyon component_updates_if_due # component CRON -updates_ucode refresh-cron-fixture "$WORK_DIR/cron-plan.json" "$WORK_DIR/existing.cron" /usr/bin/forkop '# list' '# subscription' '# component' >"$WORK_DIR/cron-apply.json" +updates_ucode refresh-cron-fixture "$WORK_DIR/cron-plan.json" "$WORK_DIR/existing.cron" /usr/bin/tachyon '# list' '# subscription' '# component' >"$WORK_DIR/cron-apply.json" node - "$WORK_DIR/cron-apply.json" <<'JS' const fs = require("fs"); const value = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); const expected = [ "0 1 * * * /bin/true # keep", - "*/30 * * * * /usr/bin/forkop list_update_if_due # list", - "*/45 * * * * /usr/bin/forkop subscription_update_if_due # subscription", - "0 */2 * * * /usr/bin/forkop component_updates_if_due # component", + "*/30 * * * * /usr/bin/tachyon list_update_if_due # list", + "*/45 * * * * /usr/bin/tachyon subscription_update_if_due # subscription", + "0 */2 * * * /usr/bin/tachyon component_updates_if_due # component", "" ].join("\n"); if (value.crontab !== expected) { @@ -224,22 +224,22 @@ if (value.crontab !== expected) { } const messages = value.logs.map(item => item.message); if (!messages.includes("The cron job removed") || - !messages.includes("The cron job has been created: */30 * * * * /usr/bin/forkop list_update_if_due # list") || - !messages.includes("The subscription cron job has been created: */45 * * * * /usr/bin/forkop subscription_update_if_due # subscription") || - !messages.includes("The component update check cron job has been created: 0 */2 * * * /usr/bin/forkop component_updates_if_due # component")) { + !messages.includes("The cron job has been created: */30 * * * * /usr/bin/tachyon list_update_if_due # list") || + !messages.includes("The subscription cron job has been created: */45 * * * * /usr/bin/tachyon subscription_update_if_due # subscription") || + !messages.includes("The component update check cron job has been created: 0 */2 * * * /usr/bin/tachyon component_updates_if_due # component")) { console.error("unexpected cron apply logs", JSON.stringify(value.logs)); process.exit(1); } JS status=0 -updates_ucode refresh-cron-fixture "$WORK_DIR/cron-plan-invalid-list.json" "$WORK_DIR/existing.cron" /usr/bin/forkop '# list' '# subscription' '# component' >"$WORK_DIR/cron-apply-invalid.json" || status="$?" +updates_ucode refresh-cron-fixture "$WORK_DIR/cron-plan-invalid-list.json" "$WORK_DIR/existing.cron" /usr/bin/tachyon '# list' '# subscription' '# component' >"$WORK_DIR/cron-apply-invalid.json" || status="$?" assert_eq 1 "$status" "invalid cron apply status" node - "$WORK_DIR/cron-apply-invalid.json" <<'JS' const fs = require("fs"); const value = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); if (value.crontab !== "0 1 * * * /bin/true # keep\n") { - console.error("invalid plan should remove old forkop cron jobs only", JSON.stringify(value.crontab)); + console.error("invalid plan should remove old tachyon cron jobs only", JSON.stringify(value.crontab)); process.exit(1); } if (!value.logs.some(item => item.level === "error" && item.message === "Invalid update_interval value: bad")) { diff --git a/tests/urltest_groups.sh b/tests/urltest_groups.sh index 62bdb378..cd9b474e 100644 --- a/tests/urltest_groups.sh +++ b/tests/urltest_groups.sh @@ -1,11 +1,11 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -PARSER_UC="$FORKOP_LIB/subscription/parser.uc" -GENERATOR_UC="$FORKOP_LIB/singbox/generator.uc" -CACHE_UC="$FORKOP_LIB/subscription/cache.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +PARSER_UC="$TACHYON_LIB/subscription/parser.uc" +GENERATOR_UC="$TACHYON_LIB/singbox/generator.uc" +CACHE_UC="$TACHYON_LIB/subscription/cache.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -21,7 +21,7 @@ fail() { normalize_subscription() { local input="$1" local output="$2" - ucode -L "$FORKOP_LIB" "$PARSER_UC" normalize-content "$input" "$output" + ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content "$input" "$output" } prepare_subscription_cache() { @@ -42,7 +42,7 @@ generate_config() { local output="$2" mkdir -p "${output}.section-cache" TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/subscriptions" \ - ucode -L "$FORKOP_LIB" "$GENERATOR_UC" generate-config-fixture \ + ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ "$fixture" "$output" "127.0.0.1" } @@ -286,7 +286,7 @@ if (!reveal_selector || length(reveal_selector.outbounds || []) != 4) ' "$xray_config" "$xray_config.section-cache/proxy.json" "$xray_reveal_urltest_config" "$xray_group_name_filter_config" || fail "xray generated URLTest behavior" xray_metadata="$WORK_DIR/xray-ui-outbound-metadata.json" -ucode -L "$FORKOP_LIB" "$CACHE_UC" get-outbound-metadata "$xray_config.section-cache" proxy "$WORK_DIR/missing-outbound-metadata.json" >"$xray_metadata" +ucode -L "$TACHYON_LIB" "$CACHE_UC" get-outbound-metadata "$xray_config.section-cache" proxy "$WORK_DIR/missing-outbound-metadata.json" >"$xray_metadata" ucode -e ' let fs = require("fs"); @@ -356,8 +356,8 @@ let value = json(fs.readfile(ARGV[0])); let flags = {}; for (let outbound in value.outbounds || []) flags[outbound.tag] = { - allow: outbound.__forkop_allow_group === true, - hidden: outbound.__forkop_hidden === true + allow: outbound.__tachyon_allow_group === true, + hidden: outbound.__tachyon_hidden === true }; if (!flags["Native Group"].allow) die("native sing-box URLTest group was not allowed\n"); @@ -591,7 +591,7 @@ if (!contains(candidates, "Native A") || !contains(candidates, "Detour Only")) ' "$singbox_config" "$singbox_config.section-cache/proxy.json" "$singbox_reveal_urltest_config" "$singbox_reveal_detour_config" || fail "native sing-box generated URLTest behavior" singbox_metadata="$WORK_DIR/singbox-ui-outbound-metadata.json" -ucode -L "$FORKOP_LIB" "$CACHE_UC" get-outbound-metadata "$singbox_config.section-cache" proxy "$WORK_DIR/missing-outbound-metadata.json" >"$singbox_metadata" +ucode -L "$TACHYON_LIB" "$CACHE_UC" get-outbound-metadata "$singbox_config.section-cache" proxy "$WORK_DIR/missing-outbound-metadata.json" >"$singbox_metadata" ucode -e ' let fs = require("fs"); diff --git a/tests/urltest_interrupt.sh b/tests/urltest_interrupt.sh index 6d6996a3..577ba09c 100644 --- a/tests/urltest_interrupt.sh +++ b/tests/urltest_interrupt.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" WORK_DIR="$(mktemp -d)" cleanup() { @@ -69,7 +69,7 @@ JSON output="$WORK_DIR/config.json" mkdir -p "$output.section-cache" -ucode -L "$FORKOP_LIB" "$FORKOP_LIB/singbox/generator.uc" generate-config-fixture \ +ucode -L "$TACHYON_LIB" "$TACHYON_LIB/singbox/generator.uc" generate-config-fixture \ "$WORK_DIR/fixture.json" "$output" "127.0.0.1" urltest_count="$(ucode -e ' @@ -148,7 +148,7 @@ JSON multi_output="$WORK_DIR/multi-config.json" mkdir -p "$multi_output.section-cache" -ucode -L "$FORKOP_LIB" "$FORKOP_LIB/singbox/generator.uc" generate-config-fixture \ +ucode -L "$TACHYON_LIB" "$TACHYON_LIB/singbox/generator.uc" generate-config-fixture \ "$WORK_DIR/multi-fixture.json" "$multi_output" "127.0.0.1" ucode -e ' diff --git a/tests/zapret_runtime_owner.sh b/tests/zapret_runtime_owner.sh index 8b3653f8..bd4764ad 100644 --- a/tests/zapret_runtime_owner.sh +++ b/tests/zapret_runtime_owner.sh @@ -1,33 +1,33 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_BIN="$ROOT_DIR/forkop/files/usr/bin/forkop" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -CLI_UC="$FORKOP_BIN" -LIFECYCLE_UC="$FORKOP_LIB/service/lifecycle.uc" -ZAPRET_RUNTIME="$FORKOP_LIB/providers/zapret/runtime.uc" -ZAPRET2_RUNTIME="$FORKOP_LIB/providers/zapret2/runtime.uc" -ZAPRET_COMMON="$FORKOP_LIB/providers/zapret/common.uc" -ZAPRET2_COMMON="$FORKOP_LIB/providers/zapret2/common.uc" -NFQUEUE_RUNTIME="$FORKOP_LIB/providers/nfqueue/runtime.uc" -NFQUEUE_CHECK="$FORKOP_LIB/providers/nfqueue/check.uc" -NFQUEUE_VALIDATOR="$FORKOP_LIB/providers/nfqueue/validator.uc" -ZAPRET2_CHECK="$FORKOP_LIB/providers/zapret2/check.uc" -ZAPRET2_VALIDATOR="$FORKOP_LIB/providers/zapret2/validator.uc" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +CLI_UC="$TACHYON_BIN" +LIFECYCLE_UC="$TACHYON_LIB/service/lifecycle.uc" +ZAPRET_RUNTIME="$TACHYON_LIB/providers/zapret/runtime.uc" +ZAPRET2_RUNTIME="$TACHYON_LIB/providers/zapret2/runtime.uc" +ZAPRET_COMMON="$TACHYON_LIB/providers/zapret/common.uc" +ZAPRET2_COMMON="$TACHYON_LIB/providers/zapret2/common.uc" +NFQUEUE_RUNTIME="$TACHYON_LIB/providers/nfqueue/runtime.uc" +NFQUEUE_CHECK="$TACHYON_LIB/providers/nfqueue/check.uc" +NFQUEUE_VALIDATOR="$TACHYON_LIB/providers/nfqueue/validator.uc" +ZAPRET2_CHECK="$TACHYON_LIB/providers/zapret2/check.uc" +ZAPRET2_VALIDATOR="$TACHYON_LIB/providers/zapret2/validator.uc" fail() { printf 'FAIL: %s\n' "$1" >&2 exit 1 } -[ ! -e "$FORKOP_LIB/zapret.sh" ] || +[ ! -e "$TACHYON_LIB/zapret.sh" ] || fail "zapret.sh shell owner must be removed" -[ ! -e "$FORKOP_LIB/zapret2.sh" ] || +[ ! -e "$TACHYON_LIB/zapret2.sh" ] || fail "zapret2.sh shell owner must be removed" -grep -Fq '#!/usr/bin/ucode' "$FORKOP_BIN" || - fail "forkop entrypoint must be a direct ucode executable" +grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" || + fail "tachyon entrypoint must be a direct ucode executable" grep -Fq 'service/lifecycle.uc' "$CLI_UC" || fail "service/cli.uc must dispatch lifecycle orchestration through service/lifecycle.uc" grep -Fq 'providers/zapret/runtime.uc' "$LIFECYCLE_UC" || @@ -64,21 +64,21 @@ if grep -n -E 'function (start_runtime|stop_runtime|status_json|create_nft_rules fail "provider common modules must stay thin config wrappers, not runtime engines" fi if grep -R -n -E 'providers/zapret/runtime\.uc" (start-runtime|stop-runtime|status|check|installed|package-installed|package-version|enabled-rule-count) zapret2' \ - "$FORKOP_BIN" "$FORKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + "$TACHYON_BIN" "$TACHYON_LIB" --include='*.sh' >/dev/null 2>&1; then fail "zapret2 must not be multiplexed through providers/zapret/runtime.uc" fi -if grep -R -n -E 'providers\.zapret\.|providers/zapret/' "$FORKOP_LIB/providers/zapret2" >/dev/null 2>&1; then +if grep -R -n -E 'providers\.zapret\.|providers/zapret/' "$TACHYON_LIB/providers/zapret2" >/dev/null 2>&1; then fail "zapret2 provider modules must not import or execute providers/zapret modules" fi if grep -n 'zapret2' "$ZAPRET_COMMON" >/dev/null 2>&1; then fail "providers/zapret/common.uc must not contain zapret2 ownership" fi if grep -R -n -E 'start_zapret2?_runtime|stop_zapret2?_runtime|get_zapret2?_status_json|check_zapret2?_runtime_json|create_zapret2?_nft_rules|validate_rule_nfqws2?_opt|has_enabled_zapret2?_rules|get_zapret2?_rule_' \ - "$FORKOP_BIN" "$FORKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + "$TACHYON_BIN" "$TACHYON_LIB" --include='*.sh' >/dev/null 2>&1; then fail "zapret runtime shell symbols must not remain" fi if grep -R -n -E 'is_zapret2?_installed|get_zapret2?_package_version' \ - "$FORKOP_BIN" "$FORKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + "$TACHYON_BIN" "$TACHYON_LIB" --include='*.sh' >/dev/null 2>&1; then fail "zapret installed/version shell predicates must not remain" fi if grep -n -E 'require\("uci"\)\.cursor|uci -q|"uci", "-q"|command_output\(command_from_args\(\[ "uci"' "$NFQUEUE_RUNTIME" "$ZAPRET_COMMON" "$ZAPRET2_COMMON" >/dev/null 2>&1; then @@ -119,7 +119,7 @@ grep -Fq 'providers/zapret2/check.uc' "$ZAPRET2_COMMON" || grep -Fq 'providers.zapret2.validator' "$ZAPRET2_COMMON" || fail "providers/zapret2/common.uc must use its own validator module" -status_json="$(FORKOP_CONFIG_NAME=forkop-definitely-missing ucode -L "$FORKOP_LIB" "$ZAPRET_RUNTIME" status)" +status_json="$(TACHYON_CONFIG_NAME=tachyon-definitely-missing ucode -L "$TACHYON_LIB" "$ZAPRET_RUNTIME" status)" JSON_VALUE="$status_json" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (value.configured !== false || value.enabled_rule_count !== 0 || typeof value.provider_path !== 'string') { @@ -128,7 +128,7 @@ if (value.configured !== false || value.enabled_rule_count !== 0 || typeof value } NODE -check_json="$(ucode -L "$FORKOP_LIB" "$ZAPRET2_RUNTIME" check)" +check_json="$(ucode -L "$TACHYON_LIB" "$ZAPRET2_RUNTIME" check)" JSON_VALUE="$check_json" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (!Object.prototype.hasOwnProperty.call(value, 'zapret2_installed') || @@ -139,14 +139,14 @@ if (!Object.prototype.hasOwnProperty.call(value, 'zapret2_installed') || } NODE -ucode -L "$FORKOP_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws2 '--name forkop --intercept=1' >/dev/null || +ucode -L "$TACHYON_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws2 '--name tachyon --intercept=1' >/dev/null || fail "providers/zapret2/validator.uc must validate nfqws2 strategies" -if ucode -L "$FORKOP_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws '--dpi-desync=fake' >/dev/null 2>&1; then +if ucode -L "$TACHYON_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws '--dpi-desync=fake' >/dev/null 2>&1; then fail "providers/zapret2/validator.uc must not validate nfqws strategies" fi printf 'table inet x { chain y { queue num 4301 bypass } }\n' | - ucode -L "$FORKOP_LIB" "$ZAPRET2_CHECK" nft-queue-overlap ForkopTable 4300 4555 >/dev/null || + ucode -L "$TACHYON_LIB" "$ZAPRET2_CHECK" nft-queue-overlap TachyonTable 4300 4555 >/dev/null || fail "providers/zapret2/check.uc must own zapret2 queue overlap checks" printf 'zapret runtime ownership checks passed\n' diff --git a/tests/zapret_validator.sh b/tests/zapret_validator.sh index bfa7cc69..a3774472 100644 --- a/tests/zapret_validator.sh +++ b/tests/zapret_validator.sh @@ -1,10 +1,10 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib" -VALIDATOR="$ROOT_DIR/forkop/files/usr/lib/providers/zapret/validator.uc" -ZAPRET2_VALIDATOR="$ROOT_DIR/forkop/files/usr/lib/providers/zapret2/validator.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +VALIDATOR="$ROOT_DIR/tachyon/files/usr/lib/providers/zapret/validator.uc" +ZAPRET2_VALIDATOR="$ROOT_DIR/tachyon/files/usr/lib/providers/zapret2/validator.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -43,43 +43,43 @@ assert_json_field() { [ "$actual" = "$expected" ] || fail "expected $field=$expected, got $actual" } -valid_nfqws="$(ucode -L "$FORKOP_LIB" -- "$VALIDATOR" validate-json nfqws '--dpi-desync=fake --dpi-desync-repeats 2')" +valid_nfqws="$(ucode -L "$TACHYON_LIB" -- "$VALIDATOR" validate-json nfqws '--dpi-desync=fake --dpi-desync-repeats 2')" assert_json_field "$valid_nfqws" valid true -configured="$(ucode -L "$FORKOP_LIB" -- "$VALIDATOR" strategy-or-default "$(printf -- '--dpi-desync=fake\t--dpi-desync-repeats 2')" '--default')" +configured="$(ucode -L "$TACHYON_LIB" -- "$VALIDATOR" strategy-or-default "$(printf -- '--dpi-desync=fake\t--dpi-desync-repeats 2')" '--default')" [ "$configured" = "--dpi-desync=fake --dpi-desync-repeats 2" ] || fail "configured zapret strategy should be normalized, got '$configured'" -defaulted="$(ucode -L "$FORKOP_LIB" -- "$VALIDATOR" strategy-or-default "" "$(printf -- '--default\t1')")" +defaulted="$(ucode -L "$TACHYON_LIB" -- "$VALIDATOR" strategy-or-default "" "$(printf -- '--default\t1')")" [ "$defaulted" = "--default 1" ] || fail "empty zapret strategy should use normalized default, got '$defaulted'" -if invalid_nfqws="$(ucode -L "$FORKOP_LIB" -- "$VALIDATOR" validate-json nfqws '--hostlist domains.txt' 2>/dev/null)"; then +if invalid_nfqws="$(ucode -L "$TACHYON_LIB" -- "$VALIDATOR" validate-json nfqws '--hostlist domains.txt' 2>/dev/null)"; then fail "hostlist selection should be rejected for nfqws" fi assert_json_field "$invalid_nfqws" valid false assert_json_field "$invalid_nfqws" needle --hostlist validator_output="$WORK_DIR/zapret-validator.out" -if ucode -L "$FORKOP_LIB" -- "$VALIDATOR" validate nfqws '--qnum=200' >"$validator_output" 2>/dev/null; then +if ucode -L "$TACHYON_LIB" -- "$VALIDATOR" validate nfqws '--qnum=200' >"$validator_output" 2>/dev/null; then fail "qnum override should be rejected for nfqws" fi -grep -q 'NFQUEUE number is assigned by Forkop' "$validator_output" || +grep -q 'NFQUEUE number is assigned by Tachyon' "$validator_output" || fail "qnum rejection should explain ownership" -valid_nfqws2="$(ucode -L "$FORKOP_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws2 '--name forkop --intercept=1')" +valid_nfqws2="$(ucode -L "$TACHYON_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws2 '--name tachyon --intercept=1')" assert_json_field "$valid_nfqws2" valid true -if invalid_nfqws2="$(ucode -L "$FORKOP_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws2 '--intercept=0' 2>/dev/null)"; then +if invalid_nfqws2="$(ucode -L "$TACHYON_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws2 '--intercept=0' 2>/dev/null)"; then fail "disabled nfqws2 intercept should be rejected" fi assert_json_field "$invalid_nfqws2" valid false assert_json_field "$invalid_nfqws2" needle --intercept -if ucode -L "$FORKOP_LIB" -- "$VALIDATOR" validate-json nfqws2 '--name forkop --intercept=1' >/dev/null 2>&1; then +if ucode -L "$TACHYON_LIB" -- "$VALIDATOR" validate-json nfqws2 '--name tachyon --intercept=1' >/dev/null 2>&1; then fail "zapret validator must not own nfqws2 validation" fi -if ucode -L "$FORKOP_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws '--dpi-desync=fake' >/dev/null 2>&1; then +if ucode -L "$TACHYON_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws '--dpi-desync=fake' >/dev/null 2>&1; then fail "zapret2 validator must not own nfqws validation" fi @@ -95,11 +95,11 @@ let invalid_nfqws = validator.validate_strategy("nfqws", "--hostlist domains.txt if (invalid_nfqws.valid || invalid_nfqws.needle != "--hostlist") exit(1); -let valid_nfqws2 = zapret2_validator.validate_strategy("nfqws2", "--name forkop --intercept=1", ""); +let valid_nfqws2 = zapret2_validator.validate_strategy("nfqws2", "--name tachyon --intercept=1", ""); if (!valid_nfqws2.valid) exit(1); -if (validator.validate_strategy("nfqws2", "--name forkop --intercept=1", "").valid) +if (validator.validate_strategy("nfqws2", "--name tachyon --intercept=1", "").valid) exit(1); if (zapret2_validator.validate_strategy("nfqws", "--dpi-desync=fake", "").valid) exit(1); @@ -108,6 +108,6 @@ if (validator.strategy_or_default("", "--default\t1") != "--default 1") exit(1); UCODE -ucode -L "$FORKOP_LIB" "$WORK_DIR/require-zapret-validator.uc" +ucode -L "$TACHYON_LIB" "$WORK_DIR/require-zapret-validator.uc" printf 'Zapret validator checks passed\n'