mirror of
https://github.com/itdoginfo/podkop.git
synced 2026-07-21 23:03:26 +03:00
Compare commits
6 Commits
de7b73af3c
...
0.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235368417b | ||
|
|
5e9d036eca | ||
|
|
a64923db5a | ||
|
|
0c99dddf11 | ||
|
|
922d6d4638 | ||
|
|
34f49c392e |
@@ -510,7 +510,7 @@ list_update() {
|
|||||||
local service_proxy_address
|
local service_proxy_address
|
||||||
service_proxy_address="$(get_service_proxy_address)"
|
service_proxy_address="$(get_service_proxy_address)"
|
||||||
|
|
||||||
if [ -n "$http_proxy_address" ]; then
|
if [ -n "$service_proxy_address" ]; then
|
||||||
if curl -s -x "http://$service_proxy_address" -m $curl_timeout https://github.com > /dev/null; then
|
if curl -s -x "http://$service_proxy_address" -m $curl_timeout https://github.com > /dev/null; then
|
||||||
echolog "✅ GitHub connection check passed (via proxy)"
|
echolog "✅ GitHub connection check passed (via proxy)"
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ patch_source_ruleset_rules() {
|
|||||||
import_plain_domain_list_to_local_source_ruleset_chunked() {
|
import_plain_domain_list_to_local_source_ruleset_chunked() {
|
||||||
local plain_list_filepath="$1"
|
local plain_list_filepath="$1"
|
||||||
local ruleset_filepath="$2"
|
local ruleset_filepath="$2"
|
||||||
local chunk_size="${3:-5000}"
|
local chunk_size="${3:-1000}"
|
||||||
|
|
||||||
local array count json_array
|
local array count json_array
|
||||||
count=0
|
count=0
|
||||||
@@ -102,7 +102,7 @@ import_plain_domain_list_to_local_source_ruleset_chunked() {
|
|||||||
import_plain_subnet_list_to_local_source_ruleset_chunked() {
|
import_plain_subnet_list_to_local_source_ruleset_chunked() {
|
||||||
local plain_list_filepath="$1"
|
local plain_list_filepath="$1"
|
||||||
local ruleset_filepath="$2"
|
local ruleset_filepath="$2"
|
||||||
local chunk_size="${3:-5000}"
|
local chunk_size="${3:-1000}"
|
||||||
|
|
||||||
local array count json_array
|
local array count json_array
|
||||||
count=0
|
count=0
|
||||||
|
|||||||
0
src/backend/.keep
Normal file
0
src/backend/.keep
Normal file
4
src/frontend/.gitignore
vendored
Normal file
4
src/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
.env
|
||||||
12
src/frontend/package.json
Normal file
12
src/frontend/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "podkop-frontend",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev:sandbox": "cd sandbox/docker && ./up.sh",
|
||||||
|
"sandbox:down": "cd sandbox/docker && docker compose down",
|
||||||
|
"sandbox:logs": "cd sandbox/docker && docker compose logs -f",
|
||||||
|
"sandbox:shell": "docker exec -it podkop-dev-router sh",
|
||||||
|
"sandbox:scenario": "cd sandbox/docker && ./scenario.sh"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/frontend/sandbox/Makefile
Normal file
32
src/frontend/sandbox/Makefile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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
|
||||||
|
# +podkop dropped while the backend is rewritten in Go; the frontend runs
|
||||||
|
# against a stub (docker/files/usr/bin/podkop). Restore before release.
|
||||||
|
LUCI_DEPENDS:=+luci-base
|
||||||
|
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)))
|
||||||
26
src/frontend/sandbox/docker/Dockerfile
Normal file
26
src/frontend/sandbox/docker/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Dev stand for luci-app-podkop: stock OpenWrt userland + real LuCI.
|
||||||
|
#
|
||||||
|
# The app is loaded by LuCI's own require loader and reaches the system through
|
||||||
|
# rpcd with real ACL checks — neither is exercised by a JS-level mock.
|
||||||
|
# No backend: /usr/bin/podkop is a shell stub serving fixtures.
|
||||||
|
ARG OPENWRT_TAG=x86-64-25.12.4
|
||||||
|
FROM openwrt/rootfs:${OPENWRT_TAG}
|
||||||
|
|
||||||
|
# Absent from the rootfs tarball; on a router procd creates them under the /var
|
||||||
|
# tmpfs. apk needs them earlier.
|
||||||
|
RUN mkdir -p /var/lock /var/run /var/log /var/state
|
||||||
|
|
||||||
|
# firewall/opkg are not about podkop — a page in an empty menu shows nothing
|
||||||
|
# about how the app sits next to its neighbours.
|
||||||
|
RUN set -eu; \
|
||||||
|
apk update; \
|
||||||
|
for p in luci luci-app-firewall luci-app-opkg; do \
|
||||||
|
apk add "$p" || echo "skipped (not in feed): $p"; \
|
||||||
|
done
|
||||||
|
|
||||||
|
COPY files/ /
|
||||||
|
RUN chmod +x /usr/bin/podkop /etc/init.d/podkop /usr/lib/podkop-dev/entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["/usr/lib/podkop-dev/entrypoint.sh"]
|
||||||
|
CMD ["/sbin/init"]
|
||||||
73
src/frontend/sandbox/docker/README.md
Normal file
73
src/frontend/sandbox/docker/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Dev stand for luci-app-podkop
|
||||||
|
|
||||||
|
Real OpenWrt 25.12 with real LuCI in a container. The app is mounted from the
|
||||||
|
host, so the loop is: edit js, hit F5.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn dev:sandbox # from src/frontend
|
||||||
|
./up.sh # or directly from here
|
||||||
|
```
|
||||||
|
|
||||||
|
UI: <http://localhost:8080> → **Services → Podkop** (`root`, empty password).
|
||||||
|
Stop with `docker compose down`.
|
||||||
|
|
||||||
|
Use `up.sh`, not `docker compose up`: `openwrt/rootfs` has no multiarch
|
||||||
|
manifest, and the arm image declares `aarch64_generic` rather than `arm64`.
|
||||||
|
`up.sh` derives both the tag and the platform from `uname -m`.
|
||||||
|
|
||||||
|
## Why a container instead of a browser mock
|
||||||
|
|
||||||
|
A JS mock only exercises our own code. Here everything below the app is real:
|
||||||
|
|
||||||
|
| mocked in the browser | real here |
|
||||||
|
| --- | --- |
|
||||||
|
| `fs.exec` | rpcd + **ACL checks** from `acl.d/luci-app-podkop.json` |
|
||||||
|
| `uci` | uci over `/etc/config/podkop` |
|
||||||
|
| `_()` | translations loaded from `.po` |
|
||||||
|
| theme | `cascade.css` served by LuCI |
|
||||||
|
| "the file parses" | **LuCI's require loader** |
|
||||||
|
|
||||||
|
The last two are the point. An ACL mistake returns 403 only on a router.
|
||||||
|
|
||||||
|
## What is not here
|
||||||
|
|
||||||
|
**The backend.** It is being rewritten in Go; `/usr/bin/podkop` and
|
||||||
|
`/etc/init.d/podkop` are shell stubs serving fixtures. The mock boundary is the
|
||||||
|
binary, not the JS, so the whole frontend above it stays real.
|
||||||
|
|
||||||
|
**Traffic interception.** `kmod-nft-tproxy` is built against OpenWrt's kernel
|
||||||
|
and cannot load in a container. `check_nft_rules` reports some rules missing on
|
||||||
|
purpose — that is the stand's honest state.
|
||||||
|
|
||||||
|
**The luci-app version.** The package Makefile substitutes it at build time;
|
||||||
|
here the js is mounted as-is, so diagnostics shows
|
||||||
|
`__COMPILED_VERSION_VARIABLE__`.
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scenario.sh # current
|
||||||
|
./scenario.sh ok # everything works
|
||||||
|
./scenario.sh fail # commands fail — exercises failed states
|
||||||
|
./scenario.sh stopped # services down, Clash unreachable
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload the page afterwards.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| `Dockerfile` | OpenWrt 25.12.4 + luci (+ firewall/opkg for menu neighbours) |
|
||||||
|
| `compose.yml` | `router` (8080) and `clash-mock` (9090) |
|
||||||
|
| `files/usr/bin/podkop` | backend stub |
|
||||||
|
| `files/etc/init.d/podkop` | service stub for Start/Stop/Restart |
|
||||||
|
| `files/etc/config/podkop` | uci fixture, one section per parse branch |
|
||||||
|
| `files/etc/rc.local` | seeds runtime state after boot |
|
||||||
|
| `clash-mock/server.mjs` | `/traffic` and `/connections` websockets |
|
||||||
|
|
||||||
|
`/etc/config/podkop` is required: `menu.d` declares
|
||||||
|
`"depends": { "uci": { "podkop": true } }`, so without it LuCI hides the menu
|
||||||
|
entry entirely.
|
||||||
109
src/frontend/sandbox/docker/clash-mock/server.mjs
Normal file
109
src/frontend/sandbox/docker/clash-mock/server.mjs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Clash API metrics for the dev stand: /traffic and /connections on :9090.
|
||||||
|
*
|
||||||
|
* Handshake and framing are written by hand so node:22-alpine needs no
|
||||||
|
* npm install.
|
||||||
|
*/
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
|
||||||
|
const PORT = 9090;
|
||||||
|
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||||
|
|
||||||
|
/** Text frame: FIN + opcode 1, unmasked (servers do not mask). */
|
||||||
|
function encodeFrame(text) {
|
||||||
|
const payload = Buffer.from(text, 'utf8');
|
||||||
|
const length = payload.length;
|
||||||
|
|
||||||
|
let header;
|
||||||
|
|
||||||
|
if (length < 126) {
|
||||||
|
header = Buffer.from([0x81, length]);
|
||||||
|
} else if (length < 65536) {
|
||||||
|
header = Buffer.alloc(4);
|
||||||
|
header[0] = 0x81;
|
||||||
|
header[1] = 126;
|
||||||
|
header.writeUInt16BE(length, 2);
|
||||||
|
} else {
|
||||||
|
header = Buffer.alloc(10);
|
||||||
|
header[0] = 0x81;
|
||||||
|
header[1] = 127;
|
||||||
|
header.writeBigUInt64BE(BigInt(length), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat([header, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
uploadTotal: 4_800_000_000,
|
||||||
|
downloadTotal: 51_200_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
function trafficFrame() {
|
||||||
|
return {
|
||||||
|
up: Math.floor(20_000 + Math.random() * 900_000),
|
||||||
|
down: Math.floor(80_000 + Math.random() * 6_000_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionsFrame() {
|
||||||
|
state.uploadTotal += Math.floor(Math.random() * 900_000);
|
||||||
|
state.downloadTotal += Math.floor(Math.random() * 6_000_000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadTotal: state.uploadTotal,
|
||||||
|
downloadTotal: state.downloadTotal,
|
||||||
|
memory: Math.floor(28_000_000 + Math.random() * 12_000_000),
|
||||||
|
connections: Array.from(
|
||||||
|
{ length: 8 + Math.floor(Math.random() * 24) },
|
||||||
|
(_unused, index) => ({ id: `conn-${index}` }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
// The dashboard only uses the websocket; GET just needs to say something
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ hello: 'podkop dev clash mock', path: req.url }));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('upgrade', (req, socket) => {
|
||||||
|
const key = req.headers['sec-websocket-key'];
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = createHash('sha1')
|
||||||
|
.update(key + GUID)
|
||||||
|
.digest('base64');
|
||||||
|
|
||||||
|
socket.write(
|
||||||
|
'HTTP/1.1 101 Switching Protocols\r\n' +
|
||||||
|
'Upgrade: websocket\r\n' +
|
||||||
|
'Connection: Upgrade\r\n' +
|
||||||
|
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const path = (req.url || '').split('?')[0];
|
||||||
|
const build = path === '/traffic' ? trafficFrame : connectionsFrame;
|
||||||
|
|
||||||
|
console.log(`[clash-mock] connected: ${path}`);
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
socket.write(encodeFrame(JSON.stringify(build())));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
socket.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('close', stop);
|
||||||
|
socket.on('error', stop);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`[clash-mock] listening on :${PORT} (/traffic, /connections)`);
|
||||||
|
});
|
||||||
37
src/frontend/sandbox/docker/compose.yml
Normal file
37
src/frontend/sandbox/docker/compose.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: podkop-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
router:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
# No multiarch manifest — one tag per arch. Set by ./up.sh.
|
||||||
|
OPENWRT_TAG: ${OPENWRT_TAG:-x86-64-25.12.4}
|
||||||
|
# The arm image declares OpenWrt's own arch name (aarch64_generic),
|
||||||
|
# which Docker does not match to linux/arm64 on its own.
|
||||||
|
platform: ${OPENWRT_PLATFORM:-linux/amd64}
|
||||||
|
container_name: podkop-dev-router
|
||||||
|
hostname: OpenWrt
|
||||||
|
privileged: true # procd as PID 1
|
||||||
|
init: false
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '8080:80'
|
||||||
|
volumes:
|
||||||
|
# Mounted from the host: edit js, hit F5, no rebuild.
|
||||||
|
- ../htdocs/luci-static/resources/view/podkop:/www/luci-static/resources/view/podkop:ro
|
||||||
|
- ../root/usr/share/luci/menu.d/luci-app-podkop.json:/usr/share/luci/menu.d/luci-app-podkop.json:ro
|
||||||
|
- ../root/usr/share/rpcd/acl.d/luci-app-podkop.json:/usr/share/rpcd/acl.d/luci-app-podkop.json:ro
|
||||||
|
|
||||||
|
# Nothing in the OpenWrt container can serve a websocket (sing-box is
|
||||||
|
# deliberately absent), so Clash API metrics live in their own service.
|
||||||
|
clash-mock:
|
||||||
|
image: node:22-alpine
|
||||||
|
container_name: podkop-dev-clash
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '9090:9090'
|
||||||
|
volumes:
|
||||||
|
- ./clash-mock:/app:ro
|
||||||
|
command: ['node', '/app/server.mjs']
|
||||||
47
src/frontend/sandbox/docker/files/etc/config/podkop
Normal file
47
src/frontend/sandbox/docker/files/etc/config/podkop
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# uci fixture for the dev stand.
|
||||||
|
#
|
||||||
|
# Required: menu.d declares "depends": { "uci": { "podkop": true } }, so LuCI
|
||||||
|
# hides the menu entry until this file exists.
|
||||||
|
#
|
||||||
|
# Sections cover every parse branch on the dashboard — url / outbound /
|
||||||
|
# selector / urltest / vpn — plus block and exclusion, which must be filtered out.
|
||||||
|
|
||||||
|
config settings 'settings'
|
||||||
|
option enable_yacd '1'
|
||||||
|
option yacd_secret_key 'dev-stand-secret'
|
||||||
|
option dns_type 'doh'
|
||||||
|
option dns_server 'dns.adguard-dns.com'
|
||||||
|
|
||||||
|
config section 'youtube'
|
||||||
|
option connection_type 'proxy'
|
||||||
|
option proxy_config_type 'url'
|
||||||
|
option proxy_string 'vless://uuid@example.com:443?type=tcp#YouTube%20VLESS'
|
||||||
|
list domain_list 'youtube'
|
||||||
|
|
||||||
|
config section 'netflix'
|
||||||
|
option connection_type 'proxy'
|
||||||
|
option proxy_config_type 'outbound'
|
||||||
|
option outbound_json '{"tag":"Netflix%20Shadowsocks","type":"shadowsocks"}'
|
||||||
|
|
||||||
|
config section 'media'
|
||||||
|
option connection_type 'proxy'
|
||||||
|
option proxy_config_type 'selector'
|
||||||
|
list selector_proxy_links 'vless://uuid@nl.example.com:443#Netherlands'
|
||||||
|
list selector_proxy_links 'vless://uuid@de.example.com:443#Germany'
|
||||||
|
list selector_proxy_links 'trojan://uuid@fi.example.com:443#Finland'
|
||||||
|
|
||||||
|
config section 'fast'
|
||||||
|
option connection_type 'proxy'
|
||||||
|
option proxy_config_type 'urltest'
|
||||||
|
list urltest_proxy_links 'vless://uuid@se.example.com:443#Sweden'
|
||||||
|
list urltest_proxy_links 'vless://uuid@pl.example.com:443#Poland'
|
||||||
|
|
||||||
|
config section 'home_vpn'
|
||||||
|
option connection_type 'vpn'
|
||||||
|
option interface 'wg0'
|
||||||
|
|
||||||
|
config section 'ads'
|
||||||
|
option connection_type 'block'
|
||||||
|
|
||||||
|
config section 'local'
|
||||||
|
option connection_type 'exclusion'
|
||||||
9
src/frontend/sandbox/docker/files/etc/config/system
Normal file
9
src/frontend/sandbox/docker/files/etc/config/system
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
config system
|
||||||
|
option hostname 'OpenWrt'
|
||||||
|
option timezone 'UTC'
|
||||||
|
option ttylogin '0'
|
||||||
|
option log_size '64'
|
||||||
|
option urandom_seed '0'
|
||||||
|
|
||||||
|
config timeserver 'ntp'
|
||||||
|
option enabled '0'
|
||||||
32
src/frontend/sandbox/docker/files/etc/init.d/podkop
Executable file
32
src/frontend/sandbox/docker/files/etc/init.d/podkop
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh /etc/rc.common
|
||||||
|
# Service stub. No daemon behind it — just enough for the Start/Stop/Restart/
|
||||||
|
# Enable/Disable buttons to go through rpcd and change observable state.
|
||||||
|
START=99
|
||||||
|
USE_PROCD=0
|
||||||
|
|
||||||
|
STATE=/tmp/podkop-service-state
|
||||||
|
|
||||||
|
start() {
|
||||||
|
echo running >"$STATE"
|
||||||
|
echo '{"result":"started"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
echo stopped >"$STATE"
|
||||||
|
echo '{"result":"stopped"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
restart() {
|
||||||
|
echo running >"$STATE"
|
||||||
|
echo '{"result":"restarted"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
touch /etc/rc.d/S99podkop 2>/dev/null
|
||||||
|
echo '{"result":"enabled"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
disable() {
|
||||||
|
rm -f /etc/rc.d/S99podkop 2>/dev/null
|
||||||
|
echo '{"result":"disabled"}'
|
||||||
|
}
|
||||||
9
src/frontend/sandbox/docker/files/etc/rc.local
Normal file
9
src/frontend/sandbox/docker/files/etc/rc.local
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Seed runtime state for the backend stub.
|
||||||
|
#
|
||||||
|
# Not in the entrypoint: that runs before procd, which mounts a tmpfs over /tmp
|
||||||
|
# and wipes anything written there earlier.
|
||||||
|
|
||||||
|
echo ok >/tmp/podkop-scenario
|
||||||
|
echo running >/tmp/podkop-service-state
|
||||||
|
|
||||||
|
exit 0
|
||||||
203
src/frontend/sandbox/docker/files/usr/bin/podkop
Executable file
203
src/frontend/sandbox/docker/files/usr/bin/podkop
Executable file
@@ -0,0 +1,203 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Backend stub for the dev stand. The real backend is being rewritten in Go.
|
||||||
|
#
|
||||||
|
# The mock boundary sits here rather than in JS, so rpcd, ACL, fs.exec, uci and
|
||||||
|
# LuCI's require loader all stay real.
|
||||||
|
#
|
||||||
|
# Commands mirror what main.js actually calls, nothing more.
|
||||||
|
#
|
||||||
|
# Scenario is read from a file (see docker/scenario.sh):
|
||||||
|
# ok everything works (default)
|
||||||
|
# fail commands fail with empty stdout
|
||||||
|
# stopped services down, Clash unreachable
|
||||||
|
SCENARIO_FILE=/tmp/podkop-scenario
|
||||||
|
SCENARIO=$(cat "$SCENARIO_FILE" 2>/dev/null || echo ok)
|
||||||
|
|
||||||
|
# Latency tests and node selection must persist between calls
|
||||||
|
LATENCY_DIR=/tmp/podkop-latency
|
||||||
|
SELECTED_DIR=/tmp/podkop-selected
|
||||||
|
mkdir -p "$LATENCY_DIR" "$SELECTED_DIR"
|
||||||
|
|
||||||
|
# Empty stdout is how the frontend detects failure
|
||||||
|
die_if_failing() {
|
||||||
|
[ "$SCENARIO" = "fail" ] && exit 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 40..2000 ms. Not `date +%s%N` (%N is not portable, emits a literal "N") and
|
||||||
|
# not `od` (absent from the OpenWrt image; empty output pinned every node to 40).
|
||||||
|
rand_latency() {
|
||||||
|
_r="$RANDOM"
|
||||||
|
|
||||||
|
if [ -z "$_r" ]; then
|
||||||
|
_r=$(awk 'BEGIN{srand();print int(rand()*32768)}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo $((_r % 1960 + 40))
|
||||||
|
}
|
||||||
|
|
||||||
|
latency_of() { # $1=code $2=fallback
|
||||||
|
if [ -f "$LATENCY_DIR/$1" ]; then cat "$LATENCY_DIR/$1"; else echo "$2"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
selected_of() { # $1=group $2=fallback
|
||||||
|
if [ -f "$SELECTED_DIR/$1" ]; then cat "$SELECTED_DIR/$1"; else echo "$2"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
history_of() { # $1=delay
|
||||||
|
if [ "$1" -eq 0 ]; then
|
||||||
|
echo '[]'
|
||||||
|
else
|
||||||
|
echo "[{\"time\":\"2026-07-20T10:00:00Z\",\"delay\":$1}]"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_json() { # $1=name $2=type $3=delay
|
||||||
|
printf '"%s":{"type":"%s","name":"%s","udp":true,"history":%s}' \
|
||||||
|
"$1" "$2" "$1" "$(history_of "$3")"
|
||||||
|
}
|
||||||
|
|
||||||
|
group_json() { # $1=name $2=type $3=now $4=all(json) $5=delay
|
||||||
|
printf '"%s":{"type":"%s","name":"%s","udp":false,"now":"%s","all":%s,"history":%s}' \
|
||||||
|
"$1" "$2" "$1" "$3" "$4" "$(history_of "$5")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mirrors the sections in /etc/config/podkop; covers every parse branch on the
|
||||||
|
# dashboard: url / outbound / selector / urltest / vpn.
|
||||||
|
emit_proxies() {
|
||||||
|
printf '{"proxies":{'
|
||||||
|
proxy_json 'youtube-out' 'vless' "$(latency_of youtube-out 142)"
|
||||||
|
printf ','
|
||||||
|
proxy_json 'netflix-out' 'shadowsocks' "$(latency_of netflix-out 980)"
|
||||||
|
printf ','
|
||||||
|
group_json 'media-out' 'Selector' "$(selected_of media-out 'media-2-out')" \
|
||||||
|
'["media-1-out","media-2-out","media-3-out"]' 0
|
||||||
|
printf ','
|
||||||
|
proxy_json 'media-1-out' 'vless' "$(latency_of media-1-out 210)"
|
||||||
|
printf ','
|
||||||
|
proxy_json 'media-2-out' 'vless' "$(latency_of media-2-out 1320)"
|
||||||
|
printf ','
|
||||||
|
proxy_json 'media-3-out' 'trojan' "$(latency_of media-3-out 2400)"
|
||||||
|
printf ','
|
||||||
|
group_json 'fast-out' 'Selector' "$(selected_of fast-out 'fast-urltest-out')" \
|
||||||
|
'["fast-urltest-out","fast-1-out","fast-2-out"]' 0
|
||||||
|
printf ','
|
||||||
|
group_json 'fast-urltest-out' 'URLTest' '' \
|
||||||
|
'["fast-1-out","fast-2-out"]' "$(latency_of fast-urltest-out 96)"
|
||||||
|
printf ','
|
||||||
|
proxy_json 'fast-1-out' 'vless' "$(latency_of fast-1-out 96)"
|
||||||
|
printf ','
|
||||||
|
proxy_json 'fast-2-out' 'vless' "$(latency_of fast-2-out 0)"
|
||||||
|
printf ','
|
||||||
|
proxy_json 'home_vpn-out' 'wireguard' "$(latency_of home_vpn-out 18)"
|
||||||
|
printf '}}\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
members_of() {
|
||||||
|
case "$1" in
|
||||||
|
media-out) echo 'media-1-out media-2-out media-3-out' ;;
|
||||||
|
fast-out) echo 'fast-urltest-out fast-1-out fast-2-out' ;;
|
||||||
|
fast-urltest-out) echo 'fast-1-out fast-2-out' ;;
|
||||||
|
*) echo '' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
clash_api() {
|
||||||
|
[ "$SCENARIO" = "stopped" ] && exit 1
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
get_proxies)
|
||||||
|
emit_proxies
|
||||||
|
;;
|
||||||
|
get_proxy_latency)
|
||||||
|
delay=$(rand_latency)
|
||||||
|
echo "$delay" >"$LATENCY_DIR/$2"
|
||||||
|
echo "{\"delay\":$delay}"
|
||||||
|
;;
|
||||||
|
get_group_latency)
|
||||||
|
i=0
|
||||||
|
printf '{'
|
||||||
|
for member in $(members_of "$2"); do
|
||||||
|
i=$((i + 1))
|
||||||
|
delay=$(rand_latency)
|
||||||
|
echo "$delay" >"$LATENCY_DIR/$member"
|
||||||
|
[ "$i" -gt 1 ] && printf ','
|
||||||
|
printf '"%s":%s' "$member" "$delay"
|
||||||
|
done
|
||||||
|
printf '}\n'
|
||||||
|
;;
|
||||||
|
set_group_proxy)
|
||||||
|
echo "$3" >"$SELECTED_DIR/$2"
|
||||||
|
echo '{"ok":true}'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "{\"error\":\"unknown clash_api method: $1\"}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
die_if_failing
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
get_status)
|
||||||
|
# Reads the autostart flag so Enable/Disable have a visible effect
|
||||||
|
if [ "$SCENARIO" = "stopped" ] || [ ! -e /etc/rc.d/S99podkop ]; then
|
||||||
|
echo '{"enabled":0,"status":"disabled"}'
|
||||||
|
else
|
||||||
|
echo '{"enabled":1,"status":"enabled"}'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
get_sing_box_status)
|
||||||
|
# Same for Start/Stop/Restart
|
||||||
|
if [ "$SCENARIO" = "stopped" ] ||
|
||||||
|
[ "$(cat /tmp/podkop-service-state 2>/dev/null)" = "stopped" ]; then
|
||||||
|
echo '{"running":0,"enabled":0,"status":"stopped"}'
|
||||||
|
else
|
||||||
|
echo '{"running":1,"enabled":1,"status":"running"}'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
get_system_info)
|
||||||
|
echo '{"podkop_version":"0.8.1-dev","podkop_latest_version":"0.8.2","luci_app_version":"0.8.1-dev","sing_box_version":"1.11.4","openwrt_version":"OpenWrt 25.12.4","device_model":"Docker dev stand"}'
|
||||||
|
;;
|
||||||
|
|
||||||
|
check_dns_available)
|
||||||
|
echo '{"dns_type":"doh","dns_server":"dns.adguard-dns.com","dns_status":1,"dns_on_router":1,"bootstrap_dns_server":"1.1.1.1","bootstrap_dns_status":1,"dhcp_config_status":1}'
|
||||||
|
;;
|
||||||
|
|
||||||
|
check_nft_rules)
|
||||||
|
# No tproxy here: OpenWrt kernel modules cannot load in a container, so some
|
||||||
|
# rules are reported missing on purpose.
|
||||||
|
echo '{"table_exist":1,"rules_mangle_exist":1,"rules_mangle_counters":1,"rules_mangle_output_exist":1,"rules_mangle_output_counters":0,"rules_proxy_exist":1,"rules_proxy_counters":0,"rules_other_mark_exist":0}'
|
||||||
|
;;
|
||||||
|
|
||||||
|
check_sing_box)
|
||||||
|
echo '{"sing_box_installed":1,"sing_box_version_ok":1,"sing_box_service_exist":1,"sing_box_autostart_disabled":1,"sing_box_process_running":1,"sing_box_ports_listening":1}'
|
||||||
|
;;
|
||||||
|
|
||||||
|
check_fakeip)
|
||||||
|
echo '{"fakeip":true,"IP":"198.18.0.1"}'
|
||||||
|
;;
|
||||||
|
|
||||||
|
check_logs)
|
||||||
|
echo "$(date '+%a %b %e %H:%M:%S %Y') [info] podkop dev stand: backend stub"
|
||||||
|
echo "$(date '+%a %b %e %H:%M:%S %Y') [info] scenario: $SCENARIO"
|
||||||
|
;;
|
||||||
|
|
||||||
|
global_check)
|
||||||
|
echo '{"result":"ok","note":"dev stand"}'
|
||||||
|
;;
|
||||||
|
|
||||||
|
show_sing_box_config)
|
||||||
|
echo '{"log":{"level":"info"},"experimental":{"clash_api":{"external_controller":"0.0.0.0:9090"}},"note":"dev stand stub"}'
|
||||||
|
;;
|
||||||
|
|
||||||
|
clash_api)
|
||||||
|
clash_api "$2" "$3" "$4"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "{\"error\":\"unknown method: $1\"}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
58
src/frontend/sandbox/docker/files/usr/lib/podkop-dev/entrypoint.sh
Executable file
58
src/frontend/sandbox/docker/files/usr/lib/podkop-dev/entrypoint.sh
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Prepare the stand, then hand over to procd.
|
||||||
|
#
|
||||||
|
# Networking has to happen HERE, before `exec /sbin/init`: config_generate
|
||||||
|
# writes its own /etc/config/network from board.json (br-lan, hardcoded
|
||||||
|
# 192.168.1.1), netifd applies it and Docker's address is gone. By the time
|
||||||
|
# uci-defaults run, eth0 is already down and flushed.
|
||||||
|
#
|
||||||
|
# config_generate's guard is `[ -s /etc/config/network -a -s /etc/config/system ]`,
|
||||||
|
# so both files must exist — hence /etc/config/system in the image.
|
||||||
|
#
|
||||||
|
# The interface is named 'lan' because OpenWrt's default firewall allows input
|
||||||
|
# in that zone; that is what makes port 80 reachable from the host.
|
||||||
|
#
|
||||||
|
# Approach borrowed from luci-theme-footstrap.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
addr="$(ip -4 -o addr show dev eth0 2>/dev/null | awk '{print $4; exit}')"
|
||||||
|
gw="$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')"
|
||||||
|
dns="${PODKOP_DEV_DNS:-1.1.1.1 8.8.8.8}"
|
||||||
|
|
||||||
|
# Docker's resolv.conf points at 127.0.0.11, which works via NAT rules that
|
||||||
|
# OpenWrt's boot flushes.
|
||||||
|
for ns in $dns; do echo "nameserver $ns"; done >/etc/resolv.conf
|
||||||
|
|
||||||
|
if [ -n "$addr" ]; then
|
||||||
|
netmask="$(ipcalc.sh "$addr" | sed -n 's/^NETMASK=//p')"
|
||||||
|
cat >/etc/config/network <<-EOF
|
||||||
|
config interface 'loopback'
|
||||||
|
option device 'lo'
|
||||||
|
option proto 'static'
|
||||||
|
option ipaddr '127.0.0.1'
|
||||||
|
option netmask '255.0.0.0'
|
||||||
|
|
||||||
|
config globals 'globals'
|
||||||
|
|
||||||
|
config interface 'lan'
|
||||||
|
option device 'eth0'
|
||||||
|
option proto 'static'
|
||||||
|
option ipaddr '${addr%/*}'
|
||||||
|
option netmask '$netmask'
|
||||||
|
option gateway '$gw'
|
||||||
|
option dns '$dns'
|
||||||
|
option delegate '0'
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
echo "podkop-dev: eth0 has no IPv4 — /etc/config/network left alone" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Autostart on by default; get_status reads this. /etc survives boot, /tmp does
|
||||||
|
# not — runtime state is seeded from /etc/rc.local instead.
|
||||||
|
mkdir -p /etc/rc.d
|
||||||
|
touch /etc/rc.d/S99podkop
|
||||||
|
|
||||||
|
# Until the cache is dropped, the mounted menu.d/acl.d stay invisible.
|
||||||
|
rm -f /tmp/luci-indexcache* /var/luci-indexcache* 2>/dev/null || true
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
32
src/frontend/sandbox/docker/scenario.sh
Executable file
32
src/frontend/sandbox/docker/scenario.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Switch the backend stub's scenario on a running stand.
|
||||||
|
#
|
||||||
|
# ./scenario.sh show current
|
||||||
|
# ./scenario.sh ok everything works
|
||||||
|
# ./scenario.sh fail commands fail (empty stdout)
|
||||||
|
# ./scenario.sh stopped services down, Clash unreachable
|
||||||
|
#
|
||||||
|
# Reload the page afterwards.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONTAINER=podkop-dev-router
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
# A missing file is fine — the stub falls back to ok
|
||||||
|
current=$(docker exec "$CONTAINER" cat /tmp/podkop-scenario 2>/dev/null || true)
|
||||||
|
echo "scenario: ${current:-ok (default)}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
ok | fail | stopped) ;;
|
||||||
|
*)
|
||||||
|
echo "unknown scenario: $1 (ok | fail | stopped)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
docker exec "$CONTAINER" sh -c "echo '$1' > /tmp/podkop-scenario"
|
||||||
|
# Drop accumulated state so the scenario starts clean
|
||||||
|
docker exec "$CONTAINER" sh -c 'rm -rf /tmp/podkop-latency /tmp/podkop-selected'
|
||||||
|
echo "scenario: $1 — reload the page"
|
||||||
36
src/frontend/sandbox/docker/up.sh
Executable file
36
src/frontend/sandbox/docker/up.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Start the stand, picking the OpenWrt image for the host arch.
|
||||||
|
#
|
||||||
|
# openwrt/rootfs has no multiarch manifest, and the arm image declares
|
||||||
|
# aarch64_generic rather than arm64 — without both the tag and the platform,
|
||||||
|
# the build fails with "no match for platform in manifest".
|
||||||
|
#
|
||||||
|
# Override: OPENWRT_TAG=... ./up.sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
OPENWRT_RELEASE=25.12.4
|
||||||
|
|
||||||
|
if [ -z "$OPENWRT_TAG" ]; then
|
||||||
|
case "$(uname -m)" in
|
||||||
|
arm64 | aarch64)
|
||||||
|
OPENWRT_TAG="armsr-armv8-$OPENWRT_RELEASE"
|
||||||
|
OPENWRT_PLATFORM="linux/aarch64_generic"
|
||||||
|
;;
|
||||||
|
x86_64 | amd64)
|
||||||
|
OPENWRT_TAG="x86-64-$OPENWRT_RELEASE"
|
||||||
|
OPENWRT_PLATFORM="linux/amd64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unknown arch $(uname -m); set OPENWRT_TAG manually" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "image: openwrt/rootfs:$OPENWRT_TAG ($OPENWRT_PLATFORM)"
|
||||||
|
OPENWRT_TAG="$OPENWRT_TAG" OPENWRT_PLATFORM="$OPENWRT_PLATFORM" \
|
||||||
|
docker compose up -d --build "$@"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "ui: http://localhost:8080 -> Services -> Podkop (root, empty password)"
|
||||||
|
echo "scenarios: ./scenario.sh ok | fail | stopped"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use strict";
|
||||||
|
"require baseclass";
|
||||||
|
"require form";
|
||||||
|
"require ui";
|
||||||
|
"require uci";
|
||||||
|
"require fs";
|
||||||
|
"require view.podkop.main as main";
|
||||||
|
|
||||||
|
function createDashboardContent(section) {
|
||||||
|
const o = section.option(form.DummyValue, "_mount_node");
|
||||||
|
o.rawhtml = true;
|
||||||
|
o.cfgvalue = () => {
|
||||||
|
main.DashboardTab.initController();
|
||||||
|
return main.DashboardTab.render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EntryPoint = {
|
||||||
|
createDashboardContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
return baseclass.extend(EntryPoint);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use strict";
|
||||||
|
"require baseclass";
|
||||||
|
"require form";
|
||||||
|
"require ui";
|
||||||
|
"require uci";
|
||||||
|
"require fs";
|
||||||
|
"require view.podkop.main as main";
|
||||||
|
|
||||||
|
function createDiagnosticContent(section) {
|
||||||
|
const o = section.option(form.DummyValue, "_mount_node");
|
||||||
|
o.rawhtml = true;
|
||||||
|
o.cfgvalue = () => {
|
||||||
|
main.DiagnosticTab.initController();
|
||||||
|
return main.DiagnosticTab.render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EntryPoint = {
|
||||||
|
createDiagnosticContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
return baseclass.extend(EntryPoint);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
"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);
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
"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");
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Flag,
|
||||||
|
"resolve_real_ip_for_routing",
|
||||||
|
_("Resolve real IP for routing"),
|
||||||
|
_("Enable DNS resolve to get real IP when routing"),
|
||||||
|
);
|
||||||
|
o.default = "0";
|
||||||
|
o.rmempty = false;
|
||||||
|
o.depends("connection_type", "proxy");
|
||||||
|
o.depends("connection_type", "vpn");
|
||||||
|
}
|
||||||
|
|
||||||
|
const EntryPoint = {
|
||||||
|
createSectionContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
return baseclass.extend(EntryPoint);
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"use strict";
|
||||||
|
"require form";
|
||||||
|
"require uci";
|
||||||
|
"require baseclass";
|
||||||
|
"require tools.widgets as widgets";
|
||||||
|
"require view.podkop.main as main";
|
||||||
|
|
||||||
|
function createSettingsContent(section) {
|
||||||
|
let o = section.option(
|
||||||
|
form.ListValue,
|
||||||
|
"dns_type",
|
||||||
|
_("DNS Protocol Type"),
|
||||||
|
_("Select DNS protocol to use"),
|
||||||
|
);
|
||||||
|
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 = section.option(
|
||||||
|
form.Value,
|
||||||
|
"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.validate = function (section_id, value) {
|
||||||
|
const validation = main.validateDNS(value);
|
||||||
|
|
||||||
|
if (validation.valid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validation.message;
|
||||||
|
};
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Value,
|
||||||
|
"bootstrap_dns_server",
|
||||||
|
_("Bootstrap DNS server"),
|
||||||
|
_(
|
||||||
|
"The DNS server used to look up the IP address of an upstream DNS server",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Object.entries(main.BOOTSTRAP_DNS_SERVER_OPTIONS).forEach(([key, label]) => {
|
||||||
|
o.value(key, _(label));
|
||||||
|
});
|
||||||
|
o.default = "77.88.8.8";
|
||||||
|
o.rmempty = false;
|
||||||
|
o.validate = function (section_id, value) {
|
||||||
|
const validation = main.validateDNS(value);
|
||||||
|
|
||||||
|
if (validation.valid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validation.message;
|
||||||
|
};
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Value,
|
||||||
|
"dns_rewrite_ttl",
|
||||||
|
_("DNS Rewrite TTL"),
|
||||||
|
_("Time in seconds for DNS record caching (default: 60)"),
|
||||||
|
);
|
||||||
|
o.default = "60";
|
||||||
|
o.rmempty = false;
|
||||||
|
o.validate = function (section_id, value) {
|
||||||
|
if (!value) {
|
||||||
|
return _("TTL value cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = parseInt(value);
|
||||||
|
if (isNaN(ttl) || ttl < 0) {
|
||||||
|
return _("TTL must be a positive number");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
widgets.DeviceSelect,
|
||||||
|
"source_network_interfaces",
|
||||||
|
_("Source Network Interface"),
|
||||||
|
_("Select the network interface from which the traffic will originate"),
|
||||||
|
);
|
||||||
|
o.default = "br-lan";
|
||||||
|
o.noaliases = true;
|
||||||
|
o.nobridges = false;
|
||||||
|
o.noinactive = false;
|
||||||
|
o.multiple = true;
|
||||||
|
o.filter = function (section_id, value) {
|
||||||
|
// Block specific interface names from being selectable
|
||||||
|
const blocked = ["wan", "phy0-ap0", "phy1-ap0", "pppoe-wan"];
|
||||||
|
if (blocked.includes(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find the device object by its name
|
||||||
|
const device = this.devices.find((dev) => dev.getName() === value);
|
||||||
|
|
||||||
|
// If no device is found, allow the value
|
||||||
|
if (!device) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the type of the device
|
||||||
|
const type = device.getType();
|
||||||
|
|
||||||
|
// Consider any Wi-Fi / wireless / wlan device as invalid
|
||||||
|
const isWireless =
|
||||||
|
type === "wifi" || type === "wireless" || type.includes("wlan");
|
||||||
|
|
||||||
|
// Allow only non-wireless devices
|
||||||
|
return !isWireless;
|
||||||
|
};
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Flag,
|
||||||
|
"enable_output_network_interface",
|
||||||
|
_("Enable Output Network Interface"),
|
||||||
|
_("You can select Output Network Interface, by default autodetect"),
|
||||||
|
);
|
||||||
|
o.default = "0";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
widgets.DeviceSelect,
|
||||||
|
"output_network_interface",
|
||||||
|
_("Output Network Interface"),
|
||||||
|
_("Select the network interface to which the traffic will originate"),
|
||||||
|
);
|
||||||
|
o.noaliases = true;
|
||||||
|
o.multiple = false;
|
||||||
|
o.depends("enable_output_network_interface", "1");
|
||||||
|
o.filter = function (section_id, value) {
|
||||||
|
// Blocked interface names that should never be selectable
|
||||||
|
const blockedInterfaces = ["br-lan"];
|
||||||
|
|
||||||
|
// Reject immediately if the value matches any blocked interface
|
||||||
|
if (blockedInterfaces.includes(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject lan*
|
||||||
|
if (
|
||||||
|
value.startsWith("lan")
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject tun*, wg*, vpn*, awg*, oc*
|
||||||
|
if (
|
||||||
|
value.startsWith("tun") ||
|
||||||
|
value.startsWith("wg") ||
|
||||||
|
value.startsWith("vpn") ||
|
||||||
|
value.startsWith("awg") ||
|
||||||
|
value.startsWith("oc")
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
"enable_badwan_interface_monitoring",
|
||||||
|
_("Interface Monitoring"),
|
||||||
|
_("Interface monitoring for Bad WAN"),
|
||||||
|
);
|
||||||
|
o.default = "0";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
widgets.NetworkSelect,
|
||||||
|
"badwan_monitored_interfaces",
|
||||||
|
_("Monitored Interfaces"),
|
||||||
|
_("Select the WAN interfaces to be monitored"),
|
||||||
|
);
|
||||||
|
o.depends("enable_badwan_interface_monitoring", "1");
|
||||||
|
o.multiple = true;
|
||||||
|
o.filter = function (section_id, value) {
|
||||||
|
// Reject if the value is in the blocked list ['lan', 'loopback']
|
||||||
|
if (["lan", "loopback"].includes(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if the value starts with '@' (means it's an alias/reference)
|
||||||
|
if (value.startsWith("@")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise allow it
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Value,
|
||||||
|
"badwan_reload_delay",
|
||||||
|
_("Interface Monitoring Delay"),
|
||||||
|
_("Delay in milliseconds before reloading podkop after interface UP"),
|
||||||
|
);
|
||||||
|
o.depends("enable_badwan_interface_monitoring", "1");
|
||||||
|
o.default = "2000";
|
||||||
|
o.rmempty = false;
|
||||||
|
o.validate = function (section_id, value) {
|
||||||
|
if (!value) {
|
||||||
|
return _("Delay value cannot be empty");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Flag,
|
||||||
|
"enable_yacd",
|
||||||
|
_("Enable YACD"),
|
||||||
|
`<a href="${main.getClashUIUrl()}" target="_blank">${main.getClashUIUrl()}</a>`,
|
||||||
|
);
|
||||||
|
o.default = "0";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Flag,
|
||||||
|
"enable_yacd_wan_access",
|
||||||
|
_("Enable YACD WAN Access"),
|
||||||
|
_("Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall."),
|
||||||
|
);
|
||||||
|
o.depends("enable_yacd", "1");
|
||||||
|
o.default = "0";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Value,
|
||||||
|
"yacd_secret_key",
|
||||||
|
_("YACD Secret Key"),
|
||||||
|
_("Secret key for authenticating remote access to YACD when WAN access is enabled."),
|
||||||
|
);
|
||||||
|
o.depends("enable_yacd_wan_access", "1");
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Flag,
|
||||||
|
"disable_quic",
|
||||||
|
_("Disable QUIC"),
|
||||||
|
_(
|
||||||
|
"Disable the QUIC protocol to improve compatibility or fix issues with video streaming",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
o.default = "0";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.ListValue,
|
||||||
|
"update_interval",
|
||||||
|
_("List Update Frequency"),
|
||||||
|
_("Select how often the domain or subnet lists are updated automatically"),
|
||||||
|
);
|
||||||
|
Object.entries(main.UPDATE_INTERVAL_OPTIONS).forEach(([key, label]) => {
|
||||||
|
o.value(key, _(label));
|
||||||
|
});
|
||||||
|
o.default = "1d";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Flag,
|
||||||
|
"download_lists_via_proxy",
|
||||||
|
_("Download Lists via Proxy/VPN"),
|
||||||
|
_("Downloading all lists via specific Proxy/VPN"),
|
||||||
|
);
|
||||||
|
o.default = "0";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.ListValue,
|
||||||
|
"download_lists_via_proxy_section",
|
||||||
|
_("Download Lists via specific proxy section"),
|
||||||
|
_("Downloading all lists via specific Proxy/VPN"),
|
||||||
|
);
|
||||||
|
|
||||||
|
o.rmempty = false;
|
||||||
|
o.depends("download_lists_via_proxy", "1");
|
||||||
|
o.cfgvalue = function (section_id) {
|
||||||
|
return uci.get("podkop", section_id, "download_lists_via_proxy_section");
|
||||||
|
};
|
||||||
|
o.load = function () {
|
||||||
|
const sections = this.map?.data?.state?.values?.podkop ?? {};
|
||||||
|
|
||||||
|
this.keylist = [];
|
||||||
|
this.vallist = [];
|
||||||
|
|
||||||
|
for (const secName in sections) {
|
||||||
|
const sec = sections[secName];
|
||||||
|
if (sec[".type"] === "section" && sec['connection_type'] !== 'block' && sec['connection_type'] !== 'exclusion') {
|
||||||
|
this.keylist.push(secName);
|
||||||
|
this.vallist.push(secName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Flag,
|
||||||
|
"dont_touch_dhcp",
|
||||||
|
_("Dont Touch My DHCP!"),
|
||||||
|
_("Podkop will not modify your DHCP configuration"),
|
||||||
|
);
|
||||||
|
o.default = "0";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.ListValue,
|
||||||
|
"config_path",
|
||||||
|
_("Config File Path"),
|
||||||
|
_(
|
||||||
|
"Select path for sing-box config file. Change this ONLY if you know what you are doing",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
o.value("/etc/sing-box/config.json", "Flash (/etc/sing-box/config.json)");
|
||||||
|
o.value("/tmp/sing-box/config.json", "RAM (/tmp/sing-box/config.json)");
|
||||||
|
o.default = "/etc/sing-box/config.json";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Value,
|
||||||
|
"cache_path",
|
||||||
|
_("Cache File Path"),
|
||||||
|
_(
|
||||||
|
"Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
o.value("/tmp/sing-box/cache.db", "RAM (/tmp/sing-box/cache.db)");
|
||||||
|
o.value(
|
||||||
|
"/usr/share/sing-box/cache.db",
|
||||||
|
"Flash (/usr/share/sing-box/cache.db)",
|
||||||
|
);
|
||||||
|
o.default = "/tmp/sing-box/cache.db";
|
||||||
|
o.rmempty = false;
|
||||||
|
o.validate = function (section_id, value) {
|
||||||
|
if (!value) {
|
||||||
|
return _("Cache file path cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.startsWith("/")) {
|
||||||
|
return _("Path must be absolute (start with /)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.endsWith("cache.db")) {
|
||||||
|
return _("Path must end with cache.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = value.split("/").filter(Boolean);
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return _("Path must contain at least one directory (like /tmp/cache.db)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.ListValue,
|
||||||
|
"log_level",
|
||||||
|
_("Log Level"),
|
||||||
|
_(
|
||||||
|
"Select the log level for sing-box",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
o.value("trace", "Trace");
|
||||||
|
o.value("debug", "Debug");
|
||||||
|
o.value("info", "Info");
|
||||||
|
o.value("warn", "Warn");
|
||||||
|
o.value("error", "Error");
|
||||||
|
o.value("fatal", "Fatal");
|
||||||
|
o.value("panic", "Panic");
|
||||||
|
o.default = "warn";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.Flag,
|
||||||
|
"exclude_ntp",
|
||||||
|
_("Exclude NTP"),
|
||||||
|
_(
|
||||||
|
"Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
o.default = "0";
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = section.option(
|
||||||
|
form.DynamicList,
|
||||||
|
"routing_excluded_ips",
|
||||||
|
_("Routing Excluded IPs"),
|
||||||
|
_("Specify a local IP address to be excluded from routing"),
|
||||||
|
);
|
||||||
|
o.placeholder = "IP";
|
||||||
|
o.rmempty = true;
|
||||||
|
o.validate = function (section_id, value) {
|
||||||
|
// Optional
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = main.validateIPV4(value);
|
||||||
|
|
||||||
|
if (validation.valid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validation.message;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EntryPoint = {
|
||||||
|
createSettingsContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
return baseclass.extend(EntryPoint);
|
||||||
30
src/frontend/sandbox/msgmerge.sh
Normal file
30
src/frontend/sandbox/msgmerge.sh
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PODIR="po"
|
||||||
|
POTFILE="$PODIR/templates/podkop.pot"
|
||||||
|
WIDTH=120
|
||||||
|
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <language_code> (e.g., ru, de, fr)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LANG="$1"
|
||||||
|
POFILE="$PODIR/$LANG/podkop.po"
|
||||||
|
|
||||||
|
if [ ! -f "$POTFILE" ]; then
|
||||||
|
echo "Template $POTFILE not found. Run xgettext first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$POFILE" ]; then
|
||||||
|
echo "Updating $POFILE"
|
||||||
|
msgmerge --update --width="$WIDTH" --no-location "$POFILE" "$POTFILE"
|
||||||
|
else
|
||||||
|
echo "Creating new $POFILE using msginit"
|
||||||
|
mkdir -p "$PODIR/$LANG"
|
||||||
|
msginit --no-translator --no-location --locale="$LANG" --width="$WIDTH" --input="$POTFILE" --output-file="$POFILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Translation file for $LANG updated."
|
||||||
795
src/frontend/sandbox/po/ru/podkop.po
Normal file
795
src/frontend/sandbox/po/ru/podkop.po
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
# 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.
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: PODKOP\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2026-05-29 16:40+0300\n"
|
||||||
|
"PO-Revision-Date: 2026-05-29 16:40+0300\n"
|
||||||
|
"Last-Translator: divocatt\n"
|
||||||
|
"Language-Team: none\n"
|
||||||
|
"Language: ru\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"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 "Active Connections"
|
||||||
|
msgstr "Активные соединения"
|
||||||
|
|
||||||
|
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 "At least one valid domain must be specified. Comments-only content is not allowed."
|
||||||
|
msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
|
||||||
|
|
||||||
|
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
|
||||||
|
msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
|
||||||
|
|
||||||
|
msgid "Available actions"
|
||||||
|
msgstr "Доступные действия"
|
||||||
|
|
||||||
|
msgid "Bootsrap DNS"
|
||||||
|
msgstr "Bootstrap DNS"
|
||||||
|
|
||||||
|
msgid "Bootstrap DNS server"
|
||||||
|
msgstr "Bootstrap DNS-сервер"
|
||||||
|
|
||||||
|
msgid "Browser is not using FakeIP"
|
||||||
|
msgstr "Браузер не использует FakeIP"
|
||||||
|
|
||||||
|
msgid "Browser is using FakeIP correctly"
|
||||||
|
msgstr "Браузер использует FakeIP"
|
||||||
|
|
||||||
|
msgid "Cache File Path"
|
||||||
|
msgstr "Путь к файлу кэша"
|
||||||
|
|
||||||
|
msgid "Cache file path cannot be empty"
|
||||||
|
msgstr "Путь к файлу кэша не может быть пустым"
|
||||||
|
|
||||||
|
msgid "Cannot receive checks result"
|
||||||
|
msgstr "Не удалось получить результаты проверки"
|
||||||
|
|
||||||
|
msgid "Checking, please wait"
|
||||||
|
msgstr "Проверяем, пожалуйста подождите"
|
||||||
|
|
||||||
|
msgid "checks"
|
||||||
|
msgstr "проверки"
|
||||||
|
|
||||||
|
msgid "Checks failed"
|
||||||
|
msgstr "Проверки не выполнены"
|
||||||
|
|
||||||
|
msgid "Checks passed"
|
||||||
|
msgstr "Проверки пройдены"
|
||||||
|
|
||||||
|
msgid "CIDR must be between 0 and 32"
|
||||||
|
msgstr "CIDR должен быть между 0 и 32"
|
||||||
|
|
||||||
|
msgid "Close"
|
||||||
|
msgstr "Закрыть"
|
||||||
|
|
||||||
|
msgid "Community Lists"
|
||||||
|
msgstr "Списки сообщества"
|
||||||
|
|
||||||
|
msgid "Config File Path"
|
||||||
|
msgstr "Путь к файлу конфигурации"
|
||||||
|
|
||||||
|
msgid "Configuration for Podkop service"
|
||||||
|
msgstr "Настройки сервиса Podkop"
|
||||||
|
|
||||||
|
msgid "Configuration Type"
|
||||||
|
msgstr "Тип конфигурации"
|
||||||
|
|
||||||
|
msgid "Connection Type"
|
||||||
|
msgstr "Тип подключения"
|
||||||
|
|
||||||
|
msgid "Connection URL"
|
||||||
|
msgstr "URL подключения"
|
||||||
|
|
||||||
|
msgid "Copy"
|
||||||
|
msgstr "Копировать"
|
||||||
|
|
||||||
|
msgid "Currently unavailable"
|
||||||
|
msgstr "Временно недоступно"
|
||||||
|
|
||||||
|
msgid "Dashboard"
|
||||||
|
msgstr "Дашборд"
|
||||||
|
|
||||||
|
msgid "Dashboard currently unavailable"
|
||||||
|
msgstr "Дашборд сейчас недоступен"
|
||||||
|
|
||||||
|
msgid "Delay in milliseconds before reloading podkop after interface UP"
|
||||||
|
msgstr "Задержка в миллисекундах перед перезагрузкой podkop после поднятия интерфейса"
|
||||||
|
|
||||||
|
msgid "Delay value cannot be empty"
|
||||||
|
msgstr "Значение задержки не может быть пустым"
|
||||||
|
|
||||||
|
msgid "DHCP has DNS server"
|
||||||
|
msgstr "DHCP содержит DNS сервер"
|
||||||
|
|
||||||
|
msgid "Diagnostics"
|
||||||
|
msgstr "Диагностика"
|
||||||
|
|
||||||
|
msgid "Disable autostart"
|
||||||
|
msgstr "Отключить автостарт"
|
||||||
|
|
||||||
|
msgid "Disable QUIC"
|
||||||
|
msgstr "Отключить QUIC"
|
||||||
|
|
||||||
|
msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming"
|
||||||
|
msgstr "Отключить QUIC протокол для улучшения совместимости или исправления видео стриминга"
|
||||||
|
|
||||||
|
msgid "Disabled"
|
||||||
|
msgstr "Отключено"
|
||||||
|
|
||||||
|
msgid "DNS on router"
|
||||||
|
msgstr "DNS на роутере"
|
||||||
|
|
||||||
|
msgid "DNS over HTTPS (DoH)"
|
||||||
|
msgstr "DNS через HTTPS (DoH)"
|
||||||
|
|
||||||
|
msgid "DNS over TLS (DoT)"
|
||||||
|
msgstr "DNS через TLS (DoT)"
|
||||||
|
|
||||||
|
msgid "DNS Protocol Type"
|
||||||
|
msgstr "Тип протокола DNS"
|
||||||
|
|
||||||
|
msgid "DNS Rewrite TTL"
|
||||||
|
msgstr "Перезапись TTL для DNS"
|
||||||
|
|
||||||
|
msgid "DNS Server"
|
||||||
|
msgstr "DNS-сервер"
|
||||||
|
|
||||||
|
msgid "DNS server address cannot be empty"
|
||||||
|
msgstr "Адрес DNS-сервера не может быть пустым"
|
||||||
|
|
||||||
|
msgid "Do not panic, everything can be fixed, just..."
|
||||||
|
msgstr "Не паникуйте, всё можно исправить, просто..."
|
||||||
|
|
||||||
|
msgid "Domain Resolver"
|
||||||
|
msgstr "Резолвер доменов"
|
||||||
|
|
||||||
|
msgid "Dont Touch My DHCP!"
|
||||||
|
msgstr "Dont Touch My DHCP!"
|
||||||
|
|
||||||
|
msgid "Downlink"
|
||||||
|
msgstr "Входящий"
|
||||||
|
|
||||||
|
msgid "Download"
|
||||||
|
msgstr "Скачать"
|
||||||
|
|
||||||
|
msgid "Download Lists via Proxy/VPN"
|
||||||
|
msgstr "Скачивать списки через Proxy/VPN"
|
||||||
|
|
||||||
|
msgid "Download Lists via specific proxy section"
|
||||||
|
msgstr "Скачивать списки через выбранную секцию"
|
||||||
|
|
||||||
|
msgid "Downloading all lists via specific Proxy/VPN"
|
||||||
|
msgstr "Загрузка всех списков через указанный прокси/VPN"
|
||||||
|
|
||||||
|
msgid "Dynamic List"
|
||||||
|
msgstr "Динамический список"
|
||||||
|
|
||||||
|
msgid "Enable autostart"
|
||||||
|
msgstr "Включить автостарт"
|
||||||
|
|
||||||
|
msgid "Enable built-in DNS resolver for domains handled by this section"
|
||||||
|
msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых в этом разделе"
|
||||||
|
|
||||||
|
msgid "Enable DNS resolve to get real IP when routing"
|
||||||
|
msgstr "Разрешать домены в реальные IP-адреса перед маршрутизацией в outbound"
|
||||||
|
|
||||||
|
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 "Every 3 minutes"
|
||||||
|
msgstr "Каждые 3 минуты"
|
||||||
|
|
||||||
|
msgid "Every 30 seconds"
|
||||||
|
msgstr "Каждые 30 секунд"
|
||||||
|
|
||||||
|
msgid "Every 5 minutes"
|
||||||
|
msgstr "Каждые 5 минут"
|
||||||
|
|
||||||
|
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 "Failed to copy!"
|
||||||
|
msgstr "Не удалось скопировать!"
|
||||||
|
|
||||||
|
msgid "Failed to execute!"
|
||||||
|
msgstr "Не удалось выполнить!"
|
||||||
|
|
||||||
|
msgid "Fastest"
|
||||||
|
msgstr "Самый быстрый"
|
||||||
|
|
||||||
|
msgid "Fully Routed IPs"
|
||||||
|
msgstr "Полностью маршрутизированные IP-адреса"
|
||||||
|
|
||||||
|
msgid "Get global check"
|
||||||
|
msgstr "Получить глобальную проверку"
|
||||||
|
|
||||||
|
msgid "Global check"
|
||||||
|
msgstr "Глобальная проверка"
|
||||||
|
|
||||||
|
msgid "HTTP error"
|
||||||
|
msgstr "Ошибка HTTP"
|
||||||
|
|
||||||
|
msgid "Interface Monitoring"
|
||||||
|
msgstr "Мониторинг интерфейса"
|
||||||
|
|
||||||
|
msgid "Interface Monitoring Delay"
|
||||||
|
msgstr "Задержка при мониторинге интерфейсов"
|
||||||
|
|
||||||
|
msgid "Interface monitoring for Bad WAN"
|
||||||
|
msgstr "Мониторинг интерфейса для Bad WAN"
|
||||||
|
|
||||||
|
msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH"
|
||||||
|
msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, dns.example.com или dns.example.com/nicedns для DoH"
|
||||||
|
|
||||||
|
msgid "Invalid domain address"
|
||||||
|
msgstr "Неверный домен"
|
||||||
|
|
||||||
|
msgid "Invalid format. Use X.X.X.X or X.X.X.X/Y"
|
||||||
|
msgstr "Неверный формат. Используйте X.X.X.X или X.X.X.X/Y"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: insecure must be 0 or 1"
|
||||||
|
msgstr "Неверный URL Hysteria2: параметр insecure должен быть 0 или 1"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: invalid port number"
|
||||||
|
msgstr "Неверный URL Hysteria2: неверный номер порта"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: missing credentials/server"
|
||||||
|
msgstr "Неверный URL Hysteria2: отсутствуют учетные данные/сервер"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: missing host"
|
||||||
|
msgstr "Неверный URL Hysteria2: отсутствует хост"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: missing host & port"
|
||||||
|
msgstr "Неверный URL Hysteria2: отсутствуют хост и порт"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: missing password"
|
||||||
|
msgstr "Неверный URL Hysteria2: отсутствует пароль"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: missing port"
|
||||||
|
msgstr "Неверный URL Hysteria2: отсутствует порт"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: must not contain spaces"
|
||||||
|
msgstr "Неверный URL Hysteria2: не должен содержать пробелов"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: must start with hysteria2:// or hy2://"
|
||||||
|
msgstr "Неверный URL Hysteria2: должен начинаться с hysteria2:// или hy2://"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: obfs-password required when obfs is set"
|
||||||
|
msgstr "Неверный URL Hysteria2: требуется obfs-password, когда установлен obfs"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: parsing failed"
|
||||||
|
msgstr "Неверный URL Hysteria2: ошибка разбора"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: sni cannot be empty"
|
||||||
|
msgstr "Неверный URL Hysteria2: sni не может быть пустым"
|
||||||
|
|
||||||
|
msgid "Invalid HY2 URL: unsupported obfs type"
|
||||||
|
msgstr "Неверный URL Hysteria2: неподдерживаемый тип obfs"
|
||||||
|
|
||||||
|
msgid "Invalid IP address"
|
||||||
|
msgstr "Неверный IP-адрес"
|
||||||
|
|
||||||
|
msgid "Invalid JSON format"
|
||||||
|
msgstr "Неверный формат JSON"
|
||||||
|
|
||||||
|
msgid "Invalid path format. Path must start with \"/\" and contain valid characters"
|
||||||
|
msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы"
|
||||||
|
|
||||||
|
msgid "Invalid port number. Must be between 1 and 65535"
|
||||||
|
msgstr "Неверный номер порта. Допустимо от 1 до 65535"
|
||||||
|
|
||||||
|
msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password"
|
||||||
|
msgstr "Неверный URL Shadowsocks: декодированные данные должны содержать method:password"
|
||||||
|
|
||||||
|
msgid "Invalid Shadowsocks URL: missing credentials"
|
||||||
|
msgstr "Неверный URL Shadowsocks: отсутствуют учетные данные"
|
||||||
|
|
||||||
|
msgid "Invalid Shadowsocks URL: missing method and password separator \":\""
|
||||||
|
msgstr "Неверный URL Shadowsocks: отсутствует разделитель метода и пароля \":\""
|
||||||
|
|
||||||
|
msgid "Invalid Shadowsocks URL: missing port"
|
||||||
|
msgstr "Неверный URL Shadowsocks: отсутствует порт"
|
||||||
|
|
||||||
|
msgid "Invalid Shadowsocks URL: missing server"
|
||||||
|
msgstr "Неверный URL Shadowsocks: отсутствует сервер"
|
||||||
|
|
||||||
|
msgid "Invalid Shadowsocks URL: missing server address"
|
||||||
|
msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера"
|
||||||
|
|
||||||
|
msgid "Invalid Shadowsocks URL: must not contain spaces"
|
||||||
|
msgstr "Неверный URL Shadowsocks: не должен содержать пробелов"
|
||||||
|
|
||||||
|
msgid "Invalid Shadowsocks URL: must start with ss://"
|
||||||
|
msgstr "Неверный URL Shadowsocks: должен начинаться с ss://"
|
||||||
|
|
||||||
|
msgid "Invalid Shadowsocks URL: parsing failed"
|
||||||
|
msgstr "Неверный URL Shadowsocks: ошибка разбора"
|
||||||
|
|
||||||
|
msgid "Invalid SOCKS URL: invalid host format"
|
||||||
|
msgstr "Неверный URL SOCKS: неверный формат хоста"
|
||||||
|
|
||||||
|
msgid "Invalid SOCKS URL: invalid port number"
|
||||||
|
msgstr "Неверный URL SOCKS: неверный номер порта"
|
||||||
|
|
||||||
|
msgid "Invalid SOCKS URL: missing host and port"
|
||||||
|
msgstr "Неверный URL SOCKS: отсутствует хост и порт"
|
||||||
|
|
||||||
|
msgid "Invalid SOCKS URL: missing hostname or IP"
|
||||||
|
msgstr "Неверный URL SOCKS: отсутствует имя хоста или IP-адрес"
|
||||||
|
|
||||||
|
msgid "Invalid SOCKS URL: missing port"
|
||||||
|
msgstr "Неверный URL SOCKS: отсутствует порт"
|
||||||
|
|
||||||
|
msgid "Invalid SOCKS URL: missing username"
|
||||||
|
msgstr "Неверный URL SOCKS: отсутствует имя пользователя"
|
||||||
|
|
||||||
|
msgid "Invalid SOCKS URL: must not contain spaces"
|
||||||
|
msgstr "Неверный URL SOCKS: не должен содержать пробелов"
|
||||||
|
|
||||||
|
msgid "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://"
|
||||||
|
msgstr "Неверный URL-адрес SOCKS: должен начинаться с socks4://, socks4a:// или socks5://"
|
||||||
|
|
||||||
|
msgid "Invalid SOCKS URL: parsing failed"
|
||||||
|
msgstr "Неверный URL SOCKS: парсинг не удался"
|
||||||
|
|
||||||
|
msgid "Invalid Trojan URL: must not contain spaces"
|
||||||
|
msgstr "Неверный URL Trojan: не должен содержать пробелов"
|
||||||
|
|
||||||
|
msgid "Invalid Trojan URL: must start with trojan://"
|
||||||
|
msgstr "Неверный URL Trojan: должен начинаться с trojan://"
|
||||||
|
|
||||||
|
msgid "Invalid Trojan URL: parsing failed"
|
||||||
|
msgstr "Неверный URL Trojan: ошибка разбора"
|
||||||
|
|
||||||
|
msgid "Invalid URL format"
|
||||||
|
msgstr "Неверный формат URL"
|
||||||
|
|
||||||
|
msgid "Invalid VLESS URL: parsing failed"
|
||||||
|
msgstr "Неверный URL VLESS: ошибка разбора"
|
||||||
|
|
||||||
|
msgid "IP address 0.0.0.0 is not allowed"
|
||||||
|
msgstr "IP-адрес 0.0.0.0 не допускается"
|
||||||
|
|
||||||
|
msgid "Issues detected"
|
||||||
|
msgstr "Обнаружены проблемы"
|
||||||
|
|
||||||
|
msgid "Latest"
|
||||||
|
msgstr "Последняя"
|
||||||
|
|
||||||
|
msgid "List Update Frequency"
|
||||||
|
msgstr "Частота обновления списков"
|
||||||
|
|
||||||
|
msgid "Local Domain Lists"
|
||||||
|
msgstr "Локальные списки доменов"
|
||||||
|
|
||||||
|
msgid "Local Subnet Lists"
|
||||||
|
msgstr "Локальные списки подсетей"
|
||||||
|
|
||||||
|
msgid "Log Level"
|
||||||
|
msgstr "Уровень логов"
|
||||||
|
|
||||||
|
msgid "Main DNS"
|
||||||
|
msgstr "Основной DNS"
|
||||||
|
|
||||||
|
msgid "Memory Usage"
|
||||||
|
msgstr "Использование памяти"
|
||||||
|
|
||||||
|
msgid "Mixed Proxy Port"
|
||||||
|
msgstr "Порт смешанного прокси"
|
||||||
|
|
||||||
|
msgid "Monitored Interfaces"
|
||||||
|
msgstr "Наблюдаемые интерфейсы"
|
||||||
|
|
||||||
|
msgid "Must be a number in the range of 50 - 1000"
|
||||||
|
msgstr "Должно быть числом от 50 до 1000"
|
||||||
|
|
||||||
|
msgid "Network Interface"
|
||||||
|
msgstr "Сетевой интерфейс"
|
||||||
|
|
||||||
|
msgid "No other marking rules found"
|
||||||
|
msgstr "Другие правила маркировки не найдены"
|
||||||
|
|
||||||
|
msgid "Not implement yet"
|
||||||
|
msgstr "Ещё не реализовано"
|
||||||
|
|
||||||
|
msgid "Not responding"
|
||||||
|
msgstr "Не отвечает"
|
||||||
|
|
||||||
|
msgid "Not running"
|
||||||
|
msgstr "Не запущено"
|
||||||
|
|
||||||
|
msgid "Operation timed out"
|
||||||
|
msgstr "Время ожидания истекло"
|
||||||
|
|
||||||
|
msgid "Outbound Config"
|
||||||
|
msgstr "Конфигурация Outbound"
|
||||||
|
|
||||||
|
msgid "Outbound Configuration"
|
||||||
|
msgstr "Конфигурация исходящего соединения"
|
||||||
|
|
||||||
|
msgid "Outdated"
|
||||||
|
msgstr "Устаревшая"
|
||||||
|
|
||||||
|
msgid "Output Network Interface"
|
||||||
|
msgstr "Выходной сетевой интерфейс"
|
||||||
|
|
||||||
|
msgid "Path cannot be empty"
|
||||||
|
msgstr "Путь не может быть пустым"
|
||||||
|
|
||||||
|
msgid "Path must be absolute (start with /)"
|
||||||
|
msgstr "Путь должен быть абсолютным (начинаться с /)"
|
||||||
|
|
||||||
|
msgid "Path must contain at least one directory (like /tmp/cache.db)"
|
||||||
|
msgstr "Путь должен содержать хотя бы одну директорию (например /tmp/cache.db)"
|
||||||
|
|
||||||
|
msgid "Path must end with cache.db"
|
||||||
|
msgstr "Путь должен заканчиваться на cache.db"
|
||||||
|
|
||||||
|
msgid "Pending"
|
||||||
|
msgstr "Ожидает запуска"
|
||||||
|
|
||||||
|
msgid "Podkop"
|
||||||
|
msgstr "Podkop"
|
||||||
|
|
||||||
|
msgid "Podkop Settings"
|
||||||
|
msgstr "Настройки podkop"
|
||||||
|
|
||||||
|
msgid "Podkop will not modify your DHCP configuration"
|
||||||
|
msgstr "Podkop не будет изменять вашу конфигурацию DHCP."
|
||||||
|
|
||||||
|
msgid "Proxy Configuration URL"
|
||||||
|
msgstr "URL конфигурации прокси"
|
||||||
|
|
||||||
|
msgid "Proxy traffic is not routed via FakeIP"
|
||||||
|
msgstr "Прокси-трафик не маршрутизируется через FakeIP"
|
||||||
|
|
||||||
|
msgid "Proxy traffic is routed via FakeIP"
|
||||||
|
msgstr "Прокси-трафик направляется через FakeIP"
|
||||||
|
|
||||||
|
msgid "Regional options cannot be used together"
|
||||||
|
msgstr "Нельзя использовать несколько региональных опций одновременно"
|
||||||
|
|
||||||
|
msgid "Remote Domain Lists"
|
||||||
|
msgstr "Внешние списки доменов"
|
||||||
|
|
||||||
|
msgid "Remote Subnet Lists"
|
||||||
|
msgstr "Внешние списки подсетей"
|
||||||
|
|
||||||
|
msgid "Resolve real IP for routing"
|
||||||
|
msgstr "Разрешение реальных IP-адресов"
|
||||||
|
|
||||||
|
msgid "Restart podkop"
|
||||||
|
msgstr "Перезапустить Podkop"
|
||||||
|
|
||||||
|
msgid "Router DNS is not routed through sing-box"
|
||||||
|
msgstr "DNS роутера не проходит через sing-box"
|
||||||
|
|
||||||
|
msgid "Router DNS is routed through sing-box"
|
||||||
|
msgstr "DNS роутера проходит через sing-box"
|
||||||
|
|
||||||
|
msgid "Routing Excluded IPs"
|
||||||
|
msgstr "Исключённые из маршрутизации IP-адреса"
|
||||||
|
|
||||||
|
msgid "Rules mangle counters"
|
||||||
|
msgstr "Счётчики правил mangle"
|
||||||
|
|
||||||
|
msgid "Rules mangle exist"
|
||||||
|
msgstr "Правила mangle существуют"
|
||||||
|
|
||||||
|
msgid "Rules mangle output counters"
|
||||||
|
msgstr "Счётчики правил mangle output"
|
||||||
|
|
||||||
|
msgid "Rules mangle output exist"
|
||||||
|
msgstr "Правила mangle output существуют"
|
||||||
|
|
||||||
|
msgid "Rules proxy counters"
|
||||||
|
msgstr "Счётчики правил proxy"
|
||||||
|
|
||||||
|
msgid "Rules proxy exist"
|
||||||
|
msgstr "Правила прокси существуют"
|
||||||
|
|
||||||
|
msgid "Run Diagnostic"
|
||||||
|
msgstr "Запустить диагностику"
|
||||||
|
|
||||||
|
msgid "Russia inside restrictions"
|
||||||
|
msgstr "Ограничения Russia inside"
|
||||||
|
|
||||||
|
msgid "Secret key for authenticating remote access to YACD when WAN access is enabled."
|
||||||
|
msgstr "Секретный ключ для аутентификации удаленного доступа к YACD при включенном доступе через WAN."
|
||||||
|
|
||||||
|
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 методами для маршрутизации трафика"
|
||||||
|
|
||||||
|
msgid "Select DNS protocol to use"
|
||||||
|
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-сервера"
|
||||||
|
|
||||||
|
msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing"
|
||||||
|
msgstr "Выберите или введите путь к файлу кеша sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
msgid "Select the network interface from which the traffic will originate"
|
||||||
|
msgstr "Выберите сетевой интерфейс, с которого будет исходить трафик"
|
||||||
|
|
||||||
|
msgid "Select the network interface to which the traffic will originate"
|
||||||
|
msgstr "Выберите сетевой интерфейс, на который будет поступать трафик."
|
||||||
|
|
||||||
|
msgid "Select the WAN interfaces to be monitored"
|
||||||
|
msgstr "Выберите WAN интерфейсы для мониторинга"
|
||||||
|
|
||||||
|
msgid "Selector"
|
||||||
|
msgstr "Selector"
|
||||||
|
|
||||||
|
msgid "Selector Proxy Links"
|
||||||
|
msgstr "Ссылки прокси для Selector"
|
||||||
|
|
||||||
|
msgid "Services info"
|
||||||
|
msgstr "Информация о сервисах"
|
||||||
|
|
||||||
|
msgid "Settings"
|
||||||
|
msgstr "Настройки"
|
||||||
|
|
||||||
|
msgid "Show sing-box config"
|
||||||
|
msgstr "Показать sing-box конфигурацию"
|
||||||
|
|
||||||
|
msgid "Sing-box"
|
||||||
|
msgstr "Sing-box"
|
||||||
|
|
||||||
|
msgid "Sing-box autostart disabled"
|
||||||
|
msgstr "Автостарт sing-box отключен"
|
||||||
|
|
||||||
|
msgid "Sing-box installed"
|
||||||
|
msgstr "Sing-box установлен"
|
||||||
|
|
||||||
|
msgid "Sing-box listening ports"
|
||||||
|
msgstr "Sing-box слушает порты"
|
||||||
|
|
||||||
|
msgid "Sing-box process running"
|
||||||
|
msgstr "Процесс sing-box запущен"
|
||||||
|
|
||||||
|
msgid "Sing-box service exist"
|
||||||
|
msgstr "Сервис sing-box существует"
|
||||||
|
|
||||||
|
msgid "Sing-box version is compatible (newer than 1.12.4)"
|
||||||
|
msgstr "Версия Sing-box совместима (новее 1.12.4)"
|
||||||
|
|
||||||
|
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 "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 "Successfully copied!"
|
||||||
|
msgstr "Успешно скопировано!"
|
||||||
|
|
||||||
|
msgid "System info"
|
||||||
|
msgstr "Системная информация"
|
||||||
|
|
||||||
|
msgid "System information"
|
||||||
|
msgstr "Системная информация"
|
||||||
|
|
||||||
|
msgid "Table exist"
|
||||||
|
msgstr "Таблица существует"
|
||||||
|
|
||||||
|
msgid "Test latency"
|
||||||
|
msgstr "Тестирование задержки"
|
||||||
|
|
||||||
|
msgid "Text List"
|
||||||
|
msgstr "Текстовый список"
|
||||||
|
|
||||||
|
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 maximum difference in response times (ms) allowed when comparing servers"
|
||||||
|
msgstr "Максимально допустимая разница во времени отклика (мс) при сравнении серверов"
|
||||||
|
|
||||||
|
msgid "The URL used to test server connectivity"
|
||||||
|
msgstr "URL-адрес, используемый для проверки подключения к серверу"
|
||||||
|
|
||||||
|
msgid "Time in seconds for DNS record caching (default: 60)"
|
||||||
|
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
|
||||||
|
|
||||||
|
msgid "Traffic"
|
||||||
|
msgstr "Трафик"
|
||||||
|
|
||||||
|
msgid "Traffic Total"
|
||||||
|
msgstr "Всего трафика"
|
||||||
|
|
||||||
|
msgid "Troubleshooting"
|
||||||
|
msgstr "Устранение неполадок"
|
||||||
|
|
||||||
|
msgid "TTL must be a positive number"
|
||||||
|
msgstr "TTL должно быть положительным числом"
|
||||||
|
|
||||||
|
msgid "TTL value cannot be empty"
|
||||||
|
msgstr "Значение TTL не может быть пустым"
|
||||||
|
|
||||||
|
msgid "UDP (Unprotected DNS)"
|
||||||
|
msgstr "UDP (Незащищённый DNS)"
|
||||||
|
|
||||||
|
msgid "UDP over TCP"
|
||||||
|
msgstr "UDP через TCP"
|
||||||
|
|
||||||
|
msgid "unknown"
|
||||||
|
msgstr "неизвестно"
|
||||||
|
|
||||||
|
msgid "Unknown error"
|
||||||
|
msgstr "Неизвестная ошибка"
|
||||||
|
|
||||||
|
msgid "Uplink"
|
||||||
|
msgstr "Исходящий"
|
||||||
|
|
||||||
|
msgid "URL must start with vless://, ss://, trojan://, socks4/5://, or hysteria2://hy2://"
|
||||||
|
msgstr "URL должен начинаться с vless://, ss://, trojan://, socks4/5:// или hysteria2:// hy2://"
|
||||||
|
|
||||||
|
msgid "URL must use one of the following protocols:"
|
||||||
|
msgstr "URL должен использовать один из следующих протоколов:"
|
||||||
|
|
||||||
|
msgid "URLTest"
|
||||||
|
msgstr "URLTest"
|
||||||
|
|
||||||
|
msgid "URLTest Check Interval"
|
||||||
|
msgstr "Интервал проверки URLTest"
|
||||||
|
|
||||||
|
msgid "URLTest Proxy Links"
|
||||||
|
msgstr "Ссылки прокси для URLTest"
|
||||||
|
|
||||||
|
msgid "URLTest Testing URL"
|
||||||
|
msgstr "URLTest ссылка для проверки"
|
||||||
|
|
||||||
|
msgid "URLTest Tolerance"
|
||||||
|
msgstr "URLTest допустимое отклонение"
|
||||||
|
|
||||||
|
msgid "User Domain List Type"
|
||||||
|
msgstr "Тип пользовательского списка доменов"
|
||||||
|
|
||||||
|
msgid "User Domains"
|
||||||
|
msgstr "Пользовательские домены"
|
||||||
|
|
||||||
|
msgid "User Domains List"
|
||||||
|
msgstr "Список пользовательских доменов"
|
||||||
|
|
||||||
|
msgid "User Subnet List Type"
|
||||||
|
msgstr "Тип пользовательского списка подсетей"
|
||||||
|
|
||||||
|
msgid "User Subnets"
|
||||||
|
msgstr "Пользовательские подсети"
|
||||||
|
|
||||||
|
msgid "User Subnets List"
|
||||||
|
msgstr "Список пользовательских подсетей"
|
||||||
|
|
||||||
|
msgid "Valid"
|
||||||
|
msgstr "Валидно"
|
||||||
|
|
||||||
|
msgid "Validation errors:"
|
||||||
|
msgstr "Ошибки валидации:"
|
||||||
|
|
||||||
|
msgid "View logs"
|
||||||
|
msgstr "Посмотреть логи"
|
||||||
|
|
||||||
|
msgid "Visit Wiki"
|
||||||
|
msgstr "Перейти в wiki"
|
||||||
|
|
||||||
|
msgid "vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
|
||||||
|
msgstr "Предупреждение: %s нельзя использовать вместе с %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 "YACD Secret Key"
|
||||||
|
msgstr "Секретный ключ YACD"
|
||||||
|
|
||||||
|
msgid "You can select Output Network Interface, by default autodetect"
|
||||||
|
msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически."
|
||||||
1115
src/frontend/sandbox/po/templates/podkop.pot
Normal file
1115
src/frontend/sandbox/po/templates/podkop.pot
Normal file
File diff suppressed because it is too large
Load Diff
10
src/frontend/sandbox/root/etc/uci-defaults/50_luci-podkop
Normal file
10
src/frontend/sandbox/root/etc/uci-defaults/50_luci-podkop
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
rm -f /var/luci-indexcache*
|
||||||
|
rm -f /tmp/luci-indexcache*
|
||||||
|
|
||||||
|
[ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload
|
||||||
|
|
||||||
|
logger -t "podkop" "$timestamp uci-defaults script executed"
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"admin/services/podkop": {
|
||||||
|
"title": "Podkop",
|
||||||
|
"order": 42,
|
||||||
|
"action": {
|
||||||
|
"type": "view",
|
||||||
|
"path": "podkop/podkop"
|
||||||
|
},
|
||||||
|
"depends": {
|
||||||
|
"acl": [ "luci-app-podkop" ],
|
||||||
|
"uci": { "podkop": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"luci-app-podkop": {
|
||||||
|
"description": "Grant UCI and RPC access to LuCI app podkop",
|
||||||
|
"read": {
|
||||||
|
"file": {
|
||||||
|
"/etc/init.d/podkop": [
|
||||||
|
"exec"
|
||||||
|
],
|
||||||
|
"/usr/bin/podkop": [
|
||||||
|
"exec"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ubus": {
|
||||||
|
"service": [
|
||||||
|
"list"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"uci": [
|
||||||
|
"podkop"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"write": {
|
||||||
|
"uci": [
|
||||||
|
"podkop"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/frontend/sandbox/xgettext.sh
Normal file
27
src/frontend/sandbox/xgettext.sh
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC_DIR="htdocs/luci-static/resources/view/podkop"
|
||||||
|
OUT_POT="po/templates/podkop.pot"
|
||||||
|
ENCODING="UTF-8"
|
||||||
|
WIDTH=120
|
||||||
|
|
||||||
|
mapfile -t FILES < <(find "$SRC_DIR" -type f -name "*.js")
|
||||||
|
if [ ${#FILES[@]} -eq 0 ]; then
|
||||||
|
echo "No JS files found in $SRC_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t FILES < <(printf '%s\n' "${FILES[@]}" | sort)
|
||||||
|
mkdir -p "$(dirname "$OUT_POT")"
|
||||||
|
|
||||||
|
echo "Generating POT template from JS files in $SRC_DIR"
|
||||||
|
xgettext --language=JavaScript \
|
||||||
|
--keyword=_ \
|
||||||
|
--from-code="$ENCODING" \
|
||||||
|
--output="$OUT_POT" \
|
||||||
|
--width="$WIDTH" \
|
||||||
|
--package-name="PODKOP" \
|
||||||
|
"${FILES[@]}"
|
||||||
|
|
||||||
|
echo "POT template generated: $OUT_POT"
|
||||||
Reference in New Issue
Block a user