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
|
||||
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
|
||||
echolog "✅ GitHub connection check passed (via proxy)"
|
||||
break
|
||||
|
||||
@@ -60,7 +60,7 @@ patch_source_ruleset_rules() {
|
||||
import_plain_domain_list_to_local_source_ruleset_chunked() {
|
||||
local plain_list_filepath="$1"
|
||||
local ruleset_filepath="$2"
|
||||
local chunk_size="${3:-5000}"
|
||||
local chunk_size="${3:-1000}"
|
||||
|
||||
local array count json_array
|
||||
count=0
|
||||
@@ -102,7 +102,7 @@ import_plain_domain_list_to_local_source_ruleset_chunked() {
|
||||
import_plain_subnet_list_to_local_source_ruleset_chunked() {
|
||||
local plain_list_filepath="$1"
|
||||
local ruleset_filepath="$2"
|
||||
local chunk_size="${3:-5000}"
|
||||
local chunk_size="${3:-1000}"
|
||||
|
||||
local array count json_array
|
||||
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