import { describe, expect, test } from "bun:test"; import { checkPublicEndpoint, getLocalPublicIpv4s, isRoutablePublicIpv4, nonPublicIpv4Range, type EndpointProbe } from "../src/steps/networkEndpoint"; import { assertPublicEndpoint } from "../src/steps/preflight"; import { baselineConfig } from "./fixtures"; /** Коды отказа резолвера, отличные от «записи нет». */ type DnsFailureCode = "ENODATA" | "ENOTFOUND" | "SERVFAIL" | "ETIMEOUT" | "ECONNREFUSED"; /** * Проба полностью подменяет и DNS, и список локальных адресов: тест не имеет * права зависеть ни от сети, ни от интерфейсов машины разработчика. */ function probe(options: { local: string[]; dns?: Record; }): EndpointProbe { return { async resolve4(hostname) { const records = options.dns?.[hostname]; if (records === undefined || typeof records === "string") { const code: DnsFailureCode = typeof records === "string" ? records : "ENODATA"; const error = new Error(`queryA ${code} ${hostname}`) as Error & { code?: string }; error.code = code; 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.114.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); } }); /** * Регрессия: функция называлась «маршрутизируемый публичный IPv4», а список * исключений покрывал только приватные диапазоны. TEST-NET-3 (203.0.113.0/24) * — адрес из RFC-примеров, который нельзя назначить серверу, — проходил * проверку как обычный публичный адрес. Список приведён к IANA * Special-Purpose Address Registry. */ test("документационные и служебные диапазоны IANA исключаются", () => { for (const ip of [ "192.0.0.1", // IETF protocol assignments "192.0.2.5", // TEST-NET-1 "192.88.99.1", // 6to4 relay anycast "198.18.0.1", // benchmarking "198.19.255.254", // benchmarking, верхняя граница /15 "198.51.100.5", // TEST-NET-2 "203.0.113.5" // TEST-NET-3 ]) { expect(isRoutablePublicIpv4(ip), `${ip} должен быть исключён`).toBe(false); } }); test("границы специальных диапазонов не задевают соседей", () => { for (const ip of ["192.0.1.1", "192.0.3.1", "198.17.255.255", "198.20.0.1", "203.0.112.255"]) { expect(isRoutablePublicIpv4(ip), `${ip} не должен быть исключён`).toBe(true); } }); test("причина исключения называется в диагностике", () => { expect(nonPublicIpv4Range("203.0.113.5")).toContain("TEST-NET-3"); expect(nonPublicIpv4Range("10.0.0.1")).toContain("private"); expect(nonPublicIpv4Range("1.1.1.1")).toBeNull(); expect(nonPublicIpv4Range("не адрес")).toBeNull(); }); 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/); } }); /** * Регрессия диагностики: любой отказ resolve4 печатался как * «has no A-record». При сломанном /etc/resolv.conf оператор шёл править * DNS-запись, которая на самом деле была на месте. */ test("отказ резолвера отличается от отсутствия записи", async () => { for (const code of ["SERVFAIL", "ETIMEOUT", "ECONNREFUSED"] as const) { const problems = await checkPublicEndpoint( baselineConfig(), probe({ local: [CURRENT], dns: { "vpn.example.com": code } }) ); expect(problems).toHaveLength(1); expect(problems[0].kind).toBe("resolver_failure"); expect(problems[0].message).toContain("DNS resolver failed"); expect(problems[0].message).toContain(code); expect(problems[0].message).toContain("/etc/resolv.conf"); expect(problems[0].message).not.toContain("has no A-record"); } }); test("NXDOMAIN и ENODATA остаются отсутствием записи", async () => { for (const code of ["ENODATA", "ENOTFOUND"] as const) { const problems = await checkPublicEndpoint( baselineConfig(), probe({ local: [CURRENT], dns: { "vpn.example.com": code } }) ); expect(problems).toHaveLength(1); expect(problems[0].kind).toBe("resolution"); expect(problems[0].message).toContain("has no A-record"); } }); // Ослаблять нечего и здесь: без ответа резолвера проверка не выполнена, // а не «выполнена с замечанием». test("отказ резолвера фатален при любой политике", async () => { for (const policy of ["strict", "warn", "off"]) { const config = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: policy }); await expect( assertPublicEndpoint(config, probe({ local: [CURRENT], dns: { "vpn.example.com": "SERVFAIL" } })) ).rejects.toThrow(/DNS resolver failed/); } }); test("IPv4-литерал из документационного диапазона отклоняется с указанием причины", async () => { const literal = baselineConfig({ HY2XS_PUBLIC_HOST: "203.0.113.5", HY2XS_DOMAIN: "vpn.example.com" }); const problems = await checkPublicEndpoint( literal, probe({ local: [CURRENT], dns: { "vpn.example.com": [CURRENT] } }) ); expect(problems.some((problem) => problem.message.includes("TEST-NET-3"))).toBe(true); }); test("неизвестное значение политики отклоняется", () => { expect(() => baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "maybe" })).toThrow( /invalid HY2XS_PUBLIC_ENDPOINT_POLICY/ ); }); });