diff --git a/docs/api-contract.md b/docs/api-contract.md index 3c10392..a5e5bbc 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -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. diff --git a/docs/features/sticky-backup-pools.md b/docs/features/sticky-backup-pools.md new file mode 100644 index 0000000..ee9e6b5 --- /dev/null +++ b/docs/features/sticky-backup-pools.md @@ -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 +и не требует обновления агента. diff --git a/frontend/src/features/routes/model.ts b/frontend/src/features/routes/model.ts index a452db0..c48b78b 100644 --- a/frontend/src/features/routes/model.ts +++ b/frontend/src/features/routes/model.ts @@ -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'; @@ -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. */ @@ -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): 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; } /** @@ -659,9 +659,8 @@ export function preferredIPApplies(draft: Pick): boolean { - if (effectiveBalanceMode(draft) !== 'pool') return false; return draft.servers.length >= 2 || draft.servers.some((s) => s.targetType === 'tcp' && s.dnsPool); } @@ -670,7 +669,8 @@ export function hasDNSPoolServer(draft: Pick): boolean { } /** Default stickiness: a DNS pool is source-hash sticky (1.0.8 behaviour), anything else none. */ -export function defaultStickyMode(draft: Pick): StickyMode { +export function defaultStickyMode(draft: Pick): StickyMode { + if (effectiveBalanceMode(draft) === 'failover') return 'none'; return hasDNSPoolServer(draft) ? 'source' : 'none'; } @@ -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') { @@ -1096,7 +1100,7 @@ export function acceptProxyPayload(draft: Pick { 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" /> @@ -771,7 +771,7 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt /> - Вес — в таблице серверов. Толерантность больше 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 ?? 'неизвестно'}.` : ''} )} @@ -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); diff --git a/frontend/tests/routeValidation.test.ts b/frontend/tests/routeValidation.test.ts index 40165cb..b24a9c7 100644 --- a/frontend/tests/routeValidation.test.ts +++ b/frontend/tests/routeValidation.test.ts @@ -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), []); +}); diff --git a/internal/panel/haproxy_renderer.go b/internal/panel/haproxy_renderer.go index c5c21c9..b799176 100644 --- a/internal/panel/haproxy_renderer.go +++ b/internal/panel/haproxy_renderer.go @@ -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 == "" { @@ -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 diff --git a/internal/panel/route_fingerprint.go b/internal/panel/route_fingerprint.go index d0a17dc..485c5ac 100644 --- a/internal/panel/route_fingerprint.go +++ b/internal/panel/route_fingerprint.go @@ -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"` diff --git a/internal/panel/route_sticky_backup_test.go b/internal/panel/route_sticky_backup_test.go new file mode 100644 index 0000000..ec0b596 --- /dev/null +++ b/internal/panel/route_sticky_backup_test.go @@ -0,0 +1,57 @@ +package panel + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStickyDNSPoolWithReserve(t *testing.T) { + for _, layout := range []struct{ name, servers string }{ + {"dns-primary", `{"name":"primary","host":"pool.example.com","port":443,"dns_pool":true},{"name":"reserve","host":"192.0.2.20","port":443,"backup":true}`}, + {"dns-reserve", `{"name":"primary","host":"192.0.2.10","port":443},{"name":"reserve","host":"pool.example.com","port":443,"dns_pool":true,"backup":true}`}, + {"two-primaries", `{"name":"primary","host":"192.0.2.10","port":443},{"name":"second","host":"192.0.2.11","port":443},{"name":"reserve","host":"192.0.2.20","port":443,"backup":true}`}, + } { + t.Run(layout.name, func(t *testing.T) { + spec, got := renderPayload(t, `{"name":"sticky-backup","match_mode":"fallback","listener_port":19443,"sticky_mode":"source_table","sticky_ttl":"24h","balance_algorithm":"leastconn","servers":[`+layout.servers+`]}`) + require.Equal(t, "failover", spec.BalanceMode) + require.Equal(t, "source_table", spec.StickyMode) + require.Equal(t, "24h", spec.StickyTTL) + require.Equal(t, "leastconn", spec.BalanceAlgorithm) + require.Contains(t, got.Config, "balance leastconn") + require.Contains(t, got.Config, "expire 24h peers nf_peers") + require.Contains(t, got.Config, "option redispatch") + require.Contains(t, got.Config, "peers nf_peers") + require.Contains(t, got.Config, " backup\n") + require.True(t, spec.Servers[len(spec.Servers)-1].Backup) + for _, server := range spec.Servers[:len(spec.Servers)-1] { + require.False(t, server.Backup) + } + // Stored routes are validated again when a node revision is rendered. + again, err := RenderHAProxyConfig([]Route{specToRoute(spec)}) + require.NoError(t, err) + require.Equal(t, got.Config, again.Config) + if dir := os.Getenv("NODEFLOW_RENDER_SAMPLES_DIR"); dir != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, "sticky-"+layout.name+".cfg"), []byte(got.Config), 0600)) + } + plain := specToRoute(spec) + plain.StickyMode, plain.StickyTTL, plain.BalanceAlgorithm, plain.StickyEnabled = "", "", "", false + legacy, err := RenderHAProxyConfig([]Route{plain}) + require.NoError(t, err) + require.False(t, strings.Contains(legacy.Config, "stick-table")) + }) + } +} + +func TestBackupDistributionRejectsAgentManagedAlgorithms(t *testing.T) { + for _, distribution := range []string{ + `"balance_algorithm":"leastping"`, + `"balance_algorithm":"leastconn","balance_tolerance":0.1`, + } { + _, err := validatePayload(t, `{"name":"backup","match_mode":"fallback","balance_mode":"failover","sticky_mode":"source_table",`+distribution+`,`+poolServers+`}`) + require.ErrorContains(t, err, "native HAProxy algorithms only") + } +} diff --git a/internal/panel/route_sticky_mode_test.go b/internal/panel/route_sticky_mode_test.go index 02528a4..4451714 100644 --- a/internal/panel/route_sticky_mode_test.go +++ b/internal/panel/route_sticky_mode_test.go @@ -100,7 +100,7 @@ func TestRenderStickyModes(t *testing.T) { {"roundrobin dns pool", `{"name":"p","match_mode":"fallback","sticky_mode":"roundrobin","servers":[{"name":"dp","host":"pool.example.com","port":443,"dns_pool":true},{"name":"b","host":"192.0.2.2","port":443}]}`, " mode tcp\n option tcp-check\n", []string{"balance", "stick"}}, {"failover", `{"name":"p","match_mode":"fallback","balance_mode":"failover","sticky_mode":"source_table",` + poolServers + `}`, - " mode tcp\n option tcp-check\n", []string{"balance", "stick"}}, + " mode tcp\n balance leastconn\n stick-table type ipv6 size 1m expire 1h peers nf_peers\n", []string{"balance source"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/panel/route_validation.go b/internal/panel/route_validation.go index 15a08c5..e92eaec 100644 --- a/internal/panel/route_validation.go +++ b/internal/panel/route_validation.go @@ -665,8 +665,8 @@ func validStickyTableEntries(value string) bool { // resolvePoolDistribution settles the two orthogonal 1.1.0 settings of a // route with an explicit servers[] pool: balance_algorithm (server choice for // a new client) and sticky_mode (client stickiness), plus their sub-fields -// and the common slowstart setting. balance_mode=failover ignores both axes -// and stores every field of this function empty/zero. +// and the common slowstart setting. Legacy failover without explicit modern +// distribution settings retains its previous behaviour. func resolvePoolDistribution(in *routeInput, out *RouteSpec, balanceMode string) error { if containsForbiddenControl(in.BalanceAlgorithm) || containsForbiddenControl(in.StickyMode) || containsForbiddenControl(in.StickyTTL) || containsForbiddenControl(in.StickyHash) || @@ -814,7 +814,7 @@ func resolvePoolDistribution(in *routeInput, out *RouteSpec, balanceMode string) out.StickyIPv6Prefix = 0 } - if balanceMode == "pool" && anyServerIPWeightOverride(out.Servers) { + if anyServerIPWeightOverride(out.Servers) { staticWeights := out.BalanceAlgorithm == BalanceAlgorithmStaticRR || (stickyMode == StickyModeSource && out.StickyHash == "map-based") if staticWeights { @@ -828,7 +828,15 @@ func resolvePoolDistribution(in *routeInput, out *RouteSpec, balanceMode string) } out.Slowstart = slowstart - if balanceMode == "failover" { + // Agent-managed weighting compares all servers and cannot yet separate backups. + if balanceMode == "failover" && stickyMode != StickyModeSource && + (out.BalanceAlgorithm == BalanceAlgorithmLeastPing || + (out.BalanceAlgorithm == BalanceAlgorithmLeastConn && out.LeastPingTolerance > 0)) { + return fmt.Errorf("failover supports native HAProxy algorithms only: use leastconn with zero tolerance") + } + + if balanceMode == "failover" && in.BalanceAlgorithm == "" && + stickyIn != StickyModeNone && stickyIn != StickyModeSource && stickyIn != StickyModeSourceTable { out.BalanceAlgorithm, out.BalanceRandomDraws = "", 0 out.LeastPingTolerance, out.LeastPingToleranceMS = 0, 0 out.StickyMode, out.StickyTTL, out.StickyEnabled = "", "", false diff --git a/internal/panel/testdata/ui_route_payloads.json.gz b/internal/panel/testdata/ui_route_payloads.json.gz index 0fecced..7205d39 100644 Binary files a/internal/panel/testdata/ui_route_payloads.json.gz and b/internal/panel/testdata/ui_route_payloads.json.gz differ