15 Commits

Author SHA1 Message Date
taylanbakircioglu 9e64002f1c fix(vip): make the Adoptable tag name the real blocker (v1.10.11)
The tag and the disabled Adopt button were computed by two separate ladders and
could disagree. Seen on a live pair: one node's keepalived.conf had an
unbalanced brace, so it was excluded from the instance; its partner was then
tagged "MASTER missing" - technically true, because the unreadable node's
state MASTER had not been counted - while the actual reason (the peer cannot be
taken over, so adopting would strand it) sat only in the button's tooltip. The
label pointed the operator at the wrong node.

groupState now makes ONE ordered decision and returns the label, its colour and
the reason together, so the tag can never describe a different condition than
the one disabling the button. A group held up by a node that references the
same address but cannot be adopted with it reads "blocked by peer"; two MASTERs
is distinguished from none.

Display only: the endpoint's checks and refusals are untouched.

Backend suite: 1366 passed, 152 skipped. Frontend build clean.
2026-08-14 08:54:29 +03:00
taylanbakircioglu 4f24d5bdd9 fix(vip): list adoption blockers once per instance (v1.10.10)
Since the panel groups a VRRP instance into one row, the blocker lists of all
its members are merged - and every node reports the SAME problems about the
SAME shared config. A two-node pair therefore showed each issue twice, in the
Adoptable tooltip and in the adopt dialog. Plain de-duplication does not
collapse them because the two files report different line numbers for the same
directive.

mergeBlockers keys on the message with a leading "line N:" stripped and keeps
the first occurrence, so each distinct problem appears once while the text the
operator reads still carries a line reference.

Display only: the endpoint already evaluated the combined set across every
node, and what it accepts or refuses is unchanged.

Backend suite: 1366 passed, 152 skipped. Frontend build clean.
2026-08-14 08:34:04 +03:00
taylanbakircioglu a87994e06a fix(vip): refuse adoption that strands a node or normalises a peer (v1.10.9)
Three findings from a second pass over the adoption flow, all of the same
class: something real leaving the set silently.

1. STRANDING. _collect_instance_participants can only match a node it can
   READ, that is ENABLED, and that is in the SAME pool. Each of those is a door
   a genuine member of the VRRP group leaves through without a word, and the
   nodes that remain are rewritten while it keeps serving the same address from
   an unmanaged config. Found on a live pool: one node of a pair had an
   unclosed vrrp_instance block, so it parsed to nothing while its partner
   parsed cleanly.

   Rather than guard each door, ask the question directly: does any reported
   keepalived.conf mention THIS virtual address without being one of the nodes
   we are about to adopt? Refuses naming the node and the reason. Scoped on the
   address so an unrelated file elsewhere cannot block every adoption, and
   excluding nodes already under management (a standing VIP, or our ownership
   marker) because those are not stranded.

2. SILENT NORMALISATION. prefix_length, unicast/multicast mode, HAProxy
   tracking and the VRRP password are stored ONCE on the VIP and re-rendered
   onto EVERY member, so whichever node was clicked imposed its settings on the
   others. prefix_length is the sharpest: the design refuses to GUESS a netmask
   for a live VIP, and copying one node's netmask onto another is that same
   change wearing a different hat. All four must now agree, with both values
   named in the refusal. The VRRP secret is compared by decrypting each node's
   token - Fernet is non-deterministic, so ciphertexts cannot be compared - and
   a token that will not decrypt is an error rather than an assumed match.

3. THE TAKEOVER AUTHORISATION WAS NOT ONE-SHOT. takeover_expected_hash is the
   permission to overwrite a keepalived.conf that lacks our ownership marker.
   It was written at adoption and never cleared, so it stayed valid for that
   file content indefinitely: restoring the pre-adoption file would have been
   overwritten again with no fresh human approval. It is now retired when a
   member acknowledges our rendered config, gated on the acked hash matching
   applied_config_hash so a partial or failed deploy never drops it and leaves
   the VIP unable to converge.

