Files
HY2XS_flamy/orchestrator/test/network-endpoint.test.ts
T
founder a88268b0cd fix(install): сделать границу «хост изменён» настоящим инвариантом
fatal_pre_apply мог означать «хост уже изменён». install-state.json пишется
сразу после успешного preflight, до установки пакетов, но классификация
отказа его не учитывала. Падение apt-get объявлялось как «на сервере ничего
не изменено»: откат и обработка состояния пропускались, а маркер оставался на
диске и ломал следующую установку по clean-host контракту.

Ownership-флаги переформулированы с «шаг успешно завершился» на «операция
могла начать менять систему» и взводятся перед мутирующим вызовом: apt-get
умеет изменить систему и упасть. fatal_pre_apply теперь недостижим ни при
одном взведённом флаге, включая stateWritten.

Read-only guard PHASE 0 можно было обойти. Guard стоял на writeText,
writeTextAtomic, runVisible, runHidden и runRawVisible, но не на универсальном
run, через который в коде проходили и наблюдение (ss, systemctl is-active), и
настоящие мутации (useradd, install -d, mkdir, cp -a, tar). Универсального
раннера больше нет: runReadOnly/runReadOnlySecret без guard'а и runMutating*
под guard'ом, выбор — явное решение на месте вызова.

clean-host не замечал часть того, что удаляет purge. /var/lib/hysteria с
ACME-состоянием Hysteria, /var/log/hy2xs, /usr/local/lib/hy2xs и
/usr/local/bin/hy2xs-orchestrator не были маркерами: сервер, где остался
только старый runtime-state Hysteria, проходил проверку и получал свежую
установку поверх чужого состояния. Пути, которые install.sh создаёт между
фазами, помечены как созданные установщиком, иначе PHASE 1 отказала бы на
собственном оркестраторе.

purge-v0.sh --keep-hysteria-binary противоречил установщику: скрипт сохранял
/usr/local/bin/hysteria и сообщал «хост чист для установки HY2XS v1», хотя
clean-host считает этот бинарник legacy-маркером. Флаг удалён.

DNS проверялся на существование A-записи, но не на то, куда она ведёт. После
принудительной смены IPv4 провайдером doctor отвечал успехом, хотя клиентская
ссылка отправляла людей на чужую машину. Проверялся при этом HY2XS_DOMAIN,
тогда как в hysteria2:// уезжает HY2XS_PUBLIC_HOST.

Добавлен инвариант публичного endpoint: A-записи обязаны принадлежать
множеству публичных IPv4, назначенных интерфейсам этого сервера. Проверка
живёт в общем preflight, поэтому действует в install, reconfigure и doctor.
Адрес определяется локально, без внешних сервисов определения IP. Строгость
управляется HY2XS_PUBLIC_ENDPOINT_POLICY (strict по умолчанию); отсутствие
A-записи фатально при любом значении.

TS-санитайзер приведён к той же формулировке, что и Go: URL-значение
определяется по самому значению, а не по имени ключа.
2026-08-27 20:49:38 +05:00

271 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string[] | "ENODATA"> }): 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/
);
});
});