Files
HY2XS_flamy/orchestrator/test/network-endpoint.test.ts
founder 672d455467 fix: закрыть каналы утечки секретов и сделать PHASE 1 владением оркестратора
Hardening-проход перед первой сборкой на Debian. Три из найденного не
воспроизводились ни на одном dry-run и проявились бы только на живом сервере.

Установка

* preflight внутри install вызывался дважды и оба раза проверял clean-host.
  Ко второму вызову на диске лежал собственный /var/lib/hy2xs/install-state.json,
  записанный после первого preflight, и опознавался как маркер посторонней
  установки: КАЖДАЯ чистая установка падала сразу после apt-get с
  fatal_post_apply и оставляла сервер наполовину настроенным. Чистота хоста —
  условие входа в операцию, возможности платформы проверяются уже внутри
  PHASE 1, поэтому checkCleanHost стал отдельным параметром без умолчания.

* PHASE 1 начиналась в install.sh: shell сам создавал /usr/local/lib/hy2xs,
  ставил бинарник, вешал symlink и копировал runtime-пакет, и только потом
  запускал оркестратор с его собственным preflight. Отказ того preflight
  объявлялся fatal_pre_apply — «на сервере ничего не изменено» — при уже
  созданном каталоге оркестратора. Отследить владение мутацией невозможно,
  пока мутируют двое: install.sh больше не изменяет ничего, раскладку
  выполняет steps/bootstrap.ts под ownership.bootstrapTouched, пути попали
  в owned_paths. Как следствие удалено деление clean-host на фазы.

* diagnosticsCollect стояла перед rollback обычным await в install и в
  reconfigure. На заполненном диске она падает сама и отменяла откат целиком.
  Диагностика — best effort, откат — обязателен.

* reconfigure/repair выбирали записываемую фазу отказа регулярным выражением
  по тексту ошибки. Переведено на ownership-флаги.

Секреты

* Журнал админки писал RequestURI, то есть путь вместе с query. Hysteria
  обращается к /internal/hysteria/auth?access_token=<секрет> при каждом
  подключении пира, поэтому действующий machine token оседал открытым текстом
  в hy2xs-admin.log, который отдаётся через ExportLog и попадает в
  diagnostics-бандл. Логируется путь; значения query не пишутся, имена —
  пишутся. Канала было два: gin.Default() печатает path?query в stdout,
  оттуда в journald и в тот же бандл, — панель переведена на gin.New() +
  Recovery(). Журналы внутри бандла и журнал Hysteria из ExportLog теперь
  проходят санитайз. Сравнение токена — constant time.

* Config API позволял прочитать и подменить ключи приложения: getConfig и
  listConfig принимали произвольный ключ, а проверка записи была denylist'ом
  из трёх ключей оркестратора. Запрос ?key=PEER_SECRET_ENCRYPTION_KEY отдавал
  master-key шифрования секретов пиров. Доступ переведён на allowlist, маршрут
  getConfig удалён целиком — потребителей у него не было ни одного.

Пиры

* Импорт применялся по одной записи вне транзакции, вопреки собственному
  контракту. Валидация не знает, что уже лежит в базе: cross-conflict по
  UNIQUE(name) оставлял часть файла применённой. Применение выполняется одной
  транзакцией, криптоматериал считается до её открытия.

* Файл импорта мог содержать хвостовой JSON-документ, который молча не
  применялся. После разбора проверяется io.EOF.

* Экспорт разделён на «Экспорт настроек» и «Резервная копия» с секретами и
  подтверждением: обычный экспорт выдаёт пирам новые секреты при импорте, и
  прежние клиентские ссылки после переноса переставали работать.

Сборка

* Два stale-грепа в приёмке роняли build.sh в самом конце, внутри
  verify_archive. Первый искал в smoke.ts исчезнувший литерал URL, второй
  совпадал с router_test.go, который перечисляет удалённые маршруты, потому
  что проверяет их отсутствие: добавление регрессионного теста ломало сборку.

* verify_archive требовал наличия мутирующей строки в install.sh. Инвариант
  перевёрнут: их не должно быть ни одной.

Очистка

* Удалены entity.LegacyAccount, миграции 002/003 и мёртвые хелперы
  listSQLMigrationFiles и envInt: v1 не мигрирует базу 0.x ни при каком
  сценарии. Номера оставшихся миграций сохранены. H UI-словарь убран из
  обычных доков, в docs/14 он остаётся — там это имена объектов для удаления.

* Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry:
  203.0.113.5 из RFC-примеров считался публичным адресом сервера. Отказ
  резолвера отделён от отсутствия A-записи.

Проверено: bun test 233, go test 71, tsc/vue-tsc, bash -n 11 скриптов,
приёмка прогнана против дерева.
2026-08-28 05:27:10 +05:00

368 lines
15 KiB
TypeScript
Raw Permalink 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,
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<string, string[] | DnsFailureCode>;
}): 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/
);
});
});