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
+584
View File
@@ -0,0 +1,584 @@
import {
useEffect,
useRef,
useState,
type FormEvent,
type ReactNode,
type RefObject,
} from 'react';
import { flushSync } from 'react-dom';
import { canAppendRouteRule } from '../../../shared/routingRules.js';
import type { RouteRule } from '../../../shared/contracts/state.js';
import { operationBlocked } from '../../state/operations.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
['domain', 'Точный домен'],
['domain_suffix', 'Суффикс'],
['domain_keyword', 'Содержит'],
];
const ROUTE_RULE_PLACEHOLDERS: Record<RouteRule['type'], string> = {
domain: 'example.com или полный URL',
domain_suffix: 'example.org',
domain_keyword: 'cdn',
};
interface DraftRule extends RouteRule {
_key: string;
removing?: boolean;
}
interface RoutingState {
localRules?: RouteRule[];
activeLocalRules?: RouteRule[];
localRulesRevision?: number;
localRulesPendingRestart?: boolean;
}
interface RoutingFeatureOptions {
route?: RoutingState | null;
connected: boolean;
operations: Record<string, { status?: string } | undefined>;
onSave: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
onDismissError: () => void;
}
interface RoutingSaveState {
localRulesRevision: number;
localRulesPendingRestart: boolean;
}
let localRuleDraftId = 0;
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
...rule,
enabled: rule?.enabled !== false,
_key: `route-rule-${localRuleDraftId += 1}`,
});
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled })),
);
const localRuleKey = ({ type, value, enabled }: RouteRule) => (
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}`
);
function localRuleStatus(
rule: DraftRule,
savedRules: RouteRule[],
activeRules: RouteRule[],
runtimeActive: boolean,
) {
const key = localRuleKey(rule);
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено'];
if (!runtimeActive) return ['saved', 'Сохранено'];
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
return ['pending', 'Ждёт перезапуска'];
}
function routingSaveState(result: unknown): RoutingSaveState | null {
if (!result) return null;
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const state = (result as Record<string, unknown>).state;
if (!state || typeof state !== 'object' || Array.isArray(state)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const route = (state as Record<string, unknown>).route;
if (!route || typeof route !== 'object' || Array.isArray(route)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const { localRulesRevision, localRulesPendingRestart } = route as Record<string, unknown>;
if (
!Number.isSafeInteger(localRulesRevision)
|| (localRulesRevision as number) < 0
|| typeof localRulesPendingRestart !== 'boolean'
) {
throw new TypeError('Harbor route mutation returned invalid state');
}
return { localRulesRevision: localRulesRevision as number, localRulesPendingRestart };
}
export function useRoutingFeature({
route,
connected,
operations,
onSave,
onDismissError,
}: RoutingFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [rules, setRules] = useState<DraftRule[]>([]);
const [revision, setRevision] = useState(route?.localRulesRevision || 0);
const [confirmingClose, setConfirmingClose] = useState(false);
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const baselineRef = useRef('[]');
const savedRules = route?.localRules || [];
const activeRules = route?.activeLocalRules || [];
const dirty = localRulesSignature(rules) !== baselineRef.current;
const pendingRestart = connected && route?.localRulesPendingRestart === true;
const pendingCount = pendingRestart
? savedRules.filter((rule) => (
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
: 0;
const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
return () => {
cancelAnimationFrame(frame);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return undefined;
const closeRouting = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown') {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return;
} else {
if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.current?.contains(event.target as Node)) return;
}
requestClose();
};
document.addEventListener('pointerdown', closeRouting);
document.addEventListener('keydown', closeRouting);
return () => {
document.removeEventListener('pointerdown', closeRouting);
document.removeEventListener('keydown', closeRouting);
};
}, [isOpen, dirty]);
useEffect(() => {
if (!isOpen || !dirty) return undefined;
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [isOpen, dirty]);
function open() {
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled })));
setRules(savedRules.map(createLocalRuleDraft));
setRevision(route?.localRulesRevision || 0);
setConfirmingClose(false);
onDismissError();
setIsOpen(true);
}
function forceClose() {
setIsOpen(false);
}
function requestClose() {
if (dirty) {
setConfirmingClose(true);
return false;
}
setIsOpen(false);
return true;
}
function discard() {
setConfirmingClose(false);
setIsOpen(false);
}
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) {
setRules((current) => current.map((rule, ruleIndex) => (
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
)));
}
function add() {
setRules((current) => [
...current,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
]);
}
function remove(ruleKey: string) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
return;
}
setRules((current) => current.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule
)));
}
function finishRemove(ruleKey: string) {
const update = () => flushSync(() => {
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const values = localRuleValues(rules);
const result = routingSaveState(await onSave(values, revision));
if (!result) return;
baselineRef.current = JSON.stringify(values);
setRevision(result.localRulesRevision);
setConfirmingClose(false);
if (!connected || !result.localRulesPendingRestart) setIsOpen(false);
}
return {
isOpen,
rules,
savedRules,
activeRules,
connected,
dirty,
pendingRestart,
pendingCount,
blocked,
confirmingClose,
panelRef,
toggleRef,
closeRef,
open,
forceClose,
requestClose,
setConfirmingClose,
discard,
change,
add,
remove,
finishRemove,
save,
};
}
type RoutingFeature = ReturnType<typeof useRoutingFeature>;
interface RuleTypePickerProps {
value: RouteRule['type'];
ruleKey: string;
index: number;
disabled?: boolean;
onChange: (value: RouteRule['type']) => void;
}
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypePickerProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const listId = `${ruleKey}-types`;
const selectedIndex = Math.max(0, ROUTE_RULE_OPTIONS.findIndex(([type]) => type === value));
useEffect(() => {
if (!open) return undefined;
optionRefs.current[selectedIndex]?.focus();
const close = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (rootRef.current?.contains(event.target as Node)) return;
setOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', close);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', close);
};
}, [open, selectedIndex]);
function choose(type: RouteRule['type']) {
onChange(type);
setOpen(false);
triggerRef.current?.focus();
}
function moveOption(event: React.KeyboardEvent<HTMLButtonElement>, offset: number) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'].includes(event.key)) return;
event.preventDefault();
if (event.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
return;
}
const current = optionRefs.current.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === 'Home'
? 0
: event.key === 'End'
? ROUTE_RULE_OPTIONS.length - 1
: (current + offset + ROUTE_RULE_OPTIONS.length) % ROUTE_RULE_OPTIONS.length;
optionRefs.current[next]?.focus();
}
return (
<div className={`client-rule-type${open ? ' is-open' : ''}`} ref={rootRef}>
<button
ref={triggerRef}
className="client-rule-type-trigger"
type="button"
aria-label={`Тип правила ${index + 1}`}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === 'Escape' && open) {
event.preventDefault();
setOpen(false);
return;
}
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
event.preventDefault();
setOpen(true);
}}
>
<span>{ROUTE_RULE_OPTIONS[selectedIndex][1]}</span>
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
</button>
<div className="client-rule-type-list" id={listId} role="listbox" aria-hidden={!open}>
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => (
<button
ref={(node) => { optionRefs.current[optionIndex] = node; }}
type="button"
role="option"
aria-selected={type === value}
tabIndex={open ? 0 : -1}
key={type}
onClick={() => choose(type)}
onKeyDown={(event) => moveOption(event, event.key === 'ArrowUp' ? -1 : 1)}
>
{label}
</button>
))}
</div>
</div>
);
}
export function RoutingToggle({
feature,
gatewayDirect,
isGateway,
hasSubscription,
onOpen,
}: {
feature: RoutingFeature;
gatewayDirect: boolean;
isGateway: boolean;
hasSubscription: boolean;
onOpen: () => void;
}) {
const disabled = gatewayDirect || (isGateway && !hasSubscription);
return (
<button
ref={feature.toggleRef}
className={`client-local-rules-toggle${feature.isOpen ? ' is-open' : ''}${feature.pendingRestart ? ' has-pending' : ''}`}
type="button"
disabled={disabled}
aria-expanded={feature.isOpen}
aria-controls="client-local-rules"
aria-label={disabled
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
: 'Локальные правила недоступны: сначала добавьте подписку'
: feature.isOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
onClick={() => feature.isOpen ? feature.requestClose() : onOpen()}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
</svg>
<span>{disabled
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Gateway'
: 'Сначала добавьте подписку'
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
</button>
);
}
export function RoutingPendingStatus({
feature,
blocked,
onRestart,
}: {
feature: RoutingFeature;
blocked: boolean;
onRestart: () => unknown;
}) {
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
{feature.pendingCount > 0 && (
<>
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
</>
)}
</div>;
}
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
const draftRules = feature.rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(draftRules) && !feature.blocked;
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
return (
<aside
ref={feature.panelRef as RefObject<HTMLElement>}
id="client-local-rules"
className={`client-drawer client-local-rules${feature.isOpen ? ' is-open' : ''}`}
aria-labelledby="local-rules-title"
aria-hidden={!feature.isOpen}
inert={!feature.isOpen ? true : undefined}
>
<div className="client-drawer-sheet client-local-rules-sheet">
<button
ref={feature.closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть локальные правила"
onClick={feature.requestClose}
>×</button>
<header className="client-local-rules-header">
<span>Маршрутизация</span>
<button
className="client-local-rules-save"
type="submit"
form="client-local-rules-form"
disabled={feature.blocked || !feature.dirty}
>
Сохранить
</button>
<h2 id="local-rules-title">Локальные правила</h2>
<p>Эти домены идут напрямую. Остальной трафик через выбранный VPN.</p>
{feature.pendingRestart && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
</p>
)}
</header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
<span id="local-rules-list-title">Правила</span>
<div className="client-local-rules-list">
{feature.rules.map((rule, index) => {
const [status, statusLabel] = localRuleStatus(
rule,
feature.savedRules,
feature.activeRules,
feature.connected,
);
return (
<div
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
key={rule._key}
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
inert={rule.removing ? true : undefined}
>
<button
className="client-local-rule-enabled"
type="button"
role="switch"
aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
onClick={() => feature.change(index, 'enabled', !rule.enabled)}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="6" />
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" />
</svg>
</button>
<RuleTypePicker
value={rule.type}
ruleKey={rule._key}
index={index}
disabled={rule.removing}
onChange={(type) => feature.change(index, 'type', type)}
/>
<input
type="text"
inputMode="url"
autoComplete="off"
spellCheck={false}
required
aria-label={`Значение правила ${index + 1}`}
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
value={rule.value}
onChange={(event) => feature.change(index, 'value', event.target.value)}
/>
<span className="client-local-rule-status" role="status">{statusLabel}</span>
<button
className="client-local-rule-delete"
type="button"
aria-label={`Удалить правило ${index + 1}`}
onClick={() => feature.remove(rule._key)}
>
×
</button>
<span
className="client-delete-strike"
aria-hidden="true"
onAnimationEnd={() => feature.finishRemove(rule._key)}
/>
</div>
);
})}
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
</div>
<div className="client-local-rule-add-slot">
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={feature.add}>
+ Добавить правило
</button>
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
</div>
</section>
<p className="client-local-rules-note">
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
</p>
{statusSlot}
<div className="client-local-rules-actions">
<button type="button" onClick={feature.requestClose}>Отмена</button>
</div>
</form>
</div>
</aside>
);
}
export function RoutingDiscardDialog({ feature }: { feature: RoutingFeature }) {
return <ConfirmationDialog
open={feature.confirmingClose}
id="discard-local-rules"
title="Есть несохранённые настройки"
description="Закрыть редактор и потерять изменения?"
cancelLabel="Остаться"
confirmLabel="Закрыть без сохранения"
onCancel={() => feature.setConfirmingClose(false)}
onConfirm={feature.discard}
/>;
}