diff --git a/README.md b/README.md index 15e0ddf..7c1a323 100644 --- a/README.md +++ b/README.md @@ -1096,6 +1096,7 @@ The HA / VIP page manages **highly-available virtual IPs** backed by Keepalived - **VIP dashboard**: lists each virtual IP with its pool, VRID, per-node interface, and a **live MASTER / BACKUP / FAULT** column per node (refreshed every 30s from the existing keepalive-state heartbeat pipeline). - **Creation form**: enter the virtual IP (+ prefix), pick the pool, and select which of the pool's HAProxy nodes participate — each with a network interface (from the node's reported interfaces), a VRRP role (MASTER/BACKUP) and a priority. VRID auto-allocates per pool; an optional VRRP secret can be set. - **Hands-off automation**: on **Apply**, each member's `keepalived.conf` is rendered and delivered; the agent installs Keepalived if missing, writes the config + a HAProxy health-check (`vrrp_script` + `track_script`), validates with `keepalived -t`, and starts the service. When HAProxy drops on the active node, the health-check lowers VRRP priority and the **VIP fails over automatically** to a backup. +- **Several VIPs per node, several addresses per VIP** (v1.12.0): a node can belong to more than one VIP — each VIP is its own `vrrp_instance` (its own VRID, role and priority) and the node's `keepalived.conf` holds all of them. A VIP can also carry up to 19 **additional addresses** next to its virtual IP; they sit in the same `virtual_ipaddress` block, on the same interface, and fail over together. **How it works (pull-based, isolated):** - New tables `vip_instances` + `vip_members`; the agent polls `GET /api/agents/{name}/keepalived-config` (key-authenticated, agent-bound) and converges. It is fully **isolated** from the global HAProxy apply flow — VIP changes never regenerate `haproxy.cfg`. @@ -2475,6 +2476,7 @@ Developed with ❤️ for the HAProxy community ## Release Notes +- **v1.12.0** (2026-10-16) — **Several keepalived VIPs on one node, and several addresses on one VIP**: a node could belong to only one VIP (the form refused a second one with "node already in VIP …") and a VIP held exactly one address. A node may now be a member of several VIPs: each VIP stays its own `vrrp_instance` with its own VRID, role and priority, and the delivery endpoint merges the node's applied VIPs into the one `keepalived.conf` the node deploys — one `global_defs`, then per VIP a `vrrp_script chk_haproxy_` (each VIP keeps its own failover weight) and its `vrrp_instance`, ordered by VIP id. A node with a single VIP receives exactly the bytes and hash it received before, so existing installs see no change and no reload. A VIP can also carry up to 19 **additional addresses** (IPv4 + prefix) in the same `virtual_ipaddress` block and on the same interface; they are checked against node IPs and every other VIP's addresses under a lock, kept in the applied snapshot, and restored by Reject. The merged file passes `keepalived -t` with no output (which older agents require) on keepalived 2.0.19, 2.1.5, 2.2.4, 2.2.7 and 2.2.8; on 2.0.19, 2.1.5 and 2.2.8 a reload that adds a second VIP did not drop the first one's address, and a VIP with 20 addresses came up. If a node's VIPs cannot be merged, the node is left untouched and its members show the reason. A database error during delivery now answers a status the agent ignores instead of `not_configured`, which on a managed node meant teardown. **Adoption** refuses a `keepalived.conf` with several `vrrp_instance` blocks (taking it over would remove the others; earlier versions allowed it), now accepts an instance with several addresses (the extra ones become additional addresses), and asks agents to re-report files analysed by the older parser once. Schema `13` is a **delta** from `12`: two additive columns, built-in roles are not re-seeded. No agent change. See the upgrade notes for rolling-deploy and downgrade rules. - **v1.11.3** (2026-10-16) — **Config Import keeps `stick-table` and `stick` rules instead of dropping them**: the import parser had no branch for `stick-table` (frontend or backend) or for backend `stick on|match|store-request|store-response`, so those lines were discarded **without a warning**. A frontend that kept its `track-sc0` / `sc_*` counter rules then received the renderer's fallback table (`type ip size 100k expire 30s store http_req_rate(10s)`), so a re-defined table such as `size 200k expire 3h store http_req_rate(10s),gpc0_rate(10s),gpt0` was silently replaced by the default while the `gpc0` rules imported alongside it referenced counters the table did not store. These lines are now imported as-is into the frontend/backend **Options** field (no schema change) with an import warning, and an import that carries only a stick-table keeps the existing option lines. The table the platform generates itself for Rate Limit / WAF `rate_limit` rules is recognised and not stored, so a round-trip of a generated config shows no change and later rule edits still take effect. The import preview also deleted every `http-request track-sc0 src` / `sc_http_req_rate(0)` line as "auto-generated"; it now does so only where the platform regenerates them (frontend Rate Limit or a WAF `rate_limit` rule), so operator trackers on other frontends survive. Backends now render a single `stick-table` (the first; a second one is a fatal HAProxy error) and the preview warns about a table that does not store `http_req_rate` while Rate Limit is active, a duplicate table left in Request Headers, `stick on` without a table, and `peers` references. Configs without stick lines import and render byte-for-byte as before. No schema change, no agent change. - **v1.11.2** (2026-10-01) — **Read-only roles read SSL certificates, and private keys need `ssl.download`**: the UI never fills in `user_pool_access`, so every non-admin user created there has no pool access, and the SSL list (`?cluster_id=`) and SSL detail answered **403** on read while clusters, frontends, backends, WAF rules, agents and VIPs were readable. A role that grants `ssl.read` (or `waf.read` for WAF stats) now reads that resource on every cluster; without it the pool check still applies, and write endpoints are unchanged. Separately, `GET /api/ssl/certificates/{id}` and the SSL diff in Apply Management returned the **private key to any authenticated user**, including `viewer`. The key is now included only with `ssl.download` plus pool access; everyone else gets `private_key_content: null` with `private_key_redacted: true`, and the UI shows the key as hidden while editing keeps the stored key. Custom roles with `ssl.update` but no `ssl.download` lose sight of the key. No schema change, no agent change. - **v1.11.1** (2026-08-15) — **A node can no longer be hidden from adoption for good, and Config Import reaches a freshly installed agent**: the agent posts an unmanaged `keepalived.conf` for adoption and caches the hash of what it sent, so the file (which carries the VRRP password) is re-posted only when it changes. Delivery was judged by `curl`'s exit code, which is **0 for 5xx as well**, so a report the server *rejected* was recorded as delivered — and because a hand-maintained config does not change on its own, that node dropped out of *Unmanaged keepalived detected* permanently, curable only by deleting a cache file on the node by hand. Now: the report is cached only on a 2xx; `GET /agents/{name}/keepalived-config` tells the agent whether the server actually holds a discovery for it, so nodes stuck from earlier releases recover by themselves on the next poll; a **400/413/422** records the refusal so identical bytes are not re-posted forever (4xx and 5xx are never sampled out of the request log, so an unattended loop would write a row carrying the whole config every cycle), while **401 and 404 keep retrying** because in this system they mean a token rotation or an agent row briefly absent, not a bad payload. The same exit-code mistake in the *clear* path is fixed too, where it left a managed node still being offered for adoption. Separately, **Config Import silently did nothing on any agent that had never self-upgraded**: `check_config_requests` was defined in the installer and in the self-upgrade daemon but not in the body a fresh install writes, and its call site is guarded by `type`, so the operator asked a node for its `haproxy.cfg` and nothing arrived, with no error anywhere. And the cluster whose `keepalived_config_path` is handed to an agent is now resolved deterministically — a pool may hold several clusters, and the unordered join could return a different one between polls, pointing the agent at a file that does not exist. Agent-script change: sync the script from Agent Management and let the agents upgrade. No schema change. diff --git a/UPGRADE_GUIDE.md b/UPGRADE_GUIDE.md index fb731b2..23ad27e 100644 --- a/UPGRADE_GUIDE.md +++ b/UPGRADE_GUIDE.md @@ -1,3 +1,59 @@ +# Upgrade Notes — v1.12.0 (several keepalived VIPs on one node, several addresses on one VIP) + +**Backend + UI change, schema `13` (delta), no agent change.** Agents of every version keep working; +no agent upgrade is needed. + +- **Schema 12 → 13 is a delta.** A database at 12 adds two columns — + `vip_instances.additional_ips` (JSONB, default `[]`) and `vip_discoveries.analysis_version` + (SMALLINT, default `0`) — and nothing else runs. **Built-in roles are not re-seeded**, so role + customizations survive this upgrade (unlike v1.10.4 and v1.11.0). A database older than 12 runs the + full sequence as before, including the role re-seed. +- **Nodes with one VIP see no change.** Their `keepalived.conf` keeps the same bytes and hash, so the + agent does not rewrite or reload anything. +- **Check for nodes that already belong to several VIPs before upgrading.** Earlier versions refused + this when a VIP was saved, but without a lock, so two saves at the same moment could still create + it. Such a node switches from one VIP's file to the merged file on its first poll after the upgrade: + ```sql + SELECT a.name, count(*) FROM vip_members m + JOIN vip_instances v ON v.id = m.vip_id JOIN agents a ON a.id = m.agent_id + WHERE v.is_active GROUP BY a.name HAVING count(*) > 1; + ``` +- **Rolling deploy:** do not add a second VIP to a node until **every backend replica** runs v1.12.0. + An older replica serves a node only one of its VIPs, so the node would alternate between two files. + An older replica applying a VIP also drops that VIP's additional addresses. +- **Rollback / downgrade:** first reduce every node to one VIP and remove the additional addresses + (edit, then Apply), then downgrade. The two new columns can stay; older versions ignore them. +- **How a node with several VIPs is served.** Each VIP is still rendered and applied on its own; the + delivery endpoint merges the node's applied VIPs into one file: `global_defs` once, then per VIP a + `vrrp_script chk_haproxy_` and a `vrrp_instance VI_`, ordered by VIP id. VRIDs are unique per + pool, so the instances never collide. One acknowledgement from the node converges all of its VIPs. + Applying two VIPs one after the other reloads keepalived twice; measured on keepalived 2.0.19, + 2.1.5 and 2.2.8, a reload that adds or removes a VIP does not drop the other VIPs' addresses. +- **If the merge fails** (an applied snapshot that is not in the platform's own format), the node keeps + running what it has: the agent is told nothing new, and the node's members show + "cannot merge this node's VIPs …" with an ERROR status. Re-apply the VIPs to fix it. +- **Delivery errors no longer tear keepalived down.** A database error while serving + `keepalived-config` answered `not_configured`, which on a node whose file is ours meant teardown. It + now answers `unavailable`, which agents of every version ignore. +- **Additional addresses:** up to 19 per VIP (IPv4, prefix 1–32), on the VIP's interface on every node. + They must not be a node's IP or any address of another active VIP. +- **Live state is per node, not per VIP.** The MASTER/BACKUP column comes from the node's heartbeat, + which reports one state for the node. A node that is MASTER for one VIP and BACKUP for another shows + the same state in both rows; with several VIPs on a node, the heartbeat's config-based fallback can + also report MASTER on a BACKUP node. Check `ip addr` on the node when it matters. The VIPs themselves + fail over correctly; only the display is affected. +- **Adoption:** a `keepalived.conf` with several `vrrp_instance` blocks can no longer be adopted (it + replaces the whole file, so the other instances would be removed — earlier versions allowed it). An + adoption made before the upgrade from such a file is refused at Apply; reject it. An instance with + several addresses is now adoptable; the extra addresses become additional addresses. Files analysed + by an older version are re-reported once by v1.11.1+ agents; until then the list shows them with the + new blocker. + +**Rollback:** see the downgrade rule above. With one VIP per node and no additional addresses, +rolling back is safe. + +--- + # Upgrade Notes — v1.11.3 (Config Import keeps `stick-table` and `stick` rules) **Backend change, no schema change, no agent change.** No `SCHEMA_VERSION` bump and no agent diff --git a/backend/models/vip.py b/backend/models/vip.py index 9c1be4d..d87df32 100644 --- a/backend/models/vip.py +++ b/backend/models/vip.py @@ -50,6 +50,38 @@ def _validate_ipv4(v: str) -> str: return s +# Issue #73: a VIP may carry further addresses in the same VRRP instance. 20 addresses in +# total keeps every advertisement within what VRRPv2 peers are known to accept. +MAX_ADDITIONAL_IPS = 19 + + +class VIPAddressIn(BaseModel): + address: str + prefix_length: int = 24 + + @field_validator('address') + @classmethod + def _addr(cls, v): + return str(ipaddress.ip_address(_validate_ipv4(v))) + + @field_validator('prefix_length') + @classmethod + def _prefix(cls, v): + if not isinstance(v, int) or not (1 <= v <= 32): + raise ValueError('prefix_length must be an integer between 1 and 32 (IPv4)') + return v + + +def _validate_additional_ips(extras: List['VIPAddressIn'], primary: Optional[str]) -> None: + if len(extras) > MAX_ADDITIONAL_IPS: + raise ValueError(f'a VIP may carry at most {MAX_ADDITIONAL_IPS} additional addresses') + addrs = [e.address for e in extras] + if len(set(addrs)) != len(addrs): + raise ValueError('each additional address may appear only once') + if primary is not None and str(ipaddress.ip_address(primary)) in addrs: + raise ValueError('an additional address must differ from virtual_ip') + + class VIPMemberIn(BaseModel): agent_id: int network_interface: str @@ -122,6 +154,7 @@ class VIPCreate(BaseModel): auth_pass: Optional[str] = None # plaintext on the wire; stored Fernet-encrypted use_unicast: bool = True track_haproxy: bool = True + additional_ips: List[VIPAddressIn] = [] members: List[VIPMemberIn] @field_validator('name') @@ -172,6 +205,7 @@ class VIPCreate(BaseModel): @model_validator(mode='after') def _members_consistent(self): _validate_members(self.members) + _validate_additional_ips(self.additional_ips, self.virtual_ip) return self @@ -187,6 +221,7 @@ class VIPUpdate(BaseModel): auth_pass: Optional[str] = None # provide only to rotate; omit to keep existing use_unicast: Optional[bool] = None track_haproxy: Optional[bool] = None + additional_ips: Optional[List[VIPAddressIn]] = None # None keeps them; [] clears them members: Optional[List[VIPMemberIn]] = None @field_validator('name') @@ -242,4 +277,6 @@ class VIPUpdate(BaseModel): def _members_consistent(self): if self.members is not None: _validate_members(self.members) + if self.additional_ips is not None: + _validate_additional_ips(self.additional_ips, self.virtual_ip) return self diff --git a/backend/routers/vip.py b/backend/routers/vip.py index 3d4469c..3a79b1c 100644 --- a/backend/routers/vip.py +++ b/backend/routers/vip.py @@ -16,6 +16,7 @@ Every DB access is wrapped so a (pathological) missing vip_* relation degrades t empty/None result instead of a 500 (B-7) — preserving the fleet-wide no-op guarantee. """ import hashlib +import ipaddress import json import logging import re @@ -171,7 +172,7 @@ async def render_vip_config_masked(conn, vip_id: int): vip_dict = {"id": v["id"], "name": v["name"], "virtual_ip": v["virtual_ip"], "prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"], "advert_int": v["advert_int"], "use_unicast": v["use_unicast"], - "track_haproxy": v["track_haproxy"]} + "track_haproxy": v["track_haproxy"], "additional_ips": _extra_ips(v["additional_ips"])} member_dicts = [{"role": m["role"], "priority": m["priority"], "network_interface": m["network_interface"], "agent_id": m["agent_id"], "ip_address": str(m["ip_address"]) if m["ip_address"] else ""} for m in members] @@ -241,7 +242,8 @@ async def _capture_vip_pending_state(conn, vip_id: int): "vip": {"name": v["name"], "description": v["description"], "virtual_ip": v["virtual_ip"], "prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"], "advert_int": v["advert_int"], "use_unicast": v["use_unicast"], - "track_haproxy": v["track_haproxy"], "auth_pass_encrypted": v["auth_pass_encrypted"]}, + "track_haproxy": v["track_haproxy"], "auth_pass_encrypted": v["auth_pass_encrypted"], + "additional_ips": _extra_ips(v["additional_ips"])}, "members": [{"agent_id": m["agent_id"], "network_interface": m["network_interface"], "role": m["role"], "priority": m["priority"]} for m in members], } @@ -290,17 +292,23 @@ async def restore_vip_from_rejected_version(conn, version_id: int): prev = {r["agent_id"]: r for r in await conn.fetch( "SELECT agent_id, applied_config_content, applied_config_hash, last_deploy_state, " "last_deploy_message, last_deploy_hash, last_deploy_at FROM vip_members WHERE vip_id=$1", vip_id)} + # Captured before v1.12.0 → no additional addresses. + extras = _extra_ips(sv.get("additional_ips") or []) try: async with conn.transaction(): + await conn.execute("SELECT pg_advisory_xact_lock($1, $2)", *_VIP_ADDRESS_LOCK) + reason = await _address_clash(conn, sv["virtual_ip"], extras, vip_id) + if reason: + return f"cannot restore — {reason}" await conn.execute(""" UPDATE vip_instances SET name=$2, description=$3, virtual_ip=$4, prefix_length=$5, virtual_router_id=$6, advert_int=$7, use_unicast=$8, track_haproxy=$9, auth_pass_encrypted=$10, is_active=TRUE, last_config_status='PENDING', - updated_at=CURRENT_TIMESTAMP + additional_ips=$11::jsonb, updated_at=CURRENT_TIMESTAMP WHERE id=$1 """, vip_id, sv["name"], sv.get("description"), sv["virtual_ip"], sv["prefix_length"], sv["virtual_router_id"], sv["advert_int"], sv["use_unicast"], sv["track_haproxy"], - sv.get("auth_pass_encrypted")) + sv.get("auth_pass_encrypted"), json.dumps(extras)) await conn.execute("DELETE FROM vip_members WHERE vip_id=$1", vip_id) for mm in sm: o = prev.get(mm["agent_id"]) @@ -383,7 +391,7 @@ async def list_vips(cluster_id: Optional[int] = None, authorization: str = Heade if cluster_id is not None: vips = await conn.fetch(f""" SELECT v.id, v.name, v.description, v.pool_id, v.virtual_ip, v.prefix_length, - v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy, + v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy, v.additional_ips, v.is_active, v.last_config_status, v.pending_delete, (v.auth_pass_encrypted IS NOT NULL) AS auth_pass_set, v.created_at, v.updated_at, p.name AS pool_name @@ -396,7 +404,7 @@ async def list_vips(cluster_id: Optional[int] = None, authorization: str = Heade else: vips = await conn.fetch(f""" SELECT v.id, v.name, v.description, v.pool_id, v.virtual_ip, v.prefix_length, - v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy, + v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy, v.additional_ips, v.is_active, v.last_config_status, v.pending_delete, (v.auth_pass_encrypted IS NOT NULL) AS auth_pass_set, v.created_at, v.updated_at, p.name AS pool_name @@ -422,6 +430,7 @@ async def list_vips(cluster_id: Optional[int] = None, authorization: str = Heade v["last_config_status"], members, v["pending_delete"], v["is_active"]) result.append({ **dict(v), + "additional_ips": _extra_ips(v["additional_ips"]), # Convergence-aware status for the table (issue #27 follow-up); the raw # last_config_status is kept above for the PENDING gate / Apply button. "deploy_status": deploy_status, @@ -493,7 +502,7 @@ async def get_vip(vip_id: int, authorization: str = Header(None)): try: v = await conn.fetchrow(""" SELECT v.id, v.name, v.description, v.pool_id, v.virtual_ip, v.prefix_length, - v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy, + v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy, v.additional_ips, v.is_active, v.last_config_status, (v.auth_pass_encrypted IS NOT NULL) AS auth_pass_set, v.created_at, v.updated_at @@ -508,7 +517,8 @@ async def get_vip(vip_id: int, authorization: str = Header(None)): FROM vip_members m LEFT JOIN agents a ON a.id = m.agent_id WHERE m.vip_id = $1 ORDER BY m.priority DESC """, vip_id) - return {**dict(v), "members": [dict(m) for m in members]} + return {**dict(v), "additional_ips": _extra_ips(v["additional_ips"]), + "members": [dict(m) for m in members]} finally: await close_database_connection(conn) @@ -545,6 +555,57 @@ async def _validate_members_against_pool(conn, pool_id: int, members: List, vip_ raise HTTPException(status_code=409, detail=f"{vip_virtual_ip} is already used by another VIP") +# Serialises every write that sets a VIP's addresses. The primary address has a partial unique +# index; the additional ones live in a JSON list no index can cover, so two concurrent writes +# could otherwise both pass the clash check below with the same address. +_VIP_ADDRESS_LOCK = (18181873, 0) + + +def _norm_ip(value) -> str: + try: + return str(ipaddress.ip_address(str(value).strip())) + except ValueError: + return str(value) + + +def _extra_ips(value) -> List[dict]: + """vip_instances.additional_ips (JSONB; asyncpg hands it over as a string) as a list.""" + items = _jsonb_list(value) + return [{"address": str(i["address"]), "prefix_length": int(i["prefix_length"])} + for i in items if isinstance(i, dict) and i.get("address")] + + +async def _address_clash(conn, primary: str, extras: List[dict], + exclude_vip_id: Optional[int]) -> Optional[str]: + """Issue #73 — why these addresses cannot be used, or None. Covers what the primary-address + checks do not: every additional address against node IPs and every active VIP's addresses, + and the primary against the other VIPs' additional addresses. Call it inside the write's + transaction after taking _VIP_ADDRESS_LOCK.""" + other = ("SELECT name FROM vip_instances WHERE is_active = TRUE " + "AND ($2::int IS NULL OR id <> $2::int) AND {cond} LIMIT 1") + in_extras = "additional_ips @> jsonb_build_array(jsonb_build_object('address', $1::text))" + hit = await conn.fetchval(other.format(cond=in_extras), primary, exclude_vip_id) + if hit: + return f"{primary} is already an additional address of VIP '{hit}'" + for e in extras: + addr = e["address"] + if await conn.fetchval("SELECT 1 FROM agents WHERE ip_address = $1::inet LIMIT 1", addr): + return f"{addr} is already a node's IP" + hit = await conn.fetchval( + other.format(cond=f"(virtual_ip = $1::text OR {in_extras})"), addr, exclude_vip_id) + if hit: + return f"{addr} is already used by VIP '{hit}'" + return None + + +async def _lock_and_check_addresses(conn, primary: str, extras: List[dict], + exclude_vip_id: Optional[int]) -> None: + await conn.execute("SELECT pg_advisory_xact_lock($1, $2)", *_VIP_ADDRESS_LOCK) + reason = await _address_clash(conn, primary, extras, exclude_vip_id) + if reason: + raise HTTPException(status_code=409, detail=reason) + + @router.post("") async def create_vip(payload: VIPCreate, request: Request, authorization: str = Header(None)): current_user = await _require(authorization, "create") @@ -556,22 +617,25 @@ async def create_vip(payload: VIPCreate, request: Request, authorization: str = await _validate_members_against_pool(conn, payload.pool_id, payload.members, payload.virtual_ip) enc = encrypt_vrrp_secret(payload.auth_pass) if payload.auth_pass else None + extras = [e.model_dump() for e in payload.additional_ips] # Allocate VRID + insert, retrying once on a unique-violation race (B-1). last_err = None for _attempt in range(2): vrid = await _alloc_free_vrid(conn, payload.pool_id, payload.virtual_router_id) try: async with conn.transaction(): + await _lock_and_check_addresses(conn, payload.virtual_ip, extras, None) vip_id = await conn.fetchval(""" INSERT INTO vip_instances (name, description, pool_id, virtual_ip, prefix_length, virtual_router_id, advert_int, auth_pass_encrypted, use_unicast, track_haproxy, - is_active, last_config_status, created_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,'PENDING',$11) + is_active, last_config_status, created_by, additional_ips) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,'PENDING',$11,$12::jsonb) RETURNING id """, payload.name, payload.description, payload.pool_id, payload.virtual_ip, payload.prefix_length, vrid, payload.advert_int, enc, - payload.use_unicast, payload.track_haproxy, current_user["id"]) + payload.use_unicast, payload.track_haproxy, current_user["id"], + json.dumps(extras)) for m in payload.members: await conn.execute(""" INSERT INTO vip_members (vip_id, agent_id, network_interface, role, priority) @@ -579,6 +643,8 @@ async def create_vip(payload: VIPCreate, request: Request, authorization: str = """, vip_id, m.agent_id, m.network_interface, m.role, m.priority) last_err = None break + except HTTPException: + raise except Exception as ie: # noqa: BLE001 if "unique" in str(ie).lower() or "duplicate" in str(ie).lower(): last_err = ie @@ -623,10 +689,17 @@ async def update_vip(vip_id: int, payload: VIPUpdate, request: Request, authoriz "SELECT 1 FROM vip_instances WHERE virtual_ip=$1 AND is_active=TRUE AND id<>$2", new_ip, vip_id) if dup: raise HTTPException(status_code=409, detail=f"{new_ip} is already used by another VIP") + new_extras = ([e.model_dump() for e in payload.additional_ips] + if payload.additional_ips is not None else None) + extras = new_extras if new_extras is not None else _extra_ips(v["additional_ips"]) + if _norm_ip(new_ip) in {e["address"] for e in extras}: + raise HTTPException(status_code=400, detail="an additional address must differ from virtual_ip") enc_set = payload.auth_pass is not None and payload.auth_pass != "" try: async with conn.transaction(): + if payload.virtual_ip or new_extras is not None: + await _lock_and_check_addresses(conn, new_ip, extras, vip_id) await conn.execute(""" UPDATE vip_instances SET name = COALESCE($2, name), @@ -638,6 +711,7 @@ async def update_vip(vip_id: int, payload: VIPUpdate, request: Request, authoriz use_unicast = COALESCE($8, use_unicast), track_haproxy = COALESCE($9, track_haproxy), auth_pass_encrypted = CASE WHEN $10 THEN $11 ELSE auth_pass_encrypted END, + additional_ips = COALESCE($12::jsonb, additional_ips), last_config_status = 'PENDING', -- Editing a VIP means you are KEEPING and changing it, so it cancels any -- staged deletion (otherwise a later Apply would delete instead of applying @@ -649,7 +723,8 @@ async def update_vip(vip_id: int, payload: VIPUpdate, request: Request, authoriz """, vip_id, payload.name, payload.description, payload.virtual_ip, payload.prefix_length, payload.virtual_router_id, payload.advert_int, payload.use_unicast, payload.track_haproxy, - enc_set, encrypt_vrrp_secret(payload.auth_pass) if enc_set else None) + enc_set, encrypt_vrrp_secret(payload.auth_pass) if enc_set else None, + json.dumps(new_extras) if new_extras is not None else None) if members is not None: # T-1: preserve the APPLIED snapshot + deploy ack for members that REMAIN, # so a pending member edit never momentarily flips a running node to @@ -816,7 +891,6 @@ async def apply_vip(vip_id: int, request: Request, authorization: str = Header(N f"{multi['agent_name']}'s keepalived.conf declares {multi['n']} vrrp_instance blocks; " f"taking it over would remove the others from the node. Reject this adoption.")) - auth_plain = decrypt_vrrp_secret(v["auth_pass_encrypted"]) if v["auth_pass_encrypted"] else None # If a secret is set but can't be decrypted (SECRET_KEY/VIP_ENCRYPTION_KEY rotated or # drifted between pods), FAIL the apply rather than silently rendering a config with NO @@ -834,7 +908,7 @@ async def apply_vip(vip_id: int, request: Request, authorization: str = Header(N vip_dict = {"id": v["id"], "name": v["name"], "virtual_ip": v["virtual_ip"], "prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"], "advert_int": v["advert_int"], "use_unicast": v["use_unicast"], - "track_haproxy": v["track_haproxy"]} + "track_haproxy": v["track_haproxy"], "additional_ips": _extra_ips(v["additional_ips"])} snapshot_members = [] async with conn.transaction(): @@ -860,7 +934,8 @@ async def apply_vip(vip_id: int, request: Request, authorization: str = Header(N "vip": {"name": v["name"], "description": v["description"], "virtual_ip": v["virtual_ip"], "prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"], "advert_int": v["advert_int"], "use_unicast": v["use_unicast"], - "track_haproxy": v["track_haproxy"], "auth_pass_encrypted": v["auth_pass_encrypted"]}, + "track_haproxy": v["track_haproxy"], "auth_pass_encrypted": v["auth_pass_encrypted"], + "additional_ips": vip_dict["additional_ips"]}, "members": snapshot_members} await conn.execute( "UPDATE vip_instances SET last_config_status='APPLIED', " @@ -934,17 +1009,24 @@ async def reject_vip(vip_id: int, request: Request, authorization: str = Header( sv = snap["vip"] sm = snap.get("members", []) + # Applied before v1.12.0 → no additional addresses. + extras = _extra_ips(sv.get("additional_ips") or []) try: async with conn.transaction(): + await conn.execute("SELECT pg_advisory_xact_lock($1, $2)", *_VIP_ADDRESS_LOCK) + reason = await _address_clash(conn, sv["virtual_ip"], extras, vip_id) + if reason: + raise HTTPException(status_code=409, detail=f"Cannot restore — {reason}") await conn.execute(""" UPDATE vip_instances SET name=$2, description=$3, virtual_ip=$4, prefix_length=$5, virtual_router_id=$6, advert_int=$7, use_unicast=$8, track_haproxy=$9, auth_pass_encrypted=$10, + additional_ips=$11::jsonb, last_config_status='APPLIED', updated_at=CURRENT_TIMESTAMP WHERE id=$1 """, vip_id, sv["name"], sv.get("description"), sv["virtual_ip"], sv["prefix_length"], sv["virtual_router_id"], sv["advert_int"], sv["use_unicast"], sv["track_haproxy"], - sv.get("auth_pass_encrypted")) + sv.get("auth_pass_encrypted"), json.dumps(extras)) await conn.execute("DELETE FROM vip_members WHERE vip_id=$1", vip_id) for m in sm: await conn.execute(""" @@ -1268,18 +1350,21 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header # this VRID in the pool (the clash check above would have refused first); # * a node whose file carries our ownership marker (is_managed) is ours already. adopted_ids = [p["agent_id"] for p in participants] + # Issue #73: every address of the instance, not only the first. + extras = _extra_ips(vip_fields.get("additional_ips") or []) + all_addrs = [virtual_ip] + [e["address"] for e in extras] stranded = await conn.fetch(""" SELECT a.name AS agent_name, a.pool_id, COALESCE(a.enabled, TRUE) AS enabled, d.parse_error FROM vip_discoveries d JOIN agents a ON a.id = d.agent_id LEFT JOIN vip_instances av ON av.id = d.adopted_vip_id - WHERE d.raw_config_masked LIKE '%' || $1 || '%' + WHERE d.raw_config_masked LIKE ANY($1::text[]) AND NOT (a.id = ANY($2::int[])) AND COALESCE(av.is_active, FALSE) = FALSE AND d.is_managed = FALSE ORDER BY a.name - """, virtual_ip, adopted_ids) + """, [f"%{a}%" for a in all_addrs], adopted_ids) if stranded: def _why(r): if r["parse_error"]: @@ -1291,7 +1376,7 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header return "it does not report this vrrp_instance" named = "; ".join(f"{r['agent_name']} — {_why(r)}" for r in stranded) raise HTTPException(status_code=422, detail=( - f"These node(s) also reference {virtual_ip} but cannot be taken over with the rest " + f"These node(s) also reference {', '.join(all_addrs)} but cannot be taken over with the rest " f"of the instance, so adopting now would rewrite the others and leave them running " f"an unmanaged config on the same address: {named}. Resolve that first, then adopt.")) @@ -1301,11 +1386,17 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header # the sharpest: the design refuses to GUESS a netmask for a live VIP, and quietly copying # one node's netmask onto another is the same change by another name. def _vfield(p, key): - return (p["candidate"].get("vip") or {}).get(key) + value = (p["candidate"].get("vip") or {}).get(key) + if key == "additional_ips": + # Hashable and order-free; an analysis from before v1.12.0 has no key → none. + return tuple(sorted((e["address"], e["prefix_length"]) + for e in _extra_ips(value or []))) + return value for key, label in (("prefix_length", "prefix length"), ("use_unicast", "unicast/multicast mode"), - ("track_haproxy", "HAProxy tracking")): + ("track_haproxy", "HAProxy tracking"), + ("additional_ips", "additional addresses")): seen = {} for p in participants: seen.setdefault(_vfield(p, key), []).append(p["agent_name"]) @@ -1375,9 +1466,9 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header f"holding the VIP. Register those nodes as agents so they report their config, " f"then adopt again.")) - # One active VIP per agent — the delivery endpoint serves a single keepalived.conf per - # node, so a second active membership never converges. create/update enforce this via - # _validate_members_against_pool; adoption did not call it at all. + # Adoption hands a node's whole keepalived.conf over to OpenManager. A node that is + # already a member of a managed VIP is not taken over a second time (issue #73 allows + # several VIPs per node for VIPs created here, not for a second adoption). busy = await conn.fetchrow(""" SELECT a.name AS agent_name, v.name AS vip_name FROM vip_members vm JOIN vip_instances v ON v.id = vm.vip_id @@ -1401,19 +1492,20 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header f"config must keep its VRID, so resolve the collision first")) async with conn.transaction(): + await _lock_and_check_addresses(conn, virtual_ip, extras, None) vip_id = await conn.fetchval(""" INSERT INTO vip_instances (name, description, pool_id, virtual_ip, prefix_length, virtual_router_id, advert_int, auth_pass_encrypted, use_unicast, track_haproxy, - is_active, last_config_status, adopted_at, created_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,'PENDING',CURRENT_TIMESTAMP,$11) + is_active, last_config_status, adopted_at, created_by, additional_ips) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,'PENDING',CURRENT_TIMESTAMP,$11,$12::jsonb) RETURNING id """, name, payload.get("description") or f"Adopted from {disc['agent_name']}", disc["pool_id"], virtual_ip, int(vip_fields["prefix_length"]), int(vrid), int(vip_fields.get("advert_int") or 1), disc["auth_pass_encrypted"], # already Fernet-encrypted at ingest bool(vip_fields.get("use_unicast")), bool(vip_fields.get("track_haproxy")), - current_user["id"]) + current_user["id"], json.dumps(extras)) # Every node of the instance becomes a member with the role/priority/interface ITS # OWN file declares, and each carries its own one-shot takeover authorisation pinned # to the hash of the file we analysed on THAT node. The guard stays per-node: a file diff --git a/backend/services/keepalived_config.py b/backend/services/keepalived_config.py index 80bbbed..c33423e 100644 --- a/backend/services/keepalived_config.py +++ b/backend/services/keepalived_config.py @@ -183,6 +183,8 @@ def render_keepalived_conf(*, vip: dict, members: List[dict], this_agent: dict, lines.append(" }") lines.append(" virtual_ipaddress {") lines.append(f' {vip["virtual_ip"]}/{int(vip.get("prefix_length", 24))} dev {iface}') + for extra in vip.get("additional_ips") or []: + lines.append(f' {extra["address"]}/{int(extra["prefix_length"])} dev {iface}') lines.append(" }") if track: lines.append(" track_script {") diff --git a/backend/services/keepalived_parser.py b/backend/services/keepalived_parser.py index 608d3ed..1bf48c7 100644 --- a/backend/services/keepalived_parser.py +++ b/backend/services/keepalived_parser.py @@ -31,6 +31,8 @@ import ipaddress import re from typing import Any, Dict, List, Optional, Tuple +from models.vip import MAX_ADDITIONAL_IPS + # Stored with every discovery (vip_discoveries.analysis_version). Bump it whenever the # analysis or its blockers change: rows from an older parser make the agent re-report once. ANALYSIS_VERSION = 1 @@ -424,14 +426,44 @@ def build_adoption_candidate(parsed: Dict[str, Any], instance: Dict[str, Any]) - vips = instance.get("virtual_ips") or [] if len(vips) == 0: blockers.append("the instance declares no virtual_ipaddress — nothing to adopt") - elif len(vips) > 1: - addrs = ", ".join(v["address"] for v in vips) - blockers.append( - f"the instance carries {len(vips)} addresses ({addrs}); a managed VIP holds exactly " - f"one, so adopting would drop all but the first") vip_entry = vips[0] if vips else None + # Issue #73: the addresses after the first become the VIP's additional addresses. Each one + # must render back exactly, so nothing about it may be guessed. + additional_ips: List[Dict[str, Any]] = [] + extra_entries = vips[1:] + if len(extra_entries) > MAX_ADDITIONAL_IPS: + blockers.append( + f"the instance carries {len(vips)} addresses; a managed VIP holds at most " + f"{MAX_ADDITIONAL_IPS + 1}") + seen_addrs = {vip_entry["address"]} if vip_entry else set() + for extra in extra_entries: + addr = extra["address"] + if ipaddress.ip_address(addr).version != 4: + blockers.append(f"additional address `{addr}` is IPv6; only IPv4 addresses are managed") + continue + if addr in seen_addrs: + blockers.append(f"additional address `{addr}` is listed more than once") + continue + seen_addrs.add(addr) + prefix = extra.get("prefix_length") + if prefix is None: + blockers.append( + f"additional address `{addr}` has no /prefix in the file; add it there so the " + f"netmask cannot change on takeover, then let the agent report again") + continue + if not (1 <= int(prefix) <= 32): + blockers.append(f"additional address `{addr}/{prefix}` has an invalid prefix length") + continue + if extra.get("dev") and instance.get("interface") and extra["dev"] != instance["interface"]: + blockers.append( + f"additional address `{addr}` is bound to `dev {extra['dev']}` but the instance " + f"uses `interface {instance['interface']}`; the render always uses the instance " + f"interface") + continue + additional_ips.append({"address": addr, "prefix_length": int(prefix)}) + if instance.get("virtual_router_id") is None: blockers.append("no virtual_router_id — it cannot be guessed: a wrong VRID puts the " "nodes in separate VRRP domains and both would claim the VIP") @@ -490,6 +522,7 @@ def build_adoption_candidate(parsed: Dict[str, Any], instance: Dict[str, Any]) - "use_unicast": bool(peers), "track_haproxy": OUR_CHECK_SCRIPT_NAME in tracked, "auth_pass": instance.get("auth_pass"), + "additional_ips": additional_ips, }, "member": { "network_interface": instance.get("interface"), diff --git a/backend/tests/test_keepalived_parser.py b/backend/tests/test_keepalived_parser.py index e2ff669..74cdaeb 100644 --- a/backend/tests/test_keepalived_parser.py +++ b/backend/tests/test_keepalived_parser.py @@ -78,6 +78,7 @@ def test_parses_a_handwritten_config_into_model_fields(): "use_unicast": True, "track_haproxy": True, "auth_pass": "s3cr3t", + "additional_ips": [], } assert cand["member"] == {"network_interface": "eth0", "role": "MASTER", "priority": 150} assert cand["peers"] == ["10.0.0.12"] and cand["unicast_src_ip"] == "10.0.0.11" @@ -141,11 +142,46 @@ def test_directives_we_cannot_render_block_adoption(): assert _only_candidate(text)["adoptable"] is False -def test_multiple_addresses_in_one_instance_block_adoption(): - text = HANDWRITTEN.replace(" 10.0.0.100/24 dev eth0", - " 10.0.0.100/24 dev eth0\n 10.0.0.101/24 dev eth0") - blockers = _blockers_for(text) - assert "2 addresses" in blockers and "10.0.0.101" in blockers +def _with_extra(line): + return HANDWRITTEN.replace(" 10.0.0.100/24 dev eth0", + " 10.0.0.100/24 dev eth0\n" + line) + + +def test_multiple_addresses_in_one_instance_are_adopted_as_additional_ips(): + """Issue #73: the addresses after the first become the VIP's additional addresses.""" + cand = _only_candidate(_with_extra(" 10.0.0.101/25 dev eth0\n 10.0.0.102/24")) + assert cand["adoptable"] is True, cand["blockers"] + assert cand["vip"]["virtual_ip"] == "10.0.0.100" + assert cand["vip"]["additional_ips"] == [ + {"address": "10.0.0.101", "prefix_length": 25}, + {"address": "10.0.0.102", "prefix_length": 24}, + ] + + +def test_an_additional_address_that_cannot_render_back_exactly_blocks_for_good(): + """No prefix, another dev, IPv6 or a repeat cannot be reproduced. None of them may be + waivable: their wording must carry neither the loss nor the prefix marker.""" + cases = { + " 10.0.0.101": "no /prefix", + " 10.0.0.101/24 dev eth1": "dev eth1", + " fd00::1/64 dev eth0": "IPv6", + " 10.0.0.100/24 dev eth0": "more than once", + " 10.0.0.101/40 dev eth0": "invalid prefix", + } + for line, reason in cases.items(): + cand = _only_candidate(_with_extra(line)) + assert cand["adoptable"] is False, line + assert reason in " | ".join(cand["blockers"]), (line, cand["blockers"]) + assert remaining_blockers(cand["blockers"], prefix_supplied=True, + accept_data_loss=True), line + + +def test_too_many_addresses_block_adoption(): + from models.vip import MAX_ADDITIONAL_IPS + lines = "\n".join(f" 10.0.1.{i}/24 dev eth0" for i in range(1, MAX_ADDITIONAL_IPS + 2)) + cand = _only_candidate(_with_extra(lines)) + assert cand["adoptable"] is False + assert f"at most {MAX_ADDITIONAL_IPS + 1}" in " | ".join(cand["blockers"]) def test_missing_vrid_blocks_adoption_with_the_split_brain_reason(): diff --git a/backend/tests/test_vip_multi_node.py b/backend/tests/test_vip_multi_node.py index 0220833..51f3d4e 100644 --- a/backend/tests/test_vip_multi_node.py +++ b/backend/tests/test_vip_multi_node.py @@ -1,6 +1,7 @@ -"""Issue #73 (v1.12.0) — several VIPs on one node. +"""Issue #73 (v1.12.0) — several VIPs on one node, several addresses on one VIP. -Pure tests for the merge of a node's applied single-VIP files, plus source-level guards for the delivery / ack / adoption / migration +Pure tests for the merge of a node's applied single-VIP files, the extra-address rendering +and validation, plus source-level guards for the delivery / ack / adoption / migration invariants that need a live database to exercise end to end. """ from __future__ import annotations @@ -14,6 +15,7 @@ import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from models.vip import MAX_ADDITIONAL_IPS, VIPCreate, VIPUpdate # noqa: E402 from services import keepalived_config as kc # noqa: E402 from services.keepalived_parser import ( # noqa: E402 ANALYSIS_VERSION, analyse_keepalived_conf, parse_keepalived_conf, @@ -107,6 +109,58 @@ class TestCompose: kc.compose_node_keepalived_conf([]) +class TestAdditionalAddresses: + def test_empty_list_renders_byte_identical(self): + assert _applied({**A, "additional_ips": []}) == _applied(A) + + def test_extras_follow_the_primary_on_the_same_interface(self): + conf = _applied({**A, "additional_ips": [ + {"address": "10.0.0.101", "prefix_length": 24}, + {"address": "192.168.5.9", "prefix_length": 32}]}) + lines = [l.strip() for l in conf.splitlines()] + i = lines.index("10.0.0.100/24 dev eth0") + assert lines[i + 1:i + 3] == ["10.0.0.101/24 dev eth0", "192.168.5.9/32 dev eth0"] + + def test_rendered_extras_are_adopted_back_unchanged(self): + extras = [{"address": "10.0.0.101", "prefix_length": 25}] + conf = _applied({**A, "additional_ips": extras}) + cand = analyse_keepalived_conf(conf)["candidates"][0] + assert cand["adoptable"] is True, cand["blockers"] + assert cand["vip"]["additional_ips"] == extras + + +def _members(): + return [{"agent_id": 1, "network_interface": "eth0", "role": "MASTER", "priority": 150}] + + +class TestModels: + def test_default_is_empty(self): + v = VIPCreate(name="v", pool_id=1, virtual_ip="10.0.0.100", members=_members()) + assert v.additional_ips == [] + + def test_address_is_normalised_and_prefix_defaults(self): + v = VIPCreate(name="v", pool_id=1, virtual_ip="10.0.0.100", members=_members(), + additional_ips=[{"address": " 10.0.0.101 "}]) + assert v.additional_ips[0].model_dump() == {"address": "10.0.0.101", "prefix_length": 24} + + @pytest.mark.parametrize("extras", [ + [{"address": "10.0.0.100"}], + [{"address": "10.0.0.101"}, {"address": "10.0.0.101"}], + [{"address": "fd00::1"}], + [{"address": "nope"}], + [{"address": "10.0.0.101", "prefix_length": 33}], + [{"address": f"10.0.1.{i}"} for i in range(1, MAX_ADDITIONAL_IPS + 2)], + ]) + def test_invalid_extras_are_rejected(self, extras): + with pytest.raises(ValueError): + VIPCreate(name="v", pool_id=1, virtual_ip="10.0.0.100", members=_members(), + additional_ips=extras) + + def test_update_omitted_keeps_and_empty_clears(self): + assert VIPUpdate().additional_ips is None + assert VIPUpdate(additional_ips=[]).additional_ips == [] + + class TestDeliveryAndAckGuards: def test_failures_answer_with_statuses_the_agent_ignores(self): assert '"status": "compose_error"' in AGENT_ROUTER @@ -146,6 +200,11 @@ class TestAdoptionGuards: row_api = VIP_ROUTER.split("def _discovery_row_to_api", 1)[1].split("\ndef ", 1)[0] assert 'cand["adoptable"] = False' in row_api + def test_address_writes_take_the_address_lock(self): + for fn in ("create_vip", "update_vip", "adopt_vip"): + body = VIP_ROUTER.split(f"async def {fn}", 1)[1].split("\nasync def ", 1)[0] + assert "_lock_and_check_addresses(" in body, fn + class TestDeltaMigration: def test_versions(self): diff --git a/backend/version.json b/backend/version.json index 0340066..c9cf7b0 100644 --- a/backend/version.json +++ b/backend/version.json @@ -1,5 +1,5 @@ { - "version": "1.11.3", - "releaseName": "Config Import keeps stick-table and stick rules instead of dropping them", + "version": "1.12.0", + "releaseName": "Several keepalived VIPs on one node, and several addresses on one VIP", "releaseDate": "2026-10-16" } diff --git a/frontend/package.json b/frontend/package.json index d397b37..f02b6f2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "haproxy-openmanager-frontend", - "version": "1.11.3", + "version": "1.12.0", "description": "HAProxy Load Balancer Management UI", "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/frontend/src/components/VIPManagement.js b/frontend/src/components/VIPManagement.js index ec85c2f..59bebe5 100644 --- a/frontend/src/components/VIPManagement.js +++ b/frontend/src/components/VIPManagement.js @@ -6,7 +6,7 @@ import { import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, WarningOutlined, PlayCircleOutlined, SyncOutlined, CrownOutlined, ClockCircleOutlined, FileSearchOutlined, - InfoCircleOutlined + InfoCircleOutlined, MinusCircleOutlined } from '@ant-design/icons'; import { useCluster } from '../contexts/ClusterContext'; import { extractApiError } from '../utils/apiError'; @@ -76,6 +76,10 @@ const mergeBlockers = (lists) => { return Array.from(seen.values()); }; +// Mirrors backend/models/vip.py MAX_ADDITIONAL_IPS. +const MAX_ADDITIONAL_IPS = 19; +const IPV4_RE = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; + // This component uses raw fetch(), but extractApiError expects an axios-shaped error // (err.response.data). Read the fetch Response body and reuse the envelope-aware extractor // so backend messages — e.g. the 409 "address already used by VIP X" — actually reach the user. @@ -351,7 +355,8 @@ const VIPManagement = () => { setSelectedPoolId(null); setMemberRows([]); form.resetFields(); - form.setFieldsValue({ prefix_length: 24, advert_int: 1, use_unicast: true, track_haproxy: true }); + form.setFieldsValue({ prefix_length: 24, advert_int: 1, use_unicast: true, track_haproxy: true, + additional_ips: [] }); setModalVisible(true); }; @@ -365,6 +370,7 @@ const VIPManagement = () => { virtual_ip: vip.virtual_ip, prefix_length: vip.prefix_length, virtual_router_id: vip.virtual_router_id, advert_int: vip.advert_int, use_unicast: vip.use_unicast, track_haproxy: vip.track_haproxy, + additional_ips: (vip.additional_ips || []).map((a) => ({ ...a })), }); setModalVisible(true); }; @@ -478,7 +484,19 @@ const VIPManagement = () => { const columns = [ { title: 'Name', dataIndex: 'name', key: 'name' }, - { title: 'Virtual IP', key: 'vip', render: (_, v) => {v.virtual_ip}/{v.prefix_length} }, + { title: 'Virtual IP', key: 'vip', render: (_, v) => { + const extra = v.additional_ips || []; + return ( + + {v.virtual_ip}/{v.prefix_length} + {extra.length > 0 && ( + `${a.address}/${a.prefix_length}`).join(', ')}> + +{extra.length} + + )} + + ); + } }, { title: 'Pool', dataIndex: 'pool_name', key: 'pool' }, { title: 'VRID', dataIndex: 'virtual_router_id', key: 'vrid' }, { title: 'Members / Live state', key: 'members', render: renderMembers }, @@ -843,6 +861,37 @@ const VIPManagement = () => { + + {(fields, { add, remove }) => ( +
+ Additional addresses +
+ Optional. Extra IPv4 addresses carried by the same VRRP instance — they move between + nodes together with the virtual IP, on the same interface (up to {MAX_ADDITIONAL_IPS}). +
+ {fields.map(({ key, name, ...rest }) => ( + + + + + + + + remove(name)} /> + + ))} + +
+ )} +
+ Participating nodes
These are the HAProxy servers (agents) already enrolled in this pool — toggle which join the VIP.