mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-23 19:06:25 +00:00
c79391cd13
The manual Frontend editor, wizard and visual ACL builder hard-rejected the ACL `-f <file>` flag while bulk import accepted it. Worse, a frontend imported with an `-f` ACL could not be edited at all (422) until the ACL was dropped. The original guard predated the fail-safe apply flow: the agent runs `haproxy -c` before every reload, so a missing pattern file is rejected safely and the previous config keeps running. Pattern files are operator-managed host files — the same policy adopted for SPOE filter configs in v1.8.8. - models: remove the 5 `-f` hard rejects (frontend acl/redirect/use_backend validators + wizard string/dict-redirect guards); `$(`/backtick and X!X contradiction guards unchanged - routers/frontend: `_pattern_file_warnings` helper; non-blocking warning on create + update responses listing referenced pattern files (empty when no rule uses `-f` — zero noise) - routers/config: bulk-import preview advisory listing pattern files per frontend (cluster config-dir aware, next to the SPOE advisories) - React: remove the FrontendManagement submit gate and SiteWizard step gate; ACLRuleBuilder renders informational notes instead of errors and re-adds `-f (pattern file on host)` to the flag dropdown; create path now renders server warnings like update - tests: 4 reject-pins inverted to accept-pins; new test_acl_pattern_file_allow.py (accept/guards-kept/zero-noise/advisory); full suite green (1094 passed)
3265 lines
155 KiB
JavaScript
3265 lines
155 KiB
JavaScript
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
import {
|
|
Card, Steps, Form, Input, Select, Button, Space, Alert, Modal,
|
|
message, Row, Col, Tag, Tooltip, Switch, InputNumber, Divider, Spin,
|
|
Collapse,
|
|
} from 'antd';
|
|
import {
|
|
PlusOutlined, MinusCircleOutlined, ReloadOutlined,
|
|
SaveOutlined, CheckCircleOutlined, ExclamationCircleOutlined,
|
|
CloseCircleOutlined, SettingOutlined, LockOutlined,
|
|
FilterOutlined,
|
|
} from '@ant-design/icons';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import axios from 'axios';
|
|
|
|
import { antdDomainsListRule } from '../utils/validation';
|
|
import { extractApiError } from '../utils/apiError';
|
|
import ACLRuleBuilder from './ACLRuleBuilder';
|
|
import { useCluster } from '../contexts/ClusterContext';
|
|
|
|
const { Option } = Select;
|
|
|
|
const SSL_MODES = [
|
|
{ value: 'acme', label: "Let's Encrypt (ACME)", description: 'Issue + auto-renew via Let\'s Encrypt' },
|
|
{ value: 'upload', label: 'Upload PEM cert', description: 'Provide certificate + private key now' },
|
|
{ value: 'existing', label: 'Reuse existing cert', description: 'Pick a cert already imported by an admin' },
|
|
{ value: 'none', label: 'HTTP only', description: 'Skip HTTPS entirely' },
|
|
];
|
|
|
|
const checkStatusToTag = (status) => {
|
|
if (status === 'ok') return <Tag color="success" icon={<CheckCircleOutlined />}>OK</Tag>;
|
|
if (status === 'warn') return <Tag color="warning" icon={<ExclamationCircleOutlined />}>WARN</Tag>;
|
|
if (status === 'fail') return <Tag color="error" icon={<CloseCircleOutlined />}>FAIL</Tag>;
|
|
if (status === 'skipped') return <Tag>SKIPPED</Tag>;
|
|
return <Tag>{(status || '').toUpperCase()}</Tag>;
|
|
};
|
|
|
|
// R16 #v3: extractApiError lives in ../utils/apiError so SiteDrafts
|
|
// and any future wizard-adjacent component can reuse the same envelope-aware
|
|
// error parsing. See that file for the full shape rationale.
|
|
|
|
// R18c round 7 (Bulgu 2): when an operator resumes a saved draft, jump
|
|
// straight to the last step (Review & Apply) so they can submit without
|
|
// clicking Next four times. Defined as a module-level constant so future
|
|
// refactors that add/remove a step only need to update this single value.
|
|
// stepContents.length - 1 would also work but stepContents is recreated
|
|
// every render and adding it as an effect dependency conflicts with the
|
|
// mount-only intent of the draft hydrate effect.
|
|
const WIZARD_LAST_STEP = 4;
|
|
// Step indices: 0=Cluster & Domains, 1=Backend & Servers, 2=Frontend,
|
|
// 3=SSL & ACME, 4=Review & Apply. Named constant so the orphan-clear
|
|
// rescue navigation (Bulgu #14) does not hard-code the magic number.
|
|
const SSL_STEP_INDEX = 3;
|
|
|
|
// Phase K Phase C: map a Pydantic validation loc[] (e.g. ['body',
|
|
// 'frontend', 'name']) and a HAProxy directive prefix (e.g. 'bind',
|
|
// 'backend', 'use_backend') back to the wizard step that owns the
|
|
// field. The validation card uses this to render `Edit Step N`
|
|
// jump-back buttons next to each error.
|
|
const PYDANTIC_LOC_TO_STEP = {
|
|
cluster_id: 0,
|
|
domains: 0,
|
|
backend: 1,
|
|
servers: 1,
|
|
frontend: 2,
|
|
ssl: 3,
|
|
apply_immediately: 4,
|
|
};
|
|
// Order matters — longer / more-specific prefixes first.
|
|
const HAPROXY_DIRECTIVE_TO_STEP = [
|
|
['http-request', 2],
|
|
['http-response', 2],
|
|
['use_backend', 2],
|
|
['redirect', 2],
|
|
['frontend', 2],
|
|
['acl', 2],
|
|
['bind ssl', 3],
|
|
['bind', 2],
|
|
['hsts', 3],
|
|
['ssl-min-ver', 3],
|
|
['ssl-max-ver', 3],
|
|
['ssl', 3],
|
|
['server', 1],
|
|
['backend', 1],
|
|
];
|
|
|
|
// Phase K Phase C audit-fix #2: Pydantic v2 raises model-level
|
|
// `model_validator(mode="after")` errors with `loc=()` (empty
|
|
// tuple) — FastAPI then prepends `'body'` so the operator-visible
|
|
// envelope is `loc=['body']` (length=1). The most operator-
|
|
// relevant case is PEM-stripped resume:
|
|
// `enforce_acme_apply_and_http` raises "ssl.mode='upload'
|
|
// requires a non-empty PEM-encoded certificate_content (if you
|
|
// resumed a draft, PEM fields were stripped at save time …)"
|
|
// → loc=['body'], msg starts with "Value error, ssl.mode='upload'
|
|
// requires …". Without a content-aware fallback the validation
|
|
// card renders the message but no Edit-Step jumpback. Plan
|
|
// (lines 148, 234) explicitly requires Step-3 jumpback in this
|
|
// flow.
|
|
//
|
|
// Heuristic: when `loc` does not pinpoint a step on its own,
|
|
// scan the message text for the field/concept that the error
|
|
// references and route to the wizard step that owns it.
|
|
//
|
|
// Patterns are ordered MOST-ACTIONABLE first — i.e. the step the
|
|
// operator actually has to edit to resolve the error, not the
|
|
// step that "feels related". `enforce_acme_apply_and_http`
|
|
// raises cross-field messages like "ssl.mode='acme' requires
|
|
// apply_immediately=true" or "ssl.mode='acme' requires
|
|
// frontend.mode='http'". The operator must edit the FIELD ON
|
|
// THE RIGHT SIDE of the requirement, not the SSL mode that
|
|
// triggered it. Pattern order therefore goes:
|
|
//
|
|
// 1. apply_immediately → Step 4 (Review)
|
|
// 2. wildcard / domains → Step 0 (Cluster + Domains)
|
|
// 3. frontend.* / bind_port → Step 2 (Frontend)
|
|
// 4. backend.* / server.* → Step 1 (Backend)
|
|
// 5. PEM / ssl.* / hsts_* → Step 3 (SSL/TLS) — general SSL fallback
|
|
//
|
|
// SSL is the catch-all LAST entry; this prevents a cross-field
|
|
// message that mentions "ssl.mode='acme'" but actually requires
|
|
// editing the FE bind_port from being mis-routed to Step 3.
|
|
//
|
|
// Pin: backend/tests/test_frontend_auth_bootstrap_phase_j.py::
|
|
// test_phase_k_sitewizard_pydantic_jumpback_falls_back_to_msg_for_root_errors
|
|
// + test_phase_k_sitewizard_pydantic_pattern_order_routes_acme_xfield_correctly
|
|
const PYDANTIC_MSG_TO_STEP_PATTERNS = [
|
|
// Review step (4) — `apply_immediately` is the only review-step
|
|
// field; matched first so "ssl.mode='acme' requires
|
|
// apply_immediately=true" routes to Step 4 (where the operator
|
|
// toggles the switch), not Step 3.
|
|
[/\bapply_immediately\b/i, 4],
|
|
// Cluster + domains step (0) — `wildcard` is matched here (not
|
|
// under SSL) because the wildcard ACME error is fixed by
|
|
// editing the domain list (or switching ssl.mode away from
|
|
// acme on Step 3 — Step 0 is the more direct path).
|
|
[/\b(wildcard|cluster_id|domains?)\b/i, 0],
|
|
// Frontend step (2) — must come BEFORE the catch-all SSL
|
|
// pattern; otherwise "ssl.mode='acme' requires frontend.mode=
|
|
// 'http'" matches the generic `ssl\.` first and routes to
|
|
// Step 3 even though the operator must edit the FE mode.
|
|
[/\b(frontend\.|bind_port|https_redirect|tcp)\b/i, 2],
|
|
// Backend step (1) — backend / server identifiers.
|
|
[/\b(backend\.|server\.|servers\[)/i, 1],
|
|
// SSL step (3) — catch-all for SSL/TLS-only fields. Order
|
|
// matters: anything that could legitimately route to Step 0,
|
|
// 1, 2, or 4 has already been matched above.
|
|
[/\b(ssl\.|certificate_content|private_key_content|chain_content|ssl_certificate_id|PEM|acme|hsts_|ciphers|alpn)/i, 3],
|
|
];
|
|
|
|
function _locPathToStep(loc, msg) {
|
|
// First-class: pinpoint via the structured `loc` if present
|
|
// (e.g. ['body', 'frontend', 'name'] → step 2). FastAPI
|
|
// prepends 'body' to the Pydantic loc so loc[1] is the
|
|
// top-level SiteCreate field.
|
|
if (Array.isArray(loc) && loc.length >= 2) {
|
|
const top = loc[1];
|
|
if (Object.prototype.hasOwnProperty.call(PYDANTIC_LOC_TO_STEP, top)) {
|
|
return PYDANTIC_LOC_TO_STEP[top];
|
|
}
|
|
}
|
|
// Fallback: SiteCreate-level model_validator errors land with
|
|
// `loc=['body']` (length 1) — Pydantic v2 keeps the failing
|
|
// field out of the loc when the validator runs at the model
|
|
// root. Walk the message text to recover the step.
|
|
if (typeof msg === 'string' && msg) {
|
|
for (const [pattern, target] of PYDANTIC_MSG_TO_STEP_PATTERNS) {
|
|
if (pattern.test(msg)) return target;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function _directiveToStep(directive) {
|
|
if (!directive || typeof directive !== 'string') return null;
|
|
const lower = directive.toLowerCase().trim();
|
|
for (const [prefix, target] of HAPROXY_DIRECTIVE_TO_STEP) {
|
|
if (lower.startsWith(prefix)) return target;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// R18c round 9: sessionStorage key migration. The user-facing brand was
|
|
// renamed "Proxied Host" → "Site" in R18; the component file names
|
|
// (ProxiedHostWizard / ProxiedHostDrafts) were left behind by oversight
|
|
// and are now SiteWizard / SiteDrafts. The sessionStorage key follows
|
|
// the same rename to stay consistent. We READ from BOTH the new and
|
|
// the legacy key for one release window so an operator who saved a
|
|
// draft in a tab still running pre-rename code keeps it on resume.
|
|
const WIZARD_DRAFT_SESSION_KEY = 'site_wizard_draft';
|
|
const LEGACY_WIZARD_DRAFT_SESSION_KEY = 'proxied_host_wizard_draft';
|
|
|
|
const SiteWizard = () => {
|
|
const navigate = useNavigate();
|
|
// Phase K Phase D (Bulgu #1): consume the SAME header cluster
|
|
// context that every other entity page (FrontendManagement,
|
|
// BackendServers, SSLManagement, ACMEAutomation, …) reads from.
|
|
// Pre-fix the wizard's Step 0 had its own cluster Select that was
|
|
// entirely decoupled from the header — operators could (and did)
|
|
// pick cluster A in the header, start the wizard, then choose
|
|
// cluster B on Step 0 with NO visual indication that the wizard
|
|
// would target a different cluster than every other tool. The
|
|
// header is now the single source of truth: form.cluster_id is
|
|
// sync'd from selectedCluster automatically, Step 0 shows a
|
|
// read-only display, and Resume from a different cluster swaps
|
|
// the header to the draft's cluster so post-resume edits stay
|
|
// cluster-consistent.
|
|
const { selectedCluster, clusters: clustersFromContext, selectCluster } = useCluster();
|
|
const [step, setStep] = useState(0);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
// Phase K Phase D (Bulgu #1): the legacy `clusters` local state
|
|
// was set by `fetchInitial` and consumed by Step 0's Select. With
|
|
// Step 0 now reading from useCluster() (the same context every
|
|
// other page uses), the local state is redundant — we keep
|
|
// `clustersFromContext` (also exposed via useCluster) for the
|
|
// Resume flow's draft.cluster_id → header sync. The duplicate
|
|
// /api/clusters call from fetchInitial is removed below.
|
|
const [existingCerts, setExistingCerts] = useState([]);
|
|
// R18c round 10 (M2): track the cluster-scoped existing-cert fetch
|
|
// state so the Submit button can be disabled while a resumed draft
|
|
// is still reconciling its `ssl.ssl_certificate_id` against the
|
|
// freshly-loaded list. Pre-round-10 the Resume flow jumped directly
|
|
// to Review & Apply; if the cert had been deleted in the
|
|
// background, the orphan-cleanup useEffect cleared the field
|
|
// asynchronously *after* the user had already seen the populated
|
|
// payload, and a fast Submit click could race the cleanup and
|
|
// submit a stale id (→ FK 400). With this flag the Submit button
|
|
// (and its keyboard equivalent) reflects "still resolving" state
|
|
// until the fetch terminates and the cleanup pass runs.
|
|
const [existingCertsLoading, setExistingCertsLoading] = useState(false);
|
|
|
|
const [form] = Form.useForm();
|
|
const [preflightResults, setPreflightResults] = useState(null);
|
|
const [preflightLoading, setPreflightLoading] = useState(false);
|
|
const [previewResults, setPreviewResults] = useState(null);
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
const [continueAnyway, setContinueAnyway] = useState(false);
|
|
|
|
// v1.5.0 (Issue #14 R12): Let's Encrypt accounts populate Account selection
|
|
// when an admin manages multiple LE accounts (e.g. staging + prod).
|
|
const [acmeAccounts, setAcmeAccounts] = useState([]);
|
|
|
|
// Default form values — v1.5.0 R12: more comprehensive defaults aligned with
|
|
// HAProxy 2.4+ best practices (timeouts, ssl-min-ver TLSv1.2, ALPN h2).
|
|
const initialValues = {
|
|
domains: [],
|
|
backend: {
|
|
name: '',
|
|
balance_method: 'roundrobin',
|
|
mode: 'http',
|
|
health_check_uri: '/',
|
|
health_check_interval: 2000,
|
|
health_check_expected_status: 200,
|
|
timeout_connect: 10000,
|
|
timeout_server: 60000,
|
|
timeout_queue: 60000,
|
|
},
|
|
servers: [{
|
|
server_name: 'srv1',
|
|
server_address: '',
|
|
server_port: 8080,
|
|
weight: 100,
|
|
check_enabled: true,
|
|
ssl_enabled: false,
|
|
}],
|
|
frontend: {
|
|
name: '',
|
|
mode: 'http',
|
|
bind_address: '*',
|
|
bind_port: 80,
|
|
https_redirect: false,
|
|
compression: false,
|
|
log_separate: false,
|
|
},
|
|
ssl: {
|
|
mode: 'none',
|
|
auto_renew: true,
|
|
https_bind_port: 443,
|
|
https_frontend_name_suffix: '-https',
|
|
ssl_alpn: 'h2,http/1.1',
|
|
ssl_min_ver: 'TLSv1.2',
|
|
hsts_enabled: false,
|
|
hsts_max_age: 31536000,
|
|
hsts_include_subdomains: true,
|
|
hsts_preload: false,
|
|
ssl_strict_sni: false,
|
|
},
|
|
apply_immediately: false,
|
|
};
|
|
|
|
const [resumedFromDraft, setResumedFromDraft] = useState(false);
|
|
// Phase K Phase D follow-up (Bulgu #14) — list of human-readable
|
|
// orphan descriptions ("HTTPS frontend: cert #15", "server #1:
|
|
// ca-file cert #13") surfaced inline on the SSL & ACME step
|
|
// after the orphan-clear effect fires. Cleared once the operator
|
|
// picks a replacement / switches mode (handled by SSL mode
|
|
// onChange below). One-toast lifecycle is still owned by
|
|
// `orphanWarnedRef`; this is the *persistent* in-page banner so
|
|
// operators who dismiss the toast still see the explanation.
|
|
const [orphanClearedCerts, setOrphanClearedCerts] = useState([]);
|
|
const [pemStrippedOnResume, setPemStrippedOnResume] = useState(false);
|
|
|
|
// Phase F (ACL UX parity with FrontendManagement): visual ACL Rule
|
|
// Builder embedded in the Frontend step. The builder is the SAME
|
|
// component used by the standalone FrontendManagement page so the
|
|
// operator gets identical drag-list-based UX (match-type dropdowns,
|
|
// backend pickers, redirect URL helpers) instead of a free-text
|
|
// HAProxy directive textarea.
|
|
//
|
|
// We keep the ACL state OUT of the antd Form (the builder is a
|
|
// controlled component with its own callback shape) and merge the
|
|
// three rule arrays back into the wizard payload at submit time.
|
|
// The `aclBuilderKey` lets us force a remount when the operator
|
|
// resumes a draft so the builder hydrates the saved rules correctly
|
|
// (otherwise the controlled-component cache from the previous
|
|
// render keeps the empty arrays).
|
|
const [aclBuilderData, setAclBuilderData] = useState({
|
|
aclRules: [],
|
|
useBackendRules: [],
|
|
redirectRules: [],
|
|
});
|
|
const [aclBuilderKey, setAclBuilderKey] = useState(0);
|
|
|
|
// Phase K Phase C: HAProxy dry-run validation state.
|
|
//
|
|
// Status state machine (mirrors the validation card the wizard
|
|
// renders on Step 4):
|
|
// idle — mounted, will auto-fire on Step 4 entry
|
|
// loading — in flight (Spin)
|
|
// clean — is_valid: true, no warnings (green)
|
|
// warnings_only — is_valid: true, warnings.length > 0 (yellow)
|
|
// errors — is_valid: false (red, blocks Create)
|
|
// pydantic_error — HTTP 422 from /preview (red, blocks Create)
|
|
// unavailable — is_valid: null OR 5xx / 429 / network (orange,
|
|
// Create stays enabled — mirrors create_site's
|
|
// validator-crash-is-non-fatal posture)
|
|
const [dryRunResult, setDryRunResult] = useState({
|
|
status: 'idle',
|
|
errors: [],
|
|
warnings: [],
|
|
infos: [],
|
|
pydanticErrors: [],
|
|
message: '',
|
|
});
|
|
// AbortController ref so a rapid Step 4 → Step 2 → Step 4 navigation
|
|
// cancels the in-flight request instead of stacking.
|
|
const dryRunAbortRef = useRef(null);
|
|
// Phase K Phase C audit-fix #3: critical race-condition fix for the
|
|
// "Validating against HAProxy…" stuck state.
|
|
//
|
|
// The original dry-run effect had `dryRunResult.status` in its deps
|
|
// array AND called `setDryRunResult({status: 'loading'})` at the top
|
|
// of its body. That meant:
|
|
// 1. step=4 entry, status='idle' → effect body runs, fires fetch,
|
|
// registers a cleanup `() => controller.abort()`.
|
|
// 2. setStatus('loading') commits → re-render → effect re-runs
|
|
// because status changed (idle → loading).
|
|
// 3. Cleanup from step 1 fires BEFORE the new body: it aborts the
|
|
// controller — i.e. the in-flight fetch we just started!
|
|
// 4. New body sees `status !== 'idle'` and returns early without
|
|
// registering a new fetch.
|
|
// 5. The aborted fetch's `.catch` checks `signal.aborted` and
|
|
// returns without setting state. Status remains 'loading'
|
|
// forever. The card sits on "Validating against HAProxy…".
|
|
//
|
|
// Audit-fix #1 (round 1) only addressed the leave-Step-4 cleanup
|
|
// branch; the enter-Step-4 self-abort was a separate failure mode
|
|
// that surfaced as soon as a user actually drove the wizard end-
|
|
// to-end on a real backend.
|
|
//
|
|
// The fix has three pieces:
|
|
// * `dryRunStatusRef` shadows the current status so the main
|
|
// effect can guard "already in flight / completed" reads
|
|
// WITHOUT putting `dryRunResult.status` in the dep array.
|
|
// * `dryRunInvalidationTick` is an explicit re-trigger
|
|
// counter; anywhere a caller wants to invalidate the cached
|
|
// result AND re-fire the dry-run, it bumps the tick. The
|
|
// onValuesChange handler does this when the operator edits a
|
|
// Step-4-visible field (e.g. the Apply Immediately switch).
|
|
// * The main effect's deps shrink to `[step, aclBuilderData,
|
|
// dryRunInvalidationTick]` — none of these change on a self-
|
|
// issued setDryRunResult, so the cleanup-aborts-its-own-
|
|
// fetch race is structurally impossible.
|
|
const dryRunStatusRef = useRef('idle');
|
|
useEffect(() => {
|
|
dryRunStatusRef.current = dryRunResult.status;
|
|
}, [dryRunResult.status]);
|
|
const [dryRunInvalidationTick, setDryRunInvalidationTick] = useState(0);
|
|
// Cluster-scoped existing backends so the operator can route ACL
|
|
// matches to backends OTHER than the wizard's brand-new one (e.g.
|
|
// route /api/* to an existing api-backend the team already runs).
|
|
// Pre-populated when cluster_id changes and surfaced in the
|
|
// ACLRuleBuilder backend dropdown alongside the wizard's new
|
|
// backend name (virtual entry).
|
|
const [clusterBackends, setClusterBackends] = useState([]);
|
|
|
|
// R15 hotfix: Form.useWatch MUST be hoisted ABOVE the stepContents array
|
|
// literal. Previously these lived after stepContents at the bottom of the
|
|
// component, but stepContents references `sslMode` inline (e.g. the SSL
|
|
// step's ACME info banner) — that's a `const` referenced before its
|
|
// declaration in the same render scope. Webpack's production minifier
|
|
// renames sslMode to a single letter ("N") and a strict-mode TDZ access
|
|
// crashes the entire wizard with:
|
|
// ReferenceError: Cannot access 'N' before initialization
|
|
// Dev mode tolerates this because variable hoisting / scope handling
|
|
// is looser before minification — production users hit a white page.
|
|
// Fix: declare these reactive watchers at the top of the component, so
|
|
// every downstream JSX expression sees them already initialised.
|
|
const sslMode = Form.useWatch(['ssl', 'mode'], form);
|
|
// Phase K Phase D (Bulgu #6): `acmeBlocksDraft` was the gate for
|
|
// the now-removed "Create as PENDING" button. With a single
|
|
// submit button driven by `effectiveApply = sslMode === 'acme'`
|
|
// (see handleSubmit) the gate is no longer needed — ACME's
|
|
// apply_immediately=true is enforced by handleSubmit itself.
|
|
|
|
// Phase F: Form.useWatch hooks for the ACL UX so the builder can
|
|
// surface the wizard's brand-new backend (`backend.name`, still
|
|
// being typed) alongside cluster-scoped existing backends. The
|
|
// `cluster_id` watch is declared further down (it pre-existed for
|
|
// SSL refresh) and re-used by the ACL backend-fetch effect below.
|
|
const watchedBackendName = Form.useWatch(['backend', 'name'], form);
|
|
const watchedFrontendMode = Form.useWatch(['frontend', 'mode'], form);
|
|
const watchedHttpsRedirect = Form.useWatch(['frontend', 'https_redirect'], form);
|
|
|
|
// Composite list passed to the ACL builder: cluster-scoped existing
|
|
// backends PLUS a synthetic entry for the wizard's new backend
|
|
// (only when the user has typed a name). Filter out the synthetic
|
|
// entry if it collides with an existing backend so dropdowns stay
|
|
// clean. Memoised so the builder doesn't see a fresh array on
|
|
// every keystroke.
|
|
const aclBuilderBackends = useMemo(() => {
|
|
const existing = Array.isArray(clusterBackends) ? clusterBackends : [];
|
|
const newName = (watchedBackendName || '').trim();
|
|
if (!newName) return existing;
|
|
const collision = existing.some((b) => b && b.name === newName);
|
|
if (collision) return existing;
|
|
// Match the shape FrontendManagement passes: { id, name, mode }.
|
|
// The builder only reads `name`, but matching the shape avoids
|
|
// surprise undefined-access regressions if the builder evolves.
|
|
const synthetic = { id: `wizard-new-${newName}`, name: newName, mode: 'http', _isWizardNew: true };
|
|
return [synthetic, ...existing];
|
|
}, [clusterBackends, watchedBackendName]);
|
|
|
|
const fetchInitial = useCallback(async () => {
|
|
// R18c round 10 (M5): pre-round-10 a silent `catch { }` hid every
|
|
// failure mode here (network outage, 500/503 from cluster manager,
|
|
// 401/403 from a stale token) and the wizard rendered with empty
|
|
// cluster + ACME account lists. Operators interpreted the empty
|
|
// dropdowns as "RBAC says I have no clusters" and filed false
|
|
// permission tickets. Promise.allSettled never throws, so the outer
|
|
// try/catch only ever caught synchronous bugs — replaced with
|
|
// explicit per-call surfacing of the rejection reason via
|
|
// extractApiError + a single message.warning that is dismissable
|
|
// and does NOT block the wizard (it stays usable for ACME-account
|
|
// independent flows like "ssl.mode=upload"). Errors are emitted
|
|
// separately so a partial outage shows exactly what is broken.
|
|
// Phase K Phase D (Bulgu #1): /api/clusters is owned by
|
|
// ClusterContext and shared across every page — duplicating
|
|
// the fetch here would double the load on cluster-manager AND
|
|
// race the context's hydration. Only fetch ACME accounts,
|
|
// which the wizard exclusively uses.
|
|
const [acmeRes] = await Promise.allSettled([
|
|
axios.get('/api/letsencrypt/accounts'),
|
|
]);
|
|
if (acmeRes.status === 'fulfilled') {
|
|
const accounts = Array.isArray(acmeRes.value.data)
|
|
? acmeRes.value.data
|
|
: (acmeRes.value.data?.accounts || []);
|
|
setAcmeAccounts(accounts.filter((a) => a.status === 'valid' || !a.status));
|
|
} else {
|
|
// ACME accounts are optional for non-ACME wizard runs; downgrade
|
|
// to an info-level toast so it is informational rather than
|
|
// alarming when ssl.mode is 'upload' / 'existing' / 'none'.
|
|
message.info(extractApiError(acmeRes.reason, "Could not load Let's Encrypt accounts"));
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { fetchInitial(); }, [fetchInitial]);
|
|
|
|
// R18 fix: the existing-certificate list is cluster-scoped.
|
|
//
|
|
// Pre-R18 the wizard called `axios.get('/api/ssl')` which does not exist
|
|
// (correct endpoint is `/api/ssl/certificates`). Result: every "Reuse
|
|
// existing cert" attempt rendered an empty Select with "No data" — even
|
|
// though the cluster had cluster-specific AND global certs imported.
|
|
//
|
|
// The list ALSO needs to refetch whenever the user changes the cluster
|
|
// on Step 0: a cert that's global OR scoped to cluster A must not show
|
|
// up while the wizard is targeting cluster B. We watch ['cluster_id']
|
|
// and refetch on every change. The list remains empty until a cluster
|
|
// is picked (matches the manual SSL Management UX).
|
|
const watchedClusterId = Form.useWatch('cluster_id', form);
|
|
// Phase K Phase D (Bulgu #1): keep form.cluster_id locked to the
|
|
// header's selectedCluster. Two-way considerations:
|
|
// * Initial mount: form.cluster_id is 0 (default). If header
|
|
// already has a cluster, push it into the form so Step 0
|
|
// renders with the right value AND watchedClusterId-keyed
|
|
// fetches (existing certs, cluster backends) fire on the
|
|
// correct cluster from the first render.
|
|
// * Header changes mid-wizard: re-sync form.cluster_id so the
|
|
// fetches refresh and the cluster-transition cleanup effect
|
|
// wipes stale per-cluster cert ids. Pre-fix this was a hidden
|
|
// trap — header showed cluster B but Step 0 still showed
|
|
// cluster A and Submit went to cluster A.
|
|
// Pin: prevClusterRef in the cluster-transition cleanup effect
|
|
// below detects the mismatch and clears orphaned ssl_certificate_id
|
|
// selections without spurious warnings.
|
|
//
|
|
// Phase K Phase D (Bulgu #7) — Resume cluster swap race fix.
|
|
//
|
|
// On a COLD mount (browser refresh of /sites/new after a Resume
|
|
// click populated sessionStorage), ClusterContext is still loading
|
|
// when the resume effect fires. `selectCluster(draftCluster)` is
|
|
// skipped because `clustersFromContext=[]`. Then ClusterContext
|
|
// finishes loading and `selectedCluster` becomes `defaultCluster`
|
|
// (NOT the draft cluster) — and the naive header sync below would
|
|
// overwrite the draft's `form.cluster_id` with the default cluster.
|
|
// The cluster-transition cleanup effect then misreads that
|
|
// overwrite as a user-driven switch and wipes the draft's cert
|
|
// selections (the Bulgu #5 second-order failure).
|
|
//
|
|
// The fix: when we ARE in a post-resume state AND the draft's
|
|
// cluster is now visible in `clustersFromContext`, push the
|
|
// header TO the draft cluster (one-shot) instead of forcing the
|
|
// form to follow the header. `resumeClusterSynced` gates the
|
|
// swap to exactly one attempt so a later operator-driven header
|
|
// cluster change is honoured normally.
|
|
const selectClusterRef = useRef(selectCluster);
|
|
useEffect(() => {
|
|
selectClusterRef.current = selectCluster;
|
|
}, [selectCluster]);
|
|
const [resumeClusterSynced, setResumeClusterSynced] = useState(false);
|
|
useEffect(() => {
|
|
if (!selectedCluster) return;
|
|
const cur = form.getFieldValue('cluster_id');
|
|
if (cur === selectedCluster.id) return;
|
|
if (
|
|
resumedFromDraft &&
|
|
!resumeClusterSynced &&
|
|
cur &&
|
|
Array.isArray(clustersFromContext) &&
|
|
clustersFromContext.length > 0
|
|
) {
|
|
const draftCluster = clustersFromContext.find((c) => c.id === cur);
|
|
if (draftCluster) {
|
|
try { selectClusterRef.current(draftCluster); } catch (_) { /* noop */ }
|
|
setResumeClusterSynced(true);
|
|
return;
|
|
}
|
|
}
|
|
form.setFieldsValue({ cluster_id: selectedCluster.id });
|
|
if (!resumeClusterSynced) setResumeClusterSynced(true);
|
|
// selectCluster is read via ref to keep the dep set small and
|
|
// avoid spurious re-runs on every ClusterProvider render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedCluster, form, resumedFromDraft, resumeClusterSynced, clustersFromContext]);
|
|
// R18 audit fix: track the previous cluster so we can clear stale
|
|
// cert selections (`ssl.ssl_certificate_id` and per-server
|
|
// `ssl_certificate_id`) when the user switches clusters mid-wizard.
|
|
// Without this, a cert id valid for cluster A would still sit in the
|
|
// form when switching to cluster B and either confuse the user (the
|
|
// dropdown would show "(invalid)") or land in the submit payload and
|
|
// get rejected at the backend with a generic 400. We use a ref to
|
|
// detect the *transition*; the very first hydration must not clear.
|
|
const prevClusterRef = React.useRef(undefined);
|
|
useEffect(() => {
|
|
if (
|
|
prevClusterRef.current !== undefined &&
|
|
prevClusterRef.current !== watchedClusterId
|
|
) {
|
|
try {
|
|
const cur = form.getFieldsValue(true);
|
|
const updates = {};
|
|
if (cur?.ssl?.ssl_certificate_id) {
|
|
updates.ssl = { ...(cur.ssl || {}), ssl_certificate_id: undefined };
|
|
}
|
|
if (Array.isArray(cur?.servers)) {
|
|
updates.servers = cur.servers.map((s) =>
|
|
s && s.ssl_certificate_id ? { ...s, ssl_certificate_id: undefined } : s,
|
|
);
|
|
}
|
|
if (Object.keys(updates).length) {
|
|
form.setFieldsValue(updates);
|
|
}
|
|
} catch (_e) { /* noop — best-effort cleanup */ }
|
|
// Phase K Phase D (Bulgu #8): mid-wizard cluster change must
|
|
// invalidate any cached dry-run result. The validation
|
|
// applies to the OLD cluster's HAProxy synthesis; after the
|
|
// switch the operator's wizard payload targets a different
|
|
// cluster's config so a "clean" card lingering from the
|
|
// previous cluster would mislead. The cluster change goes
|
|
// through `form.setFieldsValue` (header sync) which is a
|
|
// SILENT Antd update (does NOT fire `onValuesChange`), so
|
|
// the onValuesChange invalidation path below cannot catch
|
|
// it. Force the reset + tick bump here so the dry-run
|
|
// effect re-fires against the new cluster the next time the
|
|
// operator is on Step 4.
|
|
if (dryRunStatusRef.current !== 'idle') {
|
|
setDryRunResult({
|
|
status: 'idle',
|
|
errors: [],
|
|
warnings: [],
|
|
infos: [],
|
|
pydanticErrors: [],
|
|
message: '',
|
|
});
|
|
setDryRunInvalidationTick((t) => t + 1);
|
|
}
|
|
}
|
|
prevClusterRef.current = watchedClusterId;
|
|
}, [watchedClusterId, form]);
|
|
useEffect(() => {
|
|
if (!watchedClusterId) {
|
|
setExistingCerts([]);
|
|
setExistingCertsLoading(false);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setExistingCertsLoading(true);
|
|
(async () => {
|
|
try {
|
|
// No usage_type filter — the wizard surfaces existing certs for
|
|
// BOTH HTTPS frontend ('frontend' usage) and server-side mTLS
|
|
// ('client' / generic) selectors. Filtering server-side would
|
|
// hide perfectly valid choices.
|
|
const res = await axios.get(
|
|
`/api/ssl/certificates?cluster_id=${encodeURIComponent(watchedClusterId)}`,
|
|
);
|
|
if (cancelled) return;
|
|
const data = res.data;
|
|
const certs = Array.isArray(data) ? data : (data?.certificates || []);
|
|
// Surface the most relevant fields for the dropdown — `name` is
|
|
// the operator-given label, `domain` (= primary_domain) gives
|
|
// visual context. `is_active=false` certs are already filtered
|
|
// server-side; we still defensively keep only active rows.
|
|
setExistingCerts(
|
|
certs
|
|
.filter((c) => c && (c.is_active !== false))
|
|
.map((c) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
primary_domain: c.primary_domain || c.domain || '',
|
|
ssl_type: c.ssl_type,
|
|
usage_type: c.usage_type,
|
|
})),
|
|
);
|
|
} catch (err) {
|
|
if (cancelled) return;
|
|
// R18 audit fix (round 4 #G): pre-R18 every error fell into
|
|
// the same "set empty" branch — including 401 from the now-
|
|
// protected /api/ssl/certificates endpoint. That made an
|
|
// expired session look like "no certs imported" instead of a
|
|
// login prompt. Surface auth failures explicitly so the
|
|
// operator knows to re-authenticate; for any other error we
|
|
// still degrade gracefully to an empty Select (the wizard
|
|
// works in upload/acme/none modes regardless).
|
|
const status = err?.response?.status;
|
|
setExistingCerts([]);
|
|
if (status === 401) {
|
|
try { message.error('Session expired — please sign in again to load certificates.'); } catch (_) { /* noop */ }
|
|
}
|
|
} finally {
|
|
if (!cancelled) setExistingCertsLoading(false);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [watchedClusterId]);
|
|
|
|
// Phase F: cluster-scoped existing-backend fetch for the embedded
|
|
// ACLRuleBuilder. The use_backend_rules dropdown needs the OTHER
|
|
// backends in this cluster so the operator can route ACL matches
|
|
// (e.g. /api/* → existing api-backend) without hand-typing a name
|
|
// and risking a typo that would silently break HAProxy parsing.
|
|
// Mirrors the same /api/backends?cluster_id=... call that
|
|
// FrontendManagement uses, so wizard parity matches manual UX.
|
|
useEffect(() => {
|
|
if (!watchedClusterId) {
|
|
setClusterBackends([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const res = await axios.get('/api/backends', {
|
|
params: { cluster_id: watchedClusterId },
|
|
headers: {
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
'Pragma': 'no-cache',
|
|
},
|
|
});
|
|
if (cancelled) return;
|
|
const data = res.data;
|
|
const list = Array.isArray(data) ? data : (data?.backends || []);
|
|
setClusterBackends(list);
|
|
} catch (err) {
|
|
if (cancelled) return;
|
|
// Soft-fail: if backend listing is unavailable the wizard
|
|
// still works (the operator can route ACLs to the wizard's
|
|
// brand-new backend; existing-backend routing is opt-in
|
|
// sugar). Log to console so diagnosis is possible without
|
|
// blocking the create flow.
|
|
console.warn('Wizard: failed to fetch cluster backends for ACL builder:', err);
|
|
setClusterBackends([]);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [watchedClusterId]);
|
|
|
|
// R18b audit fix (round 3 #7): when a draft is resumed it can carry
|
|
// an `ssl.ssl_certificate_id` (or per-server `ssl_certificate_id`)
|
|
// pointing at a cert that has since been deleted via SSL Management.
|
|
// Pre-fix the Select silently rendered the orphan id as an empty
|
|
// option and the operator had no idea the cert was gone — submit
|
|
// would then 400 at FK validation. We watch for that mismatch
|
|
// whenever existingCerts refresh, surface a single warning toast,
|
|
// and clear the dangling field so the operator picks a valid cert
|
|
// (or switches mode). Cleared in one shot to avoid spamming toasts.
|
|
const orphanWarnedRef = useRef(new Set());
|
|
useEffect(() => {
|
|
if (!watchedClusterId) return;
|
|
// Phase K Phase C audit-fix #4 (Bulgu #5): the orphan-detect
|
|
// pass must NOT run while the existingCerts fetch is in
|
|
// flight. Pre-fix the effect ran on every render where
|
|
// `watchedClusterId` had just changed (e.g. resume from draft
|
|
// updating cluster_id=7 via form.setFieldsValue). At that
|
|
// moment `existingCerts` is still the previous tick's empty
|
|
// array — so `certIds = new Set()` and any form-resident
|
|
// ssl_certificate_id looks like an orphan. The effect then
|
|
// calls `form.setFieldsValue({ssl: {ssl_certificate_id:
|
|
// undefined}})`, wiping the freshly-resumed selection.
|
|
// Operator returns to Step 3 and sees an empty dropdown even
|
|
// though the draft DID carry a valid cert id.
|
|
//
|
|
// The fix: short-circuit while the certs are still loading.
|
|
// The effect will re-run when existingCerts changes from []
|
|
// to the loaded list (deps include existingCerts), at which
|
|
// point certIds reflects reality and the orphan check is
|
|
// accurate.
|
|
if (existingCertsLoading) return;
|
|
// Bulgu #2 follow-up — usage_type compatibility is part of
|
|
// "exists for THIS slot". The HTTPS frontend bind expects a
|
|
// cert with `usage_type='frontend'` (matches BackendServers.js
|
|
// and the SSL Management filter); the per-server `ca-file`
|
|
// slot expects `usage_type='server'`. Pre-fix, a cert that an
|
|
// admin re-tagged from 'frontend' → 'server' on the SSL
|
|
// Management page would pass the orphan check (id is still in
|
|
// the cluster list) but the dropdown filter below would hide
|
|
// it from the operator. They'd see a populated form field
|
|
// with no matching option — Pydantic would then reject at
|
|
// submit ("ssl.mode='existing' requires ssl.ssl_certificate_id"
|
|
// / "A CA bundle is required..."). Treat usage_type mismatch
|
|
// as orphan so the form is auto-cleared and the operator is
|
|
// prompted to pick a replacement on Step 3.
|
|
const certById = new Map();
|
|
for (const c of existingCerts) certById.set(c.id, c);
|
|
const isCompatible = (certId, expectedUsage) => {
|
|
const c = certById.get(certId);
|
|
if (!c) return false;
|
|
// Legacy rows without `usage_type` default to 'frontend' on
|
|
// the backend (routers/ssl.py). Treat undefined === 'frontend'
|
|
// so the operator's pre-usage_type SSL imports keep working.
|
|
const ut = c.usage_type || 'frontend';
|
|
return ut === expectedUsage;
|
|
};
|
|
const cur = form.getFieldsValue(true);
|
|
const orphans = [];
|
|
const updates = {};
|
|
if (
|
|
cur?.ssl?.ssl_certificate_id
|
|
&& !isCompatible(cur.ssl.ssl_certificate_id, 'frontend')
|
|
) {
|
|
const detail = certById.has(cur.ssl.ssl_certificate_id)
|
|
? `wrong usage_type (expected 'frontend')`
|
|
: `not found`;
|
|
orphans.push(
|
|
`HTTPS frontend: cert #${cur.ssl.ssl_certificate_id} (${detail})`,
|
|
);
|
|
updates.ssl = { ...(cur.ssl || {}), ssl_certificate_id: undefined };
|
|
}
|
|
if (Array.isArray(cur?.servers)) {
|
|
const newServers = cur.servers.map((s, i) => {
|
|
if (s && s.ssl_certificate_id && !isCompatible(s.ssl_certificate_id, 'server')) {
|
|
const detail = certById.has(s.ssl_certificate_id)
|
|
? `wrong usage_type (expected 'server')`
|
|
: `not found`;
|
|
orphans.push(
|
|
`server #${i + 1}: ca-file cert #${s.ssl_certificate_id} (${detail})`,
|
|
);
|
|
return { ...s, ssl_certificate_id: undefined };
|
|
}
|
|
return s;
|
|
});
|
|
if (newServers.some((s, i) => s !== cur.servers[i])) {
|
|
updates.servers = newServers;
|
|
}
|
|
}
|
|
if (orphans.length) {
|
|
const key = orphans.sort().join('|');
|
|
if (!orphanWarnedRef.current.has(key)) {
|
|
orphanWarnedRef.current.add(key);
|
|
try {
|
|
message.warning({
|
|
content: `Selected SSL certificate(s) no longer exist and were cleared: ${orphans.join(', ')}.`,
|
|
duration: 6,
|
|
});
|
|
} catch (_) { /* noop */ }
|
|
}
|
|
// Bulgu #14 — persistent in-page banner. The toast above is
|
|
// ephemeral and operators on a busy screen sometimes miss it
|
|
// entirely. Surface the same diagnostic on Step 3 (SSL & ACME)
|
|
// so the operator can act on it after they've navigated there.
|
|
setOrphanClearedCerts(orphans.slice());
|
|
// Phase K Phase D follow-up (Bulgu #14) — when the cleared cert
|
|
// is the HTTPS frontend cert AND `ssl.mode === 'existing'`,
|
|
// the form is left in a logically inconsistent state
|
|
// (`mode=existing` requires `ssl_certificate_id`). The
|
|
// Pydantic guard rejects that at submit and the wizard's
|
|
// dry-run on Step 5 surfaces a hard validation error,
|
|
// confusingly blaming the operator for state THEY did not
|
|
// create (the orphan-cleanup did). Auto-degrade the mode
|
|
// to 'none' so the form stays self-consistent and the
|
|
// dry-run proceeds — the operator is then prompted via a
|
|
// banner on Step 3 (SSL & ACME) to pick a replacement.
|
|
if (
|
|
updates.ssl
|
|
&& updates.ssl.ssl_certificate_id === undefined
|
|
&& (cur?.ssl?.mode === 'existing')
|
|
) {
|
|
updates.ssl = { ...updates.ssl, mode: 'none' };
|
|
try {
|
|
message.warning({
|
|
content:
|
|
'SSL mode reverted to "none" because the previously '
|
|
+ 'selected certificate is no longer available. Re-open '
|
|
+ 'Step 4 (SSL & ACME) to pick a replacement or switch '
|
|
+ 'mode before creating the site.',
|
|
duration: 8,
|
|
});
|
|
} catch (_) { /* noop */ }
|
|
}
|
|
try { form.setFieldsValue(updates); } catch (_) { /* noop */ }
|
|
|
|
// Phase K Phase D follow-up (Bulgu #14) — if the orphan
|
|
// clear happened while the operator was already parked at
|
|
// Review & Apply (most-common path after a draft resume),
|
|
// bounce them back to the SSL step so the cleared field
|
|
// is visible and easy to fix. We do this exactly once per
|
|
// distinct orphan set — same dedup key as the toast — so
|
|
// a re-render of the same orphan state doesn't keep
|
|
// hijacking the operator's navigation.
|
|
if (step === WIZARD_LAST_STEP && resumedFromDraft) {
|
|
try {
|
|
// Defer the navigation to next tick so the form
|
|
// setFieldsValue above flushes first; otherwise the
|
|
// Step 3 render reads the pre-clear ssl.ssl_certificate_id
|
|
// out of the form snapshot and the banner flashes.
|
|
setTimeout(() => setStep(SSL_STEP_INDEX), 0);
|
|
} catch (_) { /* noop */ }
|
|
}
|
|
}
|
|
}, [existingCerts, existingCertsLoading, watchedClusterId, form, step, resumedFromDraft]);
|
|
|
|
// R18c round 10 (M8): pre-round-10 the Review step had a render-time
|
|
// setTimeout(...setFieldsValue, 0) hack to force apply_immediately=true
|
|
// when ssl.mode=='acme'. That is a setState-during-render anti-pattern
|
|
// — React 18 Strict Mode double-renders it, and rapid step navigation
|
|
// can stack pending timeouts. Move the coercion to a proper useEffect
|
|
// that watches sslMode (already a Form.useWatch above) so the form
|
|
// field updates exactly once per mode transition, before the operator
|
|
// sees the Review step.
|
|
useEffect(() => {
|
|
if (sslMode === 'acme') {
|
|
const cur = form.getFieldValue('apply_immediately');
|
|
if (cur !== true) {
|
|
form.setFieldsValue({ apply_immediately: true });
|
|
}
|
|
}
|
|
}, [sslMode, form]);
|
|
|
|
// Phase K Phase B: when the operator switches the frontend to TCP
|
|
// mode, force-clear `https_redirect`. The Pydantic
|
|
// `reject_tcp_mode_with_https_redirect` validator (Phase A) refuses
|
|
// this combination at submit time; clearing it eagerly keeps the
|
|
// form in a submittable state without surprising the user. We only
|
|
// clear the boolean flag — the Switch is also disabled below in
|
|
// the JSX so the operator cannot re-enable it while `mode==='tcp'`.
|
|
useEffect(() => {
|
|
if (watchedFrontendMode === 'tcp' && watchedHttpsRedirect) {
|
|
form.setFields([
|
|
{ name: ['frontend', 'https_redirect'], value: false },
|
|
]);
|
|
}
|
|
}, [watchedFrontendMode, watchedHttpsRedirect, form]);
|
|
|
|
// Phase K Phase C: auto-fire the HAProxy dry-run validation when
|
|
// the operator enters Step 4 (Review & Apply). The validation card
|
|
// we render below switches from `idle` → `loading` → final state
|
|
// based on the response severity. AbortController cancels the
|
|
// in-flight request when the operator navigates away (rapid
|
|
// Step 4 → Step 2 → Step 4 shouldn't stack requests).
|
|
useEffect(() => {
|
|
if (step !== WIZARD_LAST_STEP) {
|
|
if (dryRunAbortRef.current) {
|
|
try { dryRunAbortRef.current.abort(); } catch (_) { /* noop */ }
|
|
dryRunAbortRef.current = null;
|
|
}
|
|
// Phase K Phase C audit-fix #1: when the operator navigates
|
|
// AWAY from Step 4, also reset the cached dry-run result to
|
|
// `idle`. Otherwise a stale `clean` / `errors` /
|
|
// `warnings_only` status survives across the round-trip and
|
|
// the auto-fire branch below (`if (statusRef !== 'idle') return
|
|
// undefined;`) suppresses the next fetch when the operator
|
|
// returns. The most common path that exposes this is Step 2 →
|
|
// ACL edit → Step 4: the ACL builder lives OUTSIDE the antd
|
|
// Form so its mutations do NOT trigger Form's onValuesChange
|
|
// invalidation. Without this reset the operator sees
|
|
// yesterday's validation result even after they added /
|
|
// removed routing rules. Re-running on every Step 4 entry is
|
|
// the explicit Phase C plan contract ("auto-fire dry-run on
|
|
// Step 4 entry") and is rate-limit-safe because the entry
|
|
// itself is operator-initiated (Next button click; no
|
|
// programmatic loop).
|
|
if (dryRunStatusRef.current !== 'idle') {
|
|
setDryRunResult({
|
|
status: 'idle',
|
|
errors: [],
|
|
warnings: [],
|
|
infos: [],
|
|
pydanticErrors: [],
|
|
message: '',
|
|
});
|
|
}
|
|
return undefined;
|
|
}
|
|
// Phase K Phase C audit-fix #3: read via REF so we don't need
|
|
// `dryRunResult.status` in the dep array. The dep array
|
|
// contains only externally-driven triggers (step change,
|
|
// aclBuilderData change, tick bump from onValuesChange) so a
|
|
// self-issued setDryRunResult({status: 'loading'}) below
|
|
// cannot re-fire the effect and abort its own fetch.
|
|
if (dryRunStatusRef.current !== 'idle') return undefined;
|
|
|
|
const controller = new AbortController();
|
|
dryRunAbortRef.current = controller;
|
|
setDryRunResult({
|
|
status: 'loading',
|
|
errors: [],
|
|
warnings: [],
|
|
infos: [],
|
|
pydanticErrors: [],
|
|
message: '',
|
|
});
|
|
|
|
(async () => {
|
|
try {
|
|
const values = form.getFieldsValue(true);
|
|
const valuesWithAcls = {
|
|
...values,
|
|
frontend: {
|
|
...(values.frontend || {}),
|
|
acl_rules: aclBuilderData.aclRules || [],
|
|
use_backend_rules: aclBuilderData.useBackendRules || [],
|
|
redirect_rules: aclBuilderData.redirectRules || [],
|
|
},
|
|
};
|
|
const res = await axios.post(
|
|
'/api/sites/preview',
|
|
valuesWithAcls,
|
|
{
|
|
params: { validate_haproxy_config: true },
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
if (controller.signal.aborted) return;
|
|
const v = res.data?.validation;
|
|
if (!v) {
|
|
setDryRunResult({
|
|
status: 'unavailable',
|
|
errors: [], warnings: [], infos: [], pydanticErrors: [],
|
|
message: 'HAProxy validation unavailable in this response.',
|
|
});
|
|
return;
|
|
}
|
|
if (v.is_valid === null) {
|
|
setDryRunResult({
|
|
status: 'unavailable',
|
|
errors: [], warnings: [], infos: [], pydanticErrors: [],
|
|
message: v.validator_error || 'Validator infrastructure error.',
|
|
});
|
|
} else if (v.is_valid === false) {
|
|
setDryRunResult({
|
|
status: 'errors',
|
|
errors: v.errors || [],
|
|
warnings: v.warnings || [],
|
|
infos: v.infos || [],
|
|
pydanticErrors: [],
|
|
message: '',
|
|
});
|
|
} else if ((v.warning_count || 0) > 0 || (v.warnings || []).length > 0) {
|
|
setDryRunResult({
|
|
status: 'warnings_only',
|
|
errors: [],
|
|
warnings: v.warnings || [],
|
|
infos: v.infos || [],
|
|
pydanticErrors: [],
|
|
message: '',
|
|
});
|
|
} else {
|
|
setDryRunResult({
|
|
status: 'clean',
|
|
errors: [],
|
|
warnings: [],
|
|
infos: v.infos || [],
|
|
pydanticErrors: [],
|
|
message: '',
|
|
});
|
|
}
|
|
} catch (err) {
|
|
if (
|
|
controller.signal.aborted ||
|
|
err?.name === 'CanceledError' ||
|
|
err?.name === 'AbortError' ||
|
|
err?.code === 'ERR_CANCELED'
|
|
) {
|
|
return;
|
|
}
|
|
const status = err?.response?.status;
|
|
if (status === 422 && Array.isArray(err.response?.data?.detail)) {
|
|
setDryRunResult({
|
|
status: 'pydantic_error',
|
|
errors: [], warnings: [], infos: [], pydanticErrors: err.response.data.detail,
|
|
message: '',
|
|
});
|
|
} else if (status === 429) {
|
|
setDryRunResult({
|
|
status: 'unavailable',
|
|
errors: [], warnings: [], infos: [], pydanticErrors: [],
|
|
message: "You're validating too quickly — wait a few seconds and try again.",
|
|
});
|
|
} else {
|
|
setDryRunResult({
|
|
status: 'unavailable',
|
|
errors: [], warnings: [], infos: [], pydanticErrors: [],
|
|
message: extractApiError(err, 'HAProxy validation could not be performed at this time.'),
|
|
});
|
|
}
|
|
} finally {
|
|
if (dryRunAbortRef.current === controller) {
|
|
dryRunAbortRef.current = null;
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
// Phase K Phase C audit-fix #3: only this effect's OWN
|
|
// controller may be aborted on cleanup. We also clear the
|
|
// ref ONLY if it still points to this controller so a fresh
|
|
// fetch that overwrote the ref between effect runs is not
|
|
// accidentally nulled.
|
|
if (controller && !controller.signal.aborted) {
|
|
try { controller.abort(); } catch (_) { /* noop */ }
|
|
}
|
|
if (dryRunAbortRef.current === controller) {
|
|
dryRunAbortRef.current = null;
|
|
}
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [step, form, aclBuilderData, dryRunInvalidationTick]);
|
|
|
|
// Bulgu #5 fix: hydrate the form from a draft when SiteDrafts navigated
|
|
// here with sessionStorage payload. Detect PEM-stripped fields so we can warn
|
|
// the user that they were scrubbed at save time (M14/M9).
|
|
//
|
|
// R13 fix (Bulgu #v9): use a TWO-LEVEL deep merge instead of a flat
|
|
// spread. A drafted payload from the v1.5.0 first deploy lacks the new
|
|
// R12 advanced fields (ssl_alpn / ssl_min_ver / hsts_*, backend
|
|
// timeout_*, etc.). With a flat spread `{...initialValues, ...parsed}`
|
|
// the parsed.ssl object would replace initialValues.ssl WHOLESALE,
|
|
// wiping the modern UI defaults the wizard pre-populates for new
|
|
// hosts. Merge each top-level group dictionary so missing nested keys
|
|
// fall back to initialValues.
|
|
const _mergeGroup = (base, override) => {
|
|
if (override == null) return base;
|
|
if (typeof override !== 'object' || Array.isArray(override)) return override;
|
|
return { ...(base || {}), ...override };
|
|
};
|
|
useEffect(() => {
|
|
try {
|
|
// R18c round 9: prefer the new key but fall back to the legacy
|
|
// one so a draft saved by SiteDrafts (which writes both keys
|
|
// during the migration window) still hydrates if the new key is
|
|
// somehow missing (e.g. older browser tab cache).
|
|
const raw =
|
|
sessionStorage.getItem(WIZARD_DRAFT_SESSION_KEY) ||
|
|
sessionStorage.getItem(LEGACY_WIZARD_DRAFT_SESSION_KEY);
|
|
if (!raw) return;
|
|
// R18c round 8 (Bulgu A): pre-fix the drafts page double-encoded the
|
|
// payload (because backend returned a raw JSON string, JSON.stringify
|
|
// re-quoted it). Resume then JSON.parsed back to a string — never
|
|
// a dict — so hydrate silently bailed. Now defensively re-parse
|
|
// until we hit a dict (or give up). This is forwards-compatible:
|
|
// properly-encoded payloads from the new code path parse to a dict
|
|
// on the FIRST attempt.
|
|
let parsed = JSON.parse(raw);
|
|
let attempts = 0;
|
|
while (typeof parsed === 'string' && attempts < 3) {
|
|
try {
|
|
parsed = JSON.parse(parsed);
|
|
} catch (_e) {
|
|
break;
|
|
}
|
|
attempts += 1;
|
|
}
|
|
// Clear BOTH keys so nothing lingers across navigations.
|
|
sessionStorage.removeItem(WIZARD_DRAFT_SESSION_KEY);
|
|
sessionStorage.removeItem(LEGACY_WIZARD_DRAFT_SESSION_KEY);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
// Detect any blank PEM placeholders left by the backend's strip pass
|
|
const ssl = parsed.ssl || {};
|
|
const sslHasBlankPem =
|
|
ssl.mode === 'upload' &&
|
|
(ssl.certificate_content === '' || ssl.private_key_content === '');
|
|
setPemStrippedOnResume(sslHasBlankPem);
|
|
const merged = {
|
|
...initialValues,
|
|
...parsed,
|
|
backend: _mergeGroup(initialValues.backend, parsed.backend),
|
|
frontend: _mergeGroup(initialValues.frontend, parsed.frontend),
|
|
ssl: _mergeGroup(initialValues.ssl, parsed.ssl),
|
|
// servers is an array — keep the parsed list as-is (replace),
|
|
// but if it's missing/empty fall back to initialValues.
|
|
servers: Array.isArray(parsed.servers) && parsed.servers.length
|
|
? parsed.servers
|
|
: initialValues.servers,
|
|
};
|
|
// Phase K Phase D (Bulgu #1): pin the cluster change-detection
|
|
// ref BEFORE setFieldsValue so the cluster-transition cleanup
|
|
// effect does NOT misread "draft hydration" as "user switched
|
|
// clusters" and wipe the freshly-resumed ssl_certificate_id /
|
|
// per-server CA bundle ids. Combined with the
|
|
// `existingCertsLoading` short-circuit in the orphan-detect
|
|
// effect, this guarantees a resumed draft retains every
|
|
// operator-saved cert selection.
|
|
if (merged.cluster_id !== undefined && merged.cluster_id !== null) {
|
|
prevClusterRef.current = merged.cluster_id;
|
|
// ALSO sync the header to the draft's cluster so post-
|
|
// resume edits, Step 0 display, and per-cluster fetches
|
|
// all converge on the draft's target cluster. If the
|
|
// header already matches, this is a no-op (selectCluster
|
|
// tolerates re-selecting the same cluster).
|
|
try {
|
|
if (
|
|
Array.isArray(clustersFromContext) &&
|
|
clustersFromContext.length > 0
|
|
) {
|
|
const draftCluster = clustersFromContext.find(
|
|
(c) => c.id === merged.cluster_id,
|
|
);
|
|
if (draftCluster) {
|
|
if (
|
|
!selectedCluster ||
|
|
selectedCluster.id !== merged.cluster_id
|
|
) {
|
|
selectCluster(draftCluster);
|
|
}
|
|
// Phase K Phase D (Bulgu #7) — mark the post-resume
|
|
// header swap as DONE so the deferred swap branch
|
|
// in the header sync effect (which exists for the
|
|
// cold-mount race where ClusterContext loads AFTER
|
|
// this point) does NOT later revert a manual
|
|
// operator-driven header change back to the draft
|
|
// cluster. Without this, an operator changing the
|
|
// header AFTER a successful eager swap here would
|
|
// be silently snapped back to the draft cluster.
|
|
setResumeClusterSynced(true);
|
|
}
|
|
}
|
|
} catch (_e) { /* best-effort: cluster sync is sugar, not safety */ }
|
|
}
|
|
form.setFieldsValue(merged);
|
|
// Phase F: hydrate the ACL builder from the saved draft if
|
|
// the resumed payload carries any of the three rule arrays
|
|
// (acl_rules / use_backend_rules / redirect_rules under
|
|
// `frontend`). Force a builder remount via key bump so the
|
|
// controlled component reflects the new initial arrays.
|
|
const resumedFE = (parsed && parsed.frontend) || {};
|
|
const aclState = {
|
|
aclRules: Array.isArray(resumedFE.acl_rules) ? resumedFE.acl_rules : [],
|
|
useBackendRules: Array.isArray(resumedFE.use_backend_rules) ? resumedFE.use_backend_rules : [],
|
|
redirectRules: Array.isArray(resumedFE.redirect_rules) ? resumedFE.redirect_rules : [],
|
|
};
|
|
if (
|
|
aclState.aclRules.length ||
|
|
aclState.useBackendRules.length ||
|
|
aclState.redirectRules.length
|
|
) {
|
|
setAclBuilderData(aclState);
|
|
setAclBuilderKey((k) => k + 1);
|
|
}
|
|
setResumedFromDraft(true);
|
|
// R18c round 7 (Bulgu 2): jump to Review & Apply so the operator
|
|
// sees the full populated payload immediately. They can still
|
|
// click Previous to edit any earlier step's fields.
|
|
setStep(WIZARD_LAST_STEP);
|
|
}
|
|
} catch (_e) {
|
|
/* ignore — fall back to clean init */
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const fetchSuggestions = async () => {
|
|
try {
|
|
const values = form.getFieldsValue(true);
|
|
if (!values.cluster_id || !values.domains || values.domains.length === 0) return;
|
|
const res = await axios.get('/api/sites/suggest', {
|
|
params: { cluster_id: values.cluster_id, domain: values.domains[0] },
|
|
});
|
|
const s = res.data || {};
|
|
form.setFieldsValue({
|
|
backend: { ...values.backend, name: values.backend?.name || s.backend_name },
|
|
frontend: { ...values.frontend, name: values.frontend?.name || s.frontend_name },
|
|
});
|
|
} catch (_e) {
|
|
/* noop */
|
|
}
|
|
};
|
|
|
|
// ---------- Preflight (ACME) ----------
|
|
const runPreflight = async () => {
|
|
const values = form.getFieldsValue(true);
|
|
if (!values.cluster_id || !values.domains || values.domains.length === 0) {
|
|
message.error('Cluster and domains are required for preflight');
|
|
return;
|
|
}
|
|
setPreflightLoading(true);
|
|
setPreflightResults(null);
|
|
// R18 audit fix (round 4 #F): clear the "Continue anyway" override
|
|
// whenever a fresh preflight run is initiated. Pre-R18 the flag
|
|
// was sticky — an operator could tick it once, click "Refresh
|
|
// preflight" after fixing some-but-not-all issues, see new
|
|
// failures, and STILL have the submit button bypass them. The
|
|
// override must be re-affirmed for each set of results.
|
|
setContinueAnyway(false);
|
|
try {
|
|
const res = await axios.post('/api/sites/preflight-acme', {
|
|
cluster_id: values.cluster_id,
|
|
domains: values.domains,
|
|
});
|
|
setPreflightResults(res.data);
|
|
} catch (err) {
|
|
// R16 #v3: envelope-aware
|
|
message.error(extractApiError(err, 'Preflight failed'));
|
|
} finally {
|
|
setPreflightLoading(false);
|
|
}
|
|
};
|
|
|
|
// ---------- Preview ----------
|
|
const runPreview = async () => {
|
|
setPreviewLoading(true);
|
|
setPreviewResults(null);
|
|
try {
|
|
// Bug B fix: getFieldsValue(true) returns ALL registered fields,
|
|
// including display:none ones, so the preview captures the full
|
|
// wizard state regardless of which step is currently visible.
|
|
const values = form.getFieldsValue(true);
|
|
// Phase F: inject the ACL builder's three rule arrays into the
|
|
// preview payload so the operator sees what HAProxy will
|
|
// actually emit (acl / use_backend / redirect lines) before
|
|
// committing.
|
|
const valuesWithAcls = {
|
|
...values,
|
|
frontend: {
|
|
...(values.frontend || {}),
|
|
acl_rules: aclBuilderData.aclRules || [],
|
|
use_backend_rules: aclBuilderData.useBackendRules || [],
|
|
redirect_rules: aclBuilderData.redirectRules || [],
|
|
},
|
|
};
|
|
const res = await axios.post('/api/sites/preview', valuesWithAcls);
|
|
setPreviewResults(res.data);
|
|
} catch (err) {
|
|
// R16 #v3: use the envelope-aware extractor so users see the
|
|
// ACTUAL server-side reason (validation field path, RBAC denial,
|
|
// collision warning) instead of a generic "Preview failed".
|
|
message.error(extractApiError(err, 'Preview failed'));
|
|
} finally {
|
|
setPreviewLoading(false);
|
|
}
|
|
};
|
|
|
|
// ---------- Submit ----------
|
|
// Phase K Phase D (Bulgu #6): the wizard used to expose TWO buttons
|
|
// — `Create as PENDING` (apply_immediately=false) and `Create &
|
|
// Apply` (apply_immediately=true). Operators (and the user during
|
|
// R18c review) found that confusing: the standard manual flow on
|
|
// FrontendManagement / BackendServers / SSLManagement always
|
|
// produces a PENDING version that the operator then reviews on
|
|
// Apply Management before pulling the trigger. Two wizard buttons
|
|
// bypassed that convention and trained operators to "always
|
|
// Create & Apply", defeating the change-review benefit of Apply
|
|
// Management.
|
|
//
|
|
// Standard flow alignment: ONE submit button — "Create Site". Its
|
|
// semantics are:
|
|
// * Default (none / upload / existing SSL modes): apply_immediately
|
|
// = false → backend returns `created_pending` → operator is
|
|
// navigated to /apply-management, where they can audit the
|
|
// bulk version and click Apply (same Agent-pull cadence as
|
|
// manual entity creation).
|
|
// * ACME SSL mode: apply_immediately = true (FORCED by backend's
|
|
// SiteCreate.enforce_acme_apply_and_http model_validator, M22).
|
|
// Operator is navigated to /frontends after the agent confirms
|
|
// the HTTP frontend that satisfies the HTTP-01 challenge.
|
|
//
|
|
// The signature still accepts an optional `applyImmediately` param
|
|
// so the (deprecated) entry-points and the test pin tests don't
|
|
// break — but the default is now mode-driven, not button-driven.
|
|
const handleSubmit = async (applyImmediately) => {
|
|
try {
|
|
// Bug B fix: validateFields returns only mounted/touched fields. Now
|
|
// that ALL step contents stay mounted, validateFields covers the
|
|
// whole form. We additionally merge in getFieldsValue(true) as a
|
|
// belt-and-braces defense against display:none + form.preserve
|
|
// edge-cases.
|
|
const validated = await form.validateFields();
|
|
const all = form.getFieldsValue(true);
|
|
const values = { ...all, ...validated };
|
|
// Phase K Phase D: derive apply_immediately from the SSL mode
|
|
// (ACME forces apply) when the caller did not explicitly pass
|
|
// a value. Eliminates the "wrong button → wrong flow" UX.
|
|
const sslModeAtSubmit = values.ssl?.mode;
|
|
const effectiveApply =
|
|
applyImmediately === undefined
|
|
? sslModeAtSubmit === 'acme'
|
|
: !!applyImmediately;
|
|
values.apply_immediately = effectiveApply;
|
|
if (sslModeAtSubmit === 'acme' && !effectiveApply) {
|
|
message.error("ssl.mode='acme' requires Apply Immediately (M22)");
|
|
return;
|
|
}
|
|
// Phase F: inject the ACL builder's three rule arrays into the
|
|
// wizard create payload. The builder lives outside the antd
|
|
// Form so its state has to be merged in explicitly here.
|
|
// FrontendStep validator (`https_redirect` ⊕ `redirect_rules`)
|
|
// catches a non-empty redirect rule list combined with the
|
|
// https_redirect=true switch with a clear 422 — surface that
|
|
// earlier as a UX message so the operator sees it without a
|
|
// round-trip.
|
|
const aclRules = aclBuilderData.aclRules || [];
|
|
const useBackendRules = aclBuilderData.useBackendRules || [];
|
|
const redirectRules = aclBuilderData.redirectRules || [];
|
|
if (values.frontend?.https_redirect && redirectRules.length) {
|
|
message.error(
|
|
'HTTP→HTTPS redirect cannot be combined with custom redirect rules. ' +
|
|
'Either disable the switch on the Frontend step or remove the rules from the ACL builder.',
|
|
);
|
|
return;
|
|
}
|
|
values.frontend = {
|
|
...(values.frontend || {}),
|
|
acl_rules: aclRules,
|
|
use_backend_rules: useBackendRules,
|
|
redirect_rules: redirectRules,
|
|
};
|
|
setSubmitting(true);
|
|
const res = await axios.post('/api/sites', values);
|
|
const { status, version_name, acme_order_id, acme_staging_error, apply_result } = res.data || {};
|
|
|
|
if (status === 'created_applied') {
|
|
message.success('Site created and applied successfully');
|
|
if (acme_order_id) message.info(`ACME order #${acme_order_id} staged; agent confirmation pending`, 8);
|
|
navigate('/frontends');
|
|
} else if (status === 'created_pending') {
|
|
message.success(`Site created (PENDING version ${version_name}). Open Apply Changes to deploy.`);
|
|
navigate('/apply-management');
|
|
} else if (status === 'created_pending_apply_failed') {
|
|
// R18b audit fix (H): surface the apply error so the operator
|
|
// can fix the cause (HAProxy parse error, missing CA file,
|
|
// ssl_verify mismatch, etc.) instead of silently navigating
|
|
// to Apply Changes with a generic "try again" message.
|
|
const applyErr =
|
|
apply_result?.error ||
|
|
apply_result?.message ||
|
|
(typeof apply_result === 'string' ? apply_result : '') ||
|
|
'(no detail returned)';
|
|
Modal.warning({
|
|
title: 'Created but Apply Failed',
|
|
width: 640,
|
|
content: (
|
|
<>
|
|
<div>The wizard created the entities but the Apply step failed.</div>
|
|
<div style={{ marginTop: 8 }}>
|
|
<strong>Apply error:</strong>
|
|
<pre style={{ marginTop: 4, padding: 8, background: '#fafafa', border: '1px solid #f0f0f0', whiteSpace: 'pre-wrap', fontSize: 12 }}>
|
|
{String(applyErr)}
|
|
</pre>
|
|
</div>
|
|
<div style={{ marginTop: 8 }}>
|
|
You can retry from <strong>Apply Changes</strong> after fixing the underlying issue.
|
|
</div>
|
|
</>
|
|
),
|
|
});
|
|
navigate('/apply-management');
|
|
} else if (status === 'applied_acme_staging_failed') {
|
|
Modal.warning({
|
|
title: 'Applied but ACME staging failed',
|
|
content: `Entities applied successfully but ACME staging failed: ${acme_staging_error}. You can manually request a certificate from ACME Automation.`,
|
|
});
|
|
navigate('/frontends');
|
|
} else {
|
|
message.success('Site created');
|
|
navigate('/frontends');
|
|
}
|
|
} catch (err) {
|
|
// R16 #v3: prefer the API envelope extractor; fall back to Antd
|
|
// form validation errors when the rejection came from the local
|
|
// validateFields() (no HTTP response).
|
|
if (err?.errorFields) {
|
|
const firstField = err.errorFields[0];
|
|
message.error(`${firstField.name.join('.')}: ${firstField.errors[0]}`);
|
|
} else {
|
|
message.error(extractApiError(err, 'Submit failed'));
|
|
}
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// ---------- Save Draft ----------
|
|
const handleSaveDraft = async () => {
|
|
// R14 #v6 fix: surface the result to callers so handleCancel can avoid
|
|
// navigating to /proxied-hosts/drafts when the save itself failed (was
|
|
// shipping the user to an empty drafts list with a confusing error
|
|
// toast). Returns true on success, false on failure.
|
|
try {
|
|
const payload = form.getFieldsValue(true);
|
|
// Phase F: persist the ACL builder's rule arrays alongside the
|
|
// form payload so a resumed draft hydrates with the same
|
|
// routing rules the operator had already configured. The
|
|
// resume flow on mount reads `frontend.acl_rules`,
|
|
// `use_backend_rules` and `redirect_rules` back out.
|
|
payload.frontend = {
|
|
...(payload.frontend || {}),
|
|
acl_rules: aclBuilderData.aclRules || [],
|
|
use_backend_rules: aclBuilderData.useBackendRules || [],
|
|
redirect_rules: aclBuilderData.redirectRules || [],
|
|
};
|
|
const res = await axios.post('/api/sites/drafts', {
|
|
title: payload?.frontend?.name || payload?.backend?.name || 'Untitled draft',
|
|
payload,
|
|
});
|
|
message.success(`Draft saved (id ${res.data?.id})`);
|
|
return true;
|
|
} catch (err) {
|
|
// R16 #v3: envelope-aware. The 256KB / 50-drafts cap (R14) and
|
|
// the validation 422s all surface here.
|
|
message.error(extractApiError(err, 'Save draft failed'));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
Modal.confirm({
|
|
title: 'Discard wizard?',
|
|
content: 'You have unsaved changes. Save as draft or discard?',
|
|
okText: 'Discard',
|
|
cancelText: 'Save Draft',
|
|
// R18c round 10 (M4): pre-round-10 the modal could be dismissed
|
|
// by Esc or backdrop click, both of which Ant Design routes
|
|
// through `onCancel` — silently triggering the Save Draft path.
|
|
// Operators who hit Esc to "abort" ended up with a draft saved
|
|
// and a navigation away from the wizard. Force the user to make
|
|
// an explicit choice (Discard vs Save Draft) by disabling both
|
|
// the keyboard escape and the masked-area click. The X close
|
|
// button still appears in the corner for accidental opens; on
|
|
// X it falls through to onCancel like Esc did, but that is the
|
|
// documented "abort" affordance and acceptable.
|
|
keyboard: false,
|
|
maskClosable: false,
|
|
onOk: () => navigate('/frontends'),
|
|
onCancel: async () => {
|
|
// R14 #v6 fix: only navigate away if the save actually succeeded.
|
|
// If handleSaveDraft fails (network, 422 size-limit, 409 too many
|
|
// drafts) we keep the user on the wizard so they don't lose their
|
|
// in-flight work. The error toast is already raised inside
|
|
// handleSaveDraft.
|
|
const ok = await handleSaveDraft();
|
|
if (ok) {
|
|
// R18: navigate to the canonical /sites/drafts. Legacy
|
|
// /quick-setup/drafts and /proxied-hosts/drafts still resolve
|
|
// for bookmarked URLs but new navigations target the rebrand.
|
|
navigate('/sites/drafts');
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
// ---------- Step content ----------
|
|
const stepContents = [
|
|
{
|
|
title: 'Cluster & Domains',
|
|
content: (
|
|
<>
|
|
{/* Phase K Phase D (Bulgu #1): cluster is sourced from the
|
|
header context (same as every other entity page). The
|
|
hidden Form.Item keeps the value in form state for the
|
|
submit payload and for `watchedClusterId`-driven fetches
|
|
(existing certs, cluster backends). Above it, a read-
|
|
only display card shows the active cluster so the
|
|
operator always sees which cluster their wizard run
|
|
will target. Mid-wizard cluster changes happen via the
|
|
header — the existing cluster-transition cleanup
|
|
effect clears stale per-cluster cert ids. */}
|
|
<Form.Item
|
|
name="cluster_id"
|
|
hidden
|
|
rules={[{ required: true, message: 'Select a cluster on the header' }]}
|
|
>
|
|
<Input type="hidden" />
|
|
</Form.Item>
|
|
<Form.Item label="Cluster">
|
|
{selectedCluster ? (
|
|
<Space size="small" align="center">
|
|
<Tag color="blue" style={{ fontSize: 13, padding: '2px 10px' }}>
|
|
{selectedCluster.name}
|
|
</Tag>
|
|
<span style={{ color: '#888', fontSize: 12 }}>
|
|
Change via the cluster picker in the header.
|
|
</span>
|
|
</Space>
|
|
) : (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message="No cluster selected"
|
|
description="Open the cluster picker in the header (top of the page) and select the target cluster before continuing."
|
|
/>
|
|
)}
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="domains"
|
|
label="Domain Names"
|
|
rules={[
|
|
{ required: true, message: 'Enter at least one domain' },
|
|
antdDomainsListRule,
|
|
]}
|
|
>
|
|
<Select
|
|
mode="tags"
|
|
placeholder="e.g. example.com, www.example.com"
|
|
tokenSeparators={[',', ' ']}
|
|
onChange={fetchSuggestions}
|
|
/>
|
|
</Form.Item>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
title: 'Backend & Servers',
|
|
content: (
|
|
<>
|
|
<Form.Item
|
|
name={['backend', 'name']}
|
|
label="Backend Name"
|
|
rules={[
|
|
{ required: true, message: 'Required' },
|
|
{ pattern: /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/, message: 'Letters/digits/_/- only, must not start with _' },
|
|
]}
|
|
>
|
|
<Input placeholder="be-myapp" />
|
|
</Form.Item>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name={['backend', 'balance_method']}
|
|
label={
|
|
<Space>
|
|
<span>Load-balance algorithm</span>
|
|
<Tooltip title="HAProxy 2.4+ algorithms. roundrobin/leastconn for HTTP, source/uri for sticky distribution, hdr/url_param for header- or query-aware balancing.">
|
|
<ExclamationCircleOutlined />
|
|
</Tooltip>
|
|
</Space>
|
|
}
|
|
>
|
|
<Select>
|
|
<Option value="roundrobin">roundrobin</Option>
|
|
<Option value="leastconn">leastconn</Option>
|
|
<Option value="static-rr">static-rr</Option>
|
|
<Option value="first">first</Option>
|
|
<Option value="source">source (sticky by IP)</Option>
|
|
<Option value="uri">uri (consistent hash on URI)</Option>
|
|
<Option value="random">random</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item name={['backend', 'mode']} label="Mode">
|
|
<Select>
|
|
<Option value="http">http (L7)</Option>
|
|
<Option value="tcp">tcp (L4)</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* Health-check basics — visible to all users */}
|
|
<Form.Item shouldUpdate>
|
|
{() => {
|
|
const beMode = form.getFieldValue(['backend', 'mode']);
|
|
if (beMode === 'tcp') {
|
|
return (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 12 }}
|
|
message="TCP backends do not use health_check_uri. The default-server check directive is used; tune via Advanced settings."
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item name={['backend', 'health_check_uri']} label="Health Check URI">
|
|
<Input placeholder="/" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Form.Item name={['backend', 'health_check_interval']} label="Interval (ms)">
|
|
<InputNumber min={100} step={500} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Form.Item name={['backend', 'health_check_expected_status']} label="Expected status">
|
|
<InputNumber min={100} max={599} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
);
|
|
}}
|
|
</Form.Item>
|
|
|
|
{/* Backend Advanced — collapsed by default */}
|
|
<Collapse
|
|
ghost
|
|
items={[{
|
|
key: 'backend-adv',
|
|
label: <Space><SettingOutlined /><span>Advanced backend settings</span></Space>,
|
|
children: (
|
|
<>
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
<Form.Item name={['backend', 'timeout_connect']} label="timeout connect (ms)">
|
|
<InputNumber min={100} step={1000} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['backend', 'timeout_server']} label="timeout server (ms)">
|
|
<InputNumber min={100} step={1000} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['backend', 'timeout_queue']} label="timeout queue (ms)">
|
|
<InputNumber min={100} step={1000} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
<Form.Item name={['backend', 'fullconn']} label="fullconn">
|
|
<InputNumber min={0} step={100} style={{ width: '100%' }} placeholder="(unset)" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['backend', 'cookie_name']} label="Sticky cookie name">
|
|
<Input placeholder="SRVID" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['backend', 'cookie_options']} label="Cookie options">
|
|
<Input placeholder="insert indirect nocache" />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Divider>default-server defaults (applied to every server)</Divider>
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
<Form.Item name={['backend', 'default_server_inter']} label="default-server inter (ms)">
|
|
<InputNumber min={0} step={500} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['backend', 'default_server_fall']} label="default-server fall">
|
|
<InputNumber min={0} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['backend', 'default_server_rise']} label="default-server rise">
|
|
<InputNumber min={0} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Form.Item name={['backend', 'request_headers']} label="Request headers (one directive per line)">
|
|
<Input.TextArea rows={3} placeholder="http-request set-header X-Forwarded-For %[src]" />
|
|
</Form.Item>
|
|
<Form.Item name={['backend', 'response_headers']} label="Response headers">
|
|
<Input.TextArea rows={3} placeholder="http-response set-header X-Powered-By haproxy-openmanager" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name={['backend', 'options']}
|
|
label="Raw HAProxy options (one per line; option httpchk auto-stripped)"
|
|
>
|
|
<Input.TextArea rows={3} placeholder="option forwardfor option redispatch" />
|
|
</Form.Item>
|
|
</>
|
|
),
|
|
}]}
|
|
/>
|
|
|
|
<Divider>Servers</Divider>
|
|
<Form.List name="servers">
|
|
{(fields, { add, remove }) => (
|
|
<>
|
|
{fields.map(({ key, name, ...rest }) => (
|
|
<Card key={key} size="small" style={{ marginBottom: 8 }} bodyStyle={{ padding: 12 }}>
|
|
<Row gutter={8} align="middle">
|
|
<Col span={5}>
|
|
<Form.Item
|
|
{...rest}
|
|
name={[name, 'server_name']}
|
|
label="Name"
|
|
rules={[{ required: true, message: 'Required' }]}
|
|
>
|
|
<Input placeholder="srv1" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item
|
|
{...rest}
|
|
name={[name, 'server_address']}
|
|
label="Address"
|
|
rules={[{ required: true, message: 'Required' }]}
|
|
>
|
|
<Input placeholder="10.0.0.1 / app.local" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={3}>
|
|
<Form.Item
|
|
{...rest}
|
|
name={[name, 'server_port']}
|
|
label="Port"
|
|
rules={[{ required: true, message: 'Required' }]}
|
|
>
|
|
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={3}>
|
|
{/* Antd warns when initialValue is set on a
|
|
Form.Item inside Form.List. Defaults are
|
|
supplied by Form's initialValues (servers[0])
|
|
and by the add() helper for new rows. */}
|
|
<Form.Item {...rest} name={[name, 'weight']} label="Weight">
|
|
<InputNumber min={0} max={256} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={3}>
|
|
<Form.Item {...rest} name={[name, 'check_enabled']} label="Check" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={2} style={{ textAlign: 'right' }}>
|
|
{fields.length > 1 && (
|
|
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => remove(name)} />
|
|
)}
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* Per-server advanced (collapsible) */}
|
|
<Collapse
|
|
ghost
|
|
items={[{
|
|
key: `srv-adv-${key}`,
|
|
label: <Space><SettingOutlined /><span>Advanced server settings</span></Space>,
|
|
children: (
|
|
<>
|
|
<Row gutter={16}>
|
|
<Col span={6}>
|
|
<Form.Item {...rest} name={[name, 'inter']} label="check inter (ms)">
|
|
<InputNumber min={0} step={500} style={{ width: '100%' }} placeholder="default" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Form.Item {...rest} name={[name, 'fall']} label="fall">
|
|
<InputNumber min={0} style={{ width: '100%' }} placeholder="default" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Form.Item {...rest} name={[name, 'rise']} label="rise">
|
|
<InputNumber min={0} style={{ width: '100%' }} placeholder="default" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Form.Item {...rest} name={[name, 'check_port']} label="check port">
|
|
<InputNumber min={1} max={65535} style={{ width: '100%' }} placeholder="(use server port)" />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
<Form.Item {...rest} name={[name, 'max_connections']} label="maxconn">
|
|
<InputNumber min={0} step={100} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item {...rest} name={[name, 'backup_server']} label="backup" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item
|
|
shouldUpdate={(prev, curr) =>
|
|
prev?.backend?.cookie_name !==
|
|
curr?.backend?.cookie_name
|
|
}
|
|
noStyle
|
|
>
|
|
{() => {
|
|
const backendCookieName = form.getFieldValue(['backend', 'cookie_name']);
|
|
const cookieEnabled = !!(backendCookieName && String(backendCookieName).trim());
|
|
return (
|
|
<Form.Item
|
|
{...rest}
|
|
name={[name, 'cookie_value']}
|
|
label="Sticky cookie value"
|
|
extra={
|
|
cookieEnabled
|
|
? undefined
|
|
: 'Requires "Sticky cookie name" on the backend block above. Otherwise this value is silently dropped at config render time.'
|
|
}
|
|
>
|
|
<Input
|
|
placeholder="srv1"
|
|
disabled={!cookieEnabled}
|
|
/>
|
|
</Form.Item>
|
|
);
|
|
}}
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Divider plain>SSL/TLS to backend (server ssl)</Divider>
|
|
<Row gutter={16}>
|
|
<Col span={6}>
|
|
<Form.Item {...rest} name={[name, 'ssl_enabled']} label="Enable SSL" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Form.Item {...rest} name={[name, 'ssl_verify']} label="ssl_verify">
|
|
<Select allowClear placeholder="(none)">
|
|
<Option value="none">none</Option>
|
|
<Option value="required">required</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item {...rest} name={[name, 'ssl_sni']} label="ssl_sni">
|
|
<Input placeholder="api.example.com" />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
{/* R18c round 10 (M1): pre-round-10 the
|
|
wizard offered TLSv1.0 / TLSv1.1 as
|
|
"legacy upstream" options to mirror the
|
|
backend's permissive Literal accept.
|
|
The model_post_validator on ServerStep
|
|
(models/site_wizard.py) however
|
|
rejects them outright per RFC 8996, so
|
|
a user picking a 1.0/1.1 entry only
|
|
discovered the rejection via a 422 at
|
|
submit time after navigating five
|
|
wizard steps. Drop the legacy options
|
|
from the UI to match the backend
|
|
contract; an operator with a hard
|
|
requirement can still author the
|
|
backend through the regular Backends
|
|
UI which uses an unrestricted path. */}
|
|
<Form.Item {...rest} name={[name, 'ssl_min_ver']} label="TLS min">
|
|
<Select allowClear placeholder="(default)">
|
|
<Option value="TLSv1.2">TLSv1.2</Option>
|
|
<Option value="TLSv1.3">TLSv1.3</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item {...rest} name={[name, 'ssl_max_ver']} label="TLS max">
|
|
<Select allowClear placeholder="(default)">
|
|
<Option value="TLSv1.2">TLSv1.2</Option>
|
|
<Option value="TLSv1.3">TLSv1.3</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item {...rest} name={[name, 'ssl_ciphers']} label="ciphers">
|
|
<Input placeholder="HIGH:!aNULL:!MD5" />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
{/* R17 minimum-parity (label corrected in R18):
|
|
this maps to HAProxy's `ca-file` directive on
|
|
the server line — i.e. the CA bundle HAProxy
|
|
uses to VERIFY the backend server's TLS
|
|
certificate when ssl_verify='required'. The
|
|
manual BackendServers UI exposes the same
|
|
field as a generic "SSL Certificate" Select
|
|
(BackendServers.js:2147). Pre-R18 the label
|
|
said "Client cert (mTLS to server)" which
|
|
wrongly suggested HAProxy presents this cert
|
|
— the actual semantics is CA verification. */}
|
|
<Form.Item shouldUpdate={(prev, cur) => {
|
|
const p = prev?.servers?.[name]?.ssl_enabled;
|
|
const c = cur?.servers?.[name]?.ssl_enabled;
|
|
const pv = prev?.servers?.[name]?.ssl_verify;
|
|
const cv = cur?.servers?.[name]?.ssl_verify;
|
|
return p !== c || pv !== cv;
|
|
}} noStyle>
|
|
{({ getFieldValue }) => {
|
|
const sslOn = !!getFieldValue(['servers', name, 'ssl_enabled']);
|
|
const verifyMode = getFieldValue(['servers', name, 'ssl_verify']);
|
|
const required = verifyMode === 'required';
|
|
return (
|
|
<Form.Item
|
|
{...rest}
|
|
name={[name, 'ssl_certificate_id']}
|
|
label="CA bundle for upstream verification"
|
|
tooltip="HAProxy `ca-file` directive: the CA bundle used to verify the upstream server's TLS certificate. Required when ssl_verify='required'."
|
|
rules={required ? [{ required: true, message: 'A CA bundle is required when ssl_verify is "required"' }] : []}
|
|
>
|
|
<Select
|
|
allowClear
|
|
placeholder={sslOn ? "(none — skip server cert verification)" : "Enable SSL to backend first"}
|
|
disabled={!sslOn}
|
|
showSearch
|
|
optionFilterProp="children"
|
|
>
|
|
{/* Phase K Phase D (Bulgu #2): the
|
|
"CA bundle for upstream
|
|
verification" field maps to
|
|
HAProxy's `ca-file` server-line
|
|
directive. The matching backend
|
|
page (BackendServers.js:221)
|
|
filters its options to certs
|
|
tagged usage_type='server' so the
|
|
operator does NOT see frontend
|
|
(HTTPS bind) certs that would
|
|
parse-error at apply time. The
|
|
wizard pre-fix surfaced every
|
|
cert in the cluster, breaking
|
|
parity AND violating the SSL
|
|
import contract (usage_type
|
|
drives WHICH HAProxy slot the
|
|
cert is allowed to occupy).
|
|
Filter explicitly here. */}
|
|
{existingCerts
|
|
.filter((c) => c.usage_type === 'server')
|
|
.map((c) => (
|
|
<Option key={c.id} value={c.id}>
|
|
{c.name}{c.primary_domain ? ` — ${c.primary_domain}` : ''}
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
);
|
|
}}
|
|
</Form.Item>
|
|
</>
|
|
),
|
|
}]}
|
|
/>
|
|
</Card>
|
|
))}
|
|
<Form.Item>
|
|
<Button
|
|
onClick={() => add({
|
|
server_name: `srv${fields.length + 1}`,
|
|
server_port: 8080,
|
|
weight: 100,
|
|
check_enabled: true,
|
|
ssl_enabled: false,
|
|
})}
|
|
icon={<PlusOutlined />}
|
|
>
|
|
Add server
|
|
</Button>
|
|
</Form.Item>
|
|
</>
|
|
)}
|
|
</Form.List>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
title: 'Frontend',
|
|
content: (
|
|
<>
|
|
<Form.Item
|
|
name={['frontend', 'name']}
|
|
label="Frontend Name"
|
|
rules={[
|
|
{ required: true, message: 'Required' },
|
|
{ pattern: /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/, message: 'Letters/digits/_/- only, must not start with _' },
|
|
]}
|
|
>
|
|
<Input placeholder="fe-myapp" />
|
|
</Form.Item>
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
<Form.Item name={['frontend', 'mode']} label="Mode">
|
|
<Select>
|
|
<Option value="http">http (L7)</Option>
|
|
<Option value="tcp">tcp (L4)</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['frontend', 'bind_address']} label="Bind Address" initialValue="*">
|
|
<Input placeholder="*" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['frontend', 'bind_port']} label="Bind Port" initialValue={80}>
|
|
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Form.Item name={['frontend', 'https_redirect']} label="HTTP -> HTTPS redirect" valuePropName="checked">
|
|
<Tooltip
|
|
title={
|
|
watchedFrontendMode === 'tcp'
|
|
? 'HTTP→HTTPS redirect requires HTTP mode (L7). TCP frontends operate at L4 and cannot inspect HTTP headers — the wizard auto-clears this when you switch to TCP.'
|
|
: ''
|
|
}
|
|
>
|
|
<Switch
|
|
disabled={watchedFrontendMode === 'tcp'}
|
|
aria-disabled={watchedFrontendMode === 'tcp'}
|
|
/>
|
|
</Tooltip>
|
|
</Form.Item>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 12 }}
|
|
message="If enabled, the wizard will inject a 301 redirect to https://. Cannot be combined with custom redirect_rules in this wizard step."
|
|
/>
|
|
|
|
{/* Phase F: Routing & ACLs — list-based ACL builder, identical
|
|
to the standalone Frontend Management page so the wizard
|
|
UX matches manual frontend creation. Hidden when the
|
|
frontend mode is TCP because L4 cannot match L7 ACLs. */}
|
|
{watchedFrontendMode !== 'tcp' && (
|
|
<Collapse
|
|
ghost
|
|
defaultActiveKey={
|
|
aclBuilderData.aclRules.length ||
|
|
aclBuilderData.useBackendRules.length ||
|
|
aclBuilderData.redirectRules.length
|
|
? ['fe-acl']
|
|
: []
|
|
}
|
|
items={[{
|
|
key: 'fe-acl',
|
|
label: (
|
|
<Space>
|
|
<FilterOutlined />
|
|
<span>Routing & ACLs (Access Control Lists)</span>
|
|
{(aclBuilderData.aclRules.length +
|
|
aclBuilderData.useBackendRules.length +
|
|
aclBuilderData.redirectRules.length) > 0 && (
|
|
<Tag color="blue">
|
|
{aclBuilderData.aclRules.length} ACL ·{' '}
|
|
{aclBuilderData.useBackendRules.length} route ·{' '}
|
|
{aclBuilderData.redirectRules.length} redirect
|
|
</Tag>
|
|
)}
|
|
</Space>
|
|
),
|
|
children: (
|
|
<>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 12 }}
|
|
message={
|
|
<span>
|
|
Optional. Define <strong>ACL rules</strong> to match
|
|
HTTP traffic, then route matches to a different{' '}
|
|
<strong>backend</strong> or apply a{' '}
|
|
<strong>redirect</strong>. The wizard’s
|
|
new backend is included in the dropdown
|
|
alongside other backends in this cluster.
|
|
</span>
|
|
}
|
|
/>
|
|
{watchedHttpsRedirect && aclBuilderData.redirectRules.length > 0 && (
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
style={{ marginBottom: 12 }}
|
|
message="HTTP→HTTPS redirect cannot be combined with custom redirect rules"
|
|
description="The switch above already publishes a 301 redirect on port 80; the rules here are mutually exclusive with it. Choose one of the two paths below before continuing."
|
|
action={
|
|
<Space direction="vertical" size={4}>
|
|
<Button
|
|
size="small"
|
|
danger
|
|
onClick={() =>
|
|
form.setFields([
|
|
{ name: ['frontend', 'https_redirect'], value: false },
|
|
])
|
|
}
|
|
>
|
|
Disable HTTP→HTTPS switch
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
onClick={() =>
|
|
setAclBuilderData((prev) => ({
|
|
...prev,
|
|
redirectRules: [],
|
|
}))
|
|
}
|
|
>
|
|
Remove redirect rules
|
|
</Button>
|
|
</Space>
|
|
}
|
|
/>
|
|
)}
|
|
<ACLRuleBuilder
|
|
key={aclBuilderKey}
|
|
aclRules={aclBuilderData.aclRules}
|
|
useBackendRules={aclBuilderData.useBackendRules}
|
|
redirectRules={aclBuilderData.redirectRules}
|
|
backends={aclBuilderBackends}
|
|
disableRedirectRules={!!watchedHttpsRedirect}
|
|
onChange={(data) => setAclBuilderData(data)}
|
|
/>
|
|
</>
|
|
),
|
|
}]}
|
|
/>
|
|
)}
|
|
|
|
{/* Frontend Advanced */}
|
|
<Collapse
|
|
ghost
|
|
items={[{
|
|
key: 'fe-adv',
|
|
label: <Space><SettingOutlined /><span>Advanced frontend settings</span></Space>,
|
|
children: (
|
|
<>
|
|
<Row gutter={16}>
|
|
<Col span={6}>
|
|
<Form.Item name={['frontend', 'maxconn']} label="maxconn">
|
|
<InputNumber min={1} step={1000} style={{ width: '100%' }} placeholder="(unset)" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Form.Item name={['frontend', 'rate_limit']} label="rate-limit (req/s)">
|
|
<InputNumber min={0} step={10} style={{ width: '100%' }} placeholder="(unset)" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Form.Item name={['frontend', 'timeout_client']} label="timeout client (ms)">
|
|
<InputNumber min={100} step={1000} style={{ width: '100%' }} placeholder="default" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Form.Item name={['frontend', 'timeout_http_request']} label="timeout http-request (ms)">
|
|
<InputNumber min={100} step={1000} style={{ width: '100%' }} placeholder="default" />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
<Form.Item name={['frontend', 'compression']} label="gzip compression" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['frontend', 'log_separate']} label="log-separate" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['frontend', 'monitor_uri']} label="monitor-uri">
|
|
<Input placeholder="/haproxy-status" />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Form.Item name={['frontend', 'request_headers']} label="Request headers (HAProxy syntax, one directive per line)">
|
|
<Input.TextArea rows={3} placeholder='http-request set-header X-Real-IP %[src]' />
|
|
</Form.Item>
|
|
<Form.Item name={['frontend', 'response_headers']} label="Response headers">
|
|
<Input.TextArea rows={3} placeholder='http-response set-header X-Frame-Options DENY' />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name={['frontend', 'options']}
|
|
label="Raw HAProxy options"
|
|
>
|
|
<Input.TextArea rows={3} placeholder="option httplog option dontlognull" />
|
|
</Form.Item>
|
|
<Form.Item shouldUpdate>
|
|
{() => {
|
|
const feMode = form.getFieldValue(['frontend', 'mode']);
|
|
if (feMode === 'tcp') {
|
|
return (
|
|
<Form.Item name={['frontend', 'tcp_request_rules']} label="tcp-request rules (TCP mode only)">
|
|
<Input.TextArea rows={3} placeholder="tcp-request inspect-delay 5s tcp-request content accept if { req_ssl_hello_type 1 }" />
|
|
</Form.Item>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
</Form.Item>
|
|
</>
|
|
),
|
|
}]}
|
|
/>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
title: 'SSL & ACME',
|
|
content: (
|
|
<>
|
|
{/* Phase K Phase D follow-up (Bulgu #14) — surface a
|
|
persistent in-page banner when the orphan-clean
|
|
effect wiped a referenced cert. Toast-only
|
|
notifications are easy to miss on a busy
|
|
dashboard; this Alert stays visible until the
|
|
operator picks a replacement / switches mode.
|
|
Dismissed automatically when the operator changes
|
|
ssl.mode below. */}
|
|
{orphanClearedCerts.length > 0 && (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
closable
|
|
onClose={() => setOrphanClearedCerts([])}
|
|
style={{ marginBottom: 12 }}
|
|
message="Previously selected SSL certificate is no longer available"
|
|
description={
|
|
<>
|
|
<div>
|
|
The certificate(s) referenced by your saved draft
|
|
have been removed from the cluster (deleted /
|
|
deactivated) since the draft was saved:
|
|
</div>
|
|
<ul style={{ marginTop: 6, marginBottom: 6 }}>
|
|
{orphanClearedCerts.map((o) => (
|
|
<li key={o}><code>{o}</code></li>
|
|
))}
|
|
</ul>
|
|
<div>
|
|
SSL mode has been reverted to <code>none</code> so the
|
|
wizard can proceed. Pick a replacement certificate
|
|
below, or switch to <code>upload</code> / <code>acme</code> mode.
|
|
</div>
|
|
</>
|
|
}
|
|
/>
|
|
)}
|
|
<Form.Item name={['ssl', 'mode']} label="SSL Mode" initialValue="none">
|
|
<Select
|
|
onChange={() => {
|
|
setPreflightResults(null);
|
|
// Bulgu #14 — dismiss the orphan banner once the
|
|
// operator explicitly picks a mode. They've now
|
|
// acknowledged the state.
|
|
if (orphanClearedCerts.length) setOrphanClearedCerts([]);
|
|
}}
|
|
>
|
|
{SSL_MODES.map((m) => (
|
|
<Option key={m.value} value={m.value}>
|
|
{m.label} — <span style={{ color: '#888' }}>{m.description}</span>
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
<Form.Item shouldUpdate>
|
|
{() => {
|
|
const sslMode = form.getFieldValue(['ssl', 'mode']);
|
|
if (sslMode === 'upload') {
|
|
return (
|
|
<>
|
|
<Form.Item name={['ssl', 'name']} label="Certificate Name" rules={[{ required: true }]}>
|
|
<Input placeholder="cert-myapp" />
|
|
</Form.Item>
|
|
<Form.Item name={['ssl', 'certificate_content']} label="Certificate (PEM)" rules={[{ required: true }]}>
|
|
<Input.TextArea rows={6} placeholder="-----BEGIN CERTIFICATE-----..." />
|
|
</Form.Item>
|
|
<Form.Item name={['ssl', 'private_key_content']} label="Private Key (PEM)" rules={[{ required: true }]}>
|
|
<Input.TextArea rows={6} placeholder="-----BEGIN PRIVATE KEY-----..." />
|
|
</Form.Item>
|
|
<Form.Item name={['ssl', 'chain_content']} label="Chain (PEM, optional)">
|
|
<Input.TextArea rows={4} />
|
|
</Form.Item>
|
|
</>
|
|
);
|
|
}
|
|
if (sslMode === 'existing') {
|
|
// R18 UX: tell the user explicitly when the list is empty
|
|
// because no cluster is picked vs. because the cluster
|
|
// genuinely has no imported certs. Otherwise an empty
|
|
// dropdown looks broken (see Issue #14 follow-up).
|
|
const hasCluster = !!watchedClusterId;
|
|
// Phase K Phase D (Bulgu #2): the empty-state alert
|
|
// must consider ONLY frontend-usage certs, otherwise
|
|
// a cluster with N server-side ca-file certs but
|
|
// zero HTTPS-bind certs would render a "select an
|
|
// existing certificate" dropdown that is in fact
|
|
// empty (filtered out below) with no explanatory
|
|
// alert.
|
|
const frontendCertsForCluster = existingCerts.filter(
|
|
(c) => c.usage_type === 'frontend',
|
|
);
|
|
const noCertsForCluster = hasCluster && frontendCertsForCluster.length === 0;
|
|
return (
|
|
<>
|
|
{!hasCluster && (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 8 }}
|
|
message="Pick a cluster on Step 1 first — the existing-certificate list is filtered to the cluster's certs (plus any global ones)."
|
|
/>
|
|
)}
|
|
{noCertsForCluster && (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
style={{ marginBottom: 8 }}
|
|
message="No imported SSL certificates are visible for this cluster."
|
|
description="Either upload a new cert via 'Upload PEM' above, switch to ACME, or import a cert from the SSL Certificates page (cluster-specific or global)."
|
|
/>
|
|
)}
|
|
<Form.Item
|
|
name={['ssl', 'ssl_certificate_id']}
|
|
label="Existing certificate"
|
|
rules={[{ required: true }]}
|
|
>
|
|
<Select
|
|
placeholder={hasCluster ? 'Select an existing certificate' : 'Select a cluster first'}
|
|
disabled={!hasCluster}
|
|
showSearch
|
|
optionFilterProp="children"
|
|
notFoundContent={hasCluster ? 'No frontend certificates for this cluster' : 'Select a cluster first'}
|
|
>
|
|
{/* Phase K Phase D (Bulgu #2): mirror the
|
|
BackendServers usage_type filter on the
|
|
HTTPS-frontend side. This Select binds the
|
|
cert to the HTTPS frontend's `crt` slot —
|
|
HAProxy expects a server-presented cert,
|
|
i.e. usage_type='frontend'. Surfacing
|
|
server-side (`ca-file`) certs here would
|
|
let an operator submit a payload that
|
|
apply-time HAProxy would parse-error on
|
|
(`unable to load SSL private key from PEM
|
|
file`). Filter matches BackendServers'
|
|
implicit contract. */}
|
|
{frontendCertsForCluster.map((c) => (
|
|
<Option key={c.id} value={c.id}>
|
|
{c.name}{c.primary_domain ? ` — ${c.primary_domain}` : ''}{c.ssl_type ? ` [${c.ssl_type}]` : ''}
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
</>
|
|
);
|
|
}
|
|
if (sslMode === 'acme') {
|
|
return (
|
|
<>
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message="ACME mode requires Apply Immediately"
|
|
description="The agent must confirm the new HTTP frontend before Let's Encrypt can validate the HTTP-01 challenge. The wizard will set apply_immediately=true automatically."
|
|
/>
|
|
|
|
{/* ACME account & renewal advanced */}
|
|
{acmeAccounts.length > 1 && (
|
|
<Form.Item name={['ssl', 'account_id']} label="Let's Encrypt account" style={{ marginTop: 12 }}>
|
|
<Select
|
|
allowClear
|
|
placeholder={`Auto-select latest valid (${acmeAccounts.length} accounts available)`}
|
|
>
|
|
{acmeAccounts.map((a) => (
|
|
<Option key={a.id} value={a.id}>
|
|
{a.email || `account #${a.id}`} — <span style={{ color: '#888' }}>{a.directory_url || ''}</span>
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
)}
|
|
<Row gutter={16} style={{ marginTop: 12 }}>
|
|
<Col span={24}>
|
|
<Form.Item name={['ssl', 'auto_renew']} label="Auto-renew when expiring" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
{/* v1.5.0 R12: per-cert renew-before-days override is
|
|
not yet plumbed end-to-end (renewal scheduler still
|
|
reads global acme.renew_before_days). Keeping the
|
|
UI honest by NOT surfacing a placebo control. */}
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginTop: 8, marginBottom: 12 }}
|
|
message="Wildcards (*.example.com) require DNS-01 challenge — not yet supported in this wizard. Use ACME Automation > Manual order for wildcards."
|
|
/>
|
|
|
|
<div style={{ marginTop: 16 }}>
|
|
<Button type="primary" icon={<ReloadOutlined />} loading={preflightLoading} onClick={runPreflight}>
|
|
Run ACME pre-flight checks
|
|
</Button>
|
|
</div>
|
|
{preflightResults && (
|
|
<div style={{ marginTop: 16 }}>
|
|
{preflightResults.checks.map((c) => (
|
|
<Card key={c.id} size="small" style={{ marginBottom: 8 }}>
|
|
<Space>
|
|
{checkStatusToTag(c.status)}
|
|
<strong>{c.label}:</strong>
|
|
<span>{c.message}</span>
|
|
</Space>
|
|
</Card>
|
|
))}
|
|
{preflightResults.checks.some((c) => c.status === 'warn' || c.status === 'fail') && (
|
|
<Alert
|
|
type={preflightResults.checks.some((c) => c.status === 'fail') ? 'error' : 'warning'}
|
|
showIcon
|
|
style={{ marginTop: 12 }}
|
|
message={preflightResults.checks.some((c) => c.status === 'fail')
|
|
? 'Some pre-flight checks failed'
|
|
: 'Some pre-flight checks reported warnings'}
|
|
description={
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={continueAnyway}
|
|
onChange={(e) => setContinueAnyway(e.target.checked)}
|
|
/>{' '}
|
|
Continue anyway (override; e.g. egress port-80 false-positive in corporate networks)
|
|
</label>
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
</Form.Item>
|
|
|
|
{/* HTTPS frontend tuning — visible whenever HTTPS will be created.
|
|
Phase K Phase D: only the security-shaping knobs (TLS bounds +
|
|
HSTS quartet) stay visible at the top level. The rarely-used
|
|
tuning (https_bind_port, https_frontend_name_suffix, ALPN,
|
|
ciphers, ciphersuites, strict-sni, mTLS) moves into a nested
|
|
"Advanced TLS settings (rarely needed)" Collapse that defaults
|
|
to closed. The Advanced Collapse auto-opens when a saved draft
|
|
has non-default values so resumed drafts surface their custom
|
|
settings instead of hiding them silently. */}
|
|
<Form.Item shouldUpdate>
|
|
{() => {
|
|
const sslMode = form.getFieldValue(['ssl', 'mode']);
|
|
if (!['acme', 'upload', 'existing'].includes(sslMode)) return null;
|
|
const sslVals = form.getFieldValue('ssl') || {};
|
|
const advancedHasNonDefault =
|
|
(sslVals.https_bind_port != null && sslVals.https_bind_port !== 443) ||
|
|
(sslVals.https_frontend_name_suffix &&
|
|
sslVals.https_frontend_name_suffix !== '-https') ||
|
|
(sslVals.ssl_alpn && sslVals.ssl_alpn !== 'h2,http/1.1') ||
|
|
!!sslVals.ssl_ciphers ||
|
|
!!sslVals.ssl_ciphersuites ||
|
|
sslVals.ssl_strict_sni === true ||
|
|
!!sslVals.ssl_verify;
|
|
return (
|
|
<Collapse
|
|
ghost
|
|
style={{ marginTop: 16 }}
|
|
items={[{
|
|
key: 'ssl-https-tuning',
|
|
label: <Space><LockOutlined /><span>HTTPS frontend tuning (TLS, HSTS)</span></Space>,
|
|
children: (
|
|
<>
|
|
{/* Read-only summary of the safe defaults that apply
|
|
unless the operator opens Advanced TLS settings. */}
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 12 }}
|
|
message="Safe defaults (apply unless overridden under Advanced TLS settings below)"
|
|
description="Port 443 · ALPN h2,http/1.1 · strict-sni off · modern HAProxy ciphers · mTLS disabled"
|
|
/>
|
|
{/* TLS min/max — first-class controls because
|
|
they shape security posture. UI parity for the
|
|
min ≤ max rule from the Phase A
|
|
`reject_inverted_tls_versions` validator. */}
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name={['ssl', 'ssl_min_ver']}
|
|
label="TLS min version"
|
|
dependencies={[['ssl', 'ssl_max_ver']]}
|
|
rules={[
|
|
({ getFieldValue }) => ({
|
|
validator(_, value) {
|
|
const max = getFieldValue(['ssl', 'ssl_max_ver']);
|
|
if (!value || !max) return Promise.resolve();
|
|
const order = { 'TLSv1.0': 0, 'TLSv1.1': 1, 'TLSv1.2': 2, 'TLSv1.3': 3 };
|
|
if (order[value] > order[max]) {
|
|
return Promise.reject(new Error(
|
|
'TLS min version cannot be greater than TLS max version.'
|
|
));
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
}),
|
|
]}
|
|
>
|
|
<Select>
|
|
<Option value="TLSv1.2">TLSv1.2 (recommended)</Option>
|
|
<Option value="TLSv1.3">TLSv1.3 (modern)</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name={['ssl', 'ssl_max_ver']}
|
|
label="TLS max version"
|
|
dependencies={[['ssl', 'ssl_min_ver']]}
|
|
rules={[
|
|
({ getFieldValue }) => ({
|
|
validator(_, value) {
|
|
const min = getFieldValue(['ssl', 'ssl_min_ver']);
|
|
if (!value || !min) return Promise.resolve();
|
|
const order = { 'TLSv1.0': 0, 'TLSv1.1': 1, 'TLSv1.2': 2, 'TLSv1.3': 3 };
|
|
if (order[min] > order[value]) {
|
|
return Promise.reject(new Error(
|
|
'TLS max version cannot be lower than TLS min version.'
|
|
));
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
}),
|
|
]}
|
|
>
|
|
<Select allowClear placeholder="(no max)">
|
|
<Option value="TLSv1.2">TLSv1.2</Option>
|
|
<Option value="TLSv1.3">TLSv1.3</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* HSTS quartet — UI parity for the Phase A backend
|
|
`reject_hsts_preload_without_hsts` validator's
|
|
three-way prerequisite. The dependencies wiring
|
|
disables `hsts_max_age` / `hsts_include_subdomains`
|
|
/ `hsts_preload` while their prerequisites are
|
|
unmet so the operator cannot author an
|
|
unreachable state. */}
|
|
<Divider plain>HTTP Strict Transport Security (HSTS)</Divider>
|
|
<Form.Item shouldUpdate noStyle>
|
|
{() => {
|
|
const hstsEnabled = !!form.getFieldValue(['ssl', 'hsts_enabled']);
|
|
const hstsMaxAge = form.getFieldValue(['ssl', 'hsts_max_age']);
|
|
const hstsIncl = !!form.getFieldValue(['ssl', 'hsts_include_subdomains']);
|
|
const preloadOk =
|
|
hstsEnabled &&
|
|
hstsIncl &&
|
|
typeof hstsMaxAge === 'number' &&
|
|
hstsMaxAge >= 31536000;
|
|
const preloadTooltip = preloadOk
|
|
? ''
|
|
: 'HSTS preload requires HSTS enabled, max-age ≥ 1 year (31536000 s), and includeSubDomains.';
|
|
return (
|
|
<Row gutter={16}>
|
|
<Col span={6}>
|
|
<Form.Item name={['ssl', 'hsts_enabled']} label="Enable HSTS" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Tooltip title={hstsEnabled ? '' : 'Enable HSTS first.'}>
|
|
<Form.Item name={['ssl', 'hsts_max_age']} label="max-age (seconds)">
|
|
<InputNumber
|
|
min={0}
|
|
step={31536000}
|
|
style={{ width: '100%' }}
|
|
disabled={!hstsEnabled}
|
|
aria-disabled={!hstsEnabled || undefined}
|
|
/>
|
|
</Form.Item>
|
|
</Tooltip>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Tooltip title={hstsEnabled ? '' : 'Enable HSTS first.'}>
|
|
<Form.Item name={['ssl', 'hsts_include_subdomains']} label="includeSubDomains" valuePropName="checked">
|
|
<Switch
|
|
disabled={!hstsEnabled}
|
|
aria-disabled={!hstsEnabled || undefined}
|
|
/>
|
|
</Form.Item>
|
|
</Tooltip>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Tooltip title={preloadTooltip}>
|
|
<Form.Item name={['ssl', 'hsts_preload']} label="preload" valuePropName="checked">
|
|
<Switch
|
|
disabled={!preloadOk}
|
|
aria-disabled={!preloadOk || undefined}
|
|
/>
|
|
</Form.Item>
|
|
</Tooltip>
|
|
</Col>
|
|
</Row>
|
|
);
|
|
}}
|
|
</Form.Item>
|
|
|
|
{/* Advanced TLS settings — default closed Collapse.
|
|
Auto-opens when a saved draft has non-default
|
|
values so resumed drafts surface their tuning. */}
|
|
<Collapse
|
|
ghost
|
|
style={{ marginTop: 8 }}
|
|
defaultActiveKey={advancedHasNonDefault ? ['advanced-tls'] : []}
|
|
items={[{
|
|
key: 'advanced-tls',
|
|
label: (
|
|
<Space>
|
|
<SettingOutlined />
|
|
<span>Advanced TLS settings (rarely needed)</span>
|
|
</Space>
|
|
),
|
|
children: (
|
|
<>
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
<Form.Item name={['ssl', 'https_bind_port']} label="HTTPS bind port">
|
|
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['ssl', 'https_frontend_name_suffix']} label="Frontend name suffix">
|
|
<Input placeholder="-https" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name={['ssl', 'ssl_strict_sni']} label="strict-sni" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Form.Item
|
|
name={['ssl', 'ssl_alpn']}
|
|
label="ALPN (HTTP/2 negotiation)"
|
|
tooltip="Comma-separated. 'h2,http/1.1' = HTTP/2 with HTTP/1.1 fallback. Empty = HTTP/1.1 only."
|
|
>
|
|
<Input placeholder="h2,http/1.1" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name={['ssl', 'ssl_ciphers']}
|
|
label="Ciphers (TLSv1.2 and earlier)"
|
|
tooltip="OpenSSL cipher list, e.g. ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM"
|
|
>
|
|
<Input placeholder="(use HAProxy default)" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name={['ssl', 'ssl_ciphersuites']}
|
|
label="Ciphersuites (TLSv1.3)"
|
|
tooltip="TLS 1.3 ciphersuites, e.g. TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256"
|
|
>
|
|
<Input placeholder="(use HAProxy default)" />
|
|
</Form.Item>
|
|
{/* R17 minimum-parity: mTLS client cert auth.
|
|
Default is (disabled) because 'required'
|
|
would lock out every browser that doesn't
|
|
ship a client cert. */}
|
|
{/* Bulgu #26 (round-12 audit): the renderer's
|
|
client-CA bundle path resolver
|
|
(`_resolve_frontend_client_ca_path`) is a
|
|
PR-7 placeholder that always returns None,
|
|
so `verify required|optional` is SILENTLY
|
|
dropped to avoid a fatal HAProxy ALERT.
|
|
Operators who picked "required" got a row
|
|
with ssl_verify='required' in the DB but
|
|
the actually-deployed bind line had no
|
|
`verify` token — mTLS was silently
|
|
disabled. Until the ca-file column lands
|
|
we restrict the UI to 'none' to match the
|
|
backend SSLChoice validator's rejection
|
|
of 'optional' / 'required'. */}
|
|
<Form.Item
|
|
name={['ssl', 'ssl_verify']}
|
|
label="Client cert verification (mTLS)"
|
|
tooltip="Mutual TLS requires a client-CA bundle field which is not yet exposed by the wizard. Pick 'none' or leave blank; configure mTLS via Frontend Management → Advanced TLS once the ca-file UI lands."
|
|
>
|
|
<Select allowClear placeholder="(disabled — anonymous TLS)">
|
|
<Option value="none">none — disabled</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</>
|
|
),
|
|
}]}
|
|
/>
|
|
</>
|
|
),
|
|
}]}
|
|
/>
|
|
);
|
|
}}
|
|
</Form.Item>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
title: 'Review & Apply',
|
|
content: (
|
|
<>
|
|
{/* Bulgu #44: ssl.mode='acme' forces apply_immediately=true (backend
|
|
enforces it via M22). Reflect that in the UI so the switch state
|
|
is never out of sync with the actual submitted value. */}
|
|
{/* R18c round 10 (M8): the coercion now lives in a useEffect at
|
|
the top of this component (search for "M8" there). The Form.Item
|
|
shouldUpdate wrapper is preserved so the Switch re-renders when
|
|
`apply_immediately` flips, but the render function no longer
|
|
schedules a setTimeout setState — that was a setState-in-render
|
|
anti-pattern. */}
|
|
<Form.Item shouldUpdate>
|
|
{() => {
|
|
const currentSslMode = form.getFieldValue(['ssl', 'mode']);
|
|
const acmeForcesApply = currentSslMode === 'acme';
|
|
return (
|
|
<Form.Item name="apply_immediately" label="Apply Immediately" valuePropName="checked">
|
|
<Switch disabled={acmeForcesApply} />
|
|
</Form.Item>
|
|
);
|
|
}}
|
|
</Form.Item>
|
|
{sslMode === 'acme' && (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
message="Apply Immediately is forced ON for ACME mode (the agent must confirm the new HTTP frontend before Let's Encrypt can validate the HTTP-01 challenge)."
|
|
/>
|
|
)}
|
|
<div style={{ marginTop: 16 }}>
|
|
<Button icon={<ReloadOutlined />} onClick={runPreview} loading={previewLoading}>
|
|
Generate diff preview
|
|
</Button>
|
|
</div>
|
|
{previewResults && (
|
|
<div style={{ marginTop: 16 }}>
|
|
{/* Bulgu #36 (round-15 audit) — surface blocking errors as
|
|
a red banner so the user knows Create will be rejected
|
|
before they click it. The same strings appear in the
|
|
warning list below for backward compatibility, but the
|
|
red banner is the authoritative gate. */}
|
|
{Array.isArray(previewResults.blocking_errors) && previewResults.blocking_errors.length > 0 && (
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
message={`Submit will be rejected (${previewResults.blocking_errors.length} blocker${previewResults.blocking_errors.length === 1 ? '' : 's'})`}
|
|
description={
|
|
<ul style={{ marginBottom: 0 }}>
|
|
{previewResults.blocking_errors.map((e, i) => <li key={i}>{e}</li>)}
|
|
</ul>
|
|
}
|
|
style={{ marginBottom: 12 }}
|
|
/>
|
|
)}
|
|
{previewResults.warnings?.length > 0 && (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message="Warnings"
|
|
description={
|
|
<ul>{previewResults.warnings.map((w, i) => <li key={i}>{w}</li>)}</ul>
|
|
}
|
|
/>
|
|
)}
|
|
<Card size="small" title="Will create" style={{ marginTop: 12 }}>
|
|
<pre style={{ fontSize: 12, whiteSpace: 'pre-wrap' }}>
|
|
{JSON.stringify(previewResults.would_create, null, 2)}
|
|
</pre>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
{/* Phase K Phase C: HAProxy dry-run validation card. */}
|
|
<div style={{ marginTop: 16 }}>
|
|
{dryRunResult.status === 'idle' && (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
message="Preparing HAProxy validation…"
|
|
/>
|
|
)}
|
|
{dryRunResult.status === 'loading' && (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
icon={<Spin size="small" />}
|
|
message="Validating against HAProxy…"
|
|
description="Synthesising the candidate config and running the heuristic validator before you click Create."
|
|
/>
|
|
)}
|
|
{dryRunResult.status === 'clean' && (
|
|
<Alert
|
|
type="success"
|
|
showIcon
|
|
message="All HAProxy validation checks passed"
|
|
description="The candidate configuration is structurally valid. The agent's haproxy -c -f remains the ultimate gate at apply time."
|
|
/>
|
|
)}
|
|
{dryRunResult.status === 'warnings_only' && (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message={`Configuration is valid but emitted ${dryRunResult.warnings.length} warning(s)`}
|
|
description={
|
|
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
|
{dryRunResult.warnings.map((w, i) => (
|
|
<li key={i}>
|
|
{w.section ? `[${w.section}] ` : ''}{w.message}
|
|
{w.directive ? ` (${w.directive})` : ''}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
}
|
|
/>
|
|
)}
|
|
{dryRunResult.status === 'errors' && (
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
message={`${dryRunResult.errors.length} HAProxy validation error(s) found`}
|
|
description={
|
|
<div>
|
|
<div style={{ marginBottom: 8 }}>
|
|
Create is disabled until these errors are resolved. Use the Edit-Step buttons to jump back and fix each issue.
|
|
</div>
|
|
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
|
{dryRunResult.errors.map((e, i) => {
|
|
const targetStep = _directiveToStep(e.directive);
|
|
return (
|
|
<li key={i} style={{ marginBottom: 6 }}>
|
|
<Space size={4} wrap>
|
|
<span>
|
|
{e.section ? <strong>[{e.section}] </strong> : null}
|
|
{e.message}
|
|
{e.directive ? <em> ({e.directive})</em> : null}
|
|
</span>
|
|
{targetStep !== null && (
|
|
<Button
|
|
size="small"
|
|
type="link"
|
|
onClick={() => setStep(targetStep)}
|
|
>
|
|
Edit Step {targetStep + 1}
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
}
|
|
/>
|
|
)}
|
|
{dryRunResult.status === 'pydantic_error' && (
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
message={`${dryRunResult.pydanticErrors.length} field-level validation error(s) found`}
|
|
description={
|
|
<div>
|
|
<div style={{ marginBottom: 8 }}>
|
|
The wizard payload itself is invalid. Use the Edit-Step buttons to jump back and re-enter the affected field(s).
|
|
</div>
|
|
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
|
{dryRunResult.pydanticErrors.map((p, i) => {
|
|
const targetStep = _locPathToStep(p.loc, p.msg);
|
|
// Phase K Phase C audit-fix #2 round 4: skip the
|
|
// empty-path strong/colon prefix for SiteCreate-
|
|
// level model_validator errors. Pydantic v2 raises
|
|
// those with `loc=()`; FastAPI prepends `'body'`
|
|
// so the operator-visible loc is `['body']`. After
|
|
// `slice(1)` that becomes `[]` → `join('.')` →
|
|
// empty string, which used to render as a stray
|
|
// `<strong>: </strong>` before the message text.
|
|
// Now we conditionally render the path prefix
|
|
// ONLY when there is actually a field path.
|
|
const fieldPath =
|
|
Array.isArray(p.loc) && p.loc.length > 1
|
|
? p.loc.slice(1).join('.')
|
|
: '';
|
|
return (
|
|
<li key={i} style={{ marginBottom: 6 }}>
|
|
<Space size={4} wrap>
|
|
<span>
|
|
{fieldPath && (
|
|
<strong>{fieldPath}: </strong>
|
|
)}
|
|
{p.msg}
|
|
</span>
|
|
{targetStep !== null && (
|
|
<Button
|
|
size="small"
|
|
type="link"
|
|
onClick={() => setStep(targetStep)}
|
|
>
|
|
Edit Step {targetStep + 1}
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
}
|
|
/>
|
|
)}
|
|
{dryRunResult.status === 'unavailable' && (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message="HAProxy validation could not be performed at this time"
|
|
description={
|
|
dryRunResult.message ||
|
|
"The agent's haproxy -c remains the final authority on apply. You can still proceed to Create."
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
),
|
|
},
|
|
];
|
|
|
|
// R15 hotfix: sslMode moved to the top of the component (above
|
|
// stepContents). Don't redeclare it here. acmeBlocksDraft was
|
|
// removed in Phase K Phase D when the "Create as PENDING" button
|
|
// was retired.
|
|
|
|
// Bulgu #6 fix: block ACME submit when pre-flight checks reported a hard
|
|
// failure. Operators can still bypass via the "Continue anyway" checkbox
|
|
// (e.g. egress port-80 false-positive in corp networks). If pre-flight
|
|
// wasn't run we don't block — backend still enforces apply_immediately+http
|
|
// and the staged ACME order surfaces a structured error if validation fails.
|
|
const acmePreflightHasFail =
|
|
sslMode === 'acme' &&
|
|
Array.isArray(preflightResults?.checks) &&
|
|
preflightResults.checks.some((c) => c.status === 'fail');
|
|
const acmeBlocksSubmit = acmePreflightHasFail && !continueAnyway;
|
|
// R18c round 10 (M2): Submit must wait for the cluster-scoped cert
|
|
// list to finish loading WHEN the wizard is in 'existing' SSL mode
|
|
// — otherwise a resumed draft can race the orphan-cleanup pass and
|
|
// submit a stale ssl_certificate_id that has been deleted in the
|
|
// background (FK 400). This guard fires for resumed AND fresh
|
|
// sessions because a slow network on Step 0 + a fast tab switch to
|
|
// Step 4 hits the same race. Other ssl modes (acme / upload / none)
|
|
// do not touch the existing-cert list, so they are unaffected.
|
|
const certReconciliationPending =
|
|
sslMode === 'existing' && existingCertsLoading;
|
|
// Phase K Phase C: gate Create on the dry-run result. Only the
|
|
// hard-error states block (errors / pydantic_error / loading);
|
|
// warnings_only / clean / unavailable keep Create enabled because
|
|
// (a) the agent's haproxy -c is the ultimate gate, and (b) we
|
|
// explicitly mirror the create_site validator-crash-is-non-fatal
|
|
// posture for `unavailable`.
|
|
const dryRunBlocksSubmit =
|
|
dryRunResult.status === 'errors' ||
|
|
dryRunResult.status === 'pydantic_error' ||
|
|
dryRunResult.status === 'loading';
|
|
const dryRunBlockReason =
|
|
dryRunResult.status === 'errors'
|
|
? 'HAProxy validation reported errors. Resolve them before creating the site.'
|
|
: dryRunResult.status === 'pydantic_error'
|
|
? 'Field-level validation errors detected. Use the Edit-Step buttons in the validation card to fix them.'
|
|
: dryRunResult.status === 'loading'
|
|
? 'Validation is still running — please wait a moment.'
|
|
: '';
|
|
|
|
// Bulgu #36 (round-15 audit) — surface preview blocking_errors as a
|
|
// hard submit block so operators see them BEFORE clicking Create
|
|
// instead of getting an opaque 400 from `/api/sites`. The /preview
|
|
// endpoint emits the same actionable strings `create_site` would
|
|
// raise (bind collisions, ACME port-80 reachability, HTTPS name
|
|
// collisions, …) into `previewResults.blocking_errors`.
|
|
const previewBlockingErrors = Array.isArray(previewResults?.blocking_errors)
|
|
? previewResults.blocking_errors
|
|
: [];
|
|
const previewBlocksSubmit = previewBlockingErrors.length > 0;
|
|
const previewBlockReason = previewBlocksSubmit
|
|
? `${previewBlockingErrors.length === 1 ? 'A blocker' : `${previewBlockingErrors.length} blockers`} was detected by the preview that the submit endpoint will hard-reject. Review the red banner above and adjust the wizard before retrying.`
|
|
: '';
|
|
|
|
const submitBlockReason = acmeBlocksSubmit
|
|
? 'ACME pre-flight reported a hard failure. Tick "Continue anyway" on the SSL step to override.'
|
|
: (previewBlocksSubmit
|
|
? previewBlockReason
|
|
: (certReconciliationPending
|
|
? 'Reconciling selected certificate against the cluster — please wait.'
|
|
: dryRunBlockReason));
|
|
// Phase K Phase D (Bulgu #6): label for the (now single) submit
|
|
// button — "Create Site" by default; "Create & Apply (ACME)" when
|
|
// the operator picked ACME so they understand why the standard
|
|
// PENDING → Apply Management round-trip is bypassed on this one
|
|
// flow. The semantic is fully driven by `handleSubmit`'s sslMode-
|
|
// derived `effectiveApply`.
|
|
const submitButtonLabel =
|
|
sslMode === 'acme' ? 'Create & Apply (ACME)' : 'Create Site';
|
|
const submitButtonTooltip = sslMode === 'acme'
|
|
? (submitBlockReason || 'ACME mode forces immediate apply so the agent can satisfy the HTTP-01 challenge.')
|
|
: (submitBlockReason || 'Creates a PENDING version. You will be taken to Apply Management to review and deploy it.');
|
|
return (
|
|
<Card title="New Site (Wizard)" extra={
|
|
<Space>
|
|
<Button onClick={handleCancel}>Cancel</Button>
|
|
<Tooltip title={submitButtonTooltip}>
|
|
<Button
|
|
type="primary"
|
|
icon={<CheckCircleOutlined />}
|
|
onClick={() => handleSubmit()}
|
|
loading={submitting}
|
|
disabled={acmeBlocksSubmit || certReconciliationPending || dryRunBlocksSubmit || previewBlocksSubmit}
|
|
>
|
|
{submitButtonLabel}
|
|
</Button>
|
|
</Tooltip>
|
|
</Space>
|
|
}>
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
initialValues={initialValues}
|
|
onValuesChange={() => {
|
|
// Phase K Phase C: invalidate the dry-run result on any
|
|
// field edit so the auto-fire useEffect re-runs the next
|
|
// time the operator lands on Step 4 (or immediately, if
|
|
// they edit a Step-4-visible field like Apply
|
|
// Immediately).
|
|
//
|
|
// Audit-fix #3: read latest status via the REF (not the
|
|
// closure-captured state) and ALSO bump
|
|
// `dryRunInvalidationTick` so the auto-fire effect re-
|
|
// runs even though we removed `dryRunResult.status` from
|
|
// its dep array. Without the tick bump the effect would
|
|
// never re-evaluate after the operator toggles Apply
|
|
// Immediately on Step 4 — the very flag whose change
|
|
// operator MOST wants reflected in the validation card.
|
|
if (dryRunStatusRef.current !== 'idle') {
|
|
setDryRunResult({
|
|
status: 'idle',
|
|
errors: [],
|
|
warnings: [],
|
|
infos: [],
|
|
pydanticErrors: [],
|
|
message: '',
|
|
});
|
|
setDryRunInvalidationTick((t) => t + 1);
|
|
}
|
|
}}
|
|
>
|
|
{resumedFromDraft && (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
message="Resumed from draft"
|
|
description={pemStrippedOnResume
|
|
? 'PEM fields (certificate / private key / chain) were stripped on save and must be re-entered before submitting. You are at Review & Apply; use Previous to edit any field.'
|
|
: 'Your previously saved wizard state has been loaded. You are at Review & Apply; use Previous to edit any field.'}
|
|
closable
|
|
onClose={() => setResumedFromDraft(false)}
|
|
/>
|
|
)}
|
|
<Steps current={step} items={stepContents.map((s) => ({ title: s.title }))} style={{ marginBottom: 24 }} />
|
|
{/* Bug B fix: keep ALL step contents mounted at all times. Antd
|
|
Form.Item only registers field values once mounted; rendering
|
|
only the current step caused validateFields() / getFieldsValue()
|
|
to return only the visible step's values — submit fired with
|
|
an empty body ({apply_immediately: true}) and the backend
|
|
returned 422. Hide non-active steps with display:none instead
|
|
of unmounting them so the field registry stays intact. */}
|
|
{stepContents.map((s, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{ display: step === idx ? 'block' : 'none' }}
|
|
aria-hidden={step !== idx}
|
|
>
|
|
{s.content}
|
|
</div>
|
|
))}
|
|
<Divider />
|
|
<Space>
|
|
<Button disabled={step === 0} onClick={() => setStep(step - 1)}>Previous</Button>
|
|
<Button
|
|
type="primary"
|
|
disabled={step === stepContents.length - 1}
|
|
onClick={async () => {
|
|
try {
|
|
// R17 fix: per-step validation must walk EVERY required nested
|
|
// path, not just the top-level Form.List wrapper. Antd's
|
|
// validateFields(['servers']) only validates the Form.List
|
|
// metadata — it does NOT trigger the per-row required rules,
|
|
// so users could leave Address blank and `Next` would happily
|
|
// advance, the error surfacing only at submit time.
|
|
if (step === 0) {
|
|
await form.validateFields(['cluster_id', 'domains']);
|
|
}
|
|
if (step === 1) {
|
|
const serverRows = form.getFieldValue('servers') || [];
|
|
const serverPaths = serverRows.flatMap((_, i) => [
|
|
['servers', i, 'server_name'],
|
|
['servers', i, 'server_address'],
|
|
['servers', i, 'server_port'],
|
|
]);
|
|
await form.validateFields([
|
|
['backend', 'name'],
|
|
...serverPaths,
|
|
]);
|
|
}
|
|
if (step === 2) {
|
|
await form.validateFields([['frontend', 'name']]);
|
|
// Phase K Phase B: hard-block on the two known
|
|
// mutually-exclusive combinations BEFORE the wizard
|
|
// advances. Pre-Phase-K both rejections only surfaced
|
|
// at the final POST as opaque 422 / 422-derived toasts;
|
|
// operators reached Step 4 only to be punted back.
|
|
// The Pydantic validators at
|
|
// `models/site_wizard.py::reject_redirect_conflict` and
|
|
// `models/site_wizard.py::reject_tcp_mode_with_https_redirect`
|
|
// (Phase A) stay as the source of truth — these UI
|
|
// gates just surface the same logic earlier with a
|
|
// one-click resolution path.
|
|
const httpsRedirectNow = form.getFieldValue(['frontend', 'https_redirect']);
|
|
const frontendModeNow = form.getFieldValue(['frontend', 'mode']);
|
|
if (httpsRedirectNow && (aclBuilderData.redirectRules || []).length > 0) {
|
|
message.error(
|
|
'HTTP→HTTPS redirect cannot be combined with custom redirect rules. ' +
|
|
'Use the buttons in the alert above to either disable the switch or remove the rules before continuing.'
|
|
);
|
|
return;
|
|
}
|
|
if (frontendModeNow === 'tcp' && httpsRedirectNow) {
|
|
message.error(
|
|
'HTTP→HTTPS redirect requires HTTP mode (L7). TCP frontends operate at L4 ' +
|
|
'and cannot inspect HTTP headers. Disable the redirect switch or change the ' +
|
|
'frontend mode to http before continuing.'
|
|
);
|
|
return;
|
|
}
|
|
// Issue #38 follow-up — the Bulgu #12 Step 2 → 3
|
|
// hard gate for the ACL `-f <file>` pattern-file
|
|
// flag was removed together with the server-side
|
|
// Pydantic rejects: pattern files are operator-
|
|
// managed host files (same policy as SPOE filter
|
|
// configs since v1.8.8) and the agent's pre-reload
|
|
// `haproxy -c` makes a missing file fail safely.
|
|
// The ACLRuleBuilder renders an informational note
|
|
// on `-f` rules instead of a blocking error.
|
|
// Phase K Phase D follow-up (Bulgu #13) — block
|
|
// advance when any routing / redirect rule has a
|
|
// self-contradictory condition (`acl1 !acl1`).
|
|
// HAProxy accepts the syntax but the rule never
|
|
// fires → silent fallback to `default_backend`.
|
|
// The ACLRuleBuilder also renders a section-level
|
|
// Alert and a per-card error decoration; this is
|
|
// the hard gate that prevents Next when the
|
|
// operator ignores the inline warning.
|
|
const CONTRA_TOKEN_RE = /^!?[A-Za-z_][\w.-]*$/;
|
|
const hasContradiction = (str) => {
|
|
if (typeof str !== 'string') return false;
|
|
const pos = new Set();
|
|
const neg = new Set();
|
|
for (const raw of str.split(/\s+/)) {
|
|
if (!raw || raw === 'if' || raw === 'unless') continue;
|
|
if (!CONTRA_TOKEN_RE.test(raw)) continue;
|
|
if (raw.startsWith('!')) {
|
|
neg.add(raw.slice(1));
|
|
} else {
|
|
pos.add(raw);
|
|
}
|
|
}
|
|
for (const n of pos) if (neg.has(n)) return true;
|
|
return false;
|
|
};
|
|
const routingAndRedirect = [
|
|
...(aclBuilderData.useBackendRules || []),
|
|
...(aclBuilderData.redirectRules || []).map(
|
|
(r) => (typeof r === 'string' ? r : ''),
|
|
),
|
|
];
|
|
if (routingAndRedirect.some(hasContradiction)) {
|
|
message.error(
|
|
'One or more routing / redirect rules contain the same ACL ' +
|
|
'in both positive AND negated form (e.g. `if acl1 !acl1`). ' +
|
|
'HAProxy accepts this syntax but the predicate `X AND NOT X` ' +
|
|
'is always false, so the rule never fires and traffic ' +
|
|
'silently falls through to `default_backend`. Remove one of ' +
|
|
'the two tokens before continuing.'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
if (step === 3) {
|
|
// SSL step: only validate the conditional required fields
|
|
// for the chosen mode. Pydantic enforces the same rules at
|
|
// submit, but catching them here keeps the user inside the
|
|
// SSL step instead of bouncing back from Review.
|
|
const mode = form.getFieldValue(['ssl', 'mode']);
|
|
if (mode === 'upload') {
|
|
await form.validateFields([
|
|
['ssl', 'name'],
|
|
['ssl', 'certificate_content'],
|
|
['ssl', 'private_key_content'],
|
|
]);
|
|
} else if (mode === 'existing') {
|
|
await form.validateFields([['ssl', 'ssl_certificate_id']]);
|
|
}
|
|
}
|
|
setStep(step + 1);
|
|
} catch (_e) {
|
|
message.error('Please fix validation errors before continuing');
|
|
}
|
|
}}
|
|
>
|
|
Next
|
|
</Button>
|
|
<Button onClick={handleSaveDraft} icon={<SaveOutlined />}>Save Draft</Button>
|
|
</Space>
|
|
</Form>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default SiteWizard;
|