Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,12 +280,14 @@ keeps working.
- `pool`: all servers are active; `backup` flags are cleared. Client
distribution is selected by `balance_algorithm` and `sticky_mode` (below).
- `failover`: position order is priority. The first server is active, every
later server gets `backup`. `balance_algorithm`, `sticky_mode` and their
settings are accepted, validated and stored empty; `sticky_enabled` is
forced false. No balance directives are emitted.
later server gets `backup`. Явные `sticky_mode` (`none`, `source`,
`source_table`) и `balance_algorithm` теперь сохраняются независимо от ролей.
Без явных настроек сохраняется прежнее поведение без закрепления.
Для резерва доступны алгоритмы самого HAProxy; `leastping` и `leastconn`
с ненулевой толерантностью не поддерживаются агентом для такой топологии.
At least one server must be active.

### Client distribution (pool mode, migration 000052)
### Client distribution (pool and failover)

Two orthogonal settings. `balance_algorithm` picks the server for a **new**
client; `sticky_mode` decides whether a returning client keeps its server.
Expand Down
34 changes: 34 additions & 0 deletions docs/features/sticky-backup-pools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Закрепление клиентов с резервными серверами

Роли серверов и распределение клиентов настраиваются независимо. В редакторе
маршрута выберите «Основной + резервные», затем «Запоминать клиента», алгоритм
первого сервера «Меньше соединений» и срок `24h`.

Для основного DNS-пула добавьте домен с включённым DNS-пулом первой строкой,
а резервный адрес — второй. Не задавайте «Основной IP», если нужно распределять
новых клиентов между всеми адресами основного домена. В меню строки можно
назначить несколько основных серверов. Проверки здоровья DNS-пула включены всегда.

Первое подключение нового IP выбирает доступный основной сервер по числу
соединений с учётом весов. Последующие подключения используют сохранённое
назначение. Если сервер недоступен, новые подключения могут перейти на другой
доступный сервер. Резерв предназначен для отказа всех основных, а не для
переполнения их нагрузки. Уже установленные TCP-соединения не переносятся.

`24h` — срок неактивности записи, не периодическая ротация. При CGNAT один IP
закрепляет всех клиентов за ним. Персистентное назначение на резерв может
сохраняться после восстановления основных: это не гарантия немедленного возврата.

Настройки сохраняются через API и при повторном открытии редактора. Старые
маршруты «Основной + резервные» без явных настроек распределения сохраняют
прежнюю генерацию. Для API сочетайте `servers[].backup` (без `balance_mode`, если
основных несколько) с `sticky_mode: source_table`, `sticky_ttl: 24h` и
`balance_algorithm: leastconn`. Для одного основного и последующих резервов
можно передать `balance_mode: failover`.

Ограничения сочетаний резервных DNS-пулов и «Основного IP» не меняются.

Используйте толерантность `0 %`. Управляемые агентом `leastping` и `leastconn`
с ненулевой толерантностью пока не разделяют основные и резервные серверы,
поэтому для этой топологии недоступны. Обычный `leastconn` работает в HAProxy
и не требует обновления агента.
33 changes: 17 additions & 16 deletions frontend/src/features/routes/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type ProxyProtocol = 'none' | 'v1' | 'v2';
export type BalanceMode = 'pool' | 'failover';
export type ShaperMode = 'haproxy' | 'kernel';