The panel applies the stranding rule too, so the Adopt button is disabled with
the reason instead of letting the operator click into a 422.

No schema change, no agent change, no API-shape break.

Backend suite: 1366 passed, 152 skipped. Frontend build clean.
2026-08-14 07:55:07 +03:00
taylanbakircioglu 7d95c737f0 fix(vip): adopt the whole VRRP instance, not one node (v1.10.8)
Four defects found while tracing the adoption flow end to end after v1.10.4
reached a live HA pair.

B3/B4 (one root, one fix). Adoption took only the node whose row was clicked:

  - adopting the BACKUP alone produced a VIP that apply always rejects, because
    apply requires exactly one MASTER member;
  - adopting the MASTER alone left the peer unmanaged, and adopting it
    afterwards hit the VRID-collision guard with 409, so a pair could never be
    completed from the panel;
  - on a UNICAST instance the single-member render dropped the unicast block
    entirely (render_keepalived_conf emits it only when peer_ips is non-empty),
    so keepalived fell back to multicast on the adopted node while its peer
    stayed unicast. They stop seeing each other and BOTH claim the VIP.

Adoption now resolves the whole instance via _collect_instance_participants,
keyed on (virtual_router_id, virtual address) - the same key keepalived uses to
group nodes. Each participant becomes a member with the role, priority and
interface its own file declares, and its own one-shot takeover hash, so the
per-node overwrite guard is unchanged. It refuses, naming the reason, when the
group has other than one MASTER, when advert_int differs across nodes, when a
declared unicast peer is not among the nodes being adopted, or when a node is
already in a live VIP - the one-active-VIP-per-agent rule that create/update
enforce via _validate_members_against_pool and adoption never called.

B1. The Apply Management "View Change" regex matched vip-(create|update|delete)
only, so an adopt version fell through to the generic HAProxy diff and rendered
the cluster's whole haproxy.cfg as removed. Display-only, but alarming. A test
now asserts every action _stage_vip_version can stage is in that alternation.

B2. Rejecting an adoption hid the node from the panel permanently:
vip_discoveries.adopted_vip_id is write-once, a VIP is only ever soft-deleted so
the column's ON DELETE SET NULL never fires, and the agent does not re-report a
file whose hash has not changed. Rather than clearing the column on each path,
adoptability is derived from whether the linked VIP is still active, which
self-heals reject, undo-reject and approved teardown alike.

The panel now lists one row per instance instead of per node, and the adopt
dialog names every node that will be taken over. Blockers are aggregated across
all of them, matching what the endpoint checks.

No schema change, no agent change, no API-shape break: /api/vip/discoveries
gains a derived field and /api/vip/adopt keeps its request body.

Backend suite: 1359 passed, 152 skipped. Frontend build clean (no new lint
warnings in VIPManagement.js).
2026-08-13 21:45:38 +03:00
taylanbakircioglu eda7f36c93 fix(vip): scope the HA/VIP page to the selected cluster (v1.10.7)
The page ignored the cluster picker in the header. Both the VIP table and the
v1.10.4 "Unmanaged keepalived detected" panel queried the whole fleet, so on an
install with more than one cluster the lists showed every cluster's nodes at
once and did not change when the selection did - the panel looked stuck on one
cluster's keepalived.

- GET /api/vip/discoveries takes the same optional cluster_id the VIP list
  already took, mapped to the cluster's pool via haproxy_clusters exactly like
  list_vips does. Omitting it still returns the whole fleet, so no existing
  caller changes behaviour.
- VIPManagement reads selectedCluster from ClusterContext (it only took the
  cluster list before) and sends cluster_id on both fetches. The fetch callbacks
  depend on the scope, so switching cluster refetches instead of showing stale
  rows.
