feat(terminal+vm): POST /terminal/exec whitelist + GET /vm/ssh-key

This commit is contained in:
chrischristiansen-glitch 2026-06-13 17:15:22 +02:00
parent beb466eab0
commit c7e7b28b68

171
main.py
View File

@ -15,6 +15,8 @@ OQ-29: GET /notify/channels — list konfigurerte delivery-kanaler (env-var-base
OQ-31: GET /opax/build-status ekte Cloud Build-status via REST API. OQ-31: GET /opax/build-status ekte Cloud Build-status via REST API.
CG4-credits: GET /billing/credits kreditt-saldo, burn rate og tom-dato. CG4-credits: GET /billing/credits kreditt-saldo, burn rate og tom-dato.
Krever GOOGLE_CREDIT_TOTAL_USD i Cloud Run env for runway-beregning. Krever GOOGLE_CREDIT_TOTAL_USD i Cloud Run env for runway-beregning.
TERMINAL: POST /terminal/exec whitelisted kommandoer: health, billing, build, logs, help.
VM: GET /vm/ssh-key henter public key fra Compute Engine metadata.
""" """
import os import os
@ -194,32 +196,156 @@ async def build_status():
return JSONResponse({"status": "error", "message": str(e)}, status_code=200) return JSONResponse({"status": "error", "message": str(e)}, status_code=200)
# ── TERMINAL: POST /terminal/exec ─────────────────────────────────────────────
TERMINAL_WHITELIST = {"health", "billing", "build", "logs", "help"}
class TerminalExecRequest(BaseModel):
cmd: str
@app.post("/terminal/exec")
async def terminal_exec(req: TerminalExecRequest):
"""
Whitelisted terminal-kommandoer:
health GET /health internt
billing GET /billing/summary
build GET /opax/build-status
logs siste 20 linjer fra Cloud Logging
help returner kommandoliste
"""
cmd = req.cmd.strip().lower().split()[0] if req.cmd.strip() else ""
if cmd not in TERMINAL_WHITELIST:
return JSONResponse(
{"output": f"Ukjent kommando: '{req.cmd}'. Lov: {', '.join(sorted(TERMINAL_WHITELIST))}", "exit_code": 1}
)
try:
if cmd == "help":
lines = [
"Tilgjengelige kommandoer:",
" health — sjekk /health",
" billing — hent billing summary",
" build — hent siste build-status",
" logs — siste 20 linjer fra Cloud Logging",
" help — vis denne listen",
]
return {"output": "\n".join(lines), "exit_code": 0}
if cmd == "health":
return {"output": json.dumps({"status": "ok"}, ensure_ascii=False), "exit_code": 0}
if cmd == "billing":
try:
summary = BillingAgent().get_summary()
return {"output": json.dumps(summary, ensure_ascii=False, indent=2, default=str), "exit_code": 0}
except Exception as e:
return {"output": f"billing feilet: {e}", "exit_code": 1}
if cmd == "build":
try:
creds, project = google.auth.default()
creds.refresh(google.auth.transport.requests.Request())
url = f"https://cloudbuild.googleapis.com/v1/projects/{project}/builds?pageSize=5"
r = httpx.get(url, headers={"Authorization": f"Bearer {creds.token}"}, timeout=10)
builds = r.json().get("builds", [])
result = [{"id": b["id"], "status": b["status"],
"branch": b.get("substitutions", {}).get("BRANCH_NAME", ""),
"createTime": b["createTime"]} for b in builds]
return {"output": json.dumps(result, ensure_ascii=False, indent=2, default=str), "exit_code": 0}
except Exception as e:
return {"output": f"build feilet: {e}", "exit_code": 1}
if cmd == "logs":
try:
creds, project = google.auth.default()
creds.refresh(google.auth.transport.requests.Request())
log_filter = (
'resource.type="cloud_run_revision" '
'resource.labels.service_name="osvauco-agent" '
'severity>=DEFAULT'
)
body = {
"resourceNames": [f"projects/{project}"],
"filter": log_filter,
"orderBy": "timestamp desc",
"pageSize": 20,
}
r = httpx.post(
"https://logging.googleapis.com/v2/entries:list",
headers={"Authorization": f"Bearer {creds.token}"},
json=body,
timeout=10,
)
entries = r.json().get("entries", [])
if not entries:
return {"output": "(ingen logglinjer funnet)", "exit_code": 0}
lines = []
for e in reversed(entries):
ts = e.get("timestamp", "")[:19].replace("T", " ")
msg = e.get("textPayload") or json.dumps(e.get("jsonPayload", {}), ensure_ascii=False)
sev = e.get("severity", "")
lines.append(f"[{ts}] {sev:<8} {msg}")
return {"output": "\n".join(lines), "exit_code": 0}
except Exception as e:
return {"output": f"logs feilet: {e}", "exit_code": 1}
except Exception as e:
return {"output": f"Intern feil: {e}", "exit_code": 1}
return {"output": "", "exit_code": 0}
# ── VM: GET /vm/ssh-key ────────────────────────────────────────────────────────
@app.get("/vm/ssh-key")
async def vm_ssh_key(instance: str = "osvauco-dev-vm"):
"""
Henter public SSH-nøkkel fra Compute Engine instance metadata.
Bruker google.auth ADC krever compute.instances.get service account.
"""
try:
creds, project = google.auth.default()
creds.refresh(google.auth.transport.requests.Request())
zone = "us-central1-b"
url = (
f"https://compute.googleapis.com/compute/v1/projects/{project}"
f"/zones/{zone}/instances/{instance}"
)
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(url, headers={"Authorization": f"Bearer {creds.token}"})
if r.status_code == 404:
raise HTTPException(status_code=404, detail=f"Instans ikke funnet: {instance}")
if r.status_code != 200:
raise HTTPException(status_code=r.status_code, detail=f"Compute API feil: {r.text[:200]}")
data = r.json()
# SSH-nøkler ligger i metadata items med key "ssh-keys"
metadata_items = data.get("metadata", {}).get("items", [])
ssh_keys_raw = next(
(item["value"] for item in metadata_items if item["key"] == "ssh-keys"),
None
)
if not ssh_keys_raw:
return {"public_key": None, "instance": instance, "detail": "Ingen SSH-nøkkel funnet i metadata"}
# Format: "user:ssh-rsa AAAA..." — returner kun public key-delen
public_key = ssh_keys_raw.strip()
if ":" in public_key:
public_key = public_key.split(":", 1)[1].strip()
return {"public_key": public_key, "instance": instance}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"vm/ssh-key feilet: {e}")
# ── OQ-29: GET /notify/channels — konfigurerte delivery-kanaler ─────────────── # ── OQ-29: GET /notify/channels — konfigurerte delivery-kanaler ───────────────
@app.get("/notify/channels") @app.get("/notify/channels")
async def get_notify_channels(): async def get_notify_channels():
"""
OQ-29 Returnerer hvilke varslings-kanaler som er konfigurert
basert env-vars i Cloud Run. IAP-beskyttet (ingen exempt).
Test: curl http://localhost:8080/notify/channels | python3 -m json.tool
"""
return { return {
"channels": [ "channels": [
{ {"type": "email", "configured": bool(os.getenv("SENDGRID_API_KEY")), "target": os.getenv("NOTIFY_EMAIL_TO", None)},
"type": "email", {"type": "sms", "configured": bool(os.getenv("TWILIO_ACCOUNT_SID")), "target": os.getenv("TWILIO_FROM_NUMBER", None)},
"configured": bool(os.getenv("SENDGRID_API_KEY")), {"type": "webhook", "configured": bool(os.getenv("NOTIFY_WEBHOOK_URL")), "url": os.getenv("NOTIFY_WEBHOOK_URL", None)},
"target": os.getenv("NOTIFY_EMAIL_TO", None),
},
{
"type": "sms",
"configured": bool(os.getenv("TWILIO_ACCOUNT_SID")),
"target": os.getenv("TWILIO_FROM_NUMBER", None),
},
{
"type": "webhook",
"configured": bool(os.getenv("NOTIFY_WEBHOOK_URL")),
"url": os.getenv("NOTIFY_WEBHOOK_URL", None),
},
] ]
} }
@ -630,9 +756,6 @@ app.add_api_route("/billing/anomalies", endpoint=require_auth(authenticated_bill
# ── CG4-credits: GET /billing/credits ──────────────────────────────────────── # ── CG4-credits: GET /billing/credits ────────────────────────────────────────
async def authenticated_billing_credits(request: Request, days: int = 90): async def authenticated_billing_credits(request: Request, days: int = 90):
"""
CG4-credits Kreditt-saldo, burn rate og estimert tom-dato.
"""
try: try:
return JSONResponse(BillingAgent().get_credits_status(days=days)) return JSONResponse(BillingAgent().get_credits_status(days=days))
except Exception as exc: except Exception as exc: