import type { Hysteria2ServerConfig, Hysteria2ServerConfigOutbound, } from "./types"; /** * Нормализация конфига Hysteria на границе API. * * Зачем этот файл существует. * * `Hysteria2ServerConfig` описывает то, что РЕАЛЬНО приходит по сети, и почти * все его секции необязательны — потому что необязательны они и в upstream * YAML. Панель при этом показывает их как обычную форму: `dataForm.tls.cert`, * `dataForm.acme.dns.config`, `dataForm.resolver.https.sni`. * * Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно. * Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` в двух файлах — и он * прав: обращение через возможно отсутствующий объект в рантайме падает. * Спасало только то, что форма строится merge'ем поверх полного объекта * значений по умолчанию, то есть инвариант «секция есть всегда» существовал, * но держался на порядке присваиваний внутри компонента и нигде не был * выражен типом. * * Два способа это закрыть неверны: * * `?.` в 141 месте шаблона — прячет вопрос «а что показывать, если секции * нет», не отвечая на него, и делает шаблон нечитаемым; * * `as any` — выключает ровно ту проверку, ради которой обновлялся * typechecker. * * Здесь выбран третий: одно преобразование на входе. Ответ приходит в * `Hysteria2ServerConfig` (как есть, с необязательными секциями), а форма * работает с `Hysteria2ServerConfigView`, где присутствие каждой секции — * свойство типа. Шаблону больше не нужно знать ни одного нюанса * необязательности upstream-схемы. * * Важно, чего этот слой НЕ делает: он не участвует в экспорте. Выгрузка * серверного конфига идёт на backend от исходного YAML и сохраняет поля, о * которых HY2XS ещё не знает (см. docs/04). View-модель — только для * отображения, поэтому потеря неизвестных полей здесь безвредна. */ /** * DeepRequired делает обязательными все поля на всех уровнях. * * Массивы обрабатываются отдельно: без этой ветки `T[]` попал бы в `object` и * маппинг прошёлся бы по свойствам самого массива. */ type DeepRequired = T extends (infer U)[] ? DeepRequired[] : T extends object ? { [K in keyof T]-?: DeepRequired> } : T; /** Конфиг Hysteria в том виде, в котором его показывает панель. */ export type Hysteria2ServerConfigView = DeepRequired; /** Один outbound в том же виде. */ export type Hysteria2ServerConfigOutboundView = DeepRequired; /** * Полное значение по умолчанию: каждая секция заполнена. * * Тип здесь не декоративный. `Hysteria2ServerConfigView` требует все поля, и * добавление секции в `Hysteria2ServerConfig` сломает компиляцию ровно здесь — * то есть новое поле upstream нельзя молча не отобразить. */ export const defaultHysteria2ServerConfigView: Hysteria2ServerConfigView = { listen: ":443", tls: { cert: "", key: "", sniGuard: "", clientCA: "", }, ech: { keyPath: "", }, acme: { domains: [], email: "", ca: "letsencrypt", listenHost: "0.0.0.0", dir: "/var/lib/hysteria/acme", type: "", http: { altPort: 8888, }, tls: { altPort: 44333, }, dns: { name: "cloudflare", config: {}, }, disableHTTP: false, disableTLSALPN: false, altHTTPPort: 80, altTLSALPNPort: 443, }, obfs: { type: "gecko", salamander: { password: "", }, gecko: { password: "", minPacketSize: 512, maxPacketSize: 1200, }, }, quic: { initStreamReceiveWindow: 8388608, maxStreamReceiveWindow: 8388608, initConnReceiveWindow: 20971520, maxConnReceiveWindow: 20971520, maxIdleTimeout: "30s", maxIncomingStreams: 1024, disablePathMTUDiscovery: false, disableStatelessReset: false, }, bandwidth: { up: "50 mbps", down: "50 mbps", disableLossCompensation: false, }, congestion: { type: "bbr", bbrProfile: "standard", }, ignoreClientBandwidth: false, speedTest: false, disableUDP: false, udpIdleTimeout: "60s", resolver: { type: "", tcp: { addr: "8.8.8.8:53", timeout: "4s", }, udp: { addr: "8.8.4.4:53", timeout: "4s", }, tls: { addr: "1.1.1.1:853", timeout: "10s", sni: "cloudflare-dns.com", insecure: false, }, https: { addr: "1.1.1.1:443", timeout: "10s", sni: "cloudflare-dns.com", insecure: false, }, }, sniff: { enable: true, timeout: "2s", rewriteDomain: false, tcpPorts: "80,443,8000-9000", udpPorts: "all", }, acl: { file: "", inline: [], geoip: "", geosite: "", geoUpdateInterval: "168h", }, outbounds: [], trafficStats: { listen: ":9999", }, masquerade: { type: "", file: { dir: "", }, proxy: { url: "", rewriteHost: true, insecure: false, xForwarded: false, }, string: { content: "hello stupid world", headers: {}, statusCode: 200, }, listenHTTP: ":80", listenHTTPS: ":443", forceHTTPS: true, }, mimic: { enabled: false, interface: "", xdpMode: "", path: "", extraArgs: [], }, realm: { stunServers: [], stunTimeout: "", punchTimeout: "", heartbeatInterval: "", insecure: false, ipMode: "", portMapping: { enabled: false, timeout: "", lifetime: "", }, }, }; function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Рекурсивное наложение ответа сервера на значение по умолчанию. * * `null` и `undefined` игнорируются намеренно: в YAML отсутствующая секция и * секция со значением `null` означают одно и то же — «не задано», — и обе * обязаны оставить значение по умолчанию, а не обнулить поле формы. * * Массивы заменяются целиком, а не сливаются поэлементно: список ACL-правил * или outbounds с сервера — это весь список, а не патч к дефолтному. */ function mergeInto(target: Record, source: unknown): void { if (!isPlainObject(source)) { return; } for (const [key, value] of Object.entries(source)) { if (value === null || value === undefined) { continue; } if (Array.isArray(value)) { target[key] = value; continue; } if (isPlainObject(value)) { const existing = target[key]; if (!isPlainObject(existing)) { target[key] = {}; } mergeInto(target[key] as Record, value); continue; } target[key] = value; } } function cloneDefaults(): Hysteria2ServerConfigView { // structuredClone есть во всех целевых браузерах и, в отличие от // JSON.parse(JSON.stringify(...)), не тратит проход на сериализацию. return structuredClone(defaultHysteria2ServerConfigView); } /** * Приводит ответ сервера к модели, с которой работает форма. * * Пустой или отсутствующий ответ даёт полное значение по умолчанию: это то же * состояние, в котором форма находится до первого запроса. */ export function normalizeHysteriaViewModel( raw: Hysteria2ServerConfig | null | undefined ): Hysteria2ServerConfigView { const view = cloneDefaults(); mergeInto(view as unknown as Record, raw); return view; } /** Значение по умолчанию для одного outbound. */ export const defaultHysteria2ServerConfigOutboundView: Hysteria2ServerConfigOutboundView = { name: "", type: "socks5", socks5: { addr: "", username: "", password: "", }, http: { url: "", insecure: false, }, direct: { mode: "auto", bindIPv4: "", bindIPv6: "", bindDevice: "", fastOpen: false, }, }; /** * Тот же приём для одного outbound: список приходит с необязательными * подблоками, а карточка показывает их как обычные поля. */ export function normalizeOutboundViewModel( raw: Hysteria2ServerConfigOutbound | null | undefined ): Hysteria2ServerConfigOutboundView { const view = structuredClone(defaultHysteria2ServerConfigOutboundView); mergeInto(view as unknown as Record, raw); return view; }