Initial public release of Podkop Plus

publish current Podkop Plus fork state as a single public snapshot

include release installer overhaul with zapret auto-install
This commit is contained in:
ushan0v
2026-04-09 23:24:33 +03:00
parent 0d01aa77d6
commit 1e50faa471
76 changed files with 13414 additions and 4126 deletions
+4 -4
View File
@@ -11,8 +11,8 @@ body:
Спасибо за создание отчета об ошибке!
Перед отправкой, пожалуйста:
- Проверьте [существующие issues](https://github.com/itdoginfo/podkop/issues)
- Просмотрите [документацию](https://podkop.net)
- Проверьте [существующие issues](https://github.com/ushan0v/podkop-plus/issues)
- Просмотрите [README проекта](https://github.com/ushan0v/podkop-plus#readme)
- type: textarea
id: description
@@ -53,7 +53,7 @@ body:
Информация о вашей системе (заполните всё применимое)
value: |
- **OpenWrt версия**:
- **Podkop версия**:
- **Podkop Plus версия**:
- **Роутер модель**:
- **Sing-box версия**:
render: markdown
@@ -71,4 +71,4 @@ body:
- Содержимое /etc/config/podkop
- Конфигурация sing-box (если релевантно)
- Дополнительные конфиги, которые потребуются wireless/network/dhcp и т.д.
render: shell
render: shell
+3 -3
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Если у вас что-то не работает, прежде всего прочитайте README проекта
url: https://github.com/itdoginfo/podkop
url: https://github.com/ushan0v/podkop-plus
about: README проекта
- name: 📚 Если вы не нашли в README документацию, то вот ссылка на неё
url: https://podkop.net
about: Официальная документация PodKop
url: https://github.com/ushan0v/podkop-plus#readme
about: README Podkop Plus
+4 -4
View File
@@ -1,6 +1,6 @@
---
name: ✨ Запрос новой функции
description: Предложите новую функцию или улучшение для Podkop
description: Предложите новую функцию или улучшение для Podkop Plus
title: "[FEATURE] "
labels: ["enhancement", "needs-discussion"]
assignees: []
@@ -11,14 +11,14 @@ body:
Спасибо за предложение новой функции!
Перед отправкой, пожалуйста:
- Проверьте [существующие запросы](https://github.com/itdoginfo/podkop/issues?q=is%3Aissue+label%3Aenhancement)
- Убедитесь, что функции не существует в [документации](https://podkop.net)
- Проверьте [существующие запросы](https://github.com/ushan0v/podkop-plus/issues?q=is%3Aissue+label%3Aenhancement)
- Убедитесь, что функции не существует в [README проекта](https://github.com/ushan0v/podkop-plus#readme)
- type: textarea
id: summary
attributes:
label: Краткое описание
description: Краткое описание предлагаемой функции
description: Краткое описание предлагаемой функции для Podkop Plus
placeholder: В одном предложении опишите, что вы хотите добавить...
validations:
required: true
+22 -79
View File
@@ -3,88 +3,42 @@ on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
release_tag:
description: Release tag to build and publish
required: true
type: string
env:
RELEASE_TAG: ${{ github.event.inputs.release_tag || github.ref_name }}
permissions:
contents: write
jobs:
preparation:
name: Setup build version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5.0.0
with:
fetch-depth: 0
- id: version
run: |
VERSION=$(git describe --tags --exact-match 2>/dev/null || echo "0.$(date +%d%m%Y)")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
build:
name: Builder for ${{ matrix.package_type }} podkop and luci-app-podkop
name: Build Podkop Plus packages
runs-on: ubuntu-latest
needs: preparation
strategy:
matrix:
include:
- { package_type: ipk }
- { package_type: apk }
steps:
- uses: actions/checkout@v5.0.0
with:
fetch-depth: 0
- name: Build ${{ matrix.package_type }}
uses: docker/build-push-action@v6.18.0
- uses: actions/setup-node@v4
with:
file: ./Dockerfile-${{ matrix.package_type }}
context: .
tags: podkop:ci-${{ matrix.package_type }}
build-args: |
PODKOP_VERSION=${{ needs.preparation.outputs.version }}
node-version: '22'
- name: Create ${{ matrix.package_type }} Docker container
run: docker create --name ${{ matrix.package_type }} podkop:ci-${{ matrix.package_type }}
- name: Copy files from ${{ matrix.package_type }} Docker container
run: |
mkdir -p ./bin/${{ matrix.package_type }}
docker cp ${{ matrix.package_type }}:/builder/bin/packages/x86_64/utilities/. ./bin/${{ matrix.package_type }}/
docker cp ${{ matrix.package_type }}:/builder/bin/packages/x86_64/luci/. ./bin/${{ matrix.package_type }}/
# IPK uses underscore `_` in filenames, while APK uses only dash `-`
- name: Fix naming difference between build for packages (replace _ with -)
if: matrix.package_type == 'ipk'
- name: Build release artifacts
shell: bash
run: |
for f in ./bin/${{ matrix.package_type }}/*.${{ matrix.package_type }}; do
[ -e "$f" ] || continue
base=$(basename "$f")
newname=$(echo "$base" | sed 's/_/-/g')
mv "$f" "./bin/${{ matrix.package_type }}/$newname"
done
- name: Filter files
shell: bash
run: |
# Use version from preparation job (already without 'v' prefix)
VERSION="${{ needs.preparation.outputs.version }}"
mkdir -p ./filtered-bin/${{ matrix.package_type }}
cp ./bin/${{ matrix.package_type }}/luci-i18n-podkop-ru-*.${{ matrix.package_type }} "./filtered-bin/${{ matrix.package_type }}/luci-i18n-podkop-ru-${VERSION}.${{ matrix.package_type }}"
cp ./bin/${{ matrix.package_type }}/podkop-*.${{ matrix.package_type }} ./filtered-bin/${{ matrix.package_type }}/
cp ./bin/${{ matrix.package_type }}/luci-app-podkop-*.${{ matrix.package_type }} ./filtered-bin/${{ matrix.package_type }}/
- name: Remove Docker container
run: docker rm ${{ matrix.package_type }}
bash ./scripts/build-packages.sh "${RELEASE_TAG}" ./dist/release
- name: Upload build artifacts
uses: actions/upload-artifact@v4.6.2
with:
name: release-files-${{ github.ref_name }}-${{ matrix.package_type }}
path: ./filtered-bin/${{ matrix.package_type }}/*.${{ matrix.package_type }}
name: release-files-${{ env.RELEASE_TAG }}
path: ./dist/release/*.*
retention-days: 1
if-no-files-found: error
@@ -93,27 +47,16 @@ jobs:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v5.0.0
- name: Create release dir
run: mkdir -p ./filtered-bin/release
- name: Download ipk artifacts
uses: actions/download-artifact@v4
- uses: actions/download-artifact@v4
with:
name: release-files-${{ github.ref_name }}-ipk
path: ./filtered-bin/release
- name: Download apk artifacts
uses: actions/download-artifact@v4
with:
name: release-files-${{ github.ref_name }}-apk
path: ./filtered-bin/release
name: release-files-${{ env.RELEASE_TAG }}
path: ./dist/release
- name: Release
uses: softprops/action-gh-release@v2.4.0
with:
files: ./filtered-bin/release/*.*
files: ./dist/release/*.*
draft: false
prerelease: false
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
name: ${{ env.RELEASE_TAG }}
tag_name: ${{ env.RELEASE_TAG }}
+4
View File
@@ -2,3 +2,7 @@
fe-app-podkop/node_modules
fe-app-podkop/.env
.DS_Store
sandbox/
.wsl-build/
dist/
+5 -4
View File
@@ -4,8 +4,9 @@ ARG PODKOP_VERSION
ENV PODKOP_VERSION=${PODKOP_VERSION}
COPY ./podkop /builder/package/feeds/utilities/podkop
COPY ./luci-app-podkop /builder/package/feeds/luci/luci-app-podkop
COPY ./luci-app-podkop-plus /builder/package/feeds/luci/luci-app-podkop-plus
RUN make defconfig && \
make package/podkop/compile -j1 V=s && \
make package/luci-app-podkop/compile -j1 V=s
RUN ./scripts/feeds update -a && \
./scripts/feeds install -a && \
make defconfig && \
make package/luci-app-podkop-plus/compile -j1 V=s
+5 -4
View File
@@ -3,9 +3,10 @@ FROM itdoginfo/openwrt-sdk-ipk:24.10.3
ARG PODKOP_VERSION
COPY ./podkop /builder/package/feeds/utilities/podkop
COPY ./luci-app-podkop /builder/package/feeds/luci/luci-app-podkop
COPY ./luci-app-podkop-plus /builder/package/feeds/luci/luci-app-podkop-plus
RUN export PODKOP_VERSION="v${PODKOP_VERSION}" && \
RUN export PODKOP_VERSION="${PODKOP_VERSION}" && \
./scripts/feeds update -a && \
./scripts/feeds install -a && \
make defconfig && \
make package/podkop/compile V=s -j4 && \
make package/luci-app-podkop/compile V=s -j4
make package/luci-app-podkop-plus/compile V=s -j4
+20 -50
View File
@@ -1,61 +1,31 @@
# Вещи, которые вам нужно знать перед установкой
# Podkop Plus
- Это бета-версия, которая находится в активной разработке. Из версии в версию что-то может меняться.
- При возникновении проблем, нужен технически грамотный фидбэк в чат. Ознакомьтесь с закрепом в топике.
- При обновлении **обязательно** [сбрасывайте кэш LuCI](https://podkop.net/docs/clear-browser-cache/).
- Также при обновлении всегда заходите в конфигурацию и проверяйте свои настройки. Конфигурация может измениться.
- Необходимо минимум 25МБ свободного места на роутере. Роутеры с флешками на 16МБ сразу мимо.
- При старте программы редактируется конфиг Dnsmasq.
- Podkop редактирует конфиг sing-box. Обязательно сохраните ваш конфиг sing-box перед установкой, если он вам нужен.
- Информация здесь может быть устаревшей. Все изменения фиксируются в [телеграм-чате](https://t.me/itdogchat/81758/420321).
- [Если у вас что-то не работает.](https://podkop.net/docs/diagnostics/)
- Если у вас установлен Getdomains, [его следует удалить](https://github.com/itdoginfo/domain-routing-openwrt?tab=readme-ov-file#%D1%81%D0%BA%D1%80%D0%B8%D0%BF%D1%82-%D0%B4%D0%BB%D1%8F-%D1%83%D0%B4%D0%B0%D0%BB%D0%B5%D0%BD%D0%B8%D1%8F).
- Требуется версия OpenWrt 24.10.
- Dashboard доступен, если вы заходите по http (из-за особенностей clash api). И не будет работать, если вы заходите по https и/или домену.
Форк [itdoginfo/podkop](https://github.com/itdoginfo/podkop) для OpenWrt с rule-based моделью маршрутизации, переработанным LuCI-интерфейсом, гибридной интеграцией `zapret`.
# Документация
https://podkop.net/
### Установка
# Установка Podkop
Полная информация в [документации](https://podkop.net/docs/install/)
Вкратце, достаточно одного скрипта для установки и обновления:
```
sh <(wget -O - https://raw.githubusercontent.com/itdoginfo/podkop/refs/heads/main/install.sh)
```sh
sh <(wget -O - https://raw.githubusercontent.com/ushan0v/podkop-plus/main/install.sh)
```
## Изменения 0.7.0
Начиная с версии 0.7.0 изменена структура конфига `/etc/config/podkop`. Старые значения несовместимы с новыми. Нужно заново настроить Podkop.
### Улучшения и новые возможности
Скрипт установки обнаружит старую версию и предупредит вас об этом. Если вы согласитесь, то он сделает автоматически написанное ниже.
- Улучшенный LuCI-интерефейс секций.
- Переход на rule-based конфигурацию.
- Новое действие `zapret`, которое можно назначать на уровне отдельного правила.
При обновлении вручную нужно:
### Интеграция Zapret
0. Не ныть в issue и чатик.
1. Забэкапить старый конфиг:
```
mv /etc/config/podkop /etc/config/podkop-070
```
2. Стянуть новый дефолтный конфиг:
```
wget -O /etc/config/podkop https://raw.githubusercontent.com/itdoginfo/podkop/refs/heads/main/podkop/files/etc/config/podkop
```
3. Настроить заново ваш Podkop через Luci или UCI.
`Zapret` встроен как действие конкретного правила. Под капотом используется [remittor/zapret-openwrt](https://github.com/remittor/zapret-openwrt/releases)
# ToDo
- `sing-box` выбирает и маршрутизирует трафик;
- `zapret` выполняет anti-DPI обработку трафика, который был выбран условием правила.
> [!IMPORTANT]
> PR принимаются только по issues, у которых стоит label "enhancement". Либо по согласованию с авторами в ТГ-чате. Остальные PR на данный момент не рассматриваются.
Для Zapret стратегии NFQWS намеренно запрещены:
## Будущее
- [ ] [Подписка](https://github.com/itdoginfo/podkop/issues/118). Здесь нужна реализация, чтоб для каждой секции помимо ручного выбора, был выбор фильтрации по тегу. Например, для main выбираем ключевые слова NL, DE, FI. А для extra секции фильтруем по RU. И создаётся outbound c urltest в которых перечислены outbound из фильтров.
- [ ] Весь трафик в sing-box и маршрутизация полностью на его уровне.
- [ ] При успешном запуске переходит в фоновый режим и следит за состоянием sing-box. Если вдруг идёт exit 1, выполняется dnsmasq restore и снова следит за состоянием. Вопрос в том, как это искусственно провернуть. Попробовать положить прокси и посмотреть, останется ли работать DNS в этом случае. И здесь, вероятно, можно обойтись триггером в init.d. [Issue](https://github.com/itdoginfo/podkop/issues/111)
- [ ] Галочка, которая режет доступ к doh серверам.
- [ ] IPv6. Только после наполнения Wiki.
## Тесты
- [ ] Unit тесты (BATS)
- [ ] Интеграционные тесты бекенда (OpenWrt rootfs + BATS)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/itdoginfo/podkop)
- шаблоны и hostlist placeholders: `<HOSTLIST>`, `<HOSTLIST_NOAUTO>`;
- hostname/IP selectors внутри самой стратегии: `--hostlist*`, `--hostlist-auto*`, `--ipset*`;
- ручное управление очередью и fwmark: `--qnum`, `--dpi-desync-fwmark`;
- режимы, которые ломают lifecycle процесса: `--daemon`;
- режимы, которые не должны быть итоговой стратегией запуска: `--dry-run`, `--version`;
- внешние конфиги вида `@file` или `$file`, которые обходят встроенную валидацию и управление очередями.
+2 -2
View File
@@ -6,11 +6,11 @@ SFTP_PASS=
# you can use key if needed
# SFTP_PRIVATE_KEY=~/.ssh/id_rsa
LOCAL_DIR_FE=../luci-app-podkop/htdocs/luci-static/resources/view/podkop
LOCAL_DIR_FE=../luci-app-podkop-plus/htdocs/luci-static/resources/view/podkop
REMOTE_DIR_FE=/www/luci-static/resources/view/podkop
LOCAL_DIR_BIN=../podkop/files/usr/bin/
REMOTE_DIR_BIN=/usr/bin/
LOCAL_DIR_LIB=../podkop/files/usr/lib/
REMOTE_DIR_LIB=/usr/lib/podkop/
REMOTE_DIR_LIB=/usr/lib/podkop-plus/
+1 -1
View File
@@ -6,7 +6,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const sourceDir = path.resolve(__dirname, 'locales');
const targetRoot = path.resolve(__dirname, '../luci-app-podkop/po');
const targetRoot = path.resolve(__dirname, '../luci-app-podkop-plus/po');
async function main() {
const files = await fs.readdir(sourceDir);
+3 -3
View File
@@ -19,12 +19,12 @@ function stripIllegalReturn(code) {
const files = await glob([
'src/**/*.ts',
'../luci-app-podkop/htdocs/luci-static/resources/view/podkop/**/*.js',
'../luci-app-podkop-plus/htdocs/luci-static/resources/view/podkop/**/*.js',
], {
ignore: [
'**/*.test.ts',
'**/main.js',
'../luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js',
'../luci-app-podkop-plus/htdocs/luci-static/resources/view/podkop/main.js',
],
absolute: true,
});
@@ -34,7 +34,7 @@ const results = {};
for (const file of files) {
const contentRaw = await fs.readFile(file, 'utf8');
const content = stripIllegalReturn(contentRaw);
const relativePath = path.relative(process.cwd(), file);
const relativePath = path.relative(process.cwd(), file).replaceAll('\\', '/');
let ast;
try {
+4 -4
View File
@@ -36,14 +36,14 @@ function getHeader(lang) {
: 'nplurals=2; plural=(n != 1);';
return [
`# ${lang.toUpperCase()} translations for PODKOP package.`,
`# Copyright (C) ${now.getFullYear()} THE PODKOP'S COPYRIGHT HOLDER`,
`# This file is distributed under the same license as the PODKOP package.`,
`# ${lang.toUpperCase()} translations for PODKOP PLUS package.`,
`# Copyright (C) ${now.getFullYear()} THE PODKOP PLUS COPYRIGHT HOLDER`,
`# This file is distributed under the same license as the PODKOP PLUS package.`,
`# ${translator}, ${now.getFullYear()}.`,
'#',
'msgid ""',
'msgstr ""',
`"Project-Id-Version: PODKOP\\n"`,
`"Project-Id-Version: PODKOP PLUS\\n"`,
`"Report-Msgid-Bugs-To: \\n"`,
`"POT-Creation-Date: ${date} ${time}${tzOffset}\\n"`,
`"PO-Revision-Date: ${date} ${time}${tzOffset}\\n"`,
+16 -4
View File
@@ -3,12 +3,24 @@ import { execSync } from 'child_process';
const inputFile = 'locales/calls.json';
const outputFile = 'locales/podkop.pot';
const projectId = 'PODKOP';
const projectId = 'PODKOP PLUS';
function getGitUser() {
const name = execSync('git config user.name').toString().trim();
const email = execSync('git config user.email').toString().trim();
return { name, email };
try {
const name = execSync('git config user.name', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
const email = execSync('git config user.email', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
if (name && email) {
return { name, email };
}
} catch (error) {
// Fall through to deterministic defaults when git identity is not configured.
}
return {
name: 'Podkop Plus',
email: 'noreply@podkop.plus',
};
}
function getPotHeader({ name, email }) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+327 -147
View File
@@ -1,15 +1,15 @@
# RU translations for PODKOP package.
# Copyright (C) 2026 THE PODKOP'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PODKOP package.
# divocatt, 2026.
# RU translations for PODKOP PLUS package.
# Copyright (C) 2026 THE PODKOP PLUS COPYRIGHT HOLDER
# This file is distributed under the same license as the PODKOP PLUS package.
# ushan0v, 2026.
#
msgid ""
msgstr ""
"Project-Id-Version: PODKOP\n"
"Project-Id-Version: PODKOP PLUS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-14 20:21+0200\n"
"PO-Revision-Date: 2026-01-14 20:21+0200\n"
"Last-Translator: divocatt\n"
"POT-Creation-Date: 2026-04-07 21:03+0300\n"
"PO-Revision-Date: 2026-04-07 21:03+0300\n"
"Last-Translator: ushan0v\n"
"Language-Team: none\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
@@ -17,39 +17,66 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
msgid "✔ Enabled"
msgstr "✔ Включено"
msgid "✔ Running"
msgstr "✔ Работает"
msgid "✘ Disabled"
msgstr "✘ Отключено"
msgid "✘ Stopped"
msgstr "✘ Остановлен"
msgid "A latency-tested group of proxy URLs for this node"
msgstr "Группа URL прокси для этого узла с проверкой задержки"
msgid "A latency-tested group of proxy URLs for this section"
msgstr "Группа URL прокси для этой секции с проверкой задержки"
msgid "A manual group of proxy URLs for this node"
msgstr "Группа URL прокси для этого узла с ручным выбором"
msgid "A manual group of proxy URLs for this section"
msgstr "Группа URL прокси для этой секции с ручным выбором"
msgid "Action"
msgstr "Действие"
msgid "Active Connections"
msgstr "Активные соединения"
msgid "Add a section"
msgstr "Добавить секцию"
msgid "Add URLs or local paths to .srs lists"
msgstr "Добавляйте URL или локальные пути к спискам .srs"
msgid "Additional marking rules found"
msgstr "Найдены дополнительные правила маркировки"
msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall."
msgstr "Обеспечивает доступ к YACD из WAN. Убедитесь, что в брандмауэре открыт соответствующий порт."
msgid "Applicable for SOCKS and Shadowsocks proxy"
msgstr "Применимо для SOCKS и Shadowsocks прокси"
msgid "An error occurred while saving the form:"
msgstr "При сохранении формы произошла ошибка:"
msgid "At least one valid domain must be specified. Comments-only content is not allowed."
msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
msgid "Applicable for SOCKS and Shadowsocks links"
msgstr "Применимо для ссылок SOCKS и Shadowsocks"
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
msgid "At least one valid domain must be specified."
msgstr "Нужно указать хотя бы один корректный домен."
msgid "At least one valid IP or subnet must be specified."
msgstr "Нужно указать хотя бы один корректный IP-адрес или подсеть."
msgid "Auto refresh"
msgstr "Автообновление"
msgid "Available actions"
msgstr "Доступные действия"
msgid "Backend validation failed: %s"
msgstr "Backend-проверка завершилась ошибкой: %s"
msgid "Binary file"
msgstr "Бинарный файл"
msgid "Bootsrap DNS"
msgstr "Bootstrap DNS"
@@ -62,6 +89,9 @@ msgstr "Браузер не использует FakeIP"
msgid "Browser is using FakeIP correctly"
msgstr "Браузер использует FakeIP"
msgid "Built-in rule sets"
msgstr "Встроенные наборы правил"
msgid "Cache File Path"
msgstr "Путь к файлу кэша"
@@ -83,23 +113,26 @@ msgstr "Проверки не выполнены"
msgid "Checks passed"
msgstr "Проверки пройдены"
msgid "Choose how this outbound should be configured"
msgstr "Выберите, как настроить этот outbound"
msgid "CIDR must be between 0 and 32"
msgstr "CIDR должен быть между 0 и 32"
msgid "Close"
msgstr "Закрыть"
msgid "Community Lists"
msgstr "Списки сообщества"
msgid "Conditions"
msgstr "Условия"
msgid "Config File Path"
msgstr "Путь к файлу конфигурации"
msgid "Configuration for Podkop service"
msgstr "Настройки сервиса Podkop"
msgid "Configuration for Podkop Plus service"
msgstr "Настройки сервиса Podkop Plus"
msgid "Configuration Type"
msgstr "Тип конфигурации"
msgid "Connection"
msgstr "Подключение"
msgid "Connection Type"
msgstr "Тип подключения"
@@ -119,8 +152,8 @@ msgstr "Дашборд"
msgid "Dashboard currently unavailable"
msgstr "Дашборд сейчас недоступен"
msgid "Delay in milliseconds before reloading podkop after interface UP"
msgstr "Задержка в миллисекундах перед перезагрузкой podkop после поднятия интерфейса"
msgid "Delay in milliseconds before reloading Podkop Plus after interface UP"
msgstr "Задержка в миллисекундах перед перезапуском Podkop Plus после поднятия интерфейса"
msgid "Delay value cannot be empty"
msgstr "Значение задержки не может быть пустым"
@@ -131,6 +164,9 @@ msgstr "DHCP содержит DNS сервер"
msgid "Diagnostics"
msgstr "Диагностика"
msgid "Direct"
msgstr "Напрямую"
msgid "Disable autostart"
msgstr "Отключить автостарт"
@@ -140,8 +176,8 @@ msgstr "Отключить QUIC"
msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming"
msgstr "Отключить QUIC протокол для улучшения совместимости или исправления видео стриминга"
msgid "Disabled"
msgstr "Отключено"
msgid "Dismiss"
msgstr "Закрыть"
msgid "DNS on router"
msgstr "DNS на роутере"
@@ -152,12 +188,21 @@ msgstr "DNS через HTTPS (DoH)"
msgid "DNS over TLS (DoT)"
msgstr "DNS через TLS (DoT)"
msgid "DNS protocol"
msgstr "Протокол DNS"
msgid "DNS Protocol"
msgstr "Протокол DNS"
msgid "DNS Protocol Type"
msgstr "Тип протокола DNS"
msgid "DNS Rewrite TTL"
msgstr "Перезапись TTL для DNS"
msgid "DNS server"
msgstr "DNS-сервер"
msgid "DNS Server"
msgstr "DNS-сервер"
@@ -167,8 +212,14 @@ msgstr "Адрес DNS-сервера не может быть пустым"
msgid "Do not panic, everything can be fixed, just..."
msgstr "Не паникуйте, всё можно исправить, просто..."
msgid "Domain Resolver"
msgstr "Резолвер доменов"
msgid "Domain keyword"
msgstr "Ключевое слово домена"
msgid "Domain regex"
msgstr "Регулярное выражение домена"
msgid "Domains"
msgstr "Домены"
msgid "Dont Touch My DHCP!"
msgstr "Dont Touch My DHCP!"
@@ -179,53 +230,53 @@ msgstr "Входящий"
msgid "Download"
msgstr "Скачать"
msgid "Download external rule sets through one of the enabled proxy sections"
msgstr "Скачивать внешние наборы правил через одну из включённых прокси-секций"
msgid "Download Lists via Proxy/VPN"
msgstr "Скачивать списки через Proxy/VPN"
msgid "Download Lists via specific proxy section"
msgstr "Скачивать списки через выбранную секцию"
msgid "Download Node"
msgstr "Узел для скачивания"
msgid "Downloading all lists via specific Proxy/VPN"
msgstr "Загрузка всех списков через указанный прокси/VPN"
msgid "Dynamic List"
msgstr "Динамический список"
msgid "Drag rows to change priority. The rule at the top is checked first."
msgstr "Перетаскивайте строки, чтобы изменить приоритет. Правило сверху проверяется первым."
msgid "Duplicate value"
msgstr "Дублирующееся значение"
msgid "Enable"
msgstr "Включить"
msgid "Enable a dedicated DNS resolver when this node uses an interface"
msgstr "Включить отдельный DNS-резолвер, когда этот узел использует интерфейс"
msgid "Enable a dedicated DNS resolver when this section uses an interface"
msgstr "Включить отдельный DNS-резолвер, когда эта секция использует интерфейс"
msgid "Enable autostart"
msgstr "Включить автостарт"
msgid "Enable built-in DNS resolver for domains handled by this section"
msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых в этом разделе"
msgid "Enable Mixed Proxy"
msgstr "Включить смешанный прокси"
msgid "Enable Output Network Interface"
msgstr "Включить выходной сетевой интерфейс"
msgid "Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies"
msgstr "Включить смешанный прокси-сервер, разрешив этому разделу маршрутизировать трафик как через HTTP, так и через SOCKS-прокси."
msgid "Enable YACD"
msgstr "Включить YACD"
msgid "Enable YACD WAN Access"
msgstr "Включить доступ YACD WAN"
msgid "Enter complete outbound configuration in JSON format"
msgstr "Введите полную конфигурацию исходящего соединения в формате JSON"
msgid "Enter domain names separated by commas, spaces, or newlines. You can add comments using //"
msgstr "Введите доменные имена, разделяя их запятыми, пробелами или переносами строк. Вы можете добавлять комментарии, используя //"
msgid "Enter domain names without protocols, e.g. example.com or sub.example.com"
msgstr "Введите доменные имена без протоколов, например example.com или sub.example.com"
msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses"
msgstr "Введите подсети в нотации CIDR (например, 103.21.244.0/22) или отдельные IP-адреса"
msgid "Every 1 minute"
msgstr "Каждую минуту"
msgid "Enter a complete sing-box outbound object"
msgstr "Введите полный объект outbound sing-box"
msgid "Every 3 minutes"
msgstr "Каждые 3 минуты"
@@ -236,12 +287,27 @@ msgstr "Каждые 30 секунд"
msgid "Every 5 minutes"
msgstr "Каждые 5 минут"
msgid "Every minute"
msgstr "Каждую минуту"
msgid "Exact full domain"
msgstr "Точный полный домен"
msgid "Exclude NTP"
msgstr "Исключить NTP"
msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN"
msgstr "Исключите трафик протокола NTP из туннеля, чтобы предотвратить его маршрутизацию через прокси-сервер или VPN."
msgid "Expecting: %s"
msgstr "Ожидается: %s"
msgid "Expose this section as a local HTTP+SOCKS proxy"
msgstr "Открыть эту секцию как локальный HTTP+SOCKS прокси"
msgid "External nfqws config files bypass Podkop Plus queue management and explicit validation."
msgstr "Внешние конфиги nfqws обходят управление очередями и явную валидацию Podkop Plus."
msgid "Failed to copy!"
msgstr "Не удалось скопировать!"
@@ -251,8 +317,8 @@ msgstr "Не удалось выполнить!"
msgid "Fastest"
msgstr "Самый быстрый"
msgid "Fully Routed IPs"
msgstr "Полностью маршрутизированные IP-адреса"
msgid "Format"
msgstr "Формат"
msgid "Get global check"
msgstr "Получить глобальную проверку"
@@ -260,9 +326,18 @@ msgstr "Получить глобальную проверку"
msgid "Global check"
msgstr "Глобальная проверка"
msgid "How often this remote rule set should be refreshed"
msgstr "Как часто обновлять этот удалённый набор правил"
msgid "How to configure the proxy connection for this section"
msgstr "Как настроить прокси-подключение для этой секции"
msgid "HTTP error"
msgstr "Ошибка HTTP"
msgid "Interface"
msgstr "Интерфейс"
msgid "Interface Monitoring"
msgstr "Мониторинг интерфейса"
@@ -332,6 +407,9 @@ msgstr "Неверный формат пути. Путь должен начин
msgid "Invalid port number. Must be between 1 and 65535"
msgstr "Неверный номер порта. Допустимо от 1 до 65535"
msgid "Invalid regular expression"
msgstr "Неверное регулярное выражение"
msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password"
msgstr "Неверный URL Shadowsocks: декодированные данные должны содержать method:password"
@@ -404,20 +482,26 @@ msgstr "Неверный URL VLESS: ошибка разбора"
msgid "IP address 0.0.0.0 is not allowed"
msgstr "IP-адрес 0.0.0.0 не допускается"
msgid "IPs"
msgstr "IP-адреса"
msgid "Issues detected"
msgstr "Обнаружены проблемы"
msgid "Keyword must not contain spaces"
msgstr "Ключевое слово не должно содержать пробелы"
msgid "Latest"
msgstr "Последняя"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
msgid "Local Domain Lists"
msgstr "Локальные списки доменов"
msgid "Local"
msgstr "Локальный"
msgid "Local Subnet Lists"
msgstr "Локальные списки подсетей"
msgid "Local file path to a sing-box rule set"
msgstr "Локальный путь к набору правил sing-box"
msgid "Log Level"
msgstr "Уровень логов"
@@ -425,6 +509,27 @@ msgstr "Уровень логов"
msgid "Main DNS"
msgstr "Основной DNS"
msgid "Match destination IPs or subnets"
msgstr "Совпадение по IP-адресам или подсетям назначения"
msgid "Match domains containing a substring"
msgstr "Совпадение по доменам, содержащим подстроку"
msgid "Match domains including all subdomains"
msgstr "Совпадение по доменам, включая все их поддомены"
msgid "Match domains using a regular expression"
msgstr "Совпадение по доменам с использованием регулярного выражения"
msgid "Match only one exact full domain name"
msgstr "Совпадение только по одному точному полному доменному имени"
msgid "Match source IPs or subnets"
msgstr "Совпадение по исходным IP-адресам или подсетям"
msgid "Maximum response time delta in milliseconds"
msgstr "Максимальная разница времени ответа в миллисекундах"
msgid "Memory Usage"
msgstr "Использование памяти"
@@ -437,12 +542,18 @@ msgstr "Наблюдаемые интерфейсы"
msgid "Must be a number in the range of 50 - 1000"
msgstr "Должно быть числом от 50 до 1000"
msgid "Network Interface"
msgstr "Сетевой интерфейс"
msgid "NFQWS Strategy"
msgstr "Стратегия NFQWS"
msgid "NFQWS strategy cannot be empty"
msgstr "Стратегия NFQWS не может быть пустой"
msgid "No other marking rules found"
msgstr "Другие правила маркировки не найдены"
msgid "Node Type"
msgstr "Тип узла"
msgid "Not implement yet"
msgstr "Ещё не реализовано"
@@ -452,14 +563,20 @@ msgstr "Не отвечает"
msgid "Not running"
msgstr "Не запущено"
msgid "Open Project Page"
msgstr "Открыть страницу проекта"
msgid "Operation timed out"
msgstr "Время ожидания истекло"
msgid "Outbound Config"
msgstr "Конфигурация Outbound"
msgid "Optional node used to download this remote rule set"
msgstr "Необязательный узел, через который скачивается этот удалённый набор правил"
msgid "Outbound Configuration"
msgstr "Конфигурация исходящего соединения"
msgid "Optional values must be attached with '=' here; a separate token would be ignored by nfqws."
msgstr "Необязательное значение здесь нужно передавать только через '='; отдельный токен nfqws проигнорирует."
msgid "Outbound JSON"
msgstr "JSON outbound"
msgid "Outdated"
msgstr "Устаревшая"
@@ -467,6 +584,9 @@ msgstr "Устаревшая"
msgid "Output Network Interface"
msgstr "Выходной сетевой интерфейс"
msgid "Path"
msgstr "Путь"
msgid "Path cannot be empty"
msgstr "Путь не может быть пустым"
@@ -482,14 +602,23 @@ msgstr "Путь должен заканчиваться на cache.db"
msgid "Pending"
msgstr "Ожидает запуска"
msgid "Podkop"
msgstr "Podkop"
msgid "Podkop Plus"
msgstr "Podkop Plus"
msgid "Podkop Settings"
msgstr "Настройки podkop"
msgid "Podkop Plus manages the nfqws process lifecycle itself, so daemon mode is not allowed here."
msgstr "Podkop Plus сам управляет жизненным циклом nfqws, поэтому режим daemon здесь запрещён."
msgid "Podkop will not modify your DHCP configuration"
msgstr "Podkop не будет изменять вашу конфигурацию DHCP."
msgid "Podkop Plus Settings"
msgstr "Настройки Podkop Plus"
msgid "Podkop Plus will not modify your DHCP configuration"
msgstr "Podkop Plus не будет изменять вашу конфигурацию DHCP"
msgid "Port cannot be empty"
msgstr "Порт не может быть пустым"
msgid "Port for the local mixed proxy of this section"
msgstr "Порт локального смешанного прокси для этой секции"
msgid "Proxy Configuration URL"
msgstr "URL конфигурации прокси"
@@ -503,14 +632,26 @@ msgstr "Прокси-трафик направляется через FakeIP"
msgid "Regional options cannot be used together"
msgstr "Нельзя использовать несколько региональных опций одновременно"
msgid "Remote Domain Lists"
msgstr "Внешние списки доменов"
msgid "Remote"
msgstr "Удалённый"
msgid "Remote Subnet Lists"
msgstr "Внешние списки подсетей"
msgid "Remote URL to a sing-box rule set"
msgstr "Удалённый URL к набору правил sing-box"
msgid "Restart podkop"
msgstr "Перезапустить Podkop"
msgid "Resolver for interface outbound"
msgstr "Резолвер для outbound через интерфейс"
msgid "Resolver For Interface Outbound"
msgstr "Резолвер для outbound через интерфейс"
msgid "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE."
msgstr "Выбор ресурсов по hostname внутри nfqws здесь не поддерживается; ресурсы выбирает sing-box до NFQUEUE."
msgid "Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE."
msgstr "Выбор ресурсов по IP или CIDR внутри nfqws здесь не поддерживается; ресурсы выбирает sing-box до NFQUEUE."
msgid "Restart Podkop Plus"
msgstr "Перезапустить Podkop Plus"
msgid "Router DNS is not routed through sing-box"
msgstr "DNS роутера не проходит через sing-box"
@@ -521,6 +662,15 @@ msgstr "DNS роутера проходит через sing-box"
msgid "Routing Excluded IPs"
msgstr "Исключённые из маршрутизации IP-адреса"
msgid "Rule set must be a direct .srs URL or a local .srs path"
msgstr "Набор правил должен быть прямым URL на .srs или локальным путём к .srs"
msgid "Rule set URL"
msgstr "URL набора правил"
msgid "Rule sets"
msgstr "Наборы правил"
msgid "Rules mangle counters"
msgstr "Счётчики правил mangle"
@@ -545,17 +695,23 @@ msgstr "Запустить диагностику"
msgid "Russia inside restrictions"
msgstr "Ограничения Russia inside"
msgid "Save error"
msgstr "Ошибка сохранения"
msgid "Secret key for authenticating remote access to YACD when WAN access is enabled."
msgstr "Секретный ключ для аутентификации удаленного доступа к YACD при включенном доступе через WAN."
msgid "Section"
msgstr "Секция"
msgid "Section name"
msgstr "Имя секции"
msgid "Sections"
msgstr "Секции"
msgid "Select a predefined list for routing"
msgstr "Выберите предопределенный список для маршрутизации"
msgid "Select between VPN and Proxy connection methods for traffic routing"
msgstr "Выберите между VPN и Proxy методами для маршрутизации трафика"
msgstr "Выберите предустановленный набор правил для маршрутизации"
msgid "Select DNS protocol to use"
msgstr "Выберите протокол DNS"
@@ -563,12 +719,6 @@ msgstr "Выберите протокол DNS"
msgid "Select how often the domain or subnet lists are updated automatically"
msgstr "Выберите частоту автоматического обновления списков доменов или подсетей."
msgid "Select how to configure the proxy"
msgstr "Выберите способ настройки прокси"
msgid "Select network interface for VPN connection"
msgstr "Выберите сетевой интерфейс для VPN подключения"
msgid "Select or enter DNS server address"
msgstr "Выберите или введите адрес DNS-сервера"
@@ -578,15 +728,6 @@ msgstr "Выберите или введите путь к файлу кеша s
msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing"
msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
msgid "Select the DNS protocol type for the domain resolver"
msgstr "Выберите тип протокола DNS для резолвера доменов"
msgid "Select the list type for adding custom domains"
msgstr "Выберите тип списка для добавления пользовательских доменов"
msgid "Select the list type for adding custom subnets"
msgstr "Выберите тип списка для добавления пользовательских подсетей"
msgid "Select the log level for sing-box"
msgstr "Выберите уровень логов для sing-box"
@@ -602,8 +743,14 @@ msgstr "Выберите WAN интерфейсы для мониторинга"
msgid "Selector"
msgstr "Selector"
msgid "Selector Proxy Links"
msgstr "Ссылки прокси для Selector"
msgid "Selector connections"
msgstr "Подключения selector"
msgid "Selector Links"
msgstr "Ссылки selector"
msgid "Service list"
msgstr "Список сервисов"
msgid "Services info"
msgstr "Информация о сервисах"
@@ -635,29 +782,23 @@ msgstr "Сервис sing-box существует"
msgid "Sing-box version is compatible (newer than 1.12.4)"
msgstr "Версия Sing-box совместима (новее 1.12.4)"
msgid "Source file"
msgstr "Исходный файл"
msgid "Source IPs"
msgstr "Исходные IP-адреса"
msgid "Source Network Interface"
msgstr "Сетевой интерфейс источника"
msgid "Specify a local IP address to be excluded from routing"
msgstr "Укажите локальный IP-адрес, который следует исключить из маршрутизации."
msgid "Specify local IP addresses or subnets whose traffic will always be routed through the configured route"
msgstr "Укажите локальные IP-адреса или подсети, трафик которых всегда будет направляться через настроенный маршрут."
msgid "Start Podkop Plus"
msgstr "Запустить Podkop Plus"
msgid "Specify remote URLs to download and use domain lists"
msgstr "Укажите URL-адреса для загрузки и использования списков доменов."
msgid "Specify remote URLs to download and use subnet lists"
msgstr "Укажите URL-адреса для загрузки и использования списков подсетей."
msgid "Specify the path to the list file located on the router filesystem"
msgstr "Укажите путь к файлу списка, расположенному в файловой системе маршрутизатора."
msgid "Start podkop"
msgstr "Запустить podkop"
msgid "Stop podkop"
msgstr "Остановить podkop"
msgid "Stop Podkop Plus"
msgstr "Остановить Podkop Plus"
msgid "Successfully copied!"
msgstr "Успешно скопировано!"
@@ -674,20 +815,26 @@ msgstr "Таблица существует"
msgid "Test latency"
msgstr "Тестирование задержки"
msgid "Text List"
msgstr "Текстовый список"
msgid "The desync fwmark is managed by Podkop Plus for loop prevention and must not be overridden here."
msgstr "Desync fwmark управляется Podkop Plus для защиты от циклов и не должен переопределяться здесь."
msgid "The DNS server used to look up the IP address of an upstream DNS server"
msgstr "DNS-сервер, используемый для поиска IP-адреса вышестоящего DNS-сервера"
msgid "The interval between connectivity tests"
msgstr "Интервал между тестами подключения"
msgid "The NFQUEUE number is assigned by Podkop Plus for each rule and must not be overridden here."
msgstr "Номер NFQUEUE назначается Podkop Plus для каждого правила и не должен переопределяться здесь."
msgid "The maximum difference in response times (ms) allowed when comparing servers"
msgstr "Максимально допустимая разница во времени отклика (мс) при сравнении серверов"
msgid "This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here."
msgstr "Это поле должно запускать рабочую стратегию nfqws; --dry-run сразу завершает процесс и здесь запрещён."
msgid "The URL used to test server connectivity"
msgstr "URL-адрес, используемый для проверки подключения к серверу"
msgid "This field must start a working nfqws strategy; --version exits immediately and is not allowed here."
msgstr "Это поле должно запускать рабочую стратегию nfqws; --version сразу завершает процесс и здесь запрещён."
msgid "This flag does not accept a value."
msgstr "Этот флаг не принимает значение."
msgid "This option requires a value."
msgstr "Этой опции требуется значение."
msgid "Time in seconds for DNS record caching (default: 60)"
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
@@ -707,18 +854,39 @@ msgstr "TTL должно быть положительным числом"
msgid "TTL value cannot be empty"
msgstr "Значение TTL не может быть пустым"
msgid "Type"
msgstr "Тип"
msgid "UDP"
msgstr "UDP"
msgid "UDP (Unprotected DNS)"
msgstr "UDP (Незащищённый DNS)"
msgid "UDP over TCP"
msgstr "UDP через TCP"
msgid "Unable to validate the NFQWS strategy through the backend parser."
msgstr "Не удалось проверить стратегию NFQWS через backend-парсер."
msgid "Unexpected standalone token. Use explicit flags such as --name or --name=value."
msgstr "Неожиданный отдельный токен. Используйте явные флаги вида --имя или --имя=значение."
msgid "unique UCI identifier"
msgstr "уникальный идентификатор UCI"
msgid "unknown"
msgstr "неизвестно"
msgid "Unknown error"
msgstr "Неизвестная ошибка"
msgid "Unknown NFQWS flag."
msgstr "Неизвестный флаг NFQWS."
msgid "Update interval"
msgstr "Интервал обновления"
msgid "Uplink"
msgstr "Исходящий"
@@ -731,35 +899,32 @@ msgstr "URL должен использовать один из следующи
msgid "URLTest"
msgstr "URLTest"
msgid "URLTest Check Interval"
msgstr "Интервал проверки URLTest"
msgid "URLTest connections"
msgstr "Подключения URLTest"
msgid "URLTest Proxy Links"
msgstr "Ссылки прокси для URLTest"
msgid "URLTest interval"
msgstr "Интервал URLTest"
msgid "URLTest Testing URL"
msgstr "URLTest ссылка для проверки"
msgid "URLTest Interval"
msgstr "Интервал URLTest"
msgid "URLTest Links"
msgstr "Ссылки URLTest"
msgid "URLTest tolerance"
msgstr "Допуск URLTest"
msgid "URLTest Tolerance"
msgstr "URLTest допустимое отклонение"
msgid "User Domain List Type"
msgstr "Тип пользовательского списка доменов"
msgid "URLTest URL"
msgstr "URL URLTest"
msgid "User Domains"
msgstr "Пользовательские домены"
msgid "Use a network interface as the outbound for this node"
msgstr "Использовать сетевой интерфейс как outbound для этого узла"
msgid "User Domains List"
msgstr "Список пользовательских доменов"
msgid "User Subnet List Type"
msgstr "Тип пользовательского списка подсетей"
msgid "User Subnets"
msgstr "Пользовательские подсети"
msgid "User Subnets List"
msgstr "Список пользовательских подсетей"
msgid "Use a network interface as the outbound for this section"
msgstr "Использовать сетевой интерфейс как outbound для этой секции"
msgid "Valid"
msgstr "Валидно"
@@ -770,11 +935,11 @@ msgstr "Ошибки валидации:"
msgid "View logs"
msgstr "Посмотреть логи"
msgid "Visit Wiki"
msgstr "Перейти в wiki"
msgid "Visible name of this section"
msgstr "Видимое имя этой секции"
msgid "vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
msgstr ""
msgstr "ссылки vless://, ss://, trojan://, socks4/5://, hy2/hysteria2://"
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены."
@@ -782,8 +947,23 @@ msgstr "Предупреждение: %s нельзя использовать
msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection."
msgstr "Предупреждение: Russia inside может быть использован только с %s. %s уже есть в Russia inside и будет удален из выбранных."
msgid "What Podkop Plus should do when this section matches"
msgstr "Что Podkop Plus должен делать при совпадении этой секции"
msgid "YACD Secret Key"
msgstr "Секретный ключ YACD"
msgid "You can select Output Network Interface, by default autodetect"
msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически."
msgid "Zapret"
msgstr "Zapret"
msgid "Zapret hostlist templates are not supported here because Podkop Plus does not expand them for per-rule NFQWS strategies."
msgstr "Шаблоны hostlist zapret здесь не поддерживаются, потому что Podkop Plus не разворачивает их для per-rule стратегий NFQWS."
msgid "Zapret installed"
msgstr "Zapret установлен"
msgid "Zapret not installed"
msgstr "Zapret не установлен"
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"format": "prettier --write src",
"format:js": "prettier --write ../luci-app-podkop/htdocs/luci-static/resources/view/podkop",
"format:js": "prettier --write ../luci-app-podkop-plus/htdocs/luci-static/resources/view/podkop",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"build": "tsup src/main.ts",
@@ -19,7 +19,7 @@ export async function executeShellCommand({
timeout = COMMAND_TIMEOUT,
}: ExecuteShellCommandParams): Promise<ExecuteShellCommandResponse> {
try {
return withTimeout(
return await withTimeout(
fs.exec(command, args),
timeout,
[command, ...args].join(' '),
@@ -1,10 +1,16 @@
import { GlobalStyles } from '../styles';
const PODKOP_GLOBAL_STYLES_ID = 'podkop-global-styles';
export function injectGlobalStyles() {
if (document.getElementById(PODKOP_GLOBAL_STYLES_ID)) {
return;
}
document.head.insertAdjacentHTML(
'beforeend',
`
<style>
<style id="${PODKOP_GLOBAL_STYLES_ID}">
${GlobalStyles}
</style>
`,
+35 -19
View File
@@ -1,30 +1,46 @@
export async function onMount(id: string): Promise<HTMLElement> {
return new Promise((resolve) => {
const el = document.getElementById(id);
function getTarget(target: string | HTMLElement): HTMLElement | null {
if (typeof target === 'string') {
return document.getElementById(target);
}
if (el && el.offsetParent !== null) {
return resolve(el);
return target;
}
export async function onMount(
target: string | HTMLElement,
): Promise<HTMLElement> {
return new Promise((resolve) => {
let observer: MutationObserver | null = null;
const resolveIfMountedAndVisible = () => {
const mountedTarget = getTarget(target);
if (
mountedTarget &&
mountedTarget.isConnected &&
mountedTarget.offsetParent !== null
) {
observer?.disconnect();
resolve(mountedTarget);
return true;
}
return false;
};
if (resolveIfMountedAndVisible()) {
return;
}
const observer = new MutationObserver(() => {
const target = document.getElementById(id);
if (target) {
const io = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (visible) {
observer.disconnect();
io.disconnect();
resolve(target);
}
});
io.observe(target);
}
observer = new MutationObserver(() => {
resolveIfMountedAndVisible();
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style', 'hidden'],
});
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
export function parseValueList(value: string): string[] {
return value
.split(/\n/) // Split to array by newline separator
.map((line) => line.split('//')[0]) // Remove comments
.map((line) => line.split('//')[0].split('#')[0]) // Remove comments
.join(' ') // Build clean string
.split(/[,\s]+/) // Split to array by comma and space
.map((s) => s.trim()) // Remove extra spaces
+255 -27
View File
@@ -2,31 +2,259 @@ import { renderButton } from '../button/renderButton';
import { copyToClipboard } from '../../helpers/copyToClipboard';
import { downloadAsTxt } from '../../helpers/downloadAsTxt';
export function renderModal(text: string, name: string) {
return E(
'div',
{ class: 'pdk-partial-modal__body' },
E('div', {}, [
E('pre', { class: 'pdk-partial-modal__content' }, E('code', {}, text)),
E('div', { class: 'pdk-partial-modal__footer' }, [
renderButton({
classNames: ['cbi-button-apply'],
text: _('Download'),
onClick: () => downloadAsTxt(text, name),
}),
renderButton({
classNames: ['cbi-button-apply'],
text: _('Copy'),
onClick: () =>
copyToClipboard(` \`\`\`${name} \n ${text} \n \`\`\``),
}),
renderButton({
classNames: ['cbi-button-remove'],
text: _('Close'),
onClick: ui.hideModal,
}),
]),
]),
);
interface RenderModalOptions {
getText?: () => string | Promise<string>;
refreshMs?: number;
initialAutoRefresh?: boolean;
showAutoRefreshToggle?: boolean;
startAtEnd?: boolean;
autoRefreshLabel?: string;
}
export function renderModal(
text: string,
name: string,
options?: RenderModalOptions,
) {
let currentText = text ?? '';
let refreshInFlight = false;
let pendingRefresh = false;
let refreshSessionId = 0;
let timer: ReturnType<typeof setInterval> | undefined;
let observer: MutationObserver | undefined;
let autoRefreshEnabled =
options?.initialAutoRefresh ?? Boolean(options?.getText);
let shouldScrollToBottomOnMount = Boolean(options?.startAtEnd);
let autoRefreshInput: HTMLInputElement | undefined;
const codeEl = E('code', {}, currentText) as HTMLElement;
const contentEl = E(
'pre',
{ class: 'pdk-partial-modal__content' },
codeEl,
) as HTMLElement;
const stopRefreshTimer = () => {
if (timer) {
clearInterval(timer);
timer = undefined;
}
};
const destroyLiveRefresh = () => {
refreshSessionId += 1;
pendingRefresh = false;
stopRefreshTimer();
observer?.disconnect();
observer = undefined;
};
const scrollToBottom = () => {
contentEl.scrollTop = contentEl.scrollHeight;
};
const scheduleInitialScrollToBottom = () => {
if (!shouldScrollToBottomOnMount || !body.isConnected) {
return;
}
shouldScrollToBottomOnMount = false;
requestAnimationFrame(() => {
scrollToBottom();
});
};
const updateText = (nextText: string) => {
const normalizedText = nextText ?? '';
const shouldStickToBottom =
shouldScrollToBottomOnMount ||
contentEl.scrollTop + contentEl.clientHeight >=
contentEl.scrollHeight - 16;
if (normalizedText === currentText) {
if (shouldStickToBottom) {
requestAnimationFrame(() => {
scrollToBottom();
});
}
return;
}
currentText = normalizedText;
codeEl.textContent = currentText;
if (shouldStickToBottom) {
requestAnimationFrame(() => {
scrollToBottom();
});
}
};
const refreshText = async () => {
if (!options?.getText || !autoRefreshEnabled || refreshInFlight) {
return;
}
if (!body.isConnected) {
return;
}
refreshInFlight = true;
const sessionId = refreshSessionId;
try {
const nextText = await options.getText();
if (!body.isConnected || !autoRefreshEnabled) {
return;
}
if (sessionId !== refreshSessionId) {
return;
}
updateText(nextText ?? '');
} catch (error) {
console.warn('[renderModal] failed to refresh modal content', error);
} finally {
refreshInFlight = false;
if (pendingRefresh) {
pendingRefresh = false;
if (autoRefreshEnabled && body.isConnected) {
void refreshText();
}
}
}
};
const requestRefresh = () => {
if (!options?.getText || !autoRefreshEnabled) {
return;
}
if (refreshInFlight) {
pendingRefresh = true;
return;
}
void refreshText();
};
const startRefreshTimer = () => {
if (
!options?.getText ||
!autoRefreshEnabled ||
timer ||
typeof document === 'undefined'
) {
return;
}
timer = setInterval(() => {
requestRefresh();
}, options.refreshMs ?? 3000);
};
const setAutoRefreshEnabled = (nextValue: boolean) => {
autoRefreshEnabled = nextValue;
refreshSessionId += 1;
pendingRefresh = false;
if (autoRefreshInput) {
autoRefreshInput.checked = nextValue;
}
if (nextValue) {
startRefreshTimer();
requestRefresh();
return;
}
stopRefreshTimer();
};
const footerChildren = [
renderButton({
classNames: ['cbi-button-apply'],
text: _('Download'),
onClick: () => downloadAsTxt(currentText, name),
}),
renderButton({
classNames: ['cbi-button-apply'],
text: _('Copy'),
onClick: () =>
copyToClipboard(` \`\`\`${name} \n ${currentText} \n \`\`\``),
}),
renderButton({
classNames: ['cbi-button-remove'],
text: _('Close'),
onClick: () => {
destroyLiveRefresh();
ui.hideModal();
},
}),
];
if (options?.getText && options?.showAutoRefreshToggle) {
autoRefreshInput = document.createElement('input');
autoRefreshInput.type = 'checkbox';
autoRefreshInput.className = 'cbi-input-checkbox';
autoRefreshInput.checked = autoRefreshEnabled;
autoRefreshInput.addEventListener('change', () => {
setAutoRefreshEnabled(autoRefreshInput!.checked);
});
footerChildren.unshift(
E('label', { class: 'pdk-partial-modal__checkbox' }, [
autoRefreshInput,
E(
'span',
{ class: 'pdk-partial-modal__checkbox-text' },
options.autoRefreshLabel ?? _('Auto refresh'),
),
]) as HTMLElement,
);
}
const body = E('div', { class: 'pdk-partial-modal__body' }, [
E('div', {}, [
contentEl,
E('div', { class: 'pdk-partial-modal__footer' }, footerChildren),
]),
]) as HTMLElement;
if (
(options?.getText || options?.startAtEnd) &&
typeof document !== 'undefined'
) {
observer = new MutationObserver(() => {
if (!body.isConnected) {
destroyLiveRefresh();
return;
}
scheduleInitialScrollToBottom();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
scheduleInitialScrollToBottom();
}
if (options?.getText && typeof document !== 'undefined') {
startRefreshTimer();
requestRefresh();
}
return body;
}
+17 -1
View File
@@ -12,9 +12,25 @@ export const styles = `
.pdk-partial-modal__footer {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.pdk-partial-modal__footer button {
margin-left: 10px;
margin-left: 0;
}
.pdk-partial-modal__checkbox {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: auto;
cursor: pointer;
user-select: none;
}
.pdk-partial-modal__checkbox-text {
line-height: 1.2;
}
`;
@@ -1,29 +1,55 @@
import { PodkopShellMethods } from '../methods';
import { store } from '../services';
import { logger, store } from '../services';
import { Podkop } from '../types';
let latestServicesInfoRequestId = 0;
function getSettledMethodResponse<T>(
scope: string,
result: PromiseSettledResult<Podkop.MethodResponse<T>>,
): Podkop.MethodResponse<T> {
if (result.status === 'fulfilled') {
return result.value;
}
logger.error('[SERVICES_INFO]', `${scope} failed`, result.reason);
return {
success: false,
error: result.reason instanceof Error ? result.reason.message : '',
};
}
export async function fetchServicesInfo() {
const [podkop, singbox] = await Promise.all([
const requestId = ++latestServicesInfoRequestId;
const [podkopResult, singboxResult, zapretResult] = await Promise.allSettled([
PodkopShellMethods.getStatus(),
PodkopShellMethods.getSingBoxStatus(),
PodkopShellMethods.getZapretStatus(),
]);
if (!podkop.success || !singbox.success) {
store.set({
servicesInfoWidget: {
loading: false,
failed: true,
data: { singbox: 0, podkop: 0 },
},
});
if (requestId !== latestServicesInfoRequestId) {
return;
}
if (podkop.success && singbox.success) {
store.set({
servicesInfoWidget: {
loading: false,
failed: false,
data: { singbox: singbox.data.running, podkop: podkop.data.enabled },
const podkop = getSettledMethodResponse('getStatus', podkopResult);
const singbox = getSettledMethodResponse(
'getSingBoxStatus',
singboxResult,
);
const zapret = getSettledMethodResponse('getZapretStatus', zapretResult);
store.set({
servicesInfoWidget: {
loading: false,
failed: !podkop.success || !singbox.success || !zapret.success,
data: {
singbox: singbox.success ? singbox.data.running : 0,
podkopRunning: podkop.success ? podkop.data.running : 0,
podkopEnabled: podkop.success ? podkop.data.enabled : 0,
zapret: zapret.success ? zapret.data.ready : 0,
},
});
}
},
});
}
@@ -8,6 +8,40 @@ interface IGetDashboardSectionsResponse {
data: Podkop.OutboundGroup[];
}
function getDisplayName(section: Podkop.ConfigSection) {
return section.label || section['.name'];
}
function getSectionAction(section: Podkop.ConfigSection) {
if (section.action) {
return section.action;
}
switch (section.connection_type) {
case 'proxy':
case 'vpn':
return 'proxy';
case 'block':
return 'block';
case 'exclusion':
return 'direct';
default:
return '';
}
}
function getSectionProxyConfigType(section: Podkop.ConfigSection) {
if (section.proxy_config_type) {
return section.proxy_config_type;
}
if (section.connection_type === 'vpn') {
return 'interface';
}
return undefined;
}
export async function getDashboardSections(): Promise<IGetDashboardSectionsResponse> {
const configSections = await getConfigSections();
const clashProxies = await PodkopShellMethods.getClashApiProxies();
@@ -29,134 +63,139 @@ export async function getDashboardSections(): Promise<IGetDashboardSectionsRespo
const data = configSections
.filter(
(section) =>
section.connection_type !== 'block' && section['.type'] !== 'settings',
section.enabled !== '0' &&
getSectionAction(section) === 'proxy',
)
.map((section) => {
if (section.connection_type === 'proxy') {
if (section.proxy_config_type === 'url') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
const displayName = getDisplayName(section);
const proxyConfigType = getSectionProxyConfigType(section);
const activeConfigs = splitProxyString(section.proxy_string);
if (proxyConfigType === 'url') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
const proxyDisplayName =
getProxyUrlName(activeConfigs?.[0]) || outbound?.value?.name || '';
const activeConfigs = splitProxyString(section.proxy_string || '');
const proxyDisplayName =
getProxyUrlName(activeConfigs?.[0]) || outbound?.value?.name || '';
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || section['.name'],
displayName: proxyDisplayName,
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
},
],
};
}
if (section.proxy_config_type === 'outbound') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
const parsedOutbound = JSON.parse(section.outbound_json);
const parsedTag = parsedOutbound?.tag
? decodeURIComponent(parsedOutbound?.tag)
: undefined;
const proxyDisplayName = parsedTag || outbound?.value?.name || '';
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || section['.name'],
displayName: proxyDisplayName,
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
},
],
};
}
if (section.proxy_config_type === 'selector') {
const selector = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
const links = section.selector_proxy_links ?? [];
const outbounds = links
.map((link, index) => ({
link,
outbound: proxies.find(
(item) => item.code === `${section['.name']}-${index + 1}-out`,
),
}))
.map((item) => ({
code: item?.outbound?.code || '',
displayName:
getProxyUrlName(item.link) || item?.outbound?.value?.name || '',
latency: item?.outbound?.value?.history?.[0]?.delay || 0,
type: item?.outbound?.value?.type || '',
selected: selector?.value?.now === item?.outbound?.code,
}));
return {
withTagSelect: true,
code: selector?.code || section['.name'],
displayName: section['.name'],
outbounds,
};
}
if (section.proxy_config_type === 'urltest') {
const selector = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-urltest-out`,
);
const outbounds = (outbound?.value?.all ?? [])
.map((code) => proxies.find((item) => item.code === code))
.map((item, index) => ({
code: item?.code || '',
displayName:
getProxyUrlName(section.urltest_proxy_links?.[index]) ||
item?.value?.name ||
'',
latency: item?.value?.history?.[0]?.delay || 0,
type: item?.value?.type || '',
selected: selector?.value?.now === item?.code,
}));
return {
withTagSelect: true,
code: selector?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || '',
displayName: _('Fastest'),
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: selector?.value?.now === outbound?.code,
},
...outbounds,
],
};
}
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName,
outbounds: [
{
code: outbound?.code || section['.name'],
displayName: proxyDisplayName,
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
},
],
};
}
if (section.connection_type === 'vpn') {
if (proxyConfigType === 'outbound') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
let parsedTag = '';
try {
const parsedOutbound = JSON.parse(section.outbound_json || '{}');
parsedTag = parsedOutbound?.tag
? decodeURIComponent(parsedOutbound.tag)
: '';
} catch (_error) {
parsedTag = '';
}
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName,
outbounds: [
{
code: outbound?.code || section['.name'],
displayName: parsedTag || outbound?.value?.name || '',
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
},
],
};
}
if (proxyConfigType === 'selector') {
const selector = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
const links = section.selector_proxy_links ?? [];
const outbounds = links
.map((link, index) => ({
link,
outbound: proxies.find(
(item) => item.code === `${section['.name']}-${index + 1}-out`,
),
}))
.map((item) => ({
code: item?.outbound?.code || '',
displayName:
getProxyUrlName(item.link) || item?.outbound?.value?.name || '',
latency: item?.outbound?.value?.history?.[0]?.delay || 0,
type: item?.outbound?.value?.type || '',
selected: selector?.value?.now === item?.outbound?.code,
}));
return {
withTagSelect: true,
code: selector?.code || section['.name'],
displayName,
outbounds,
};
}
if (proxyConfigType === 'urltest') {
const selector = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-urltest-out`,
);
const outbounds = (outbound?.value?.all ?? [])
.map((code) => proxies.find((item) => item.code === code))
.map((item, index) => ({
code: item?.code || '',
displayName:
getProxyUrlName(section.urltest_proxy_links?.[index] || '') ||
item?.value?.name ||
'',
latency: item?.value?.history?.[0]?.delay || 0,
type: item?.value?.type || '',
selected: selector?.value?.now === item?.code,
}));
return {
withTagSelect: true,
code: selector?.code || section['.name'],
displayName,
outbounds: [
{
code: outbound?.code || '',
displayName: _('Fastest'),
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: selector?.value?.now === outbound?.code,
},
...outbounds,
],
};
}
if (proxyConfigType === 'interface') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
@@ -164,7 +203,7 @@ export async function getDashboardSections(): Promise<IGetDashboardSectionsRespo
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
displayName,
outbounds: [
{
code: outbound?.code || section['.name'],
@@ -180,7 +219,7 @@ export async function getDashboardSections(): Promise<IGetDashboardSectionsRespo
return {
withTagSelect: false,
code: section['.name'],
displayName: section['.name'],
displayName,
outbounds: [],
};
});
@@ -4,30 +4,37 @@ import { Podkop } from '../../types';
export async function callBaseMethod<T>(
method: Podkop.AvailableMethods,
args: string[] = [],
command: string = '/usr/bin/podkop',
command: string = '/usr/bin/podkop-plus',
): Promise<Podkop.MethodResponse<T>> {
const response = await executeShellCommand({
command,
args: [method as string, ...args],
timeout: 15000,
});
try {
const response = await executeShellCommand({
command,
args: [method as string, ...args],
timeout: 15000,
});
if (response.stdout) {
try {
return {
success: true,
data: JSON.parse(response.stdout) as T,
};
} catch (_e) {
return {
success: true,
data: response.stdout as T,
};
if (response.stdout) {
try {
return {
success: true,
data: JSON.parse(response.stdout) as T,
};
} catch (_e) {
return {
success: true,
data: response.stdout as T,
};
}
}
}
return {
success: false,
error: '',
};
return {
success: false,
error: response.stderr || '',
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : '',
};
}
}
@@ -14,6 +14,10 @@ export const PodkopShellMethods = {
callBaseMethod<Podkop.NftRulesCheckResult>(
Podkop.AvailableMethods.CHECK_NFT_RULES,
),
checkZapretRuntime: async () =>
callBaseMethod<Podkop.ZapretCheckResult>(
Podkop.AvailableMethods.CHECK_ZAPRET_RUNTIME,
),
getStatus: async () =>
callBaseMethod<Podkop.GetStatus>(Podkop.AvailableMethods.GET_STATUS),
checkSingBox: async () =>
@@ -24,6 +28,10 @@ export const PodkopShellMethods = {
callBaseMethod<Podkop.GetSingBoxStatus>(
Podkop.AvailableMethods.GET_SING_BOX_STATUS,
),
getZapretStatus: async () =>
callBaseMethod<Podkop.GetZapretStatus>(
Podkop.AvailableMethods.GET_ZAPRET_STATUS,
),
getClashApiProxies: async () =>
callBaseMethod<ClashAPI.Proxies>(Podkop.AvailableMethods.CLASH_API, [
Podkop.AvailableClashAPIMethods.GET_PROXIES,
@@ -48,31 +56,31 @@ export const PodkopShellMethods = {
callBaseMethod<unknown>(
Podkop.AvailableMethods.RESTART,
[],
'/etc/init.d/podkop',
'/etc/init.d/podkop-plus',
),
start: async () =>
callBaseMethod<unknown>(
Podkop.AvailableMethods.START,
[],
'/etc/init.d/podkop',
'/etc/init.d/podkop-plus',
),
stop: async () =>
callBaseMethod<unknown>(
Podkop.AvailableMethods.STOP,
[],
'/etc/init.d/podkop',
'/etc/init.d/podkop-plus',
),
enable: async () =>
callBaseMethod<unknown>(
Podkop.AvailableMethods.ENABLE,
[],
'/etc/init.d/podkop',
'/etc/init.d/podkop-plus',
),
disable: async () =>
callBaseMethod<unknown>(
Podkop.AvailableMethods.DISABLE,
[],
'/etc/init.d/podkop',
'/etc/init.d/podkop-plus',
),
globalCheck: async () =>
callBaseMethod<unknown>(Podkop.AvailableMethods.GLOBAL_CHECK),
@@ -80,6 +88,8 @@ export const PodkopShellMethods = {
callBaseMethod<unknown>(Podkop.AvailableMethods.SHOW_SING_BOX_CONFIG),
checkLogs: async () =>
callBaseMethod<unknown>(Podkop.AvailableMethods.CHECK_LOGS),
checkSingBoxLogs: async () =>
callBaseMethod<unknown>(Podkop.AvailableMethods.CHECK_SING_BOX_LOGS),
getSystemInfo: async () =>
callBaseMethod<Podkop.GetSystemInfo>(
Podkop.AvailableMethods.GET_SYSTEM_INFO,
@@ -34,7 +34,11 @@ export function coreService() {
line.toLowerCase().includes('[error]') ||
line.toLowerCase().includes('[fatal]')
) {
ui.addNotification('Podkop Error', E('div', {}, line), 'error');
ui.addNotification(
'Podkop Plus Error',
E('div', {}, line),
'error',
);
}
},
},
@@ -159,7 +159,12 @@ export interface StoreType {
servicesInfoWidget: {
loading: boolean;
failed: boolean;
data: { singbox: number; podkop: number };
data: {
singbox: number;
podkopRunning: number;
podkopEnabled: number;
zapret: number;
};
};
sectionsWidget: {
loading: boolean;
@@ -187,6 +192,7 @@ export interface StoreType {
podkop_latest_version: string;
luci_app_version: string;
sing_box_version: string;
zapret_version: string;
openwrt_version: string;
device_model: string;
};
@@ -215,7 +221,7 @@ const initialStore: StoreType = {
servicesInfoWidget: {
loading: true,
failed: false,
data: { singbox: 0, podkop: 0 },
data: { singbox: 0, podkopRunning: 0, podkopEnabled: 0, zapret: 0 },
},
sectionsWidget: {
loading: true,
@@ -340,12 +340,12 @@ async function renderServicesInfoWidget() {
title: _('Services info'),
items: [
{
key: _('Podkop'),
value: servicesInfoWidget.data.podkop
? _('✔ Enabled')
: _('✘ Disabled'),
key: _('Podkop Plus'),
value: servicesInfoWidget.data.podkopRunning
? _('✔ Running')
: _('✘ Stopped'),
attributes: {
class: servicesInfoWidget.data.podkop
class: servicesInfoWidget.data.podkopRunning
? 'pdk_dashboard-page__widgets-section__item__row--success'
: 'pdk_dashboard-page__widgets-section__item__row--error',
},
@@ -361,6 +361,17 @@ async function renderServicesInfoWidget() {
: 'pdk_dashboard-page__widgets-section__item__row--error',
},
},
{
key: _('Zapret'),
value: servicesInfoWidget.data.zapret
? _('✔ Running')
: _('✘ Stopped'),
attributes: {
class: servicesInfoWidget.data.zapret
? 'pdk_dashboard-page__widgets-section__item__row--success'
: 'pdk_dashboard-page__widgets-section__item__row--error',
},
},
],
});
@@ -420,7 +431,16 @@ function onPageUnmount() {
socket.resetAll();
}
let dashboardLifecycleRegistered = false;
let dashboardControllerInitialized = false;
function registerLifecycleListeners() {
if (dashboardLifecycleRegistered) {
return;
}
dashboardLifecycleRegistered = true;
store.subscribe((next, prev, diff) => {
if (
diff.tabService &&
@@ -455,6 +475,12 @@ function registerLifecycleListeners() {
}
export async function initController(): Promise<void> {
if (dashboardControllerInitialized) {
return;
}
dashboardControllerInitialized = true;
onMount('dashboard-status').then(() => {
logger.debug('[DASHBOARD]', 'initController', 'onMount');
onPageMount();
@@ -4,6 +4,7 @@ export enum DIAGNOSTICS_CHECKS {
DNS = 'DNS',
SINGBOX = 'SINGBOX',
NFT = 'NFT',
ZAPRET = 'ZAPRET',
FAKEIP = 'FAKEIP',
OUTBOUNDS = 'OUTBOUNDS',
}
@@ -27,13 +28,18 @@ export const DIAGNOSTICS_CHECKS_MAP: Record<
title: getCheckTitle('Nftables'),
code: DIAGNOSTICS_CHECKS.NFT,
},
[DIAGNOSTICS_CHECKS.OUTBOUNDS]: {
[DIAGNOSTICS_CHECKS.ZAPRET]: {
order: 4,
title: getCheckTitle('Zapret'),
code: DIAGNOSTICS_CHECKS.ZAPRET,
},
[DIAGNOSTICS_CHECKS.OUTBOUNDS]: {
order: 5,
title: getCheckTitle('Outbounds'),
code: DIAGNOSTICS_CHECKS.OUTBOUNDS,
},
[DIAGNOSTICS_CHECKS.FAKEIP]: {
order: 5,
order: 6,
title: getCheckTitle('FakeIP'),
code: DIAGNOSTICS_CHECKS.FAKEIP,
},
@@ -31,9 +31,10 @@ export async function runFakeIPCheck() {
checkFakeIPResponse.data.IP !== checkIPResponse.data.IP,
};
const allGood = checks.router || checks.browserFakeIP || checks.differentIP;
const atLeastOneGood =
const allGood =
checks.router && checks.browserFakeIP && checks.differentIP;
const atLeastOneGood =
checks.router || checks.browserFakeIP || checks.differentIP;
const { state, description } = getMeta({ atLeastOneGood, allGood });
@@ -29,7 +29,7 @@ export async function runSectionsCheck() {
items: [],
});
throw new Error('Sections checks failed');
throw new Error('Rule outbounds checks failed');
}
const items = (await Promise.all(
@@ -127,6 +127,6 @@ export async function runSectionsCheck() {
});
if (!atLeastOneGood) {
throw new Error('Sections checks failed');
throw new Error('Rule outbounds checks failed');
}
}
@@ -0,0 +1,52 @@
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
import { PodkopShellMethods } from '../../../methods';
import { updateCheckStore } from './updateCheckStore';
export async function runZapretCheck() {
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.ZAPRET;
updateCheckStore({
order,
code,
title,
description: _('Checking, please wait'),
state: 'loading',
items: [],
});
const zapretChecks = await PodkopShellMethods.checkZapretRuntime();
if (!zapretChecks.success) {
updateCheckStore({
order,
code,
title,
description: _('Cannot receive checks result'),
state: 'error',
items: [],
});
throw new Error('Zapret checks failed');
}
const installed = Boolean(zapretChecks.data.zapret_installed);
updateCheckStore({
order,
code,
title,
description: installed ? _('Checks passed') : _('Checks failed'),
state: installed ? 'success' : 'error',
items: [
{
state: installed ? 'success' : 'error',
key: installed ? _('Zapret installed') : _('Zapret not installed'),
value: '',
},
],
});
if (!installed) {
throw new Error('Zapret checks failed');
}
}
@@ -17,6 +17,7 @@ export const initialDiagnosticStore: Pick<
podkop_latest_version: 'loading',
luci_app_version: 'loading',
sing_box_version: 'loading',
zapret_version: 'loading',
openwrt_version: 'loading',
device_model: 'loading',
},
@@ -72,6 +73,14 @@ export const initialDiagnosticStore: Pick<
items: [],
state: 'skipped',
},
{
code: DIAGNOSTICS_CHECKS.ZAPRET,
title: DIAGNOSTICS_CHECKS_MAP.ZAPRET.title,
order: DIAGNOSTICS_CHECKS_MAP.ZAPRET.order,
description: _('Not running'),
items: [],
state: 'skipped',
},
{
code: DIAGNOSTICS_CHECKS.OUTBOUNDS,
title: DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS.title,
@@ -120,6 +129,14 @@ export const loadingDiagnosticsChecksStore: Pick<
items: [],
state: 'skipped',
},
{
code: DIAGNOSTICS_CHECKS.ZAPRET,
title: DIAGNOSTICS_CHECKS_MAP.ZAPRET.title,
order: DIAGNOSTICS_CHECKS_MAP.ZAPRET.order,
description: _('Pending'),
items: [],
state: 'skipped',
},
{
code: DIAGNOSTICS_CHECKS.OUTBOUNDS,
title: DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS.title,
@@ -3,6 +3,7 @@ import { runDnsCheck } from './checks/runDnsCheck';
import { runSingBoxCheck } from './checks/runSingBoxCheck';
import { runNftCheck } from './checks/runNftCheck';
import { runFakeIPCheck } from './checks/runFakeIPCheck';
import { runZapretCheck } from './checks/runZapretCheck';
import { loadingDiagnosticsChecksStore } from './diagnostic.store';
import { logger, store, StoreType } from '../../services';
import {
@@ -21,26 +22,48 @@ import { showToast } from '../../../helpers/showToast';
import { renderWikiDisclaimer } from './partials/renderWikiDisclaimer';
import { runSectionsCheck } from './checks/runSectionsCheck';
async function fetchSystemInfo() {
const systemInfo = await PodkopShellMethods.getSystemInfo();
const UNKNOWN_DIAGNOSTICS_SYSTEM_INFO = {
podkop_version: _('unknown'),
podkop_latest_version: _('unknown'),
luci_app_version: _('unknown'),
sing_box_version: _('unknown'),
zapret_version: _('unknown'),
openwrt_version: _('unknown'),
device_model: _('unknown'),
};
if (systemInfo.success) {
let latestSystemInfoRequestId = 0;
let diagnosticLifecycleRegistered = false;
let diagnosticControllerInitialized = false;
async function fetchSystemInfo() {
const requestId = ++latestSystemInfoRequestId;
try {
const systemInfo = await PodkopShellMethods.getSystemInfo();
if (requestId !== latestSystemInfoRequestId) {
return;
}
if (systemInfo.success) {
store.set({
diagnosticsSystemInfo: {
loading: false,
...systemInfo.data,
},
});
return;
}
} catch (error) {
logger.error('[DIAGNOSTIC]', 'fetchSystemInfo failed', error);
}
if (requestId === latestSystemInfoRequestId) {
store.set({
diagnosticsSystemInfo: {
loading: false,
...systemInfo.data,
},
});
} else {
store.set({
diagnosticsSystemInfo: {
loading: false,
podkop_version: _('unknown'),
podkop_latest_version: _('unknown'),
luci_app_version: _('unknown'),
sing_box_version: _('unknown'),
openwrt_version: _('unknown'),
device_model: _('unknown'),
...UNKNOWN_DIAGNOSTICS_SYSTEM_INFO,
},
});
}
@@ -252,9 +275,25 @@ async function handleViewLogs() {
const viewLogs = await PodkopShellMethods.checkLogs();
if (viewLogs.success) {
const getLatestLogs = async () => {
const latestLogs = await PodkopShellMethods.checkLogs();
if (!latestLogs.success) {
throw latestLogs;
}
return (latestLogs.data as string) ?? '';
};
ui.showModal(
_('View logs'),
renderModal(viewLogs.data as string, 'view_logs'),
renderModal(viewLogs.data as string, 'view_logs', {
getText: getLatestLogs,
refreshMs: 250,
initialAutoRefresh: true,
showAutoRefreshToggle: true,
startAtEnd: true,
}),
);
} else {
logger.error('[DIAGNOSTIC]', 'handleViewLogs - e', viewLogs);
@@ -343,13 +382,17 @@ function renderDiagnosticAvailableActionsWidget() {
const servicesInfoWidget = store.get().servicesInfoWidget;
logger.debug('[DIAGNOSTIC]', 'renderDiagnosticAvailableActionsWidget');
const podkopEnabled = Boolean(servicesInfoWidget.data.podkop);
const podkopEnabled = Boolean(servicesInfoWidget.data.podkopEnabled);
const singBoxRunning = Boolean(servicesInfoWidget.data.singbox);
const atLeastOneServiceCommandLoading =
servicesInfoWidget.loading ||
const atLeastOneMutatingActionLoading =
diagnosticsActions.restart.loading ||
diagnosticsActions.start.loading ||
diagnosticsActions.stop.loading;
diagnosticsActions.stop.loading ||
diagnosticsActions.enable.loading ||
diagnosticsActions.disable.loading;
const serviceControlsDisabled =
servicesInfoWidget.loading || atLeastOneMutatingActionLoading;
const utilityActionsDisabled = atLeastOneMutatingActionLoading;
const container = document.getElementById('pdk_diagnostic-page-actions');
@@ -358,49 +401,49 @@ function renderDiagnosticAvailableActionsWidget() {
loading: diagnosticsActions.restart.loading,
visible: true,
onClick: handleRestart,
disabled: atLeastOneServiceCommandLoading,
disabled: serviceControlsDisabled,
},
start: {
loading: diagnosticsActions.start.loading,
visible: !singBoxRunning,
onClick: handleStart,
disabled: atLeastOneServiceCommandLoading,
disabled: serviceControlsDisabled,
},
stop: {
loading: diagnosticsActions.stop.loading,
visible: singBoxRunning,
onClick: handleStop,
disabled: atLeastOneServiceCommandLoading,
disabled: serviceControlsDisabled,
},
enable: {
loading: diagnosticsActions.enable.loading,
visible: !podkopEnabled,
onClick: handleEnable,
disabled: atLeastOneServiceCommandLoading,
disabled: serviceControlsDisabled,
},
disable: {
loading: diagnosticsActions.disable.loading,
visible: podkopEnabled,
onClick: handleDisable,
disabled: atLeastOneServiceCommandLoading,
disabled: serviceControlsDisabled,
},
globalCheck: {
loading: diagnosticsActions.globalCheck.loading,
visible: true,
onClick: handleShowGlobalCheck,
disabled: atLeastOneServiceCommandLoading,
disabled: utilityActionsDisabled,
},
viewLogs: {
loading: diagnosticsActions.viewLogs.loading,
visible: true,
onClick: handleViewLogs,
disabled: atLeastOneServiceCommandLoading,
disabled: false,
},
showSingBoxConfig: {
loading: diagnosticsActions.showSingBoxConfig.loading,
visible: true,
onClick: handleShowSingBoxConfig,
disabled: atLeastOneServiceCommandLoading,
disabled: utilityActionsDisabled,
},
});
@@ -424,23 +467,25 @@ function renderDiagnosticSystemInfoWidget() {
const version = normalizeCompiledVersion(
diagnosticsSystemInfo.podkop_version,
);
const latestVersion =
`${diagnosticsSystemInfo.podkop_latest_version || ''}`.replace(/^v/, '');
const isDevVersion = version === 'dev';
if (loading || unknown || !hasActualVersion || isDevVersion) {
return {
key: 'Podkop',
key: 'Podkop Plus',
value: version,
};
}
if (version !== `v${diagnosticsSystemInfo.podkop_latest_version}`) {
if (`${version}`.replace(/^v/, '') !== latestVersion) {
logger.debug(
'[DIAGNOSTIC]',
'diagnosticsSystemInfo',
diagnosticsSystemInfo,
);
return {
key: 'Podkop',
key: 'Podkop Plus',
value: version,
tag: {
label: _('Outdated'),
@@ -450,7 +495,7 @@ function renderDiagnosticSystemInfoWidget() {
}
return {
key: 'Podkop',
key: 'Podkop Plus',
value: version,
tag: {
label: _('Latest'),
@@ -470,6 +515,10 @@ function renderDiagnosticSystemInfoWidget() {
key: 'Sing-box',
value: diagnosticsSystemInfo.sing_box_version,
},
{
key: 'Zapret',
value: diagnosticsSystemInfo.zapret_version,
},
{
key: 'OS',
value: diagnosticsSystemInfo.openwrt_version,
@@ -510,21 +559,28 @@ async function onStoreUpdate(
}
async function runChecks() {
const runners = [
runDnsCheck,
runSingBoxCheck,
runNftCheck,
runZapretCheck,
runSectionsCheck,
runFakeIPCheck,
];
try {
store.set({
diagnosticsRunAction: { loading: true },
diagnosticsChecks: loadingDiagnosticsChecksStore.diagnosticsChecks,
});
await runDnsCheck();
await runSingBoxCheck();
await runNftCheck();
await runSectionsCheck();
await runFakeIPCheck();
for (const runner of runners) {
try {
await runner();
} catch (e) {
logger.error('[DIAGNOSTIC]', `runChecks - ${runner.name} failed`, e);
}
}
} catch (e) {
logger.error('[DIAGNOSTIC]', 'runChecks - e', e);
} finally {
@@ -532,6 +588,16 @@ async function runChecks() {
}
}
async function loadInitialDiagnosticData() {
await fetchServicesInfo();
const diagnosticStatus = document.getElementById('diagnostic-status');
if (diagnosticStatus?.isConnected && diagnosticStatus.offsetParent !== null) {
await fetchSystemInfo();
}
}
function onPageMount() {
// Cleanup before mount
onPageUnmount();
@@ -554,11 +620,7 @@ function onPageMount() {
// Initial Wiki disclaimer render
renderWikiDisclaimerWidget();
// Initial services info fetch
fetchServicesInfo();
// Initial system info fetch
fetchSystemInfo();
void loadInitialDiagnosticData();
}
function onPageUnmount() {
@@ -575,6 +637,12 @@ function onPageUnmount() {
}
function registerLifecycleListeners() {
if (diagnosticLifecycleRegistered) {
return;
}
diagnosticLifecycleRegistered = true;
store.subscribe((next, prev, diff) => {
if (
diff.tabService &&
@@ -609,6 +677,12 @@ function registerLifecycleListeners() {
}
export async function initController(): Promise<void> {
if (diagnosticControllerInitialized) {
return;
}
diagnosticControllerInitialized = true;
onMount('diagnostic-status').then(() => {
logger.debug('[DIAGNOSTIC]', 'initController', 'onMount');
onPageMount();
@@ -46,7 +46,7 @@ export function renderAvailableActions({
classNames: ['cbi-button-apply'],
onClick: restart.onClick,
icon: renderRotateCcwIcon24,
text: _('Restart podkop'),
text: _('Restart Podkop Plus'),
loading: restart.loading,
disabled: restart.disabled,
}),
@@ -56,7 +56,7 @@ export function renderAvailableActions({
classNames: ['cbi-button-remove'],
onClick: stop.onClick,
icon: renderCircleStopIcon24,
text: _('Stop podkop'),
text: _('Stop Podkop Plus'),
loading: stop.loading,
disabled: stop.disabled,
}),
@@ -66,7 +66,7 @@ export function renderAvailableActions({
classNames: ['cbi-button-save'],
onClick: start.onClick,
icon: renderCirclePlayIcon24,
text: _('Start podkop'),
text: _('Start Podkop Plus'),
loading: start.loading,
disabled: start.disabled,
}),
@@ -28,10 +28,10 @@ export function renderWikiDisclaimer(kind: 'default' | 'error' | 'warning') {
]),
renderButton({
classNames: ['cbi-button-save'],
text: _('Visit Wiki'),
text: _('Open Project Page'),
onClick: () =>
window.open(
'https://podkop.net/docs/troubleshooting/?utm_source=podkop',
'https://github.com/ushan0v/podkop-plus#readme',
'_blank',
'noopener,noreferrer',
),
+46 -43
View File
@@ -28,6 +28,7 @@ export namespace Podkop {
// restart Restart podkop service
// enable Enable podkop autostart
// disable Disable podkop autostart
// uninstall Remove podkop files installed outside opkg/apk
// main Run main podkop process
// list_update Update domain lists
// check_proxy Check proxy connectivity
@@ -52,9 +53,11 @@ export namespace Podkop {
CHECK_DNS_AVAILABLE = 'check_dns_available',
CHECK_FAKEIP = 'check_fakeip',
CHECK_NFT_RULES = 'check_nft_rules',
CHECK_ZAPRET_RUNTIME = 'check_zapret_runtime',
GET_STATUS = 'get_status',
CHECK_SING_BOX = 'check_sing_box',
GET_SING_BOX_STATUS = 'get_sing_box_status',
GET_ZAPRET_STATUS = 'get_zapret_status',
CLASH_API = 'clash_api',
RESTART = 'restart',
START = 'start',
@@ -64,6 +67,7 @@ export namespace Podkop {
GLOBAL_CHECK = 'global_check',
SHOW_SING_BOX_CONFIG = 'show_sing_box_config',
CHECK_LOGS = 'check_logs',
CHECK_SING_BOX_LOGS = 'check_sing_box_logs',
GET_SYSTEM_INFO = 'get_system_info',
}
@@ -89,52 +93,32 @@ export namespace Podkop {
outbounds: Outbound[];
}
export interface ConfigProxyUrlTestSection {
connection_type: 'proxy';
proxy_config_type: 'urltest';
urltest_proxy_links: string[];
}
export type RuleAction = 'proxy' | 'direct' | 'block' | 'zapret';
export type LegacyConnectionType = 'proxy' | 'vpn' | 'block' | 'exclusion';
export type ProxyConfigType =
| 'urltest'
| 'selector'
| 'url'
| 'outbound'
| 'interface';
export interface ConfigProxySelectorSection {
connection_type: 'proxy';
proxy_config_type: 'selector';
selector_proxy_links: string[];
}
export interface ConfigProxyUrlSection {
connection_type: 'proxy';
proxy_config_type: 'url';
proxy_string: string;
}
export interface ConfigProxyOutboundSection {
connection_type: 'proxy';
proxy_config_type: 'outbound';
outbound_json: string;
}
export interface ConfigVpnSection {
connection_type: 'vpn';
interface: string;
}
export interface ConfigBlockSection {
connection_type: 'block';
}
export type ConfigBaseSection =
| ConfigProxyUrlTestSection
| ConfigProxySelectorSection
| ConfigProxyUrlSection
| ConfigProxyOutboundSection
| ConfigVpnSection
| ConfigBlockSection;
export type ConfigSection = ConfigBaseSection & {
export interface ConfigSection {
'.name': string;
'.type': 'settings' | 'section';
'.type': 'settings' | 'rule' | 'node' | 'ruleset' | 'section';
enabled?: string;
action?: RuleAction;
connection_type?: LegacyConnectionType;
proxy_config_type?: ProxyConfigType;
node?: string;
rule_set?: string[];
proxy_string?: string;
nfqws_opt?: string;
selector_proxy_links?: string[];
urltest_proxy_links?: string[];
outbound_json?: string;
interface?: string;
yacd_secret_key?: string;
};
}
export interface MethodSuccessResponse<T> {
success: true;
@@ -186,6 +170,7 @@ export namespace Podkop {
}
export interface GetStatus {
running: number;
enabled: number;
status: string;
}
@@ -201,10 +186,28 @@ export namespace Podkop {
podkop_latest_version: string;
luci_app_version: string;
sing_box_version: string;
zapret_version: string;
openwrt_version: string;
device_model: string;
}
export interface GetZapretStatus {
installed: 0 | 1;
version: string;
configured: 0 | 1;
enabled_rule_count: number;
expected_process_count: number;
running_process_count: number;
standalone_service_enabled: 0 | 1;
standalone_service_running: 0 | 1;
ready: 0 | 1;
conflict: 0 | 1;
}
export interface ZapretCheckResult {
zapret_installed: 0 | 1;
}
export interface GetClashApiProxyLatency {
delay: number;
message?: string;
+64 -4
View File
@@ -13,13 +13,73 @@ ${PartialStyles}
display: none;
}
/* Hide extra H3 for sections tab */
#cbi-podkop-section > h3:nth-child(1) {
/* Hide extra H3 for rules tab */
#cbi-podkop-rule > h3:nth-child(1) {
display: none;
}
/* Vertical align for remove section action button */
#cbi-podkop-section > .cbi-section-remove {
/* Hide extra H3 for nodes tab */
#cbi-podkop-node > h3:nth-child(1) {
display: none;
}
/* Hide extra H3 for rule set tab */
#cbi-podkop-ruleset > h3:nth-child(1) {
display: none;
}
/* Vertical align for remove rule action button */
#cbi-podkop-rule > .cbi-section-remove {
margin-bottom: -32px;
}
#cbi-podkop-rule .cbi-section-actions > div {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Rule reorder visuals */
#cbi-podkop-rule {
position: relative;
}
#cbi-podkop-rule .cbi-section-table-row {
position: relative;
}
#cbi-podkop-rule .cbi-section-table-row.placeholder {
opacity: 1;
}
#cbi-podkop-rule .cbi-section-table-row.placeholder em {
font-style: italic;
}
#cbi-podkop-rule .cbi-section-table-row.drag-over-above::after,
#cbi-podkop-rule .cbi-section-table-row.drag-over-below::after {
content: '';
position: absolute;
left: 10px;
right: 10px;
height: 2px;
border-radius: 2px;
background: var(--primary-color-high, #1976d2);
pointer-events: none;
z-index: 2;
}
#cbi-podkop-rule .cbi-section-table-row.drag-over-above::after {
top: -1px;
}
#cbi-podkop-rule .cbi-section-table-row.drag-over-below::after {
bottom: -1px;
}
/* Vertical align for remove node action button */
#cbi-podkop-node > .cbi-section-remove,
#cbi-podkop-ruleset > .cbi-section-remove {
margin-bottom: -32px;
}
+2 -2
View File
@@ -5,7 +5,7 @@ import path from 'path';
export default defineConfig({
entry: ['src/main.ts'],
format: ['esm'], // пусть tsup генерит export {...}
outDir: '../luci-app-podkop/htdocs/luci-static/resources/view/podkop',
outDir: '../luci-app-podkop-plus/htdocs/luci-static/resources/view/podkop',
outExtension: () => ({ js: '.js' }),
dts: false,
clean: false,
@@ -18,7 +18,7 @@ export default defineConfig({
},
onSuccess: () => {
const outDir =
'../luci-app-podkop/htdocs/luci-static/resources/view/podkop';
'../luci-app-podkop-plus/htdocs/luci-static/resources/view/podkop';
const file = path.join(outDir, 'main.js');
let code = fs.readFileSync(file, 'utf8');
+2 -2
View File
@@ -18,7 +18,7 @@ const config = {
const syncDirs = [
{
local: path.resolve(process.env.LOCAL_DIR_FE ?? '../luci-app-podkop/htdocs/luci-static/resources/view/podkop'),
local: path.resolve(process.env.LOCAL_DIR_FE ?? '../luci-app-podkop-plus/htdocs/luci-static/resources/view/podkop'),
remote: process.env.REMOTE_DIR_FE ?? '/www/luci-static/resources/view/podkop',
},
{
@@ -27,7 +27,7 @@ const syncDirs = [
},
{
local: path.resolve(process.env.LOCAL_DIR_LIB ?? '../podkop/files/usr/lib/'),
remote: process.env.REMOTE_DIR_LIB ?? '/usr/lib/podkop/',
remote: process.env.REMOTE_DIR_LIB ?? '/usr/lib/podkop-plus/',
},
{
local: path.resolve(process.env.LOCAL_DIR_INIT ?? '../podkop/files/etc/init.d/'),
+630 -243
View File
@@ -1,43 +1,153 @@
#!/bin/sh
# shellcheck shell=dash
REPO="https://api.github.com/repos/itdoginfo/podkop/releases/latest"
DOWNLOAD_DIR="/tmp/podkop"
COUNT=3
REPO_OWNER="ushan0v"
REPO_NAME="podkop-plus"
PODKOP_PLUS_RELEASE_TAG="${PODKOP_PLUS_RELEASE_TAG:-}"
PODKOP_PLUS_SOURCE_REF="${PODKOP_PLUS_SOURCE_REF:-}"
ZAPRET_RELEASE_TAG="${ZAPRET_RELEASE_TAG:-}"
DOWNLOAD_RETRIES="${DOWNLOAD_RETRIES:-3}"
REQUIRED_SPACE_KB=15360
PODKOP_PLUS_ASSUME_YES="${PODKOP_PLUS_ASSUME_YES:-0}"
PODKOP_PLUS_KEEP_EXISTING_ZAPRET="${PODKOP_PLUS_KEEP_EXISTING_ZAPRET:-0}"
# Cached flag to switch between ipk or apk package managers
PKG_IS_APK=0
FETCHER=""
TMP_DIR=""
PODKOP_WAS_ENABLED=0
TARGET_ARCH=""
ZAPRET_ARCH=""
ZAPRET_ARCH_CANDIDATES=""
PODKOP_PLUS_RELEASE_JSON=""
PODKOP_PLUS_PACKAGE_URL=""
PODKOP_PLUS_PACKAGE_NAME=""
PODKOP_PLUS_PACKAGE_FILE=""
PODKOP_PLUS_I18N_URL=""
PODKOP_PLUS_I18N_NAME=""
PODKOP_PLUS_I18N_FILE=""
PODKOP_PLUS_PACKAGE_VERSION=""
ZAPRET_RELEASE_JSON=""
ZAPRET_RELEASE_TAG_RESOLVED=""
ZAPRET_BUNDLE_URL=""
ZAPRET_BUNDLE_NAME=""
ZAPRET_PACKAGE_NAME=""
ZAPRET_PACKAGE_FILE=""
ZAPRET_PACKAGE_VERSION=""
command -v apk >/dev/null 2>&1 && PKG_IS_APK=1
rm -rf "$DOWNLOAD_DIR"
mkdir -p "$DOWNLOAD_DIR"
msg() {
printf "\033[32;1m%s\033[0m\n" "$1"
printf '\033[32;1m%s\033[0m\n' "$1"
}
pkg_is_installed () {
local pkg_name="$1"
warn() {
printf '\033[33;1m%s\033[0m\n' "$1"
}
if [ "$PKG_IS_APK" -eq 1 ]; then
# grep -q should work without change based on example from documentation
# apk list --installed --providers dnsmasq
# <dnsmasq> dnsmasq-full-2.90-r3 x86_64 {feeds/base/package/network/services/dnsmasq} (GPL-2.0) [installed]
apk list --installed | grep -q "$pkg_name"
else
opkg list-installed | grep -q "$pkg_name"
fail() {
printf '\033[31;1m%s\033[0m\n' "$1" >&2
exit 1
}
cleanup() {
[ -n "$TMP_DIR" ] && rm -rf "$TMP_DIR"
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
init_tmp_dir() {
TMP_DIR="$(mktemp -d /tmp/podkop-plus.XXXXXX 2>/dev/null || true)"
if [ -z "$TMP_DIR" ]; then
TMP_DIR="/tmp/podkop-plus.$$"
mkdir -p "$TMP_DIR" || fail "Failed to create temporary directory: $TMP_DIR"
fi
}
pkg_remove() {
local pkg_name="$1"
detect_fetcher() {
if command_exists wget; then
FETCHER="wget"
return 0
fi
if command_exists curl; then
FETCHER="curl"
return 0
fi
fail "wget or curl is required to download Podkop Plus"
}
http_get() {
case "$FETCHER" in
wget)
wget -qO- "$1"
;;
curl)
curl -fsSL "$1"
;;
*)
return 1
;;
esac
}
download_file_once() {
case "$FETCHER" in
wget)
wget -q -O "$2" "$1"
;;
curl)
curl -fsSL "$1" -o "$2"
;;
*)
return 1
;;
esac
}
download_with_retry() {
url="$1"
output_path="$2"
label="$3"
attempt=1
while [ "$attempt" -le "$DOWNLOAD_RETRIES" ]; do
msg "Downloading $label ($attempt/$DOWNLOAD_RETRIES)"
if download_file_once "$url" "$output_path" && [ -s "$output_path" ]; then
return 0
fi
rm -f "$output_path"
warn "Retrying $label"
attempt=$((attempt + 1))
done
return 1
}
pkg_list_installed_names() {
if [ "$PKG_IS_APK" -eq 1 ]; then
apk info 2>/dev/null
else
opkg list-installed 2>/dev/null | awk '{print $1}'
fi
}
pkg_is_installed() {
pkg_name="$1"
if [ "$PKG_IS_APK" -eq 1 ]; then
# TODO: check --force-depends flag
# Nothing here: https://openwrt.org/docs/guide-user/additional-software/opkg-to-apk-cheatsheet
apk del "$pkg_name"
apk info -e "$pkg_name" >/dev/null 2>&1
else
opkg remove --force-depends "$pkg_name"
opkg list-installed 2>/dev/null | grep -Eq "^${pkg_name}([[:space:]-]|$)"
fi
}
@@ -49,262 +159,539 @@ pkg_list_update() {
fi
}
pkg_install() {
local pkg_file="$1"
pkg_install_name() {
pkg_name="$1"
if [ "$PKG_IS_APK" -eq 1 ]; then
# Can't install without flag based on info from documentation
# If you're installing a non-standard (self-built) package, use the --allow-untrusted option:
apk add --allow-untrusted "$pkg_file"
apk add "$pkg_name"
else
opkg install "$pkg_file"
opkg install "$pkg_name"
fi
}
update_config() {
printf "\033[48;5;196m\033[1m╔══════════════════════════════════════════════════════════════════════╗\033[0m\n"
printf "\033[48;5;196m\033[1m║ ! Обнаружена старая версия podkop. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Если продолжите обновление, вам потребуется настроить Podkop заново. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Старая конфигурация будет сохранена в /etc/config/podkop-070 ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Подробности: https://github.com/itdoginfo/podkop ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Точно хотите продолжить? ║\033[0m\n"
printf "\033[48;5;196m\033[1m╚══════════════════════════════════════════════════════════════════════╝\033[0m\n"
pkg_remove_if_installed() {
pkg_name="$1"
echo ""
if ! pkg_is_installed "$pkg_name"; then
return 0
fi
printf "\033[48;5;196m\033[1m╔══════════════════════════════════════════════════════════════════════╗\033[0m\n"
printf "\033[48;5;196m\033[1m║ ! Detected old podkop version. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ If you continue the update, you will need to RECONFIGURE podkop. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Your old configuration will be saved to /etc/config/podkop-070 ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Details: https://github.com/itdoginfo/podkop ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Are you sure you want to continue? ║\033[0m\n"
printf "\033[48;5;196m\033[1m╚══════════════════════════════════════════════════════════════════════╝\033[0m\n"
if [ "$PKG_IS_APK" -eq 1 ]; then
apk del "$pkg_name" >/dev/null 2>&1 || true
else
opkg remove --force-depends "$pkg_name" >/dev/null 2>&1 || true
fi
}
msg "Continue? (yes/no)"
pkg_remove_matching_prefix() {
prefix="$1"
while true; do
read -r -p '' CONFIG_UPDATE
case $CONFIG_UPDATE in
yes|y|Y)
mv /etc/config/podkop /etc/config/podkop-070
wget -O /etc/config/podkop https://raw.githubusercontent.com/itdoginfo/podkop/refs/heads/main/podkop/files/etc/config/podkop
msg "Podkop config has been reset to default. Your old config saved in /etc/config/podkop-070"
break
;;
*)
msg "Exit"
exit 1
;;
esac
for pkg_name in $(pkg_list_installed_names | grep "^$prefix" 2>/dev/null); do
pkg_remove_if_installed "$pkg_name"
done
}
main() {
check_system
sing_box
/usr/sbin/ntpd -q -p 194.190.168.1 -p 216.239.35.0 -p 216.239.35.4 -p 162.159.200.1 -p 162.159.200.123
pkg_list_update || { echo "Packages list update failed"; exit 1; }
if [ -f "/etc/init.d/podkop" ]; then
msg "Podkop is already installed. Upgrading..."
else
msg "Installing podkop..."
fi
if command -v curl >/dev/null 2>&1; then
check_response=$(curl -s "https://api.github.com/repos/itdoginfo/podkop/releases/latest")
if echo "$check_response" | grep -q 'API rate limit '; then
msg "You've reached the GitHub rate limit. Repeat in five minutes."
exit 1
fi
fi
local grep_url_pattern
pkg_install_files() {
if [ "$PKG_IS_APK" -eq 1 ]; then
grep_url_pattern='https://[^"[:space:]]*\.apk'
apk add --allow-untrusted "$@"
else
grep_url_pattern='https://[^"[:space:]]*\.ipk'
opkg install "$@"
fi
}
ensure_bootstrap_tool() {
tool_name="$1"
package_name="$2"
if command_exists "$tool_name"; then
return 0
fi
wget -qO- "$REPO" | grep -o "$grep_url_pattern" | while read -r url; do
filename=$(basename "$url")
filepath="$DOWNLOAD_DIR/$filename"
msg "Installing bootstrap dependency: $package_name"
pkg_install_name "$package_name" || fail "Failed to install $package_name"
}
attempt=0
while [ $attempt -lt $COUNT ]; do
msg "Download $filename (count $((attempt+1)))..."
if wget -q -O "$filepath" "$url"; then
if [ -s "$filepath" ]; then
msg "$filename successfully downloaded"
break
fi
fi
msg "Download error for $filename. Retrying..."
rm -f "$filepath"
attempt=$((attempt+1))
done
if [ $attempt -eq $COUNT ]; then
msg "Failed to download $filename after $COUNT attempts"
fi
done
# Check if any files were downloaded
if ! ls "$DOWNLOAD_DIR"/*podkop* >/dev/null 2>&1; then
msg "No packages were downloaded successfully"
exit 1
sync_time() {
if ! command_exists ntpd; then
return 0
fi
for pkg in podkop luci-app-podkop; do
file=""
for f in "$DOWNLOAD_DIR"/"$pkg"*; do
if [ -f "$f" ]; then
file=$(basename "$f")
break
fi
done
if [ -n "$file" ]; then
msg "Installing $file..."
pkg_install "$DOWNLOAD_DIR/$file"
sleep 3
fi
done
ntpd -q \
-p 194.190.168.1 \
-p 216.239.35.0 \
-p 216.239.35.4 \
-p 162.159.200.1 \
-p 162.159.200.123 >/dev/null 2>&1 || true
}
ru=""
for f in "$DOWNLOAD_DIR"/luci-i18n-podkop-ru*; do
if [ -f "$f" ]; then
ru=$(basename "$f")
break
fi
done
if [ -n "$ru" ]; then
if pkg_is_installed luci-i18n-podkop-ru; then
msg "Upgrading Russian translation..."
pkg_remove luci-i18n-podkop*
pkg_install "$DOWNLOAD_DIR/$ru"
else
msg "Русский язык интерфейса ставим? y/n (Install the Russian interface language?)"
while true; do
read -r -p '' RUS
case $RUS in
y)
pkg_remove luci-i18n-podkop*
pkg_install "$DOWNLOAD_DIR/$ru"
break
;;
n)
break
;;
*)
echo "Введите y или n"
;;
esac
done
fi
check_root() {
if command_exists id && [ "$(id -u)" != "0" ]; then
fail "Please run this installer as root"
fi
find "$DOWNLOAD_DIR" -type f -name '*podkop*' -exec rm {} \;
}
check_system() {
# Get router model
MODEL=$(cat /tmp/sysinfo/model)
msg "Router model: $MODEL"
release=""
major=""
model=""
available_space=""
# Check OpenWrt version
openwrt_version=$(cat /etc/openwrt_release | grep DISTRIB_RELEASE | cut -d"'" -f2 | cut -d'.' -f1)
if [ "$openwrt_version" = "23" ]; then
msg "OpenWrt 23.05 не поддерживается начиная с podkop 0.5.0"
msg "Для OpenWrt 23.05 используйте podkop версии 0.4.11 или устанавливайте зависимости и podkop вручную"
msg "Подробности: https://podkop.net/docs/install/#%d1%83%d1%81%d1%82%d0%b0%d0%bd%d0%be%d0%b2%d0%ba%d0%b0-%d0%bd%d0%b0-2305"
exit 1
[ -f /etc/openwrt_release ] || fail "This installer supports OpenWrt only"
model="$(cat /tmp/sysinfo/model 2>/dev/null || true)"
[ -n "$model" ] && msg "Router model: $model"
release="$(sed -n "s/^DISTRIB_RELEASE='\(.*\)'/\1/p" /etc/openwrt_release | head -n 1)"
major="$(printf '%s' "$release" | sed 's/[^0-9].*$//' | cut -d. -f1)"
if [ -n "$major" ] && [ "$major" -lt 24 ]; then
fail "Podkop Plus requires OpenWrt 24.10 or newer"
fi
# Check available space
AVAILABLE_SPACE=$(df /overlay | awk 'NR==2 {print $4}')
REQUIRED_SPACE=15360 # 15MB in KB
available_space="$(df /overlay 2>/dev/null | awk 'NR==2 {print $4}')"
[ -n "$available_space" ] || available_space="$(df / 2>/dev/null | awk 'NR==2 {print $4}')"
if [ "$AVAILABLE_SPACE" -lt "$REQUIRED_SPACE" ]; then
msg "Error: Insufficient space in flash"
msg "Available: $((AVAILABLE_SPACE/1024))MB"
msg "Required: $((REQUIRED_SPACE/1024))MB"
exit 1
fi
if ! nslookup google.com >/dev/null 2>&1; then
msg "DNS is not working."
exit 1
fi
# Check version
if command -v podkop > /dev/null 2>&1; then
local version
version=$(/usr/bin/podkop show_version 2> /dev/null)
if [ -n "$version" ]; then
version=$(echo "$version" | sed 's/^v//')
local major
local minor
local patch
major=$(echo "$version" | cut -d. -f1)
minor=$(echo "$version" | cut -d. -f2)
patch=$(echo "$version" | cut -d. -f3)
# Compare version: must be >= 0.7.0
if [ "$major" -gt 0 ] ||
[ "$major" -eq 0 ] && [ "$minor" -gt 7 ] ||
[ "$major" -eq 0 ] && [ "$minor" -eq 7 ] && [ "$patch" -ge 0 ]; then
msg "Podkop version >= 0.7.0"
break
else
msg "Podkop version < 0.7.0"
update_config
fi
else
msg "Unknown podkop version"
update_config
fi
fi
if pkg_is_installed https-dns-proxy; then
msg "Conflicting package detected: https-dns-proxy. Remove?"
while true; do
read -r -p '' DNSPROXY
case $DNSPROXY in
yes|y|Y)
pkg_remove luci-app-https-dns-proxy
pkg_remove https-dns-proxy
pkg_remove luci-i18n-https-dns-proxy*
break
;;
*)
msg "Exit"
exit 1
;;
esac
done
if [ -n "$available_space" ] && [ "$available_space" -lt "$REQUIRED_SPACE_KB" ]; then
fail "Not enough free flash space. Available: $((available_space / 1024)) MB, required: $((REQUIRED_SPACE_KB / 1024)) MB"
fi
}
sing_box() {
if ! pkg_is_installed "^sing-box"; then
return
confirm_prompt() {
prompt_text="$1"
answer=""
if [ "$PODKOP_PLUS_ASSUME_YES" = "1" ]; then
return 0
fi
sing_box_version=$(sing-box version | head -n 1 | awk '{print $3}')
printf '%s [y/N]: ' "$prompt_text"
read -r answer || return 1
case "$answer" in
y|Y|yes|YES|Yes)
return 0
;;
*)
return 1
;;
esac
}
sanitize_semver() {
printf '%s\n' "$1" | sed 's/^v//;s/-.*$//;s/[^0-9.].*$//'
}
version_ge() {
awk \
-v lhs="$(sanitize_semver "$1")" \
-v rhs="$(sanitize_semver "$2")" \
'
function fill(version, out, parts, count, i) {
count = split(version, parts, ".")
for (i = 1; i <= 3; i++) {
out[i] = (i <= count && parts[i] ~ /^[0-9]+$/) ? parts[i] + 0 : 0
}
}
BEGIN {
fill(lhs, a)
fill(rhs, b)
if (
a[1] > b[1] ||
(a[1] == b[1] && a[2] > b[2]) ||
(a[1] == b[1] && a[2] == b[2] && a[3] >= b[3])
) {
exit 0
}
exit 1
}
'
}
extract_package_version() {
package_name="$1"
case "$package_name" in
luci-app-podkop-plus_*.ipk|luci-app-podkop-plus_*.apk)
printf '%s\n' "$package_name" | sed 's/^luci-app-podkop-plus_//;s/\.\(ipk\|apk\)$//'
;;
zapret_*.ipk)
printf '%s\n' "$package_name" | sed 's/^zapret_//;s/_[^_]*\.ipk$//'
;;
zapret-*.apk)
printf '%s\n' "$package_name" | sed 's/^zapret-//;s/\.apk$//'
;;
*)
printf '%s\n' "$package_name"
;;
esac
}
fetch_github_release_json() {
owner="$1"
repo="$2"
tag="$3"
url=""
response=""
message=""
if [ -n "$tag" ]; then
url="https://api.github.com/repos/${owner}/${repo}/releases/tags/${tag}"
else
url="https://api.github.com/repos/${owner}/${repo}/releases/latest"
fi
response="$(http_get "$url" 2>/dev/null || true)"
[ -n "$response" ] || fail "Failed to query GitHub release metadata for ${owner}/${repo}"
printf '%s' "$response" | jq -e . >/dev/null 2>&1 || fail "GitHub returned an invalid response for ${owner}/${repo}"
message="$(printf '%s' "$response" | jq -r '.message // empty')"
case "$message" in
*"API rate limit"*|*"rate limit exceeded"*)
fail "GitHub API rate limit reached. Try again later or set PODKOP_PLUS_RELEASE_TAG/ZAPRET_RELEASE_TAG manually."
;;
"Not Found")
if [ -n "$tag" ]; then
fail "GitHub release tag not found: ${owner}/${repo}@${tag}"
fi
fail "No published releases found for ${owner}/${repo}"
;;
esac
printf '%s' "$response"
}
resolve_podkop_plus_release() {
asset_ext="ipk"
[ "$PKG_IS_APK" -eq 1 ] && asset_ext="apk"
PODKOP_PLUS_RELEASE_JSON="$(fetch_github_release_json "$REPO_OWNER" "$REPO_NAME" "$PODKOP_PLUS_RELEASE_TAG")"
PODKOP_PLUS_RELEASE_TAG="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | jq -r '.tag_name // empty')"
[ -n "$PODKOP_PLUS_RELEASE_TAG" ] || fail "Failed to detect the Podkop Plus release tag"
[ -n "$PODKOP_PLUS_SOURCE_REF" ] || PODKOP_PLUS_SOURCE_REF="$PODKOP_PLUS_RELEASE_TAG"
PODKOP_PLUS_PACKAGE_URL="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | jq -r --arg ext "$asset_ext" '.assets[] | select((.name | startswith("luci-app-podkop-plus_")) and (.name | endswith("." + $ext))) | .browser_download_url' | sed -n '1p')"
PODKOP_PLUS_I18N_URL="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | jq -r --arg ext "$asset_ext" '.assets[] | select((.name | startswith("luci-i18n-podkop-plus-ru_")) and (.name | endswith("." + $ext))) | .browser_download_url' | sed -n '1p')"
[ -n "$PODKOP_PLUS_PACKAGE_URL" ] || fail "The Podkop Plus release does not contain a luci-app-podkop-plus .$asset_ext package"
[ -n "$PODKOP_PLUS_I18N_URL" ] || fail "The Podkop Plus release does not contain a luci-i18n-podkop-plus-ru .$asset_ext package"
PODKOP_PLUS_PACKAGE_NAME="$(basename "$PODKOP_PLUS_PACKAGE_URL")"
PODKOP_PLUS_I18N_NAME="$(basename "$PODKOP_PLUS_I18N_URL")"
PODKOP_PLUS_PACKAGE_VERSION="$(extract_package_version "$PODKOP_PLUS_PACKAGE_NAME")"
}
detect_installed_podkop_version() {
version=""
if command_exists podkop-plus; then
version="$(podkop-plus show_version 2>/dev/null | head -n 1)"
fi
if [ -z "$version" ] && command_exists podkop; then
version="$(podkop show_version 2>/dev/null | head -n 1)"
fi
printf '%s' "$version"
}
reset_legacy_config_if_needed() {
current_version=""
backup_path=""
default_config_url=""
default_config_tmp=""
[ -f /etc/config/podkop ] || return 0
current_version="$(detect_installed_podkop_version)"
if [ -n "$current_version" ] && version_ge "$current_version" "0.7.0"; then
return 0
fi
if ! pkg_is_installed "podkop" && ! pkg_is_installed "luci-app-podkop" && [ ! -x /etc/init.d/podkop ]; then
return 0
fi
warn "Detected a legacy podkop installation."
warn "The current config will be backed up to /etc/config/podkop-070.<timestamp> and replaced with the Podkop Plus default config."
confirm_prompt "Continue and reset /etc/config/podkop?" || fail "Installation cancelled by user"
backup_path="/etc/config/podkop-070.$(date +%Y%m%d%H%M%S 2>/dev/null || echo "$$")"
mv /etc/config/podkop "$backup_path" || fail "Failed to back up /etc/config/podkop"
default_config_url="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${PODKOP_PLUS_SOURCE_REF}/podkop/files/etc/config/podkop"
default_config_tmp="$TMP_DIR/default-podkop-config"
download_with_retry "$default_config_url" "$default_config_tmp" "default Podkop Plus config" || fail "Failed to download the default Podkop Plus config"
cp "$default_config_tmp" /etc/config/podkop || fail "Failed to restore /etc/config/podkop"
chmod 0644 /etc/config/podkop || true
msg "A fresh Podkop Plus config was installed. Backup saved to $backup_path"
}
remove_conflicting_dns_proxy() {
if ! pkg_is_installed "https-dns-proxy"; then
return 0
fi
warn "Detected conflicting package: https-dns-proxy"
confirm_prompt "Remove https-dns-proxy and continue?" || fail "Please remove https-dns-proxy manually and run the installer again"
pkg_remove_if_installed "luci-app-https-dns-proxy"
pkg_remove_if_installed "https-dns-proxy"
pkg_remove_matching_prefix "luci-i18n-https-dns-proxy"
}
remove_old_sing_box_if_needed() {
installed_version=""
required_version="1.12.4"
if [ "$(printf '%s\n%s\n' "$sing_box_version" "$required_version" | sort -V | head -n 1)" != "$required_version" ]; then
msg "sing-box version $sing_box_version is older than the required version $required_version."
msg "Removing old version..."
service podkop stop
pkg_remove sing-box
pkg_is_installed "sing-box" || return 0
command_exists sing-box || return 0
installed_version="$(sing-box version 2>/dev/null | head -n 1 | awk '{print $3}')"
[ -n "$installed_version" ] || return 0
if version_ge "$installed_version" "$required_version"; then
return 0
fi
warn "sing-box $installed_version is older than the required version $required_version. Removing the old package first."
[ -x /etc/init.d/podkop-plus ] && /etc/init.d/podkop-plus stop >/dev/null 2>&1 || true
[ -x /etc/init.d/podkop ] && /etc/init.d/podkop stop >/dev/null 2>&1 || true
pkg_remove_if_installed "sing-box"
}
remember_autostart_state() {
if [ -x /etc/init.d/podkop-plus ] && /etc/init.d/podkop-plus enabled >/dev/null 2>&1; then
PODKOP_WAS_ENABLED=1
return 0
fi
if [ -x /etc/init.d/podkop ] && /etc/init.d/podkop enabled >/dev/null 2>&1; then
PODKOP_WAS_ENABLED=1
fi
}
main
stop_conflicting_services() {
[ -x /etc/init.d/podkop-plus ] && /etc/init.d/podkop-plus stop >/dev/null 2>&1 || true
[ -x /etc/init.d/podkop-plus ] && /etc/init.d/podkop-plus disable >/dev/null 2>&1 || true
[ -x /etc/init.d/podkop ] && /etc/init.d/podkop stop >/dev/null 2>&1 || true
[ -x /etc/init.d/podkop ] && /etc/init.d/podkop disable >/dev/null 2>&1 || true
}
cleanup_legacy_installation() {
remember_autostart_state
stop_conflicting_services
pkg_remove_matching_prefix "luci-i18n-podkop-plus"
pkg_remove_matching_prefix "luci-i18n-podkop-"
pkg_remove_if_installed "luci-app-podkop-plus"
pkg_remove_if_installed "luci-app-podkop"
pkg_remove_if_installed "podkop"
rm -rf /usr/lib/podkop-plus /usr/lib/podkop /www/luci-static/resources/view/podkop
rm -f /etc/init.d/podkop-plus /etc/init.d/podkop
rm -f /usr/bin/podkop-plus /usr/bin/podkop
rm -f /usr/share/luci/menu.d/luci-app-podkop-plus.json
rm -f /usr/share/rpcd/acl.d/luci-app-podkop-plus.json
rm -f /usr/share/luci/menu.d/luci-app-podkop.json
rm -f /usr/share/rpcd/acl.d/luci-app-podkop.json
rm -f /etc/uci-defaults/50_luci-podkop-plus /etc/uci-defaults/50_luci-podkop
}
append_arch_candidate() {
candidate="$1"
[ -n "$candidate" ] || return 0
case " $ZAPRET_ARCH_CANDIDATES " in
*" $candidate "*)
return 0
;;
esac
if [ -n "$ZAPRET_ARCH_CANDIDATES" ]; then
ZAPRET_ARCH_CANDIDATES="$ZAPRET_ARCH_CANDIDATES $candidate"
else
ZAPRET_ARCH_CANDIDATES="$candidate"
fi
}
add_arch_family_fallbacks() {
arch="$1"
append_arch_candidate "$arch"
case "$arch" in
aarch64_*)
append_arch_candidate "aarch64_generic"
;;
riscv64_*)
append_arch_candidate "riscv64_generic"
;;
arm_cortex-a7_neon-vfpv4)
append_arch_candidate "arm_cortex-a7_vfpv4"
append_arch_candidate "arm_cortex-a7"
;;
arm_cortex-a7_*)
append_arch_candidate "arm_cortex-a7"
;;
arm_cortex-a9_*)
append_arch_candidate "arm_cortex-a9"
;;
mipsel_24kc_24kf)
append_arch_candidate "mipsel_24kc"
;;
esac
}
resolve_arch_candidates() {
arch_list=""
if [ "$PKG_IS_APK" -eq 1 ]; then
arch_list="$(apk --print-arch 2>/dev/null || true)"
else
arch_list="$(opkg print-architecture 2>/dev/null | awk '$1 == "arch" && $2 !~ /^(all|noarch)$/ {print $2 " " $3}' | sort -k2,2nr | awk '{print $1}')"
fi
if [ -z "$arch_list" ]; then
arch_list="$(uname -m 2>/dev/null || true)"
fi
for arch in $arch_list; do
[ -n "$TARGET_ARCH" ] || TARGET_ARCH="$arch"
add_arch_family_fallbacks "$arch"
done
[ -n "$TARGET_ARCH" ] || fail "Failed to detect the router package architecture"
}
resolve_zapret_release() {
candidate_name=""
if [ "$PODKOP_PLUS_KEEP_EXISTING_ZAPRET" = "1" ] && pkg_is_installed "zapret"; then
warn "Keeping the existing zapret package because PODKOP_PLUS_KEEP_EXISTING_ZAPRET=1"
return 0
fi
ZAPRET_RELEASE_JSON="$(fetch_github_release_json "remittor" "zapret-openwrt" "$ZAPRET_RELEASE_TAG")"
ZAPRET_RELEASE_TAG_RESOLVED="$(printf '%s' "$ZAPRET_RELEASE_JSON" | jq -r '.tag_name // empty')"
[ -n "$ZAPRET_RELEASE_TAG_RESOLVED" ] || fail "Failed to detect the zapret release tag"
for arch in $ZAPRET_ARCH_CANDIDATES; do
candidate_name="$(printf '%s' "$ZAPRET_RELEASE_JSON" | jq -r --arg arch "$arch" '.assets[] | select(.name | endswith("_" + $arch + ".zip")) | .name' | sed -n '1p')"
if [ -n "$candidate_name" ]; then
ZAPRET_ARCH="$arch"
ZAPRET_BUNDLE_NAME="$candidate_name"
break
fi
done
[ -n "$ZAPRET_BUNDLE_NAME" ] || fail "No zapret package was found for architecture: $TARGET_ARCH. Tried: $ZAPRET_ARCH_CANDIDATES"
ZAPRET_BUNDLE_URL="$(printf '%s' "$ZAPRET_RELEASE_JSON" | jq -r --arg name "$ZAPRET_BUNDLE_NAME" '.assets[] | select(.name == $name) | .browser_download_url' | sed -n '1p')"
[ -n "$ZAPRET_BUNDLE_URL" ] || fail "Failed to resolve the zapret download URL for $ZAPRET_BUNDLE_NAME"
}
download_podkop_plus_packages() {
PODKOP_PLUS_PACKAGE_FILE="$TMP_DIR/$PODKOP_PLUS_PACKAGE_NAME"
PODKOP_PLUS_I18N_FILE="$TMP_DIR/$PODKOP_PLUS_I18N_NAME"
download_with_retry "$PODKOP_PLUS_PACKAGE_URL" "$PODKOP_PLUS_PACKAGE_FILE" "$PODKOP_PLUS_PACKAGE_NAME" || fail "Failed to download $PODKOP_PLUS_PACKAGE_NAME"
download_with_retry "$PODKOP_PLUS_I18N_URL" "$PODKOP_PLUS_I18N_FILE" "$PODKOP_PLUS_I18N_NAME" || fail "Failed to download $PODKOP_PLUS_I18N_NAME"
}
download_and_extract_zapret_package() {
bundle_file=""
inner_package_path=""
[ -n "$ZAPRET_BUNDLE_URL" ] || return 0
bundle_file="$TMP_DIR/$ZAPRET_BUNDLE_NAME"
download_with_retry "$ZAPRET_BUNDLE_URL" "$bundle_file" "$ZAPRET_BUNDLE_NAME" || fail "Failed to download $ZAPRET_BUNDLE_NAME"
if [ "$PKG_IS_APK" -eq 1 ]; then
inner_package_path="$(unzip -l "$bundle_file" | awk '{print $4}' | grep -E '^apk/zapret-.*\.apk$' | sed -n '1p')"
else
inner_package_path="$(unzip -l "$bundle_file" | awk '{print $4}' | grep -E "^zapret_.*_${ZAPRET_ARCH}\.ipk$" | sed -n '1p')"
[ -n "$inner_package_path" ] || inner_package_path="$(unzip -l "$bundle_file" | awk '{print $4}' | grep -E '^zapret_.*\.ipk$' | sed -n '1p')"
fi
[ -n "$inner_package_path" ] || fail "Failed to locate the zapret package inside $ZAPRET_BUNDLE_NAME"
ZAPRET_PACKAGE_NAME="$(basename "$inner_package_path")"
ZAPRET_PACKAGE_FILE="$TMP_DIR/$ZAPRET_PACKAGE_NAME"
ZAPRET_PACKAGE_VERSION="$(extract_package_version "$ZAPRET_PACKAGE_NAME")"
unzip -p "$bundle_file" "$inner_package_path" > "$ZAPRET_PACKAGE_FILE" || fail "Failed to extract $ZAPRET_PACKAGE_NAME"
[ -s "$ZAPRET_PACKAGE_FILE" ] || fail "The extracted zapret package is empty"
}
install_packages() {
if [ "$PODKOP_PLUS_KEEP_EXISTING_ZAPRET" = "1" ] && pkg_is_installed "zapret"; then
pkg_install_files "$PODKOP_PLUS_PACKAGE_FILE" "$PODKOP_PLUS_I18N_FILE" || fail "Podkop Plus package installation failed"
return 0
fi
pkg_remove_if_installed "zapret"
pkg_install_files "$ZAPRET_PACKAGE_FILE" "$PODKOP_PLUS_PACKAGE_FILE" "$PODKOP_PLUS_I18N_FILE" || fail "Package installation failed"
}
post_install() {
rm -f /var/luci-indexcache* /tmp/luci-indexcache*
[ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload >/dev/null 2>&1 || true
if [ "$PODKOP_WAS_ENABLED" -eq 1 ] && [ -x /etc/init.d/podkop-plus ]; then
/etc/init.d/podkop-plus enable >/dev/null 2>&1 || true
fi
}
main() {
trap cleanup EXIT HUP INT TERM
check_root
init_tmp_dir
detect_fetcher
sync_time
check_system
pkg_list_update || fail "Failed to update package lists"
ensure_bootstrap_tool "jq" "jq"
ensure_bootstrap_tool "unzip" "unzip"
resolve_podkop_plus_release
reset_legacy_config_if_needed
remove_conflicting_dns_proxy
remove_old_sing_box_if_needed
resolve_arch_candidates
resolve_zapret_release
cleanup_legacy_installation
download_podkop_plus_packages
download_and_extract_zapret_package
install_packages
post_install
msg "Podkop Plus $PODKOP_PLUS_PACKAGE_VERSION has been installed successfully"
msg "Source release: ${REPO_OWNER}/${REPO_NAME}@${PODKOP_PLUS_RELEASE_TAG}"
if [ -n "$ZAPRET_PACKAGE_VERSION" ]; then
msg "zapret $ZAPRET_PACKAGE_VERSION installed for architecture $ZAPRET_ARCH"
msg "zapret source: remittor/zapret-openwrt@${ZAPRET_RELEASE_TAG_RESOLVED}"
elif [ "$PODKOP_PLUS_KEEP_EXISTING_ZAPRET" = "1" ] && pkg_is_installed "zapret"; then
warn "Keeping the existing zapret package"
fi
warn "Open LuCI and review your rules before enabling Podkop Plus"
}
main
+61
View File
@@ -0,0 +1,61 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-podkop-plus
PODKOP_BASE_VERSION:=$(or $(PODKOP_BASE_VERSION),$(PODKOP_VERSION),0.7.14)
PODKOP_FORK_RELEASE:=$(or $(PODKOP_FORK_RELEASE),1)
PODKOP_PACKAGE_VERSION:=$(PODKOP_BASE_VERSION)-$(PODKOP_FORK_RELEASE)
PKG_VERSION:=$(PODKOP_PACKAGE_VERSION)
PKG_RELEASE:=
PKG_PO_VERSION:=$(PODKOP_PACKAGE_VERSION)
LUCI_TITLE:=LuCI Podkop Plus app
LUCI_DEPENDS:=+luci-base +sing-box +curl +jq +kmod-nft-tproxy +coreutils-base64 +bind-dig +nftables +kmod-nft-nat +kmod-nft-offload +kmod-nft-queue +libnetfilter-queue +libmnl +libcap +zlib +gzip +coreutils +coreutils-sort +coreutils-sleep
LUCI_PKGARCH:=all
LUCI_LANG.ru:=Русский (Russian)
LUCI_LANG.en:=English
LUCI_MAINTAINER:=ushan0v <ushan0v@users.noreply.github.com>
CONFLICTS:=luci-app-podkop podkop luci-i18n-podkop-ru
PKG_LICENSE:=GPL-2.0-or-later
LUCI_LANGUAGES:=en ru
include $(TOPDIR)/feeds/luci/luci.mk
define Package/$(PKG_NAME)/description
Rule-based Podkop Plus LuCI app with hybrid sing-box + zapret orchestration
endef
define Package/$(PKG_NAME)/prerm
#!/bin/sh
grep -q "105 podkopplus" /etc/iproute2/rt_tables && sed -i "/105 podkopplus/d" /etc/iproute2/rt_tables
/etc/init.d/podkop-plus stop >/dev/null 2>&1 || true
exit 0
endef
define Package/$(PKG_NAME)/conffiles
/etc/config/podkop
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)$(HTDOCS)
$(CP) $(PKG_BUILD_DIR)/htdocs/* $(1)$(HTDOCS)/
$(INSTALL_DIR) $(1)/
$(CP) $(PKG_BUILD_DIR)/root/* $(1)/
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ../podkop/files/etc/init.d/podkop $(1)/etc/init.d/podkop-plus
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ../podkop/files/etc/config/podkop $(1)/etc/config/podkop
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) ../podkop/files/usr/bin/podkop $(1)/usr/bin/podkop-plus
$(INSTALL_DIR) $(1)/usr/lib/podkop-plus
$(CP) ../podkop/files/usr/lib/* $(1)/usr/lib/podkop-plus/
sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PODKOP_PACKAGE_VERSION)/g' $(1)$(HTDOCS)/luci-static/resources/view/podkop/main.js || true
sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PODKOP_PACKAGE_VERSION)/g' $(1)/usr/lib/podkop-plus/constants.sh || true
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
@@ -0,0 +1,259 @@
"use strict";
"require form";
"require baseclass";
"require tools.widgets as widgets";
"require view.podkop.main as main";
function createNodeContent(section) {
let o = section.option(form.Flag, "enabled", _("Enable"));
o.default = "1";
o.rmempty = false;
o.editable = true;
o = section.option(
form.ListValue,
"proxy_config_type",
_("Node Type"),
_("Choose how this outbound should be configured"),
);
o.value("url", _("Connection URL"));
o.value("selector", _("Selector"));
o.value("urltest", _("URLTest"));
o.value("outbound", _("Outbound JSON"));
o.value("interface", _("Interface"));
o.default = "url";
o.rmempty = false;
o.editable = true;
o = section.option(
form.TextValue,
"proxy_string",
_("Proxy Configuration URL"),
_("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"),
);
o.depends("proxy_config_type", "url");
o.rows = 5;
o.wrap = "soft";
o.textarea = true;
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return true;
}
const validation = main.validateProxyUrl(value);
return validation.valid ? true : validation.message;
};
o = section.option(
form.TextValue,
"outbound_json",
_("Outbound JSON"),
_("Enter a complete sing-box outbound object"),
);
o.depends("proxy_config_type", "outbound");
o.rows = 10;
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return true;
}
const validation = main.validateOutboundJson(value);
return validation.valid ? true : validation.message;
};
o = section.option(
form.DynamicList,
"selector_proxy_links",
_("Selector Links"),
_("A manual group of proxy URLs for this node"),
);
o.depends("proxy_config_type", "selector");
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return true;
}
const validation = main.validateProxyUrl(value);
return validation.valid ? true : validation.message;
};
o = section.option(
form.DynamicList,
"urltest_proxy_links",
_("URLTest Links"),
_("A latency-tested group of proxy URLs for this node"),
);
o.depends("proxy_config_type", "urltest");
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return true;
}
const validation = main.validateProxyUrl(value);
return validation.valid ? true : validation.message;
};
o = section.option(form.ListValue, "urltest_check_interval", _("URLTest Interval"));
o.value("30s", _("Every 30 seconds"));
o.value("1m", _("Every minute"));
o.value("3m", _("Every 3 minutes"));
o.value("5m", _("Every 5 minutes"));
o.default = "3m";
o.rmempty = false;
o.depends("proxy_config_type", "urltest");
o.modalonly = true;
o = section.option(
form.Value,
"urltest_tolerance",
_("URLTest Tolerance"),
_("Maximum response time delta in milliseconds"),
);
o.default = "50";
o.rmempty = false;
o.depends("proxy_config_type", "urltest");
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return true;
}
const parsed = parseFloat(value);
if (
/^[0-9]+$/.test(value) &&
!isNaN(parsed) &&
isFinite(parsed) &&
parsed >= 50 &&
parsed <= 1000
) {
return true;
}
return _("Must be a number in the range of 50 - 1000");
};
o = section.option(form.Value, "urltest_testing_url", _("URLTest URL"));
o.value(
"https://www.gstatic.com/generate_204",
"https://www.gstatic.com/generate_204 (Google)",
);
o.value(
"https://cp.cloudflare.com/generate_204",
"https://cp.cloudflare.com/generate_204 (Cloudflare)",
);
o.value("https://captive.apple.com", "https://captive.apple.com (Apple)");
o.value(
"https://connectivity-check.ubuntu.com",
"https://connectivity-check.ubuntu.com (Ubuntu)",
);
o.default = "https://www.gstatic.com/generate_204";
o.rmempty = false;
o.depends("proxy_config_type", "urltest");
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return true;
}
const validation = main.validateUrl(value);
return validation.valid ? true : validation.message;
};
o = section.option(
form.Flag,
"enable_udp_over_tcp",
_("UDP over TCP"),
_("Applicable for SOCKS and Shadowsocks links"),
);
o.default = "0";
o.rmempty = false;
o.modalonly = true;
o = section.option(
widgets.DeviceSelect,
"interface",
_("Interface"),
_("Use a network interface as the outbound for this node"),
);
o.noaliases = true;
o.nobridges = false;
o.noinactive = false;
o.depends("proxy_config_type", "interface");
o.modalonly = true;
o.filter = function (_section_id, value) {
const blockedInterfaces = [
"br-lan",
"eth0",
"eth1",
"wan",
"phy0-ap0",
"phy1-ap0",
"pppoe-wan",
"lan",
];
if (blockedInterfaces.includes(value)) {
return false;
}
const device = this.devices.find((dev) => dev.getName() === value);
if (!device) {
return true;
}
const type = device.getType();
const isWireless =
type === "wifi" || type === "wireless" || type.indexOf("wlan") >= 0;
return !isWireless;
};
o = section.option(
form.Flag,
"domain_resolver_enabled",
_("Resolver For Interface Outbound"),
_("Enable a dedicated DNS resolver when this node uses an interface"),
);
o.default = "0";
o.rmempty = false;
o.depends("proxy_config_type", "interface");
o.modalonly = true;
o = section.option(form.ListValue, "domain_resolver_dns_type", _("DNS Protocol"));
o.value("doh", _("DNS over HTTPS (DoH)"));
o.value("dot", _("DNS over TLS (DoT)"));
o.value("udp", _("UDP"));
o.default = "udp";
o.rmempty = false;
o.depends({
proxy_config_type: "interface",
domain_resolver_enabled: "1",
});
o.modalonly = true;
o = section.option(form.Value, "domain_resolver_dns_server", _("DNS Server"));
Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => {
o.value(key, _(label));
});
o.default = "8.8.8.8";
o.rmempty = false;
o.depends({
proxy_config_type: "interface",
domain_resolver_enabled: "1",
});
o.modalonly = true;
o.validate = function (_section_id, value) {
const validation = main.validateDNS(value);
return validation.valid ? true : validation.message;
};
}
const EntryPoint = {
createNodeContent,
};
return baseclass.extend(EntryPoint);
@@ -0,0 +1,432 @@
"use strict";
"require view";
"require form";
"require baseclass";
"require uci";
"require ui";
"require view.podkop.main as main";
// Global settings
"require view.podkop.settings as settings";
// Sections
"require view.podkop.section as section";
// Dashboard
"require view.podkop.dashboard as dashboard";
// Diagnostic
"require view.podkop.diagnostic as diagnostic";
function renderSectionAdd(sectionRef, extra_class) {
const el = form.GridSection.prototype.renderSectionAdd.apply(sectionRef, [
extra_class,
]);
const nameEl = el.querySelector(".cbi-section-create-name");
ui.addValidator(
nameEl,
"uciname",
true,
(value) => {
const button = el.querySelector(".cbi-section-create > .cbi-button-add");
const uciconfig = sectionRef.uciconfig || sectionRef.map.config;
if (!value) {
button.disabled = true;
return true;
}
if (uci.get(uciconfig, value)) {
button.disabled = true;
return _("Expecting: %s").format(_("unique UCI identifier"));
}
button.disabled = null;
return true;
},
"blur",
"keyup",
);
return el;
}
function repaintRuleRowColors() {
const container = document.getElementById("cbi-podkop-rule");
if (!container) {
return;
}
const rows = Array.from(
container.querySelectorAll(".cbi-section-table-row"),
).filter(
(row) =>
row.closest("#cbi-podkop-rule") === container &&
!row.classList.contains("placeholder"),
);
rows.forEach((row, index) => {
row.classList.remove("cbi-rowstyle-1", "cbi-rowstyle-2");
row.classList.add(index % 2 === 0 ? "cbi-rowstyle-1" : "cbi-rowstyle-2");
});
}
function clearRuleDropIndicators(container) {
container.querySelectorAll(".drag-over-above, .drag-over-below").forEach((row) => {
row.classList.remove("drag-over-above");
row.classList.remove("drag-over-below");
});
}
function sanitizeRuleHeaderRows(root) {
if (!root) {
return;
}
const rows = [];
if (typeof root.matches === "function" && root.matches(".cbi-section-table-titles")) {
rows.push(root);
}
if (typeof root.querySelectorAll === "function") {
rows.push(...root.querySelectorAll(".cbi-section-table-titles"));
}
rows.forEach((row) => {
const sanitizedRow = row.cloneNode(true);
sanitizedRow.removeAttribute("data-sort-direction");
sanitizedRow.style.cursor = "default";
sanitizedRow.querySelectorAll("[data-sortable-row]").forEach((cell) => {
cell.removeAttribute("data-sortable-row");
cell.removeAttribute("data-sort-direction");
cell.style.cursor = "default";
});
if (row.parentNode) {
row.parentNode.replaceChild(sanitizedRow, row);
return;
}
row.removeAttribute("data-sort-direction");
row.style.cursor = "default";
row.querySelectorAll("[data-sortable-row]").forEach((cell) => {
cell.removeAttribute("data-sortable-row");
cell.removeAttribute("data-sort-direction");
cell.style.cursor = "default";
});
});
}
function sanitizeRenderedRuleHeaders(output) {
if (!output) {
return output;
}
if (Array.isArray(output)) {
output.forEach((item) => sanitizeRenderedRuleHeaders(item));
return output;
}
sanitizeRuleHeaderRows(output);
return output;
}
function showPendingChangeSaveError(error) {
ui.showModal(_("Save error"), [
E("p", {}, [_("An error occurred while saving the form:")]),
E(
"p",
{},
[E("em", { style: "white-space:pre-wrap" }, [error?.message || `${error}`])],
),
E("div", { class: "right" }, [
E("button", { class: "cbi-button", click: ui.hideModal }, [_("Dismiss")]),
]),
]);
}
function rerenderGridSection(sectionRef) {
const configName = sectionRef.uciconfig ?? sectionRef.map.config;
const sectionNode = document.getElementById(
`cbi-${configName}-${sectionRef.sectiontype}`,
);
if (!sectionNode) {
return Promise.resolve();
}
const cfgsections = sectionRef.cfgsections();
const renderTasks = cfgsections.map((sectionId) =>
sectionRef.renderUCISection(sectionId),
);
return Promise.all(renderTasks).then((nodes) => {
const nextNode = sectionRef.renderContents(cfgsections, nodes);
sectionNode.replaceWith(nextNode);
if (sectionRef.sectiontype === "rule") {
sanitizeRuleHeaderRows(nextNode);
window.setTimeout(() => {
installRuleRowInteractionSync();
repaintRuleRowColors();
}, 0);
}
});
}
function removeRuleRowFromDom(sectionRef, section_id) {
const configName = sectionRef.uciconfig ?? sectionRef.map.config;
const sectionNode = document.getElementById(
`cbi-${configName}-${sectionRef.sectiontype}`,
);
if (!sectionNode) {
return;
}
const tbody = sectionNode.querySelector(".cbi-section-tbody");
if (!tbody) {
return;
}
const rows = Array.from(
tbody.querySelectorAll(".cbi-section-table-row"),
).filter((row) => !row.classList.contains("placeholder"));
const currentRow = rows.find((row) => row.getAttribute("data-sid") === section_id);
if (currentRow) {
currentRow.remove();
}
tbody.querySelectorAll(".cbi-section-table-row.placeholder").forEach((row) => {
row.remove();
});
const remainingRows = Array.from(
tbody.querySelectorAll(".cbi-section-table-row"),
).filter((row) => !row.classList.contains("placeholder"));
if (!remainingRows.length) {
tbody.appendChild(
E(
"tr",
{ class: "tr cbi-section-table-row placeholder" },
E("td", { class: "td" }, sectionRef.renderSectionPlaceholder()),
),
);
}
const table = sectionNode.querySelector(".cbi-section-table");
if (table && typeof sectionRef.stabilizeActionColumnWidth === "function") {
try {
sectionRef.stabilizeActionColumnWidth(table);
} catch (_error) {}
}
repaintRuleRowColors();
}
function saveRulePendingChanges(sectionRef, mutate, options = {}) {
const { rerenderSection = false, onSuccess = null } = options;
sectionRef.__pdkRulePendingChangesPromise = Promise.resolve(
sectionRef.__pdkRulePendingChangesPromise,
)
.catch(() => {})
.then(() => mutate())
.then(() => sectionRef.map.data.save())
.then(() => ui.changes.init())
.then(() => (typeof onSuccess === "function" ? onSuccess() : null))
.then(() => (rerenderSection ? rerenderGridSection(sectionRef) : null))
.catch((error) => {
return sectionRef.map
.load()
.then(() => (rerenderSection ? rerenderGridSection(sectionRef) : null))
.catch(() => {})
.finally(() => showPendingChangeSaveError(error));
});
return sectionRef.__pdkRulePendingChangesPromise;
}
function installRulePendingChanges(sectionRef) {
if (sectionRef.__pdkRulePendingChangesBound) {
return;
}
sectionRef.__pdkRulePendingChangesBound = true;
const originalHandleRemove = sectionRef.handleRemove;
if (typeof originalHandleRemove === "function") {
sectionRef.handleRemove = function (section_id, ev) {
const configName = this.uciconfig ?? this.map.config;
return saveRulePendingChanges(
this,
() => {
this.map.data.remove(configName, section_id);
},
{
onSuccess: () => removeRuleRowFromDom(this, section_id),
},
);
};
}
}
function installRuleRowInteractionSync() {
const container = document.getElementById("cbi-podkop-rule");
if (!container || container.__pdkRowColorSyncBound) {
return;
}
sanitizeRuleHeaderRows(container);
container.__pdkRowColorSyncBound = true;
const schedule = () => {
if (typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(repaintRuleRowColors);
} else {
window.setTimeout(repaintRuleRowColors, 0);
}
};
const observer = new MutationObserver(schedule);
observer.observe(container, { childList: true, subtree: true });
container.addEventListener("dragend", () => {
window.setTimeout(() => {
clearRuleDropIndicators(container);
schedule();
}, 0);
});
container.addEventListener("drop", () => {
window.setTimeout(() => {
clearRuleDropIndicators(container);
schedule();
}, 0);
});
container.addEventListener("mouseleave", () => {
window.setTimeout(() => {
clearRuleDropIndicators(container);
schedule();
}, 0);
});
["mouseup", "dragend", "drop"].forEach((eventName) => {
container.addEventListener(eventName, schedule);
});
schedule();
}
function configureGridSection(sectionRef, type, title, addTitle) {
sectionRef.anonymous = false;
sectionRef.addremove = true;
sectionRef.sortable = true;
sectionRef.rowcolors = true;
sectionRef.nodescriptions = true;
sectionRef.modaltitle = function (section_id) {
const label = uci.get("podkop", section_id, "label");
return section_id ? `${title}: ${label || section_id}` : addTitle;
};
sectionRef.sectiontitle = function (section_id) {
return uci.get("podkop", section_id, "label") || section_id;
};
sectionRef.renderSectionAdd = function (extra_class) {
return renderSectionAdd(sectionRef, extra_class);
};
if (type === "rule") {
const originalRenderHeaderRows = sectionRef.renderHeaderRows;
if (typeof originalRenderHeaderRows === "function") {
sectionRef.renderHeaderRows = function () {
return sanitizeRenderedRuleHeaders(
originalRenderHeaderRows.apply(this, arguments),
);
};
}
sectionRef.handleSort = function () {
return false;
};
installRulePendingChanges(sectionRef);
}
}
const EntryPoint = {
async render() {
main.injectGlobalStyles();
const podkopMap = new form.Map(
"podkop",
_("Podkop Plus Settings"),
_("Configuration for Podkop Plus service"),
);
podkopMap.tabbed = true;
const rulesSection = podkopMap.section(
form.GridSection,
"rule",
_("Sections"),
_("Drag rows to change priority. The rule at the top is checked first."),
);
configureGridSection(
rulesSection,
"rule",
_("Section"),
_("Add a section"),
);
section.createSectionContent(rulesSection);
const settingsSection = podkopMap.section(
form.TypedSection,
"settings",
_("Settings"),
);
settingsSection.anonymous = true;
settingsSection.addremove = false;
settingsSection.cfgsections = function () {
return ["settings"];
};
settings.createSettingsContent(settingsSection);
const diagnosticSection = podkopMap.section(
form.TypedSection,
"diagnostic",
_("Diagnostics"),
);
diagnosticSection.anonymous = true;
diagnosticSection.addremove = false;
diagnosticSection.cfgsections = function () {
return ["diagnostic"];
};
diagnostic.createDiagnosticContent(diagnosticSection);
const dashboardSection = podkopMap.section(
form.TypedSection,
"dashboard",
_("Dashboard"),
);
dashboardSection.anonymous = true;
dashboardSection.addremove = false;
dashboardSection.cfgsections = function () {
return ["dashboard"];
};
dashboard.createDashboardContent(dashboardSection);
main.coreService();
const rendered = await podkopMap.render();
window.setTimeout(installRuleRowInteractionSync, 0);
return rendered;
},
};
return view.extend(EntryPoint);
@@ -0,0 +1,96 @@
"use strict";
"require form";
"require baseclass";
"require uci";
"require view.podkop.main as main";
function createRulesetContent(section) {
let o = section.option(form.Flag, "enabled", _("Enable"));
o.default = "1";
o.rmempty = false;
o.editable = true;
o = section.option(form.ListValue, "type", _("Type"));
o.value("local", _("Local"));
o.value("remote", _("Remote"));
o.default = "remote";
o.rmempty = false;
o.editable = true;
o = section.option(form.ListValue, "format", _("Format"));
o.value("binary", _("Binary file"));
o.value("source", _("Source file"));
o.default = "binary";
o.rmempty = false;
o.editable = true;
o = section.option(
form.Value,
"path",
_("Path"),
_("Local file path to a sing-box rule set"),
);
o.datatype = "file";
o.placeholder = "/etc/podkop/ruleset/example.srs";
o.rmempty = false;
o.depends("type", "local");
o.modalonly = true;
o.validate = function (_section_id, value) {
const validation = main.validatePath(value);
return validation.valid ? true : validation.message;
};
o = section.option(
form.Value,
"url",
_("Rule set URL"),
_("Remote URL to a sing-box rule set"),
);
o.placeholder = "https://example.com/ruleset.srs";
o.rmempty = false;
o.depends("type", "remote");
o.modalonly = true;
o.validate = function (_section_id, value) {
const validation = main.validateUrl(value);
return validation.valid ? true : validation.message;
};
o = section.option(
form.ListValue,
"outbound",
_("Download Node"),
_("Optional node used to download this remote rule set"),
);
o.value("", _("Direct"));
o.depends("type", "remote");
o.modalonly = true;
o.load = function (section_id) {
delete this.keylist;
delete this.vallist;
this.value("", _("Direct"));
uci.sections("podkop", "node", (node) => {
if (node[".name"] !== section_id && node.enabled !== "0") {
this.value(node[".name"], node[".name"]);
}
});
return this.super("load", section_id);
};
o = section.option(
form.Value,
"update_interval",
_("Update interval"),
_("How often this remote rule set should be refreshed"),
);
o.placeholder = "1d";
o.depends("type", "remote");
o.modalonly = true;
}
const EntryPoint = {
createRulesetContent,
};
return baseclass.extend(EntryPoint);
File diff suppressed because it is too large Load Diff
@@ -219,7 +219,7 @@ function createSettingsContent(section) {
form.Value,
"badwan_reload_delay",
_("Interface Monitoring Delay"),
_("Delay in milliseconds before reloading podkop after interface UP"),
_("Delay in milliseconds before reloading Podkop Plus after interface UP"),
);
o.depends("enable_badwan_interface_monitoring", "1");
o.default = "2000";
@@ -295,7 +295,7 @@ function createSettingsContent(section) {
form.ListValue,
"download_lists_via_proxy_section",
_("Download Lists via specific proxy section"),
_("Downloading all lists via specific Proxy/VPN"),
_("Download external rule sets through one of the enabled proxy sections"),
);
o.rmempty = false;
@@ -311,9 +311,13 @@ function createSettingsContent(section) {
for (const secName in sections) {
const sec = sections[secName];
if (sec[".type"] === "section") {
if (
sec[".type"] === "rule" &&
sec.enabled !== "0" &&
sec.action === "proxy"
) {
this.keylist.push(secName);
this.vallist.push(secName);
this.vallist.push(sec.label || secName);
}
}
@@ -324,7 +328,7 @@ function createSettingsContent(section) {
form.Flag,
"dont_touch_dhcp",
_("Dont Touch My DHCP!"),
_("Podkop will not modify your DHCP configuration"),
_("Podkop Plus will not modify your DHCP configuration"),
);
o.default = "0";
o.rmempty = false;
@@ -1,15 +1,15 @@
# RU translations for PODKOP package.
# Copyright (C) 2026 THE PODKOP'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PODKOP package.
# divocatt, 2026.
# RU translations for PODKOP PLUS package.
# Copyright (C) 2026 THE PODKOP PLUS COPYRIGHT HOLDER
# This file is distributed under the same license as the PODKOP PLUS package.
# ushan0v, 2026.
#
msgid ""
msgstr ""
"Project-Id-Version: PODKOP\n"
"Project-Id-Version: PODKOP PLUS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-14 20:21+0200\n"
"PO-Revision-Date: 2026-01-14 20:21+0200\n"
"Last-Translator: divocatt\n"
"POT-Creation-Date: 2026-04-07 21:03+0300\n"
"PO-Revision-Date: 2026-04-07 21:03+0300\n"
"Last-Translator: ushan0v\n"
"Language-Team: none\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
@@ -17,39 +17,66 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
msgid "✔ Enabled"
msgstr "✔ Включено"
msgid "✔ Running"
msgstr "✔ Работает"
msgid "✘ Disabled"
msgstr "✘ Отключено"
msgid "✘ Stopped"
msgstr "✘ Остановлен"
msgid "A latency-tested group of proxy URLs for this node"
msgstr "Группа URL прокси для этого узла с проверкой задержки"
msgid "A latency-tested group of proxy URLs for this section"
msgstr "Группа URL прокси для этой секции с проверкой задержки"
msgid "A manual group of proxy URLs for this node"
msgstr "Группа URL прокси для этого узла с ручным выбором"
msgid "A manual group of proxy URLs for this section"
msgstr "Группа URL прокси для этой секции с ручным выбором"
msgid "Action"
msgstr "Действие"
msgid "Active Connections"
msgstr "Активные соединения"
msgid "Add a section"
msgstr "Добавить секцию"
msgid "Add URLs or local paths to .srs lists"
msgstr "Добавляйте URL или локальные пути к спискам .srs"
msgid "Additional marking rules found"
msgstr "Найдены дополнительные правила маркировки"
msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall."
msgstr "Обеспечивает доступ к YACD из WAN. Убедитесь, что в брандмауэре открыт соответствующий порт."
msgid "Applicable for SOCKS and Shadowsocks proxy"
msgstr "Применимо для SOCKS и Shadowsocks прокси"
msgid "An error occurred while saving the form:"
msgstr "При сохранении формы произошла ошибка:"
msgid "At least one valid domain must be specified. Comments-only content is not allowed."
msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
msgid "Applicable for SOCKS and Shadowsocks links"
msgstr "Применимо для ссылок SOCKS и Shadowsocks"
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
msgid "At least one valid domain must be specified."
msgstr "Нужно указать хотя бы один корректный домен."
msgid "At least one valid IP or subnet must be specified."
msgstr "Нужно указать хотя бы один корректный IP-адрес или подсеть."
msgid "Auto refresh"
msgstr "Автообновление"
msgid "Available actions"
msgstr "Доступные действия"
msgid "Backend validation failed: %s"
msgstr "Backend-проверка завершилась ошибкой: %s"
msgid "Binary file"
msgstr "Бинарный файл"
msgid "Bootsrap DNS"
msgstr "Bootstrap DNS"
@@ -62,6 +89,9 @@ msgstr "Браузер не использует FakeIP"
msgid "Browser is using FakeIP correctly"
msgstr "Браузер использует FakeIP"
msgid "Built-in rule sets"
msgstr "Встроенные наборы правил"
msgid "Cache File Path"
msgstr "Путь к файлу кэша"
@@ -83,23 +113,26 @@ msgstr "Проверки не выполнены"
msgid "Checks passed"
msgstr "Проверки пройдены"
msgid "Choose how this outbound should be configured"
msgstr "Выберите, как настроить этот outbound"
msgid "CIDR must be between 0 and 32"
msgstr "CIDR должен быть между 0 и 32"
msgid "Close"
msgstr "Закрыть"
msgid "Community Lists"
msgstr "Списки сообщества"
msgid "Conditions"
msgstr "Условия"
msgid "Config File Path"
msgstr "Путь к файлу конфигурации"
msgid "Configuration for Podkop service"
msgstr "Настройки сервиса Podkop"
msgid "Configuration for Podkop Plus service"
msgstr "Настройки сервиса Podkop Plus"
msgid "Configuration Type"
msgstr "Тип конфигурации"
msgid "Connection"
msgstr "Подключение"
msgid "Connection Type"
msgstr "Тип подключения"
@@ -119,8 +152,8 @@ msgstr "Дашборд"
msgid "Dashboard currently unavailable"
msgstr "Дашборд сейчас недоступен"
msgid "Delay in milliseconds before reloading podkop after interface UP"
msgstr "Задержка в миллисекундах перед перезагрузкой podkop после поднятия интерфейса"
msgid "Delay in milliseconds before reloading Podkop Plus after interface UP"
msgstr "Задержка в миллисекундах перед перезапуском Podkop Plus после поднятия интерфейса"
msgid "Delay value cannot be empty"
msgstr "Значение задержки не может быть пустым"
@@ -131,6 +164,9 @@ msgstr "DHCP содержит DNS сервер"
msgid "Diagnostics"
msgstr "Диагностика"
msgid "Direct"
msgstr "Напрямую"
msgid "Disable autostart"
msgstr "Отключить автостарт"
@@ -140,8 +176,8 @@ msgstr "Отключить QUIC"
msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming"
msgstr "Отключить QUIC протокол для улучшения совместимости или исправления видео стриминга"
msgid "Disabled"
msgstr "Отключено"
msgid "Dismiss"
msgstr "Закрыть"
msgid "DNS on router"
msgstr "DNS на роутере"
@@ -152,12 +188,21 @@ msgstr "DNS через HTTPS (DoH)"
msgid "DNS over TLS (DoT)"
msgstr "DNS через TLS (DoT)"
msgid "DNS protocol"
msgstr "Протокол DNS"
msgid "DNS Protocol"
msgstr "Протокол DNS"
msgid "DNS Protocol Type"
msgstr "Тип протокола DNS"
msgid "DNS Rewrite TTL"
msgstr "Перезапись TTL для DNS"
msgid "DNS server"
msgstr "DNS-сервер"
msgid "DNS Server"
msgstr "DNS-сервер"
@@ -167,8 +212,14 @@ msgstr "Адрес DNS-сервера не может быть пустым"
msgid "Do not panic, everything can be fixed, just..."
msgstr "Не паникуйте, всё можно исправить, просто..."
msgid "Domain Resolver"
msgstr "Резолвер доменов"
msgid "Domain keyword"
msgstr "Ключевое слово домена"
msgid "Domain regex"
msgstr "Регулярное выражение домена"
msgid "Domains"
msgstr "Домены"
msgid "Dont Touch My DHCP!"
msgstr "Dont Touch My DHCP!"
@@ -179,53 +230,53 @@ msgstr "Входящий"
msgid "Download"
msgstr "Скачать"
msgid "Download external rule sets through one of the enabled proxy sections"
msgstr "Скачивать внешние наборы правил через одну из включённых прокси-секций"
msgid "Download Lists via Proxy/VPN"
msgstr "Скачивать списки через Proxy/VPN"
msgid "Download Lists via specific proxy section"
msgstr "Скачивать списки через выбранную секцию"
msgid "Download Node"
msgstr "Узел для скачивания"
msgid "Downloading all lists via specific Proxy/VPN"
msgstr "Загрузка всех списков через указанный прокси/VPN"
msgid "Dynamic List"
msgstr "Динамический список"
msgid "Drag rows to change priority. The rule at the top is checked first."
msgstr "Перетаскивайте строки, чтобы изменить приоритет. Правило сверху проверяется первым."
msgid "Duplicate value"
msgstr "Дублирующееся значение"
msgid "Enable"
msgstr "Включить"
msgid "Enable a dedicated DNS resolver when this node uses an interface"
msgstr "Включить отдельный DNS-резолвер, когда этот узел использует интерфейс"
msgid "Enable a dedicated DNS resolver when this section uses an interface"
msgstr "Включить отдельный DNS-резолвер, когда эта секция использует интерфейс"
msgid "Enable autostart"
msgstr "Включить автостарт"
msgid "Enable built-in DNS resolver for domains handled by this section"
msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых в этом разделе"
msgid "Enable Mixed Proxy"
msgstr "Включить смешанный прокси"
msgid "Enable Output Network Interface"
msgstr "Включить выходной сетевой интерфейс"
msgid "Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies"
msgstr "Включить смешанный прокси-сервер, разрешив этому разделу маршрутизировать трафик как через HTTP, так и через SOCKS-прокси."
msgid "Enable YACD"
msgstr "Включить YACD"
msgid "Enable YACD WAN Access"
msgstr "Включить доступ YACD WAN"
msgid "Enter complete outbound configuration in JSON format"
msgstr "Введите полную конфигурацию исходящего соединения в формате JSON"
msgid "Enter domain names separated by commas, spaces, or newlines. You can add comments using //"
msgstr "Введите доменные имена, разделяя их запятыми, пробелами или переносами строк. Вы можете добавлять комментарии, используя //"
msgid "Enter domain names without protocols, e.g. example.com or sub.example.com"
msgstr "Введите доменные имена без протоколов, например example.com или sub.example.com"
msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses"
msgstr "Введите подсети в нотации CIDR (например, 103.21.244.0/22) или отдельные IP-адреса"
msgid "Every 1 minute"
msgstr "Каждую минуту"
msgid "Enter a complete sing-box outbound object"
msgstr "Введите полный объект outbound sing-box"
msgid "Every 3 minutes"
msgstr "Каждые 3 минуты"
@@ -236,12 +287,27 @@ msgstr "Каждые 30 секунд"
msgid "Every 5 minutes"
msgstr "Каждые 5 минут"
msgid "Every minute"
msgstr "Каждую минуту"
msgid "Exact full domain"
msgstr "Точный полный домен"
msgid "Exclude NTP"
msgstr "Исключить NTP"
msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN"
msgstr "Исключите трафик протокола NTP из туннеля, чтобы предотвратить его маршрутизацию через прокси-сервер или VPN."
msgid "Expecting: %s"
msgstr "Ожидается: %s"
msgid "Expose this section as a local HTTP+SOCKS proxy"
msgstr "Открыть эту секцию как локальный HTTP+SOCKS прокси"
msgid "External nfqws config files bypass Podkop Plus queue management and explicit validation."
msgstr "Внешние конфиги nfqws обходят управление очередями и явную валидацию Podkop Plus."
msgid "Failed to copy!"
msgstr "Не удалось скопировать!"
@@ -251,8 +317,8 @@ msgstr "Не удалось выполнить!"
msgid "Fastest"
msgstr "Самый быстрый"
msgid "Fully Routed IPs"
msgstr "Полностью маршрутизированные IP-адреса"
msgid "Format"
msgstr "Формат"
msgid "Get global check"
msgstr "Получить глобальную проверку"
@@ -260,9 +326,18 @@ msgstr "Получить глобальную проверку"
msgid "Global check"
msgstr "Глобальная проверка"
msgid "How often this remote rule set should be refreshed"
msgstr "Как часто обновлять этот удалённый набор правил"
msgid "How to configure the proxy connection for this section"
msgstr "Как настроить прокси-подключение для этой секции"
msgid "HTTP error"
msgstr "Ошибка HTTP"
msgid "Interface"
msgstr "Интерфейс"
msgid "Interface Monitoring"
msgstr "Мониторинг интерфейса"
@@ -332,6 +407,9 @@ msgstr "Неверный формат пути. Путь должен начин
msgid "Invalid port number. Must be between 1 and 65535"
msgstr "Неверный номер порта. Допустимо от 1 до 65535"
msgid "Invalid regular expression"
msgstr "Неверное регулярное выражение"
msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password"
msgstr "Неверный URL Shadowsocks: декодированные данные должны содержать method:password"
@@ -404,20 +482,26 @@ msgstr "Неверный URL VLESS: ошибка разбора"
msgid "IP address 0.0.0.0 is not allowed"
msgstr "IP-адрес 0.0.0.0 не допускается"
msgid "IPs"
msgstr "IP-адреса"
msgid "Issues detected"
msgstr "Обнаружены проблемы"
msgid "Keyword must not contain spaces"
msgstr "Ключевое слово не должно содержать пробелы"
msgid "Latest"
msgstr "Последняя"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
msgid "Local Domain Lists"
msgstr "Локальные списки доменов"
msgid "Local"
msgstr "Локальный"
msgid "Local Subnet Lists"
msgstr "Локальные списки подсетей"
msgid "Local file path to a sing-box rule set"
msgstr "Локальный путь к набору правил sing-box"
msgid "Log Level"
msgstr "Уровень логов"
@@ -425,6 +509,27 @@ msgstr "Уровень логов"
msgid "Main DNS"
msgstr "Основной DNS"
msgid "Match destination IPs or subnets"
msgstr "Совпадение по IP-адресам или подсетям назначения"
msgid "Match domains containing a substring"
msgstr "Совпадение по доменам, содержащим подстроку"
msgid "Match domains including all subdomains"
msgstr "Совпадение по доменам, включая все их поддомены"
msgid "Match domains using a regular expression"
msgstr "Совпадение по доменам с использованием регулярного выражения"
msgid "Match only one exact full domain name"
msgstr "Совпадение только по одному точному полному доменному имени"
msgid "Match source IPs or subnets"
msgstr "Совпадение по исходным IP-адресам или подсетям"
msgid "Maximum response time delta in milliseconds"
msgstr "Максимальная разница времени ответа в миллисекундах"
msgid "Memory Usage"
msgstr "Использование памяти"
@@ -437,12 +542,18 @@ msgstr "Наблюдаемые интерфейсы"
msgid "Must be a number in the range of 50 - 1000"
msgstr "Должно быть числом от 50 до 1000"
msgid "Network Interface"
msgstr "Сетевой интерфейс"
msgid "NFQWS Strategy"
msgstr "Стратегия NFQWS"
msgid "NFQWS strategy cannot be empty"
msgstr "Стратегия NFQWS не может быть пустой"
msgid "No other marking rules found"
msgstr "Другие правила маркировки не найдены"
msgid "Node Type"
msgstr "Тип узла"
msgid "Not implement yet"
msgstr "Ещё не реализовано"
@@ -452,14 +563,20 @@ msgstr "Не отвечает"
msgid "Not running"
msgstr "Не запущено"
msgid "Open Project Page"
msgstr "Открыть страницу проекта"
msgid "Operation timed out"
msgstr "Время ожидания истекло"
msgid "Outbound Config"
msgstr "Конфигурация Outbound"
msgid "Optional node used to download this remote rule set"
msgstr "Необязательный узел, через который скачивается этот удалённый набор правил"
msgid "Outbound Configuration"
msgstr "Конфигурация исходящего соединения"
msgid "Optional values must be attached with '=' here; a separate token would be ignored by nfqws."
msgstr "Необязательное значение здесь нужно передавать только через '='; отдельный токен nfqws проигнорирует."
msgid "Outbound JSON"
msgstr "JSON outbound"
msgid "Outdated"
msgstr "Устаревшая"
@@ -467,6 +584,9 @@ msgstr "Устаревшая"
msgid "Output Network Interface"
msgstr "Выходной сетевой интерфейс"
msgid "Path"
msgstr "Путь"
msgid "Path cannot be empty"
msgstr "Путь не может быть пустым"
@@ -482,14 +602,23 @@ msgstr "Путь должен заканчиваться на cache.db"
msgid "Pending"
msgstr "Ожидает запуска"
msgid "Podkop"
msgstr "Podkop"
msgid "Podkop Plus"
msgstr "Podkop Plus"
msgid "Podkop Settings"
msgstr "Настройки podkop"
msgid "Podkop Plus manages the nfqws process lifecycle itself, so daemon mode is not allowed here."
msgstr "Podkop Plus сам управляет жизненным циклом nfqws, поэтому режим daemon здесь запрещён."
msgid "Podkop will not modify your DHCP configuration"
msgstr "Podkop не будет изменять вашу конфигурацию DHCP."
msgid "Podkop Plus Settings"
msgstr "Настройки Podkop Plus"
msgid "Podkop Plus will not modify your DHCP configuration"
msgstr "Podkop Plus не будет изменять вашу конфигурацию DHCP"
msgid "Port cannot be empty"
msgstr "Порт не может быть пустым"
msgid "Port for the local mixed proxy of this section"
msgstr "Порт локального смешанного прокси для этой секции"
msgid "Proxy Configuration URL"
msgstr "URL конфигурации прокси"
@@ -503,14 +632,26 @@ msgstr "Прокси-трафик направляется через FakeIP"
msgid "Regional options cannot be used together"
msgstr "Нельзя использовать несколько региональных опций одновременно"
msgid "Remote Domain Lists"
msgstr "Внешние списки доменов"
msgid "Remote"
msgstr "Удалённый"
msgid "Remote Subnet Lists"
msgstr "Внешние списки подсетей"
msgid "Remote URL to a sing-box rule set"
msgstr "Удалённый URL к набору правил sing-box"
msgid "Restart podkop"
msgstr "Перезапустить Podkop"
msgid "Resolver for interface outbound"
msgstr "Резолвер для outbound через интерфейс"
msgid "Resolver For Interface Outbound"
msgstr "Резолвер для outbound через интерфейс"
msgid "Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE."
msgstr "Выбор ресурсов по hostname внутри nfqws здесь не поддерживается; ресурсы выбирает sing-box до NFQUEUE."
msgid "Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE."
msgstr "Выбор ресурсов по IP или CIDR внутри nfqws здесь не поддерживается; ресурсы выбирает sing-box до NFQUEUE."
msgid "Restart Podkop Plus"
msgstr "Перезапустить Podkop Plus"
msgid "Router DNS is not routed through sing-box"
msgstr "DNS роутера не проходит через sing-box"
@@ -521,6 +662,15 @@ msgstr "DNS роутера проходит через sing-box"
msgid "Routing Excluded IPs"
msgstr "Исключённые из маршрутизации IP-адреса"
msgid "Rule set must be a direct .srs URL or a local .srs path"
msgstr "Набор правил должен быть прямым URL на .srs или локальным путём к .srs"
msgid "Rule set URL"
msgstr "URL набора правил"
msgid "Rule sets"
msgstr "Наборы правил"
msgid "Rules mangle counters"
msgstr "Счётчики правил mangle"
@@ -545,17 +695,23 @@ msgstr "Запустить диагностику"
msgid "Russia inside restrictions"
msgstr "Ограничения Russia inside"
msgid "Save error"
msgstr "Ошибка сохранения"
msgid "Secret key for authenticating remote access to YACD when WAN access is enabled."
msgstr "Секретный ключ для аутентификации удаленного доступа к YACD при включенном доступе через WAN."
msgid "Section"
msgstr "Секция"
msgid "Section name"
msgstr "Имя секции"
msgid "Sections"
msgstr "Секции"
msgid "Select a predefined list for routing"
msgstr "Выберите предопределенный список для маршрутизации"
msgid "Select between VPN and Proxy connection methods for traffic routing"
msgstr "Выберите между VPN и Proxy методами для маршрутизации трафика"
msgstr "Выберите предустановленный набор правил для маршрутизации"
msgid "Select DNS protocol to use"
msgstr "Выберите протокол DNS"
@@ -563,12 +719,6 @@ msgstr "Выберите протокол DNS"
msgid "Select how often the domain or subnet lists are updated automatically"
msgstr "Выберите частоту автоматического обновления списков доменов или подсетей."
msgid "Select how to configure the proxy"
msgstr "Выберите способ настройки прокси"
msgid "Select network interface for VPN connection"
msgstr "Выберите сетевой интерфейс для VPN подключения"
msgid "Select or enter DNS server address"
msgstr "Выберите или введите адрес DNS-сервера"
@@ -578,15 +728,6 @@ msgstr "Выберите или введите путь к файлу кеша s
msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing"
msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
msgid "Select the DNS protocol type for the domain resolver"
msgstr "Выберите тип протокола DNS для резолвера доменов"
msgid "Select the list type for adding custom domains"
msgstr "Выберите тип списка для добавления пользовательских доменов"
msgid "Select the list type for adding custom subnets"
msgstr "Выберите тип списка для добавления пользовательских подсетей"
msgid "Select the log level for sing-box"
msgstr "Выберите уровень логов для sing-box"
@@ -602,8 +743,14 @@ msgstr "Выберите WAN интерфейсы для мониторинга"
msgid "Selector"
msgstr "Selector"
msgid "Selector Proxy Links"
msgstr "Ссылки прокси для Selector"
msgid "Selector connections"
msgstr "Подключения selector"
msgid "Selector Links"
msgstr "Ссылки selector"
msgid "Service list"
msgstr "Список сервисов"
msgid "Services info"
msgstr "Информация о сервисах"
@@ -635,29 +782,23 @@ msgstr "Сервис sing-box существует"
msgid "Sing-box version is compatible (newer than 1.12.4)"
msgstr "Версия Sing-box совместима (новее 1.12.4)"
msgid "Source file"
msgstr "Исходный файл"
msgid "Source IPs"
msgstr "Исходные IP-адреса"
msgid "Source Network Interface"
msgstr "Сетевой интерфейс источника"
msgid "Specify a local IP address to be excluded from routing"
msgstr "Укажите локальный IP-адрес, который следует исключить из маршрутизации."
msgid "Specify local IP addresses or subnets whose traffic will always be routed through the configured route"
msgstr "Укажите локальные IP-адреса или подсети, трафик которых всегда будет направляться через настроенный маршрут."
msgid "Start Podkop Plus"
msgstr "Запустить Podkop Plus"
msgid "Specify remote URLs to download and use domain lists"
msgstr "Укажите URL-адреса для загрузки и использования списков доменов."
msgid "Specify remote URLs to download and use subnet lists"
msgstr "Укажите URL-адреса для загрузки и использования списков подсетей."
msgid "Specify the path to the list file located on the router filesystem"
msgstr "Укажите путь к файлу списка, расположенному в файловой системе маршрутизатора."
msgid "Start podkop"
msgstr "Запустить podkop"
msgid "Stop podkop"
msgstr "Остановить podkop"
msgid "Stop Podkop Plus"
msgstr "Остановить Podkop Plus"
msgid "Successfully copied!"
msgstr "Успешно скопировано!"
@@ -674,20 +815,26 @@ msgstr "Таблица существует"
msgid "Test latency"
msgstr "Тестирование задержки"
msgid "Text List"
msgstr "Текстовый список"
msgid "The desync fwmark is managed by Podkop Plus for loop prevention and must not be overridden here."
msgstr "Desync fwmark управляется Podkop Plus для защиты от циклов и не должен переопределяться здесь."
msgid "The DNS server used to look up the IP address of an upstream DNS server"
msgstr "DNS-сервер, используемый для поиска IP-адреса вышестоящего DNS-сервера"
msgid "The interval between connectivity tests"
msgstr "Интервал между тестами подключения"
msgid "The NFQUEUE number is assigned by Podkop Plus for each rule and must not be overridden here."
msgstr "Номер NFQUEUE назначается Podkop Plus для каждого правила и не должен переопределяться здесь."
msgid "The maximum difference in response times (ms) allowed when comparing servers"
msgstr "Максимально допустимая разница во времени отклика (мс) при сравнении серверов"
msgid "This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here."
msgstr "Это поле должно запускать рабочую стратегию nfqws; --dry-run сразу завершает процесс и здесь запрещён."
msgid "The URL used to test server connectivity"
msgstr "URL-адрес, используемый для проверки подключения к серверу"
msgid "This field must start a working nfqws strategy; --version exits immediately and is not allowed here."
msgstr "Это поле должно запускать рабочую стратегию nfqws; --version сразу завершает процесс и здесь запрещён."
msgid "This flag does not accept a value."
msgstr "Этот флаг не принимает значение."
msgid "This option requires a value."
msgstr "Этой опции требуется значение."
msgid "Time in seconds for DNS record caching (default: 60)"
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
@@ -707,18 +854,39 @@ msgstr "TTL должно быть положительным числом"
msgid "TTL value cannot be empty"
msgstr "Значение TTL не может быть пустым"
msgid "Type"
msgstr "Тип"
msgid "UDP"
msgstr "UDP"
msgid "UDP (Unprotected DNS)"
msgstr "UDP (Незащищённый DNS)"
msgid "UDP over TCP"
msgstr "UDP через TCP"
msgid "Unable to validate the NFQWS strategy through the backend parser."
msgstr "Не удалось проверить стратегию NFQWS через backend-парсер."
msgid "Unexpected standalone token. Use explicit flags such as --name or --name=value."
msgstr "Неожиданный отдельный токен. Используйте явные флаги вида --имя или --имя=значение."
msgid "unique UCI identifier"
msgstr "уникальный идентификатор UCI"
msgid "unknown"
msgstr "неизвестно"
msgid "Unknown error"
msgstr "Неизвестная ошибка"
msgid "Unknown NFQWS flag."
msgstr "Неизвестный флаг NFQWS."
msgid "Update interval"
msgstr "Интервал обновления"
msgid "Uplink"
msgstr "Исходящий"
@@ -731,35 +899,32 @@ msgstr "URL должен использовать один из следующи
msgid "URLTest"
msgstr "URLTest"
msgid "URLTest Check Interval"
msgstr "Интервал проверки URLTest"
msgid "URLTest connections"
msgstr "Подключения URLTest"
msgid "URLTest Proxy Links"
msgstr "Ссылки прокси для URLTest"
msgid "URLTest interval"
msgstr "Интервал URLTest"
msgid "URLTest Testing URL"
msgstr "URLTest ссылка для проверки"
msgid "URLTest Interval"
msgstr "Интервал URLTest"
msgid "URLTest Links"
msgstr "Ссылки URLTest"
msgid "URLTest tolerance"
msgstr "Допуск URLTest"
msgid "URLTest Tolerance"
msgstr "URLTest допустимое отклонение"
msgid "User Domain List Type"
msgstr "Тип пользовательского списка доменов"
msgid "URLTest URL"
msgstr "URL URLTest"
msgid "User Domains"
msgstr "Пользовательские домены"
msgid "Use a network interface as the outbound for this node"
msgstr "Использовать сетевой интерфейс как outbound для этого узла"
msgid "User Domains List"
msgstr "Список пользовательских доменов"
msgid "User Subnet List Type"
msgstr "Тип пользовательского списка подсетей"
msgid "User Subnets"
msgstr "Пользовательские подсети"
msgid "User Subnets List"
msgstr "Список пользовательских подсетей"
msgid "Use a network interface as the outbound for this section"
msgstr "Использовать сетевой интерфейс как outbound для этой секции"
msgid "Valid"
msgstr "Валидно"
@@ -770,11 +935,11 @@ msgstr "Ошибки валидации:"
msgid "View logs"
msgstr "Посмотреть логи"
msgid "Visit Wiki"
msgstr "Перейти в wiki"
msgid "Visible name of this section"
msgstr "Видимое имя этой секции"
msgid "vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
msgstr ""
msgstr "ссылки vless://, ss://, trojan://, socks4/5://, hy2/hysteria2://"
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены."
@@ -782,8 +947,23 @@ msgstr "Предупреждение: %s нельзя использовать
msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection."
msgstr "Предупреждение: Russia inside может быть использован только с %s. %s уже есть в Russia inside и будет удален из выбранных."
msgid "What Podkop Plus should do when this section matches"
msgstr "Что Podkop Plus должен делать при совпадении этой секции"
msgid "YACD Secret Key"
msgstr "Секретный ключ YACD"
msgid "You can select Output Network Interface, by default autodetect"
msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически."
msgid "Zapret"
msgstr "Zapret"
msgid "Zapret hostlist templates are not supported here because Podkop Plus does not expand them for per-rule NFQWS strategies."
msgstr "Шаблоны hostlist zapret здесь не поддерживаются, потому что Podkop Plus не разворачивает их для per-rule стратегий NFQWS."
msgid "Zapret installed"
msgstr "Zapret установлен"
msgid "Zapret not installed"
msgstr "Zapret не установлен"
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,6 @@ rm -f /tmp/luci-indexcache*
[ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload
logger -t "podkop" "$timestamp uci-defaults script executed"
logger -t "podkop-plus" "uci-defaults script executed"
exit 0
exit 0
@@ -1,14 +1,14 @@
{
"admin/services/podkop": {
"title": "Podkop",
"admin/services/podkop-plus": {
"title": "Podkop Plus",
"order": 42,
"action": {
"type": "view",
"path": "podkop/podkop"
},
"depends": {
"acl": [ "luci-app-podkop" ],
"acl": [ "luci-app-podkop-plus" ],
"uci": { "podkop": true }
}
}
}
}
@@ -1,12 +1,12 @@
{
"luci-app-podkop": {
"description": "Grant UCI and RPC access to LuCI app podkop",
"luci-app-podkop-plus": {
"description": "Grant UCI and RPC access to LuCI app Podkop Plus",
"read": {
"file": {
"/etc/init.d/podkop": [
"/etc/init.d/podkop-plus": [
"exec"
],
"/usr/bin/podkop": [
"/usr/bin/podkop-plus": [
"exec"
]
},
@@ -25,4 +25,4 @@
]
}
}
}
}
-30
View File
@@ -1,30 +0,0 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-podkop
PKG_VERSION := $(if $(PODKOP_VERSION),$(PODKOP_VERSION),0.$(shell date +%d%m%Y))
PKG_RELEASE:=1
LUCI_TITLE:=LuCI podkop app
LUCI_DEPENDS:=+luci-base +podkop
LUCI_PKGARCH:=all
LUCI_LANG.ru:=Русский (Russian)
LUCI_LANG.en:=English
PKG_LICENSE:=GPL-2.0-or-later
PKG_MAINTAINER:=ITDog <podkop@itdog.info>
LUCI_LANGUAGES:=en ru
include $(TOPDIR)/feeds/luci/luci.mk
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)$(HTDOCS)
$(CP) $(PKG_BUILD_DIR)/htdocs/* $(1)$(HTDOCS)/
$(INSTALL_DIR) $(1)/
$(CP) $(PKG_BUILD_DIR)/root/* $(1)/
sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PKG_VERSION)/g' $(1)$(HTDOCS)/luci-static/resources/view/podkop/main.js || true
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
@@ -1,98 +0,0 @@
"use strict";
"require view";
"require form";
"require baseclass";
"require network";
"require view.podkop.main as main";
// Settings content
"require view.podkop.settings as settings";
// Sections content
"require view.podkop.section as section";
// Dashboard content
"require view.podkop.dashboard as dashboard";
// Diagnostic content
"require view.podkop.diagnostic as diagnostic";
const EntryPoint = {
async render() {
main.injectGlobalStyles();
const podkopMap = new form.Map(
"podkop",
_("Podkop Settings"),
_("Configuration for Podkop service"),
);
// Enable tab views
podkopMap.tabbed = true;
// Sections tab
const sectionsSection = podkopMap.section(
form.TypedSection,
"section",
_("Sections"),
);
sectionsSection.anonymous = false;
sectionsSection.addremove = true;
sectionsSection.template = "cbi/simpleform";
// Render section content
section.createSectionContent(sectionsSection);
// Settings tab
const settingsSection = podkopMap.section(
form.TypedSection,
"settings",
_("Settings"),
);
settingsSection.anonymous = true;
settingsSection.addremove = false;
// Make it named [ config settings 'settings' ]
settingsSection.cfgsections = function () {
return ["settings"];
};
// Render settings content
settings.createSettingsContent(settingsSection);
// Diagnostic tab
const diagnosticSection = podkopMap.section(
form.TypedSection,
"diagnostic",
_("Diagnostics"),
);
diagnosticSection.anonymous = true;
diagnosticSection.addremove = false;
diagnosticSection.cfgsections = function () {
return ["diagnostic"];
};
// Render diagnostic content
diagnostic.createDiagnosticContent(diagnosticSection);
// Dashboard tab
const dashboardSection = podkopMap.section(
form.TypedSection,
"dashboard",
_("Dashboard"),
);
dashboardSection.anonymous = true;
dashboardSection.addremove = false;
dashboardSection.cfgsections = function () {
return ["dashboard"];
};
// Render dashboard content
dashboard.createDashboardContent(dashboardSection);
// Inject core service
main.coreService();
return podkopMap.render();
},
};
return view.extend(EntryPoint);
@@ -1,692 +0,0 @@
"use strict";
"require form";
"require baseclass";
"require ui";
"require tools.widgets as widgets";
"require view.podkop.main as main";
function createSectionContent(section) {
let o = section.option(
form.ListValue,
"connection_type",
_("Connection Type"),
_("Select between VPN and Proxy connection methods for traffic routing"),
);
o.value("proxy", "Proxy");
o.value("vpn", "VPN");
o.value("block", "Block");
o.value("exclusion", "Exclusion");
o = section.option(
form.ListValue,
"proxy_config_type",
_("Configuration Type"),
_("Select how to configure the proxy"),
);
o.value("url", _("Connection URL"));
o.value("selector", _("Selector"));
o.value("urltest", _("URLTest"));
o.value("outbound", _("Outbound Config"));
o.default = "url";
o.depends("connection_type", "proxy");
o = section.option(
form.TextValue,
"proxy_string",
_("Proxy Configuration URL"),
_("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links")
);
o.depends("proxy_config_type", "url");
o.rows = 5;
// Enable soft wrapping for multi-line proxy URLs (e.g., for URLTest proxy links)
o.wrap = "soft";
// Render as a textarea to allow multiple proxy URLs/configs
o.textarea = true;
o.rmempty = false;
o.sectionDescriptions = new Map();
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateProxyUrl(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.TextValue,
"outbound_json",
_("Outbound Configuration"),
_("Enter complete outbound configuration in JSON format"),
);
o.depends("proxy_config_type", "outbound");
o.rows = 10;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateOutboundJson(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.DynamicList,
"selector_proxy_links",
_("Selector Proxy Links"),
_("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links")
);
o.depends("proxy_config_type", "selector");
o.rmempty = false;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateProxyUrl(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.DynamicList,
"urltest_proxy_links",
_("URLTest Proxy Links"),
_("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links")
);
o.depends("proxy_config_type", "urltest");
o.rmempty = false;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateProxyUrl(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.ListValue,
"urltest_check_interval",
_("URLTest Check Interval"),
_("The interval between connectivity tests")
);
o.value("30s", _("Every 30 seconds"));
o.value("1m", _("Every 1 minute"));
o.value("3m", _("Every 3 minutes"));
o.value("5m", _("Every 5 minutes"));
o.default = "3m";
o.depends("proxy_config_type", "urltest");
o = section.option(
form.Value,
"urltest_tolerance",
_("URLTest Tolerance"),
_("The maximum difference in response times (ms) allowed when comparing servers")
);
o.default = "50";
o.rmempty = false;
o.depends("proxy_config_type", "urltest");
o.validate = function (section_id, value) {
if (!value || value.length === 0) {
return true;
}
const parsed = parseFloat(value);
if (/^[0-9]+$/.test(value) && !isNaN(parsed) && isFinite(parsed) && parsed >= 50 && parsed <= 1000) {
return true;
}
return _('Must be a number in the range of 50 - 1000');
};
o = section.option(
form.Value,
"urltest_testing_url",
_("URLTest Testing URL"),
_("The URL used to test server connectivity")
);
o.value("https://www.gstatic.com/generate_204", "https://www.gstatic.com/generate_204 (Google)");
o.value("https://cp.cloudflare.com/generate_204", "https://cp.cloudflare.com/generate_204 (Cloudflare)");
o.value("https://captive.apple.com", "https://captive.apple.com (Apple)");
o.value("https://connectivity-check.ubuntu.com", "https://connectivity-check.ubuntu.com (Ubuntu)")
o.default = "https://www.gstatic.com/generate_204";
o.rmempty = false;
o.depends("proxy_config_type", "urltest");
o.validate = function (section_id, value) {
if (!value || value.length === 0) {
return true;
}
const validation = main.validateUrl(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.Flag,
"enable_udp_over_tcp",
_("UDP over TCP"),
_("Applicable for SOCKS and Shadowsocks proxy"),
);
o.default = "0";
o.depends("connection_type", "proxy");
o.rmempty = false;
o = section.option(
widgets.DeviceSelect,
"interface",
_("Network Interface"),
_("Select network interface for VPN connection"),
);
o.depends("connection_type", "vpn");
o.noaliases = true;
o.nobridges = false;
o.noinactive = false;
o.filter = function (section_id, value) {
// Blocked interface names that should never be selectable
const blockedInterfaces = [
"br-lan",
"eth0",
"eth1",
"wan",
"phy0-ap0",
"phy1-ap0",
"pppoe-wan",
"lan",
];
// Reject immediately if the value matches any blocked interface
if (blockedInterfaces.includes(value)) {
return false;
}
// Try to find the device object with the given name
const device = this.devices.find((dev) => dev.getName() === value);
// If no device is found, allow the value
if (!device) {
return true;
}
// Get the device type (e.g., "wifi", "ethernet", etc.)
const type = device.getType();
// Reject wireless-related devices
const isWireless =
type === "wifi" || type === "wireless" || type.includes("wlan");
return !isWireless;
};
o = section.option(
form.Flag,
"domain_resolver_enabled",
_("Domain Resolver"),
_("Enable built-in DNS resolver for domains handled by this section"),
);
o.default = "0";
o.rmempty = false;
o.depends("connection_type", "vpn");
o = section.option(
form.ListValue,
"domain_resolver_dns_type",
_("DNS Protocol Type"),
_("Select the DNS protocol type for the domain resolver"),
);
o.value("doh", _("DNS over HTTPS (DoH)"));
o.value("dot", _("DNS over TLS (DoT)"));
o.value("udp", _("UDP (Unprotected DNS)"));
o.default = "udp";
o.rmempty = false;
o.depends("domain_resolver_enabled", "1");
o = section.option(
form.Value,
"domain_resolver_dns_server",
_("DNS Server"),
_("Select or enter DNS server address"),
);
Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => {
o.value(key, _(label));
});
o.default = "8.8.8.8";
o.rmempty = false;
o.depends("domain_resolver_enabled", "1");
o.validate = function (section_id, value) {
const validation = main.validateDNS(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.DynamicList,
"community_lists",
_("Community Lists"),
_("Select a predefined list for routing") +
' <a href="https://github.com/itdoginfo/allow-domains" target="_blank">github.com/itdoginfo/allow-domains</a>',
);
o.placeholder = "Service list";
Object.entries(main.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => {
o.value(key, _(label));
});
o.rmempty = true;
let lastValues = [];
let isProcessing = false;
o.onchange = function (ev, section_id, value) {
if (isProcessing) return;
isProcessing = true;
try {
const values = Array.isArray(value) ? value : [value];
let newValues = [...values];
let notifications = [];
const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter((opt) =>
newValues.includes(opt),
);
if (selectedRegionalOptions.length > 1) {
const lastSelected =
selectedRegionalOptions[selectedRegionalOptions.length - 1];
const removedRegions = selectedRegionalOptions.slice(0, -1);
newValues = newValues.filter(
(v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v),
);
notifications.push(
E("p", {}, [
E("strong", {}, _("Regional options cannot be used together")),
E("br"),
_(
"Warning: %s cannot be used together with %s. Previous selections have been removed.",
).format(removedRegions.join(", "), lastSelected),
]),
);
}
if (newValues.includes("russia_inside")) {
const removedServices = newValues.filter(
(v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v),
);
if (removedServices.length > 0) {
newValues = newValues.filter((v) =>
main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v),
);
notifications.push(
E("p", { class: "alert-message warning" }, [
E("strong", {}, _("Russia inside restrictions")),
E("br"),
_(
"Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.",
).format(
main.ALLOWED_WITH_RUSSIA_INSIDE.map(
(key) => main.DOMAIN_LIST_OPTIONS[key],
)
.filter((label) => label !== "Russia inside")
.join(", "),
removedServices.join(", "),
),
]),
);
}
}
if (JSON.stringify(newValues.sort()) !== JSON.stringify(values.sort())) {
this.getUIElement(section_id).setValue(newValues);
}
notifications.forEach((notification) =>
ui.addNotification(null, notification),
);
lastValues = newValues;
} catch (e) {
console.error("Error in onchange handler:", e);
} finally {
isProcessing = false;
}
};
o = section.option(
form.ListValue,
"user_domain_list_type",
_("User Domain List Type"),
_("Select the list type for adding custom domains"),
);
o.value("disabled", _("Disabled"));
o.value("dynamic", _("Dynamic List"));
o.value("text", _("Text List"));
o.default = "disabled";
o.rmempty = false;
o = section.option(
form.DynamicList,
"user_domains",
_("User Domains"),
_(
"Enter domain names without protocols, e.g. example.com or sub.example.com",
),
);
o.placeholder = "Domains list";
o.depends("user_domain_list_type", "dynamic");
o.rmempty = false;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateDomain(value, true);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.TextValue,
"user_domains_text",
_("User Domains List"),
_(
"Enter domain names separated by commas, spaces, or newlines. You can add comments using //",
),
);
o.placeholder =
"example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains";
o.depends("user_domain_list_type", "text");
o.rows = 8;
o.rmempty = false;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const domains = main.parseValueList(value);
if (!domains.length) {
return _(
"At least one valid domain must be specified. Comments-only content is not allowed.",
);
}
const { valid, results } = main.bulkValidate(domains, (row) =>
main.validateDomain(row, true),
);
if (!valid) {
const errors = results
.filter((validation) => !validation.valid) // Leave only failed validations
.map((validation) => `${validation.value}: ${validation.message}`); // Collect validation errors
return [_("Validation errors:"), ...errors].join("\n");
}
return true;
};
o = section.option(
form.ListValue,
"user_subnet_list_type",
_("User Subnet List Type"),
_("Select the list type for adding custom subnets"),
);
o.value("disabled", _("Disabled"));
o.value("dynamic", _("Dynamic List"));
o.value("text", _("Text List"));
o.default = "disabled";
o.rmempty = false;
o = section.option(
form.DynamicList,
"user_subnets",
_("User Subnets"),
_(
"Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses",
),
);
o.placeholder = "IP or subnet";
o.depends("user_subnet_list_type", "dynamic");
o.rmempty = false;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateSubnet(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.TextValue,
"user_subnets_text",
_("User Subnets List"),
_(
"Enter subnets in CIDR notation or single IP addresses, separated by commas, spaces, or newlines. " +
"You can add comments using //",
),
);
o.placeholder =
"103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9";
o.depends("user_subnet_list_type", "text");
o.rows = 10;
o.rmempty = false;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const subnets = main.parseValueList(value);
if (!subnets.length) {
return _(
"At least one valid subnet or IP must be specified. Comments-only content is not allowed.",
);
}
const { valid, results } = main.bulkValidate(subnets, main.validateSubnet);
if (!valid) {
const errors = results
.filter((validation) => !validation.valid) // Leave only failed validations
.map((validation) => `${validation.value}: ${validation.message}`); // Collect validation errors
return [_("Validation errors:"), ...errors].join("\n");
}
return true;
};
o = section.option(
form.DynamicList,
"local_domain_lists",
_("Local Domain Lists"),
_("Specify the path to the list file located on the router filesystem"),
);
o.placeholder = "/path/file.lst";
o.rmempty = true;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validatePath(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.DynamicList,
"local_subnet_lists",
_("Local Subnet Lists"),
_("Specify the path to the list file located on the router filesystem"),
);
o.placeholder = "/path/file.lst";
o.rmempty = true;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validatePath(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.DynamicList,
"remote_domain_lists",
_("Remote Domain Lists"),
_("Specify remote URLs to download and use domain lists"),
);
o.placeholder = "https://example.com/domains.srs";
o.rmempty = true;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateUrl(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.DynamicList,
"remote_subnet_lists",
_("Remote Subnet Lists"),
_("Specify remote URLs to download and use subnet lists"),
);
o.placeholder = "https://example.com/subnets.srs";
o.rmempty = true;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateUrl(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.DynamicList,
"fully_routed_ips",
_("Fully Routed IPs"),
_(
"Specify local IP addresses or subnets whose traffic will always be routed through the configured route",
),
);
o.placeholder = "192.168.1.2 or 192.168.1.0/24";
o.rmempty = true;
o.depends("connection_type", "proxy");
o.depends("connection_type", "vpn");
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateSubnet(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.option(
form.Flag,
"mixed_proxy_enabled",
_("Enable Mixed Proxy"),
_(
"Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies",
),
);
o.default = "0";
o.rmempty = false;
o.depends("connection_type", "proxy");
o.depends("connection_type", "vpn");
o = section.option(
form.Value,
"mixed_proxy_port",
_("Mixed Proxy Port"),
_(
"Specify the port number on which the mixed proxy will run for this section. " +
"Make sure the selected port is not used by another service",
),
);
o.rmempty = false;
o.depends("mixed_proxy_enabled", "1");
}
const EntryPoint = {
createSectionContent,
};
return baseclass.extend(EntryPoint);
File diff suppressed because it is too large Load Diff
-64
View File
@@ -1,64 +0,0 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=podkop
PKG_VERSION := $(if $(PODKOP_VERSION),$(PODKOP_VERSION),0.$(shell date +%d%m%Y))
PKG_RELEASE:=1
PKG_MAINTAINER:=ITDog <podkop@itdog.info>
PKG_LICENSE:=GPL-2.0-or-later
include $(INCLUDE_DIR)/package.mk
define Package/podkop
SECTION:=net
CATEGORY:=Network
DEPENDS:=+sing-box +curl +jq +kmod-nft-tproxy +coreutils-base64 +bind-dig
CONFLICTS:=https-dns-proxy nextdns luci-app-passwall luci-app-passwall2
TITLE:=Domain routing app
URL:=https://podkop.net
PKGARCH:=all
endef
define Package/podkop/description
Domain routing. Use of VLESS, Shadowsocks technologies
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/podkop/prerm
#!/bin/sh
grep -q "105 podkop" /etc/iproute2/rt_tables && sed -i "/105 podkop/d" /etc/iproute2/rt_tables
/etc/init.d/podkop stop
exit 0
endef
define Package/podkop/conffiles
/etc/config/podkop
endef
define Package/podkop/install
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/podkop $(1)/etc/init.d/podkop
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/podkop $(1)/etc/config/podkop
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) ./files/usr/bin/podkop $(1)/usr/bin/podkop
$(INSTALL_DIR) $(1)/usr/lib/podkop
$(CP) ./files/usr/lib/* $(1)/usr/lib/podkop/
sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PKG_VERSION)/g' $(1)/usr/lib/podkop/constants.sh
endef
$(eval $(call BuildPackage,podkop))
+2 -19
View File
@@ -1,6 +1,6 @@
config settings 'settings'
option dns_type 'udp'
option dns_server '8.8.8.8'
option dns_server '77.88.8.8'
option bootstrap_dns_server '77.88.8.8'
option dns_rewrite_ttl '60'
list source_network_interfaces 'br-lan'
@@ -13,6 +13,7 @@ config settings 'settings'
option disable_quic '0'
option update_interval '1d'
option download_lists_via_proxy '0'
#option download_lists_via_proxy_section 'main'
option dont_touch_dhcp '0'
option config_path '/etc/sing-box/config.json'
option cache_path '/tmp/sing-box/cache.db'
@@ -20,21 +21,3 @@ config settings 'settings'
option exclude_ntp '0'
option shutdown_correctly '0'
#list routing_excluded_ips '192.168.1.3'
config section 'main'
option connection_type 'proxy'
option proxy_config_type 'url'
option proxy_string ''
option enable_udp_over_tcp '0'
list community_lists 'russia_inside'
#option user_domain_list_type 'dynamic'
#list user_domains '2ip.ru'
#option user_subnet_list_type 'dynamic'
#list user_subnets '1.1.1.1'
#list local_domain_lists '/tmp/domains.lst'
#list local_subnet_lists '/tmp/subnets.lst'
#list remote_domain_lists 'https://example.com/domains.srs'
#list remote_subnet_lists 'https://example.com/subnets.srs'
#list fully_routed_ips '192.168.1.2'
#option mixed_proxy_enabled '1'
#option mixed_proxy_port '2080'
+60 -7
View File
@@ -6,16 +6,43 @@ USE_PROCD=1
script=$(readlink "$initscript")
NAME="$(basename ${script:-$initscript})"
config_load "$NAME"
CONFIG_NAME="podkop"
PODKOP_BIN="/usr/bin/podkop-plus"
PODKOP_SERVICE_INIT="/etc/init.d/podkop-plus"
PODKOP_RELOAD_LOCK_DIR="/var/run/podkop-plus.reload.lock"
PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD="/var/run/podkop-plus.internal-config-change"
config_load "$CONFIG_NAME"
reload_lock_is_active() {
[ -d "$PODKOP_RELOAD_LOCK_DIR" ]
}
acquire_reload_lock() {
mkdir "$PODKOP_RELOAD_LOCK_DIR" 2> /dev/null
}
release_reload_lock() {
rmdir "$PODKOP_RELOAD_LOCK_DIR" 2> /dev/null
}
should_skip_internal_config_reload() {
local reason="$1"
[ "$reason" = "on_config_change" ] || return 1
[ -f "$PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD" ] || return 1
reload_lock_is_active || return 1
return 0
}
start_service() {
echo "Start podkop"
echo "Start Podkop Plus"
config_get enable_badwan_interface_monitoring "settings" "enable_badwan_interface_monitoring"
config_get badwan_monitored_interfaces "settings" "badwan_monitored_interfaces"
procd_open_instance
procd_set_param command /usr/bin/podkop start
procd_set_param command "$PODKOP_BIN" start
[ "$enable_badwan_interface_monitoring" = "1" ] && [ -n "$badwan_monitored_interfaces" ] &&
procd_set_param netdev "$badwan_monitored_interfaces"
procd_set_param stdout 1
@@ -24,11 +51,37 @@ start_service() {
}
stop_service() {
/usr/bin/podkop stop
"$PODKOP_BIN" stop
}
reload_service() {
/usr/bin/podkop reload > /dev/null 2>&1
local reason="$1"
if should_skip_internal_config_reload "$reason"; then
return 0
fi
if ! acquire_reload_lock; then
return 0
fi
trap 'release_reload_lock' EXIT INT TERM
"$PODKOP_BIN" reload "$reason" > /dev/null 2>&1
local status="$?"
trap - EXIT INT TERM
release_reload_lock
return "$status"
}
status_service() {
if "$PODKOP_BIN" get_status 2> /dev/null | grep -q '"running":1'; then
echo "running"
return 0
fi
echo "not running"
return 1
}
service_triggers() {
@@ -41,11 +94,11 @@ service_triggers() {
PROCD_RELOAD_DELAY=$badwan_reload_delay
procd_open_trigger
procd_add_config_trigger "config.change" "$NAME" "$initscript" restart 'on_config_change'
procd_add_config_trigger "config.change" "$CONFIG_NAME" "$initscript" reload 'on_config_change'
if [ "$enable_badwan_interface_monitoring" = "1" ]; then
for iface in $badwan_monitored_interfaces; do
procd_add_interface_trigger "interface.*.up" "$iface" /etc/init.d/podkop reload
procd_add_interface_trigger "interface.*.up" "$iface" "$PODKOP_SERVICE_INIT" reload
done
fi
procd_close_trigger
+1908 -114
View File
File diff suppressed because it is too large Load Diff
+38 -7
View File
@@ -1,8 +1,14 @@
# shellcheck disable=SC2034
PODKOP_APP_NAME="Podkop Plus"
PODKOP_VERSION="__COMPILED_VERSION_VARIABLE__"
## Common
PODKOP_CONFIG="/etc/config/podkop"
PODKOP_BIN="/usr/bin/podkop-plus"
PODKOP_SERVICE_NAME="podkop-plus"
PODKOP_SERVICE_INIT="/etc/init.d/podkop-plus"
PODKOP_LIB_DIR="/usr/lib/podkop-plus"
PODKOP_RELEASE_REPO="ushan0v/podkop-plus"
## Common
RESOLV_CONF="/etc/resolv.conf"
DNS_RESOLVERS="1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 9.9.9.9 9.9.9.11 94.140.14.14 94.140.15.15 208.67.220.220 208.67.222.222 77.88.8.1 77.88.8.8"
CHECK_PROXY_IP_DOMAIN="ip.podkop.fyi"
@@ -12,14 +18,14 @@ TMP_RULESET_FOLDER="$TMP_SING_BOX_FOLDER/rulesets"
CLOUDFLARE_OCTETS="8.47 162.159 188.114" # Endpoints https://github.com/ampetelin/warp-endpoint-checker
JQ_REQUIRED_VERSION="1.7.1"
COREUTILS_BASE64_REQUIRED_VERSION="9.7"
RT_TABLE_NAME="podkop"
RT_TABLE_NAME="podkopplus"
## nft
NFT_TABLE_NAME="PodkopTable"
NFT_TABLE_NAME="PodkopPlusTable"
NFT_LOCALV4_SET_NAME="localv4"
NFT_COMMON_SET_NAME="podkop_subnets"
NFT_DISCORD_SET_NAME="podkop_discord_subnets"
NFT_INTERFACE_SET_NAME="interfaces"
NFT_COMMON_SET_NAME="podkop_plus_subnets"
NFT_DISCORD_SET_NAME="podkop_plus_discord_subnets"
NFT_INTERFACE_SET_NAME="podkop_plus_interfaces"
NFT_FAKEIP_MARK="0x00100000"
NFT_OUTBOUND_MARK="0x00200000"
@@ -31,6 +37,8 @@ SB_FAKEIP_DNS_SERVER_TAG="fakeip-server"
SB_FAKEIP_INET4_RANGE="198.18.0.0/15"
SB_BOOTSTRAP_SERVER_TAG="bootstrap-dns-server"
SB_FAKEIP_DNS_RULE_TAG="fakeip-dns-rule-tag"
SB_FAKEIP_RULESET_DNS_RULE_TAG="fakeip-ruleset-dns-rule-tag"
SB_SERVICE_FAKEIP_DNS_RULE_TAG="service-fakeip-dns-rule-tag"
SB_INVERT_FAKEIP_DNS_RULE_TAG="invert-fakeip-dns-rule-tag"
# Inbounds
SB_TPROXY_INBOUND_TAG="tproxy-in"
@@ -63,4 +71,27 @@ SUBNETS_HETZNER="${GITHUB_RAW_URL}/Subnets/IPv4/hetzner.lst"
SUBNETS_OVH="${GITHUB_RAW_URL}/Subnets/IPv4/ovh.lst"
SUBNETS_DIGITALOCEAN="${GITHUB_RAW_URL}/Subnets/IPv4/digitalocean.lst"
SUBNETS_CLOUDFRONT="${GITHUB_RAW_URL}/Subnets/IPv4/cloudfront.lst"
COMMUNITY_SERVICES="russia_inside russia_outside ukraine_inside geoblock block porn news anime youtube hdrezka tiktok google_ai google_play hodca discord meta twitter cloudflare cloudfront digitalocean hetzner ovh telegram roblox"
COMMUNITY_SERVICES="russia_inside russia_outside ukraine_inside geoblock block porn news anime youtube hdrezka tiktok google_ai google_play hodca discord meta twitter cloudflare cloudfront digitalocean hetzner ovh telegram roblox"
## Zapret
ZAPRET_SOURCE_BASE_DIR="/opt/zapret"
ZAPRET_SOURCE_NFQWS_BIN="$ZAPRET_SOURCE_BASE_DIR/nfq/nfqws"
ZAPRET_SOURCE_FILES_DIR="$ZAPRET_SOURCE_BASE_DIR/files"
ZAPRET_SOURCE_IPSET_DIR="$ZAPRET_SOURCE_BASE_DIR/ipset"
ZAPRET_BASE_DIR="/var/run/podkop-plus/zapret-runtime"
ZAPRET_RUNTIME_BASE_DIR="$ZAPRET_BASE_DIR"
ZAPRET_RUNTIME_NFQWS_BIN="$ZAPRET_RUNTIME_BASE_DIR/nfq/nfqws"
ZAPRET_RUNTIME_FILES_DIR="$ZAPRET_RUNTIME_BASE_DIR/files"
ZAPRET_RUNTIME_IPSET_DIR="$ZAPRET_RUNTIME_BASE_DIR/ipset"
ZAPRET_HOSTLIST_BASE="$ZAPRET_RUNTIME_IPSET_DIR"
ZAPRET_NFQWS_BIN="$ZAPRET_RUNTIME_NFQWS_BIN"
ZAPRET_STATE_DIR="/var/run/podkop-plus/zapret"
ZAPRET_PID_DIR="$ZAPRET_STATE_DIR/pid"
ZAPRET_LOG_DIR="$ZAPRET_STATE_DIR/log"
ZAPRET_HOSTLIST_DIR="$ZAPRET_STATE_DIR/hostlist"
ZAPRET_ROUTE_MARK_BASE="0x01000000"
ZAPRET_QUEUE_BASE=200
ZAPRET_DESYNC_MARK="0x40000000"
ZAPRET_DESYNC_MARK_POSTNAT="0x20000000"
ZAPRET_LEGACY_DEFAULT_NFQWS_OPT="--filter-tcp=80 <HOSTLIST> --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --new --filter-udp=443 <HOSTLIST_NOAUTO> --dpi-desync=fake --dpi-desync-repeats=11 --new --filter-tcp=443 <HOSTLIST> --dpi-desync=multidisorder --dpi-desync-split-pos=1,sniext+1,host+1,midsld-2,midsld,midsld+2,endhost-1"
ZAPRET_DEFAULT_NFQWS_OPT="--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin"
+3 -2
View File
@@ -299,7 +299,7 @@ parse_domain_or_subnet_string_to_commas_string() {
local type="$2"
tmpfile=$(mktemp)
printf "%s\n" "$string" | sed 's/\/\/.*//' | tr ', ' '\n' | grep -v '^$' > "$tmpfile"
printf "%s\n" "$string" | sed -e 's/[[:space:]]*\/\/.*$//' -e 's/[[:space:]]*#.*$//' | tr ', ' '\n' | grep -v '^$' > "$tmpfile"
result="$(parse_domain_or_subnet_file_to_comma_string "$tmpfile" "$type")"
rm -f "$tmpfile"
@@ -322,6 +322,7 @@ parse_domain_or_subnet_file_to_comma_string() {
local result
while IFS= read -r line; do
line=$(printf "%s\n" "$line" | sed -e 's/[[:space:]]*\/\/.*$//' -e 's/[[:space:]]*#.*$//')
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$line" ] && continue
@@ -353,4 +354,4 @@ parse_domain_or_subnet_file_to_comma_string() {
done < "$filepath"
echo "$result"
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ log() {
level="info"
fi
logger -t "podkop" "[$level] $message"
logger -t "podkop-plus" "[$level] $message"
}
nolog() {
+14 -1
View File
@@ -176,5 +176,18 @@ extract_ip_cidr_from_json_ruleset_to_file() {
local output_file="$2"
log "Extracting ip_cidr entries from $json_file to $output_file" "debug"
jq -r '.rules[].ip_cidr[]' "$json_file" > "$output_file"
jq -r '.rules[]? | select(has("ip_cidr")) | .ip_cidr[]?' "$json_file" > "$output_file"
}
# Extracts plain domain/domain_suffix entries from a JSON ruleset file and writes them to an output file.
# Domain keyword/regex matchers are intentionally skipped because zapret hostlists only support suffix-style hosts.
extract_domains_from_json_ruleset_to_file() {
local json_file="$1"
local output_file="$2"
log "Extracting domain entries from $json_file to $output_file" "debug"
jq -r '
.rules[]? |
(.domain[]?, .domain_suffix[]?) // empty
' "$json_file" > "$output_file"
}
@@ -1,4 +1,4 @@
PODKOP_LIB="/usr/lib/podkop"
PODKOP_LIB="/usr/lib/podkop-plus"
. "$PODKOP_LIB/helpers.sh"
. "$PODKOP_LIB/sing_box_config_manager.sh"
@@ -11,7 +11,7 @@
#
# Usage:
# Include this script in your ash script with:
# . /usr/lib/podkop/sing_box_config_manager.sh
# . /usr/lib/podkop-plus/sing_box_config_manager.sh
#
# After that, sing_box_cm_* functions are available for generating
# and modifying sing-box JSON configuration.
@@ -287,7 +287,7 @@ sing_box_cm_patch_dns_route_rule() {
--arg tag "$tag" \
--arg key "$key" \
--argjson value "$value" \
'import "helpers" as h {"search": "/usr/lib/podkop"};
'import "helpers" as h {"search": "/usr/lib/podkop-plus"};
.dns.rules |= map(
if .[$service_tag] == $tag then
if has($key) then
@@ -439,13 +439,18 @@ sing_box_cm_add_mixed_inbound() {
sing_box_cm_add_direct_outbound() {
local config="$1"
local tag="$2"
local routing_mark="$3"
echo "$config" | jq \
--arg tag "$tag" \
'.outbounds += [{
type: "direct",
tag: $tag
}]'
--arg routing_mark "$routing_mark" \
'.outbounds += [(
{
type: "direct",
tag: $tag
}
+ (if $routing_mark != "" then { routing_mark: ($routing_mark | tonumber) } else {} end)
)]'
}
#######################################
@@ -1115,7 +1120,7 @@ sing_box_cm_patch_route_rule() {
--arg tag "$tag" \
--arg key "$key" \
--argjson value "$value" \
'import "helpers" as h {"search": "/usr/lib/podkop"};
'import "helpers" as h {"search": "/usr/lib/podkop-plus"};
.route.rules |= map(
if .[$service_tag] == $tag then
if has($key) then
@@ -1278,11 +1283,11 @@ sing_box_cm_add_inline_ruleset_rule() {
value=$(_normalize_arg "$value")
echo "$config" | jq -L /usr/lib/podkop \
echo "$config" | jq -L /usr/lib/podkop-plus \
--arg tag "$tag" \
--arg key "$key" \
--argjson value "$value" \
'import "helpers" as h {"search": "/usr/lib/podkop"};
'import "helpers" as h {"search": "/usr/lib/podkop-plus"};
.route.rule_set |= map(
if .tag == $tag then
if has($key) then
File diff suppressed because it is too large Load Diff
+752
View File
@@ -0,0 +1,752 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
RELEASE_VERSION="${1:-0.7.14-1}"
OUTPUT_DIR_INPUT="${2:-}"
BASE_VERSION="${RELEASE_VERSION%-*}"
FORK_RELEASE="${RELEASE_VERSION##*-}"
APK_INTERNAL_VERSION="${APK_INTERNAL_VERSION:-${BASE_VERSION}-r${FORK_RELEASE}}"
SOURCE_ROOT_DIR="${SOURCE_ROOT_DIR:-}"
WINDOWS_ARTIFACTS_DIR="${WINDOWS_ARTIFACTS_DIR:-}"
DEFAULT_BUILD_HOME="${HOME}"
if [[ "$DEFAULT_BUILD_HOME" == "/root" ]]; then
DEFAULT_BUILD_HOME="$(getent passwd 1000 | cut -d: -f6 || true)"
DEFAULT_BUILD_HOME="${DEFAULT_BUILD_HOME:-/root}"
fi
if [[ "$BASE_VERSION" == "$RELEASE_VERSION" || -z "$FORK_RELEASE" ]]; then
echo "Expected release version in the form 0.7.14-1" >&2
exit 1
fi
WSL_NATIVE_ROOT="${WSL_NATIVE_ROOT:-$DEFAULT_BUILD_HOME/build/podkop-plus}"
WORK_DIR="${WORK_DIR:-$ROOT_DIR/.wsl-build}"
SDK_WORK_DIR="${SDK_WORK_DIR:-$WORK_DIR/sdk}"
SDK_CACHE_DIR="${SDK_CACHE_DIR:-$DEFAULT_BUILD_HOME/.cache/podkop-plus/openwrt-sdk}"
IPK_SDK_URL="${IPK_SDK_URL:-https://downloads.openwrt.org/releases/24.10.3/targets/x86/64/openwrt-sdk-24.10.3-x86-64_gcc-13.3.0_musl.Linux-x86_64.tar.zst}"
APK_SDK_URL="${APK_SDK_URL:-https://downloads.openwrt.org/snapshots/targets/x86/64/openwrt-sdk-x86-64_gcc-14.3.0_musl.Linux-x86_64.tar.zst}"
APP_DESCRIPTION="Rule-based Podkop Plus LuCI app with hybrid sing-box + zapret orchestration"
I18N_DESCRIPTION="Translation for luci-app-podkop-plus - Русский (Russian)"
MAINTAINER="ushan0v <ushan0v@users.noreply.github.com>"
PROJECT_URL="https://github.com/ushan0v/podkop-plus"
APP_DEPENDS_IPK="libc, luci-base, sing-box, curl, jq, kmod-nft-tproxy, coreutils-base64, bind-dig, nftables, kmod-nft-nat, kmod-nft-offload, kmod-nft-queue, libnetfilter-queue1, libmnl0, libcap, zlib, gzip, coreutils, coreutils-sort, coreutils-sleep"
APP_DEPENDS_APK="bind-dig coreutils coreutils-base64 coreutils-sleep coreutils-sort curl gzip jq kmod-nft-nat kmod-nft-offload kmod-nft-queue kmod-nft-tproxy libc libcap libmnl0 libnetfilter-queue1 luci-base nftables sing-box zlib"
APT_PACKAGES=(
build-essential
curl
fakeroot
file
gawk
git
patch
python3
rsync
tar
unzip
wget
xz-utils
zstd
)
copy_to_native_root() {
local target_root="${WSL_NATIVE_ROOT%/}"
local target_output
local source_root="${SOURCE_ROOT_DIR:-$ROOT_DIR}"
local windows_output="${WINDOWS_ARTIFACTS_DIR:-$source_root/dist/release-final}"
mkdir -p "$target_root"
rsync -a --delete \
--exclude ".git" \
--exclude ".wsl-build" \
--exclude "dist" \
--exclude ".idea" \
--exclude "sandbox" \
--exclude "fe-app-podkop/node_modules" \
--exclude "fe-app-podkop/tests" \
"$ROOT_DIR/" "$target_root/"
rm -rf \
"$target_root/.idea" \
"$target_root/sandbox" \
"$target_root/fe-app-podkop/node_modules" \
"$target_root/fe-app-podkop/tests"
if [[ -n "$OUTPUT_DIR_INPUT" ]]; then
target_output="$OUTPUT_DIR_INPUT"
else
target_output="$target_root/dist/release-final"
fi
echo "Synced repository to native WSL path: $target_root" >&2
export SOURCE_ROOT_DIR="$source_root"
export WINDOWS_ARTIFACTS_DIR="$windows_output"
exec bash "$target_root/scripts/build-openwrt-packages-wsl.sh" "$RELEASE_VERSION" "$target_output"
}
ensure_native_root() {
case "$ROOT_DIR" in
/mnt/*)
copy_to_native_root
;;
esac
}
ensure_host_deps() {
local missing=()
local commands=(
ar
curl
fakeroot
file
gcc
git
make
python3
rsync
sha256sum
tar
wget
zstd
)
for cmd in "${commands[@]}"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if (( ${#missing[@]} == 0 )); then
return 0
fi
echo "Installing missing host dependencies: ${APT_PACKAGES[*]}" >&2
if [[ "$(id -u)" -eq 0 ]]; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y "${APT_PACKAGES[@]}"
return 0
fi
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "${APT_PACKAGES[@]}"
return 0
fi
echo "Missing build dependencies and no passwordless sudo/root available: ${missing[*]}" >&2
exit 1
}
have_passwordless_sudo() {
command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1
}
download_sdk_archive() {
local url="$1"
local archive_path="$SDK_CACHE_DIR/$(basename "$url")"
mkdir -p "$SDK_CACHE_DIR"
if [[ ! -f "$archive_path" ]]; then
echo "Downloading SDK: $url" >&2
wget -O "$archive_path.part" "$url"
mv "$archive_path.part" "$archive_path"
fi
printf '%s\n' "$archive_path"
}
extract_sdk() {
local kind="$1"
local archive_path="$2"
local sdk_url="$3"
local destination="$SDK_WORK_DIR/$kind"
local marker_file="$destination/.podkop-sdk-url"
local temp_dir
local extracted_root
mkdir -p "$SDK_WORK_DIR"
if [[ -d "$destination" && -f "$marker_file" ]] && [[ "$(cat "$marker_file")" == "$sdk_url" ]]; then
printf '%s\n' "$destination"
return 0
fi
rm -rf "$destination"
temp_dir="$(mktemp -d "$SDK_WORK_DIR/.${kind}.XXXXXX")"
tar --zstd -xf "$archive_path" -C "$temp_dir"
extracted_root="$(find "$temp_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
mv "$extracted_root" "$destination"
printf '%s\n' "$sdk_url" > "$marker_file"
rmdir "$temp_dir" 2>/dev/null || true
printf '%s\n' "$destination"
}
ensure_po2lmo() {
local ipk_sdk_dir="$1"
local po2lmo_bin="$ipk_sdk_dir/staging_dir/hostpkg/bin/po2lmo"
local luci_src_dir="$ipk_sdk_dir/feeds/luci/modules/luci-base/src"
if [[ -x "$po2lmo_bin" ]]; then
printf '%s\n' "$po2lmo_bin"
return 0
fi
(
cd "$ipk_sdk_dir"
if [[ ! -d feeds/luci ]]; then
./scripts/feeds update luci >&2
fi
)
if [[ ! -f "$luci_src_dir/po2lmo" ]]; then
make -C "$luci_src_dir" po2lmo >&2
fi
printf '%s\n' "$luci_src_dir/po2lmo"
}
make_dir() {
mkdir -p "$1"
}
build_app_root() {
local output_root="$1"
rm -rf "$output_root"
make_dir "$output_root/www"
make_dir "$output_root/etc/init.d"
make_dir "$output_root/etc/config"
make_dir "$output_root/usr/bin"
make_dir "$output_root/usr/lib/podkop-plus"
if [[ -d "$ROOT_DIR/luci-app-podkop-plus/htdocs" ]]; then
cp -a "$ROOT_DIR/luci-app-podkop-plus/htdocs/." "$output_root/www/"
fi
if [[ -d "$ROOT_DIR/luci-app-podkop-plus/root" ]]; then
cp -a "$ROOT_DIR/luci-app-podkop-plus/root/." "$output_root/"
fi
install -m 0755 "$ROOT_DIR/podkop/files/etc/init.d/podkop" "$output_root/etc/init.d/podkop-plus"
install -m 0644 "$ROOT_DIR/podkop/files/etc/config/podkop" "$output_root/etc/config/podkop"
install -m 0755 "$ROOT_DIR/podkop/files/usr/bin/podkop" "$output_root/usr/bin/podkop-plus"
cp -a "$ROOT_DIR/podkop/files/usr/lib/." "$output_root/usr/lib/podkop-plus/"
sed -i -e "s/__COMPILED_VERSION_VARIABLE__/${RELEASE_VERSION}/g" \
"$output_root/www/luci-static/resources/view/podkop/main.js" \
"$output_root/usr/lib/podkop-plus/constants.sh"
}
build_i18n_root() {
local output_root="$1"
local po2lmo_bin="$2"
local lmo_path="$output_root/usr/lib/lua/luci/i18n/podkop.ru.lmo"
rm -rf "$output_root"
make_dir "$output_root/etc/uci-defaults"
make_dir "$(dirname "$lmo_path")"
cat > "$output_root/etc/uci-defaults/luci-i18n-podkop-plus-ru" <<'EOF'
uci set luci.languages.ru='Русский (Russian)'; uci commit luci
EOF
"$po2lmo_bin" "$ROOT_DIR/luci-app-podkop-plus/po/ru/podkop.po" "$lmo_path"
}
generate_apk_metadata_files() {
local package_name="$1"
local package_root="$2"
local conffile_path="${3:-}"
local list_file="$package_root/lib/apk/packages/${package_name}.list"
make_dir "$(dirname "$list_file")"
(
cd "$package_root"
find . -type f ! -path './lib/apk/packages/*' | LC_ALL=C sort | sed 's#^\./#/#'
) > "$list_file"
if [[ -n "$conffile_path" ]]; then
local conffiles_file="$package_root/lib/apk/packages/${package_name}.conffiles"
local conffiles_static_file="$package_root/lib/apk/packages/${package_name}.conffiles_static"
local hash_value
hash_value="$(sha256sum "$package_root$conffile_path" | awk '{print $1}')"
printf '%s\n' "$conffile_path" > "$conffiles_file"
printf '%s %s\n' "$conffile_path" "$hash_value" > "$conffiles_static_file"
fi
}
installed_size_bytes() {
du -sk "$1" | awk '{print $1 * 1024}'
}
write_app_ipk_control() {
local control_dir="$1"
local installed_size="$2"
rm -rf "$control_dir"
make_dir "$control_dir"
cat > "$control_dir/control" <<EOF
Package: luci-app-podkop-plus
Version: ${RELEASE_VERSION}
Depends: ${APP_DEPENDS_IPK}
License: GPL-2.0-or-later
Section: luci
URL: ${PROJECT_URL}
Maintainer: ${MAINTAINER}
Architecture: all
Installed-Size: ${installed_size}
Description: ${APP_DESCRIPTION}
EOF
cat > "$control_dir/conffiles" <<'EOF'
/etc/config/podkop
EOF
cat > "$control_dir/postinst" <<'EOF'
#!/bin/sh
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
default_postinst $0 $@
EOF
cat > "$control_dir/postinst-pkg" <<'EOF'
[ -n "${IPKG_INSTROOT}" ] || { rm -f /tmp/luci-indexcache.*
rm -rf /tmp/luci-modulecache/
killall -HUP rpcd 2>/dev/null
exit 0
}
EOF
cat > "$control_dir/prerm" <<'EOF'
#!/bin/sh
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
default_prerm $0 $@
EOF
cat > "$control_dir/prerm-pkg" <<'EOF'
#!/bin/sh
grep -q "105 podkopplus" /etc/iproute2/rt_tables && sed -i "/105 podkopplus/d" /etc/iproute2/rt_tables
/etc/init.d/podkop-plus stop >/dev/null 2>&1 || true
exit 0
EOF
chmod 0755 "$control_dir/postinst" "$control_dir/prerm" "$control_dir/prerm-pkg"
}
write_i18n_ipk_control() {
local control_dir="$1"
local installed_size="$2"
rm -rf "$control_dir"
make_dir "$control_dir"
cat > "$control_dir/control" <<EOF
Package: luci-i18n-podkop-plus-ru
Version: ${RELEASE_VERSION}
Depends: libc, luci-app-podkop-plus
License: GPL-2.0-or-later
Section: luci
URL: ${PROJECT_URL}
Maintainer: ${MAINTAINER}
Architecture: all
Installed-Size: ${installed_size}
Description: ${I18N_DESCRIPTION}
EOF
cat > "$control_dir/postinst" <<'EOF'
#!/bin/sh
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
default_postinst $0 $@
EOF
cat > "$control_dir/prerm" <<'EOF'
#!/bin/sh
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
default_prerm $0 $@
EOF
chmod 0755 "$control_dir/postinst" "$control_dir/prerm"
}
build_ipk_package() {
local package_name="$1"
local data_root="$2"
local control_root="$3"
local output_file="$4"
local build_dir="$WORK_DIR/manual/ipk-${package_name}"
rm -rf "$build_dir"
make_dir "$build_dir"
printf '2.0\n' > "$build_dir/debian-binary"
tar --numeric-owner --owner=0 --group=0 -C "$control_root" -czf "$build_dir/control.tar.gz" .
tar --numeric-owner --owner=0 --group=0 -C "$data_root" -czf "$build_dir/data.tar.gz" .
tar --numeric-owner --owner=0 --group=0 \
-C "$build_dir" \
-czf "$output_file" \
./debian-binary \
./data.tar.gz \
./control.tar.gz
}
write_app_apk_scripts() {
local scripts_dir="$1"
rm -rf "$scripts_dir"
make_dir "$scripts_dir"
cat > "$scripts_dir/app-post-install.sh" <<'EOF'
#!/bin/sh
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
export root="${IPKG_INSTROOT}"
export pkgname="luci-app-podkop-plus"
add_group_and_user
default_postinst
[ -n "${IPKG_INSTROOT}" ] || { rm -f /tmp/luci-indexcache.*
rm -rf /tmp/luci-modulecache/
killall -HUP rpcd 2>/dev/null
exit 0
}
EOF
cat > "$scripts_dir/app-pre-deinstall.sh" <<'EOF'
#!/bin/sh
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
export root="${IPKG_INSTROOT}"
export pkgname="luci-app-podkop-plus"
default_prerm
grep -q "105 podkopplus" /etc/iproute2/rt_tables && sed -i "/105 podkopplus/d" /etc/iproute2/rt_tables
/etc/init.d/podkop-plus stop >/dev/null 2>&1 || true
exit 0
EOF
cat > "$scripts_dir/app-post-upgrade.sh" <<'EOF'
#!/bin/sh
export PKG_UPGRADE=1
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
export root="${IPKG_INSTROOT}"
export pkgname="luci-app-podkop-plus"
add_group_and_user
default_postinst
[ -n "${IPKG_INSTROOT}" ] || { rm -f /tmp/luci-indexcache.*
rm -rf /tmp/luci-modulecache/
killall -HUP rpcd 2>/dev/null
exit 0
}
EOF
chmod 0755 "$scripts_dir"/app-*.sh
}
write_i18n_apk_scripts() {
local scripts_dir="$1"
make_dir "$scripts_dir"
cat > "$scripts_dir/i18n-post-install.sh" <<'EOF'
#!/bin/sh
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
export root="${IPKG_INSTROOT}"
export pkgname="luci-i18n-podkop-plus-ru"
add_group_and_user
default_postinst
EOF
cat > "$scripts_dir/i18n-pre-deinstall.sh" <<'EOF'
#!/bin/sh
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
export root="${IPKG_INSTROOT}"
export pkgname="luci-i18n-podkop-plus-ru"
default_prerm
EOF
cat > "$scripts_dir/i18n-post-upgrade.sh" <<'EOF'
#!/bin/sh
export PKG_UPGRADE=1
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
export root="${IPKG_INSTROOT}"
export pkgname="luci-i18n-podkop-plus-ru"
add_group_and_user
default_postinst
EOF
chmod 0755 "$scripts_dir"/i18n-*.sh
}
build_apk_package() {
local apk_bin="$1"
local package_name="$2"
local package_version="$3"
local description="$4"
local depends="$5"
local files_root="$6"
local scripts_dir="$7"
local script_prefix="$8"
local output_file="$9"
local temp_root="$WORK_DIR/manual/${package_name}.apk-root"
local temp_scripts="$WORK_DIR/manual/${package_name}.apk-scripts"
local maintainer="${10}"
local stderr_file
rm -rf "$temp_root" "$temp_scripts"
cp -a "$files_root" "$temp_root"
cp -a "$scripts_dir" "$temp_scripts"
if [[ "$(id -u)" -eq 0 ]]; then
chown -R 0:0 "$temp_root" "$temp_scripts"
"$apk_bin" mkpkg \
--files "$temp_root" \
--output "$output_file" \
-I "name:${package_name}" \
-I "version:${package_version}" \
-I "description:${description}" \
-I "arch:noarch" \
-I "license:GPL-2.0-or-later" \
-I "origin:podkop-plus" \
-I "maintainer:${maintainer}" \
-I "url:${PROJECT_URL}" \
-I "depends:${depends}" \
-s "post-install:${temp_scripts}/${script_prefix}-post-install.sh" \
-s "pre-deinstall:${temp_scripts}/${script_prefix}-pre-deinstall.sh" \
-s "post-upgrade:${temp_scripts}/${script_prefix}-post-upgrade.sh"
elif have_passwordless_sudo; then
sudo chown -R 0:0 "$temp_root" "$temp_scripts"
sudo "$apk_bin" mkpkg \
--files "$temp_root" \
--output "$output_file" \
-I "name:${package_name}" \
-I "version:${package_version}" \
-I "description:${description}" \
-I "arch:noarch" \
-I "license:GPL-2.0-or-later" \
-I "origin:podkop-plus" \
-I "maintainer:${maintainer}" \
-I "url:${PROJECT_URL}" \
-I "depends:${depends}" \
-s "post-install:${temp_scripts}/${script_prefix}-post-install.sh" \
-s "pre-deinstall:${temp_scripts}/${script_prefix}-pre-deinstall.sh" \
-s "post-upgrade:${temp_scripts}/${script_prefix}-post-upgrade.sh"
sudo chown "$(id -u):$(id -g)" "$output_file"
sudo rm -rf "$temp_root" "$temp_scripts"
else
stderr_file="$(mktemp)"
if ! fakeroot sh -c "
chown -R 0:0 '$temp_root' '$temp_scripts'
'$apk_bin' mkpkg \
--files '$temp_root' \
--output '$output_file' \
-I 'name:${package_name}' \
-I 'version:${package_version}' \
-I 'description:${description}' \
-I 'arch:noarch' \
-I 'license:GPL-2.0-or-later' \
-I 'origin:podkop-plus' \
-I 'maintainer:${maintainer}' \
-I 'url:${PROJECT_URL}' \
-I 'depends:${depends}' \
-s post-install:'$temp_scripts/${script_prefix}-post-install.sh' \
-s pre-deinstall:'$temp_scripts/${script_prefix}-pre-deinstall.sh' \
-s post-upgrade:'$temp_scripts/${script_prefix}-post-upgrade.sh'
" 2>"$stderr_file"; then
grep -v "object 'libfakeroot-.*so' from LD_PRELOAD cannot be preloaded" "$stderr_file" >&2 || true
rm -f "$stderr_file"
return 1
fi
grep -v "object 'libfakeroot-.*so' from LD_PRELOAD cannot be preloaded" "$stderr_file" >&2 || true
rm -f "$stderr_file"
fi
}
verify_ipk_metadata() {
local package_file="$1"
local expected_package="$2"
local expected_version="$3"
local tmp_dir
tmp_dir="$(mktemp -d)"
tar -xzf "$package_file" -C "$tmp_dir"
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"
rm -rf "$tmp_dir"
}
verify_apk_metadata() {
local apk_bin="$1"
local package_file="$2"
local expected_package="$3"
local expected_version="$4"
local dump_file
dump_file="$(mktemp)"
"$apk_bin" adbdump "$package_file" > "$dump_file"
grep -q "^ name: ${expected_package}$" "$dump_file"
grep -q "^ version: ${expected_version}$" "$dump_file"
rm -f "$dump_file"
}
cleanup_work_dir() {
rm -rf "$WORK_DIR/manual"
rm -f "$WORK_DIR/ipk-build.log"
}
sync_artifacts_to_windows() {
local output_dir="$1"
local output_real
local windows_real
[[ -n "$WINDOWS_ARTIFACTS_DIR" ]] || return 0
mkdir -p "$WINDOWS_ARTIFACTS_DIR"
output_real="$(readlink -f "$output_dir")"
windows_real="$(readlink -f "$WINDOWS_ARTIFACTS_DIR")"
if [[ "$output_real" == "$windows_real" ]]; then
return 0
fi
rm -f \
"$WINDOWS_ARTIFACTS_DIR"/luci-app-podkop-plus_* \
"$WINDOWS_ARTIFACTS_DIR"/luci-i18n-podkop-plus-ru_*
cp -f \
"$output_dir"/luci-app-podkop-plus_"${RELEASE_VERSION}".ipk \
"$output_dir"/luci-i18n-podkop-plus-ru_"${RELEASE_VERSION}".ipk \
"$output_dir"/luci-app-podkop-plus_"${RELEASE_VERSION}".apk \
"$output_dir"/luci-i18n-podkop-plus-ru_"${RELEASE_VERSION}".apk \
"$WINDOWS_ARTIFACTS_DIR"/
echo "Synced artifacts to Windows path: $WINDOWS_ARTIFACTS_DIR" >&2
}
print_summary() {
local output_dir="$1"
echo "Build root: $ROOT_DIR"
echo "Output dir: $output_dir"
if [[ -n "$WINDOWS_ARTIFACTS_DIR" ]]; then
echo "Windows artifacts dir: $WINDOWS_ARTIFACTS_DIR"
fi
echo "Artifacts:"
find "$output_dir" -maxdepth 1 -type f \( -name '*.ipk' -o -name '*.apk' \) | sort
}
main() {
local output_dir
local ipk_archive
local apk_archive
local ipk_sdk_dir
local apk_sdk_dir
local po2lmo_bin
local apk_bin
local manual_root="$WORK_DIR/manual"
local app_root="$manual_root/app-root"
local i18n_root="$manual_root/i18n-root"
local app_control="$manual_root/app-ipk-control"
local i18n_control="$manual_root/i18n-ipk-control"
local apk_scripts="$manual_root/apk-scripts"
local app_size
local i18n_size
ensure_native_root
ensure_host_deps
mkdir -p "$WORK_DIR"
output_dir="${OUTPUT_DIR_INPUT:-$ROOT_DIR/dist/release-final}"
mkdir -p "$output_dir"
rm -f "$output_dir"/luci-app-podkop-plus_* "$output_dir"/luci-i18n-podkop-plus-ru_*
ipk_archive="$(download_sdk_archive "$IPK_SDK_URL")"
apk_archive="$(download_sdk_archive "$APK_SDK_URL")"
ipk_sdk_dir="$(extract_sdk ipk "$ipk_archive" "$IPK_SDK_URL")"
apk_sdk_dir="$(extract_sdk apk "$apk_archive" "$APK_SDK_URL")"
po2lmo_bin="$(ensure_po2lmo "$ipk_sdk_dir")"
apk_bin="$apk_sdk_dir/staging_dir/host/bin/apk"
[[ -x "$apk_bin" ]] || { echo "apk host tool not found at $apk_bin" >&2; exit 1; }
build_app_root "$app_root"
build_i18n_root "$i18n_root" "$po2lmo_bin"
generate_apk_metadata_files "luci-app-podkop-plus" "$app_root" "/etc/config/podkop"
generate_apk_metadata_files "luci-i18n-podkop-plus-ru" "$i18n_root"
app_size="$(installed_size_bytes "$app_root")"
i18n_size="$(installed_size_bytes "$i18n_root")"
write_app_ipk_control "$app_control" "$app_size"
write_i18n_ipk_control "$i18n_control" "$i18n_size"
write_app_apk_scripts "$apk_scripts"
write_i18n_apk_scripts "$apk_scripts"
build_ipk_package \
"luci-app-podkop-plus" \
"$app_root" \
"$app_control" \
"$output_dir/luci-app-podkop-plus_${RELEASE_VERSION}.ipk"
build_ipk_package \
"luci-i18n-podkop-plus-ru" \
"$i18n_root" \
"$i18n_control" \
"$output_dir/luci-i18n-podkop-plus-ru_${RELEASE_VERSION}.ipk"
build_apk_package \
"$apk_bin" \
"luci-app-podkop-plus" \
"$APK_INTERNAL_VERSION" \
"$APP_DESCRIPTION" \
"$APP_DEPENDS_APK" \
"$app_root" \
"$apk_scripts" \
"app" \
"$output_dir/luci-app-podkop-plus_${RELEASE_VERSION}.apk" \
"$MAINTAINER"
build_apk_package \
"$apk_bin" \
"luci-i18n-podkop-plus-ru" \
"$APK_INTERNAL_VERSION" \
"$I18N_DESCRIPTION" \
"libc luci-app-podkop-plus" \
"$i18n_root" \
"$apk_scripts" \
"i18n" \
"$output_dir/luci-i18n-podkop-plus-ru_${RELEASE_VERSION}.apk" \
"$MAINTAINER"
verify_ipk_metadata "$output_dir/luci-app-podkop-plus_${RELEASE_VERSION}.ipk" "luci-app-podkop-plus" "$RELEASE_VERSION"
verify_ipk_metadata "$output_dir/luci-i18n-podkop-plus-ru_${RELEASE_VERSION}.ipk" "luci-i18n-podkop-plus-ru" "$RELEASE_VERSION"
verify_apk_metadata "$apk_bin" "$output_dir/luci-app-podkop-plus_${RELEASE_VERSION}.apk" "luci-app-podkop-plus" "$APK_INTERNAL_VERSION"
verify_apk_metadata "$apk_bin" "$output_dir/luci-i18n-podkop-plus-ru_${RELEASE_VERSION}.apk" "luci-i18n-podkop-plus-ru" "$APK_INTERNAL_VERSION"
cleanup_work_dir
sync_artifacts_to_windows "$output_dir"
print_summary "$output_dir"
}
main "$@"
+798
View File
@@ -0,0 +1,798 @@
[CmdletBinding()]
param(
[string]$Router = '192.168.1.1',
[string]$User = 'root',
[int]$Port = 22
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
function Get-RepoRoot {
param([string]$StartPath)
$currentPath = [System.IO.Path]::GetFullPath($StartPath)
while ($true) {
$hasPodkop = Test-Path -LiteralPath (Join-Path $currentPath 'podkop')
$hasLuciApp = Test-Path -LiteralPath (Join-Path $currentPath 'luci-app-podkop-plus')
if ($hasPodkop -and $hasLuciApp) {
return $currentPath
}
$parentPath = Split-Path -Parent $currentPath
if ([string]::IsNullOrEmpty($parentPath) -or $parentPath -eq $currentPath) {
throw "Failed to locate repository root from script path: $StartPath"
}
$currentPath = $parentPath
}
}
$RepoRoot = Get-RepoRoot -StartPath $PSScriptRoot
Add-Type -TypeDefinition @"
using System;
public static class PodkopLmoHash
{
private static uint Get16(byte[] data, int offset)
{
return (uint)(data[offset] | (data[offset + 1] << 8));
}
public static uint Compute(byte[] data, uint init)
{
if (data == null || data.Length == 0)
return 0;
uint hash = init;
uint tmp;
int rem = data.Length & 3;
int len = data.Length >> 2;
int offset = 0;
while (len-- > 0)
{
hash += Get16(data, offset);
tmp = (Get16(data, offset + 2) << 11) ^ hash;
hash = (hash << 16) ^ tmp;
offset += 4;
hash += hash >> 11;
}
switch (rem)
{
case 3:
hash += Get16(data, offset);
hash ^= hash << 16;
hash ^= (uint)(((sbyte)data[offset + 2]) << 18);
hash += hash >> 11;
break;
case 2:
hash += Get16(data, offset);
hash ^= hash << 11;
hash += hash >> 17;
break;
case 1:
hash += (uint)(sbyte)data[offset];
hash ^= hash << 10;
hash += hash >> 1;
break;
}
hash ^= hash << 3;
hash += hash >> 5;
hash ^= hash << 4;
hash += hash >> 17;
hash ^= hash << 25;
hash += hash >> 6;
return hash;
}
}
"@
function Write-Step {
param([string]$Message)
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Get-RequiredCommand {
param([string]$Name)
$command = Get-Command -Name $Name -ErrorAction SilentlyContinue
if (-not $command) {
throw "Required command '$Name' was not found in PATH."
}
return $command.Source
}
function Invoke-External {
param(
[string]$FilePath,
[string[]]$Arguments,
[string]$Description
)
if ($Description) {
Write-Step $Description
}
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
$argString = ($Arguments | ForEach-Object { "'$_'" }) -join ' '
throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $argString"
}
}
function Invoke-ExternalCapture {
param(
[string]$FilePath,
[string[]]$Arguments
)
$output = & $FilePath @Arguments 2>&1
if ($LASTEXITCODE -ne 0) {
$argString = ($Arguments | ForEach-Object { "'$_'" }) -join ' '
throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $argString`n$($output -join [Environment]::NewLine)"
}
return ($output -join [Environment]::NewLine).Trim()
}
function Assert-RepoPath {
param([string]$RelativePath)
$fullPath = Join-Path $RepoRoot $RelativePath
if (-not (Test-Path -LiteralPath $fullPath)) {
throw "Expected repository path is missing: $RelativePath"
}
return $fullPath
}
function Get-DeployVersion {
$makefilePath = Assert-RepoPath 'luci-app-podkop-plus\Makefile'
$makefile = Get-Content -LiteralPath $makefilePath -Raw -Encoding UTF8
$pkgVersion = 'dev'
if ($makefile -match 'PKG_VERSION:=.*?([0-9]+\.[0-9]+\.[0-9]+)') {
$pkgVersion = $Matches[1]
}
$git = Get-Command -Name git -ErrorAction SilentlyContinue
if ($git) {
try {
$gitShort = Invoke-ExternalCapture -FilePath $git.Source -Arguments @('-C', $RepoRoot, 'rev-parse', '--short', 'HEAD')
if ($gitShort) {
return "$pkgVersion-dev+$gitShort"
}
}
catch {
}
}
return "$pkgVersion-dev"
}
function Copy-Tree {
param(
[string]$Source,
[string]$Destination
)
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
Get-ChildItem -LiteralPath $Source -Force | ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination $Destination -Recurse -Force
}
}
function Write-Utf8NoBom {
param(
[string]$Path,
[string]$Content
)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
}
function Replace-VersionPlaceholder {
param(
[string]$Path,
[string]$Version
)
$content = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
if ($content.Contains('__COMPILED_VERSION_VARIABLE__')) {
Write-Utf8NoBom -Path $Path -Content $content.Replace('__COMPILED_VERSION_VARIABLE__', $Version)
}
}
function New-PoMessageState {
return @{
PluralNum = -1
Ctxt = $null
Id = $null
IdPlural = $null
Values = (New-Object string[] 10)
CurrentField = $null
CurrentIndex = -1
}
}
function Get-PoStringSegment {
param([string]$Line)
if ([string]::IsNullOrEmpty($Line) -or $Line[0] -eq '#') {
return $null
}
$quoteIndex = $Line.IndexOf('"')
if ($quoteIndex -lt 0) {
return $null
}
$builder = New-Object System.Text.StringBuilder
$escape = $false
for ($index = $quoteIndex + 1; $index -lt $Line.Length; $index++) {
$char = $Line[$index]
if ($escape) {
if (($char -eq '"') -or ($char -eq '\')) {
[void]$builder.Remove($builder.Length - 1, 1)
}
[void]$builder.Append($char)
$escape = $false
continue
}
if ($char -eq '\') {
[void]$builder.Append($char)
$escape = $true
continue
}
if ($char -eq '"') {
break
}
[void]$builder.Append($char)
}
return $builder.ToString()
}
function Add-PoSegment {
param(
[hashtable]$Message,
[string]$Segment
)
if ([string]::IsNullOrEmpty($Segment)) {
return
}
switch ($Message.CurrentField) {
'ctxt' {
if ($null -eq $Message.Ctxt) {
$Message.Ctxt = ''
}
$Message.Ctxt += $Segment
}
'id' {
if ($null -eq $Message.Id) {
$Message.Id = ''
}
$Message.Id += $Segment
}
'idPlural' {
if ($null -eq $Message.IdPlural) {
$Message.IdPlural = ''
}
$Message.IdPlural += $Segment
}
'value' {
if ($Message.CurrentIndex -lt 0 -or $Message.CurrentIndex -ge $Message.Values.Length) {
throw "Invalid PO plural index: $($Message.CurrentIndex)"
}
if ($null -eq $Message.Values[$Message.CurrentIndex]) {
$Message.Values[$Message.CurrentIndex] = ''
}
$Message.Values[$Message.CurrentIndex] += $Segment
}
}
}
function Get-SignedByte {
param([byte]$Value)
if ($Value -ge 128) {
return [int]$Value - 256
}
return [int]$Value
}
function Get-UInt16Le {
param(
[byte[]]$Bytes,
[int]$Index
)
return [uint32]($Bytes[$Index] -bor ($Bytes[$Index + 1] -shl 8))
}
function Convert-ToUInt32 {
param([long]$Value)
return [uint32]($Value -band 0xffffffffL)
}
function Get-SfhHash {
param(
[byte[]]$Data,
[uint32]$Init
)
return [PodkopLmoHash]::Compute($Data, $Init)
}
function Write-UInt32BigEndian {
param(
[System.IO.BinaryWriter]$Writer,
[uint32]$Value
)
$bytes = [System.BitConverter]::GetBytes($Value)
if ([System.BitConverter]::IsLittleEndian) {
[Array]::Reverse($bytes)
}
$Writer.Write($bytes)
}
function Write-AlignedBytes {
param(
[System.IO.BinaryWriter]$Writer,
[byte[]]$Bytes
)
$Writer.Write($Bytes)
$padding = (4 - ($Bytes.Length % 4)) % 4
for ($index = 0; $index -lt $padding; $index++) {
$Writer.Write([byte]0)
}
}
function Add-LmoEntry {
param(
[System.Collections.Generic.List[object]]$Entries,
[System.IO.BinaryWriter]$DataWriter,
[System.IO.MemoryStream]$DataStream,
[uint32]$KeyId,
[uint32]$ValId,
[string]$Value
)
$utf8 = [System.Text.Encoding]::UTF8
$valueBytes = $utf8.GetBytes($Value)
$entry = [pscustomobject]@{
KeyId = $KeyId
ValId = $ValId
Offset = [uint32]$DataStream.Length
Length = [uint32]$valueBytes.Length
}
Write-AlignedBytes -Writer $DataWriter -Bytes $valueBytes
[void]$Entries.Add($entry)
}
function Flush-PoMessage {
param(
[hashtable]$Message,
[System.Collections.Generic.List[object]]$Entries,
[System.IO.BinaryWriter]$DataWriter,
[System.IO.MemoryStream]$DataStream
)
$utf8 = [System.Text.Encoding]::UTF8
if ($null -ne $Message.Id -and $null -ne $Message.Values[0]) {
$pluralLimit = if ($Message.PluralNum -lt 0) { 0 } else { $Message.PluralNum }
for ($index = 0; $index -le $pluralLimit; $index++) {
$value = $Message.Values[$index]
if ($null -eq $value) {
continue
}
if (($null -ne $Message.Ctxt) -and ($null -ne $Message.IdPlural)) {
$key = '{0}{1}{2}{3}{4}' -f $Message.Ctxt, [char]1, $Message.Id, [char]2, $index
}
elseif ($null -ne $Message.Ctxt) {
$key = '{0}{1}{2}' -f $Message.Ctxt, [char]1, $Message.Id
}
elseif ($null -ne $Message.IdPlural) {
$key = '{0}{1}{2}' -f $Message.Id, [char]2, $index
}
else {
$key = $Message.Id
}
$keyBytes = $utf8.GetBytes($key)
$valueBytes = $utf8.GetBytes($value)
$keyHash = Get-SfhHash -Data $keyBytes -Init ([uint32]$keyBytes.Length)
$valueHash = Get-SfhHash -Data $valueBytes -Init ([uint32]$valueBytes.Length)
if ($keyHash -eq $valueHash) {
continue
}
Add-LmoEntry -Entries $Entries -DataWriter $DataWriter -DataStream $DataStream -KeyId $keyHash -ValId ([uint32]($Message.PluralNum + 1)) -Value $value
}
}
elseif ($null -ne $Message.Values[0]) {
foreach ($field in ($Message.Values[0] -split '\\n')) {
if ($field.StartsWith('Plural-Forms: ')) {
$pluralDefinition = $field.Substring(14)
Add-LmoEntry -Entries $Entries -DataWriter $DataWriter -DataStream $DataStream -KeyId ([uint32]0) -ValId ([uint32]0) -Value $pluralDefinition
break
}
}
}
$Message.PluralNum = -1
$Message.Ctxt = $null
$Message.Id = $null
$Message.IdPlural = $null
$Message.Values = (New-Object string[] 10)
$Message.CurrentField = $null
$Message.CurrentIndex = -1
}
function Convert-PoToLmo {
param(
[string]$PoPath,
[string]$LmoPath
)
$entries = [System.Collections.Generic.List[object]]::new()
$dataStream = New-Object System.IO.MemoryStream
$dataWriter = New-Object System.IO.BinaryWriter($dataStream)
$message = New-PoMessageState
try {
foreach ($line in (Get-Content -LiteralPath $PoPath -Encoding UTF8)) {
if ($line.StartsWith('msgctxt "')) {
if (($null -ne $message.Id) -or ($null -ne $message.Values[0])) {
Flush-PoMessage -Message $message -Entries $entries -DataWriter $dataWriter -DataStream $dataStream
}
$message.CurrentField = 'ctxt'
$message.CurrentIndex = -1
$message.Ctxt = $null
}
elseif ($line.StartsWith('msgid "')) {
if (($null -ne $message.Id) -or ($null -ne $message.Values[0])) {
Flush-PoMessage -Message $message -Entries $entries -DataWriter $dataWriter -DataStream $dataStream
}
$message.CurrentField = 'id'
$message.CurrentIndex = -1
$message.Id = $null
}
elseif ($line.StartsWith('msgid_plural "')) {
$message.CurrentField = 'idPlural'
$message.CurrentIndex = -1
$message.IdPlural = $null
}
elseif ($line.StartsWith('msgstr "')) {
$message.PluralNum = 0
$message.CurrentField = 'value'
$message.CurrentIndex = 0
$message.Values[0] = $null
}
elseif ($line.StartsWith('msgstr[')) {
if ($line -notmatch '^msgstr\[(\d+)\]') {
throw "Failed to parse plural msgstr line: $line"
}
$pluralIndex = [int]$Matches[1]
if ($pluralIndex -ge $message.Values.Length) {
throw "Too many plural forms in $PoPath"
}
$message.PluralNum = $pluralIndex
$message.CurrentField = 'value'
$message.CurrentIndex = $pluralIndex
$message.Values[$pluralIndex] = $null
}
$segment = Get-PoStringSegment -Line $line
if ($null -ne $segment) {
Add-PoSegment -Message $message -Segment $segment
}
}
Flush-PoMessage -Message $message -Entries $entries -DataWriter $dataWriter -DataStream $dataStream
if ($dataStream.Length -le 0) {
throw "No translatable data was produced from $PoPath"
}
$indexStream = New-Object System.IO.MemoryStream
$indexWriter = New-Object System.IO.BinaryWriter($indexStream)
try {
foreach ($entry in ($entries | Sort-Object -Property KeyId)) {
Write-UInt32BigEndian -Writer $indexWriter -Value ([uint32]$entry.KeyId)
Write-UInt32BigEndian -Writer $indexWriter -Value ([uint32]$entry.ValId)
Write-UInt32BigEndian -Writer $indexWriter -Value ([uint32]$entry.Offset)
Write-UInt32BigEndian -Writer $indexWriter -Value ([uint32]$entry.Length)
}
$finalStream = New-Object System.IO.MemoryStream
$finalWriter = New-Object System.IO.BinaryWriter($finalStream)
try {
$finalWriter.Write($dataStream.ToArray())
$finalWriter.Write($indexStream.ToArray())
Write-UInt32BigEndian -Writer $finalWriter -Value ([uint32]$dataStream.Length)
[System.IO.File]::WriteAllBytes($LmoPath, $finalStream.ToArray())
}
finally {
$finalWriter.Dispose()
$finalStream.Dispose()
}
}
finally {
$indexWriter.Dispose()
$indexStream.Dispose()
}
}
finally {
$dataWriter.Dispose()
$dataStream.Dispose()
}
}
function Get-RemoteInstallScript {
return @'
#!/bin/sh
set -eu
BUNDLE_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
replace_file() {
src="$1"
dest="$2"
mode="$3"
tmp="${dest}.podkop-plus-new.$$"
mkdir -p "$(dirname "$dest")"
cp "$src" "$tmp"
chmod "$mode" "$tmp"
mv -f "$tmp" "$dest"
}
replace_dir() {
src="$1"
dest="$2"
base_dir="$(dirname "$dest")"
dir_name="$(basename "$dest")"
tmp="${base_dir}/.${dir_name}.podkop-plus-new.$$"
mkdir -p "$base_dir"
rm -rf "$tmp"
mkdir -p "$tmp"
(cd "$src" && tar -cf - .) | (cd "$tmp" && tar -xf -)
rm -rf "$dest"
mv "$tmp" "$dest"
}
[ -x /etc/init.d/podkop-plus ] && /etc/init.d/podkop-plus stop >/dev/null 2>&1 || true
[ -x /etc/init.d/podkop ] && /etc/init.d/podkop stop >/dev/null 2>&1 || true
[ -x /etc/init.d/podkop ] && /etc/init.d/podkop disable >/dev/null 2>&1 || true
replace_file "$BUNDLE_DIR/etc/init.d/podkop-plus" "/etc/init.d/podkop-plus" 0755
replace_file "$BUNDLE_DIR/usr/bin/podkop-plus" "/usr/bin/podkop-plus" 0755
replace_dir "$BUNDLE_DIR/usr/lib/podkop-plus" "/usr/lib/podkop-plus"
replace_dir "$BUNDLE_DIR/www/luci-static/resources/view/podkop" "/www/luci-static/resources/view/podkop"
replace_file "$BUNDLE_DIR/usr/share/luci/menu.d/luci-app-podkop-plus.json" "/usr/share/luci/menu.d/luci-app-podkop-plus.json" 0644
replace_file "$BUNDLE_DIR/usr/share/rpcd/acl.d/luci-app-podkop-plus.json" "/usr/share/rpcd/acl.d/luci-app-podkop-plus.json" 0644
replace_file "$BUNDLE_DIR/usr/lib/lua/luci/i18n/podkop.ru.lmo" "/usr/lib/lua/luci/i18n/podkop.ru.lmo" 0644
replace_file "$BUNDLE_DIR/etc/uci-defaults/50_luci-podkop-plus" "/etc/uci-defaults/50_luci-podkop-plus" 0755
if [ ! -f /etc/config/podkop ]; then
replace_file "$BUNDLE_DIR/etc/config/podkop" "/etc/config/podkop" 0644
fi
rm -f /etc/init.d/podkop
rm -f /usr/bin/podkop
rm -f /usr/share/luci/menu.d/luci-app-podkop.json
rm -f /usr/share/rpcd/acl.d/luci-app-podkop.json
rm -rf /usr/lib/podkop
rm -f /usr/lib/lua/luci/i18n/podkop.ru.lua
rm -f /usr/lib/lua/luci/i18n/podkop.en.lua
rm -f /usr/lib/lua/luci/i18n/podkop.en.lmo
rm -f /var/luci-indexcache* /tmp/luci-indexcache*
[ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload >/dev/null 2>&1 || true
/etc/init.d/podkop-plus enable >/dev/null 2>&1 || true
get_status_json() {
/usr/bin/podkop-plus get_status 2>/dev/null || true
}
wait_for_service() {
attempts=90
consecutive=0
LAST_STATUS_JSON='{}'
while [ "$attempts" -gt 0 ]; do
LAST_STATUS_JSON="$(get_status_json)"
if printf '%s\n' "$LAST_STATUS_JSON" | grep -q '"running":1'; then
consecutive=$((consecutive + 1))
if [ "$consecutive" -ge 3 ]; then
return 0
fi
else
consecutive=0
fi
attempts=$((attempts - 1))
sleep 1
done
return 1
}
/etc/init.d/podkop-plus restart >/dev/null 2>&1 || true
echo "System LuCI language: $(uci -q get luci.main.lang || echo auto)"
echo "Podkop RU catalog: $( [ -f /usr/lib/lua/luci/i18n/podkop.ru.lmo ] && echo installed || echo absent )"
echo "Autostart enabled: $(/etc/init.d/podkop-plus enabled >/dev/null 2>&1 && echo yes || echo no)"
echo "Service status:"
if ! wait_for_service; then
printf '%s\n' "$LAST_STATUS_JSON"
exit 1
fi
printf '%s\n' "$LAST_STATUS_JSON"
'@
}
function New-DeployBundle {
param(
[string]$StageRoot,
[string]$Version
)
$bundleFiles = @{
'podkop/files/etc/init.d/podkop' = 'etc/init.d/podkop-plus'
'podkop/files/etc/config/podkop' = 'etc/config/podkop'
'podkop/files/usr/bin/podkop' = 'usr/bin/podkop-plus'
'luci-app-podkop-plus/root/usr/share/luci/menu.d/luci-app-podkop-plus.json' = 'usr/share/luci/menu.d/luci-app-podkop-plus.json'
'luci-app-podkop-plus/root/usr/share/rpcd/acl.d/luci-app-podkop-plus.json' = 'usr/share/rpcd/acl.d/luci-app-podkop-plus.json'
'luci-app-podkop-plus/root/etc/uci-defaults/50_luci-podkop-plus' = 'etc/uci-defaults/50_luci-podkop-plus'
}
foreach ($entry in $bundleFiles.GetEnumerator()) {
$source = Assert-RepoPath $entry.Key
$destination = Join-Path $StageRoot $entry.Value
New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null
Copy-Item -LiteralPath $source -Destination $destination -Force
}
Copy-Tree -Source (Assert-RepoPath 'podkop/files/usr/lib') -Destination (Join-Path $StageRoot 'usr/lib/podkop-plus')
Copy-Tree -Source (Assert-RepoPath 'luci-app-podkop-plus/htdocs/luci-static/resources/view/podkop') -Destination (Join-Path $StageRoot 'www/luci-static/resources/view/podkop')
Replace-VersionPlaceholder -Path (Join-Path $StageRoot 'usr/lib/podkop-plus/constants.sh') -Version $Version
Replace-VersionPlaceholder -Path (Join-Path $StageRoot 'www/luci-static/resources/view/podkop/main.js') -Version $Version
$poPath = Assert-RepoPath 'luci-app-podkop-plus\po\ru\podkop.po'
$lmoPath = Join-Path $StageRoot 'usr/lib/lua/luci/i18n/podkop.ru.lmo'
New-Item -ItemType Directory -Path (Split-Path -Parent $lmoPath) -Force | Out-Null
Convert-PoToLmo -PoPath $poPath -LmoPath $lmoPath
$remoteScriptPath = Join-Path $StageRoot '.devdeploy\install.sh'
New-Item -ItemType Directory -Path (Split-Path -Parent $remoteScriptPath) -Force | Out-Null
Write-Utf8NoBom -Path $remoteScriptPath -Content (Get-RemoteInstallScript)
}
function New-TarArchive {
param(
[string]$TarExe,
[string]$SourceDirectory,
[string]$ArchivePath
)
if (Test-Path -LiteralPath $ArchivePath) {
Remove-Item -LiteralPath $ArchivePath -Force
}
Push-Location $SourceDirectory
try {
Invoke-External -FilePath $TarExe -Arguments @('-czf', $ArchivePath, '.') -Description 'Packing deploy bundle'
}
finally {
Pop-Location
}
}
$sshExe = Get-RequiredCommand -Name 'ssh.exe'
$scpExe = Get-RequiredCommand -Name 'scp.exe'
$tarExe = Get-RequiredCommand -Name 'tar.exe'
[void](Assert-RepoPath 'podkop')
[void](Assert-RepoPath 'luci-app-podkop-plus')
$version = Get-DeployVersion
$target = "$User@$Router"
$sshArgs = @('-p', $Port.ToString(), '-o', 'BatchMode=yes')
$scpArgs = @('-O', '-P', $Port.ToString(), '-o', 'BatchMode=yes')
$remoteId = "podkop-plus-devdeploy-$PID"
$remoteArchive = "/tmp/$remoteId.tgz"
$remoteWorkDir = "/tmp/$remoteId"
$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "$remoteId-local"
$stageRoot = Join-Path $tempRoot 'bundle'
$archivePath = Join-Path $tempRoot "$remoteId.tgz"
try {
if (Test-Path -LiteralPath $tempRoot) {
Remove-Item -LiteralPath $tempRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $stageRoot -Force | Out-Null
Write-Step "Preparing deploy bundle for $target"
New-DeployBundle -StageRoot $stageRoot -Version $version
New-TarArchive -TarExe $tarExe -SourceDirectory $stageRoot -ArchivePath $archivePath
Invoke-External -FilePath $scpExe -Arguments ($scpArgs + @($archivePath, "${target}:$remoteArchive")) -Description "Uploading archive to $target"
$remoteCommand = @'
set -eu
archive='__ARCHIVE__'
work='__WORKDIR__'
cleanup() {
rm -rf "$work" "$archive"
}
trap cleanup EXIT INT TERM
rm -rf "$work"
mkdir -p "$work"
tar -xzf "$archive" -C "$work"
sh "$work/.devdeploy/install.sh"
'@
$remoteCommand = $remoteCommand.Replace('__ARCHIVE__', $remoteArchive)
$remoteCommand = $remoteCommand.Replace('__WORKDIR__', $remoteWorkDir)
Invoke-External -FilePath $sshExe -Arguments ($sshArgs + @($target, $remoteCommand)) -Description "Installing bundle on $target"
Write-Step 'Deploy finished successfully'
}
finally {
if (Test-Path -LiteralPath $tempRoot) {
Remove-Item -LiteralPath $tempRoot -Recurse -Force
}
}