From c7e7b28b683304c3daf1cb27a21894a6102f7774 Mon Sep 17 00:00:00 2001 From: chrischristiansen-glitch Date: Sat, 13 Jun 2026 17:15:22 +0200 Subject: [PATCH] feat(terminal+vm): POST /terminal/exec whitelist + GET /vm/ssh-key --- main.py | 171 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 147 insertions(+), 24 deletions(-) diff --git a/main.py b/main.py index bdad718..9e3c5b2 100644 --- a/main.py +++ b/main.py @@ -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. ✅ CG4-credits: GET /billing/credits — kreditt-saldo, burn rate og tom-dato. 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 @@ -194,32 +196,156 @@ async def build_status(): 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 på 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 ─────────────── @app.get("/notify/channels") async def get_notify_channels(): - """ - OQ-29 ✅ — Returnerer hvilke varslings-kanaler som er konfigurert - basert på env-vars i Cloud Run. IAP-beskyttet (ingen exempt). - - Test: curl http://localhost:8080/notify/channels | python3 -m json.tool - """ return { "channels": [ - { - "type": "email", - "configured": bool(os.getenv("SENDGRID_API_KEY")), - "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), - }, + {"type": "email", "configured": bool(os.getenv("SENDGRID_API_KEY")), "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 ──────────────────────────────────────── async def authenticated_billing_credits(request: Request, days: int = 90): - """ - CG4-credits — Kreditt-saldo, burn rate og estimert tom-dato. - """ try: return JSONResponse(BillingAgent().get_credits_status(days=days)) except Exception as exc: