"""OCI config routes — CRUD, test, OCI CLI terminal (execute, history, completions).""" import asyncio import json import os import re import shlex import shutil import subprocess import uuid from functools import partial from pathlib import Path from typing import Optional from fastapi import ( APIRouter, HTTPException, Depends, UploadFile, File, Form, Query, ) from config import OCI_DIR, _chat_executor, log from database import db from auth.crypto import _enc, _dec, _mask, _safe_dec from auth.jwt_auth import current_user, require, _audit, _config_log, _verify_config_access from utils import validate_upload router = APIRouter() # ── OCI Config ──────────────────────────────────────────────────────────────── @router.post("/api/oci/config") async def save_oci( tenancy_name: str = Form(...), tenancy_ocid: str = Form(...), user_ocid: str = Form(""), fingerprint: str = Form(""), region: str = Form(...), compartment_id: str = Form(""), key_passphrase: str = Form(""), ssh_public_key: str = Form(""), auth_type: str = Form("api_key"), security_token: str = Form(""), private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None), u = Depends(require("admin","user")) ): if auth_type not in ("api_key", "session_token"): raise HTTPException(400, "auth_type deve ser 'api_key' ou 'session_token'") _PEM_EXTS = {".pem", ".key"} if private_key and private_key.filename: await validate_upload(private_key, _PEM_EXTS) private_key.file.seek(0) if public_key and public_key.filename: await validate_upload(public_key, _PEM_EXTS) public_key.file.seek(0) cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True) kp = cdir / "oci_api_key.pem" if auth_type == "session_token": # Session token auth: requires tenancy_ocid, region, token, and session key if not security_token.strip(): shutil.rmtree(cdir, ignore_errors=True) raise HTTPException(400, "Session Token é obrigatório para autenticação por token.") if not private_key or not private_key.filename: shutil.rmtree(cdir, ignore_errors=True) raise HTTPException(400, "A chave de sessão (.pem) é obrigatória para autenticação por token.") key_bytes = await private_key.read() kp.write_bytes(key_bytes); kp.chmod(0o600) # Save security token file token_file = cdir / "token" token_file.write_text(security_token.strip()) token_file.chmod(0o600) # Generate config cfg_content = (f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\n" f"key_file={kp}\nsecurity_token_file={token_file}\n") if user_ocid: cfg_content = f"[DEFAULT]\nuser={user_ocid}\n" + cfg_content.replace("[DEFAULT]\n", "") cfg_file = cdir / "config" cfg_file.write_text(cfg_content); cfg_file.chmod(0o600) # For session_token, fingerprint/user may not be provided — use placeholders user_ocid = user_ocid or "session_token_user" fingerprint = fingerprint or "session_token" else: # API Key auth (original flow) if not user_ocid or not fingerprint: shutil.rmtree(cdir, ignore_errors=True) raise HTTPException(400, "OCID User e Fingerprint são obrigatórios para API Key.") if not private_key or not private_key.filename: shutil.rmtree(cdir, ignore_errors=True) raise HTTPException(400, "Selecione a chave privada (.pem).") key_bytes = await private_key.read() kp.write_bytes(key_bytes); kp.chmod(0o600) key_text = key_bytes.decode("utf-8", errors="ignore") key_is_encrypted = "ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text if key_is_encrypted and not key_passphrase: shutil.rmtree(cdir, ignore_errors=True) raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.") if public_key and public_key.filename: (cdir / "oci_api_key_public.pem").write_bytes(await public_key.read()) cfg_file = cdir / "config" cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n" f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n") if key_passphrase: cfg_content += f"pass_phrase={key_passphrase}\n" cfg_file.write_text(cfg_content); cfg_file.chmod(0o600) with db() as c: c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id,ssh_public_key,auth_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (cid, u["id"], tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, str(kp), _enc(compartment_id) if compartment_id else None, ssh_public_key.strip() if ssh_public_key.strip() else None, auth_type)) _audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}") _config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"]) return {"id": cid, "tenancy_name": tenancy_name, "region": region} @router.get("/api/oci/configs") async def list_oci(u=Depends(current_user)): with db() as c: cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,ssh_public_key,auth_type,created_at" if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM oci_configs").fetchall() else: rows=c.execute(f"SELECT {cols} FROM oci_configs WHERE user_id=?",(u["id"],)).fetchall() result = [] for r in rows: d = dict(r) # Decrypt and mask sensitive fields for display try: d["tenancy_ocid"] = _mask(_dec(d["tenancy_ocid"])) except Exception: d["tenancy_ocid"] = _mask(d["tenancy_ocid"]) try: d["user_ocid"] = _mask(_dec(d["user_ocid"])) except Exception: d["user_ocid"] = _mask(d["user_ocid"]) d["fingerprint"] = "\u2022" * 20 # fingerprint fully masked if d.get("compartment_id"): try: d["compartment_id"] = _mask(_dec(d["compartment_id"])) except Exception: d["compartment_id"] = _mask(d["compartment_id"]) if d.get("ssh_public_key"): # Show just type + first/last few chars for identification parts = d["ssh_public_key"].split() d["ssh_public_key_preview"] = f"{parts[0]} ...{parts[1][-12:]}" if len(parts) >= 2 else "ssh-rsa ..." result.append(d) return result @router.delete("/api/oci/configs/{cid}") async def del_oci(cid: str, u=Depends(require("admin","user"))): with db() as c: cfg=c.execute("SELECT * FROM oci_configs WHERE id=?",(cid,)).fetchone() if not cfg: raise HTTPException(404) if u["role"]!="admin" and cfg["user_id"]!=u["id"]: raise HTTPException(403) c.execute("DELETE FROM oci_configs WHERE id=?",(cid,)) d=OCI_DIR/cid if d.exists(): shutil.rmtree(d) return {"ok": True} @router.put("/api/oci/configs/{cid}") async def update_oci( cid: str, tenancy_name: str = Form(...), tenancy_ocid: str = Form(""), user_ocid: str = Form(""), fingerprint: str = Form(""), region: str = Form(...), compartment_id: str = Form(""), key_passphrase: str = Form(""), ssh_public_key: str = Form(""), auth_type: str = Form(""), security_token: str = Form(""), private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None), u = Depends(require("admin","user")) ): _PEM_EXTS = {".pem", ".key"} if private_key and private_key.filename: await validate_upload(private_key, _PEM_EXTS) private_key.file.seek(0) if public_key and public_key.filename: await validate_upload(public_key, _PEM_EXTS) public_key.file.seek(0) with db() as c: existing = c.execute("SELECT * FROM oci_configs WHERE id=?", (cid,)).fetchone() if not existing: raise HTTPException(404) if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403) # Keep existing encrypted values if not provided (empty = keep current) tenancy_ocid = tenancy_ocid or _safe_dec(existing["tenancy_ocid"]) user_ocid = user_ocid or _safe_dec(existing["user_ocid"]) fingerprint = fingerprint or _safe_dec(existing["fingerprint"]) compartment_id = compartment_id or _safe_dec(existing["compartment_id"]) or "" try: existing_auth = existing["auth_type"] or "api_key" except (IndexError, KeyError): existing_auth = "api_key" auth_type = auth_type or existing_auth try: existing_ssh = existing["ssh_public_key"] or "" except (IndexError, KeyError): existing_ssh = "" ssh_public_key = ssh_public_key.strip() if ssh_public_key.strip() else existing_ssh cdir = OCI_DIR / cid; cdir.mkdir(parents=True, exist_ok=True) kp = cdir / "oci_api_key.pem" if private_key and private_key.filename: key_bytes = await private_key.read() kp.write_bytes(key_bytes); kp.chmod(0o600) key_text = key_bytes.decode("utf-8", errors="ignore") if auth_type == "api_key" and ("ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text) and not key_passphrase: raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.") if public_key and public_key.filename: (cdir / "oci_api_key_public.pem").write_bytes(await public_key.read()) # Update session token file if provided if security_token.strip(): token_file = cdir / "token" token_file.write_text(security_token.strip()); token_file.chmod(0o600) cfg_file = cdir / "config" if auth_type == "session_token": token_path = cdir / "token" cfg_content = f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n" if user_ocid and user_ocid != "session_token_user": cfg_content = f"[DEFAULT]\nuser={user_ocid}\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n" cfg_content += f"security_token_file={token_path}\n" else: cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n" f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n") if key_passphrase: cfg_content += f"pass_phrase={key_passphrase}\n" cfg_file.write_text(cfg_content); cfg_file.chmod(0o600) with db() as c: c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=?,ssh_public_key=?,auth_type=? WHERE id=?", (tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, ssh_public_key or None, auth_type, cid)) _audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}") _config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"]) return {"id": cid, "tenancy_name": tenancy_name, "region": region} @router.post("/api/oci/test/{cid}") async def test_oci(cid: str, u=Depends(require("admin","user"))): _verify_config_access("oci", cid, u) cp = OCI_DIR / cid / "config" cname = None with db() as c: row = c.execute("SELECT tenancy_name FROM oci_configs WHERE id=?", (cid,)).fetchone() if row: cname = row["tenancy_name"] if not cp.exists(): _config_log("oci", cid, cname, "error", "test", "Config não encontrada", u["id"], u["username"]) return {"status":"error","message":"Config não encontrada"} try: loop = asyncio.get_event_loop() r = await loop.run_in_executor(None, partial( subprocess.run, ["oci","iam","region","list","--config-file",str(cp),"--output","json"], capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL)) if r.returncode == 0: _config_log("oci", cid, cname, "success", "test", "Conexão OK", u["id"], u["username"]) return {"status":"success","message":"Conexão OK","data":json.loads(r.stdout)} msg = r.stderr[:500] if "passphrase" in msg.lower() or "getpass" in msg.lower(): msg = "A chave privada requer passphrase. Recadastre a credencial informando a Key Passphrase." _config_log("oci", cid, cname, "error", "test", msg, u["id"], u["username"]) return {"status":"error","message":msg} except FileNotFoundError: _config_log("oci", cid, cname, "error", "test", "OCI CLI não disponível no container.", u["id"], u["username"]) return {"status":"error","message":"OCI CLI não disponível no container."} except subprocess.TimeoutExpired: _config_log("oci", cid, cname, "error", "test", "Timeout na conexão", u["id"], u["username"]) return {"status":"error","message":"Timeout na conexão"} except Exception as e: _config_log("oci", cid, cname, "error", "test", str(e)[:500], u["id"], u["username"]) return {"status":"error","message":str(e)[:500]} # ── OCI CLI Terminal ───────────────────────────────────────────────────────── # OCID resource type → OCI CLI get command mapping _OCID_CMD_MAP = { "instance": "oci compute instance get --instance-id", "image": "oci compute image get --image-id", "vcn": "oci network vcn get --vcn-id", "subnet": "oci network subnet get --subnet-id", "securitylist": "oci network security-list get --security-list-id", "routetable": "oci network route-table get --rt-id", "internetgateway": "oci network internet-gateway get --ig-id", "natgateway": "oci network nat-gateway get --nat-gateway-id", "servicegateway": "oci network service-gateway get --service-gateway-id", "drg": "oci network drg get --drg-id", "drgattachment": "oci network drg-attachment get --drg-attachment-id", "localpeeringgateway": "oci network local-peering-gateway get --local-peering-gateway-id", "networksecuritygroup": "oci network nsg get --nsg-id", "vnic": "oci network vnic get --vnic-id", "privateip": "oci network private-ip get --private-ip-id", "publicip": "oci network public-ip get --public-ip-id", "dhcpoptions": "oci network dhcp-options get --dhcp-id", "cpe": "oci network cpe get --cpe-id", "ipsecconnection": "oci network ip-sec-connection get --ipsc-id", "volume": "oci bv volume get --volume-id", "bootvolume": "oci bv boot-volume get --boot-volume-id", "volumebackup": "oci bv backup get --volume-backup-id", "bootvolumereplica": "oci bv boot-volume-replica get --boot-volume-replica-id", "volumegroup": "oci bv volume-group get --volume-group-id", "bucket": "oci os bucket get --bucket-name", "compartment": "oci iam compartment get --compartment-id", "tenancy": "oci iam tenancy get --tenancy-id", "user": "oci iam user get --user-id", "group": "oci iam group get --group-id", "policy": "oci iam policy get --policy-id", "dynamicgroup": "oci iam dynamic-group get --dynamic-group-id", "identityprovider": "oci iam identity-provider get --identity-provider-id", "autonomousdatabase": "oci db autonomous-database get --autonomous-database-id", "dbsystem": "oci db system get --db-system-id", "database": "oci db database get --database-id", "dbhome": "oci db db-home get --db-home-id", "dbnode": "oci db node get --db-node-id", "mysqldbsystem": "oci mysql db-system get --db-system-id", "loadbalancer": "oci lb load-balancer get --load-balancer-id", "networkloadbalancer": "oci nlb network-load-balancer get --network-load-balancer-id", "certificate": "oci certs-mgmt certificate get --certificate-id", "vault": "oci kms management vault get --vault-id", "key": "oci kms management key get --key-id", "secret": "oci vault secret get --secret-id", "fnapp": "oci fn application get --application-id", "fnfunc": "oci fn function get --function-id", "apigateway": "oci api-gateway gateway get --gateway-id", "containerinstance": "oci container-instances container-instance get --container-instance-id", "cluster": "oci ce cluster get --cluster-id", "nodepool": "oci ce node-pool get --node-pool-id", "filesystem": "oci fs file-system get --file-system-id", "mounttarget": "oci fs mount-target get --mount-target-id", "alarm": "oci monitoring alarm get --alarm-id", "loggroup": "oci logging log-group get --log-group-id", "log": "oci logging log get --log-id", "topic": "oci ons topic get --topic-id", "subscription": "oci ons subscription get --subscription-id", "stream": "oci streaming stream get --stream-id", "streampool": "oci streaming stream-pool get --stream-pool-id", "dnszone": "oci dns zone get --zone-name-or-id", "networkfirewall": "oci network-firewall network-firewall get --network-firewall-id", "networkfirewallpolicy": "oci network-firewall network-firewall-policy get --network-firewall-policy-id", "waaspolicy": "oci waas waas-policy get --waas-policy-id", "instancepool": "oci compute-management instance-pool get --instance-pool-id", "instanceconfiguration": "oci compute-management instance-configuration get --instance-configuration-id", "capacityreservation": "oci compute capacity-reservation get --capacity-reservation-id", "dedicatedvmhost": "oci compute dedicated-vm-host get --dedicated-vm-host-id", } def _resolve_ocid_command(ocid: str) -> str | None: """Parse an OCID and return the corresponding OCI CLI get command.""" parts = ocid.strip().split(".") if len(parts) < 4 or parts[0] != "ocid1": return None resource_type = parts[1].lower() # Extract region from OCID if present (4th part, non-empty for regional resources) region = parts[3] if len(parts) > 3 and parts[3] and parts[3] != parts[2] else None cmd_template = _OCID_CMD_MAP.get(resource_type) if not cmd_template: return None cmd = f"{cmd_template} {ocid}" if region: cmd += f" --region {region}" return cmd _TERMINAL_BLOCKED_PATTERNS = re.compile( r'(rm\s|mv\s|cp\s|chmod\s|chown\s|sudo\s|bash\s|sh\s|curl\s|wget\s|python|pip\s|apt\s|yum\s|>\s|>>\s|\|)', re.IGNORECASE ) @router.post("/api/terminal/execute") async def terminal_execute(req: dict, u=Depends(current_user)): """Execute an OCI CLI command using the selected OCI config. Only 'oci' commands allowed.""" oci_config_id = req.get("oci_config_id", "") command = (req.get("command", "") or "").strip() if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") if not command: raise HTTPException(400, "Comando vazio") _verify_config_access("oci", oci_config_id, u) # Auto-lookup: OCID pasted directly → resolve to oci get command resolved_cmd = None if command.startswith("ocid1."): resolved_cmd = _resolve_ocid_command(command) if resolved_cmd: command = resolved_cmd else: raise HTTPException(400, f"Tipo de recurso não reconhecido no OCID: {command.split('.')[1]}") # find → search resources by display name using OCI Search service elif command.startswith("find "): query = command[5:].strip() if not query: raise HTTPException(400, "Uso: find | find %partial% | find 10.0.1.5") # Detect IP address → search by IP if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query): search_query = f"query privateip resources where ipAddress = '{query}'" elif '%' in query: # Partial match with LIKE search_query = f"query all resources where displayName =~ '{query}'" else: search_query = f"query all resources where displayName = '{query}'" resolved_cmd = f"oci search resource structured-search --query-text \"{search_query}\" --limit 20" command = resolved_cmd # Security: only allow 'oci' commands if command == "oci": command = "oci --help" if not command.startswith("oci "): raise HTTPException(400, "Apenas comandos 'oci' são permitidos. Ex: oci iam user list") if _TERMINAL_BLOCKED_PATTERNS.search(command): raise HTTPException(400, "Comando contém operações não permitidas") # Build config path cfg_path = OCI_DIR / oci_config_id / "config" if not cfg_path.exists(): raise HTTPException(400, "OCI config file não encontrado. Reconfigure as credenciais.") # Execute with timeout cmd_args = shlex.split(command) + ["--config-file", str(cfg_path)] try: loop = asyncio.get_event_loop() result = await asyncio.wait_for( loop.run_in_executor(_chat_executor, lambda: subprocess.run( cmd_args, capture_output=True, text=True, timeout=60, cwd="/tmp", env={**os.environ, "OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING": "True", "HOME": "/tmp"} )), timeout=65) output = result.stdout or "" error = result.stderr or "" exit_code = result.returncode # Save to history with db() as c: c.execute("INSERT INTO terminal_history (id,user_id,oci_config_id,command,exit_code,output,error) VALUES (?,?,?,?,?,?,?)", (str(uuid.uuid4()), u["id"], oci_config_id, command, exit_code, output[:10000], error[:2000])) return { "exit_code": exit_code, "output": output[:50000], "error": error[:5000] if exit_code != 0 else "", "resolved_cmd": resolved_cmd, } except asyncio.TimeoutError: with db() as c: c.execute("INSERT INTO terminal_history (id,user_id,oci_config_id,command,exit_code,error) VALUES (?,?,?,?,?,?)", (str(uuid.uuid4()), u["id"], oci_config_id, command, -1, "Timeout: 60s")) return {"exit_code": -1, "output": "", "error": "Timeout: comando excedeu 60 segundos"} except Exception as e: return {"exit_code": -1, "output": "", "error": str(e)[:2000]} @router.get("/api/terminal/history") async def terminal_history(oci_config_id: str = Query(""), limit: int = Query(50), u=Depends(current_user)): """Get recent terminal commands for the user.""" with db() as c: if oci_config_id: rows = c.execute( "SELECT * FROM terminal_history WHERE user_id=? AND oci_config_id=? ORDER BY created_at DESC LIMIT ?", (u["id"], oci_config_id, limit)).fetchall() else: rows = c.execute( "SELECT * FROM terminal_history WHERE user_id=? ORDER BY created_at DESC LIMIT ?", (u["id"], limit)).fetchall() return [dict(r) for r in rows] # OCI CLI autocomplete cache _oci_completions_cache: dict = {} def _parse_oci_help(args: list[str]) -> list[str]: """Run oci --help and extract available commands/options.""" try: result = subprocess.run( ["oci"] + args + ["--help"], capture_output=True, text=True, timeout=10, env={**os.environ, "OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING": "True", "HOME": "/tmp"}) text = result.stdout + result.stderr commands = [] in_commands = False for line in text.splitlines(): stripped = line.strip() # Detect transition into command/service listing if not in_commands: if re.match(r'^(Commands|Options|Core|Others|Networking|Database|Identity|Storage|Compute|Security)', stripped): in_commands = True continue if not stripped: continue # Section headers (single word or capitalized phrase not indented) — skip if not line.startswith(" "): continue # Capture " command-name Description text" pattern m = re.match(r'^ ([a-z][a-z0-9\-_]+)\s', line) if m and len(m.group(1)) > 1: commands.append(m.group(1)) continue # Capture 2-space indent commands (sub-command help) m = re.match(r'^ ([a-z][a-z0-9\-_]+)\s', line) if m and len(m.group(1)) > 2: commands.append(m.group(1)) continue # Capture --options if stripped.startswith("--"): opt = stripped.split()[0].rstrip(",") if opt.startswith("--"): commands.append(opt) return sorted(set(commands)) except Exception: return [] @router.get("/api/terminal/completions") async def terminal_completions(prefix: str = Query(""), u=Depends(current_user)): """Get OCI CLI autocomplete suggestions for a partial command.""" parts = prefix.strip().split() if not parts or parts[0] != "oci": return {"suggestions": []} # Build cache key from command path (without final partial word) # e.g. "oci iam us" → lookup completions for ["oci", "iam"], filter by "us" if prefix.endswith(" "): cmd_parts = parts[1:] filter_prefix = "" else: cmd_parts = parts[1:-1] filter_prefix = parts[-1] if len(parts) > 1 else "" cache_key = " ".join(cmd_parts) if cache_key not in _oci_completions_cache: loop = asyncio.get_event_loop() completions = await loop.run_in_executor(_chat_executor, lambda: _parse_oci_help(cmd_parts)) _oci_completions_cache[cache_key] = completions suggestions = _oci_completions_cache[cache_key] if filter_prefix: suggestions = [s for s in suggestions if s.startswith(filter_prefix)] # Commands first, then options cmds = [s for s in suggestions if not s.startswith("--")] opts = [s for s in suggestions if s.startswith("--")] return {"suggestions": (cmds + opts)[:50]}