- tests/test_vip_discoveries_scope.py pins both defects this endpoint has had:
  that the route reaches its own handler rather than being parsed as a vip_id
  (asserting != 422 specifically, since the repo's generic endpoint-auth tests
  accept 422 alongside 401/403 and therefore could not catch it), and that the
  cluster filter resolves cluster -> pool and short-circuits when absent.

Behaviour change worth calling out: the VIP table is now scoped to the selected
cluster where it was fleet-wide before. The API still serves the fleet-wide view
to any caller that omits cluster_id.

Backend suite: 1345 passed, 152 skipped. Frontend production build clean.
2026-08-13 19:12:01 +03:00
taylanbakircioglu 11e5bf57d9 fix(vip): make the adoption panel reachable again (v1.10.6)
Follow-up to #58. The "Unmanaged keepalived detected" panel it shipped never
appeared on any deployment. GET /discoveries was declared after GET /{vip_id} in
routers/vip.py, and FastAPI matches routes in declaration order, so every request
for the discovery list was routed into get_vip, which takes vip_id: int and
rejected "discoveries" with 422 before list_vip_discoveries ever ran.

The failure was completely silent. The agents reported their configs correctly,
the rows landed in vip_discoveries, and the HA/VIP page treats any non-OK
response as "nothing to show" - so the feature was invisible with no error in
any log. Confirmed against a real fleet: two discovery rows present in the
database, one with a parsed candidate, and an empty panel.

- move list_vip_discoveries above the /{vip_id} routes, with a comment stating
  the ordering requirement
- add tests/test_router_path_shadowing.py: a static source scan that fails if
  any literal path in any router is declared after a parameterised route that
  would swallow it. The whole router tree is clean; the detector is itself
  tested against the pre-fix ordering so the guard cannot pass vacuously.

POST /adopt is unaffected: no POST /{vip_id} route exists.

Also carries the release metadata for #58 (v1.10.4) and #60 (v1.10.5), which
are published together with this fix rather than as separate artifacts.

No schema, API-shape, frontend or agent change. Data reported under the earlier
code is not lost - existing rows show up as soon as this backend is deployed,
with no agent action needed.
2026-08-13 18:55:54 +03:00
mustafa.ulukaya 78af849fdc docs(v1.10.4): document VIP adoption and its upgrade caveats
Release note covering why the page was empty, why the heartbeat could not drive
adoption, and how the blocker gate decides what may and may not be waived.

The upgrade notes lead with the two things an operator has to act on rather than
burying them. This release bumps SCHEMA_VERSION, which re-seeds the four
built-in roles - the first re-seed since v1.9.0, because the three releases in
between did not bump it - so role customizations have to be re-applied. And the
Linux agent script changed, so discovery does not start until nodes pull it;
until then a node simply never appears in the list.

Also states the parts that are easy to get wrong: nothing is taken over
implicitly, adoption can refuse on purpose and why, a multi-node VIP needs every
peer adopted before applying, where the VRRP password lives, and what actually
happens on a downgrade (the table goes unread, an adopted-but-unapplied VIP
loses its takeover authorisation and the node keeps its original config).
2026-08-11 01:36:35 +03:00
mustafa.ulukaya bbd8359f50 docs(v1.10.3): document the multi-account ACME wizard fix
Release note covering the three compounding faults, and upgrade notes stating
that this is frontend-only with nothing to do on upgrade. Two points are called
out for operators rather than glossed: installations that never picked an
account explicitly were already using the newest valid account, so only the
preview was wrong; and the wildcard guard that stopped applying on Review was a
lost warning, not a correctness hole, since the backend still rejected those
requests.
2026-08-08 12:21:02 +03:00
taylanbakircioglu dbb9189f16 fix(ui): make Apply Management readable in dark mode (v1.10.2)
Three dark-mode defects reported on the Apply Management page, all the same
class of bug: light-mode colour literals hardcoded where theme tokens belong.

