Refactor runtime and harden package lifecycle

This commit is contained in:
ushan0v
2026-07-14 05:06:27 +03:00
parent 8094108286
commit 80d20cd8ef
72 changed files with 630 additions and 790 deletions
+1 -5
View File
@@ -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
+25 -20
View File
@@ -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"
}
+83 -79
View 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);
});
+62 -56
View File
@@ -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;
});
+1 -1
View File
@@ -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');
+2
View File
@@ -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;
}
-1
View File
@@ -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());
}
-5
View File
@@ -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('');
});
});
+2 -1
View File
@@ -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,
+1 -5
View File
@@ -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';
+10 -2
View File
@@ -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',
+5 -1
View File
@@ -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',
+2 -4
View File
@@ -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',
+28 -15
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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 ]
};
+2 -15
View File
@@ -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);
+1 -14
View File
@@ -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;
+2 -15
View File
@@ -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 -17
View File
@@ -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)) : "";
}
+14 -2
View File
@@ -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())
+3 -15
View File
@@ -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;
}
+1 -17
View File
@@ -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 "";
+28 -3
View File
@@ -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);
}
+15 -12
View File
@@ -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);
+2 -15
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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() {
+3 -2
View File
@@ -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
+28
View File
@@ -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);
+11
View File
@@ -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 }
+22
View File
@@ -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'
+13
View File
@@ -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" ||
+44 -1
View File
@@ -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'
+3 -2
View File
@@ -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'
+8
View File
@@ -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" ||
+13
View File
@@ -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"
+3 -2
View File
@@ -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')"