feat(vip): store the keepalived.conf an agent finds on a node

Ingest side of adopting an existing VIP. A node reports the keepalived.conf it
found and does NOT own to a new endpoint, and the finding is kept in a new
vip_discoveries table (one row per agent, since the file is per-node).

Reporting is read-only on the node. The heartbeat cannot carry this: it has the
VIP address and a best-effort MASTER/BACKUP, while rendering a node's config
needs eleven fields, so the file itself has to be read and parsed server-side -
parsing keepalived's block syntax in bash is not something to attempt on a
production load balancer.

Secrets are split at ingest. The reported content may contain the VRRP
auth_pass, so the password is Fernet-encrypted into its own column through the
same key path as vip_instances, and the stored copy of the file has it masked.
Nothing readable through the API, the UI preview or a database dump carries it
in cleartext, and the parse result is never logged. A file that does not parse
records its error rather than failing the agent's poll loop, and a report of
"the file is gone" clears the row so the UI stops offering a stale candidate.

The keepalived-config delivery gains allow_takeover and
takeover_expected_hash. The agent refuses to overwrite a keepalived.conf
without our ownership marker, which is the guard that protects a
hand-maintained setup - and is exactly the guard adoption has to pass. Rather
than weaken it, an adopted VIP authorises exactly ONE takeover of exactly the
file that was analysed by pinning its md5, so a config edited between adoption
and Apply is still refused instead of being silently overwritten.