1. The "Pending Changes" box was painted background #fffbe6 with border
   #ffe58f. In dark mode the text on top is light, so the version name,
   timestamp and "View Change" link sat on a cream panel and were unreadable.
   Measured contrast was 1.03:1; it is now 11.50:1 (secondary text 1.03:1 ->
   7.02:1).

2. The added/removed rows in the View Change diff used #f6ffed/#52c41a and
   #fff2f0/#ff4d4f, which stayed near-white inside the otherwise dark diff
   panel. Now 5.49:1 (added) and 4.01:1 (removed), from 2.21:1 and 2.99:1.

3. The "Apply All Configuration Changes" confirm dialog came up white. This one
   is not a colour literal: in Ant Design 5 the STATIC Modal.confirm / message /
   notification APIs render into their own detached root and never see the app's
   ConfigProvider, so they always fall back to the light algorithm. Registering
   ConfigProvider.config({ holderRender }) once at the app root wraps that
   detached root in the same ConfigProvider. Verified against the installed antd
   5.29.3 source rather than assumed: config-provider/index.js sets
   globalHolderRender, and modal/confirm.js wraps the dialog with it. This fixes
   EVERY static dialog in the application — 12 components call Modal.confirm —
   not just this page.

While in the file, six more instances of the same bug were fixed: the error
Alert border, the VIP pending-delete row, two ACME/pending version panels, the
applied-version panel and the agent-error recommendation box.

