CI / build-and-push (push) Successful in 3m53s
F1 — Any authenticated user could read any file the backend could see.
/api/files/read and /download hung on get_current_user, and the sandbox that
should have caught that was open by default: ALLOWED_BROWSE_ROOTS contained
"/", for which _is_allowed() waves through every path. So the `user` role could
download stackpilot.db (password hashes, agent tokens, backup credentials),
every stack's .env and every .secrets/* file — with no audit trail, because
only mutations were logged.
Implementing that turned up three more doors into the same room, all fixed
here since closing only the first would have made the fix cosmetic:
GET /api/stacks/{id} handed the .env to any user, /export tarred the whole
stack dir including .secrets/*, and both the agent file proxies and
/api/agents/{id}/stacks/{id} repeated the leak for every remote host. All 24
filesystem-touching routes are now admin-only; reads and downloads are audited
(listing is not — the Files page polls it). DATA_DIR is refused outright, since
the API deliberately masks agent tokens and destination secrets and the browser
would otherwise be the way around that. "/" is out of the default browse roots.
F2 — Backup destination credentials were plaintext JSON in the DB, which is
what made F1 worth exploiting. They are now Fernet-encrypted at rest behind
parse_config/dump_config, with existing rows migrated at startup.
This needed a prerequisite from F6: the key is derived from SECRET_KEY, which
was regenerated on every boot when unset. Encrypting against a key that changes
per restart would be worse than plaintext, so an auto-generated SECRET_KEY is
now persisted to ${DATA_DIR}/secret_key at mode 0600. Sessions surviving a
restart is a welcome side effect.
F3 — /api/audit is admin-only. Also hidden from the dashboard and the nav for
non-admins, so nobody polls into a 403.
F4 — uvicorn now runs with --proxy-headers, so nginx's X-Forwarded-For is
honoured. Without it request.client.host was the frontend container's IP for
every request, which made the login rate limit global instead of per-IP (10
failures locked out everyone) and filled the audit log's IP column with one
useless value.
Verified: encrypt/decrypt round-trip incl. plaintext passthrough, idempotent
re-encryption and wrong-key handling; sandbox denial for DATA_DIR, traversal
into it, and paths outside the roots, with the allowed roots still reachable.
Both against stubbed settings — there is no Docker here, so nothing was run
end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
1201 lines
39 KiB
Python
1201 lines
39 KiB
Python
"""Remote host (agent) management + proxied stack/system operations."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import tempfile
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
from pydantic import BaseModel
|
|
from sqlmodel import Session, select
|
|
from starlette.background import BackgroundTask
|
|
|
|
from auth import get_current_user, require_admin
|
|
from database import get_session
|
|
from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate
|
|
from models.backup_destination import BackupDestination
|
|
from models.stack import StackCreate, StackUpdate
|
|
from models.user import User
|
|
from routers.files import NameBody, RenameBody, TransferBody, WriteBody
|
|
from routers.networks import ContainerRef, NetworkCreate
|
|
from routers.secrets import AttachBody, DetachBody, SecretWrite
|
|
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
|
|
from services import (
|
|
agent_service,
|
|
audit_service,
|
|
auto_update_service,
|
|
backup_destination_service as dest_service,
|
|
backup_service,
|
|
compose_service,
|
|
)
|
|
from services.agent_service import AgentError
|
|
|
|
router = APIRouter(prefix="/api/agents", tags=["agents"])
|
|
|
|
_ACTIONS = {"start", "stop", "restart", "pull", "update", "down"}
|
|
|
|
|
|
class AgentPushBody(BaseModel):
|
|
destination_id: int
|
|
include_volumes: bool = True
|
|
include_binds: bool = True
|
|
stop_first: bool = True
|
|
binds: list[str] | None = None
|
|
volumes: list[str] | None = None
|
|
|
|
|
|
class AgentRestoreFromBody(BaseModel):
|
|
destination_id: int
|
|
name: str
|
|
target_id: str | None = None
|
|
overwrite: bool = False
|
|
restore_binds: bool = True
|
|
restore_volumes: bool = True
|
|
|
|
|
|
def _get_dest(session: Session, dest_id: int) -> BackupDestination:
|
|
d = session.get(BackupDestination, dest_id)
|
|
if not d:
|
|
raise HTTPException(status_code=404, detail=f"Destination {dest_id} not found")
|
|
return d
|
|
|
|
|
|
def _ip(request: Request) -> str:
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
def _to_read(a: Agent) -> AgentRead:
|
|
return AgentRead(
|
|
id=a.id,
|
|
name=a.name,
|
|
url=a.url,
|
|
status=a.status,
|
|
hostname=a.hostname,
|
|
last_seen=a.last_seen,
|
|
created_at=a.created_at,
|
|
token_set=bool(a.token),
|
|
)
|
|
|
|
|
|
def _get_or_404(session: Session, agent_id: int) -> Agent:
|
|
agent = session.get(Agent, agent_id)
|
|
if not agent:
|
|
raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
|
|
return agent
|
|
|
|
|
|
def _raise(exc: AgentError):
|
|
raise HTTPException(
|
|
status_code=exc.status if exc.status >= 400 else 502,
|
|
detail={"error": exc.error, "detail": exc.detail},
|
|
)
|
|
|
|
|
|
async def _proxy(session: Session, agent: Agent, method: str, path: str, **kw):
|
|
try:
|
|
return await agent_service.call(session, agent, method, path, **kw)
|
|
except AgentError as exc:
|
|
_raise(exc)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# CRUD
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("", response_model=list[AgentRead])
|
|
async def list_agents(
|
|
refresh: bool = Query(True),
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> list[AgentRead]:
|
|
agents = session.exec(select(Agent).order_by(Agent.id)).all()
|
|
if refresh:
|
|
for agent in agents:
|
|
await agent_service.ping(session, agent)
|
|
return [_to_read(a) for a in agents]
|
|
|
|
|
|
@router.post("", response_model=AgentRead, status_code=201)
|
|
async def create_agent(
|
|
body: AgentCreate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> AgentRead:
|
|
agent = Agent(name=body.name, url=body.url.rstrip("/"), token=body.token)
|
|
session.add(agent)
|
|
session.commit()
|
|
session.refresh(agent)
|
|
# Validate connectivity immediately (best-effort; agent is saved regardless).
|
|
await agent_service.ping(session, agent)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.create", target=agent.name,
|
|
detail=agent.url, ip=_ip(request),
|
|
)
|
|
return _to_read(agent)
|
|
|
|
|
|
@router.put("/{agent_id}", response_model=AgentRead)
|
|
async def update_agent(
|
|
agent_id: int,
|
|
body: AgentUpdate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> AgentRead:
|
|
agent = _get_or_404(session, agent_id)
|
|
if body.name is not None:
|
|
agent.name = body.name
|
|
if body.url is not None:
|
|
agent.url = body.url.rstrip("/")
|
|
if body.token:
|
|
agent.token = body.token
|
|
agent.status = "unknown"
|
|
session.add(agent)
|
|
session.commit()
|
|
session.refresh(agent)
|
|
await agent_service.ping(session, agent)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.update", target=agent.name,
|
|
ip=_ip(request),
|
|
)
|
|
return _to_read(agent)
|
|
|
|
|
|
@router.delete("/{agent_id}")
|
|
def delete_agent(
|
|
agent_id: int,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
name = agent.name
|
|
session.delete(agent)
|
|
session.commit()
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.delete", target=name, ip=_ip(request),
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/{agent_id}/ping")
|
|
async def ping_agent(
|
|
agent_id: int,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await agent_service.ping(session, agent)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Proxied stack + system operations
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("/{agent_id}/system")
|
|
async def agent_system(
|
|
agent_id: int,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
):
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", "/agent/system")
|
|
|
|
|
|
@router.get("/{agent_id}/stacks")
|
|
async def agent_stacks(
|
|
agent_id: int,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> list[dict]:
|
|
agent = _get_or_404(session, agent_id)
|
|
stacks = await _proxy(session, agent, "GET", "/agent/stacks") or []
|
|
for s in stacks:
|
|
s["agent_id"] = agent.id
|
|
s["agent_name"] = agent.name
|
|
return stacks
|
|
|
|
|
|
@router.get("/{agent_id}/stacks/stats")
|
|
async def agent_stacks_stats(
|
|
agent_id: int,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", "/agent/stacks/stats")
|
|
|
|
|
|
@router.get("/{agent_id}/stacks/updates")
|
|
async def agent_stacks_updates(
|
|
agent_id: int,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", "/agent/stacks/updates")
|
|
|
|
|
|
@router.get("/{agent_id}/stacks/{stack_id}")
|
|
async def agent_stack_detail(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
data = await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}")
|
|
# Withhold the .env from the read-only role, exactly as the local
|
|
# GET /api/stacks/{id} does.
|
|
if user.role != "admin" and isinstance(data, dict):
|
|
data["env"] = ""
|
|
data["agent_id"] = agent.id
|
|
data["agent_name"] = agent.name
|
|
return data
|
|
|
|
|
|
@router.get("/{agent_id}/stacks/{stack_id}/logs")
|
|
async def agent_stack_logs(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
tail: int = Query(200, le=2000),
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(
|
|
session, agent, "GET", f"/agent/stacks/{stack_id}/logs", params={"tail": tail}
|
|
)
|
|
|
|
|
|
@router.post("/{agent_id}/stacks", status_code=201)
|
|
async def agent_create_stack(
|
|
agent_id: int,
|
|
body: StackCreate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(
|
|
session, agent, "POST", "/agent/stacks",
|
|
json={"name": body.name, "yaml": body.yaml, "env": body.env},
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.stack.create",
|
|
target=f"{agent.name}/{result.get('id')}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.put("/{agent_id}/stacks/{stack_id}")
|
|
async def agent_update_stack(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
body: StackUpdate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(
|
|
session, agent, "PUT", f"/agent/stacks/{stack_id}",
|
|
json={"yaml": body.yaml, "env": body.env},
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.stack.update",
|
|
target=f"{agent.name}/{stack_id}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.delete("/{agent_id}/stacks/{stack_id}")
|
|
async def agent_delete_stack(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
request: Request,
|
|
delete_files: bool = Query(True),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(
|
|
session, agent, "DELETE", f"/agent/stacks/{stack_id}",
|
|
params={"delete_files": delete_files},
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.stack.delete",
|
|
target=f"{agent.name}/{stack_id}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/stacks/{stack_id}/{action}")
|
|
async def agent_lifecycle(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
action: str,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
if action not in _ACTIONS:
|
|
raise HTTPException(status_code=400, detail=f"Unknown action '{action}'")
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", f"/agent/stacks/{stack_id}/{action}")
|
|
audit_service.record(
|
|
session, user=user.username, action=f"agent.stack.{action}",
|
|
target=f"{agent.name}/{stack_id}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Backup / restore of remote stacks (streamed agent <-> main <-> destination)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("/{agent_id}/stacks/{stack_id}/backup/inventory")
|
|
async def agent_backup_inventory(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}/backup/inventory")
|
|
|
|
|
|
@router.get("/{agent_id}/stacks/{stack_id}/backup")
|
|
async def agent_backup_download(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
request: Request,
|
|
include_volumes: bool = Query(True),
|
|
include_binds: bool = Query(True),
|
|
stop_first: bool = Query(True),
|
|
binds: list[str] | None = Query(None),
|
|
volumes: list[str] | None = Query(None),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
):
|
|
agent = _get_or_404(session, agent_id)
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
|
|
tmp.close()
|
|
try:
|
|
await agent_service.download_to_file(
|
|
session, agent, f"/agent/stacks/{stack_id}/backup", tmp.name,
|
|
params={
|
|
"include_volumes": include_volumes,
|
|
"include_binds": include_binds,
|
|
"stop_first": stop_first,
|
|
**({"binds": binds} if binds else {}),
|
|
**({"volumes": volumes} if volumes else {}),
|
|
},
|
|
)
|
|
except AgentError as exc:
|
|
if os.path.exists(tmp.name):
|
|
os.unlink(tmp.name)
|
|
_raise(exc)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.stack.backup",
|
|
target=f"{agent.name}/{stack_id}", ip=_ip(request),
|
|
)
|
|
fname = backup_service.backup_filename(
|
|
stack_id, include_volumes, prefix=compose_service.slugify(agent.name)
|
|
)
|
|
return FileResponse(
|
|
tmp.name, media_type="application/gzip", filename=fname,
|
|
background=BackgroundTask(os.unlink, tmp.name),
|
|
)
|
|
|
|
|
|
@router.post("/{agent_id}/stacks/{stack_id}/backup/push")
|
|
async def agent_backup_push(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
body: AgentPushBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
dest = _get_dest(session, body.destination_id)
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
|
|
tmp.close()
|
|
try:
|
|
try:
|
|
await agent_service.download_to_file(
|
|
session, agent, f"/agent/stacks/{stack_id}/backup", tmp.name,
|
|
params={
|
|
"include_volumes": body.include_volumes,
|
|
"include_binds": body.include_binds,
|
|
"stop_first": body.stop_first,
|
|
**({"binds": body.binds} if body.binds else {}),
|
|
**({"volumes": body.volumes} if body.volumes else {}),
|
|
},
|
|
)
|
|
except AgentError as exc:
|
|
_raise(exc)
|
|
fname = backup_service.backup_filename(
|
|
stack_id, body.include_volumes, prefix=compose_service.slugify(agent.name)
|
|
)
|
|
try:
|
|
await asyncio.to_thread(dest_service.upload, dest, tmp.name, fname)
|
|
except dest_service.DestinationError as exc:
|
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
finally:
|
|
if os.path.exists(tmp.name):
|
|
os.unlink(tmp.name)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.stack.backup.push",
|
|
target=f"{agent.name}/{stack_id}", detail=f"{dest.name}:{fname}", ip=_ip(request),
|
|
)
|
|
return {"ok": True, "destination": dest.name, "name": fname}
|
|
|
|
|
|
@router.post("/{agent_id}/stacks/restore")
|
|
async def agent_restore_upload(
|
|
agent_id: int,
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
target_id: str | None = Form(None),
|
|
overwrite: bool = Form(False),
|
|
restore_volumes: bool = Form(True),
|
|
restore_binds: bool = Form(True),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
|
|
try:
|
|
while chunk := await file.read(1024 * 1024):
|
|
tmp.write(chunk)
|
|
tmp.close()
|
|
result = await agent_service.upload_file(
|
|
session, agent, "/agent/stacks/restore", tmp.name,
|
|
file.filename or "backup.tar.gz",
|
|
{
|
|
"target_id": target_id or "",
|
|
"overwrite": str(overwrite).lower(),
|
|
"restore_volumes": str(restore_volumes).lower(),
|
|
"restore_binds": str(restore_binds).lower(),
|
|
},
|
|
)
|
|
except AgentError as exc:
|
|
_raise(exc)
|
|
finally:
|
|
if os.path.exists(tmp.name):
|
|
os.unlink(tmp.name)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.stack.restore",
|
|
target=f"{agent.name}/{result.get('stack_id')}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/stacks/restore-from")
|
|
async def agent_restore_from(
|
|
agent_id: int,
|
|
body: AgentRestoreFromBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
dest = _get_dest(session, body.destination_id)
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
|
|
tmp.close()
|
|
try:
|
|
try:
|
|
await asyncio.to_thread(dest_service.download, dest, body.name, tmp.name)
|
|
except dest_service.DestinationError as exc:
|
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
result = await agent_service.upload_file(
|
|
session, agent, "/agent/stacks/restore", tmp.name, body.name,
|
|
{
|
|
"target_id": body.target_id or "",
|
|
"overwrite": str(body.overwrite).lower(),
|
|
"restore_volumes": str(body.restore_volumes).lower(),
|
|
"restore_binds": str(body.restore_binds).lower(),
|
|
},
|
|
)
|
|
except AgentError as exc:
|
|
_raise(exc)
|
|
finally:
|
|
if os.path.exists(tmp.name):
|
|
os.unlink(tmp.name)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.stack.restore",
|
|
target=f"{agent.name}/{result.get('stack_id')}", detail=f"from {dest.name}:{body.name}",
|
|
ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Networks (proxied)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("/{agent_id}/networks")
|
|
async def agent_networks(
|
|
agent_id: int,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> list[dict]:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", "/agent/networks") or []
|
|
|
|
|
|
@router.get("/{agent_id}/networks/{network_id}")
|
|
async def agent_network_inspect(
|
|
agent_id: int,
|
|
network_id: str,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", f"/agent/networks/{network_id}")
|
|
|
|
|
|
@router.get("/{agent_id}/networks/{network_id}/containers")
|
|
async def agent_network_containers(
|
|
agent_id: int,
|
|
network_id: str,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> list[dict]:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", f"/agent/networks/{network_id}/containers") or []
|
|
|
|
|
|
@router.post("/{agent_id}/networks/prune")
|
|
async def agent_network_prune(
|
|
agent_id: int,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", "/agent/networks/prune")
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.network.prune", target=agent.name,
|
|
detail=str(result.get("NetworksDeleted") or []), ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/networks", status_code=201)
|
|
async def agent_network_create(
|
|
agent_id: int,
|
|
body: NetworkCreate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", "/agent/networks", json=body.model_dump())
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.network.create",
|
|
target=f"{agent.name}/{body.name}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.delete("/{agent_id}/networks/{network_id}")
|
|
async def agent_network_delete(
|
|
agent_id: int,
|
|
network_id: str,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "DELETE", f"/agent/networks/{network_id}")
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.network.delete",
|
|
target=f"{agent.name}/{network_id}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/networks/{network_id}/connect")
|
|
async def agent_network_connect(
|
|
agent_id: int,
|
|
network_id: str,
|
|
body: ContainerRef,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(
|
|
session, agent, "POST", f"/agent/networks/{network_id}/connect", json=body.model_dump()
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.network.connect",
|
|
target=f"{agent.name}/{network_id}", detail=body.container, ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/networks/{network_id}/disconnect")
|
|
async def agent_network_disconnect(
|
|
agent_id: int,
|
|
network_id: str,
|
|
body: ContainerRef,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(
|
|
session, agent, "POST", f"/agent/networks/{network_id}/disconnect", json=body.model_dump()
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.network.disconnect",
|
|
target=f"{agent.name}/{network_id}", detail=body.container, ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Images (proxied)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("/{agent_id}/images")
|
|
async def agent_images(
|
|
agent_id: int,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> list[dict]:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", "/agent/images") or []
|
|
|
|
|
|
@router.get("/{agent_id}/images/updates")
|
|
async def agent_image_updates(
|
|
agent_id: int,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", "/agent/images/updates")
|
|
|
|
|
|
@router.post("/{agent_id}/images/check")
|
|
async def agent_image_check(
|
|
agent_id: int,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", "/agent/images/check")
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.image.check", target=agent.name,
|
|
ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/images/prune")
|
|
async def agent_image_prune(
|
|
agent_id: int,
|
|
request: Request,
|
|
all_unused: bool = Query(False, alias="all"),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(
|
|
session, agent, "POST", "/agent/images/prune", params={"all": all_unused}
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.image.prune", target=agent.name,
|
|
detail=f"all={all_unused} reclaimed={(result or {}).get('SpaceReclaimed')}",
|
|
ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Volumes (proxied)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("/{agent_id}/volumes")
|
|
async def agent_volumes(
|
|
agent_id: int,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> list[dict]:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", "/agent/volumes") or []
|
|
|
|
|
|
@router.get("/{agent_id}/volumes/sizes")
|
|
async def agent_volume_sizes(
|
|
agent_id: int,
|
|
force: bool = Query(False),
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(
|
|
session, agent, "GET", "/agent/volumes/sizes", params={"force": force}
|
|
)
|
|
|
|
|
|
@router.post("/{agent_id}/volumes/prune")
|
|
async def agent_volumes_prune(
|
|
agent_id: int,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", "/agent/volumes/prune")
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.volume.prune", target=agent.name,
|
|
detail=str(result.get("VolumesDeleted") or []), ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.delete("/{agent_id}/volumes/{name}")
|
|
async def agent_volume_delete(
|
|
agent_id: int,
|
|
name: str,
|
|
request: Request,
|
|
force: bool = Query(False),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(
|
|
session, agent, "DELETE", f"/agent/volumes/{name}", params={"force": force}
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.volume.delete",
|
|
target=f"{agent.name}/{name}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# File browser (proxied)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("/{agent_id}/files/list")
|
|
async def agent_files_list(
|
|
agent_id: int,
|
|
path: str = Query("/"),
|
|
show_hidden: bool = Query(False),
|
|
session: Session = Depends(get_session),
|
|
_admin: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(
|
|
session, agent, "GET", "/agent/files/list",
|
|
params={"path": path, "show_hidden": show_hidden},
|
|
)
|
|
|
|
|
|
@router.get("/{agent_id}/files/read")
|
|
async def agent_files_read(
|
|
agent_id: int,
|
|
request: Request,
|
|
path: str = Query(...),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "GET", "/agent/files/read", params={"path": path})
|
|
audit_service.record(
|
|
session, user=user.username, action="file.read",
|
|
target=f"{agent.name}:{path}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.get("/{agent_id}/files/download")
|
|
async def agent_files_download(
|
|
agent_id: int,
|
|
request: Request,
|
|
path: str = Query(...),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
):
|
|
agent = _get_or_404(session, agent_id)
|
|
audit_service.record(
|
|
session, user=user.username, action="file.download",
|
|
target=f"{agent.name}:{path}", ip=_ip(request),
|
|
)
|
|
# Stream the agent's response straight through (works for single files and
|
|
# for on-the-fly folder zips), so nothing is staged to disk and the
|
|
# download starts immediately. Pull the first chunk eagerly so a failed
|
|
# agent (offline / bad token / 404) still surfaces a clean HTTP status
|
|
# before we commit to a 200 streaming response.
|
|
chunks = agent_service.stream_download(
|
|
session, agent, "/agent/files/download", params={"path": path}
|
|
)
|
|
try:
|
|
first = await chunks.__anext__()
|
|
except StopAsyncIteration:
|
|
first = b""
|
|
except AgentError as exc:
|
|
_raise(exc)
|
|
|
|
async def body():
|
|
yield first
|
|
async for chunk in chunks:
|
|
yield chunk
|
|
|
|
return StreamingResponse(
|
|
body(),
|
|
media_type="application/octet-stream",
|
|
headers={"Content-Disposition": f'attachment; filename="{os.path.basename(path)}"'},
|
|
)
|
|
|
|
|
|
@router.put("/{agent_id}/files/write")
|
|
async def agent_files_write(
|
|
agent_id: int,
|
|
body: WriteBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "PUT", "/agent/files/write", json=body.model_dump())
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.file.write",
|
|
target=f"{agent.name}:{body.path}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/files/mkdir")
|
|
async def agent_files_mkdir(
|
|
agent_id: int,
|
|
body: NameBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", "/agent/files/mkdir", json=body.model_dump())
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.file.mkdir",
|
|
target=f"{agent.name}:{result.get('path')}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/files/touch")
|
|
async def agent_files_touch(
|
|
agent_id: int,
|
|
body: NameBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", "/agent/files/touch", json=body.model_dump())
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.file.create",
|
|
target=f"{agent.name}:{result.get('path')}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/files/rename")
|
|
async def agent_files_rename(
|
|
agent_id: int,
|
|
body: RenameBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", "/agent/files/rename", json=body.model_dump())
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.file.rename",
|
|
target=f"{agent.name}:{body.path}", detail=f"-> {result.get('path')}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/files/copy")
|
|
async def agent_files_copy(
|
|
agent_id: int,
|
|
body: TransferBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", "/agent/files/copy", json=body.model_dump())
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.file.copy",
|
|
target=f"{agent.name}:{body.src}", detail=f"-> {result.get('path')}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/files/move")
|
|
async def agent_files_move(
|
|
agent_id: int,
|
|
body: TransferBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", "/agent/files/move", json=body.model_dump())
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.file.move",
|
|
target=f"{agent.name}:{body.src}", detail=f"-> {result.get('path')}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.delete("/{agent_id}/files")
|
|
async def agent_files_delete(
|
|
agent_id: int,
|
|
request: Request,
|
|
path: str = Query(...),
|
|
recursive: bool = Query(False),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(
|
|
session, agent, "DELETE", "/agent/files", params={"path": path, "recursive": recursive}
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.file.delete",
|
|
target=f"{agent.name}:{path}", detail="recursive" if recursive else None, ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/files/upload")
|
|
async def agent_files_upload(
|
|
agent_id: int,
|
|
request: Request,
|
|
path: str = Form(...),
|
|
overwrite: bool = Form(False),
|
|
rel_path: str = Form(""),
|
|
file: UploadFile = File(...),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
tmp = tempfile.NamedTemporaryFile(delete=False)
|
|
try:
|
|
while chunk := await file.read(1024 * 1024):
|
|
tmp.write(chunk)
|
|
tmp.close()
|
|
result = await agent_service.upload_file(
|
|
session, agent, "/agent/files/upload", tmp.name, file.filename or "upload",
|
|
{"path": path, "overwrite": str(overwrite).lower(), "rel_path": rel_path},
|
|
)
|
|
except AgentError as exc:
|
|
_raise(exc)
|
|
finally:
|
|
if os.path.exists(tmp.name):
|
|
os.unlink(tmp.name)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.file.upload",
|
|
target=f"{agent.name}:{path}", detail=rel_path or file.filename, ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Containers (proxied)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("/{agent_id}/containers/{container_id}")
|
|
async def agent_container_inspect(
|
|
agent_id: int,
|
|
container_id: str,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", f"/agent/containers/{container_id}")
|
|
|
|
|
|
@router.post("/{agent_id}/containers/{container_id}/{action}")
|
|
async def agent_container_action(
|
|
agent_id: int,
|
|
container_id: str,
|
|
action: str,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(
|
|
session, agent, "POST", f"/agent/containers/{container_id}/{action}"
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action=f"agent.container.{action}",
|
|
target=f"{agent.name}/{container_id[:12]}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Auto-update policy (Watchtower-style) — remote stacks (stored centrally)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("/{agent_id}/stacks/{stack_id}/auto-update", response_model=AutoUpdateRead)
|
|
def agent_get_auto_update(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
_get_or_404(session, agent_id)
|
|
policy = auto_update_service.get_policy(session, stack_id, agent_id)
|
|
return auto_update_service.to_read(session, policy, stack_id, agent_id)
|
|
|
|
|
|
@router.put("/{agent_id}/stacks/{stack_id}/auto-update", response_model=AutoUpdateRead)
|
|
def agent_set_auto_update(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
body: AutoUpdateWrite,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
policy = auto_update_service.upsert_policy(
|
|
session, stack_id, body.enabled, body.redeploy, agent_id=agent_id
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.stack.auto_update",
|
|
target=f"{agent.name}/{stack_id}",
|
|
detail=f"enabled={body.enabled} redeploy={body.redeploy}", ip=_ip(request),
|
|
)
|
|
return auto_update_service.to_read(session, policy, stack_id, agent_id)
|
|
|
|
|
|
@router.post("/{agent_id}/stacks/{stack_id}/auto-update/run", response_model=AutoUpdateRead)
|
|
async def agent_run_auto_update(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
_get_or_404(session, agent_id)
|
|
policy = auto_update_service.get_policy(session, stack_id, agent_id)
|
|
if policy is None:
|
|
raise HTTPException(status_code=404, detail="No auto-update policy for this stack")
|
|
await auto_update_service.run_policy(session, policy)
|
|
session.refresh(policy)
|
|
return auto_update_service.to_read(session, policy, stack_id, agent_id)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Secrets & configs (proxied) — remote stacks
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("/{agent_id}/stacks/{stack_id}/secrets")
|
|
async def agent_list_secrets(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(require_admin),
|
|
) -> list:
|
|
agent = _get_or_404(session, agent_id)
|
|
return await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}/secrets") or []
|
|
|
|
|
|
@router.put("/{agent_id}/stacks/{stack_id}/secrets")
|
|
async def agent_write_secret(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
body: SecretWrite,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "PUT", f"/agent/stacks/{stack_id}/secrets", json=body.model_dump())
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.secret.write",
|
|
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.delete("/{agent_id}/stacks/{stack_id}/secrets/{kind}/{name}")
|
|
async def agent_delete_secret(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
kind: str,
|
|
name: str,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "DELETE", f"/agent/stacks/{stack_id}/secrets/{kind}/{name}")
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.secret.delete",
|
|
target=f"{agent.name}/{stack_id}/{kind}/{name}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/stacks/{stack_id}/secrets/attach")
|
|
async def agent_attach_secret(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
body: AttachBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", f"/agent/stacks/{stack_id}/secrets/attach", json=body.model_dump())
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.secret.attach",
|
|
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{agent_id}/stacks/{stack_id}/secrets/detach")
|
|
async def agent_detach_secret(
|
|
agent_id: int,
|
|
stack_id: str,
|
|
body: DetachBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
agent = _get_or_404(session, agent_id)
|
|
result = await _proxy(session, agent, "POST", f"/agent/stacks/{stack_id}/secrets/detach", json=body.model_dump())
|
|
audit_service.record(
|
|
session, user=user.username, action="agent.secret.detach",
|
|
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
|
|
)
|
|
return result
|