Compare commits

...

43 Commits

Author SHA1 Message Date
Kirill Sobakin
984ae5f2a9 Merge pull request #213 from itdoginfo/fix/diagnostic
fix: correct luci-version displaying
2025-10-10 14:46:59 +03:00
divocat
7a62898541 fix: correct luci-version displaying 2025-10-10 14:41:35 +03:00
itdoginfo
7911d1d29f Draft false 2025-10-10 14:23:37 +03:00
Kirill Sobakin
bc673b7881 Merge pull request #212 from itdoginfo/fix/dashboard
fix: correct vless/trojan validation on some browsers
2025-10-10 14:21:25 +03:00
divocat
0493565c5f fix: implement query params parsing func 2025-10-10 14:06:19 +03:00
itdoginfo
4cd1094395 Check ipk without v 2025-10-09 19:53:37 +03:00
itdoginfo
e87b431d86 Check v* 2025-10-09 19:33:27 +03:00
itdoginfo
b9ee917abf Fix PODKOP_VERSION for ipk 2025-10-09 19:20:35 +03:00
divocat
715a278af8 fix: force http for yacd enable link 2025-10-09 18:23:35 +03:00
divocat
9bc2b5ffef fix: correct link validation & some points on dash 2025-10-09 18:23:35 +03:00
divocat
9d89258c0c fix: correct vless/trojan validation on some browsers 2025-10-09 18:23:35 +03:00
itdoginfo
52d1c5d95f Fix PKG_VERSION -> PODKOP_VERSION 2025-10-09 18:15:54 +03:00
itdoginfo
587e5245d3 Test without v* 2025-10-09 16:01:31 +03:00
itdoginfo
e7578d61bc Rm v* 2025-10-09 15:34:23 +03:00
itdoginfo
9918b71a82 Fix version tag 3 2025-10-09 15:32:01 +03:00
itdoginfo
f48c4ff2bb Fix version tag 2 2025-10-09 15:08:37 +03:00
itdoginfo
e77bcc386a Fix version tag 2025-10-09 14:59:22 +03:00
itdoginfo
455c19ab2e Fix #211 2025-10-09 14:40:45 +03:00
Kirill Sobakin
914e1792f3 Merge pull request #211 from SaltyMonkey/build-process-improvements
chore: Build process improvements
2025-10-09 11:13:59 +03:00
SaltyMonkey
826245a89a fix: Minor changes and bugfixes, ci fix 2025-10-09 00:56:15 +03:00
SaltyMonkey
b5cfc017fe chore: Automatic build process rewrite
* Added apk packages support
* Move to matrix builds
* Minor versions update for some actions just in case
* Automatic release with ipk/apk packages
2025-10-08 23:15:52 +03:00
SaltyMonkey
267fd2b793 refactor: Added .gitattributes for better dev life at win and linux 2025-10-08 22:26:16 +03:00
SaltyMonkey
c0b400dfb0 refactor: New docker files for build process 2025-10-08 22:25:06 +03:00
SaltyMonkey
752636347e refactor: Remove old docker files 2025-10-08 22:20:33 +03:00
SaltyMonkey
28aeb29c51 refactor: Update luci-app-podkop package
* Removed direct package manager calls
* Removed commands related to optional luci package
* Update external global_check call with version pass
* Removed useless external calls in version check cases
* Improved build process support: version will be automatically set at installation time from package metadata and will be readable from JS as constant
2025-10-08 22:09:48 +03:00
SaltyMonkey
6ff543d7fb refactor: Update podkop package
* Removed direct package manager calls
* Removed commands related to optional luci package
* Added optional parameter for global_check for cases when function called by LuCI package
* Removed useless external calls in version check cases
* Improved build process support: version will be automatically set at installation time from package metadata and will be readable from script itself
2025-10-08 21:57:46 +03:00
SaltyMonkey
b89fe33296 chore: Added apk package manager support for install script 2025-10-08 21:42:19 +03:00
Kirill Sobakin
3d63a82815 Merge pull request #209 from itdoginfo/fix/dashboard
fix: force http for clash api
2025-10-08 00:05:04 +03:00
divocat
934f802879 fix: force http for clash api 2025-10-08 00:02:41 +03:00
Kirill Sobakin
4d0755e4c0 Merge pull request #208 from itdoginfo/fix/dashboard
feat: change get latency class coloring
2025-10-07 23:48:29 +03:00
divocat
88ee7b4a54 feat: change get latency class coloring 2025-10-07 23:45:07 +03:00
Kirill Sobakin
0eb575d171 Merge pull request #207 from itdoginfo/fix/dashboard
fix: dashboard behavior on corner cases
2025-10-07 23:41:06 +03:00
divocat
9a46d731c9 fix: correct dashboard displaying 2025-10-07 23:33:57 +03:00
divocat
a45ab62885 fix: correct section display name for json outbound 2025-10-07 22:56:40 +03:00
Kirill Sobakin
b7bad57299 Merge pull request #206 from itdoginfo/feat/all_traffic_ip_cidr
feat: add support IP/CIDR format in LuCI for all_traffic_ip
2025-10-07 22:33:43 +03:00
divocat
4ac755bd36 feat: add support IP/CIDR format in LuCI for all_traffic_ip 2025-10-07 22:26:29 +03:00
Kirill Sobakin
e9a0c96882 Merge pull request #205 from itdoginfo/hotfix
Hotfix
2025-10-07 21:34:26 +03:00
divocat
48c8f01d2f fix: correct proxy string label displaying on dashboard 2025-10-07 20:34:38 +03:00
divocat
72b2a34af9 fix: allow .tld for user_domains_text & user_domains 2025-10-07 19:19:10 +03:00
Andrey Petelin
ae4a3781e6 i18n: update Russian translations for additional settings and related messages 2025-10-07 20:42:34 +05:00
divocat
1bce7c0c98 fix: migrate test latency to locales 2025-10-07 18:26:59 +03:00
Andrey Petelin
a8b2001cc1 fix: sort input files before processing in xgettext.sh to ensure consistent POT generation 2025-10-07 20:12:46 +05:00
Andrey Petelin
d6481675e0 fix: update shebang to env bash and add strict mode for safer script execution in xgettext.sh 2025-10-07 20:12:14 +05:00
38 changed files with 1148 additions and 442 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

