Refactor runtime and harden package lifecycle
This commit is contained in:
@@ -54,8 +54,4 @@ jobs:
|
||||
- name: Run backend tests
|
||||
shell: bash
|
||||
run: |
|
||||
for test_script in tests/*.sh; do
|
||||
echo "::group::$test_script"
|
||||
bash "$test_script"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
printf '%s\0' tests/*.sh | xargs -0 -n1 -P4 bash
|
||||
|
||||
@@ -34,8 +34,9 @@ APP_DESCRIPTION="Rule-based Forkop LuCI app with hybrid sing-box + zapret orches
|
||||
I18N_DESCRIPTION="Translation for luci-app-forkop - Русский (Russian)"
|
||||
MAINTAINER="ushan0v <ushan0v@users.noreply.github.com>"
|
||||
PROJECT_URL="https://github.com/ushan0v/forkop"
|
||||
BACKEND_DEPENDS_IPK="libc, ca-bundle, kmod-inet-diag, kmod-netlink-diag, kmod-tun, curl, ucode, ucode-mod-fs, ucode-mod-uci, kmod-nft-tproxy, coreutils-base64, coreutils-sort, bind-dig, nftables, kmod-nft-nat, ip-full"
|
||||
BACKEND_DEPENDS_APK="bind-dig ca-bundle coreutils-base64 coreutils-sort curl ip-full kmod-inet-diag kmod-netlink-diag kmod-nft-nat kmod-nft-tproxy kmod-tun libc nftables ucode ucode-mod-fs ucode-mod-uci"
|
||||
BACKEND_DEPENDS_IPK="libc, 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"
|
||||
BACKEND_DEPENDS_APK="bind-dig ca-bundle coreutils-base64 curl ip-full kmod-inet-diag kmod-netlink-diag kmod-nft-nat kmod-nft-tproxy kmod-tun libc nftables ucode ucode-mod-fs ucode-mod-uci !https-dns-proxy !nextdns !luci-app-passwall !luci-app-passwall2"
|
||||
BACKEND_CONFLICTS_IPK="https-dns-proxy, nextdns, luci-app-passwall, luci-app-passwall2"
|
||||
APP_DEPENDS_IPK="libc, luci-base, forkop"
|
||||
APP_DEPENDS_APK="libc luci-base forkop"
|
||||
|
||||
@@ -255,18 +256,10 @@ build_app_root() {
|
||||
rm -rf "$output_root"
|
||||
make_dir "$output_root/www"
|
||||
|
||||
if [[ -d "$ROOT_DIR/luci-app-forkop/htdocs" ]]; then
|
||||
cp -a "$ROOT_DIR/luci-app-forkop/htdocs/." "$output_root/www/"
|
||||
fi
|
||||
|
||||
if [[ -d "$ROOT_DIR/luci-app-forkop/root" ]]; then
|
||||
cp -a "$ROOT_DIR/luci-app-forkop/root/." "$output_root/"
|
||||
fi
|
||||
|
||||
if [[ -f "$output_root/www/luci-static/resources/view/forkop/main.js" ]]; then
|
||||
sed -i -e "s/__COMPILED_VERSION_VARIABLE__/${RELEASE_VERSION}/g" \
|
||||
"$output_root/www/luci-static/resources/view/forkop/main.js"
|
||||
fi
|
||||
cp -a "$ROOT_DIR/luci-app-forkop/htdocs/." "$output_root/www/"
|
||||
cp -a "$ROOT_DIR/luci-app-forkop/root/." "$output_root/"
|
||||
sed -i -e "s/__COMPILED_VERSION_VARIABLE__/${RELEASE_VERSION}/g" \
|
||||
"$output_root/www/luci-static/resources/view/forkop/main.js"
|
||||
|
||||
normalize_package_root_modes "$output_root"
|
||||
find "$output_root/etc/uci-defaults" -type f -exec chmod 0755 {} + 2>/dev/null || true
|
||||
@@ -329,6 +322,7 @@ write_backend_ipk_control() {
|
||||
Package: forkop
|
||||
Version: ${RELEASE_VERSION}
|
||||
Depends: ${BACKEND_DEPENDS_IPK}
|
||||
Conflicts: ${BACKEND_CONFLICTS_IPK}
|
||||
License: GPL-2.0-or-later
|
||||
Section: net
|
||||
URL: ${PROJECT_URL}
|
||||
@@ -345,14 +339,15 @@ EOF
|
||||
cat > "$control_dir/postinst" <<'EOF'
|
||||
#!/bin/sh
|
||||
[ -n "${IPKG_INSTROOT}" ] && exit 0
|
||||
FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate
|
||||
FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate || exit $?
|
||||
/usr/bin/forkop package_postinst
|
||||
EOF
|
||||
|
||||
cat > "$control_dir/prerm" <<'EOF'
|
||||
#!/usr/bin/ucode
|
||||
|
||||
if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "")
|
||||
system("/usr/bin/forkop package_prerm >/dev/null 2>&1");
|
||||
system("/usr/bin/forkop package_prerm " + (ARGV[0] || "") + " >/dev/null 2>&1");
|
||||
|
||||
exit(0);
|
||||
EOF
|
||||
@@ -494,7 +489,7 @@ EOF
|
||||
cat > "$scripts_dir/backend-post-install.sh" <<'EOF'
|
||||
#!/usr/bin/ucode
|
||||
if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "")
|
||||
exit(system("FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate"));
|
||||
exit(system("FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate && /usr/bin/forkop package_postinst"));
|
||||
exit(0);
|
||||
EOF
|
||||
|
||||
@@ -502,20 +497,22 @@ EOF
|
||||
#!/usr/bin/ucode
|
||||
|
||||
if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "")
|
||||
system("/usr/bin/forkop package_prerm >/dev/null 2>&1");
|
||||
system("/usr/bin/forkop package_prerm remove >/dev/null 2>&1");
|
||||
|
||||
exit(0);
|
||||
EOF
|
||||
|
||||
cat > "$scripts_dir/backend-pre-upgrade.sh" <<'EOF'
|
||||
cat > "$scripts_dir/backend-pre-upgrade.sh" <<'EOF'
|
||||
#!/usr/bin/ucode
|
||||
if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "")
|
||||
exit(system("/usr/bin/forkop package_prerm upgrade >/dev/null 2>&1"));
|
||||
exit(0);
|
||||
EOF
|
||||
|
||||
cat > "$scripts_dir/backend-post-upgrade.sh" <<'EOF'
|
||||
#!/usr/bin/ucode
|
||||
if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "")
|
||||
exit(system("FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate"));
|
||||
exit(system("FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate && /usr/bin/forkop package_postinst"));
|
||||
exit(0);
|
||||
EOF
|
||||
|
||||
@@ -747,6 +744,9 @@ verify_ipk_metadata() {
|
||||
tar -xzf "$tmp_dir/control.tar.gz" -C "$tmp_dir"
|
||||
grep -q "^Package: ${expected_package}$" "$tmp_dir/control"
|
||||
grep -q "^Version: ${expected_version}$" "$tmp_dir/control"
|
||||
if [[ "$expected_package" == "forkop" ]]; then
|
||||
grep -q "^Conflicts: ${BACKEND_CONFLICTS_IPK}$" "$tmp_dir/control"
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
@@ -761,6 +761,11 @@ verify_apk_metadata() {
|
||||
"$apk_bin" adbdump "$package_file" > "$dump_file"
|
||||
grep -q "^ name: ${expected_package}$" "$dump_file"
|
||||
grep -q "^ version: ${expected_version}$" "$dump_file"
|
||||
if [[ "$expected_package" == "forkop" ]]; then
|
||||
for conflict in https-dns-proxy nextdns luci-app-passwall luci-app-passwall2; do
|
||||
grep -q "^[[:space:]]*- '!${conflict}'$" "$dump_file"
|
||||
done
|
||||
fi
|
||||
rm -f "$dump_file"
|
||||
}
|
||||
|
||||
|
||||
@@ -3,111 +3,115 @@ import { execSync } from 'child_process';
|
||||
|
||||
const lang = process.argv[2];
|
||||
if (!lang) {
|
||||
console.error('❌ Укажи язык, например: node generate-po.js ru');
|
||||
process.exit(1);
|
||||
console.error('❌ Укажи язык, например: node generate-po.js ru');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const callsPath = 'locales/calls.json';
|
||||
const poPath = `locales/forkop.${lang}.po`;
|
||||
|
||||
function getGitUser() {
|
||||
try {
|
||||
return execSync('git config user.name').toString().trim();
|
||||
} catch {
|
||||
return 'Automatically generated';
|
||||
}
|
||||
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 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);';
|
||||
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 FORKOP package.`,
|
||||
`# Copyright (C) ${now.getFullYear()} THE FORKOP COPYRIGHT HOLDER`,
|
||||
`# This file is distributed under the same license as the FORKOP package.`,
|
||||
`# ${translator}, ${now.getFullYear()}.`,
|
||||
'#',
|
||||
'msgid ""',
|
||||
'msgstr ""',
|
||||
`"Project-Id-Version: FORKOP\\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"`,
|
||||
'',
|
||||
];
|
||||
return [
|
||||
`# ${lang.toUpperCase()} translations for FORKOP package.`,
|
||||
`# Copyright (C) ${now.getFullYear()} THE FORKOP COPYRIGHT HOLDER`,
|
||||
`# This file is distributed under the same license as the FORKOP package.`,
|
||||
`# ${translator}, ${now.getFullYear()}.`,
|
||||
'#',
|
||||
'msgid ""',
|
||||
'msgstr ""',
|
||||
`"Project-Id-Version: FORKOP\\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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return translations;
|
||||
}
|
||||
|
||||
function escapePoString(str) {
|
||||
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
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 [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 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 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;
|
||||
const finalPo = header.join('\n') + '\n' + body;
|
||||
|
||||
await fs.writeFile(poPath, finalPo, 'utf8');
|
||||
console.log(`✅ Файл ${poPath} успешно сгенерирован. Переведено ${[...oldTranslations.keys()].length}/${calls.length}`);
|
||||
await fs.writeFile(poPath, finalPo, 'utf8');
|
||||
console.log(
|
||||
`✅ Файл ${poPath} успешно сгенерирован. Переведено ${[...oldTranslations.keys()].length}/${calls.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
generatePo().catch((err) => {
|
||||
console.error('Ошибка генерации PO файла:', err);
|
||||
process.exitCode = 1;
|
||||
console.error('Ошибка генерации PO файла:', err);
|
||||
});
|
||||
|
||||
@@ -6,80 +6,86 @@ const outputFile = 'locales/forkop.pot';
|
||||
const projectId = 'FORKOP';
|
||||
|
||||
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();
|
||||
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.
|
||||
if (name && email) {
|
||||
return { name, email };
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall through to deterministic defaults when git identity is not configured.
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Forkop',
|
||||
email: 'ushan0v@users.noreply.github.com',
|
||||
};
|
||||
return {
|
||||
name: 'Forkop',
|
||||
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}`;
|
||||
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 <LL@li.org>\\n"`,
|
||||
`"Language: \\n"`,
|
||||
`"MIME-Version: 1.0\\n"`,
|
||||
`"Content-Type: text/plain; charset=UTF-8\\n"`,
|
||||
`"Content-Transfer-Encoding: 8bit\\n"`,
|
||||
'',
|
||||
].join('\n');
|
||||
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 <LL@li.org>\\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, '\\"');
|
||||
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');
|
||||
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 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');
|
||||
const header = getPotHeader(gitUser);
|
||||
const body = entries.map(generateEntry).join('\n');
|
||||
|
||||
await fs.writeFile(outputFile, `${header}\n${body}`, 'utf8');
|
||||
await fs.writeFile(outputFile, `${header}\n${body}`, 'utf8');
|
||||
|
||||
console.log(`✅ POT-файл успешно создан: ${outputFile}`);
|
||||
console.log(`✅ POT-файл успешно создан: ${outputFile}`);
|
||||
}
|
||||
|
||||
generatePot().catch(console.error);
|
||||
generatePot().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"format:js": "prettier --write ../luci-app-forkop/htdocs/luci-static/resources/view/forkop",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||
"build": "tsup src/main.ts",
|
||||
"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",
|
||||
|
||||
@@ -132,10 +132,6 @@ function getDisplayName(section: Forkop.ConfigSection) {
|
||||
return section.label || section['.name'];
|
||||
}
|
||||
|
||||
function getSectionAction(section: Forkop.ConfigSection) {
|
||||
return section.action || '';
|
||||
}
|
||||
|
||||
function getSettingsSection(configSections: Forkop.ConfigSection[]) {
|
||||
return configSections.find((section) => section['.type'] === 'settings');
|
||||
}
|
||||
@@ -358,8 +354,10 @@ function getJsonOutbounds(section: Forkop.ConfigSection) {
|
||||
return values.length ? values : getListValues(section.outbound_json);
|
||||
}
|
||||
|
||||
function isConnectionAction(action: string) {
|
||||
return ['connection', 'proxy', 'outbound', 'vpn'].includes(action);
|
||||
function isConnectionAction(action?: string) {
|
||||
return Boolean(
|
||||
action && ['connection', 'proxy', 'outbound', 'vpn'].includes(action),
|
||||
);
|
||||
}
|
||||
|
||||
function hasSubscriptionSources(section: Forkop.ConfigSection) {
|
||||
@@ -1374,13 +1372,12 @@ export async function getDashboardSections(
|
||||
configSections
|
||||
.filter(
|
||||
(section) =>
|
||||
section.enabled !== '0' &&
|
||||
isConnectionAction(getSectionAction(section)),
|
||||
section.enabled !== '0' && isConnectionAction(section.action),
|
||||
)
|
||||
.map(async (section) => {
|
||||
const displayName = getDisplayName(section);
|
||||
const sectionName = section['.name'];
|
||||
const sectionAction = getSectionAction(section);
|
||||
const sectionAction = section.action;
|
||||
const proxyConfigType = getSectionProxyConfigType(section);
|
||||
|
||||
if (isConnectionAction(sectionAction) && shouldUseProxyGroup(section)) {
|
||||
|
||||
@@ -64,8 +64,8 @@ class SocketManager {
|
||||
|
||||
this.sockets.set(url, ws);
|
||||
this.connected.set(url, false);
|
||||
this.listeners.set(url, new Set());
|
||||
this.errorListeners.set(url, new Set());
|
||||
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);
|
||||
|
||||
@@ -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<string, Array<(event: Event) => 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();
|
||||
});
|
||||
});
|
||||
@@ -878,10 +878,7 @@ function renderUrlTestCopyButton(
|
||||
);
|
||||
}
|
||||
|
||||
function renderUrlTestInfoModal(
|
||||
section: Forkop.OutboundGroup,
|
||||
outbound: Forkop.Outbound,
|
||||
) {
|
||||
function renderUrlTestInfoModal(outbound: Forkop.Outbound) {
|
||||
const info = outbound.urlTestInfo;
|
||||
|
||||
if (!info) {
|
||||
@@ -1017,10 +1014,7 @@ function renderUrlTestInfoModal(
|
||||
]);
|
||||
}
|
||||
|
||||
function handleShowUrlTestInfo(
|
||||
section: Forkop.OutboundGroup,
|
||||
outbound: Forkop.Outbound,
|
||||
) {
|
||||
function handleShowUrlTestInfo(outbound: Forkop.Outbound) {
|
||||
if (!outbound.urlTestInfo) {
|
||||
return;
|
||||
}
|
||||
@@ -1029,7 +1023,7 @@ function handleShowUrlTestInfo(
|
||||
`${_('URLTest details')}: ${
|
||||
outbound.urlTestInfo.displayName || outbound.displayName
|
||||
}`,
|
||||
renderUrlTestInfoModal(section, outbound),
|
||||
renderUrlTestInfoModal(outbound),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1105,10 +1099,7 @@ function renderPriorityMemberName(member: Forkop.PriorityMember) {
|
||||
];
|
||||
}
|
||||
|
||||
function renderPriorityInfoModal(
|
||||
section: Forkop.OutboundGroup,
|
||||
outbound: Forkop.Outbound,
|
||||
) {
|
||||
function renderPriorityInfoModal(outbound: Forkop.Outbound) {
|
||||
const info = outbound.priorityInfo;
|
||||
|
||||
if (!info) {
|
||||
@@ -1273,10 +1264,7 @@ function renderPriorityInfoModal(
|
||||
]);
|
||||
}
|
||||
|
||||
function handleShowPriorityInfo(
|
||||
section: Forkop.OutboundGroup,
|
||||
outbound: Forkop.Outbound,
|
||||
) {
|
||||
function handleShowPriorityInfo(outbound: Forkop.Outbound) {
|
||||
if (!outbound.priorityInfo) {
|
||||
return;
|
||||
}
|
||||
@@ -1285,7 +1273,7 @@ function handleShowPriorityInfo(
|
||||
`${_('Priority details')}: ${
|
||||
outbound.priorityInfo.displayName || outbound.displayName
|
||||
}`,
|
||||
renderPriorityInfoModal(section, outbound),
|
||||
renderPriorityInfoModal(outbound),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1499,11 +1487,11 @@ async function renderSectionsWidget() {
|
||||
onCopyOutbound: (_section, outbound) => {
|
||||
handleCopyOutbound(outbound);
|
||||
},
|
||||
onShowUrlTestInfo: (section, outbound) => {
|
||||
handleShowUrlTestInfo(section, outbound);
|
||||
onShowUrlTestInfo: (outbound) => {
|
||||
handleShowUrlTestInfo(outbound);
|
||||
},
|
||||
onShowPriorityInfo: (section, outbound) => {
|
||||
handleShowPriorityInfo(section, outbound);
|
||||
onShowPriorityInfo: (outbound) => {
|
||||
handleShowPriorityInfo(outbound);
|
||||
},
|
||||
onUpdateSubscription: (section) => {
|
||||
void handleUpdateSubscription(section);
|
||||
|
||||
@@ -22,14 +22,8 @@ interface IRenderSectionsProps {
|
||||
section: Forkop.OutboundGroup,
|
||||
outbound: Forkop.Outbound,
|
||||
) => void;
|
||||
onShowUrlTestInfo: (
|
||||
section: Forkop.OutboundGroup,
|
||||
outbound: Forkop.Outbound,
|
||||
) => void;
|
||||
onShowPriorityInfo: (
|
||||
section: Forkop.OutboundGroup,
|
||||
outbound: Forkop.Outbound,
|
||||
) => void;
|
||||
onShowUrlTestInfo: (outbound: Forkop.Outbound) => void;
|
||||
onShowPriorityInfo: (outbound: Forkop.Outbound) => void;
|
||||
onUpdateSubscription: (section: Forkop.OutboundGroup) => void;
|
||||
latencyFetching: boolean;
|
||||
latencyProgress?: Forkop.LatencyActionProgress;
|
||||
@@ -386,7 +380,7 @@ function renderDefaultState({
|
||||
'aria-label': _('URLTest details'),
|
||||
click: (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onShowUrlTestInfo(section, outbound);
|
||||
onShowUrlTestInfo(outbound);
|
||||
},
|
||||
},
|
||||
renderInfoIcon24(),
|
||||
@@ -405,7 +399,7 @@ function renderDefaultState({
|
||||
'aria-label': _('Priority details'),
|
||||
click: (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onShowPriorityInfo(section, outbound);
|
||||
onShowPriorityInfo(outbound);
|
||||
},
|
||||
},
|
||||
renderInfoIcon24(),
|
||||
|
||||
@@ -4,9 +4,13 @@ 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,
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('provider diagnostics checks', () => {
|
||||
|
||||
await expect(runZapretCheck()).resolves.toBeUndefined();
|
||||
|
||||
const result = mocks.updateCheckStore.mock.calls.at(-1)?.[0];
|
||||
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'success',
|
||||
@@ -95,7 +95,7 @@ describe('provider diagnostics checks', () => {
|
||||
|
||||
await expect(runZapretCheck()).resolves.toBeUndefined();
|
||||
|
||||
const result = mocks.updateCheckStore.mock.calls.at(-1)?.[0];
|
||||
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'error',
|
||||
@@ -122,7 +122,7 @@ describe('provider diagnostics checks', () => {
|
||||
|
||||
await expect(runZapret2Check()).resolves.toBeUndefined();
|
||||
|
||||
const result = mocks.updateCheckStore.mock.calls.at(-1)?.[0];
|
||||
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'success',
|
||||
@@ -171,7 +171,7 @@ describe('provider diagnostics checks', () => {
|
||||
|
||||
await expect(runByedpiCheck()).resolves.toBeUndefined();
|
||||
|
||||
const result = mocks.updateCheckStore.mock.calls.at(-1)?.[0];
|
||||
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'success',
|
||||
|
||||
@@ -30,6 +30,7 @@ const SING_BOX_MASKED_KEYS = new Set([
|
||||
|
||||
const FORKOP_MASK_AFTER_TOKEN = [
|
||||
'option proxy_string',
|
||||
'option hwid',
|
||||
'option subscription_url',
|
||||
'list subscription_urls',
|
||||
'list urltest_proxy_links',
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('diagnostic masking', () => {
|
||||
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",',
|
||||
@@ -77,6 +78,7 @@ describe('diagnostic masking', () => {
|
||||
|
||||
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');
|
||||
|
||||
@@ -631,6 +631,8 @@ export namespace Forkop {
|
||||
legacy_runtime_present: 0 | 1;
|
||||
ready: 0 | 1;
|
||||
conflict: 0 | 1;
|
||||
outbounds_configured: 0 | 1;
|
||||
routes_configured: 0 | 1;
|
||||
status_message: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ export * from './parseValueList';
|
||||
export * from './injectGlobalStyles';
|
||||
export * from './withTimeout';
|
||||
export * from './executeShellCommand';
|
||||
export * from './maskIP';
|
||||
export * from './getProxyUrlName';
|
||||
export * from './onMount';
|
||||
export * from './getClashApiUrl';
|
||||
|
||||
@@ -3,24 +3,6 @@ const COPYABLE_PROXY_URI_RE =
|
||||
const HTTP_PROXY_URI_RE =
|
||||
/^https?:\/\/(?:[^/@\s]+(?::[^/@\s]*)?@)?[^:/?#@\s]+:(\d{1,5})$/i;
|
||||
|
||||
const COPYABLE_PROXY_OUTBOUND_TYPES = new Set([
|
||||
'vless',
|
||||
'vmess',
|
||||
'trojan',
|
||||
'shadowsocks',
|
||||
'ss',
|
||||
'shadowsocksr',
|
||||
'ssr',
|
||||
'hysteria2',
|
||||
'hy2',
|
||||
'tuic',
|
||||
'socks',
|
||||
'socks4',
|
||||
'socks4a',
|
||||
'socks5',
|
||||
'http',
|
||||
]);
|
||||
|
||||
export function isCopyableProxyLink(link?: string) {
|
||||
const value = (link || '').trim();
|
||||
if (COPYABLE_PROXY_URI_RE.test(value)) {
|
||||
@@ -35,7 +17,3 @@ export function isCopyableProxyLink(link?: string) {
|
||||
const port = Number(httpProxy[1]);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
||||
}
|
||||
|
||||
export function isCopyableProxyOutboundType(type?: string) {
|
||||
return COPYABLE_PROXY_OUTBOUND_TYPES.has((type || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export function maskIP(ip: string = ''): string {
|
||||
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
|
||||
return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`);
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isCopyableProxyLink,
|
||||
isCopyableProxyOutboundType,
|
||||
} from '../isCopyableProxyLink';
|
||||
import { isCopyableProxyLink } from '../isCopyableProxyLink';
|
||||
|
||||
const copyableLinks = [
|
||||
'vless://uuid@example.com:443',
|
||||
@@ -44,20 +41,3 @@ describe('isCopyableProxyLink', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCopyableProxyOutboundType', () => {
|
||||
it('accepts protocol outbound types from imported proxy links', () => {
|
||||
expect(isCopyableProxyOutboundType('VLESS')).toBe(true);
|
||||
expect(isCopyableProxyOutboundType('shadowsocks')).toBe(true);
|
||||
expect(isCopyableProxyOutboundType('hysteria2')).toBe(true);
|
||||
expect(isCopyableProxyOutboundType('socks')).toBe(true);
|
||||
expect(isCopyableProxyOutboundType('http')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects group, service and routing-only outbound types', () => {
|
||||
expect(isCopyableProxyOutboundType('urltest')).toBe(false);
|
||||
expect(isCopyableProxyOutboundType('selector')).toBe(false);
|
||||
expect(isCopyableProxyOutboundType('direct')).toBe(false);
|
||||
expect(isCopyableProxyOutboundType('block')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { maskIP } from '../maskIP';
|
||||
|
||||
export const validIPs = [
|
||||
['Standard private IP', '192.168.0.1', 'XX.XX.XX.1'],
|
||||
['Public IP', '8.8.8.8', 'XX.XX.XX.8'],
|
||||
['Mixed digits', '10.0.255.99', 'XX.XX.XX.99'],
|
||||
['Edge values', '255.255.255.255', 'XX.XX.XX.255'],
|
||||
['Zeros', '0.0.0.0', 'XX.XX.XX.0'],
|
||||
];
|
||||
|
||||
export const invalidIPs = [
|
||||
['Empty string', '', ''],
|
||||
['Missing octets', '192.168.1', '192.168.1'],
|
||||
['Extra octets', '1.2.3.4.5', '1.2.3.4.5'],
|
||||
['Letters inside', 'abc.def.ghi.jkl', 'abc.def.ghi.jkl'],
|
||||
['Spaces inside', '1. 2.3.4', '1. 2.3.4'],
|
||||
['Just dots', '...', '...'],
|
||||
['IP with port', '127.0.0.1:8080', '127.0.0.1:8080'],
|
||||
['IP with text', 'ip=192.168.0.1', 'ip=192.168.0.1'],
|
||||
];
|
||||
|
||||
describe('maskIP', () => {
|
||||
describe.each(validIPs)('Valid IPv4: %s', (_desc, ip, expected) => {
|
||||
it(`masks "${ip}" → "${expected}"`, () => {
|
||||
expect(maskIP(ip)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(invalidIPs)(
|
||||
'Invalid or malformed IP: %s',
|
||||
(_desc, ip, expected) => {
|
||||
it(`returns original string for "${ip}"`, () => {
|
||||
expect(maskIP(ip)).toBe(expected);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('defaults to empty string if no param passed', () => {
|
||||
expect(maskIP()).toBe('');
|
||||
});
|
||||
});
|
||||
Vendored
+2
-1
@@ -9,6 +9,7 @@ type HtmlAttributes<T extends HtmlTag = 'div'> = Partial<
|
||||
'aria-busy'?: string;
|
||||
'aria-disabled'?: string;
|
||||
'aria-label'?: string;
|
||||
'data-latency-section'?: string;
|
||||
click?: (event: MouseEvent) => void;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
}
|
||||
@@ -45,7 +46,7 @@ declare global {
|
||||
const _ = (_key: string) => string;
|
||||
|
||||
const ui = {
|
||||
showModal: (_title: stirng, _content: HtmlElement) => undefined,
|
||||
showModal: (_title: string, _content: HTMLElement) => undefined,
|
||||
hideModal: () => undefined,
|
||||
addNotification: (
|
||||
_title: string,
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
if (typeof structuredClone !== 'function')
|
||||
globalThis.structuredClone = (obj) => JSON.parse(JSON.stringify(obj));
|
||||
|
||||
export {
|
||||
validateIP,
|
||||
validateIPV4,
|
||||
validateIPv6,
|
||||
} from './validators/validateIp';
|
||||
export { validateIP } from './validators/validateIp';
|
||||
export { validateDomain } from './validators/validateDomain';
|
||||
export { validateDNS } from './validators/validateDns';
|
||||
export { validateUrl } from './validators/validateUrl';
|
||||
|
||||
@@ -24,6 +24,12 @@ export function isValidHost(host: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -35,10 +41,11 @@ export function parseHostPort(value: string): ParsedHostPort | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
const parsed = {
|
||||
host: value.slice(1, end),
|
||||
port: value.slice(end + 2),
|
||||
};
|
||||
return isValidHost(parsed.host) ? parsed : null;
|
||||
}
|
||||
|
||||
const firstColon = value.indexOf(':');
|
||||
@@ -47,8 +54,9 @@ export function parseHostPort(value: string): ParsedHostPort | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
const parsed = {
|
||||
host: value.slice(0, firstColon),
|
||||
port: value.slice(firstColon + 1),
|
||||
};
|
||||
return isValidHost(parsed.host) ? parsed : null;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,13 @@ export const additionalValidDns = [
|
||||
|
||||
const validDns = [...validIPs, ...validDomains, ...additionalValidDns];
|
||||
|
||||
const invalidDns = [...invalidIPs, ...invalidDomains];
|
||||
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) => {
|
||||
|
||||
@@ -46,6 +46,7 @@ const invalidUrls = [
|
||||
['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/'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateIP, validateIPV4, validateIPv6 } from '../validateIp';
|
||||
import { validateIP } from '../validateIp';
|
||||
|
||||
export const validIPs = [
|
||||
['Private LAN', '192.168.1.1'],
|
||||
@@ -21,23 +21,23 @@ export const invalidIPs = [
|
||||
['Trailing dot', '1.2.3.'],
|
||||
];
|
||||
|
||||
describe('validateIPV4', () => {
|
||||
describe('validateIP IPv4', () => {
|
||||
describe.each(validIPs)('Valid IP: %s', (_desc, ip) => {
|
||||
it(`returns {valid:true} for "${ip}"`, () => {
|
||||
const res = validateIPV4(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 = validateIPV4(ip);
|
||||
const res = validateIP(ip);
|
||||
expect(res.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateIPv6', () => {
|
||||
describe('validateIP IPv6', () => {
|
||||
const validIPv6Addresses = [
|
||||
['Loopback', '::1'],
|
||||
['ULA', 'fc00::1'],
|
||||
@@ -53,14 +53,13 @@ describe('validateIPv6', () => {
|
||||
|
||||
describe.each(validIPv6Addresses)('Valid IPv6: %s', (_desc, ip) => {
|
||||
it(`returns {valid:true} for "${ip}"`, () => {
|
||||
expect(validateIPv6(ip).valid).toBe(true);
|
||||
expect(validateIP(ip).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(invalidIPv6Addresses)('Invalid IPv6: %s', (_desc, ip) => {
|
||||
it(`returns {valid:false} for "${ip}"`, () => {
|
||||
expect(validateIPv6(ip).valid).toBe(false);
|
||||
expect(validateIP(ip).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,4 +67,12 @@ describe('validateProxyUrl', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ 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'],
|
||||
|
||||
@@ -104,6 +104,7 @@ const invalidUrls = [
|
||||
['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',
|
||||
|
||||
@@ -67,6 +67,7 @@ const invalidUrls = [
|
||||
['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',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { validateDomain } from './validateDomain';
|
||||
import { parseHostPort, unbracketHost } from './hostPort';
|
||||
import { isValidPort, parseHostPort, unbracketHost } from './hostPort';
|
||||
import { validateIP } from './validateIp';
|
||||
import { ValidationResult } from './types';
|
||||
|
||||
@@ -17,6 +17,10 @@ export function validateDNS(value: string): ValidationResult {
|
||||
? 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') };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ValidationResult } from './types';
|
||||
import { isValidHost, parseHostPort } from './hostPort';
|
||||
import { isValidPort, parseHostPort } from './hostPort';
|
||||
|
||||
export function validateHttpProxyUrl(url: string): ValidationResult {
|
||||
try {
|
||||
@@ -73,20 +73,12 @@ export function validateHttpProxyUrl(url: string): ValidationResult {
|
||||
};
|
||||
}
|
||||
|
||||
const portNum = Number(port);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||
if (!isValidPort(port)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid HTTP proxy URL: invalid port number'),
|
||||
};
|
||||
}
|
||||
|
||||
if (!isValidHost(host)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid HTTP proxy URL: invalid host format'),
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
return {
|
||||
valid: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ValidationResult } from './types';
|
||||
import { parseQueryString } from '../helpers/parseQueryString';
|
||||
import { parseHostPort } from './hostPort';
|
||||
import { isValidPort, parseHostPort } from './hostPort';
|
||||
|
||||
export function validateHysteria2Url(url: string): ValidationResult {
|
||||
try {
|
||||
@@ -64,22 +64,13 @@ export function validateHysteria2Url(url: string): ValidationResult {
|
||||
const cleanedPort = port.replace('/', '');
|
||||
const portEntries = cleanedPort.split(',');
|
||||
|
||||
const isValidPortNumber = (value: string) => {
|
||||
if (!/^\d+$/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const portNum = Number(value);
|
||||
return Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535;
|
||||
};
|
||||
|
||||
const isValidPortEntry = (entry: string) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!entry.includes('-')) {
|
||||
return isValidPortNumber(entry);
|
||||
return isValidPort(entry);
|
||||
}
|
||||
|
||||
const rangeParts = entry.split('-');
|
||||
@@ -89,9 +80,7 @@ export function validateHysteria2Url(url: string): ValidationResult {
|
||||
|
||||
const [start, end] = rangeParts;
|
||||
return (
|
||||
isValidPortNumber(start) &&
|
||||
isValidPortNumber(end) &&
|
||||
Number(start) <= Number(end)
|
||||
isValidPort(start) && isValidPort(end) && Number(start) <= Number(end)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,22 +20,6 @@ export function isIPv6(ip: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function validateIPV4(ip: string): ValidationResult {
|
||||
if (isIPv4(ip)) {
|
||||
return { valid: true, message: _('Valid') };
|
||||
}
|
||||
|
||||
return { valid: false, message: _('Invalid IP address') };
|
||||
}
|
||||
|
||||
export function validateIPv6(ip: string): ValidationResult {
|
||||
if (isIPv6(ip)) {
|
||||
return { valid: true, message: _('Valid') };
|
||||
}
|
||||
|
||||
return { valid: false, message: _('Invalid IP address') };
|
||||
}
|
||||
|
||||
export function validateIP(ip: string): ValidationResult {
|
||||
if (isIPv4(ip) || isIPv6(ip)) {
|
||||
return { valid: true, message: _('Valid') };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ValidationResult } from './types';
|
||||
import { parseHostPort } from './hostPort';
|
||||
import { isValidPort, parseHostPort } from './hostPort';
|
||||
|
||||
// TODO refactor current validation and add tests
|
||||
export function validateShadowsocksUrl(url: string): ValidationResult {
|
||||
@@ -85,9 +85,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult {
|
||||
};
|
||||
}
|
||||
|
||||
const portNum = parseInt(port, 10);
|
||||
|
||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
||||
if (!isValidPort(port)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid port number. Must be between 1 and 65535'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ValidationResult } from './types';
|
||||
import { isValidHost, parseHostPort } from './hostPort';
|
||||
import { isValidPort, parseHostPort } from './hostPort';
|
||||
|
||||
export function validateSocksUrl(url: string): ValidationResult {
|
||||
try {
|
||||
@@ -62,20 +62,12 @@ export function validateSocksUrl(url: string): ValidationResult {
|
||||
return { valid: false, message: _('Invalid SOCKS URL: missing port') };
|
||||
}
|
||||
|
||||
const portNum = Number(port);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||
if (!isValidPort(port)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid SOCKS URL: invalid port number'),
|
||||
};
|
||||
}
|
||||
|
||||
if (!isValidHost(host)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid SOCKS URL: invalid host format'),
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
return { valid: false, message: _('Invalid SOCKS URL: parsing failed') };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ValidationResult } from './types';
|
||||
import { parseHostPort } from './hostPort';
|
||||
import { isValidPort, parseHostPort } from './hostPort';
|
||||
|
||||
export function validateTrojanUrl(url: string): ValidationResult {
|
||||
try {
|
||||
@@ -52,9 +52,7 @@ export function validateTrojanUrl(url: string): ValidationResult {
|
||||
if (!port)
|
||||
return { valid: false, message: 'Invalid Trojan URL: missing port' };
|
||||
|
||||
const portNum = Number(port);
|
||||
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
|
||||
if (!isValidPort(port))
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Invalid Trojan URL: invalid port number',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ValidationResult } from './types';
|
||||
import { isValidHost } from './hostPort';
|
||||
import { isValidHost, isValidPort } from './hostPort';
|
||||
|
||||
export function validateUrl(
|
||||
url: string,
|
||||
@@ -25,11 +25,9 @@ export function validateUrl(
|
||||
const host = parsed.hostname.startsWith('[')
|
||||
? parsed.hostname.slice(1, -1)
|
||||
: parsed.hostname;
|
||||
const portNum = parsed.port ? Number(parsed.port) : 0;
|
||||
if (
|
||||
(isValidHost(host) || host === 'localhost') &&
|
||||
(!parsed.port ||
|
||||
(Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535))
|
||||
(!parsed.port || isValidPort(parsed.port))
|
||||
) {
|
||||
return { valid: true, message: _('Valid') };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ValidationResult } from './types';
|
||||
import { parseQueryString } from '../helpers/parseQueryString';
|
||||
import { parseHostPort } from './hostPort';
|
||||
import { isValidPort, parseHostPort } from './hostPort';
|
||||
|
||||
export function validateVlessUrl(url: string): ValidationResult {
|
||||
try {
|
||||
@@ -51,8 +51,7 @@ export function validateVlessUrl(url: string): ValidationResult {
|
||||
return { valid: false, message: 'Invalid VLESS URL: missing port' };
|
||||
|
||||
const cleanedPort = port.replace('/', '');
|
||||
const portNum = Number(cleanedPort);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
|
||||
if (!isValidPort(cleanedPort))
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Invalid VLESS URL: invalid port number',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ValidationResult } from './types';
|
||||
import { isValidHost, isValidPort } from './hostPort';
|
||||
|
||||
function decodeBase64Json(value: string): unknown {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
@@ -47,12 +48,11 @@ export function validateVmessUrl(url: string): ValidationResult {
|
||||
}
|
||||
|
||||
const { add, port, id } = config as Record<string, unknown>;
|
||||
if (!add || typeof add !== 'string') {
|
||||
return { valid: false, message: 'Invalid VMess URL: missing server' };
|
||||
if (!add || typeof add !== 'string' || !isValidHost(add)) {
|
||||
return { valid: false, message: 'Invalid VMess URL: invalid server' };
|
||||
}
|
||||
|
||||
const portNum = Number(port);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||
if (!isValidPort(port)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Invalid VMess URL: invalid port number',
|
||||
|
||||
@@ -12,27 +12,37 @@ const config = {
|
||||
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 }),
|
||||
? { 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-forkop/htdocs/luci-static/resources/view/forkop'),
|
||||
remote: process.env.REMOTE_DIR_FE ?? '/www/luci-static/resources/view/forkop',
|
||||
local: path.resolve(
|
||||
process.env.LOCAL_DIR_FE ??
|
||||
'../luci-app-forkop/htdocs/luci-static/resources/view/forkop',
|
||||
),
|
||||
remote:
|
||||
process.env.REMOTE_DIR_FE ?? '/www/luci-static/resources/view/forkop',
|
||||
},
|
||||
{
|
||||
local: path.resolve(process.env.LOCAL_DIR_BIN ?? '../forkop/files/usr/bin/'),
|
||||
local: path.resolve(
|
||||
process.env.LOCAL_DIR_BIN ?? '../forkop/files/usr/bin/',
|
||||
),
|
||||
remote: process.env.REMOTE_DIR_BIN ?? '/usr/bin/',
|
||||
},
|
||||
{
|
||||
local: path.resolve(process.env.LOCAL_DIR_LIB ?? '../forkop/files/usr/lib/'),
|
||||
local: path.resolve(
|
||||
process.env.LOCAL_DIR_LIB ?? '../forkop/files/usr/lib/',
|
||||
),
|
||||
remote: process.env.REMOTE_DIR_LIB ?? '/usr/lib/forkop/',
|
||||
},
|
||||
{
|
||||
local: path.resolve(process.env.LOCAL_DIR_INIT ?? '../forkop/files/etc/init.d/'),
|
||||
local: path.resolve(
|
||||
process.env.LOCAL_DIR_INIT ?? '../forkop/files/etc/init.d/',
|
||||
),
|
||||
remote: process.env.REMOTE_DIR_INIT ?? '/etc/init.d/',
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
async function uploadFile(filePath, baseDir, remoteBase) {
|
||||
@@ -79,13 +89,15 @@ async function main() {
|
||||
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);
|
||||
}
|
||||
});
|
||||
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 () => {
|
||||
@@ -96,5 +108,6 @@ async function main() {
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.exitCode = 1;
|
||||
console.error('💥 Fatal:', err);
|
||||
});
|
||||
|
||||
+4
-3
@@ -22,7 +22,7 @@ include $(INCLUDE_DIR)/package.mk
|
||||
define Package/forkop
|
||||
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 +coreutils-sort +bind-dig +nftables +kmod-nft-nat +ip-full
|
||||
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:=Forkop backend service
|
||||
URL:=https://github.com/ushan0v/forkop
|
||||
@@ -43,7 +43,7 @@ define Package/forkop/prerm
|
||||
#!/usr/bin/ucode
|
||||
|
||||
if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "")
|
||||
system("/usr/bin/forkop package_prerm >/dev/null 2>&1");
|
||||
system("/usr/bin/forkop package_prerm " + (ARGV[0] || "") + " >/dev/null 2>&1");
|
||||
|
||||
exit(0);
|
||||
endef
|
||||
@@ -52,7 +52,8 @@ define Package/forkop/postinst
|
||||
#!/bin/sh
|
||||
|
||||
[ -n "$${IPKG_INSTROOT}" ] && exit 0
|
||||
FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate
|
||||
FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate || exit $$?
|
||||
/usr/bin/forkop package_postinst
|
||||
endef
|
||||
|
||||
define Package/forkop/conffiles
|
||||
|
||||
@@ -191,7 +191,8 @@ function command_spec(command) {
|
||||
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", 0 ],
|
||||
package_prerm: [ "service/package.uc", "prerm", 1 ],
|
||||
package_postinst: [ "service/package.uc", "postinst", 0 ],
|
||||
luci_postinst: [ "service/package.uc", "luci-postinst", 0 ]
|
||||
};
|
||||
|
||||
|
||||
@@ -123,19 +123,6 @@ function path_basename(value) {
|
||||
return length(parts) > 0 ? as_string(parts[length(parts) - 1]) : "";
|
||||
}
|
||||
|
||||
function str_last_index(value, needle) {
|
||||
value = as_string(value);
|
||||
needle = as_string(needle);
|
||||
if (needle == "")
|
||||
return length(value);
|
||||
|
||||
for (let i = length(value) - length(needle); i >= 0; i--)
|
||||
if (substr(value, i, length(needle)) == needle)
|
||||
return i;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function arg_bool(value) {
|
||||
return value === true || value == "true" || value == "1" || value == 1;
|
||||
}
|
||||
@@ -295,7 +282,7 @@ function openwrt_release_value(path, key) {
|
||||
continue;
|
||||
|
||||
let rest = substr(line, length(prefix));
|
||||
let quote = str_last_index(rest, "'");
|
||||
let quote = rindex(rest, "'");
|
||||
if (quote >= 0)
|
||||
print(substr(rest, 0, quote), "\n");
|
||||
return;
|
||||
@@ -313,7 +300,7 @@ function openwrt_release_series(path) {
|
||||
continue;
|
||||
|
||||
let value = substr(line, length(prefix));
|
||||
let quote = str_last_index(value, "'");
|
||||
let quote = rindex(value, "'");
|
||||
if (quote >= 0)
|
||||
value = substr(value, 0, quote);
|
||||
|
||||
|
||||
@@ -300,19 +300,6 @@ function whitespace_list_values(section, key) {
|
||||
return list_option(section, key);
|
||||
}
|
||||
|
||||
function str_last_index(value, needle) {
|
||||
value = as_string(value);
|
||||
needle = as_string(needle);
|
||||
if (needle == "")
|
||||
return length(value);
|
||||
|
||||
for (let i = length(value) - length(needle); i >= 0; i--)
|
||||
if (substr(value, i, length(needle)) == needle)
|
||||
return i;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function subscription_url_entry_profile(value) {
|
||||
let entry = trim(as_string(value));
|
||||
let result = {
|
||||
@@ -322,7 +309,7 @@ function subscription_url_entry_profile(value) {
|
||||
changed: false
|
||||
};
|
||||
let delimiter = " | ";
|
||||
let delimiter_index = str_last_index(entry, delimiter);
|
||||
let delimiter_index = rindex(entry, delimiter);
|
||||
|
||||
if (delimiter_index < 0)
|
||||
return result;
|
||||
|
||||
@@ -249,19 +249,6 @@ function strip_first_scheme_marker(value) {
|
||||
return marker >= 0 ? substr(value, marker + 3) : value;
|
||||
}
|
||||
|
||||
function str_last_index(value, needle) {
|
||||
value = as_string(value);
|
||||
needle = as_string(needle);
|
||||
if (needle == "")
|
||||
return length(value);
|
||||
|
||||
for (let i = length(value) - length(needle); i >= 0; i--)
|
||||
if (substr(value, i, length(needle)) == needle)
|
||||
return i;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function url_get_scheme(value) {
|
||||
value = as_string(value);
|
||||
let marker = index(value, "://");
|
||||
@@ -333,7 +320,7 @@ function url_get_query_param(value, param) {
|
||||
|
||||
function url_file_extension(value) {
|
||||
let basename = as_string(value);
|
||||
let slash = str_last_index(basename, "/");
|
||||
let slash = rindex(basename, "/");
|
||||
if (slash >= 0)
|
||||
basename = substr(basename, slash + 1);
|
||||
|
||||
@@ -345,7 +332,7 @@ function url_file_extension(value) {
|
||||
if (fragment >= 0)
|
||||
basename = substr(basename, 0, fragment);
|
||||
|
||||
let dot = str_last_index(basename, ".");
|
||||
let dot = rindex(basename, ".");
|
||||
if (dot >= 0)
|
||||
print(substr(basename, dot + 1), "\n");
|
||||
else
|
||||
|
||||
@@ -4,19 +4,6 @@ let common = require("core.common");
|
||||
|
||||
let as_string = common.as_string;
|
||||
|
||||
function str_last_index(value, needle) {
|
||||
value = as_string(value);
|
||||
needle = as_string(needle);
|
||||
if (needle == "")
|
||||
return length(value);
|
||||
|
||||
for (let i = length(value) - length(needle); i >= 0; i--)
|
||||
if (substr(value, i, length(needle)) == needle)
|
||||
return i;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function first_index_any(value, needles, start) {
|
||||
value = as_string(value);
|
||||
start = int(start || 0);
|
||||
@@ -104,7 +91,7 @@ function strip_first_scheme_marker(value) {
|
||||
|
||||
function authority(value) {
|
||||
value = strip_first_scheme_marker(value);
|
||||
let at = str_last_index(value, "@");
|
||||
let at = rindex(value, "@");
|
||||
if (at >= 0)
|
||||
value = substr(value, at + 1);
|
||||
|
||||
@@ -123,7 +110,7 @@ function host(value) {
|
||||
if (colon < 0)
|
||||
return value_authority;
|
||||
|
||||
return str_last_index(value_authority, ":") == colon ? substr(value_authority, 0, colon) : value_authority;
|
||||
return rindex(value_authority, ":") == colon ? substr(value_authority, 0, colon) : value_authority;
|
||||
}
|
||||
|
||||
function port(value) {
|
||||
@@ -136,7 +123,7 @@ function port(value) {
|
||||
}
|
||||
|
||||
let colon = index(value_authority, ":");
|
||||
if (colon < 0 || str_last_index(value_authority, ":") != colon)
|
||||
if (colon < 0 || rindex(value_authority, ":") != colon)
|
||||
return "";
|
||||
|
||||
return substr(value_authority, colon + 1);
|
||||
@@ -146,7 +133,7 @@ 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 = str_last_index(value_authority, "@");
|
||||
let at = rindex(value_authority, "@");
|
||||
return at >= 0 ? decode(substr(value_authority, 0, at)) : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ const SB_TPROXY_INBOUND6_ADDRESS = getenv("SB_TPROXY_INBOUND6_ADDRESS") || const
|
||||
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/forkop/sing-box-variant";
|
||||
const SING_BOX_BIN_PATH = getenv("FORKOP_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";
|
||||
@@ -975,7 +976,9 @@ function sing_box_component_action_running() {
|
||||
}
|
||||
|
||||
function sing_box_live_probe_disabled() {
|
||||
return sing_box_marker_is("extended-compressed") || sing_box_component_action_running();
|
||||
return sing_box_marker_is("extended") ||
|
||||
sing_box_marker_is("extended-compressed") ||
|
||||
sing_box_component_action_running();
|
||||
}
|
||||
|
||||
function sing_box_tiny_package_installed() {
|
||||
@@ -992,7 +995,7 @@ function sing_box_capability_flags(sing_box_version, sing_box_version_output) {
|
||||
module_success(SINGBOX_RUNTIME_UC, [ "is-extended", sing_box_version ]))
|
||||
extended = 1;
|
||||
|
||||
if (extended == 0 && (sing_box_tiny_package_installed() || sing_box_marker_is("tiny")))
|
||||
if (extended == 0 && (sing_box_marker_is("tiny") || sing_box_tiny_package_installed()))
|
||||
tiny = 1;
|
||||
|
||||
if (extended == 1)
|
||||
@@ -1102,6 +1105,15 @@ function get_system_info() {
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -346,6 +346,7 @@ function mask_option_path(line, token) {
|
||||
|
||||
function forkop_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");
|
||||
@@ -631,19 +632,6 @@ function str_remove_suffix(value, suffix) {
|
||||
return str_endswith(value, suffix) ? substr(value, 0, length(value) - length(suffix)) : value;
|
||||
}
|
||||
|
||||
function str_last_index(value, needle) {
|
||||
value = as_string(value);
|
||||
needle = as_string(needle);
|
||||
if (needle == "")
|
||||
return length(value);
|
||||
|
||||
for (let i = length(value) - length(needle); i >= 0; i--)
|
||||
if (substr(value, i, length(needle)) == needle)
|
||||
return i;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function contains(values, needle) {
|
||||
needle = as_string(needle);
|
||||
for (let value in array_or_empty(values))
|
||||
@@ -659,7 +647,7 @@ function netstat_fields(line) {
|
||||
|
||||
function netstat_addr_port(addr) {
|
||||
addr = as_string(addr);
|
||||
let colon = str_last_index(addr, ":");
|
||||
let colon = rindex(addr, ":");
|
||||
return colon >= 0 ? substr(addr, colon + 1) : addr;
|
||||
}
|
||||
|
||||
@@ -669,7 +657,7 @@ function netstat_addr_host(addr) {
|
||||
let end = index(addr, "]");
|
||||
return end > 0 ? substr(addr, 1, end - 1) : addr;
|
||||
}
|
||||
let colon = str_last_index(addr, ":");
|
||||
let colon = rindex(addr, ":");
|
||||
return colon >= 0 ? substr(addr, 0, colon) : addr;
|
||||
}
|
||||
|
||||
|
||||
@@ -945,22 +945,6 @@ function nft_invalid(invalid, value, message) {
|
||||
push(invalid, as_string(value) + "\t" + message);
|
||||
}
|
||||
|
||||
function str_last_index(value, needle) {
|
||||
value = as_string(value);
|
||||
needle = as_string(needle);
|
||||
let result = -1;
|
||||
let start = 0;
|
||||
|
||||
while (true) {
|
||||
let offset = index(substr(value, start), needle);
|
||||
if (offset < 0)
|
||||
return result;
|
||||
|
||||
result = start + offset;
|
||||
start = result + length(needle);
|
||||
}
|
||||
}
|
||||
|
||||
function nft_trimmed_lines(path) {
|
||||
let data = fs.readfile(path);
|
||||
if (data == null)
|
||||
@@ -1014,7 +998,7 @@ function nft_build_chunks_from_values(values, kind, ports_csv, chunk_size_text,
|
||||
|
||||
if (kind == "ip-ports") {
|
||||
let separator = index(line, " . ");
|
||||
let last_separator = str_last_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;
|
||||
|
||||
@@ -24,32 +24,12 @@ function trim_left_space(value) {
|
||||
return substr(value, i);
|
||||
}
|
||||
|
||||
function last_index(value, needle) {
|
||||
value = as_string(value);
|
||||
needle = as_string(needle);
|
||||
let result = -1;
|
||||
let offset = 0;
|
||||
|
||||
if (needle == "")
|
||||
return -1;
|
||||
|
||||
while (offset <= length(value)) {
|
||||
let found = index(substr(value, offset), needle);
|
||||
if (found < 0)
|
||||
break;
|
||||
result = offset + found;
|
||||
offset = result + length(needle);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function strip_binary_prefix(line, binary) {
|
||||
line = as_string(line);
|
||||
binary = as_string(binary || "nfqws");
|
||||
|
||||
let path_marker = "/" + binary + ":";
|
||||
let path_index = last_index(line, path_marker);
|
||||
let path_index = rindex(line, path_marker);
|
||||
if (path_index >= 0)
|
||||
return trim_left_space(substr(line, path_index + length(path_marker)));
|
||||
|
||||
@@ -111,7 +91,7 @@ function validation_summary(binary) {
|
||||
}
|
||||
|
||||
function value_after_last_colon(summary) {
|
||||
let colon = last_index(summary, ":");
|
||||
let colon = rindex(summary, ":");
|
||||
if (colon < 0)
|
||||
return "";
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const SING_BOX_INIT = env("FORKOP_SING_BOX_INIT", "/etc/init.d/sing-box");
|
||||
const SING_BOX_BIN = env("FORKOP_SING_BOX_BIN", "/usr/bin/sing-box");
|
||||
const SING_BOX_CRONET = env("FORKOP_SING_BOX_CRONET", "/usr/lib/libcronet.so");
|
||||
const SING_BOX_MANAGED_MARKER = env("SB_MANAGED_SERVICE_MARKER", "Forkop managed sing-box service for binary variants");
|
||||
const PACKAGE_UPGRADE_STATE = env("FORKOP_PACKAGE_UPGRADE_STATE", "/tmp/forkop-package-was-running");
|
||||
const PACKAGE_TEST_MODE = env("FORKOP_PACKAGE_TEST_MODE", "") != "";
|
||||
|
||||
function shell_quote(value) {
|
||||
@@ -108,10 +109,21 @@ function remove_managed_sing_box() {
|
||||
unlink_if_exists(SING_BOX_CRONET);
|
||||
}
|
||||
|
||||
function prerm_cleanup() {
|
||||
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();
|
||||
@@ -120,6 +132,17 @@ function prerm_cleanup() {
|
||||
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("FORKOP_LUCI_CACHE_GLOBS", "");
|
||||
if (configured != "")
|
||||
@@ -152,12 +175,14 @@ function luci_postinst() {
|
||||
let mode = ARGV[0] || "";
|
||||
|
||||
if (mode == "prerm")
|
||||
exit(prerm_cleanup() ? 0 : 1);
|
||||
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 <prerm|remove-rt-tables-entry|luci-postinst>\n");
|
||||
warn("Usage: service/package.uc <prerm|postinst|remove-rt-tables-entry|luci-postinst>\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ function now_seconds() {
|
||||
}
|
||||
|
||||
function ensure_dir(path) {
|
||||
command_success_from_args([ "mkdir", "-p", path ]);
|
||||
return fs.mkdir(path, 0755) || fs.stat(path) != null;
|
||||
}
|
||||
|
||||
function ensure_dirs() {
|
||||
@@ -737,23 +737,26 @@ function state_file_ack_expired(path) {
|
||||
|
||||
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 && state_file_ack_expired(path))
|
||||
remove_state_file(path);
|
||||
}
|
||||
|
||||
let old = command_output_from_args([ "find", dir, "-type", "f", "-name", "*.json", "-mmin", "+" + as_string(ACTION_FINISHED_TTL_MINUTES) ]);
|
||||
for (let path in split(old, "\n")) {
|
||||
path = trim(as_string(path));
|
||||
if (path == "")
|
||||
if (value.running !== false)
|
||||
continue;
|
||||
let value = read_json_file(path);
|
||||
if (type(value) == "object" && value.running === false)
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1041,7 +1044,7 @@ function capability_flags() {
|
||||
result.sing_box_extended = 1;
|
||||
result.sing_box_tailscale = 1;
|
||||
}
|
||||
else if (tiny_package_installed() || marker_is("tiny")) {
|
||||
else if (marker_is("tiny") || tiny_package_installed()) {
|
||||
result.sing_box_tiny = 1;
|
||||
}
|
||||
else if (component_action_running_for("sing_box")) {
|
||||
@@ -1076,7 +1079,7 @@ function current_ui_state_json() {
|
||||
let capabilities = capability_flags();
|
||||
let forkop_is_running = forkop_running() ? 1 : 0;
|
||||
let forkop_is_enabled = service_enabled() ? 1 : 0;
|
||||
let sing_box_is_running = sing_box_running() ? 1 : 0;
|
||||
let sing_box_is_running = forkop_is_running ? 1 : (sing_box_running() ? 1 : 0);
|
||||
let sing_box_is_enabled = sing_box_enabled() ? 1 : 0;
|
||||
let forkop_status = service_status_text(forkop_is_running, forkop_is_enabled);
|
||||
let sing_box_status = service_status_text(sing_box_is_running, sing_box_is_enabled);
|
||||
|
||||
@@ -54,19 +54,6 @@ function community_url(name) {
|
||||
return SRS_MAIN_URL + "/" + name + ".srs";
|
||||
}
|
||||
|
||||
function str_last_index(value, needle) {
|
||||
value = as_string(value);
|
||||
needle = as_string(needle);
|
||||
if (needle == "")
|
||||
return length(value);
|
||||
|
||||
for (let i = length(value) - length(needle); i >= 0; i--)
|
||||
if (substr(value, i, length(needle)) == needle)
|
||||
return i;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function hash12(value) {
|
||||
value = as_string(value);
|
||||
let first = 2166136261;
|
||||
@@ -83,7 +70,7 @@ function hash12(value) {
|
||||
|
||||
function file_extension(value) {
|
||||
let basename = as_string(value);
|
||||
let slash = str_last_index(basename, "/");
|
||||
let slash = rindex(basename, "/");
|
||||
if (slash >= 0)
|
||||
basename = substr(basename, slash + 1);
|
||||
|
||||
@@ -95,7 +82,7 @@ function file_extension(value) {
|
||||
if (fragment >= 0)
|
||||
basename = substr(basename, 0, fragment);
|
||||
|
||||
let dot = str_last_index(basename, ".");
|
||||
let dot = rindex(basename, ".");
|
||||
return dot >= 0 ? lc(substr(basename, dot + 1)) : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -650,10 +650,6 @@ function ensure_runtime_cache_format() {
|
||||
}
|
||||
}
|
||||
|
||||
function remove_legacy_server_country_cache() {
|
||||
unlink_path(FORKOP_RUNTIME_STATE_DIR + "/server-country-cache.json");
|
||||
}
|
||||
|
||||
function temp_path(dir, section, kind) {
|
||||
ensure_dir(dir);
|
||||
let stamp = clock();
|
||||
@@ -661,7 +657,6 @@ function temp_path(dir, section, kind) {
|
||||
}
|
||||
|
||||
function move_file(source, target) {
|
||||
unlink_path(target);
|
||||
return fs.rename(as_string(source), as_string(target));
|
||||
}
|
||||
|
||||
@@ -2482,9 +2477,6 @@ else if (mode == "clear-runtime-cache") {
|
||||
else if (mode == "ensure-runtime-cache-format") {
|
||||
ensure_runtime_cache_format();
|
||||
}
|
||||
else if (mode == "remove-legacy-server-country-cache") {
|
||||
remove_legacy_server_country_cache();
|
||||
}
|
||||
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]);
|
||||
}
|
||||
|
||||
+6
-27
@@ -1676,36 +1676,12 @@ numbered_yes_no_prompt() {
|
||||
done
|
||||
}
|
||||
|
||||
confirm_prompt() {
|
||||
prompt_text="$1"
|
||||
numbered_yes_no_prompt "$prompt_text"
|
||||
}
|
||||
|
||||
get_luci_main_lang() {
|
||||
command_exists ucode || return 0
|
||||
ucode -e 'require("fs"); require("uci");' >/dev/null 2>&1 || return 0
|
||||
install_json_ucode uci-get luci.main.lang 2>/dev/null || true
|
||||
}
|
||||
|
||||
extract_package_version() {
|
||||
package_name="$1"
|
||||
|
||||
case "$package_name" in
|
||||
forkop_*.ipk|forkop_*.apk)
|
||||
printf '%s\n' "$package_name" | sed 's/^forkop_//;s/\.ipk$//;s/\.apk$//'
|
||||
;;
|
||||
luci-app-forkop_*.ipk|luci-app-forkop_*.apk)
|
||||
printf '%s\n' "$package_name" | sed 's/^luci-app-forkop_//;s/\.ipk$//;s/\.apk$//'
|
||||
;;
|
||||
luci-i18n-forkop-ru_*.ipk|luci-i18n-forkop-ru_*.apk)
|
||||
printf '%s\n' "$package_name" | sed 's/^luci-i18n-forkop-ru_//;s/\.ipk$//;s/\.apk$//'
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$package_name"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
fetch_github_latest_release_json() {
|
||||
owner="$1"
|
||||
repo="$2"
|
||||
@@ -1747,7 +1723,7 @@ resolve_forkop_release() {
|
||||
|
||||
FORKOP_BACKEND_NAME="$(basename "$FORKOP_BACKEND_URL")"
|
||||
FORKOP_APP_NAME="$(basename "$FORKOP_APP_URL")"
|
||||
FORKOP_PACKAGE_VERSION="$(extract_package_version "$FORKOP_BACKEND_NAME")"
|
||||
FORKOP_PACKAGE_VERSION="$(printf '%s\n' "$FORKOP_BACKEND_NAME" | sed 's/^forkop_//;s/\.ipk$//;s/\.apk$//')"
|
||||
|
||||
FORKOP_I18N_URL=""
|
||||
FORKOP_I18N_NAME=""
|
||||
@@ -1899,7 +1875,7 @@ decide_i18n_installation() {
|
||||
;;
|
||||
esac
|
||||
|
||||
if confirm_prompt "$(installer_text i18n_prompt)"; then
|
||||
if numbered_yes_no_prompt "$(installer_text i18n_prompt)"; then
|
||||
FORKOP_I18N_REQUESTED=1
|
||||
INSTALLER_LANG="ru"
|
||||
return 0
|
||||
@@ -1965,7 +1941,10 @@ post_install() {
|
||||
}
|
||||
|
||||
main() {
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
trap cleanup EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
parse_args "$@"
|
||||
check_root
|
||||
|
||||
@@ -28,7 +28,7 @@ PKG_LICENSE:=GPL-2.0-or-later
|
||||
LUCI_LANGUAGES:=en ru
|
||||
|
||||
define Build/Prepare/$(PKG_NAME)
|
||||
sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(FORKOP_COMPILED_VERSION)/g' $(PKG_BUILD_DIR)/htdocs/luci-static/resources/view/forkop/main.js || true
|
||||
sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(FORKOP_COMPILED_VERSION)/g' $(PKG_BUILD_DIR)/htdocs/luci-static/resources/view/forkop/main.js
|
||||
endef
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"require view.forkop.updates as updates";
|
||||
|
||||
const UCI_PACKAGE = main.FORKOP_UCI_PACKAGE;
|
||||
const CBI_PREFIX = UCI_PACKAGE;
|
||||
|
||||
function renderSectionAdd(sectionRef, extra_class) {
|
||||
const el = form.GridSection.prototype.renderSectionAdd.apply(sectionRef, [
|
||||
|
||||
@@ -21,18 +21,6 @@ function isIPv6(ip) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function validateIPV4(ip) {
|
||||
if (isIPv4(ip)) {
|
||||
return { valid: true, message: _("Valid") };
|
||||
}
|
||||
return { valid: false, message: _("Invalid IP address") };
|
||||
}
|
||||
function validateIPv6(ip) {
|
||||
if (isIPv6(ip)) {
|
||||
return { valid: true, message: _("Valid") };
|
||||
}
|
||||
return { valid: false, message: _("Invalid IP address") };
|
||||
}
|
||||
function validateIP(ip) {
|
||||
if (isIPv4(ip) || isIPv6(ip)) {
|
||||
return { valid: true, message: _("Valid") };
|
||||
@@ -104,6 +92,11 @@ 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;
|
||||
@@ -113,20 +106,22 @@ function parseHostPort(value) {
|
||||
if (end <= 1 || value.slice(end + 1, end + 2) !== ":") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
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;
|
||||
}
|
||||
return {
|
||||
const parsed = {
|
||||
host: value.slice(0, firstColon),
|
||||
port: value.slice(firstColon + 1)
|
||||
};
|
||||
return isValidHost(parsed.host) ? parsed : null;
|
||||
}
|
||||
|
||||
// src/validators/validateDns.ts
|
||||
@@ -138,6 +133,9 @@ function validateDNS(value) {
|
||||
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") };
|
||||
}
|
||||
@@ -166,8 +164,7 @@ function validateUrl(url, protocols = ["http:", "https:"]) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname.startsWith("[") ? parsed.hostname.slice(1, -1) : parsed.hostname;
|
||||
const portNum = parsed.port ? Number(parsed.port) : 0;
|
||||
if ((isValidHost(host) || host === "localhost") && (!parsed.port || Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535)) {
|
||||
if ((isValidHost(host) || host === "localhost") && (!parsed.port || isValidPort(parsed.port))) {
|
||||
return { valid: true, message: _("Valid") };
|
||||
}
|
||||
} catch (_e) {
|
||||
@@ -396,8 +393,7 @@ function validateShadowsocksUrl(url) {
|
||||
message: _("Invalid Shadowsocks URL: missing port")
|
||||
};
|
||||
}
|
||||
const portNum = parseInt(port, 10);
|
||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
||||
if (!isValidPort(port)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _("Invalid port number. Must be between 1 and 65535")
|
||||
@@ -467,8 +463,7 @@ function validateVlessUrl(url) {
|
||||
if (!port)
|
||||
return { valid: false, message: "Invalid VLESS URL: missing port" };
|
||||
const cleanedPort = port.replace("/", "");
|
||||
const portNum = Number(cleanedPort);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
|
||||
if (!isValidPort(cleanedPort))
|
||||
return {
|
||||
valid: false,
|
||||
message: "Invalid VLESS URL: invalid port number"
|
||||
@@ -566,11 +561,10 @@ function validateVmessUrl(url) {
|
||||
return { valid: false, message: "Invalid VMess URL: invalid config" };
|
||||
}
|
||||
const { add, port, id } = config;
|
||||
if (!add || typeof add !== "string") {
|
||||
return { valid: false, message: "Invalid VMess URL: missing server" };
|
||||
if (!add || typeof add !== "string" || !isValidHost(add)) {
|
||||
return { valid: false, message: "Invalid VMess URL: invalid server" };
|
||||
}
|
||||
const portNum = Number(port);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||
if (!isValidPort(port)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "Invalid VMess URL: invalid port number"
|
||||
@@ -627,8 +621,7 @@ function validateTrojanUrl(url) {
|
||||
return { valid: false, message: "Invalid Trojan URL: missing hostname" };
|
||||
if (!port)
|
||||
return { valid: false, message: "Invalid Trojan URL: missing port" };
|
||||
const portNum = Number(port);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
|
||||
if (!isValidPort(port))
|
||||
return {
|
||||
valid: false,
|
||||
message: "Invalid Trojan URL: invalid port number"
|
||||
@@ -691,19 +684,12 @@ function validateSocksUrl(url) {
|
||||
if (!port) {
|
||||
return { valid: false, message: _("Invalid SOCKS URL: missing port") };
|
||||
}
|
||||
const portNum = Number(port);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||
if (!isValidPort(port)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _("Invalid SOCKS URL: invalid port number")
|
||||
};
|
||||
}
|
||||
if (!isValidHost(host)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _("Invalid SOCKS URL: invalid host format")
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
return { valid: false, message: _("Invalid SOCKS URL: parsing failed") };
|
||||
}
|
||||
@@ -760,26 +746,19 @@ function validateHysteria2Url(url) {
|
||||
}
|
||||
const cleanedPort = port.replace("/", "");
|
||||
const portEntries = cleanedPort.split(",");
|
||||
const isValidPortNumber = (value) => {
|
||||
if (!/^\d+$/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const portNum = Number(value);
|
||||
return Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535;
|
||||
};
|
||||
const isValidPortEntry = (entry) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (!entry.includes("-")) {
|
||||
return isValidPortNumber(entry);
|
||||
return isValidPort(entry);
|
||||
}
|
||||
const rangeParts = entry.split("-");
|
||||
if (rangeParts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
const [start, end] = rangeParts;
|
||||
return isValidPortNumber(start) && isValidPortNumber(end) && Number(start) <= Number(end);
|
||||
return isValidPort(start) && isValidPort(end) && Number(start) <= Number(end);
|
||||
};
|
||||
if (!portEntries.every(isValidPortEntry)) {
|
||||
return {
|
||||
@@ -894,19 +873,12 @@ function validateHttpProxyUrl(url) {
|
||||
message: _("Invalid HTTP proxy URL: missing port")
|
||||
};
|
||||
}
|
||||
const portNum = Number(port);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||
if (!isValidPort(port)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _("Invalid HTTP proxy URL: invalid port number")
|
||||
};
|
||||
}
|
||||
if (!isValidHost(host)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _("Invalid HTTP proxy URL: invalid host format")
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
return {
|
||||
valid: false,
|
||||
@@ -2164,7 +2136,7 @@ function renderDefaultState({
|
||||
"aria-label": _("URLTest details"),
|
||||
click: (event) => {
|
||||
event.stopPropagation();
|
||||
onShowUrlTestInfo(section, outbound);
|
||||
onShowUrlTestInfo(outbound);
|
||||
}
|
||||
},
|
||||
renderInfoIcon24()
|
||||
@@ -2180,7 +2152,7 @@ function renderDefaultState({
|
||||
"aria-label": _("Priority details"),
|
||||
click: (event) => {
|
||||
event.stopPropagation();
|
||||
onShowPriorityInfo(section, outbound);
|
||||
onShowPriorityInfo(outbound);
|
||||
}
|
||||
},
|
||||
renderInfoIcon24()
|
||||
@@ -3190,9 +3162,6 @@ var DASHBOARD_SECTION_CACHE_DIR = "/var/run/forkop/section-cache";
|
||||
function getDisplayName(section) {
|
||||
return section.label || section[".name"];
|
||||
}
|
||||
function getSectionAction(section) {
|
||||
return section.action || "";
|
||||
}
|
||||
function getSettingsSection(configSections) {
|
||||
return configSections.find((section) => section[".type"] === "settings");
|
||||
}
|
||||
@@ -3365,7 +3334,9 @@ function getJsonOutbounds(section) {
|
||||
return values.length ? values : getListValues(section.outbound_json);
|
||||
}
|
||||
function isConnectionAction(action) {
|
||||
return ["connection", "proxy", "outbound", "vpn"].includes(action);
|
||||
return Boolean(
|
||||
action && ["connection", "proxy", "outbound", "vpn"].includes(action)
|
||||
);
|
||||
}
|
||||
function hasSubscriptionSources(section) {
|
||||
return getSubscriptionSourceCount(section) > 0;
|
||||
@@ -4049,11 +4020,11 @@ async function getDashboardSections(options = {}) {
|
||||
);
|
||||
const data = await Promise.all(
|
||||
configSections.filter(
|
||||
(section) => section.enabled !== "0" && isConnectionAction(getSectionAction(section))
|
||||
(section) => section.enabled !== "0" && isConnectionAction(section.action)
|
||||
).map(async (section) => {
|
||||
const displayName = getDisplayName(section);
|
||||
const sectionName = section[".name"];
|
||||
const sectionAction = getSectionAction(section);
|
||||
const sectionAction = section.action;
|
||||
const proxyConfigType = getSectionProxyConfigType(section);
|
||||
if (isConnectionAction(sectionAction) && shouldUseProxyGroup(section)) {
|
||||
const subscriptionSourceCount = getSubscriptionSourceCount(section);
|
||||
@@ -5293,8 +5264,8 @@ var SocketManager = class _SocketManager {
|
||||
}
|
||||
this.sockets.set(url, ws);
|
||||
this.connected.set(url, false);
|
||||
this.listeners.set(url, /* @__PURE__ */ new Set());
|
||||
this.errorListeners.set(url, /* @__PURE__ */ new Set());
|
||||
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);
|
||||
@@ -6202,7 +6173,7 @@ function renderUrlTestCopyButton(title, onClick) {
|
||||
renderCopyIcon24()
|
||||
);
|
||||
}
|
||||
function renderUrlTestInfoModal(section, outbound) {
|
||||
function renderUrlTestInfoModal(outbound) {
|
||||
const info = outbound.urlTestInfo;
|
||||
if (!info) {
|
||||
return E("div", {}, _("URLTest details are unavailable"));
|
||||
@@ -6318,13 +6289,13 @@ function renderUrlTestInfoModal(section, outbound) {
|
||||
])
|
||||
]);
|
||||
}
|
||||
function handleShowUrlTestInfo(section, outbound) {
|
||||
function handleShowUrlTestInfo(outbound) {
|
||||
if (!outbound.urlTestInfo) {
|
||||
return;
|
||||
}
|
||||
ui.showModal(
|
||||
`${_("URLTest details")}: ${outbound.urlTestInfo.displayName || outbound.displayName}`,
|
||||
renderUrlTestInfoModal(section, outbound)
|
||||
renderUrlTestInfoModal(outbound)
|
||||
);
|
||||
}
|
||||
function renderPrioritySelectedValue(info) {
|
||||
@@ -6385,7 +6356,7 @@ function renderPriorityMemberName(member) {
|
||||
)
|
||||
];
|
||||
}
|
||||
function renderPriorityInfoModal(section, outbound) {
|
||||
function renderPriorityInfoModal(outbound) {
|
||||
const info = outbound.priorityInfo;
|
||||
if (!info) {
|
||||
return E("div", {}, _("Priority details are unavailable"));
|
||||
@@ -6527,13 +6498,13 @@ function renderPriorityInfoModal(section, outbound) {
|
||||
])
|
||||
]);
|
||||
}
|
||||
function handleShowPriorityInfo(section, outbound) {
|
||||
function handleShowPriorityInfo(outbound) {
|
||||
if (!outbound.priorityInfo) {
|
||||
return;
|
||||
}
|
||||
ui.showModal(
|
||||
`${_("Priority details")}: ${outbound.priorityInfo.displayName || outbound.displayName}`,
|
||||
renderPriorityInfoModal(section, outbound)
|
||||
renderPriorityInfoModal(outbound)
|
||||
);
|
||||
}
|
||||
async function handleUpdateSubscription(section) {
|
||||
@@ -6702,11 +6673,11 @@ async function renderSectionsWidget() {
|
||||
onCopyOutbound: (_section, outbound) => {
|
||||
handleCopyOutbound(outbound);
|
||||
},
|
||||
onShowUrlTestInfo: (section2, outbound) => {
|
||||
handleShowUrlTestInfo(section2, outbound);
|
||||
onShowUrlTestInfo: (outbound) => {
|
||||
handleShowUrlTestInfo(outbound);
|
||||
},
|
||||
onShowPriorityInfo: (section2, outbound) => {
|
||||
handleShowPriorityInfo(section2, outbound);
|
||||
onShowPriorityInfo: (outbound) => {
|
||||
handleShowPriorityInfo(outbound);
|
||||
},
|
||||
onUpdateSubscription: (section2) => {
|
||||
void handleUpdateSubscription(section2);
|
||||
@@ -9592,6 +9563,7 @@ var SING_BOX_MASKED_KEYS = /* @__PURE__ */ new Set([
|
||||
]);
|
||||
var FORKOP_MASK_AFTER_TOKEN = [
|
||||
"option proxy_string",
|
||||
"option hwid",
|
||||
"option subscription_url",
|
||||
"list subscription_urls",
|
||||
"list urltest_proxy_links",
|
||||
@@ -14249,8 +14221,6 @@ return baseclass.extend({
|
||||
validateDNS,
|
||||
validateDomain,
|
||||
validateIP,
|
||||
validateIPV4,
|
||||
validateIPv6,
|
||||
validateOutboundJson,
|
||||
validatePath,
|
||||
validateProxyUrl,
|
||||
|
||||
@@ -4095,20 +4095,6 @@ function populateActionOptionValues(option) {
|
||||
}
|
||||
}
|
||||
|
||||
function setFlagOptionWidgetValue(section_id, optionName, enabled) {
|
||||
const frame = document.getElementById(
|
||||
`cbid.${UCI_PACKAGE}.${section_id}.${optionName}`,
|
||||
);
|
||||
const checkbox = frame ? frame.querySelector('input[type="checkbox"]') : null;
|
||||
|
||||
if (!checkbox || checkbox.checked === Boolean(enabled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkbox.checked = Boolean(enabled);
|
||||
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
function getConfigListValues(section_id, key) {
|
||||
return normalizeOptionValues(uci.get(UCI_PACKAGE, section_id, key));
|
||||
}
|
||||
@@ -4398,21 +4384,7 @@ function validateKeyword(_section_id, value) {
|
||||
}
|
||||
|
||||
function isSingBoxDuration(value) {
|
||||
return /^([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value);
|
||||
}
|
||||
|
||||
function readDurationOptionWithDefault(section_id, key, defaultValue) {
|
||||
if (uci.get(UCI_PACKAGE, section_id, `${key}_disabled`) === "1") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const rawValue = uci.get(UCI_PACKAGE, section_id, key);
|
||||
|
||||
if (rawValue == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return `${rawValue}`;
|
||||
return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value);
|
||||
}
|
||||
|
||||
function writeOptionalDurationOption(section_id, key, value) {
|
||||
@@ -4428,10 +4400,6 @@ function writeOptionalDurationOption(section_id, key, value) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeOptionalDurationOption(section_id, key) {
|
||||
writeOptionalDurationOption(section_id, key, "");
|
||||
}
|
||||
|
||||
function validateOptionalSingBoxDuration(value) {
|
||||
const normalized = value ? `${value}`.trim() : "";
|
||||
|
||||
@@ -4491,39 +4459,6 @@ function validateSubscriptionUrlEntry(_section_id, value) {
|
||||
return validation.valid ? true : validation.message;
|
||||
}
|
||||
|
||||
function parseRequiredValueOnSave(section_id) {
|
||||
const active = this.isActive(section_id);
|
||||
|
||||
if (active && !this.isValid(section_id)) {
|
||||
const title = this.stripTags(this.title).trim();
|
||||
const error = this.getValidationError(section_id);
|
||||
return Promise.reject(
|
||||
new TypeError(
|
||||
`${_('Option "%s" contains an invalid input value.').format(title || this.option)} ${error}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (active) {
|
||||
const formValue = this.formvalue(section_id);
|
||||
const normalized = formValue ? `${formValue}`.trim() : "";
|
||||
|
||||
if (!normalized.length) {
|
||||
return Promise.reject(
|
||||
new TypeError(_("Subscription URL cannot be empty")),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(this.write(section_id, normalized));
|
||||
}
|
||||
|
||||
if (!this.retain) {
|
||||
return Promise.resolve(this.remove(section_id));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function getDuplicateTextListErrors(values, normalizeValue, duplicateMessage) {
|
||||
const seen = new Set();
|
||||
const duplicates = [];
|
||||
@@ -4544,57 +4479,6 @@ function getDuplicateTextListErrors(values, normalizeValue, duplicateMessage) {
|
||||
return duplicates.map((item) => `${item}: ${duplicateMessage}`);
|
||||
}
|
||||
|
||||
function validateTextList(
|
||||
_section_id,
|
||||
value,
|
||||
validateItem,
|
||||
emptyMessage,
|
||||
options = {},
|
||||
) {
|
||||
if (!value || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const values = main.parseValueList(value);
|
||||
|
||||
if (!values.length) {
|
||||
return emptyMessage;
|
||||
}
|
||||
|
||||
if (!validateItem) {
|
||||
const duplicateErrors = getDuplicateTextListErrors(
|
||||
values,
|
||||
options.normalizeDuplicateValue,
|
||||
options.duplicateMessage || _("Duplicate value"),
|
||||
);
|
||||
|
||||
if (!duplicateErrors.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [_("Validation errors:"), ...duplicateErrors].join("\n");
|
||||
}
|
||||
|
||||
const { valid, results } = main.bulkValidate(values, validateItem);
|
||||
|
||||
const duplicateErrors = getDuplicateTextListErrors(
|
||||
values,
|
||||
options.normalizeDuplicateValue,
|
||||
options.duplicateMessage || _("Duplicate value"),
|
||||
);
|
||||
|
||||
if (valid && !duplicateErrors.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const errors = results
|
||||
.filter((item) => !item.valid)
|
||||
.map((item) => `${item.value}: ${item.message}`)
|
||||
.concat(duplicateErrors);
|
||||
|
||||
return [_("Validation errors:"), ...errors].join("\n");
|
||||
}
|
||||
|
||||
function getValidationHeaderText() {
|
||||
return _("Validation errors:");
|
||||
}
|
||||
|
||||
@@ -496,10 +496,6 @@ function getFirstListValue(sectionId, option) {
|
||||
return asList(uci.get(UCI_PACKAGE, sectionId, option))[0] || "";
|
||||
}
|
||||
|
||||
function serverInboundTag(sectionId) {
|
||||
return `server-${sectionId}-in`;
|
||||
}
|
||||
|
||||
function getProtocol(sectionId) {
|
||||
return uci.get(UCI_PACKAGE, sectionId, "protocol") || DEFAULT_SERVER_PROTOCOL;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
const UCI_PACKAGE = main.FORKOP_UCI_PACKAGE;
|
||||
|
||||
function isSingBoxDuration(value) {
|
||||
return /^([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value);
|
||||
return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value);
|
||||
}
|
||||
|
||||
function latencyTestUrlChoices() {
|
||||
|
||||
@@ -48,10 +48,11 @@ invalid_json="$(ucode -- "$VALIDATOR" validate-json '--port 1080 --disorder 3')"
|
||||
assert_json_field "$invalid_json" valid false
|
||||
assert_json_field "$invalid_json" needle --port
|
||||
|
||||
if ucode -- "$VALIDATOR" validate '--transparent' >/tmp/byedpi-validator.out 2>/dev/null; then
|
||||
validator_output="$WORK_DIR/byedpi-validator.out"
|
||||
if ucode -- "$VALIDATOR" validate '--transparent' >"$validator_output" 2>/dev/null; then
|
||||
fail "controlled transparent mode should be rejected"
|
||||
fi
|
||||
grep -q 'Transparent proxy mode is incompatible' /tmp/byedpi-validator.out ||
|
||||
grep -q 'Transparent proxy mode is incompatible' "$validator_output" ||
|
||||
fail "reject message should explain transparent mode"
|
||||
|
||||
if ucode -- "$VALIDATOR" validate '--disorder --fake example.org' >/dev/null 2>&1; then
|
||||
|
||||
@@ -61,6 +61,34 @@ grep -Fq '"forkop-stably-running", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MAR
|
||||
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
|
||||
)"
|
||||
JSON_VALUE="$capabilities" node - <<'NODE'
|
||||
const value = JSON.parse(process.env.JSON_VALUE);
|
||||
if (value.sing_box_extended !== 0 || value.sing_box_tiny !== 0 || value.sing_box_tailscale !== 0) {
|
||||
console.error('missing sing-box must not expose stale capabilities');
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
|
||||
masked_config="$WORK_DIR/forkop-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")"
|
||||
case "$masked_output" in
|
||||
*device-secret*|*vless://secret*) fail "masked Forkop config leaked a secret" ;;
|
||||
esac
|
||||
case "$masked_output" in
|
||||
*"option hwid 'MASKED'"*) ;;
|
||||
*) fail "masked Forkop config must preserve the HWID option shape" ;;
|
||||
esac
|
||||
|
||||
legacy_json="$(status_ucode service-status-json 1 0 ignored 1)"
|
||||
JSON_VALUE="$legacy_json" node - <<'NODE'
|
||||
const value = JSON.parse(process.env.JSON_VALUE);
|
||||
|
||||
@@ -32,6 +32,17 @@ fail() {
|
||||
|
||||
[ -r "$INSTALLER" ] || fail "install.sh is missing"
|
||||
|
||||
grep -Fq 'trap cleanup EXIT' "$INSTALLER" ||
|
||||
fail "installer cleanup must run on every exit"
|
||||
for signal_status in "HUP 129" "INT 130" "TERM 143"; do
|
||||
set -- $signal_status
|
||||
grep -Fq "trap 'exit $2' $1" "$INSTALLER" ||
|
||||
fail "installer must stop with status $2 on $1"
|
||||
done
|
||||
if grep -Fq 'trap cleanup EXIT HUP INT TERM' "$INSTALLER"; then
|
||||
fail "installer signal handlers must not resume installation after cleanup"
|
||||
fi
|
||||
|
||||
deadline_helper="$WORK_DIR/install-deadline.sh"
|
||||
awk '
|
||||
/cat > "\$deadline_helper_path" <<'\''EOF'\''/ { capture = 1; next }
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/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")"
|
||||
|
||||
DURATION_SOURCE="$source" node <<'NODE'
|
||||
const validate = Function(`${process.env.DURATION_SOURCE}; return isSingBoxDuration`)();
|
||||
|
||||
for (const value of ['1s', '0.5s', '2m30s']) {
|
||||
if (!validate(value)) throw new Error(`valid duration rejected: ${value}`);
|
||||
}
|
||||
for (const value of ['0s', '0h0m', '0.0s']) {
|
||||
if (validate(value)) throw new Error(`zero duration accepted: ${value}`);
|
||||
}
|
||||
NODE
|
||||
done
|
||||
|
||||
printf 'LuCI duration validation checks passed\n'
|
||||
@@ -46,6 +46,19 @@ require_file "$FORKOP_CONFIG"
|
||||
require_file "$BUILD_SCRIPT"
|
||||
require_file "$FORKOP_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 '^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
|
||||
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 'APK_INTERNAL_VERSION="$RELEASE_VERSION"' "$BUILD_SCRIPT" ||
|
||||
|
||||
@@ -9,6 +9,7 @@ FORKOP_MAKEFILE="$ROOT_DIR/forkop/Makefile"
|
||||
LUCI_UCI_DEFAULTS="$ROOT_DIR/luci-app-forkop/root/etc/uci-defaults/50_luci-forkop"
|
||||
BUILD_SCRIPT="$ROOT_DIR/build.sh"
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
export FORKOP_PACKAGE_UPGRADE_STATE="$WORK_DIR/package-was-running"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
@@ -27,8 +28,10 @@ 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", 0 ]' "$FORKOP_BIN" ||
|
||||
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 '#!/bin/sh' "$LUCI_UCI_DEFAULTS" ||
|
||||
@@ -46,6 +49,12 @@ 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" ||
|
||||
fail "manual APK pre-upgrade must record and stop the running service"
|
||||
grep -Fq '/usr/bin/forkop 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" ||
|
||||
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"' \
|
||||
@@ -143,4 +152,38 @@ FORKOP_RT_TABLES="$WORK_DIR/rt_tables_restore" \
|
||||
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:?}" ;;
|
||||
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" ] ||
|
||||
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
|
||||
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" ] ||
|
||||
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" ] ||
|
||||
fail "package pre-upgrade must not mark an already stopped service"
|
||||
|
||||
printf 'package lifecycle checks passed\n'
|
||||
|
||||
@@ -62,11 +62,12 @@ cat >"$WORK_DIR/different-array-order.json" <<'JSON'
|
||||
}
|
||||
JSON
|
||||
|
||||
if node "$COMPARE" "$WORK_DIR/expected.json" "$WORK_DIR/different-array-order.json" >/tmp/forkop-semantic-json.out 2>&1; then
|
||||
compare_output="$WORK_DIR/semantic-json.out"
|
||||
if node "$COMPARE" "$WORK_DIR/expected.json" "$WORK_DIR/different-array-order.json" >"$compare_output" 2>&1; then
|
||||
fail "array order mismatch should be rejected"
|
||||
fi
|
||||
|
||||
grep -Fq '$.outbounds[0].tag: "first" != "second"' /tmp/forkop-semantic-json.out ||
|
||||
grep -Fq '$.outbounds[0].tag: "first" != "second"' "$compare_output" ||
|
||||
fail "array order mismatch reported unexpected diff"
|
||||
|
||||
printf 'semantic JSON compare checks passed\n'
|
||||
|
||||
@@ -69,6 +69,14 @@ if grep -n -E 'require\("uci"\)\.cursor|uci -q' "$CACHE_UC" >/dev/null 2>&1; the
|
||||
fail "subscription/cache.uc must use core.uci instead of owning direct UCI cursor or CLI calls"
|
||||
fi
|
||||
|
||||
move_file_source="$(sed -n '/^function move_file(/,/^}/p' "$CACHE_UC")"
|
||||
if grep -Fq 'unlink_path(target)' <<<"$move_file_source"; then
|
||||
fail "subscription cache replacement must keep the current file until atomic rename succeeds"
|
||||
fi
|
||||
if grep -Fq 'remove-legacy-server-country-cache' "$CACHE_UC"; then
|
||||
fail "subscription/cache.uc must not expose the migrated legacy cache cleanup"
|
||||
fi
|
||||
|
||||
grep -q 'mode == "ensure-runtime-dirs"' "$CACHE_UC" ||
|
||||
fail "subscription/cache.uc must expose ensure-runtime-dirs owner mode"
|
||||
grep -q 'mode == "ensure-runtime-cache-format"' "$CACHE_UC" ||
|
||||
|
||||
@@ -63,6 +63,12 @@ grep -Fq '"forkop-stably-running"' "$UI_UC" ||
|
||||
fail "UI Forkop 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
|
||||
fail "UI state refresh must not spawn mkdir for existing directories"
|
||||
fi
|
||||
if sed -n '/^function cleanup_dir(/,/^}/p' "$UI_UC" | grep -Fq '"find"'; then
|
||||
fail "UI state refresh must clean action files without spawning find"
|
||||
fi
|
||||
grep -Fq 'run_pending_reload_after_service_action(action, success)' "$UI_UC" ||
|
||||
fail "UI service actions must run pending reload after the current action finishes"
|
||||
grep -Fq 'service_action_wait_for_expected_state(action, SERVICE_ACTION_TIMEOUT_SECONDS, SERVICE_ACTION_SETTLE_SECONDS)' "$UI_UC" ||
|
||||
@@ -207,6 +213,13 @@ ui_ucode cleanup-action-dir-fixture "$cleanup_dir"
|
||||
[ ! -e "$cleanup_dir/done.out.json" ] ||
|
||||
fail "expired acknowledged action JSON sidecar should be cleaned"
|
||||
|
||||
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"
|
||||
[ ! -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"
|
||||
|
||||
@@ -60,10 +60,11 @@ fi
|
||||
assert_json_field "$invalid_nfqws" valid false
|
||||
assert_json_field "$invalid_nfqws" needle --hostlist
|
||||
|
||||
if ucode -L "$FORKOP_LIB" -- "$VALIDATOR" validate nfqws '--qnum=200' >/tmp/zapret-validator.out 2>/dev/null; then
|
||||
validator_output="$WORK_DIR/zapret-validator.out"
|
||||
if ucode -L "$FORKOP_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' /tmp/zapret-validator.out ||
|
||||
grep -q 'NFQUEUE number is assigned by Forkop' "$validator_output" ||
|
||||
fail "qnum rejection should explain ownership"
|
||||
|
||||
valid_nfqws2="$(ucode -L "$FORKOP_LIB" -- "$ZAPRET2_VALIDATOR" validate-json nfqws2 '--name forkop --intercept=1')"
|
||||
|
||||
Reference in New Issue
Block a user