/** Client stickiness in pool mode: who a repeat connection lands on. */
/** Client stickiness: who a repeat connection lands on. */
export type StickyMode = 'none' | 'source' | 'source_table';
/** Which server a NEW client gets. static-rr is the "static weights" sub-choice of roundrobin. */
export type BalanceAlgorithm = 'roundrobin' | 'static-rr' | 'random' | 'leastconn' | 'leastping';
Expand Down Expand Up @@ -92,7 +92,7 @@ export interface RouteDraft {
*/
balanceMode: BalanceMode;
/**
* stickyMode: client stickiness in pool mode (ignored in failover).
* stickyMode: client stickiness, independent of primary/backup roles.
* null = not chosen by the operator: derived from the servers (DNS pool
* => source, otherwise none), exactly like the backend default.
*/
Expand Down Expand Up @@ -319,7 +319,7 @@ export function autoTableEntries(memoryTotalBytes: number | undefined, autoTable

/** Whether a stored route renders an «Авто» source_table stick-table. */
export function routeUsesAutoTable(route: Pick<RouteRecord, 'enabled' | 'sticky_mode' | 'sticky_table_entries' | 'balance_mode'>): boolean {
return route.enabled && route.sticky_mode === 'source_table' && !route.sticky_table_entries && route.balance_mode !== 'failover';
return route.enabled && route.sticky_mode === 'source_table' && !route.sticky_table_entries;
}

/**
Expand Down Expand Up @@ -659,9 +659,8 @@ export function preferredIPApplies(draft: Pick<RouteDraft, 'servers' | 'balanceM
&& effectiveBalanceMode(draft) === 'failover' && !serverIsBackup(draft, 0);
}

/** Client distribution matters only in pool mode with several candidates. */
/** Distribution applies when multiple servers or DNS addresses are available. */
export function stickyApplicable(draft: Pick<RouteDraft, 'servers' | 'balanceMode'>): boolean {
if (effectiveBalanceMode(draft) !== 'pool') return false;
return draft.servers.length >= 2 || draft.servers.some((s) => s.targetType === 'tcp' && s.dnsPool);
}

Expand All @@ -670,7 +669,8 @@ export function hasDNSPoolServer(draft: Pick<RouteDraft, 'servers'>): boolean {
}

/** Default stickiness: a DNS pool is source-hash sticky (1.0.8 behaviour), anything else none. */
export function defaultStickyMode(draft: Pick<RouteDraft, 'servers'>): StickyMode {
export function defaultStickyMode(draft: Pick<RouteDraft, 'servers' | 'balanceMode'>): StickyMode {
if (effectiveBalanceMode(draft) === 'failover') return 'none';
return hasDNSPoolServer(draft) ? 'source' : 'none';
}

Expand Down Expand Up @@ -871,12 +871,16 @@ export function validateExpertOverride(value: string): RouteDraftError[] {
/** Range checks for the «Распределение клиентов» inputs that actually travel in the payload. */
export function validateDistribution(draft: RouteDraft): RouteDraftError[] {
const errors: RouteDraftError[] = [];
if (effectiveBalanceMode(draft) !== 'pool' || !stickyApplicable(draft)) return errors;
if (!stickyApplicable(draft)) return errors;
const add = (field: RouteDraftError['field'], message: string) => errors.push({ field, message });
const inRange = (value: number | '', min: number, max: number, integer = true) => value === ''
|| (Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isInteger(value)));
const sticky = effectiveStickyMode(draft);
const algorithm = effectiveBalanceAlgorithm(draft);
if (effectiveBalanceMode(draft) === 'failover' && sticky !== 'source') {
if (algorithm === 'leastping') add('balanceAlgorithm', 'С резервом выберите алгоритм HAProxy, например «Меньше соединений».');
if (algorithm === 'leastconn' && Number(draft.leastpingTolerancePct) > 0) add('leastpingTolerancePct', 'С резервом толерантность должна быть 0 %.');
}
if (sticky !== 'source') {
if (algorithm === 'random' && !inRange(draft.balanceRandomDraws, 1, 5)) add('balanceRandomDraws', 'Выборок — от 1 до 5.');
if (algorithm === 'leastping') {
Expand Down Expand Up @@ -1096,15 +1100,15 @@ export function acceptProxyPayload(draft: Pick<RouteDraft, 'acceptProxyEnabled'
* leastping and no IP hash (the hash overrides any algorithm).
*/
export function leastpingActive(draft: RouteDraft): boolean {
return effectiveBalanceMode(draft) === 'pool' && stickyApplicable(draft) && effectiveBalanceAlgorithm(draft) === 'leastping';
return stickyApplicable(draft) && effectiveBalanceAlgorithm(draft) === 'leastping';
}

/**
* Connections mode actually in effect: pool with several candidates,
* algorithm leastconn and no IP hash (source_table uses it for the first pick).
*/
export function leastconnActive(draft: RouteDraft): boolean {
return effectiveBalanceMode(draft) === 'pool' && stickyApplicable(draft) && effectiveBalanceAlgorithm(draft) === 'leastconn';
return stickyApplicable(draft) && effectiveBalanceAlgorithm(draft) === 'leastconn';
}

/**
Expand Down Expand Up @@ -1168,12 +1172,9 @@ export function routePayload(draft: RouteDraft, enabled: boolean, expectedVersio
const acceptProxyFrom = acceptProxyPayload(draft);
const balanceMode = effectiveBalanceMode(draft);
const shape = payloadShape(draft);
const poolMode = balanceMode === 'pool';
// Two orthogonal pool-mode settings; failover ignores both and stores them
// empty. In pool mode both keys always travel (never omitted), so a stale
// choice from before a mode/server-count change never lingers server-side.
const stickyMode: StickyMode | '' = poolMode ? effectiveStickyMode(draft) : '';
const balanceApplicable = poolMode && stickyApplicable(draft);
// Server roles and client distribution are independent.
const stickyMode: StickyMode | '' = effectiveStickyMode(draft);
const balanceApplicable = stickyApplicable(draft);
const balanceAlgorithm: BalanceAlgorithm | '' = balanceApplicable ? effectiveBalanceAlgorithm(draft) : '';
const latency = leastpingActive(draft);
const connections = leastconnActive(draft);
Expand Down Expand Up @@ -1349,7 +1350,7 @@ export function renderRoutePreview(draft: RouteDraft, peers: RouteRecord[], memo

lines.push('', `backend nf_be_${routeID}`, ' mode tcp');
const legacyDNSPool = shape.kind === 'legacy' && shape.dnsPool;
// routePayload already sends sticky_mode '' for failover (both settings are pool-only).
// Distribution applies to both ordinary pools and pools with reserves.
const distribution = current.sticky_mode;
if (distribution === 'source') {
// balance source when the IPv6 grouping is untouched (0, byte-identical to
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/pages/RouteEditorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
value={family}
onChange={(value) => { if (value) update('balanceAlgorithm', value as BalanceAlgorithm); }}
allowDeselect={false}
data={balanceAlgorithmOptions.map(({ value, label }) => ({ value, label, disabled: value === 'leastping' && !agent110 }))}
data={balanceAlgorithmOptions.map(({ value, label }) => ({ value, label, disabled: value === 'leastping' && (!agent110 || effectiveBalanceMode(draft) === 'failover') }))}
aria-describedby="dist-algorithm-hint"
/>
</SettingRow>
Expand Down Expand Up @@ -771,7 +771,7 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
/>
</SettingRow>
<SettingsNote>
Вес — в таблице серверов. Толерантность больше 0 применяет агент ноды каждые 5 с.{!leastconnToleranceSupported(agentVersion) ? ` Нужен Node Agent ${LEASTCONN_TOLERANCE_MIN_AGENT}, на ноде ${agentVersion ?? 'неизвестно'}.` : ''}
{effectiveBalanceMode(draft) === 'failover' ? 'С резервными серверами используется обычный leastconn: оставьте толерантность 0 %.' : 'Вес — в таблице серверов. Толерантность больше 0 применяет агент ноды каждые 5 с.'}{effectiveBalanceMode(draft) === 'pool' && !leastconnToleranceSupported(agentVersion) ? ` Нужен Node Agent ${LEASTCONN_TOLERANCE_MIN_AGENT}, на ноде ${agentVersion ?? 'неизвестно'}.` : ''}
</SettingsNote>
</>
)}
Expand Down Expand Up @@ -1139,7 +1139,7 @@ export function RouteEditorPage() {
const isWildcardIP = draft.listenerIP === '*' || draft.listenerIP === '0.0.0.0' || draft.listenerIP === '::';
const listenerIPMode = isWildcardIP ? 'all' : 'specific';

// «Распределение клиентов»: pool mode with 2+ servers or a DNS pool (several addresses).
// Client distribution is independent of primary/backup roles.
const hasDNSPool = hasDNSPoolServer(draft);
const showSticky = stickyApplicable(draft);
const stickyMode = effectiveStickyMode(draft);
Expand Down
50 changes: 50 additions & 0 deletions frontend/tests/routeValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,53 @@ test('route name must not contain control characters', () => {
d.name = 'edge msk';
assert.deepEqual(errors(d), []);
});

test('DNS primary with a reserve keeps client persistence and first-server selection', () => {
const d = draft();
d.servers.reverse();
d.balanceMode = 'failover';
d.servers = model.withCanonicalFailoverRoles(d.servers);
d.stickyMode = 'source_table';
d.stickyTTL = '24h';
d.balanceAlgorithm = 'leastconn';
assert.equal(model.stickyApplicable(d), true);
assert.deepEqual(errors(d), []);
const payload = model.routePayload(d, true);
assert.equal(payload.sticky_mode, 'source_table');
assert.equal(payload.sticky_ttl, '24h');
assert.equal(payload.balance_algorithm, 'leastconn');
assert.deepEqual(payload.servers.map(s => s.backup), [false, true]);
const loaded = model.routeToDraft({ ...payload, id: '00000000-0000-0000-0000-000000000001', version: 1 } as any);
const saved = model.routePayload(loaded, true);
assert.equal(saved.sticky_mode, 'source_table');
assert.equal(saved.sticky_ttl, '24h');
assert.deepEqual(saved.servers.map(s => s.backup), [false, true]);
d.stickyTTL = '0h';
assert.ok(errors(d).length > 0);
});

test('legacy failover defaults remain non-sticky while explicit choices survive mode changes', () => {
const d = draft();
d.balanceMode = 'failover';
d.servers = model.withCanonicalFailoverRoles(d.servers);
assert.equal(model.effectiveStickyMode(d), 'none');
d.stickyMode = 'source_table';
for (const mode of ['pool', 'failover'] as const) {
d.balanceMode = mode;
assert.equal(model.routePayload(d, true).sticky_mode, 'source_table');
}
});

test('backup roles reject agent weighting that cannot distinguish reserves', () => {
const d = draft();
d.balanceMode = 'failover';
d.servers = model.withCanonicalFailoverRoles(d.servers);
d.stickyMode = 'source_table';
d.balanceAlgorithm = 'leastping';
assert.ok(errors(d).some(e => e.includes('С резервом')));
d.balanceAlgorithm = 'leastconn';
d.leastpingTolerancePct = 10;
assert.ok(errors(d).some(e => e.includes('толерантность')));
d.leastpingTolerancePct = 0;
assert.deepEqual(errors(d), []);
});
5 changes: 1 addition & 4 deletions internal/panel/haproxy_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ func legacyDNSPoolRoute(route renderRoute) bool {
// or "" when no balance directives are emitted. Rows stored before migration
// 000051 carry an empty sticky_mode and keep their earlier render, except that
// a DNS pool is always source-hash sticky, as it was in 1.0.8. Failover keeps
// strict priority order; a legacy route-level DNS pool never is failover.
// legacy failover defaults; explicit distribution settings apply to either role layout.
func effectiveStickyMode(route renderRoute, legacyPool bool) string {
mode := route.StickyMode
if mode == "" {
Expand All @@ -847,9 +847,6 @@ func effectiveStickyMode(route renderRoute, legacyPool bool) string {
return ""
}
}
if route.BalanceMode == "failover" && !legacyPool {
return ""
}
switch mode {
case StickyModeSource, StickyModeSourceTable, StickyModeLeastConn:
// StickyModeLeastConn only ever appears on a single-target route
Expand Down
4 changes: 2 additions & 2 deletions internal/panel/route_fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ func routeSpecFingerprint(spec RouteSpec) string {
StickyTableSize string `json:"sticky_table_size"`
StickyExpire string `json:"sticky_expire"`
// balance_mode is deliberately not hashed: the rendered backend is fully
// determined by servers[].backup and sticky_enabled (validation forces
// sticky off in failover mode), so hashing it would only change the
// determined by servers[].backup and the distribution settings,
// so hashing it would only change the
// fingerprint of existing routes without any render change.
// Omitted for the default so existing route fingerprints stay stable.
ShaperMode string `json:"shaper_mode,omitempty"`
Expand Down
Loading
Loading