@@ -2,53 +2,118 @@ name: Build packages
on:
push:
tags:
- v*
- '*'
permissions:
contents: write
jobs:
build:
name: Build podkop and luci-app-podkop
preparation:
name: Setup build version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4.2.1
- uses: actions/checkout@v5.0.0
with:
fetch-depth: 0
- id: version
run: |
VERSION=$(git describe --tags --exact-match 2>/dev/null || echo "0.$(date +%d%m%Y)")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
build:
name: Builder for ${{ matrix.package_type }} podkop and luci-app-podkop
runs-on: ubuntu-latest
needs: preparation
strategy:
matrix:
include:
- { package_type: ipk }
- { package_type: apk }
steps:
- uses: actions/checkout@v5.0.0
with:
fetch-depth: 0
- name: Extract version
id: version
run: |
VERSION=$(git describe --tags --exact-match 2>/dev/null || echo "dev_$(date +%d%m%Y)")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6.9.0
- name: Build ${{ matrix.package_type }}
uses: docker/build-push-action@v6.18.0
with:
file: ./Dockerfile-${{ matrix.package_type }}
context: .
tags: podkop:ci
tags: podkop:ci-${{ matrix.package_type }}
build-args: |
PKG_VERSION=${{ steps.version.outputs.version }}
PODKOP_VERSION=${{ needs.preparation.outputs.version }}
- name: Create Docker container
run: docker create --name podkop podkop:ci
- name: Create ${{ matrix.package_type }} Docker container
run: docker create --name ${{ matrix.package_type }} podkop:ci-${{ matrix.package_type }}
- name: Copy file from Docker container
- name: Copy files from ${{ matrix.package_type }} Docker container
run: |
docker cp podkop:/builder/bin/packages/x86_64/utilites/. ./bin/
docker cp podkop:/builder/bin/packages/x86_64/luci/. ./bin/
mkdir -p ./bin/${{ matrix.package_type }}
docker cp ${{ matrix.package_type }}:/builder/bin/packages/x86_64/utilities/. ./bin/${{ matrix.package_type }}/
docker cp ${{ matrix.package_type }}:/builder/bin/packages/x86_64/luci/. ./bin/${{ matrix.package_type }}/
- name: Filter IPK files
# IPK uses underscore `_` in filenames, while APK uses only dash `-`
- name: Fix naming difference between build for packages (replace _ with -)
if: matrix.package_type == 'ipk'
shell: bash
run: |
# Извлекаем версию из тега, убирая префикс 'v'
VERSION=${GITHUB_REF#refs/tags/v}
for f in ./bin/${{ matrix.package_type }}/*.${{ matrix.package_type }}; do
[ -e "$f" ] || continue
base=$(basename "$f")
newname=$(echo "$base" | sed 's/_/-/g')
mv "$f" "./bin/${{ matrix.package_type }}/$newname"
done
mkdir -p ./filtered-bin
cp ./bin/luci-i18n-podkop-ru_*.ipk "./filtered-bin/luci-i18n-podkop-ru_${VERSION}.ipk"
cp ./bin/podkop_*.ipk ./filtered-bin/
cp ./bin/luci-app-podkop_*.ipk ./filtered-bin/
- name: Filter files
shell: bash
run: |
# Use version from preparation job (already without 'v' prefix)
VERSION="${{ needs.preparation.outputs.version }}"
mkdir -p ./filtered-bin/${{ matrix.package_type }}
cp ./bin/${{ matrix.package_type }}/luci-i18n-podkop-ru-*.${{ matrix.package_type }} "./filtered-bin/${{ matrix.package_type }}/luci-i18n-podkop-ru-${VERSION}.${{ matrix.package_type }}"
cp ./bin/${{ matrix.package_type }}/podkop-*.${{ matrix.package_type }} ./filtered-bin/${{ matrix.package_type }}/
cp ./bin/${{ matrix.package_type }}/luci-app-podkop-*.${{ matrix.package_type }} ./filtered-bin/${{ matrix.package_type }}/
- name: Remove Docker container
run: docker rm podkop
run: docker rm ${{ matrix.package_type }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4.6.2
with:
name: release-files-${{ github.ref_name }}-${{ matrix.package_type }}
path: ./filtered-bin/${{ matrix.package_type }}/*.${{ matrix.package_type }}
retention-days: 1
if-no-files-found: error
release:
name: Create Release
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v5.0.0
- name: Create release dir
run: mkdir -p ./filtered-bin/release
- name: Download ipk artifacts
uses: actions/download-artifact@v4
with:
name: release-files-${{ github.ref_name }}-ipk
path: ./filtered-bin/release
- name: Download apk artifacts
uses: actions/download-artifact@v4
with:
name: release-files-${{ github.ref_name }}-apk
path: ./filtered-bin/release
- name: Release
uses: softprops/action-gh-release@v2.0.8
uses: softprops/action-gh-release@v2.4.0
with:
files: ./filtered-bin/*.ipk
files: ./filtered-bin/release/*.*
draft: false
prerelease: false
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}

View File

@@ -1,9 +0,0 @@
FROM itdoginfo/openwrt-sdk:24.10.1
ARG PKG_VERSION
ENV PKG_VERSION=${PKG_VERSION}
COPY ./podkop /builder/package/feeds/utilites/podkop
COPY ./luci-app-podkop /builder/package/feeds/luci/luci-app-podkop
RUN make defconfig && make package/podkop/compile && make package/luci-app-podkop/compile V=s -j4

View File

@@ -1,3 +0,0 @@
FROM openwrt/sdk:x86_64-v24.10.1
RUN ./scripts/feeds update -a && ./scripts/feeds install luci-base && mkdir -p /builder/package/feeds/utilites/ && mkdir -p /builder/package/feeds/luci/

11
Dockerfile-apk Normal file
View File

@@ -0,0 +1,11 @@
FROM itdoginfo/openwrt-sdk-apk:09102025
ARG PODKOP_VERSION
ENV PODKOP_VERSION=${PODKOP_VERSION}
COPY ./podkop /builder/package/feeds/utilities/podkop
COPY ./luci-app-podkop /builder/package/feeds/luci/luci-app-podkop
RUN make defconfig && \
make package/podkop/compile -j1 V=s && \
make package/luci-app-podkop/compile -j1 V=s

11
Dockerfile-ipk Normal file
View File

@@ -0,0 +1,11 @@
FROM itdoginfo/openwrt-sdk-ipk:24.10.3
ARG PODKOP_VERSION
COPY ./podkop /builder/package/feeds/utilities/podkop
COPY ./luci-app-podkop /builder/package/feeds/luci/luci-app-podkop
RUN export PODKOP_VERSION="v${PODKOP_VERSION}" && \
make defconfig && \
make package/podkop/compile V=s -j4 && \
make package/luci-app-podkop/compile V=s -j4

View File

@@ -4,6 +4,7 @@ export const STATUS_COLORS = {
WARNING: '#ff9800',
};
export const PODKOP_LUCI_APP_VERSION = '__COMPILED_VERSION_VARIABLE__';
export const FAKEIP_CHECK_DOMAIN = 'fakeip.podkop.fyi';
export const IP_CHECK_DOMAIN = 'ip.podkop.fyi';

View File

@@ -1,7 +1,7 @@
export function getClashApiUrl(): string {
const { protocol, hostname } = window.location;
const { hostname } = window.location;
return `${protocol}//${hostname}:9090`;
return `http://${hostname}:9090`;
}
export function getClashWsUrl(): string {
@@ -9,3 +9,9 @@ export function getClashWsUrl(): string {
return `ws://${hostname}:9090`;
}
export function getClashUIUrl(): string {
const { hostname } = window.location;
return `http://${hostname}:9090/ui`;
}

View File

@@ -7,3 +7,6 @@ export * from './maskIP';
export * from './getProxyUrlName';
export * from './onMount';
export * from './getClashApiUrl';
export * from './splitProxyString';
export * from './preserveScrollForPage';
export * from './parseQueryString';

View File

@@ -0,0 +1,22 @@
export function parseQueryString(query: string): Record<string, string> {
const clean = query.startsWith('?') ? query.slice(1) : query;
return clean
.split('&')
.filter(Boolean)
.reduce(
(acc, pair) => {
const [rawKey, rawValue = ''] = pair.split('=');
if (!rawKey) {
return acc;
}
const key = decodeURIComponent(rawKey);
const value = decodeURIComponent(rawValue);
return { ...acc, [key]: value };
},
{} as Record<string, string>,
);
}

View File

@@ -0,0 +1,9 @@
export function preserveScrollForPage(renderFn: () => void) {
const scrollY = window.scrollY;
renderFn();
requestAnimationFrame(() => {
window.scrollTo({ top: scrollY });
});
}

View File

@@ -0,0 +1,7 @@
export function splitProxyString(str: string) {
return str
.split('\n')
.map((line) => line.trim())
.filter((line) => !line.startsWith('//'))
.filter(Boolean);
}

View File

@@ -1,7 +1,7 @@
import { Podkop } from '../types';
import { getConfigSections } from './getConfigSections';
import { getClashProxies } from '../../clash';
import { getProxyUrlName } from '../../helpers';
import { getProxyUrlName, splitProxyString } from '../../helpers';
interface IGetDashboardSectionsResponse {
success: boolean;
@@ -35,6 +35,11 @@ export async function getDashboardSections(): Promise<IGetDashboardSectionsRespo
(proxy) => proxy.code === `${section['.name']}-out`,
);
const activeConfigs = splitProxyString(section.proxy_string);
const proxyDisplayName =
getProxyUrlName(activeConfigs?.[0]) || outbound?.value?.name || '';
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
@@ -42,10 +47,7 @@ export async function getDashboardSections(): Promise<IGetDashboardSectionsRespo
outbounds: [
{
code: outbound?.code || section['.name'],
displayName:
getProxyUrlName(section.proxy_string) ||
outbound?.value?.name ||
'',
displayName: proxyDisplayName,
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
@@ -59,6 +61,12 @@ export async function getDashboardSections(): Promise<IGetDashboardSectionsRespo
(proxy) => proxy.code === `${section['.name']}-out`,
);
const parsedOutbound = JSON.parse(section.outbound_json);
const parsedTag = parsedOutbound?.tag
? decodeURIComponent(parsedOutbound?.tag)
: undefined;
const proxyDisplayName = parsedTag || outbound?.value?.name || '';
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
@@ -66,10 +74,7 @@ export async function getDashboardSections(): Promise<IGetDashboardSectionsRespo
outbounds: [
{
code: outbound?.code || section['.name'],
displayName:
decodeURIComponent(JSON.parse(section.outbound_json)?.tag) ||
outbound?.value?.name ||
'',
displayName: proxyDisplayName,
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,

View File

@@ -7,7 +7,7 @@ export async function getPodkopStatus(): Promise<{
const response = await executeShellCommand({
command: '/usr/bin/podkop',
args: ['get_status'],
timeout: 1000,
timeout: 10000,
});
if (response.stdout) {

View File

@@ -8,7 +8,7 @@ export async function getSingboxStatus(): Promise<{
const response = await executeShellCommand({
command: '/usr/bin/podkop',
args: ['get_sing_box_status'],
timeout: 1000,
timeout: 10000,
});
if (response.stdout) {

View File

@@ -3,7 +3,12 @@ import {
getPodkopStatus,
getSingboxStatus,
} from '../../methods';
import { getClashWsUrl, onMount } from '../../../helpers';
import {
getClashApiUrl,
getClashWsUrl,
onMount,
preserveScrollForPage,
} from '../../../helpers';
import {
triggerLatencyGroupTest,
triggerLatencyProxyTest,
@@ -29,8 +34,13 @@ async function fetchDashboardSections() {
const { data, success } = await getDashboardSections();
if (!success) {
console.log('[fetchDashboardSections]: failed to fetch', getClashApiUrl());
}
store.set({
sectionsWidget: {
latencyFetching: false,
loading: false,
failed: !success,
data,
@@ -39,18 +49,30 @@ async function fetchDashboardSections() {
}
async function fetchServicesInfo() {
const [podkop, singbox] = await Promise.all([
getPodkopStatus(),
getSingboxStatus(),
]);
try {
const [podkop, singbox] = await Promise.all([
getPodkopStatus(),
getSingboxStatus(),
]);
store.set({
servicesInfoWidget: {
loading: false,
failed: false,
data: { singbox: singbox.running, podkop: podkop.enabled },
},
});
store.set({
servicesInfoWidget: {
loading: false,
failed: false,
data: { singbox: singbox.running, podkop: podkop.enabled },
},
});
} catch (err) {
console.log('[fetchServicesInfo]: failed to fetchServices', err);
store.set({
servicesInfoWidget: {
loading: false,
failed: true,
data: { singbox: 0, podkop: 0 },
},
});
}
}
async function connectToClashSockets() {
@@ -68,6 +90,10 @@ async function connectToClashSockets() {
});
},
(_err) => {
console.log(
'[fetchDashboardSections]: failed to connect',
getClashWsUrl(),
);
store.set({
bandwidthWidget: {
loading: false,
@@ -103,6 +129,10 @@ async function connectToClashSockets() {
});
},
(_err) => {
console.log(
'[fetchDashboardSections]: failed to connect',
getClashWsUrl(),
);
store.set({
trafficTotalWidget: {
loading: false,
@@ -130,25 +160,41 @@ async function handleChooseOutbound(selector: string, tag: string) {
}
async function handleTestGroupLatency(tag: string) {
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: true,
},
});
await triggerLatencyGroupTest(tag);
await fetchDashboardSections();
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: false,
},
});
}
async function handleTestProxyLatency(tag: string) {
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: true,
},
});
await triggerLatencyProxyTest(tag);
await fetchDashboardSections();
}
function replaceTestLatencyButtonsWithSkeleton() {
document
.querySelectorAll('.dashboard-sections-grid-item-test-latency')
.forEach((el) => {
const newDiv = document.createElement('div');
newDiv.className = 'skeleton';
newDiv.style.width = '99px';
newDiv.style.height = '28px';
el.replaceWith(newDiv);
});
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: false,
},
});
}
// Renderer
@@ -170,8 +216,12 @@ async function renderSectionsWidget() {
},
onTestLatency: () => {},
onChooseOutbound: () => {},
latencyFetching: sectionsWidget.latencyFetching,
});
return preserveScrollForPage(() => {
container!.replaceChildren(renderedWidget);
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidgets = sectionsWidget.data.map((section) =>
@@ -179,9 +229,8 @@ async function renderSectionsWidget() {
loading: sectionsWidget.loading,
failed: sectionsWidget.failed,
section,
latencyFetching: sectionsWidget.latencyFetching,
onTestLatency: (tag) => {
replaceTestLatencyButtonsWithSkeleton();
if (section.withTagSelect) {
return handleTestGroupLatency(tag);
}
@@ -194,7 +243,9 @@ async function renderSectionsWidget() {
}),
);
return container!.replaceChildren(...renderedWidgets);
return preserveScrollForPage(() => {
container!.replaceChildren(...renderedWidgets);
});
}
async function renderBandwidthWidget() {

View File

@@ -1,4 +1,5 @@
import { Podkop } from '../../types';
import { getClashApiUrl } from '../../../helpers';
interface IRenderSectionsProps {
loading: boolean;
@@ -6,6 +7,7 @@ interface IRenderSectionsProps {
section: Podkop.OutboundGroup;
onTestLatency: (tag: string) => void;
onChooseOutbound: (selector: string, tag: string) => void;
latencyFetching: boolean;
}
function renderFailedState() {
@@ -15,7 +17,10 @@ function renderFailedState() {
class: 'pdk_dashboard-page__outbound-section centered',
style: 'height: 127px',
},
E('span', {}, _('Dashboard currently unavailable')),
E('span', {}, [
E('span', {}, _('Dashboard currently unavailable')),
E('div', { style: 'text-align: center;' }, `API: ${getClashApiUrl()}`),
]),
);
}
@@ -31,6 +36,7 @@ export function renderDefaultState({
section,
onChooseOutbound,
onTestLatency,
latencyFetching,
}: IRenderSectionsProps) {
function testLatency() {
if (section.withTagSelect) {
@@ -48,11 +54,11 @@ export function renderDefaultState({
return 'pdk_dashboard-page__outbound-grid__item__latency--empty';
}
if (outbound.latency < 200) {
if (outbound.latency < 800) {
return 'pdk_dashboard-page__outbound-grid__item__latency--green';
}
if (outbound.latency < 400) {
if (outbound.latency < 1500) {
return 'pdk_dashboard-page__outbound-grid__item__latency--yellow';
}
@@ -95,14 +101,16 @@ export function renderDefaultState({
},
section.displayName,
),
E(
'button',
{
class: 'btn dashboard-sections-grid-item-test-latency',
click: () => testLatency(),
},
'Test latency',
),
latencyFetching
? E('div', { class: 'skeleton', style: 'width: 99px; height: 28px' })
: E(
'button',
{
class: 'btn dashboard-sections-grid-item-test-latency',
click: () => testLatency(),
},
_('Test latency'),
),
]),
E(
'div',

View File

@@ -141,6 +141,7 @@ export interface StoreType {
loading: boolean;
failed: boolean;
data: Podkop.OutboundGroup[];
latencyFetching: boolean;
};
}
@@ -172,6 +173,7 @@ const initialStore: StoreType = {
sectionsWidget: {
loading: true,
failed: false,
latencyFetching: false,
data: [],
},
};

View File

@@ -29,6 +29,13 @@ export const invalidDomains = [
['Too long domain (>253 chars)', Array(40).fill('abcdef').join('.') + '.com'],
];
export const dotTLDTests = [
['Dot TLD allowed (.net)', '.net', true, true],
['Dot TLD not allowed (.net)', '.net', false, false],
['Invalid with double dot', '..net', true, false],
['Invalid single word TLD (net)', 'net', true, false],
];
describe('validateDomain', () => {
describe.each(validDomains)('Valid domain: %s', (_desc, domain) => {
it(`returns valid=true for "${domain}"`, () => {
@@ -43,4 +50,14 @@ describe('validateDomain', () => {
expect(res.valid).toBe(false);
});
});
describe.each(dotTLDTests)(
'Dot TLD toggle: %s',
(_desc, domain, allowDotTLD, expected) => {
it(`"${domain}" with allowDotTLD=${allowDotTLD} → valid=${expected}`, () => {
const res = validateDomain(domain, allowDotTLD);
expect(res.valid).toBe(expected);
});
},
);
});

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { validateShadowsocksUrl } from '../validateShadowsocksUrl';
const validUrls = [
[
'no-client',
'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206ZG1DbHkvWmgxNVd3OStzK0dGWGlGVElrcHc3Yy9xQ0lTYUJyYWk3V2hoWT0@127.0.0.1:25144?type=tcp#shadowsocks-no-client',
],
[
'client',
'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206S3FiWXZiNkhwb1RmTUt0N2VGcUZQSmJNNXBXaHlFU0ZKTXY2dEp1Ym1Fdz06dzRNMEx5RU9OTGQ5SWlkSGc0endTbzN2R3h4NS9aQ3hId0FpaWlxck5hcz0@127.0.0.1:26627?type=tcp#shadowsocks-client',
],
[
'plain-user',
'ss://2022-blake3-aes-256-gcm:dmCly/Zh15Ww9+s+GFXiFTIkpw7c/qCISaBrai7WhhY=@127.0.0.1:27214?type=tcp#shadowsocks-plain-user',
],
];
const invalidUrls = [
['No prefix', 'uuid@127.0.0.1:443?type=tcp'],
['No host', 'ss://password@:443?type=tcp'],
['No port', 'ss://password@127.0.0.1?type=tcp'],
['Invalid port', 'ss://password@127.0.0.1:abc?type=tcp'],
['Missing type', 'ss://password@127.0.0.1:443'],
['Contains space', 'ss://password@127.0.0.1:443?type=tcp #extra'],
];
describe('validateShadowsocksUrl', () => {
describe.each(validUrls)('Valid URL: %s', (_desc, url) => {
it(`returns valid=true for "${url}"`, () => {
const res = validateShadowsocksUrl(url);
expect(res.valid).toBe(true);
});
});
describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => {
it(`returns valid=false for "${url}"`, () => {
const res = validateShadowsocksUrl(url);
expect(res.valid).toBe(false);
});
});
it('detects invalid port range', () => {
const res = validateShadowsocksUrl(
'ss://password@127.0.0.1:99999?type=tcp',
);
expect(res.valid).toBe(false);
});
});

View File

@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest';
import { validateTrojanUrl } from '../validateTrojanUrl';
const validUrls = [
// TCP
[
'tcp + none',
'trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none',
],
[
'tcp + reality',
'trojan://cME3ZlUrYF@127.0.0.1:43772?type=tcp&security=reality&pbk=DckTwU6p6pTX9QxFXOi6vH4Vzt_RCE1vMCnj2c6hvjw&fp=chrome&sni=google.com&sid=221a80cf94&spx=%2F#trojan-tcp-reality',
],
[
'tcp + tls',
'trojan://EJjpAj02lg@127.0.0.1:11381?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-tcp-tls',
],
[
'tcp + tls + insecure',
'trojan://ZP2Ik5sxN3@127.0.0.1:16247?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-tcp-tls-insecure',
],
[
'tcp + tls + ech',
'trojan://90caP481ay@127.0.0.1:59708?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACC2y%2BAe4dqthLNpfvmtE6g%2BnaJ%2FciK6P%2BREbRLkR%2Fg%2FEgAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-tcp-tls-ech',
],
// mKCP
[
'mKCP + none',
'trojan://N5v7iIOe9G@127.0.0.1:36319?type=kcp&headerType=none&seed=P91wFIfjzZ&security=none#trojan-mKCP',
],
// WebSocket
[
'ws + none',
'trojan://G3cE9phv1g@127.0.0.1:57370?type=ws&path=%2Fwspath&host=google.com&security=none#trojan-websocket-none',
],
[
'ws + tls',
'trojan://FBok41WczO@127.0.0.1:59919?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-websocket-tls',
],
[
'ws + tls + insecure',
'trojan://bhwvndUBPA@127.0.0.1:22969?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-websocket-tls-insecure',
],
[
'ws + tls + ech',
'trojan://pwiduqFUWO@127.0.0.1:46765?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCFcQYEtwrFOidJJLYHvSiN%2BljRgaAIrNHoVnio3uXAOwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-websocket-tls-ech',
],
// gRPC
[
'grpc + none',
'trojan://WMR7qkKhsV@127.0.0.1:27897?type=grpc&serviceName=TunService&authority=authority&security=none#trojan-gRPC-none',
],
[
'grpc + reality',
'trojan://KVuRNsu6KG@127.0.0.1:46077?type=grpc&serviceName=TunService&authority=authority&security=reality&pbk=Xn59i4gum3ppCICS6-_NuywrhHIVVAH54b2mjd5CFkE&fp=chrome&sni=google.com&sid=e5be&spx=%2F#trojan-gRPC-reality',
],
[
'grpc + tls',
'trojan://7BJtbywy8h@127.0.0.1:10627?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-gRPC-tls',
],
[
'grpc + tls + insecure',
'trojan://TI3PakvtP4@127.0.0.1:10435?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-gRPC-tls-insecure',
],
[
'grpc + tls + ech',
'trojan://mbzoVKL27h@127.0.0.1:38681?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCq72Ru3VbFlDpKttl3LccmInu8R2oAsCr8wzyxB0vZZQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-gRPC-tls-ech',
],
// HTTPUpgrade
[
'httpupgrade + none',
'trojan://uc44gBwOKQ@127.0.0.1:29085?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=none#trojan-httpupgrade-none',
],
[
'httpupgrade + tls',
'trojan://MhNxbcVB14@127.0.0.1:32700?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-httpupgrade-tls',
],
[
'httpupgrade + tls + insecure',
'trojan://7SOQFUpLob@127.0.0.1:28474?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-httpupgrade-tls-insecure',
],
[
'httpupgrade + tls + ech',
'trojan://ou8pLSyx9N@127.0.0.1:17737?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACB%2FlkIkit%2BblFzE7PtbYDVF3NXK8olXJ5a7YwY%2Biy9QQwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-httpupgrade-tls-ech',
],
// XHTTP
[
'xhttp + none',
'trojan://VEetltxLtw@127.0.0.1:59072?type=xhttp&path=%2Fxhttppath&host=google.com&mode=auto&security=none#trojan-xhttp',
],
];
const invalidUrls = [
['No prefix', 'uuid@host:443?type=tcp&security=tls'],
['No password', 'trojan://@127.0.0.1:443?type=tcp&security=tls'],
['No host', 'trojan://pass@:443?type=tcp&security=tls'],
['No port', 'trojan://pass@127.0.0.1?type=tcp&security=tls'],
['Invalid port', 'trojan://pass@127.0.0.1:abc?type=tcp&security=tls'],
[
'tcp + reality + unexpected spaces',
'trojan://cME3ZlUrYF@127.0.0.1:43772?type=tcp&security=reality&pbk=DckTwU6p6pTX9QxFXOi6vH4Vzt_RCE1vMCnj2c6hvjw&fp=chrome&sni= google.com&sid=221a80cf94&spx=%2F#trojan-tcp-reality',
],
];
describe('validateTrojanUrl', () => {
describe.each(validUrls)('Valid URL: %s', (_desc, url) => {
it(`returns valid=true for "${url}"`, () => {
const res = validateTrojanUrl(url);
expect(res.valid).toBe(true);
});
});
describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => {
it(`returns valid=false for "${url}"`, () => {
const res = validateTrojanUrl(url);
expect(res.valid).toBe(false);
});
});
it('detects invalid port range', () => {
const res = validateTrojanUrl(
'trojan://pass@127.0.0.1:99999?type=tcp&security=tls',
);
expect(res.valid).toBe(false);
});
});

View File

@@ -1,9 +1,19 @@
import { ValidationResult } from './types';
export function validateDomain(domain: string): ValidationResult {
export function validateDomain(
domain: string,
allowDotTLD = false,
): ValidationResult {
const domainRegex =
/^(?=.{1,253}(?:\/|$))(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]{1,59}[a-zA-Z0-9])(?:\/[^\s]*)?$/;
if (allowDotTLD) {
const dotTLD = /^\.[a-zA-Z]{2,}$/;
if (dotTLD.test(domain)) {
return { valid: true, message: _('Valid') };
}
}
if (!domainRegex.test(domain)) {
return { valid: false, message: _('Invalid domain address') };
}

View File

@@ -1,32 +1,57 @@
import { ValidationResult } from './types';
// TODO refactor current validation and add tests
export function validateTrojanUrl(url: string): ValidationResult {
if (!url.startsWith('trojan://')) {
return {
valid: false,
message: _('Invalid Trojan URL: must start with trojan://'),
};
}
if (!url || /\s/.test(url)) {
return {
valid: false,
message: _('Invalid Trojan URL: must not contain spaces'),
};
}
try {
const parsedUrl = new URL(url);
if (!parsedUrl.username || !parsedUrl.hostname || !parsedUrl.port) {
if (!url.startsWith('trojan://')) {
return {
valid: false,
message: _(
'Invalid Trojan URL: must contain username, hostname and port',
),
message: _('Invalid Trojan URL: must start with trojan://'),
};
}
if (!url || /\s/.test(url)) {
return {
valid: false,
message: _('Invalid Trojan URL: must not contain spaces'),
};
}
const body = url.slice('trojan://'.length);
const [mainPart] = body.split('#');
const [userHostPort] = mainPart.split('?');
const [userPart, hostPortPart] = userHostPort.split('@');
if (!userHostPort)
return {
valid: false,
message: 'Invalid Trojan URL: missing credentials and host',
};
if (!userPart)
return { valid: false, message: 'Invalid Trojan URL: missing password' };
if (!hostPortPart)
return {
valid: false,
message: 'Invalid Trojan URL: missing hostname and port',
};
const [host, port] = hostPortPart.split(':');
if (!host)
return { valid: false, message: 'Invalid Trojan URL: missing hostname' };
if (!port)
return { valid: false, message: 'Invalid Trojan URL: missing port' };
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
return {
valid: false,
message: 'Invalid Trojan URL: invalid port number',
};
} catch (_e) {
return { valid: false, message: _('Invalid Trojan URL: parsing failed') };
}

View File

@@ -1,58 +1,63 @@
import { ValidationResult } from './types';
import { parseQueryString } from '../helpers';
export function validateVlessUrl(url: string): ValidationResult {
try {
const parsedUrl = new URL(url);
if (!url || /\s/.test(url)) {
if (!url.startsWith('vless://'))
return {
valid: false,
message: _('Invalid VLESS URL: must not contain spaces'),
message: 'Invalid VLESS URL: must start with vless://',
};
}
if (parsedUrl.protocol !== 'vless:') {
if (/\s/.test(url))
return {
valid: false,
message: _('Invalid VLESS URL: must start with vless://'),
message: 'Invalid VLESS URL: must not contain spaces',
};
}
if (!parsedUrl.username) {
return { valid: false, message: _('Invalid VLESS URL: missing UUID') };
}
const body = url.slice('vless://'.length);
if (!parsedUrl.hostname) {
return { valid: false, message: _('Invalid VLESS URL: missing server') };
}
const [mainPart] = body.split('#');
if (!parsedUrl.port) {
return { valid: false, message: _('Invalid VLESS URL: missing port') };
}
const [userHostPort, queryString] = mainPart.split('?');
if (
isNaN(+parsedUrl.port) ||
+parsedUrl.port < 1 ||
+parsedUrl.port > 65535
) {
if (!userHostPort)
return {
valid: false,
message: _(
'Invalid VLESS URL: invalid port number. Must be between 1 and 65535',
),
message: 'Invalid VLESS URL: missing host and UUID',
};
}
if (!parsedUrl.search) {
const [userPart, hostPortPart] = userHostPort.split('@');
if (!userPart)
return { valid: false, message: 'Invalid VLESS URL: missing UUID' };
if (!hostPortPart)
return { valid: false, message: 'Invalid VLESS URL: missing server' };
const [host, port] = hostPortPart.split(':');
if (!host)
return { valid: false, message: 'Invalid VLESS URL: missing hostname' };
if (!port)
return { valid: false, message: 'Invalid VLESS URL: missing port' };
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
return {
valid: false,
message: _('Invalid VLESS URL: missing query parameters'),
message: 'Invalid VLESS URL: invalid port number',
};
}
const params = new URLSearchParams(parsedUrl.search);
if (!queryString)
return {
valid: false,
message: 'Invalid VLESS URL: missing query parameters',
};
const params = parseQueryString(queryString);
const type = params.get('type');
const validTypes = [
'tcp',
'raw',
@@ -64,45 +69,31 @@ export function validateVlessUrl(url: string): ValidationResult {
'ws',
'kcp',
];
if (!type || !validTypes.includes(type)) {
return {
valid: false,
message: _(
'Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws',
),
};
}
const security = params.get('security');
const validSecurities = ['tls', 'reality', 'none'];
if (!security || !validSecurities.includes(security)) {
if (!params.type || !validTypes.includes(params.type))
return {
valid: false,
message: _(
'Invalid VLESS URL: security must be one of tls, reality, none',
),
message: 'Invalid VLESS URL: unsupported or missing type',
};
}
if (security === 'reality') {
if (!params.get('pbk')) {
if (!params.security || !validSecurities.includes(params.security))
return {
valid: false,
message: 'Invalid VLESS URL: unsupported or missing security',
};
if (params.security === 'reality') {
if (!params.pbk)
return {
valid: false,
message: _(
'Invalid VLESS URL: missing pbk parameter for reality security',
),
message: 'Invalid VLESS URL: missing pbk for reality',
};
}
if (!params.get('fp')) {
if (!params.fp)
return {
valid: false,
message: _(
'Invalid VLESS URL: missing fp parameter for reality security',
),
message: 'Invalid VLESS URL: missing fp for reality',
};
}
}
return { valid: true, message: _('Valid') };

View File

@@ -4,6 +4,10 @@ REPO="https://api.github.com/repos/itdoginfo/podkop/releases/latest"
DOWNLOAD_DIR="/tmp/podkop"
COUNT=3
# Cached flag to switch between ipk or apk package managers
PKG_IS_APK=0
command -v apk >/dev/null 2>&1 && PKG_IS_APK=1
rm -rf "$DOWNLOAD_DIR"
mkdir -p "$DOWNLOAD_DIR"
@@ -11,20 +15,65 @@ msg() {
printf "\033[32;1m%s\033[0m\n" "$1"
}
pkg_is_installed () {
local pkg_name="$1"
if [ "$PKG_IS_APK" -eq 1 ]; then
# grep -q should work without change based on example from documentation
# apk list --installed --providers dnsmasq
# <dnsmasq> dnsmasq-full-2.90-r3 x86_64 {feeds/base/package/network/services/dnsmasq} (GPL-2.0) [installed]
apk list --installed | grep -q "$pkg_name"
else
opkg list-installed | grep -q "$pkg_name"
fi
}
pkg_remove() {
local pkg_name="$1"
if [ "$PKG_IS_APK" -eq 1 ]; then
# TODO: check --force-depends flag
# Nothing here: https://openwrt.org/docs/guide-user/additional-software/opkg-to-apk-cheatsheet
apk del "$pkg_name"
else
opkg remove --force-depends "$pkg_name"
fi
}
pkg_list_update() {
if [ "$PKG_IS_APK" -eq 1 ]; then
apk update
else
opkg update
fi
}
pkg_install() {
local pkg_file="$1"
if [ "$PKG_IS_APK" -eq 1 ]; then
# Can't install without flag based on info from documentation
# If you're installing a non-standard (self-built) package, use the --allow-untrusted option:
apk add --allow-untrusted "$pkg_file"
else
opkg install "$pkg_file"
fi
}
main() {
check_system
sing_box
/usr/sbin/ntpd -q -p 194.190.168.1 -p 216.239.35.0 -p 216.239.35.4 -p 162.159.200.1 -p 162.159.200.123
opkg update || { echo "opkg update failed"; exit 1; }
pkg_list_update || { echo "Packages list update failed"; exit 1; }
if [ -f "/etc/init.d/podkop" ]; then
msg "Podkop is already installed. Upgraded..."
else
msg "Installed podkop..."
fi
if command -v curl &> /dev/null; then
check_response=$(curl -s "https://api.github.com/repos/itdoginfo/podkop/releases/latest")
@@ -34,11 +83,18 @@ main() {
fi
fi
local grep_url_pattern
if [ "$PKG_IS_APK" -eq 1 ]; then
grep_url_pattern='https://[^"[:space:]]*\.apk'
else
grep_url_pattern='https://[^"[:space:]]*\.ipk'
fi
download_success=0
while read -r url; do
filename=$(basename "$url")
filepath="$DOWNLOAD_DIR/$filename"
attempt=0
while [ $attempt -lt $COUNT ]; do
msg "Download $filename (count $((attempt+1)))..."
@@ -53,40 +109,40 @@ main() {
rm -f "$filepath"
attempt=$((attempt+1))
done
if [ $attempt -eq $COUNT ]; then
msg "Failed to download $filename after $COUNT attempts"
fi
done < <(wget -qO- "$REPO" | grep -o 'https://[^"[:space:]]*\.ipk')
done < <(wget -qO- "$REPO" | grep -o "$grep_url_pattern")
if [ $download_success -eq 0 ]; then
msg "No packages were downloaded successfully"
exit 1
fi
for pkg in podkop luci-app-podkop; do
file=$(ls "$DOWNLOAD_DIR" | grep "^$pkg" | head -n 1)
if [ -n "$file" ]; then
msg "Installing $file"
opkg install "$DOWNLOAD_DIR/$file"
pkg_install "$DOWNLOAD_DIR/$file"
sleep 3
fi
done
ru=$(ls "$DOWNLOAD_DIR" | grep "luci-i18n-podkop-ru" | head -n 1)
if [ -n "$ru" ]; then
if opkg list-installed | grep -q luci-i18n-podkop-ru; then
if pkg_is_installed luci-i18n-podkop-ru; then
msg "Upgraded ru translation..."
opkg remove luci-i18n-podkop*
opkg install "$DOWNLOAD_DIR/$ru"
pkg_remove luci-i18n-podkop*
pkg_install "$DOWNLOAD_DIR/$ru"
else
msg "Русский язык интерфейса ставим? y/n (Need a Russian translation?)"
while true; do
read -r -p '' RUS
case $RUS in
y)
opkg remove luci-i18n-podkop*
opkg install "$DOWNLOAD_DIR/$ru"
pkg_remove luci-i18n-podkop*
pkg_install "$DOWNLOAD_DIR/$ru"
break
;;
n)
@@ -133,15 +189,17 @@ check_system() {
exit 1
fi
if opkg list-installed | grep -q https-dns-proxy; then
if pkg_is_installed https-dns-proxy; then
msg "Сonflicting package detected: https-dns-proxy. Remove?"
while true; do
read -r -p '' DNSPROXY
case $DNSPROXY in
yes|y|Y|yes)
opkg remove --force-depends luci-app-https-dns-proxy https-dns-proxy luci-i18n-https-dns-proxy*
yes|y|Y)
pkg_remove luci-app-https-dns-proxy
pkg_remove https-dns-proxy
pkg_remove luci-i18n-https-dns-proxy*
break
;;
*)
@@ -154,7 +212,7 @@ check_system() {
}
sing_box() {
if ! opkg list-installed | grep -q "^sing-box"; then
if ! pkg_is_installed "^sing-box"; then
return
fi
@@ -165,8 +223,8 @@ sing_box() {
msg "sing-box version $sing_box_version is older than required $required_version"
msg "Removing old version..."
service podkop stop
opkg remove sing-box --force-depends
pkg_remove sing-box
fi
}
main
main

View File

@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-podkop
PKG_VERSION := $(if $(PKG_VERSION),$(PKG_VERSION),dev_$(shell date +%d%m%Y))
PKG_VERSION := $(if $(PODKOP_VERSION),$(PODKOP_VERSION),0.$(shell date +%d%m%Y))
PKG_RELEASE:=1
@@ -19,4 +19,12 @@ LUCI_LANGUAGES:=en ru
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
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)))

View File

@@ -12,7 +12,7 @@ function createAdditionalSection(mainSection) {
form.Flag,
'yacd',
_('Yacd enable'),
`<a href="${main.getBaseUrl()}:9090/ui" target="_blank">${main.getBaseUrl()}:9090/ui</a>`,
`<a href="${main.getClashUIUrl()}" target="_blank">${main.getClashUIUrl()}</a>`,
);
o.default = '0';
o.rmempty = false;

View File

@@ -130,11 +130,7 @@ function createConfigSection(section) {
}
try {
const activeConfigs = value
.split('\n')
.map((line) => line.trim())
.filter((line) => !line.startsWith('//'))
.filter(Boolean);
const activeConfigs = main.splitProxyString(value);
if (!activeConfigs.length) {
return _(
@@ -455,7 +451,7 @@ function createConfigSection(section) {
return true;
}
const validation = main.validateDomain(value);
const validation = main.validateDomain(value, true);
if (validation.valid) {
return true;
@@ -493,7 +489,7 @@ function createConfigSection(section) {
);
}
const { valid, results } = main.bulkValidate(domains, main.validateDomain);
const { valid, results } = main.bulkValidate(domains, row => main.validateDomain(row, true));
if (!valid) {
const errors = results
@@ -768,7 +764,7 @@ function createConfigSection(section) {
return true;
}
const validation = main.validateIPV4(value);
const validation = main.validateSubnet(value);
if (validation.valid) {
return true;

View File

@@ -376,7 +376,7 @@ function showConfigModal(command, title) {
let formattedOutput = '';
if (command === 'global_check') {
safeExec('/usr/bin/podkop', [command], 'P0_PRIORITY', (res) => {
safeExec('/usr/bin/podkop', [command, `${main.PODKOP_LUCI_APP_VERSION}`], 'P0_PRIORITY', (res) => {
formattedOutput = formatDiagnosticOutput(res.stdout || _('No output'));
try {
@@ -918,18 +918,11 @@ async function updateDiagnostics() {
);
});
safeExec(
'/usr/bin/podkop',
['show_luci_version'],
'P2_PRIORITY',
(result) => {
updateTextElement(
'luci-version',
document.createTextNode(
result.stdout ? result.stdout.trim() : _('Unknown'),
),
);
},
updateTextElement(
'luci-version',
document.createTextNode(
`${main.PODKOP_LUCI_APP_VERSION}`
)
);
safeExec(

View File

@@ -14,8 +14,14 @@ function validateIPV4(ip) {
}
// src/validators/validateDomain.ts
function validateDomain(domain) {
function validateDomain(domain, allowDotTLD = false) {
const domainRegex = /^(?=.{1,253}(?:\/|$))(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]{1,59}[a-zA-Z0-9])(?:\/[^\s]*)?$/;
if (allowDotTLD) {
const dotTLD = /^\.[a-zA-Z]{2,}$/;
if (dotTLD.test(domain)) {
return { valid: true, message: _("Valid") };
}
}
if (!domainRegex.test(domain)) {
return { valid: false, message: _("Invalid domain address") };
}
@@ -204,165 +210,6 @@ function validateShadowsocksUrl(url) {
return { valid: true, message: _("Valid") };
}
// src/validators/validateVlessUrl.ts
function validateVlessUrl(url) {
try {
const parsedUrl = new URL(url);
if (!url || /\s/.test(url)) {
return {
valid: false,
message: _("Invalid VLESS URL: must not contain spaces")
};
}
if (parsedUrl.protocol !== "vless:") {
return {
valid: false,
message: _("Invalid VLESS URL: must start with vless://")
};
}
if (!parsedUrl.username) {
return { valid: false, message: _("Invalid VLESS URL: missing UUID") };
}
if (!parsedUrl.hostname) {
return { valid: false, message: _("Invalid VLESS URL: missing server") };
}
if (!parsedUrl.port) {
return { valid: false, message: _("Invalid VLESS URL: missing port") };
}
if (isNaN(+parsedUrl.port) || +parsedUrl.port < 1 || +parsedUrl.port > 65535) {
return {
valid: false,
message: _(
"Invalid VLESS URL: invalid port number. Must be between 1 and 65535"
)
};
}
if (!parsedUrl.search) {
return {
valid: false,
message: _("Invalid VLESS URL: missing query parameters")
};
}
const params = new URLSearchParams(parsedUrl.search);
const type = params.get("type");
const validTypes = [
"tcp",
"raw",
"udp",
"grpc",
"http",
"httpupgrade",
"xhttp",
"ws",
"kcp"
];
if (!type || !validTypes.includes(type)) {
return {
valid: false,
message: _(
"Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws"
)
};
}
const security = params.get("security");
const validSecurities = ["tls", "reality", "none"];
if (!security || !validSecurities.includes(security)) {
return {
valid: false,
message: _(
"Invalid VLESS URL: security must be one of tls, reality, none"
)
};
}
if (security === "reality") {
if (!params.get("pbk")) {
return {
valid: false,
message: _(
"Invalid VLESS URL: missing pbk parameter for reality security"
)
};
}
if (!params.get("fp")) {
return {
valid: false,
message: _(
"Invalid VLESS URL: missing fp parameter for reality security"
)
};
}
}
return { valid: true, message: _("Valid") };
} catch (_e) {
return { valid: false, message: _("Invalid VLESS URL: parsing failed") };
}
}
// src/validators/validateOutboundJson.ts
function validateOutboundJson(value) {
try {
const parsed = JSON.parse(value);
if (!parsed.type || !parsed.server || !parsed.server_port) {
return {
valid: false,
message: _(
'Outbound JSON must contain at least "type", "server" and "server_port" fields'
)
};
}
return { valid: true, message: _("Valid") };
} catch {
return { valid: false, message: _("Invalid JSON format") };
}
}
// src/validators/validateTrojanUrl.ts
function validateTrojanUrl(url) {
if (!url.startsWith("trojan://")) {
return {
valid: false,
message: _("Invalid Trojan URL: must start with trojan://")
};
}
if (!url || /\s/.test(url)) {
return {
valid: false,
message: _("Invalid Trojan URL: must not contain spaces")
};
}
try {
const parsedUrl = new URL(url);
if (!parsedUrl.username || !parsedUrl.hostname || !parsedUrl.port) {
return {
valid: false,
message: _(
"Invalid Trojan URL: must contain username, hostname and port"
)
};
}
} catch (_e) {
return { valid: false, message: _("Invalid Trojan URL: parsing failed") };
}
return { valid: true, message: _("Valid") };
}
// src/validators/validateProxyUrl.ts
function validateProxyUrl(url) {
if (url.startsWith("ss://")) {
return validateShadowsocksUrl(url);
}
if (url.startsWith("vless://")) {
return validateVlessUrl(url);
}
if (url.startsWith("trojan://")) {
return validateTrojanUrl(url);
}
return {
valid: false,
message: _("URL must start with vless:// or ss:// or trojan://")
};
}
// src/helpers/getBaseUrl.ts
function getBaseUrl() {
const { protocol, hostname } = window.location;
@@ -586,6 +433,7 @@ var STATUS_COLORS = {
ERROR: "#f44336",
WARNING: "#ff9800"
};
var PODKOP_LUCI_APP_VERSION = "__COMPILED_VERSION_VARIABLE__";
var FAKEIP_CHECK_DOMAIN = "fakeip.podkop.fyi";
var IP_CHECK_DOMAIN = "ip.podkop.fyi";
var REGIONAL_OPTIONS = [
@@ -757,13 +605,214 @@ async function onMount(id) {
// src/helpers/getClashApiUrl.ts
function getClashApiUrl() {
const { protocol, hostname } = window.location;
return `${protocol}//${hostname}:9090`;
const { hostname } = window.location;
return `http://${hostname}:9090`;
}
function getClashWsUrl() {
const { hostname } = window.location;
return `ws://${hostname}:9090`;
}
function getClashUIUrl() {
const { hostname } = window.location;
return `http://${hostname}:9090/ui`;
}
// src/helpers/splitProxyString.ts
function splitProxyString(str) {
return str.split("\n").map((line) => line.trim()).filter((line) => !line.startsWith("//")).filter(Boolean);
}
// src/helpers/preserveScrollForPage.ts
function preserveScrollForPage(renderFn) {
const scrollY = window.scrollY;
renderFn();
requestAnimationFrame(() => {
window.scrollTo({ top: scrollY });
});
}
// src/helpers/parseQueryString.ts
function parseQueryString(query) {
const clean = query.startsWith("?") ? query.slice(1) : query;
return clean.split("&").filter(Boolean).reduce(
(acc, pair) => {
const [rawKey, rawValue = ""] = pair.split("=");
if (!rawKey) {
return acc;
}
const key = decodeURIComponent(rawKey);
const value = decodeURIComponent(rawValue);
return { ...acc, [key]: value };
},
{}
);
}
// src/validators/validateVlessUrl.ts
function validateVlessUrl(url) {
try {
if (!url.startsWith("vless://"))
return {
valid: false,
message: "Invalid VLESS URL: must start with vless://"
};
if (/\s/.test(url))
return {
valid: false,
message: "Invalid VLESS URL: must not contain spaces"
};
const body = url.slice("vless://".length);
const [mainPart] = body.split("#");
const [userHostPort, queryString] = mainPart.split("?");
if (!userHostPort)
return {
valid: false,
message: "Invalid VLESS URL: missing host and UUID"
};
const [userPart, hostPortPart] = userHostPort.split("@");
if (!userPart)
return { valid: false, message: "Invalid VLESS URL: missing UUID" };
if (!hostPortPart)
return { valid: false, message: "Invalid VLESS URL: missing server" };
const [host, port] = hostPortPart.split(":");
if (!host)
return { valid: false, message: "Invalid VLESS URL: missing hostname" };
if (!port)
return { valid: false, message: "Invalid VLESS URL: missing port" };
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
return {
valid: false,
message: "Invalid VLESS URL: invalid port number"
};
if (!queryString)
return {
valid: false,
message: "Invalid VLESS URL: missing query parameters"
};
const params = parseQueryString(queryString);
const validTypes = [
"tcp",
"raw",
"udp",
"grpc",
"http",
"httpupgrade",
"xhttp",
"ws",
"kcp"
];
const validSecurities = ["tls", "reality", "none"];
if (!params.type || !validTypes.includes(params.type))
return {
valid: false,
message: "Invalid VLESS URL: unsupported or missing type"
};
if (!params.security || !validSecurities.includes(params.security))
return {
valid: false,
message: "Invalid VLESS URL: unsupported or missing security"
};
if (params.security === "reality") {
if (!params.pbk)
return {
valid: false,
message: "Invalid VLESS URL: missing pbk for reality"
};
if (!params.fp)
return {
valid: false,
message: "Invalid VLESS URL: missing fp for reality"
};
}
return { valid: true, message: _("Valid") };
} catch (_e) {
return { valid: false, message: _("Invalid VLESS URL: parsing failed") };
}
}
// src/validators/validateOutboundJson.ts
function validateOutboundJson(value) {
try {
const parsed = JSON.parse(value);
if (!parsed.type || !parsed.server || !parsed.server_port) {
return {
valid: false,
message: _(
'Outbound JSON must contain at least "type", "server" and "server_port" fields'
)
};
}
return { valid: true, message: _("Valid") };
} catch {
return { valid: false, message: _("Invalid JSON format") };
}
}
// src/validators/validateTrojanUrl.ts
function validateTrojanUrl(url) {
try {
if (!url.startsWith("trojan://")) {
return {
valid: false,
message: _("Invalid Trojan URL: must start with trojan://")
};
}
if (!url || /\s/.test(url)) {
return {
valid: false,
message: _("Invalid Trojan URL: must not contain spaces")
};
}
const body = url.slice("trojan://".length);
const [mainPart] = body.split("#");
const [userHostPort] = mainPart.split("?");
const [userPart, hostPortPart] = userHostPort.split("@");
if (!userHostPort)
return {
valid: false,
message: "Invalid Trojan URL: missing credentials and host"
};
if (!userPart)
return { valid: false, message: "Invalid Trojan URL: missing password" };
if (!hostPortPart)
return {
valid: false,
message: "Invalid Trojan URL: missing hostname and port"
};
const [host, port] = hostPortPart.split(":");
if (!host)
return { valid: false, message: "Invalid Trojan URL: missing hostname" };
if (!port)
return { valid: false, message: "Invalid Trojan URL: missing port" };
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
return {
valid: false,
message: "Invalid Trojan URL: invalid port number"
};
} catch (_e) {
return { valid: false, message: _("Invalid Trojan URL: parsing failed") };
}
return { valid: true, message: _("Valid") };
}
// src/validators/validateProxyUrl.ts
function validateProxyUrl(url) {
if (url.startsWith("ss://")) {
return validateShadowsocksUrl(url);
}
if (url.startsWith("vless://")) {
return validateVlessUrl(url);
}
if (url.startsWith("trojan://")) {
return validateTrojanUrl(url);
}
return {
valid: false,
message: _("URL must start with vless:// or ss:// or trojan://")
};
}
// src/clash/methods/createBaseApiRequest.ts
async function createBaseApiRequest(fetchFn) {
@@ -893,6 +942,8 @@ async function getDashboardSections() {
const outbound = proxies.find(
(proxy) => proxy.code === `${section[".name"]}-out`
);
const activeConfigs = splitProxyString(section.proxy_string);
const proxyDisplayName = getProxyUrlName(activeConfigs?.[0]) || outbound?.value?.name || "";
return {
withTagSelect: false,
code: outbound?.code || section[".name"],
@@ -900,7 +951,7 @@ async function getDashboardSections() {
outbounds: [
{
code: outbound?.code || section[".name"],
displayName: getProxyUrlName(section.proxy_string) || outbound?.value?.name || "",
displayName: proxyDisplayName,
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || "",
selected: true
@@ -912,6 +963,9 @@ async function getDashboardSections() {
const outbound = proxies.find(
(proxy) => proxy.code === `${section[".name"]}-out`
);
const parsedOutbound = JSON.parse(section.outbound_json);
const parsedTag = parsedOutbound?.tag ? decodeURIComponent(parsedOutbound?.tag) : void 0;
const proxyDisplayName = parsedTag || outbound?.value?.name || "";
return {
withTagSelect: false,
code: outbound?.code || section[".name"],
@@ -919,7 +973,7 @@ async function getDashboardSections() {
outbounds: [
{
code: outbound?.code || section[".name"],
displayName: decodeURIComponent(JSON.parse(section.outbound_json)?.tag) || outbound?.value?.name || "",
displayName: proxyDisplayName,
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || "",
selected: true
@@ -995,7 +1049,7 @@ async function getPodkopStatus() {
const response = await executeShellCommand({
command: "/usr/bin/podkop",
args: ["get_status"],
timeout: 1e3
timeout: 1e4
});
if (response.stdout) {
return JSON.parse(response.stdout.replace(/\n/g, ""));
@@ -1008,7 +1062,7 @@ async function getSingboxStatus() {
const response = await executeShellCommand({
command: "/usr/bin/podkop",
args: ["get_sing_box_status"],
timeout: 1e3
timeout: 1e4
});
if (response.stdout) {
return JSON.parse(response.stdout.replace(/\n/g, ""));
@@ -1194,6 +1248,7 @@ var initialStore = {
sectionsWidget: {
loading: true,
failed: false,
latencyFetching: false,
data: []
}
};
@@ -1219,7 +1274,10 @@ function renderFailedState() {
class: "pdk_dashboard-page__outbound-section centered",
style: "height: 127px"
},
E("span", {}, _("Dashboard currently unavailable"))
E("span", {}, [
E("span", {}, _("Dashboard currently unavailable")),
E("div", { style: "text-align: center;" }, `API: ${getClashApiUrl()}`)
])
);
}
function renderLoadingState() {
@@ -1232,7 +1290,8 @@ function renderLoadingState() {
function renderDefaultState({
section,
onChooseOutbound,
onTestLatency
onTestLatency,
latencyFetching
}) {
function testLatency() {
if (section.withTagSelect) {
@@ -1247,10 +1306,10 @@ function renderDefaultState({
if (!outbound.latency) {
return "pdk_dashboard-page__outbound-grid__item__latency--empty";
}
if (outbound.latency < 200) {
if (outbound.latency < 800) {
return "pdk_dashboard-page__outbound-grid__item__latency--green";
}
if (outbound.latency < 400) {
if (outbound.latency < 1500) {
return "pdk_dashboard-page__outbound-grid__item__latency--yellow";
}
return "pdk_dashboard-page__outbound-grid__item__latency--red";
@@ -1288,13 +1347,13 @@ function renderDefaultState({
},
section.displayName
),
E(
latencyFetching ? E("div", { class: "skeleton", style: "width: 99px; height: 28px" }) : E(
"button",
{
class: "btn dashboard-sections-grid-item-test-latency",
click: () => testLatency()
},
"Test latency"
_("Test latency")
)
]),
E(
@@ -1555,8 +1614,12 @@ async function fetchDashboardSections() {
}
});
const { data, success } = await getDashboardSections();
if (!success) {
console.log("[fetchDashboardSections]: failed to fetch", getClashApiUrl());
}
store.set({
sectionsWidget: {
latencyFetching: false,
loading: false,
failed: !success,
data
@@ -1564,17 +1627,28 @@ async function fetchDashboardSections() {
});
}
async function fetchServicesInfo() {
const [podkop, singbox] = await Promise.all([
getPodkopStatus(),
getSingboxStatus()
]);
store.set({
servicesInfoWidget: {
loading: false,
failed: false,
data: { singbox: singbox.running, podkop: podkop.enabled }
}
});
try {
const [podkop, singbox] = await Promise.all([
getPodkopStatus(),
getSingboxStatus()
]);
store.set({
servicesInfoWidget: {
loading: false,
failed: false,
data: { singbox: singbox.running, podkop: podkop.enabled }
}
});
} catch (err) {
console.log("[fetchServicesInfo]: failed to fetchServices", err);
store.set({
servicesInfoWidget: {
loading: false,
failed: true,
data: { singbox: 0, podkop: 0 }
}
});
}
}
async function connectToClashSockets() {
socket.subscribe(
@@ -1590,6 +1664,10 @@ async function connectToClashSockets() {
});
},
(_err) => {
console.log(
"[fetchDashboardSections]: failed to connect",
getClashWsUrl()
);
store.set({
bandwidthWidget: {
loading: false,
@@ -1623,6 +1701,10 @@ async function connectToClashSockets() {
});
},
(_err) => {
console.log(
"[fetchDashboardSections]: failed to connect",
getClashWsUrl()
);
store.set({
trafficTotalWidget: {
loading: false,
@@ -1646,20 +1728,35 @@ async function handleChooseOutbound(selector, tag) {
await fetchDashboardSections();
}
async function handleTestGroupLatency(tag) {
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: true
}
});
await triggerLatencyGroupTest(tag);
await fetchDashboardSections();
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: false
}
});
}
async function handleTestProxyLatency(tag) {
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: true
}
});
await triggerLatencyProxyTest(tag);
await fetchDashboardSections();
}
function replaceTestLatencyButtonsWithSkeleton() {
document.querySelectorAll(".dashboard-sections-grid-item-test-latency").forEach((el) => {
const newDiv = document.createElement("div");
newDiv.className = "skeleton";
newDiv.style.width = "99px";
newDiv.style.height = "28px";
el.replaceWith(newDiv);
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: false
}
});
}
async function renderSectionsWidget() {
@@ -1679,17 +1776,20 @@ async function renderSectionsWidget() {
onTestLatency: () => {
},
onChooseOutbound: () => {
}
},
latencyFetching: sectionsWidget.latencyFetching
});
return preserveScrollForPage(() => {
container.replaceChildren(renderedWidget);
});
return container.replaceChildren(renderedWidget);
}
const renderedWidgets = sectionsWidget.data.map(
(section) => renderSections({
loading: sectionsWidget.loading,
failed: sectionsWidget.failed,
section,
latencyFetching: sectionsWidget.latencyFetching,
onTestLatency: (tag) => {
replaceTestLatencyButtonsWithSkeleton();
if (section.withTagSelect) {
return handleTestGroupLatency(tag);
}
@@ -1700,7 +1800,9 @@ async function renderSectionsWidget() {
}
})
);
return container.replaceChildren(...renderedWidgets);
return preserveScrollForPage(() => {
container.replaceChildren(...renderedWidgets);
});
}
async function renderBandwidthWidget() {
console.log("renderBandwidthWidget");
@@ -1864,6 +1966,7 @@ return baseclass.extend({
FAKEIP_CHECK_DOMAIN,
FETCH_TIMEOUT,
IP_CHECK_DOMAIN,
PODKOP_LUCI_APP_VERSION,
REGIONAL_OPTIONS,
STATUS_COLORS,
TabService,
@@ -1878,6 +1981,7 @@ return baseclass.extend({
getClashConfig,
getClashGroupDelay,
getClashProxies,
getClashUIUrl,
getClashVersion,
getClashWsUrl,
getConfigSections,
@@ -1889,8 +1993,11 @@ return baseclass.extend({
injectGlobalStyles,
maskIP,
onMount,
parseQueryString,
parseValueList,
preserveScrollForPage,
renderDashboard,
splitProxyString,
triggerLatencyGroupTest,
triggerLatencyProxyTest,
triggerProxySelector,

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
PODIR="po"

View File

@@ -51,9 +51,11 @@ msgid "Config without description"
msgstr "Конфигурация без описания"
msgid ""
"Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs"
"Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup "
"configs"
msgstr ""
"Введите строку подключения, начинающуюся с vless:// или ss:// для настройки прокси. Добавляйте комментарии с // для резервных конфигураций"
"Введите строку подключения, начинающуюся с vless:// или ss:// для настройки прокси. Добавляйте комментарии с // для "
"резервных конфигураций"
msgid "No active configuration found. One configuration is required."
msgstr "Активная конфигурация не найдена. Требуется хотя бы одна незакомментированная строка."
@@ -124,14 +126,18 @@ msgstr "Выберите предустановленные сервисы дл
msgid "Regional options cannot be used together"
msgstr "Нельзя использовать несколько региональных опций одновременно"
#, javascript-format
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены."
msgid "Russia inside restrictions"
msgstr "Ограничения Russia inside"
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» и был удалён из выбора."
#, javascript-format
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 "User Domain List Type"
msgstr "Тип пользовательского списка доменов"
@@ -214,8 +220,12 @@ msgstr "Введите подсети в нотации CIDR (например:
msgid "User Subnets List"
msgstr "Список пользовательских подсетей"
msgid "Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //"
msgstr "Введите подсети в нотации CIDR или IP-адреса через запятую, пробел или новую строку. Можно добавлять комментарии после //"
msgid ""
"Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments "
"after //"
msgstr ""
"Введите подсети в нотации CIDR или IP-адреса через запятую, пробел или новую строку. Можно добавлять комментарии "
"после //"
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
@@ -568,11 +578,122 @@ msgstr "Конфигурация: "
msgid "Diagnostics"
msgstr "Диагностика"
msgid "Podkop"
msgstr "Podkop"
msgid "Additional Settings"
msgstr "Дополнительные настройки"
msgid "Extra configurations"
msgstr "Дополнительные конфигурации"
msgid "Yacd enable"
msgstr "Включить YACD"
msgid "Add Section"
msgstr "Добавить раздел"
msgid "Exclude NTP"
msgstr "Исключить NTP"
msgid "Allows you to exclude NTP protocol traffic from the tunnel"
msgstr "Позволяет исключить направление трафика NTP-протокола в туннель"
msgid "QUIC disable"
msgstr "Отключить QUIC"
msgid "For issues with the video stream"
msgstr "Для проблем с видеопотоком"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
msgid "Select how often the lists will be updated"
msgstr "Выберите как часто будут обновляться списки"
msgid "Select DNS protocol to use"
msgstr "Выберите протокол DNS"
msgid "Bootstrap DNS server"
msgstr "Bootstrap DNS-сервер"
msgid "The DNS server used to look up the IP address of an upstream DNS server"
msgstr "DNS-сервер, используемый для поиска IP-адреса вышестоящего DNS-сервера"
msgid "DNS Rewrite TTL"
msgstr "Перезапись TTL для DNS"
msgid "Time in seconds for DNS record caching (default: 60)"
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
msgid "TTL value cannot be empty"
msgstr "Значение TTL не может быть пустым"
msgid "TTL must be a positive number"
msgstr "TTL должно быть положительным числом"
msgid "Config File Path"
msgstr "Путь к файлу конфигурации"
msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing"
msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
msgid "Cache File Path"
msgstr "Путь к файлу кэша"
msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing"
msgstr "Выберите или введите путь к файлу кеша sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
msgid "Cache file path cannot be empty"
msgstr "Путь к файлу кэша не может быть пустым"
msgid "Path must be absolute (start with /)"
msgstr "Путь должен быть абсолютным (начинаться с /)"
msgid "Path must end with cache.db"
msgstr "Путь должен заканчиваться на cache.db"
msgid "Path must contain at least one directory (like /tmp/cache.db)"
msgstr "Путь должен содержать хотя бы одну директорию (например /tmp/cache.db)"
msgid "Source Network Interface"
msgstr "Сетевой интерфейс источника"
msgid "Select the network interface from which the traffic will originate"
msgstr "Выберите сетевой интерфейс, с которого будет исходить трафик"
msgid "Interface monitoring"
msgstr "Мониторинг интерфейсов"
msgid "Interface monitoring for bad WAN"
msgstr "Мониторинг интерфейсов для плохого WAN"
msgid "Interface for monitoring"
msgstr "Интерфейс для мониторинга"
msgid "Select the WAN interfaces to be monitored"
msgstr "Выберите WAN интерфейсы для мониторинга"
msgid "Interface Monitoring Delay"
msgstr "Задержка при мониторинге интерфейсов"
msgid "Delay in milliseconds before reloading podkop after interface UP"
msgstr "Задержка в миллисекундах перед перезагрузкой podkop после поднятия интерфейса"
msgid "Delay value cannot be empty"
msgstr "Значение задержки не может быть пустым"
msgid "Dont touch my DHCP!"
msgstr "Не трогать мой DHCP!"
msgid "Podkop will not change the DHCP config"
msgstr "Podkop не будет изменять конфигурацию DHCP"
msgid "Proxy download of lists"
msgstr "Загрузка списков через прокси"
msgid "Downloading all lists via main Proxy/VPN"
msgstr "Загрузка всех списков через основной прокси/VPN"
msgid "IP for exclusion"
msgstr "IP для исключения"
msgid "Specify local IP addresses that will never use the configured route"
msgstr "Укажите локальные IP-адреса, которые никогда не будут использовать настроенный маршрут"
msgid "Mixed enable"
msgstr "Включить смешанный режим"
msgid "Browser port: 2080"
msgstr "Порт браузера: 2080"

View File

@@ -1,4 +1,5 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
SRC_DIR="htdocs/luci-static/resources/view/podkop"
OUT_POT="po/templates/podkop.pot"
@@ -11,6 +12,7 @@ if [ ${#FILES[@]} -eq 0 ]; then
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"

View File

@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=podkop
PKG_VERSION := $(if $(PKG_VERSION),$(PKG_VERSION),dev_$(shell date +%d%m%Y))
PKG_VERSION := $(if $(PODKOP_VERSION),$(PODKOP_VERSION),0.$(shell date +%d%m%Y))
PKG_RELEASE:=1
@@ -48,7 +48,6 @@ endef
define Package/podkop/install
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/podkop $(1)/etc/init.d/podkop
sed -i "s/VERSION_FROM_MAKEFILE/$(PKG_VERSION)/g" $(1)/etc/init.d/podkop
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/podkop $(1)/etc/config/podkop
@@ -58,6 +57,8 @@ define Package/podkop/install
$(INSTALL_DIR) $(1)/usr/lib/podkop
$(CP) ./files/usr/lib/* $(1)/usr/lib/podkop/
sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PKG_VERSION)/g' $(1)/usr/lib/podkop/constants.sh
endef
$(eval $(call BuildPackage,podkop))

View File

@@ -1784,13 +1784,7 @@ show_config() {
}
show_version() {
local version=$(opkg list-installed podkop | awk '{print $3}')
echo "$version"
}
show_luci_version() {
local version=$(opkg list-installed luci-app-podkop | awk '{print $3}')
echo "$version"
echo "$PODKOP_VERSION"
}
show_sing_box_version() {
@@ -1967,11 +1961,14 @@ find_working_resolver() {
}
global_check() {
local PODKOP_LUCI_VERSION="Unknown"
[ -n "$1" ] && PODKOP_LUCI_VERSION="$1"
print_global "📡 Global check run!"
print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
print_global "🛠️ System info"
print_global "🕳️ Podkop: $(opkg list-installed podkop | awk '{print $3}')"
print_global "🕳️ LuCI App: $(opkg list-installed luci-app-podkop | awk '{print $3}')"
print_global "🕳️ Podkop: ${PODKOP_VERSION}"
print_global "🕳️ LuCI App: ${PODKOP_LUCI_VERSION}"
print_global "📦 Sing-box: $(sing-box version | head -n 1 | awk '{print $3}')"
print_global "🛜 OpenWrt: $(grep OPENWRT_RELEASE /etc/os-release | cut -d'"' -f2)"
print_global "🛜 Device: $(cat /tmp/sysinfo/model)"
@@ -2133,7 +2130,6 @@ Available commands:
show_config Display current podkop configuration
show_version Show podkop version
show_sing_box_config Show sing-box configuration
show_luci_version Show LuCI app version
show_sing_box_version Show sing-box version
show_system_info Show system information
get_status Get podkop service status
@@ -2192,9 +2188,6 @@ show_version)
show_sing_box_config)
show_sing_box_config
;;
show_luci_version)
show_luci_version
;;
show_sing_box_version)
show_sing_box_version
;;
@@ -2211,10 +2204,10 @@ check_dns_available)
check_dns_available
;;
global_check)
global_check
global_check "${2:-}"
;;
*)
show_help
exit 1
;;
esac
esac

View File

@@ -1,5 +1,6 @@
# shellcheck disable=SC2034
PODKOP_VERSION="__COMPILED_VERSION_VARIABLE__"
## Common
PODKOP_CONFIG="/etc/config/podkop"
RESOLV_CONF="/etc/resolv.conf"

7
sdk/Dockerfile-sdk-apk Normal file
View File

@@ -0,0 +1,7 @@
FROM openwrt/sdk:x86-64-SNAPSHOT
WORKDIR /builder
RUN ./setup.sh \
&& ./scripts/feeds update -a \
&& ./scripts/feeds install luci-base \
&& mkdir -p /builder/package/feeds/utilities/ \
&& mkdir -p /builder/package/feeds/luci/

6
sdk/Dockerfile-sdk-ipk Normal file
View File

@@ -0,0 +1,6 @@
FROM openwrt/sdk:x86_64-v24.10.3
WORKDIR /builder
RUN ./scripts/feeds update -a \
&& ./scripts/feeds install luci-base \
&& mkdir -p /builder/package/feeds/utilities/ \
&& mkdir -p /builder/package/feeds/luci/