SCHEMA_VERSION 10 -> 11 for the new table and two additive columns. Nothing
existing is altered, but the bump re-seeds the four built-in roles, which the
upgrade notes call out.
This commit is contained in:
mustafa.ulukaya
2026-08-11 01:35:59 +03:00
parent 8ac567dfe0
commit 7dfd31832a
2 changed files with 157 additions and 3 deletions
+46 -1
View File
@@ -1758,7 +1758,13 @@ async def ensure_agent_activity_logs_table():
# until the operator imports the CA-signed certificate; the import creates a
# normal ssl_certificates row and NULLs the key copy here. Additive + idempotent;
# no existing table is altered, agents never read this table.
SCHEMA_VERSION = 10
# v1.10.4 (VIP adoption): bumped 10 -> 11 for the new `vip_discoveries` table plus two
# additive columns (`vip_instances.adopted_at`, `vip_members.takeover_expected_hash`).
# Holds the keepalived.conf an agent found already on a node so an existing VIP can be
# adopted instead of retyped. Additive + idempotent; no existing table is altered and no
# existing row changes. NOTE for the upgrade notes: a SCHEMA_VERSION bump re-seeds the four
# built-in roles to their defaults, so role customizations are lost on this upgrade.
SCHEMA_VERSION = 11
async def run_all_migrations():
@@ -2161,6 +2167,45 @@ async def ensure_vip_tables():
"CREATE INDEX IF NOT EXISTS idx_vip_members_agent ON vip_members(agent_id);"
)
# ── v1.10.4 — VIP adoption: what the agent found already on the node ──────────
# A node with a hand-maintained keepalived.conf reports it here so an existing VIP can
# be adopted instead of retyped. One row per agent (the file is per-node); the agent
# only reports a config it does NOT own, and only when the content changed.
#
# SECRETS: `raw_config` is stored MASKED (auth_pass replaced) because it is served to
# the UI. The real VRRP password is Fernet-encrypted in auth_pass_encrypted, mirroring
# vip_instances, so adoption can carry it into the managed VIP without it ever being
# readable through the API or a DB dump. `analysis` is the parser output with auth_pass
# stripped out.
await conn.execute("""
CREATE TABLE IF NOT EXISTS vip_discoveries (
id SERIAL PRIMARY KEY,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
config_path VARCHAR(500) NOT NULL,
config_hash VARCHAR(64) NOT NULL,
is_managed BOOLEAN NOT NULL DEFAULT FALSE,
raw_config_masked TEXT,
auth_pass_encrypted TEXT,
analysis JSONB,
parse_error TEXT,
adopted_vip_id INTEGER REFERENCES vip_instances(id) ON DELETE SET NULL,
reported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT vip_discovery_agent_unique UNIQUE (agent_id)
);
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_vip_discoveries_agent ON vip_discoveries(agent_id);"
)
# Adoption provenance + the one-shot takeover authorisation. The agent refuses to
# overwrite a keepalived.conf that lacks our ownership marker, which is exactly the
# guard adoption has to pass. Rather than weaken it, an adopted VIP carries the hash of
# the file we analysed: the agent takes over ONLY if the file on disk still hashes to
# that value, so a config that changed after adoption is never clobbered.
await conn.execute(
"ALTER TABLE vip_instances ADD COLUMN IF NOT EXISTS adopted_at TIMESTAMP;")
await conn.execute(
"ALTER TABLE vip_members ADD COLUMN IF NOT EXISTS takeover_expected_hash VARCHAR(64);")
logger.info("✅ VIP tables ensured (Issue #27 — HA/VIP Keepalived management)")
except Exception as e:
logger.error(f"Failed to ensure VIP tables: {e}")
+111 -2
View File
@@ -9,8 +9,15 @@ import os
import json
import ipaddress
import hashlib
import re
# Pipeline trigger - force backend redeploy v2
# v1.10.4 — a discovered keepalived.conf is stored and served to the UI, so the VRRP password is
# masked out of the stored copy (the real value lives Fernet-encrypted in its own column). Mask
# the WHOLE remainder of the line, mirroring vip.py's version-diff masking, so a password
# containing whitespace cannot partially leak.
_AUTH_PASS_MASK_RE = re.compile(r"(auth_pass\s+).*")
from models import AgentCreate
from models.agent import AgentToggle, AgentHeartbeat, AgentScriptRequest, AgentUpgradeRequest
from database.connection import get_database_connection, close_database_connection
@@ -26,7 +33,7 @@ logger = logging.getLogger(__name__)
# Global version storage (acts as in-memory database)
AGENT_VERSIONS = {
"macos": "2.1.0", # Updated via endpoint
"linux": "2.0.0"
"linux": "2.1.0"
}
@@ -2332,7 +2339,7 @@ async def get_agent_keepalived_config(agent_name: str, x_api_key: Optional[str]
row = await conn.fetchrow("""
SELECT v.id AS vip_id, v.name AS vip_name, v.is_active, v.track_haproxy,
v.purge_on_teardown,
m.applied_config_content, m.applied_config_hash
m.applied_config_content, m.applied_config_hash, m.takeover_expected_hash
FROM vip_members m JOIN vip_instances v ON v.id = m.vip_id
WHERE m.agent_id = $1
-- Active VIP first (an agent has at most one). With NO active VIP, pick the most
@@ -2367,6 +2374,14 @@ async def get_agent_keepalived_config(agent_name: str, x_api_key: Optional[str]
"config_content": row['applied_config_content'],
"config_hash": row['applied_config_hash'],
"check_script": check_script,
# v1.10.4 adoption handoff. The agent refuses to overwrite a keepalived.conf
# without our ownership marker — the guard that protects a hand-maintained
# setup. Adoption does not weaken it: it authorises exactly ONE takeover, of
# exactly the file we analysed, by pinning its hash. If the file changed since
# adoption the hashes differ and the agent keeps refusing, so an edit made
# between adoption and Apply can never be silently overwritten.
"allow_takeover": bool(row['takeover_expected_hash']),
"takeover_expected_hash": row['takeover_expected_hash'],
},
}
except HTTPException:
@@ -2431,6 +2446,100 @@ async def agent_keepalived_status(agent_name: str, status_data: dict, x_api_key:
if conn:
await close_database_connection(conn)
@router.post("/{agent_name}/keepalived-discovery")
async def agent_keepalived_discovery(agent_name: str, payload: dict, x_api_key: Optional[str] = Header(None)):
"""v1.10.4 — the agent reports a keepalived.conf it found on the node but does NOT own.
This is what makes adopting a hand-maintained VIP possible: the heartbeat only carries the
VIP address and a best-effort MASTER/BACKUP, while rendering a node's config needs eleven
fields, so the file itself has to be read. Read-only on the agent side — reporting never
changes anything on the node.
Auth mirrors /keepalived-status: a MISSING key is rejected outright, and because the token
is a shared install token a name mismatch is an advisory audit log rather than a 403.
SECRETS: the reported content may contain the VRRP `auth_pass`. It is split immediately —
the password is Fernet-encrypted into its own column and the stored copy of the file has it
masked, so nothing readable through the API or a DB dump carries it in cleartext. The
parse result is never logged.
"""
conn = None
try:
from auth_middleware import validate_agent_api_key
agent_auth = await validate_agent_api_key(x_api_key)
if not x_api_key or not agent_auth:
raise HTTPException(status_code=401, detail="Invalid API key")
if agent_auth['name'] != agent_name:
logger.info(f"Agent '{agent_name}' reporting keepalived discovery using API key "
f"from agent '{agent_auth['name']}'")
conn = await get_database_connection()
agent = await conn.fetchrow("SELECT id FROM agents WHERE name = $1", agent_name)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
config_path = (payload.get("config_path") or "/etc/keepalived/keepalived.conf")[:500]
exists = bool(payload.get("exists"))
if not exists:
# The file is gone (keepalived removed, or we adopted and now own it) — drop the row
# so the UI stops offering a stale candidate.
await conn.execute("DELETE FROM vip_discoveries WHERE agent_id = $1", agent['id'])
return {"status": "cleared"}
content = payload.get("config_content") or ""
if len(content) > 256_000:
raise HTTPException(status_code=413, detail="keepalived.conf too large to analyse")
is_managed = bool(payload.get("is_managed"))
config_hash = hashlib.md5(content.encode("utf-8", "replace")).hexdigest()
from services.keepalived_parser import analyse_keepalived_conf, KeepalivedParseError
from services.keepalived_config import encrypt_vrrp_secret
parse_error = None
analysis = None
auth_enc = None
try:
analysis = analyse_keepalived_conf(content)
# Split the secret out of everything we persist or serve.
for cand in analysis.get("candidates", []):
secret = (cand.get("vip") or {}).pop("auth_pass", None)
cand["vip"]["has_auth_pass"] = bool(secret)
if secret and auth_enc is None:
auth_enc = encrypt_vrrp_secret(secret)
except KeepalivedParseError as exc:
parse_error = str(exc)[:500]
except Exception as exc: # noqa: BLE001 — a malformed file must not 500 the agent loop
parse_error = f"could not analyse the config ({type(exc).__name__})"
masked = _AUTH_PASS_MASK_RE.sub(r"\1********", content)
await conn.execute("""
INSERT INTO vip_discoveries
(agent_id, config_path, config_hash, is_managed, raw_config_masked,
auth_pass_encrypted, analysis, parse_error, reported_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,CURRENT_TIMESTAMP)
ON CONFLICT (agent_id) DO UPDATE SET
config_path = EXCLUDED.config_path,
config_hash = EXCLUDED.config_hash,
is_managed = EXCLUDED.is_managed,
raw_config_masked = EXCLUDED.raw_config_masked,
auth_pass_encrypted = EXCLUDED.auth_pass_encrypted,
analysis = EXCLUDED.analysis,
parse_error = EXCLUDED.parse_error,
reported_at = CURRENT_TIMESTAMP
""", agent['id'], config_path, config_hash, is_managed, masked, auth_enc,
json.dumps(analysis) if analysis is not None else None, parse_error)
return {"status": "recorded", "config_hash": config_hash}
except HTTPException:
raise
except Exception as e:
logger.error(f"keepalived-discovery failed for '{agent_name}': {e}")
raise HTTPException(status_code=500, detail="keepalived-discovery failed")
finally:
if conn:
await close_database_connection(conn)
@router.get("/script-version")
async def get_latest_script_version(platform: str = "macos"):
"""Get the latest available agent script version for specified platform"""