import { describe, expect, test } from "bun:test"; import { checkPublicEndpoint, getLocalPublicIpv4s, isRoutablePublicIpv4, type EndpointProbe } from "../src/steps/networkEndpoint"; import { assertPublicEndpoint } from "../src/steps/preflight"; import { baselineConfig } from "./fixtures"; /** * Проба полностью подменяет и DNS, и список локальных адресов: тест не имеет * права зависеть ни от сети, ни от интерфейсов машины разработчика. */ function probe(options: { local: string[]; dns?: Record }): EndpointProbe { return { async resolve4(hostname) { const records = options.dns?.[hostname]; if (records === undefined || records === "ENODATA") { const error = new Error(`queryA ENODATA ${hostname}`) as Error & { code?: string }; error.code = "ENODATA"; throw error; } return records; }, getLocalPublicIpv4s: () => options.local }; } const CURRENT = "185.10.20.27"; const STALE = "185.10.20.10"; const SECOND = "185.10.20.28"; describe("классификация IPv4", () => { test("публичные адреса распознаются", () => { for (const ip of ["1.1.1.1", "8.8.8.8", "185.10.20.27", "203.0.113.5"]) { expect(isRoutablePublicIpv4(ip)).toBe(true); } }); test("непубличные диапазоны исключаются", () => { for (const ip of [ "0.0.0.0", "10.1.2.3", "100.64.0.1", "127.0.0.1", "169.254.1.1", "172.16.0.1", "172.31.255.254", "192.168.1.1", "224.0.0.1", "240.0.0.1", "255.255.255.255" ]) { expect(isRoutablePublicIpv4(ip), `${ip} должен быть исключён`).toBe(false); } }); test("172.32.0.0 уже вне приватного диапазона", () => { expect(isRoutablePublicIpv4("172.32.0.1")).toBe(true); expect(isRoutablePublicIpv4("172.15.255.255")).toBe(true); }); test("не-IPv4 не проходит", () => { for (const value of ["", "vpn.example.com", "2001:db8::1", "1.2.3", "1.2.3.256"]) { expect(isRoutablePublicIpv4(value)).toBe(false); } }); test("перечисление локальных адресов не падает на реальной машине", () => { const local = getLocalPublicIpv4s(); expect(Array.isArray(local)).toBe(true); for (const ip of local) { expect(isRoutablePublicIpv4(ip)).toBe(true); } }); }); describe("инвариант публичного endpoint", () => { const config = baselineConfig(); test("A-запись совпадает с текущим публичным IPv4 — PASS", async () => { const problems = await checkPublicEndpoint( config, probe({ local: [CURRENT], dns: { "vpn.example.com": [CURRENT] } }) ); expect(problems).toHaveLength(0); }); // Основной сценарий: провайдер принудительно сменил IP, DNS не обновили. test("A-запись указывает на старый IPv4 — FAIL с обоими адресами в тексте", async () => { const problems = await checkPublicEndpoint( config, probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } }) ); expect(problems).toHaveLength(1); expect(problems[0].kind).toBe("mismatch"); expect(problems[0].message).toContain(STALE); expect(problems[0].message).toContain(CURRENT); expect(problems[0].message).toContain("Update the DNS A record"); }); test("A-запись отсутствует — FAIL", async () => { const problems = await checkPublicEndpoint(config, probe({ local: [CURRENT], dns: {} })); expect(problems).toHaveLength(1); expect(problems[0].kind).toBe("resolution"); expect(problems[0].message).toContain("has no A-record"); }); // Единственный правильный адрес рядом с чужим — это второй backend за тем // же именем. Для single-host профиля это ошибка, а не балансировка. test("A = текущий + чужой — FAIL", async () => { const problems = await checkPublicEndpoint( config, probe({ local: [CURRENT], dns: { "vpn.example.com": [CURRENT, "1.1.1.1"] } }) ); expect(problems).toHaveLength(1); expect(problems[0].kind).toBe("mismatch"); }); test("у сервера два публичных IP, DNS использует один — PASS", async () => { const problems = await checkPublicEndpoint( config, probe({ local: [CURRENT, SECOND], dns: { "vpn.example.com": [CURRENT] } }) ); expect(problems).toHaveLength(0); }); test("PUBLIC_HOST как правильный IPv4-литерал — PASS", async () => { const literal = baselineConfig({ HY2XS_PUBLIC_HOST: CURRENT, HY2XS_DOMAIN: "vpn.example.com" }); const problems = await checkPublicEndpoint( literal, probe({ local: [CURRENT], dns: { "vpn.example.com": [CURRENT] } }) ); expect(problems).toHaveLength(0); }); test("PUBLIC_HOST как устаревший IPv4-литерал — FAIL", async () => { const literal = baselineConfig({ HY2XS_PUBLIC_HOST: STALE, HY2XS_DOMAIN: "vpn.example.com" }); const problems = await checkPublicEndpoint( literal, probe({ local: [CURRENT], dns: { "vpn.example.com": [CURRENT] } }) ); expect(problems).toHaveLength(1); expect(problems[0].message).toContain(STALE); }); /** * Ключевой пробел исходной проверки: проверялся HY2XS_DOMAIN, а в * hysteria2:// уходит HY2XS_PUBLIC_HOST. */ test("DOMAIN в порядке, но отдельный PUBLIC_HOST устарел — FAIL", async () => { const split = baselineConfig({ HY2XS_DOMAIN: "tls.example.com", HY2XS_PUBLIC_HOST: "vpn.example.com" }); const problems = await checkPublicEndpoint( split, probe({ local: [CURRENT], dns: { "tls.example.com": [CURRENT], "vpn.example.com": [STALE] } }) ); expect(problems).toHaveLength(1); expect(problems[0].message).toContain("HY2XS_PUBLIC_HOST"); }); test("PUBLIC_HOST в порядке, но отдельный TLS-домен устарел — FAIL", async () => { const split = baselineConfig({ HY2XS_DOMAIN: "tls.example.com", HY2XS_PUBLIC_HOST: "vpn.example.com" }); const problems = await checkPublicEndpoint( split, probe({ local: [CURRENT], dns: { "tls.example.com": [STALE], "vpn.example.com": [CURRENT] } }) ); expect(problems).toHaveLength(1); expect(problems[0].message).toContain("HY2XS_DOMAIN"); }); test("совпадающие DOMAIN и PUBLIC_HOST проверяются один раз", async () => { const problems = await checkPublicEndpoint( config, probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } }) ); expect(problems).toHaveLength(1); }); test("на сервере нет ни одного публичного IPv4 — FAIL", async () => { const problems = await checkPublicEndpoint( config, probe({ local: [], dns: { "vpn.example.com": [CURRENT] } }) ); expect(problems).toHaveLength(1); expect(problems[0].kind).toBe("no_local_address"); }); // Регрессия: пока резолв выполнялся только после проверки локального // множества, сервер за NAT скрывал отсутствие A-записи. test("отсутствие A-записи видно даже без локального публичного IPv4", async () => { const problems = await checkPublicEndpoint(config, probe({ local: [], dns: {} })); expect(problems.map((problem) => problem.kind)).toEqual(["resolution", "no_local_address"]); }); test("без локального множества не возникает каскада mismatch по каждому имени", async () => { const split = baselineConfig({ HY2XS_DOMAIN: "tls.example.com", HY2XS_PUBLIC_HOST: "vpn.example.com" }); const problems = await checkPublicEndpoint( split, probe({ local: [], dns: { "tls.example.com": [CURRENT], "vpn.example.com": [CURRENT] } }) ); expect(problems).toHaveLength(1); expect(problems[0].kind).toBe("no_local_address"); }); }); describe("HY2XS_PUBLIC_ENDPOINT_POLICY", () => { test("strict — расхождение останавливает операцию", async () => { const strict = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "strict" }); await expect( assertPublicEndpoint(strict, probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } })) ).rejects.toThrow(/DNS IPv4 mismatch/); }); test("strict — значение по умолчанию", async () => { const implicit = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: null }); expect(implicit.publicEndpointPolicy).toBe("strict"); await expect( assertPublicEndpoint(implicit, probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } })) ).rejects.toThrow(/DNS IPv4 mismatch/); }); test("warn — расхождение пропускается", async () => { const warn = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "warn" }); await expect( assertPublicEndpoint(warn, probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } })) ).resolves.toBeUndefined(); }); test("off — сравнение не выполняется", async () => { const off = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "off" }); await expect( assertPublicEndpoint(off, probe({ local: [], dns: { "vpn.example.com": [STALE] } })) ).resolves.toBeUndefined(); await expect( assertPublicEndpoint(off, probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } })) ).resolves.toBeUndefined(); }); // Ослаблять нечего: имя без A-записи не работает ни в какой топологии. test("отсутствие A-записи фатально при любой политике", async () => { for (const policy of ["strict", "warn", "off"]) { const config = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: policy }); await expect( assertPublicEndpoint(config, probe({ local: [CURRENT], dns: {} })) ).rejects.toThrow(/has no A-record/); } }); test("неизвестное значение политики отклоняется", () => { expect(() => baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "maybe" })).toThrow( /invalid HY2XS_PUBLIC_ENDPOINT_POLICY/ ); }); });