Refactor VPN proxy client implementation
Build and Deploy Gateway / build-and-push (push) Failing after 2s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-09 00:41:52 +03:00
parent 32be4380a3
commit 34d8b681ad
190 changed files with 20064 additions and 9761 deletions
@@ -0,0 +1,275 @@
import { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { copyText } from '../../utils/clientControls.js';
import { instructionBlocks } from './instructionBlocks.js';
interface InstructionLinkStep {
before?: string;
link: [string, string];
after?: string;
}
interface InstructionCopyAction {
id: string;
label: string;
text: string;
}
interface InstructionBlockData {
id: string;
label: string;
title: string;
summary: string;
paragraphs?: string[];
steps?: Array<string | InstructionLinkStep>;
code?: string;
multilineCode?: boolean;
copies?: InstructionCopyAction[];
note?: string;
}
interface InstructionsFeatureOptions {
isGateway: boolean;
host: string;
port: number;
controlHost: string;
}
function InstructionStep({ step }: { step: string | InstructionLinkStep }) {
if (typeof step === 'string') return step;
return (
<>
{step.before}
<a href={step.link[1]} target="_blank" rel="noreferrer">{step.link[0]}</a>
{step.after}
</>
);
}
function InstructionBlock({
block,
open,
onToggle,
}: {
block: InstructionBlockData;
open: boolean;
onToggle: () => void;
}) {
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
}, []);
async function copyInstruction(action: InstructionCopyAction) {
if (copyTimer.current) clearTimeout(copyTimer.current);
try {
await copyText(action.text);
setCopyFeedback({ id: action.id, failed: false });
} catch {
setCopyFeedback({ id: action.id, failed: true });
}
copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
}
return (
<section
className={`client-instruction-block${open ? ' is-open' : ''}`}
style={{ viewTransitionName: `instruction-${block.id}` }}
>
<button
className="client-instruction-summary"
type="button"
aria-expanded={open}
onClick={onToggle}
>
<span>{block.label}</span>
<strong>{block.title}</strong>
<small>{block.summary}</small>
<i aria-hidden="true" />
</button>
<div className="client-instruction-reveal" aria-hidden={!open} inert={!open ? true : undefined}>
<div className="client-instruction-body">
{block.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
{block.steps && (
<ol>
{block.steps.map((step) => (
<li key={typeof step === 'string' ? step : step.link[1]}>
<InstructionStep step={step} />
</li>
))}
</ol>
)}
{block.code && (block.multilineCode
? <pre className="client-instruction-code"><code>{block.code}</code></pre>
: <code>{block.code}</code>)}
{block.copies && <div className="client-instruction-copies">
{block.copies.map((action) => {
const feedback = copyFeedback?.id === action.id ? copyFeedback : null;
return <div className="client-instruction-copy" key={action.id}>
<span>{action.label}</span>
<button
className={`client-copy-button client-instruction-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
onClick={() => copyInstruction(action)}
>
<span className="client-copy-label">Скопировать</span>
{feedback && <span className="client-copy-feedback" aria-hidden="true">
{feedback.failed ? 'Ошибка' : 'Скопировано'}
</span>}
</button>
</div>;
})}
<span className="client-live-region" role="status" aria-live="polite">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
</span>
</div>}
{block.note && <p className="client-instruction-note">{block.note}</p>}
</div>
</div>
</section>
);
}
export function useInstructionsFeature({
isGateway,
host,
port,
controlHost,
}: InstructionsFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [openInstructionId, setOpenInstructionId] = useState('');
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const [intro, ...guides] = instructionBlocks({ isGateway, host, port, controlHost }) as InstructionBlockData[];
const openInstruction = guides.find((block) => block.id === openInstructionId);
const orderedGuides = openInstruction
? [openInstruction, ...guides.filter((block) => block.id !== openInstructionId)]
: guides;
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false);
};
document.addEventListener('keydown', closeOnEscape);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', closeOnEscape);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return undefined;
const closeOutside = (event: PointerEvent) => {
if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.current?.contains(event.target as Node)) return;
setIsOpen(false);
};
document.addEventListener('pointerdown', closeOutside);
return () => document.removeEventListener('pointerdown', closeOutside);
}, [isOpen]);
function toggleInstruction(id: string) {
const update = () => flushSync(() => {
setOpenInstructionId((current) => current === id ? '' : id);
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
return {
isOpen,
openInstructionId,
intro,
guides: orderedGuides,
panelRef,
toggleRef,
closeRef,
close: () => setIsOpen(false),
toggle: () => setIsOpen((open) => !open),
toggleInstruction,
};
}
export type InstructionsFeature = ReturnType<typeof useInstructionsFeature>;
export function InstructionsToggle({
feature,
onToggle,
}: {
feature: InstructionsFeature;
onToggle: () => void;
}) {
return <button
ref={feature.toggleRef}
className={`client-instructions-toggle${feature.isOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={feature.isOpen}
aria-controls="client-instructions"
aria-label={feature.isOpen ? 'Закрыть инструкции' : 'Как использовать'}
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle className="client-rail-info-ring" cx="12" cy="12" r="8.5" pathLength="1" />
<path d="M12 11v5M12 8h.01" />
</svg>
<span>Как использовать</span>
</button>;
}
export function InstructionsPanel({
feature,
isGateway,
}: {
feature: InstructionsFeature;
isGateway: boolean;
}) {
return <aside
ref={feature.panelRef}
id="client-instructions"
className={`client-drawer client-instructions${feature.isOpen ? ' is-open' : ''}`}
aria-labelledby="instructions-title"
aria-hidden={!feature.isOpen}
inert={!feature.isOpen ? true : undefined}
>
<div className="client-drawer-sheet client-instructions-sheet">
<button
ref={feature.closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть инструкции"
onClick={feature.close}
>×</button>
<header className="client-instructions-header">
<span>Подключение</span>
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
<div className="client-instructions-intro">
{feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</div>
</header>
<div className="client-instruction-list">
{feature.guides.map((block) => (
<InstructionBlock
block={block}
key={block.id}
open={block.id === feature.openInstructionId}
onToggle={() => feature.toggleInstruction(block.id)}
/>
))}
</div>
</div>
</aside>;
}
+6
View File
@@ -0,0 +1,6 @@
export {
InstructionsPanel,
InstructionsToggle,
useInstructionsFeature,
type InstructionsFeature,
} from './InstructionsFeature.js';
@@ -0,0 +1,116 @@
import { grafanaDashboardJson, prometheusScrapeConfig } from './prometheus.js';
export function instructionBlocks({ isGateway, host, port, controlHost }: {
isGateway: boolean;
host: string;
port: number;
controlHost: string;
}) {
const httpProxy = `http://${host}:${port}`;
const socksProxy = `socks5://${host}:${port}`;
return [
{
id: 'about',
label: 'Основы',
title: isGateway ? 'Gateway и прокси' : 'Что такое прокси',
summary: isGateway
? 'Два способа направить трафик через это устройство.'
: 'Способ направить трафик выбранного приложения через VPN.',
paragraphs: isGateway
? [
`Gateway (${host}) заменяет основной шлюз устройства и проводит через VPN весь его интернет-трафик.`,
`Gateway Proxy (${host}:${port}) работает точечно: его указывают в браузере, редакторе или другом приложении. Если приложение не умеет работать с прокси, можно использовать ProxyBridge.`,
]
: [
`Локальный прокси (${host}:${port}) не перенаправляет приложения автоматически. Каждое приложение должно использовать этот адрес само — напрямую или через ProxyBridge.`,
'HTTP обычно проще для браузеров и редакторов. SOCKS5 подходит приложениям и инструментам, которым нужен более универсальный транспорт.',
],
},
{
id: 'proxybridge',
label: 'Приложения',
title: 'ProxyBridge',
summary: 'Направляет через прокси отдельные приложения, даже если у них нет своей настройки.',
steps: [
{
link: ['Установите ProxyBridge', 'https://interceptsuite.com/download/proxybridge'],
after: ' с официальной страницы проекта.',
},
`Добавьте прокси типа SOCKS5: сервер ${host}, порт ${port}.`,
'Создайте правило, выберите нужное приложение и действие Proxy.',
'Включите ProxyBridge и запустите приложение заново.',
],
note: 'Не добавляйте в правило сам ProxyBridge и VPN-клиент: это может создать прокси-цикл.',
},
{
id: 'switchyomega',
label: 'Браузер',
title: 'SwitchyOmega',
summary: 'Переключает прокси-профили только для браузера.',
steps: [
{
link: ['Установите расширение', 'https://chromewebstore.google.com/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif'],
after: ' и откройте его настройки.',
},
'Создайте профиль Proxy Profile.',
`Выберите HTTP, укажите сервер ${host} и порт ${port}.`,
'Создайте профиль Auto Switch, выберите созданный прокси для нужных сайтов, а для остальных оставьте Direct.',
'Если сайт не загрузился, откройте SwitchyOmega: расширение покажет проблемные ресурсы. Добавьте домен текущего сайта в Auto Switch и назначьте ему прокси-профиль.',
],
note: 'Проект больше не поддерживается. Используйте его только если расширение уже подходит вашему браузеру.',
},
{
id: 'vscode',
label: 'Редактор',
title: 'Visual Studio Code',
summary: 'VS Code использует системный прокси или адрес, переданный при запуске.',
steps: [
'Если прокси уже настроен в системе, полностью перезапустите VS Code — обычно он подхватит настройку автоматически.',
'Для отдельного запуска через SOCKS5 используйте команду ниже.',
{
link: ['Документация VS Code', 'https://code.visualstudio.com/docs/setup/network'],
after: ' описывает также системный прокси, HTTP и параметры исключений.',
},
],
code: `code --proxy-server="${socksProxy}"`,
note: `VS Code не поддерживает логин и пароль для SOCKS5. Здесь прокси ${host}:${port} локальный и без авторизации, поэтому этот вариант подходит. HTTP-адрес ${httpProxy} остаётся альтернативой.`,
},
...(isGateway ? [{
id: 'router',
label: 'Вся сеть',
title: 'Заменить Gateway в роутере',
summary: 'Роутер будет выдавать этот Gateway устройствам как основной шлюз.',
steps: [
`Закрепите за Gateway постоянный адрес ${host} в настройках DHCP роутера.`,
'Откройте настройки локальной сети или DHCP. Не меняйте шлюз WAN/интернет-подключения.',
`В поле Default Gateway, Router или Основной шлюз укажите ${host}.`,
'Сохраните настройки и переподключите устройства к сети, чтобы они получили новый маршрут.',
`Для отката верните в это поле локальный адрес самого роутера вместо ${host}.`,
],
note: 'Gateway и устройства должны находиться в одной локальной сети. Сначала проверьте настройку на одном устройстве вручную.',
}, {
id: 'prometheus',
label: 'Мониторинг',
title: 'Prometheus и Grafana',
summary: 'Готовые traffic и domain metrics для Gateway и отдельных устройств.',
paragraphs: [
`Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`,
'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.',
'Единый фильтр «Устройства» управляет скоростью, накопленным трафиком, сервисами и доменами для всех или одного устройства. Отдельный график показывает текущую скорость каждого активного устройства; нулевые series скрыты.',
],
steps: [
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
'В Grafana добавьте этот Prometheus как data source.',
'Скопируйте dashboard JSON, откройте Dashboards → New → Import и вставьте его.',
],
code: prometheusScrapeConfig(controlHost),
multilineCode: true,
copies: [
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson },
],
note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками, устройства с policy Direct и трафик без распознанного домена в domain series не входят.',
}] : []),
];
}
@@ -0,0 +1,13 @@
import dashboard from '../../../../monitoring/grafana/harbor-gateway.json' with { type: 'json' };
export const grafanaDashboardJson = JSON.stringify(dashboard, null, 2);
export function prometheusScrapeConfig(controlHost: string) {
return `scrape_configs:
- job_name: harbor_gateway
scrape_interval: 30s
scrape_timeout: 3s
metrics_path: /metrics
static_configs:
- targets: ["${controlHost}"]`;
}