Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions backend/commit_confirm_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
Commit-Confirm State Tracker

Tracks active commit-confirm sessions per VyOS instance.
When a commit-confirm is active, no further changes are allowed
until the user confirms or the timer expires (and VyOS auto-reverts).
"""

from datetime import datetime, timedelta, timezone
from typing import Optional, Dict
from dataclasses import dataclass, field
import logging

logger = logging.getLogger(__name__)


@dataclass
class CommitConfirmSession:
instance_id: str
started_at: datetime
expires_at: datetime
confirm_time_minutes: int
action: str # "reload" or "reboot"

def seconds_remaining(self) -> int:
delta = self.expires_at - datetime.now(timezone.utc)
return max(0, int(delta.total_seconds()))

def is_expired(self) -> bool:
return datetime.now(timezone.utc) >= self.expires_at

def to_dict(self) -> dict:
return {
"active": True,
"instance_id": self.instance_id,
"confirm_time_minutes": self.confirm_time_minutes,
"action": self.action,
"started_at": self.started_at.isoformat(),
"expires_at": self.expires_at.isoformat(),
"seconds_remaining": self.seconds_remaining(),
}


# In-memory store keyed by instance_id
_sessions: Dict[str, CommitConfirmSession] = {}


def set_active(
instance_id: str,
confirm_time_minutes: int,
action: str = "reload",
) -> CommitConfirmSession:
"""Record a new active commit-confirm session for an instance."""
now = datetime.now(timezone.utc)
session = CommitConfirmSession(
instance_id=instance_id,
started_at=now,
expires_at=now + timedelta(minutes=confirm_time_minutes),
confirm_time_minutes=confirm_time_minutes,
action=action,
)
_sessions[instance_id] = session
logger.info(
"Commit-confirm started for instance %s (%d min, action=%s)",
instance_id, confirm_time_minutes, action,
)
return session


def get_active(instance_id: str) -> Optional[CommitConfirmSession]:
"""Return the active session if it exists and hasn't expired."""
session = _sessions.get(instance_id)
if session is None:
return None
if session.is_expired():
logger.info("Commit-confirm expired for instance %s — VyOS should have reverted", instance_id)
del _sessions[instance_id]
return None
return session


def is_active(instance_id: str) -> bool:
"""Return True if there is an active (non-expired) commit-confirm."""
return get_active(instance_id) is not None