Light mode is byte-identical. Each token resolves under the default algorithm
to exactly the literal it replaced (colorWarningBg -> #fffbe6, colorSuccessBg ->
#f6ffed, colorErrorBg -> #fff2f0, colorInfoBg, colorErrorBorder, ...), so this
release can only change dark mode. Contrast was measured by resolving the real
design tokens under both algorithms and computing WCAG ratios, not by eye.

Note the diff rows were already low-contrast in LIGHT mode (2.21:1 and 2.99:1)
and remain so; that is the design system's own success/error pair and changing
it would alter the established light-mode appearance, so it is left alone.

holderRender is registered in an effect rather than during render, since
ConfigProvider.config() mutates antd module state; effects still run long
before a user can click anything that opens a static dialog.

Frontend only: no schema, no SCHEMA_VERSION bump, no API change, no environment
variable, zero agent impact. Backend suite unchanged at 1243 passed.
2026-08-08 02:39:23 +03:00
taylanbakircioglu eee0a4716a feat(ssl): encrypt the pending CSR private key at rest (v1.10.1, closes #53)
Closes the follow-up filed during the v1.9.0 CSR review. The private key of a
PENDING CSR is now Fernet-encrypted in the database instead of being stored as
a raw PEM.

Why this key specifically: it is the one key in the system that sits idle. It
is generated at CSR creation, waits for an external CA to sign the request
(days to weeks), and is destroyed the moment the signed certificate is
imported. It is never transmitted to an agent and never leaves the server.
ssl_certificates.private_key_content and the ACME order keys are deliberately
NOT covered, because agents must receive those in plaintext on every poll, so
encrypting them at rest buys nothing without an end-to-end redesign.

Implementation follows the pattern already used for the VRRP secret, TOTP
secrets and DNS provider credentials: a new utils/csr_key_crypto.py with its
own CSR_ENCRYPTION_KEY env var and its own HKDF info string
("csr-private-key-v1"), so rotating one secret class never affects another.

No schema change and deliberately NO SCHEMA_VERSION bump: the Fernet token
replaces the PEM inside the existing ssl_csrs.private_key_pem TEXT column. A
bump would re-run the migration sequence and re-seed the four built-in roles to
their defaults, which is a needless side effect for a storage-format change.

Backward compatible with no data migration. Rows written before this release
hold a raw PEM and are still read unchanged; the discriminator is exact rather
than a heuristic, since a Fernet token is base64url and can never contain the
"-----BEGIN" marker. Legacy rows drain naturally because a CSR's key copy is
NULLed on import.

A key that cannot be decrypted (SECRET_KEY rotated while CSR_ENCRYPTION_KEY was
unset) now fails with an explicit "delete this CSR and create a new one" error.
Previously that situation would have surfaced as the far more confusing
"certificate does not match this CSR's private key".

Also documents all four per-purpose encryption keys in .env.template. Only
VIP_ENCRYPTION_KEY was listed; MFA_ENCRYPTION_KEY and
DNS_PROVIDER_ENCRYPTION_KEY had been missing since v1.6.0 and v1.8.0.

Verified before release, on a corporate pre-production environment and locally:
- Full backend suite 1234 -> 1243 passed (+9 new tests), 0 failed.
- Against a real Postgres: a CSR created through the API stores a Fernet token
  with no PEM header in the column, and imports successfully.
- Full 1.10.0 -> 1.10.1 -> 1.10.0 drill on one database volume. The upgrade
  logs "Schema already at version 10 (>= 10); skipping migration run", so no
  migration executes and the built-in roles are not re-seeded. A CSR created on
  1.10.0 with a plaintext key imports successfully after the upgrade, which is
  the backward-compatibility guarantee proven against a real row rather than a
  mock.
- rsa-2048, rsa-4096 and ecdsa-p384 all round-trip through create, encrypt,
  decrypt and import.
- Key derivation is stable across processes: two independent containers sharing
  SECRET_KEY decrypt each other's tokens (required for UVICORN_WORKERS > 1 and
  multi-replica deployments), while a different SECRET_KEY yields None rather
  than a wrong key or an exception.
- Downgrade behaviour was measured, not assumed: 1.10.0 cannot parse the token
  and fails with HTTP 500 "key parse failed (encrypted?)" rather than pairing a
  wrong key. The rollback note states the measured behaviour.
- No CSR endpoint returns the key in any form: list and detail responses
  contain neither a PEM nor a Fernet token.

Not changed here, from the issue's "worth folding in" list: the create rate
limit is not a concurrency guard, create_csr holds a pooled connection across
RSA key generation, detail=str(e) echoes internal error text (a repo-wide
convention), and is_global skips cluster validation in both routers/ssl.py and
routers/csr.py. None are storage concerns and each is a separate change.
2026-08-08 01:44:32 +03:00
mustafa.ulukaya 81ab674072 docs(v1.10.0): document the GoDaddy DNS provider and upgrade notes
README: add GoDaddy to the two feature bullets and to the DNS-01 provider
catalog, spelling out that the API Key must be a Production key (the first key
the developer dashboard issues is an OTE/test key and is rejected), that the
zone must be in the same account, that the account needs a registered domain
before GoDaddy permits DNS API access, and that a Personal Access Token works
with the Secret left blank. Note that publishing is automatic for GoDaddy as
well as Cloudflare, and add the release-notes entry.

UPGRADE_GUIDE: new section stating there is no SCHEMA_VERSION bump, so the
built-in-role re-seed warning from v1.9.0 does not apply, and no new
environment variable, API-shape or agent change. Two limits are stated
explicitly rather than glossed: the credential check is a read, so a token
with read but not write scope saves successfully and only fails at the first
publish; and downgrading after adopting GoDaddy is not a no-op, because an
unknown provider name degrades DNS-01 orders to the manual-confirm path and
leaves published TXT records marked cleaned without being removed.
2026-08-07 09:01:35 +03:00
taylanbakircioglu 71786200dd docs(v1.9.0): correct upgrade notes on built-in role re-seed + backfill 1.8.8-1.8.10 release notes
Found during a v1.8.10 to v1.9.0 upgrade drill on a populated database
(schema v9 to v10) before releasing. Documentation only, no code change.

1. The v1.9.0 upgrade notes said "custom roles need no changes", which reads
   as "role data is untouched". It is not: because the SCHEMA_VERSION bump
   re-runs the whole idempotent sequence, update_system_roles_to_enterprise_rbac()
   issues an unconditional UPDATE roles SET ... permissions = <defaults> for
   the four BUILT-IN roles. In the drill an `operator` role that had been
   narrowed by removing apply.execute and config.bulk_import came back with
   both restored (57 to 59 permissions). Operator-created roles are NOT
   affected; the re-seed matches the four built-in names only.

   This is pre-existing behaviour of every SCHEMA_VERSION bump and is
   documented as intentional in migrations.py, so it is not introduced by the
   CSR feature. The v1.7.0 upgrade notes carried this caveat and it was not
   carried forward. Restored, with an export and re-apply procedure.

   Also clarified why the admin password is safe: the default-user seeding is
   guarded by an existence check ("safer than ON CONFLICT"), not an upsert,
   so an operator-changed password survives.

2. README release notes jumped from v1.8.7 straight to v1.9.0 because
   v1.8.8, v1.8.9 and v1.8.10 were never backfilled. Added all three.
2026-08-05 23:43:14 +03:00
mustafa.ulukaya 69e12f7459 chore(version): bump to 1.9.0 - CSR creation
- backend/version.json + frontend package version to 1.9.0
- README: feature list entry, CSR workflow section, SSL CSR API reference,
  v1.9.0 release notes
- UPGRADE_GUIDE: v1.9.0 section (additive ssl_csrs table, SCHEMA_VERSION
  9 -> 10, no RBAC changes, zero agent impact, rollback note)
2026-08-04 21:24:34 +03:00
taylanbakircioglu a1192e602d feat: HA / VIP (Keepalived) management from the UI (#27)
Manage highly-available virtual IPs backed by Keepalived (VRRP) directly from the OpenManager
UI — no more SSHing into nodes to install/configure Keepalived by hand. Builds on the agent
pull-architecture: define the VIP centrally, click Apply, and the agents converge.

Highlights:
- New "HA / VIP" tab: create a virtual IP, pick a per-node interface, select which pool nodes
  participate (MASTER/BACKUP roles + priorities); live MASTER/BACKUP/FAULT per node.
- On Apply, agents install & configure Keepalived (unicast VRRP, cloud-safe default) across the
  major distros (Debian/Ubuntu, RHEL/CentOS/Alma/Rocky, Fedora, SUSE/openSUSE, Alpine) with a
  HAProxy health-check, so the VIP fails over automatically when HAProxy drops.
- Single-node (a managed floating IP without failover) and multi-node VRRP failover both work.
- VIP changes ride the standard Apply Management flow with the standard "View Change" diff.
- Approval-gated deletion (safety): deleting a running VIP is staged for approval and the VIP
  keeps running, untouched, until you approve it — an agent never tears a VIP down without an
  explicit human approval. Per-VIP Diagnostics view; opt-in package uninstall (only on nodes
  where OpenManager installed it). A node already running a hand-managed Keepalived is detected
  and never overwritten ("externally managed").
- Fully opt-in and backward compatible: nodes/clusters without a VIP are unaffected. Adds
  vip_instances + vip_members tables (idempotent SCHEMA_VERSION bump; existing data and
  passwords unaffected) and a `vip` RBAC permission group.
- Also includes a HAProxy config-generator robustness fix: auto-inject a stick-table when a
  frontend uses a stick counter (track-sc / sc_*_rate) but declares none.

On-prem / L2 (VRRP) scope; the UI notes the cloud caveat.
2026-06-07 01:52:20 +03:00
taylanbakircioglu 73bed7811f docs: Add CONFIG.md and UPGRADE_GUIDE.md with public-friendly URLs
- Add configuration guide for environment variables
- Add agent upgrade guide
- Use example.com instead of company-specific URLs
- No sensitive information included
2025-11-12 14:15:51 +03:00