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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -2,3 +2,7 @@
|
||||
fe-app-podkop/node_modules
|
||||
fe-app-podkop/.env
|
||||
.DS_Store
|
||||
|
||||
sandbox/
|
||||
.wsl-build/
|
||||
dist/
|
||||
|
||||
+5
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
[](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`, которые обходят встроенную валидацию и управление очередями.
|
||||
@@ -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/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
+841
-386
File diff suppressed because it is too large
Load Diff
+561
-286
File diff suppressed because it is too large
Load Diff
+327
-147
@@ -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 не установлен"
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
+710
-251
File diff suppressed because it is too large
Load Diff
@@ -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
+9
-5
@@ -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
+2
-2
@@ -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
|
||||
+4
-4
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
@@ -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'
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ log() {
|
||||
level="info"
|
||||
fi
|
||||
|
||||
logger -t "podkop" "[$level] $message"
|
||||
logger -t "podkop-plus" "[$level] $message"
|
||||
}
|
||||
|
||||
nolog() {
|
||||
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user