def clear(instance_id: str) -> None:
"""Clear the commit-confirm session (after confirm or explicit discard)."""
if instance_id in _sessions:
del _sessions[instance_id]
logger.info("Commit-confirm cleared for instance %s", instance_id)
6 changes: 6 additions & 0 deletions backend/middleware/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ async def dispatch(self, request: Request, call_next):
i."vyosVersion" as vyos_version,
i.protocol,
i."verifySsl" as verify_ssl,
i."commitConfirmEnabled" as commit_confirm_enabled,
i."commitConfirmMinutes" as commit_confirm_minutes,
s.name as site_name,
'ADMIN' as user_role
FROM active_sessions a
Expand Down Expand Up @@ -158,6 +160,8 @@ async def dispatch(self, request: Request, call_next):
i."vyosVersion" as vyos_version,
i.protocol,
i."verifySsl" as verify_ssl,
i."commitConfirmEnabled" as commit_confirm_enabled,
i."commitConfirmMinutes" as commit_confirm_minutes,
s.name as site_name,
uir.role as user_role
FROM active_sessions a
Expand Down Expand Up @@ -215,6 +219,8 @@ async def dispatch(self, request: Request, call_next):
"vyos_version": session.get("vyos_version"),
"protocol": session.get("protocol"),
"verify_ssl": session.get("verify_ssl"),
"commit_confirm_enabled": session.get("commit_confirm_enabled") or False,
"commit_confirm_minutes": session.get("commit_confirm_minutes") or 5,
}
request.state.site = {
"id": session["site_id"],
Expand Down
78 changes: 78 additions & 0 deletions backend/routers/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from session_vyos_service import get_session_vyos_service
from fastapi_permissions import require_read_permission, require_write_permission
from rbac_permissions import FeatureGroup
import commit_confirm_state
import json
import logging
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -256,3 +257,80 @@ async def refresh_config(request: Request):
raise HTTPException(status_code=500, detail="Internal server error")


# ========================================================================
# Commit-Confirm Endpoints
# ========================================================================

class CommitConfirmStatusResponse(BaseModel):
active: bool
instance_id: Optional[str] = None
confirm_time_minutes: Optional[int] = None
action: Optional[str] = None
seconds_remaining: Optional[int] = None
expires_at: Optional[str] = None


class CommitConfirmRequest(BaseModel):
confirm_time_minutes: int = 5
action: str = "reload"


@router.get("/commit-confirm/status", response_model=CommitConfirmStatusResponse)
async def get_commit_confirm_status(request: Request):
"""
Get the current commit-confirm status for the active instance.

Returns active=True with countdown info if a commit-confirm is in progress,
or active=False if no commit-confirm is active (or it has expired).
"""
await require_read_permission(request, FeatureGroup.CONFIGURATION)
try:
instance_id = request.state.instance["id"]
session = commit_confirm_state.get_active(instance_id)
if session is None:
return CommitConfirmStatusResponse(active=False)
return CommitConfirmStatusResponse(**session.to_dict())
except HTTPException:
raise
except Exception:
logger.exception("Unhandled error in /config/commit-confirm/status")
raise HTTPException(status_code=500, detail="Internal server error")


@router.post("/commit-confirm/confirm", response_model=SaveConfigResponse)
async def confirm_commit(request: Request):
"""
Confirm the active commit-confirm, stopping the rollback timer.

This makes the previously applied changes permanent and saves the
configuration to disk.
"""
await require_write_permission(request, FeatureGroup.CONFIGURATION)
try:
service = get_session_vyos_service(request)
instance_id = request.state.instance["id"]

response = await run_in_threadpool(service.confirm_commit, instance_id)

if response.status != 200:
return SaveConfigResponse(
success=False,
message="Failed to confirm commit",
error=response.error or "Unknown error",
)

# Refresh the config cache so the unsaved-changes banner reflects
# the new running config, prompting the user to save when ready.
await run_in_threadpool(service.get_full_config, refresh=True)

return SaveConfigResponse(
success=True,
message="Commit confirmed — changes are live. Save configuration when ready.",
)
except HTTPException:
raise
except Exception:
logger.exception("Unhandled error in /config/commit-confirm/confirm")
raise HTTPException(status_code=500, detail="Internal server error")


1 change: 0 additions & 1 deletion backend/routers/firewall/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,6 @@ async def configure_group_batch(http_request: Request, request: GroupBatchReques
detail=f"Unsupported operation: {op_type}"
)

# Execute the batch
response = service.execute_batch(batch)

# Handle empty string result (convert to None for Pydantic validation)
Expand Down
32 changes: 31 additions & 1 deletion backend/routers/session/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ class InstanceResponse(BaseModel):
ssh_port: int = 22
ssh_username: Optional[str] = None
ssh_key_configured: bool = False
commit_confirm_enabled: bool = False
commit_confirm_minutes: int = 5
created_at: datetime
updated_at: datetime

Expand All @@ -95,6 +97,8 @@ class InstanceCreateRequest(BaseModel):
is_active: bool = Field(default=True, description="Whether instance is active")
ssh_port: int = Field(default=22, ge=1, le=65535, description="SSH port for monitoring")
ssh_username: Optional[str] = Field(None, description="SSH username for monitoring")
commit_confirm_enabled: bool = Field(default=False, description="Use commit-confirm for all changes (VyOS 1.5+ only)")
commit_confirm_minutes: int = Field(default=5, ge=1, le=60, description="Minutes before auto-revert if not confirmed")


class InstanceUpdateRequest(BaseModel):
Expand All @@ -112,6 +116,8 @@ class InstanceUpdateRequest(BaseModel):
site_id: Optional[str] = Field(None, description="Move to different site")
ssh_port: Optional[int] = Field(None, ge=1, le=65535, description="SSH port for monitoring")
ssh_username: Optional[str] = Field(None, description="SSH username for monitoring")
commit_confirm_enabled: Optional[bool] = Field(None, description="Use commit-confirm for all changes (VyOS 1.5+ only)")
commit_confirm_minutes: Optional[int] = Field(None, ge=1, le=60, description="Minutes before auto-revert if not confirmed")


class ActiveSessionResponse(BaseModel):
Expand Down Expand Up @@ -583,6 +589,7 @@ async def list_site_instances(request: Request, site_id: str):
"""
SELECT id, "siteId", name, description, host, port, protocol, "verifySsl", "isActive",
"vyosVersion", "sshPort", "sshUsername", "sshKeyConfigured",
"commitConfirmEnabled", "commitConfirmMinutes",
"createdAt", "updatedAt"
FROM instances
WHERE "siteId" = $1
Expand All @@ -596,6 +603,7 @@ async def list_site_instances(request: Request, site_id: str):
"""
SELECT DISTINCT i.id, i."siteId", i.name, i.description, i.host, i.port, i.protocol, i."verifySsl", i."isActive",
i."vyosVersion", i."sshPort", i."sshUsername", i."sshKeyConfigured",
i."commitConfirmEnabled", i."commitConfirmMinutes",
i."createdAt", i."updatedAt"
FROM instances i
JOIN user_instance_roles uir ON i.id = uir."instanceId"
Expand Down Expand Up @@ -624,6 +632,8 @@ async def list_site_instances(request: Request, site_id: str):
ssh_port=inst["sshPort"],
ssh_username=inst["sshUsername"],
ssh_key_configured=inst["sshKeyConfigured"],
commit_confirm_enabled=inst.get("commitConfirmEnabled") or False,
commit_confirm_minutes=inst.get("commitConfirmMinutes") or 5,
created_at=inst["createdAt"],
updated_at=inst["updatedAt"],
)
Expand Down Expand Up @@ -923,11 +933,13 @@ async def create_instance(request: Request, body: InstanceCreateRequest):
id, "siteId", name, description, host, port, username, password,
"apiKey", "vyosVersion", protocol, "verifySsl", "isActive",
"sshPort", "sshUsername",
"commitConfirmEnabled", "commitConfirmMinutes",
"createdAt", "updatedAt"
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW())
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), NOW())
RETURNING id, "siteId", name, description, host, port, protocol, "verifySsl", "vyosVersion",
"isActive", "sshPort", "sshUsername", "sshKeyConfigured",
"commitConfirmEnabled", "commitConfirmMinutes",
"createdAt", "updatedAt"
""",
instance_id,
Expand All @@ -945,6 +957,8 @@ async def create_instance(request: Request, body: InstanceCreateRequest):
body.is_active,
body.ssh_port,
body.ssh_username,
body.commit_confirm_enabled,
body.commit_confirm_minutes,
)

clear_session_cache(instance_id)
Expand All @@ -963,6 +977,8 @@ async def create_instance(request: Request, body: InstanceCreateRequest):
ssh_port=instance["sshPort"],
ssh_username=instance["sshUsername"],
ssh_key_configured=instance["sshKeyConfigured"],
commit_confirm_enabled=instance.get("commitConfirmEnabled") or False,
commit_confirm_minutes=instance.get("commitConfirmMinutes") or 5,
created_at=instance["createdAt"],
updated_at=instance["updatedAt"],
)
Expand Down Expand Up @@ -1096,12 +1112,23 @@ async def update_instance(request: Request, instance_id: str, body: InstanceUpda
params.append(body.ssh_username)
param_num += 1

if body.commit_confirm_enabled is not None:
updates.append(f'"commitConfirmEnabled" = ${param_num}')
params.append(body.commit_confirm_enabled)
param_num += 1

if body.commit_confirm_minutes is not None:
updates.append(f'"commitConfirmMinutes" = ${param_num}')
params.append(body.commit_confirm_minutes)
param_num += 1

if not updates:
# No fields to update, return current instance
instance = await conn.fetchrow(
"""
SELECT id, "siteId", name, description, host, port, protocol, "verifySsl", "vyosVersion",
"isActive", "sshPort", "sshUsername", "sshKeyConfigured",
"commitConfirmEnabled", "commitConfirmMinutes",
"createdAt", "updatedAt"
FROM instances WHERE id = $1
""",
Expand All @@ -1115,6 +1142,7 @@ async def update_instance(request: Request, instance_id: str, body: InstanceUpda
WHERE id = $1
RETURNING id, "siteId", name, description, host, port, protocol, "verifySsl", "vyosVersion",
"isActive", "sshPort", "sshUsername", "sshKeyConfigured",
"commitConfirmEnabled", "commitConfirmMinutes",
"createdAt", "updatedAt"
"""
instance = await conn.fetchrow(query, *params)
Expand All @@ -1139,6 +1167,8 @@ async def update_instance(request: Request, instance_id: str, body: InstanceUpda
ssh_port=instance["sshPort"],
ssh_username=instance["sshUsername"],
ssh_key_configured=instance["sshKeyConfigured"],
commit_confirm_enabled=instance.get("commitConfirmEnabled") or False,
commit_confirm_minutes=instance.get("commitConfirmMinutes") or 5,
created_at=instance["createdAt"],
updated_at=instance["updatedAt"],
)
Expand Down
3 changes: 3 additions & 0 deletions backend/session_vyos_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ async def get_interfaces(request: Request):
port=instance.get("port", 443),
verify=verify,
timeout=30,
commit_confirm_enabled=instance.get("commit_confirm_enabled") or False,
commit_confirm_minutes=instance.get("commit_confirm_minutes") or 5,
instance_id=instance_id,
)

# Register the service
Expand Down
Loading