Добавлена сборка под Linux: nfqws + правила nftables (NFQUEUE) вместо socks-прокси или службы Windows. Повышение прав — pkexec на каждое включение/выключение, без службы и без автозапуска. Стратегии переиспользуют существующий набор Flowseal (strategies.win32.generated.ts): --wf-tcp/--wf-udp уходят в правила nftables, остальное — в nfqws как есть. Все правила nftables держат bypass, поэтому упавший или выключенный демон не рвёт пользователю сеть. Помощник (resources/linux-helper/rknboost-helper.sh) и бинарник nfqws перед каждым запуском копируются в userData: root не может прочитать файлы внутри AppImage (FUSE-примонтирован без allow_other), а обычная файловая система на диске ему доступна независимо от прав. Заодно: - починен баг генератора стратегий (^! не разэкранировался в !, ломало fake-tls-auto и на Windows тоже); - карточка обхода теперь показывает текст ошибки независимо от state; - счётчик auto-hostlist в настройках больше не привязан к !isWindows; - CI: отдельная джоба сборки под Linux на ubuntu-latest.
194 lines
8.8 KiB
JavaScript
194 lines
8.8 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Разовый (перезапускаемый) генератор: превращает `general*.bat` из
|
||
* Flowseal/zapret-discord-youtube в `src/main/zapret/strategies.win32.generated.ts`.
|
||
*
|
||
* Зачем отдельным скриптом, а не частью `fetch-zapret.mjs`: вывод коммитится в
|
||
* репозиторий — в рантайме и в сборке .bat-файлов быть не должно, только сгенерированный
|
||
* TypeScript. Запускать вручную при обновлении `FLOWSEAL_COMMIT` в `fetch-zapret.mjs`,
|
||
* проверяя результат через `git diff`, а не доверяя вслепую (формат батников может
|
||
* измениться — тогда парсер должен упасть, а не тихо сгенерировать мусор).
|
||
*
|
||
* Формат batch-файлов Flowseal строго регулярный (проверено на всех 22 вариантах):
|
||
* start "zapret: %~n0" /min "%BIN%winws.exe" --arg1 --arg2 ^
|
||
* --arg3 ^
|
||
* ...
|
||
* --argN
|
||
* Это одна команда, продолжающаяся через `^` в конце строки — не программа, а
|
||
* длинная командная строка winws. Переносим только сами аргументы.
|
||
*/
|
||
|
||
import { execFileSync } from 'node:child_process'
|
||
import { mkdtemp, readFile, rm, readdir, writeFile } from 'node:fs/promises'
|
||
import { tmpdir } from 'node:os'
|
||
import { dirname, join, resolve } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
// Держим в синхроне с `fetch-zapret.mjs` — версия данных должна совпадать.
|
||
const FLOWSEAL_COMMIT = 'dfd8e613b099676cf2aa7b474ee5923801514dec'
|
||
const FLOWSEAL_VERSION = '1.10.2'
|
||
|
||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||
const OUTPUT = join(ROOT, 'src', 'main', 'zapret', 'strategies.win32.generated.ts')
|
||
|
||
/** Списки Flowseal → наши короткие имена (совпадает с `FLOWSEAL_LISTS` в fetch-zapret.mjs). */
|
||
const LIST_FILES = {
|
||
'list-general.txt': 'general',
|
||
'list-google.txt': 'google',
|
||
'list-exclude.txt': 'exclude',
|
||
'ipset-exclude.txt': 'ipset-exclude',
|
||
'ipset-all.txt': 'ipset-all'
|
||
}
|
||
|
||
/** Пользовательские дубликаты списков — у нас все списки и так редактируемые, лишние. */
|
||
const DROP_LIST_SUFFIXES = ['-user.txt']
|
||
|
||
/** Игровой фильтр Flowseal по умолчанию выключен: `GameFilterTCP=12`, `GameFilterUDP=12`. */
|
||
const GAME_FILTER_DISABLED = '12'
|
||
|
||
function idFromFilename(filename) {
|
||
// "general.bat" → "general" / "Стандартная"; "general (ALT2).bat" → "alt2" / "ALT2"
|
||
const match = /^general(?: \((.+)\))?\.bat$/.exec(filename)
|
||
if (!match) throw new Error(`Неожиданное имя файла стратегии: ${filename}`)
|
||
const variant = match[1]
|
||
if (!variant) return { id: 'general', name: 'Стандартная' }
|
||
const id = variant.toLowerCase().replace(/\s+/g, '-')
|
||
return { id, name: variant }
|
||
}
|
||
|
||
/** Разбивает командную строку на токены, учитывая кавычки, без внешней библиотеки. */
|
||
function tokenize(command) {
|
||
const tokens = command.match(/(?:[^\s"]|"[^"]*")+/g) ?? []
|
||
return tokens.map((token) => token.replace(/^"(.*)"$/, '$1'))
|
||
}
|
||
|
||
function resolvePlaceholders(token) {
|
||
for (const [flowsealName, ourName] of Object.entries(LIST_FILES)) {
|
||
if (token.includes(`%LISTS%${flowsealName}`)) {
|
||
return token.replace(`%LISTS%${flowsealName}`, `{LISTS}/${ourName}.txt`)
|
||
}
|
||
}
|
||
const binMatch = /%BIN%([A-Za-z0-9_.]+\.bin)/.exec(token)
|
||
if (binMatch) return token.replace(binMatch[0], `{FAKES}/${binMatch[1]}`)
|
||
|
||
return token
|
||
.replaceAll('%GameFilterTCP%', GAME_FILTER_DISABLED)
|
||
.replaceAll('%GameFilterUDP%', GAME_FILTER_DISABLED)
|
||
}
|
||
|
||
function isDroppedUserListArg(token) {
|
||
return DROP_LIST_SUFFIXES.some((suffix) => token.includes(`%LISTS%`) && token.includes(suffix))
|
||
}
|
||
|
||
function parseBatFile(filename, content) {
|
||
const marker = 'winws.exe"'
|
||
const start = content.indexOf(marker)
|
||
if (start === -1) throw new Error(`${filename}: не нашёл запуск winws.exe`)
|
||
|
||
// Дальше до конца файла — одна командная строка, продолжающаяся через "^\n".
|
||
const rest = content.slice(start + marker.length)
|
||
const joined = rest.replace(/\^\r?\n/g, ' ')
|
||
|
||
const tokens = tokenize(joined)
|
||
.map((t) => t.trim())
|
||
.filter(Boolean)
|
||
.filter((t) => !isDroppedUserListArg(t))
|
||
// Кавычки в .bat защищали путь от разбора cmd.exe при запуске через `start`.
|
||
// Мы передаём argv напрямую в spawn (без шелла) — кавычки внутри значения
|
||
// остались бы буквальными символами в имени файла, поэтому убираем везде.
|
||
.map((t) => t.replaceAll('"', ''))
|
||
// `^!` — экранирование `!` в cmd.exe (в .bat включён enabledelayedexpansion, где `!`
|
||
// иначе разворачивался бы как переменная). cmd.exe снимает его сам перед запуском
|
||
// winws.exe; здесь командную строку разбираем руками, и `^` иначе остался бы буквально
|
||
// в значении. Реальное значение zapret ожидает — `!` («'!' = standard fake» в readme).
|
||
.map((t) => t.replaceAll('=^!', '=!'))
|
||
.map(resolvePlaceholders)
|
||
|
||
if (tokens.length < 5)
|
||
throw new Error(`${filename}: подозрительно мало аргументов (${tokens.length})`)
|
||
|
||
const { id, name } = idFromFilename(filename)
|
||
return { id, name, args: tokens }
|
||
}
|
||
|
||
async function main() {
|
||
const work = await mkdtemp(join(tmpdir(), 'flowseal-strategies-'))
|
||
|
||
try {
|
||
console.log(
|
||
`Flowseal ${FLOWSEAL_VERSION} (commit ${FLOWSEAL_COMMIT.slice(0, 12)}): скачиваю batch-файлы`
|
||
)
|
||
const archivePath = join(work, 'flowseal.tar.gz')
|
||
const response = await fetch(
|
||
`https://codeload.github.com/Flowseal/zapret-discord-youtube/tar.gz/${FLOWSEAL_COMMIT}`
|
||
)
|
||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
|
||
await writeFile(archivePath, Buffer.from(await response.arrayBuffer()))
|
||
|
||
const prefix = execFileSync('tar', ['-tzf', archivePath], { encoding: 'utf8' })
|
||
.split('\n')[0]
|
||
.split('/')[0]
|
||
execFileSync('tar', ['-xzf', archivePath, '-C', work], {
|
||
stdio: ['ignore', 'ignore', 'inherit']
|
||
})
|
||
|
||
const srcDir = join(work, prefix)
|
||
const batFiles = (await readdir(srcDir)).filter(
|
||
(name) => name.startsWith('general') && name.endsWith('.bat')
|
||
)
|
||
batFiles.sort()
|
||
|
||
if (batFiles.length === 0)
|
||
throw new Error('Не нашлось ни одного general*.bat — формат репозитория изменился')
|
||
|
||
const strategies = []
|
||
for (const filename of batFiles) {
|
||
const content = await readFile(join(srcDir, filename), 'utf8')
|
||
strategies.push(parseBatFile(filename, content))
|
||
console.log(
|
||
` ${filename} → ${strategies.at(-1).id} (${strategies.at(-1).args.length} аргументов)`
|
||
)
|
||
}
|
||
|
||
const ids = new Set(strategies.map((s) => s.id))
|
||
if (ids.size !== strategies.length)
|
||
throw new Error('Столкновение id — два файла дали одинаковый идентификатор')
|
||
|
||
const body = strategies
|
||
.map(
|
||
(s) =>
|
||
` {\n id: ${JSON.stringify(s.id)},\n name: ${JSON.stringify(s.name)},\n args: [\n${s.args
|
||
.map((a) => ` ${JSON.stringify(a)}`)
|
||
.join(',\n')}\n ]\n }`
|
||
)
|
||
.join(',\n')
|
||
|
||
const output = `/**
|
||
* СГЕНЕРИРОВАНО \`scripts/import-flowseal-strategies.mjs\` из
|
||
* Flowseal/zapret-discord-youtube ${FLOWSEAL_VERSION} (commit ${FLOWSEAL_COMMIT}).
|
||
* Не редактировать руками — перезапустить генератор и закоммитить результат.
|
||
*
|
||
* \`{LISTS}\` и \`{FAKES}\` — плейсхолдеры путей, резолвятся в \`strategies.ts\`
|
||
* через \`resolvePlaceholders()\` в фактические пути userData/resources.
|
||
*/
|
||
|
||
export interface GeneratedWin32Strategy {
|
||
id: string
|
||
name: string
|
||
args: string[]
|
||
}
|
||
|
||
export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
||
${body}
|
||
]
|
||
`
|
||
|
||
await writeFile(OUTPUT, output)
|
||
console.log(`Готово: ${OUTPUT} (${strategies.length} стратегий)`)
|
||
} finally {
|
||
await rm(work, { recursive: true, force: true })
|
||
}
|
||
}
|
||
|
||
await main()
|